Provider adapters should expose differences
V0M3 used a stable proposal contract without pretending that model providers shared context rules, tool behavior, privacy terms, streaming semantics, or recovery guarantees.
The first V0M3 provider adapter was barely an adapter. It renamed a few fields, sent a request, and returned text.
The second provider made that abstraction look successful. Both accepted instructions and document context. Both streamed words. Both could return something that resembled a proposed edit. A single generate() function seemed like sensible engineering.
Then the similarities ended. One provider enforced a structured response at generation time; another only encouraged one. One exposed tool calls as distinct events; another mixed them into a broader stream. Cancellation, usage accounting, context limits, safety refusals, request identifiers, retention controls, and retry advice all differed. My generic interface hid the facts V0M3 needed most.
An adapter should give a product stable concepts. It should not manufacture equality.
The original interface normalized too early
The first contract looked roughly like this:
generate({ instructions, context, schema, tools })
-> AsyncIterable<Chunk>Chunk could contain text, a tool call, usage, or an error. The shape was pleasingly small and almost useless for governing document work.
Was schema guaranteed or advisory? Could two tool calls arrive concurrently? Did usage refer to the current attempt or the whole request? If the connection dropped, could the operation resume, or did V0M3 need a new attempt? Did cancellation stop billing, stop tool execution, or merely stop delivery to the client?
The type answered none of these questions. Call sites filled the gaps with assumptions based on whichever adapter I had tested most recently.
Product invariants came first
I separated what V0M3 required from what a provider happened to offer.
Every generated change still had to become a typed proposal operation. It needed an exact document target, task revision, source lineage, validation result, and sequence identity. A model could request only product capabilities allowed for that task. Acceptance remained a separate human-authorized document command.
Those were product invariants. An adapter could not weaken them.
The provider-specific questions sat beneath that boundary: how context was represented, whether output constraints were native, how a tool event arrived, whether a response could resume, and what metadata accompanied a refusal. V0M3 could accommodate those differences or decline a provider for a particular task. It could not wish them away.
Each connection published a capability profile
I gave every configured provider connection a versioned capability profile. It described observed, tested behavior rather than marketing categories.
The profile included:
- Accepted input roles and content forms.
- Context and output limits known to the adapter.
- Structured-output strength and unsupported schema features.
- Tool-selection and parallel-call behavior.
- Streaming event kinds and ordering guarantees.
- Cancellation and resumption semantics.
- Usage, rate-limit, and request-identity fields.
- Data-handling configuration and unsupported privacy requirements.
- Retryable, terminal, refusal, and unknown outcomes.
V0M3 pinned the profile revision to each generation attempt. If an adapter or remote behavior changed, an old trace still described the assumptions under which it ran.
The profile did not certify a provider forever. It recorded the contract V0M3 had actually exercised.
Context was compiled, not forwarded
Providers represented instructions, messages, files, tool results, and cached material differently. Passing V0M3's internal record directly would either leak irrelevant state or couple the product to one provider's conversation format.
The adapter received a bounded generation plan: task goal, target document excerpts, selected source records, prior accepted decisions, permitted capabilities, and output contract. It compiled that plan into the provider's request shape.
Compilation produced a manifest of what was included, omitted, shortened, or transformed. Token estimates were useful but not treated as exact across providers. If required evidence could not fit, the attempt failed before generation rather than silently dropping the oldest source.
The manifest joined the attempt record. Later, a surprising proposal could be examined against the context the provider actually received, not the larger document V0M3 happened to hold.
I had been using a Boolean called supportsJson. It collapsed several very different promises.
A provider might generate valid JSON syntax, conform to a subset of a supplied schema, return tool arguments that usually parsed, or merely respond well to an instruction asking for JSON. These were not interchangeable.
The capability profile classified the mode and listed unsupported constructs. Regardless of mode, V0M3 parsed into an untrusted intermediate value, rejected unknown fields, validated operation semantics, and checked document preconditions.
For a provider with weak output constraints, the adapter could use a repair attempt within a strict budget. It could not reinterpret ambiguous prose into a destructive operation. A failed proposal remained a failed proposal.
Native schema enforcement reduced malformed output. It did not prove that a well-formed replacement was true, relevant, or authorized.
Tools stayed product-shaped
Provider tool APIs differed in naming rules, argument encoding, selection controls, and event sequencing. V0M3 did not expose those details to its document domain.
It defined capabilities such as retrieving an authorized source passage or rendering a preview. The adapter mapped a capability revision to the provider's tool declaration and mapped returned calls back into candidate capability requests.
Provider-generated call identifiers remained useful provenance, but V0M3 assigned its own stable intent and attempt identities. If a provider reused an identifier after a retry, the product did not confuse that with authority to repeat an external consequence.
Parallel tool requests were accepted only where product policy allowed parallel reads. A provider's ability to emit five calls at once did not expand the task's concurrency or effect budget.
Streaming exposed events, not one universal chunk
The generic Chunk type encouraged call sites to switch on optional fields. I replaced it with an explicit adapter event union:
attempt.started
content.delta
operation.candidate
tool.requested
usage.observed
refusal.observed
attempt.completed
attempt.failedNot every provider produced every event natively. The adapter could derive a candidate operation after buffering enough content, but the event recorded that it was adapter-derived. It could synthesize a final usage observation from response metadata, but not claim live accounting.
Ordering was explicit. Each adapter event received a local sequence after ingestion and retained any provider sequence or timestamp separately. V0M3 never built document truth from arrival order alone.
This made the common stream slightly more verbose and much more honest.
The interface originally exposed cancel(). I had read that as “the attempt stops.” In practice it could mean close the local connection, send a remote cancellation request, stop receiving events, or mark the UI uninterested.
The adapter profile stated which guarantees existed. V0M3 recorded cancellation requested, provider acknowledgement when available, final usage if observed, and whether a late completion remained possible.
A canceled generation attempt never became a canceled document operation automatically. Already committed proposal operations remained reviewable; incomplete candidates stayed provisional. Late events could be recorded without jumping into the active interface.
The user-facing word stayed simple. The state behind it stopped pretending certainty.
Some connections could continue an interrupted response by remote identity. Others required a fresh request with reconstructed context. Treating both as resume() risked combining two generations into one trace.
V0M3 used a stable generation task with one or more attempts. A native continuation remained part of its original attempt only when the provider contract and response identity supported that claim. A reconstructed request created a new attempt linked to the same task and target revision.
No attempt could append operations after its target or policy preconditions had become invalid. Resuming delivery did not resume authority.
That separation also kept replay from looking like a harmless variation of new execution.
Errors preserved provider evidence
Flattening every failure into ProviderError made recovery guesswork. I defined product-level categories such as invalid request, policy refusal, rate limited, unavailable, context exceeded, malformed output, interrupted delivery, and unknown outcome.
The adapter mapped only when evidence supported the category. Original codes, safe response metadata, provider request identity, retry advice, and raw timing remained attached for diagnostics with sensitive values redacted.
Unknown did not become retryable merely because it was inconvenient. If a tool consequence might have occurred, the orchestrator queried its receipt or paused for review before another attempt.
Callers received a stable recovery vocabulary without losing the provider facts required to challenge a bad mapping.
Privacy was a routing constraint
A checkbox named “private mode” could not normalize different data terms, account settings, regions, logging behavior, or file handling. V0M3 represented project requirements separately from provider configuration.
A generation plan could prohibit external processing, require a configured retention condition, exclude certain source classes, or permit only a local adapter. Provider selection evaluated those requirements before compiling context.
The attempt record identified the connection and applicable policy snapshot. It did not copy secrets or make legal promises the adapter could not verify.
If no configured provider satisfied the requirement, the correct result was unavailable generation. Manual document work continued. Privacy was not silently traded for feature continuity.
Selection used fitness, not a universal ranking
There was no single “best provider” inside V0M3. One could support a larger context, another stricter structured output, another an approved local environment, and another more useful latency for a narrow rewrite.
The task declared requirements. The selector found eligible connections, then applied explicit preferences such as privacy, output-contract strength, estimated cost, measured latency, and recent health. The decision and profile revision were recorded.
An author could pin a connection or prohibit automatic fallback. A fallback that changed data handling, output guarantees, or tool availability required a new decision rather than hiding behind the same spinner.
Provider choice became explainable product behavior.
Every adapter ran against the same suite of product scenarios: valid operation generation, malformed output, refusal, context overflow, dropped stream, duplicate tool request, rate limit, cancellation, late event, and unknown external outcome.
Provider-specific fixtures captured raw response shapes with sensitive data removed. Record mode was separate from replay mode. A missing fixture failed closed; tests never reached a live provider by surprise.
The assertions focused on V0M3's observable contract. Did the attempt reach the correct state? Were provisional operations kept out of the document? Was evidence preserved? Could an unsafe retry occur?
Snapshot changes prompted review, but a visually similar payload was not enough. The adapter had to preserve meaning.
Upgrades created new adapter revisions
Changing a model name, API version, schema compiler, tool mapping, or event parser could alter behavior even when the TypeScript interface stayed stable. I versioned the adapter configuration as part of the run.
In-progress attempts either completed under their pinned revision or stopped at a known boundary. New attempts used the new revision. I did not hot-swap parsing rules halfway through a stream.
Canary fixtures and a small set of synthetic documents compared operation validity, evidence linkage, refusal mapping, latency, and usage before broader selection changed. The purpose was not to prove two providers equivalent. It was to discover where their consequences differed.
V0M3 did not turn its writing surface into an infrastructure dashboard. Most provider detail lived in task provenance and diagnostics.
The author saw a choice when it changed a meaningful condition: external versus local processing, unavailable evidence capacity, a weaker output contract, a fallback with different tools, or an attempt that could not truly resume. Technical identifiers stayed available behind that explanation.
This was another adapter responsibility: translate differences into product consequences without erasing them or making every user learn a remote API.
Costs were observations, not portable prices
The generic adapter originally returned a single numeric cost. That assumed every provider measured and priced the same unit, that cached input behaved like ordinary input, and that tool or reasoning work appeared in one comparable total.
V0M3 instead recorded provider-reported usage dimensions with their units, source, and time. A separate estimator applied a versioned price table where one was available. Estimated cost and billed cost remained distinct, and neither could be reconstructed by pretending all tokens were interchangeable.
Budgets belonged to the product. A task could cap attempts, wall time, context sent, output accepted, tool calls, or estimated spend. The adapter reported observations; the orchestrator enforced limits it could actually control.
A provider that omitted live usage could still run under conservative request limits, but V0M3 did not display a precise progress meter built from guesses. Cost portability required preserving disagreement first.
One connection could generate text successfully while its tool stream or structured-output mode was degraded. A single green provider status hid the failure mode relevant to the task.
Health checks exercised narrow synthetic capabilities and recorded freshness. Recent task outcomes contributed signals without exposing document content. The selector evaluated the particular route it planned to use: context size, schema mode, tools, stream, and region.
Circuit breakers lived per adapter capability and failure class. Repeated malformed structured output could pause that route without disabling manual writing or a provider's plain summarization path. A rate limit scheduled later eligibility; an authentication failure required connection repair; a provider refusal was not counted as infrastructure downtime.
This made fallback less dramatic. V0M3 routed around a known impaired capability instead of declaring an entire model good or bad.
Quality never entered the adapter contract
I was tempted to normalize response quality by automatically asking a weaker adapter for more candidates or running a second model as judge. That would have hidden additional data transfer, cost, latency, and assumptions behind one call.
The adapter guaranteed transport and mapping behavior, not intelligence. Quality strategies were explicit generation plans with their own attempts and evidence. A compare-two-proposals task said so. A verifier run declared what it could inspect and could not silently accept the text it preferred.
This separation prevented benchmark scores or provider labels from becoming document authority. A beautifully written operation still passed the same source, target, and review checks.
It also made evaluation fairer. I could compare proposal usefulness while holding the product workflow constant, or test adapter correctness with fixed fixtures without pretending either measurement covered the other.
Occasionally a provider offered a useful feature with no honest common equivalent: a particular cache control, a hosted file reference, or a continuation primitive. Refusing all provider-specific features would make the abstraction lowest-common-denominator. Exposing arbitrary options through the domain would dissolve it.
I allowed typed adapter extensions inside a provider-specific generation strategy. The strategy declared its connection requirement, transformed a standard plan without weakening required fields, and emitted the same attempt events and proposal operations. Its configuration digest joined the trace.
Generic document code could not inspect or construct the escape hatch. Switching providers made the strategy unavailable rather than silently ignoring it. The interface explained the relevant consequence when an author had selected that strategy.
The quarantine kept experimentation possible while making the portability cost visible.
I added a destructive architecture exercise: remove one adapter and ask what stopped working.
Existing documents, accepted revisions, proposal records, evidence links, and exports had to remain readable. Pending attempts tied to the removed connection became interrupted with known recovery choices. New tasks selected another eligible strategy only after privacy and capability requirements were re-evaluated.
Provider request identifiers and raw diagnostic payloads could remain as historical evidence under retention rules. They were never required to render the canonical document. A remote conversation identifier could not be the only checkpoint for a long-running task.
The exercise exposed accidental dependencies faster than another interface review. If removing an adapter made accepted prose inexplicable or a document unopenable, I had stored product state in the wrong place.
The abstraction became less elegant and more stable
The final boundary was not one magical generate() function. It was a small family of explicit contracts: plan compilation, capability profile, attempt events, tool mapping, error evidence, usage observations, and adapter fixtures.
That added code. It removed scattered guesses.
V0M3 could add a provider without letting its vocabulary leak into document state. It could also decline to normalize a feature whose guarantees were too different. The adapter absorbed syntax; it surfaced semantics.
The important lesson was not that providers were hopelessly incompatible. It was that a durable product chooses the level at which sameness is true.
For V0M3, proposals, evidence, review, and document authority were stable. Context rules, tools, streams, privacy, and recovery were not. A good adapter protected the first set by refusing to conceal the second.