5 The Provider Adapter Boundary
Part II · The portable model runtime
Primary-source review: 2026-10-08–09 · Immutable implementation baseline: v0.4.0 · Historical live reports: v0.3.0
The Northstar research flow now works. Its schemas are checked, calls are correlated, and streams have terminal meaning. Then an operator changes the coordinator deployment. The code still compiles, but token policy, protocol details, and representable features can change. The difficult question is no longer how to write an adapter. It is which decisions the adapter is allowed to make.
A provider adapter boundary separates a stable application model contract from the construction, configuration, and translation decisions needed to execute it on a particular provider deployment. Replacement is controlled when those decisions can change without silently changing the application’s authority or required meanings.
5.1 The problem
In Part I, the Northstar runtime comparison had one purpose: compare two synthetic measurements without treating provisional output as a final answer. We now want the same purpose to survive a different model deployment. The tool implementation, authorization decision, and result correlation should not be rewritten merely because a different model proposes the calls.
There are two tempting solutions. One is to duplicate the whole agent loop for each provider. The other is to move every provider setting into one universal request object. The first duplicates the invariants established in Chapters 6–8; the second makes the core dependent on a growing catalogue of vendor features. Neither tells us what substitution actually preserves.
The architectural task is to divide decisions, not just files. Which layer chooses the endpoint? Which one supplies a token default? Who interprets tool arguments? Who decides that an unsupported feature may be dropped? Who owns the retry budget after a tool has executed? A change is portable only when the relevant owner can change it under an explicit contract.
This chapter follows the immutable v0.4.0 adapter implementation and uses the older v0.3.0 reports only for the live runs they actually record. It extends Chapter 1’s model boundary into deployment construction, examines the shared SDK seam, and shows where normalization loses information. Chapter 14 owns capability admission and fallback; Chapter 15 owns recovery.
5.2 Context and forces
One place for invariants. Within one authority domain, shared tool policy, cancellation propagation, event normalization, and iteration limits need one owner and implementation. Duplicated loops make a provider change also a policy change. Client-local tools remain a separate execution domain; a server cannot acquire their authority by advertising their schemas.
A visible place for differences. Required request fields, model construction, credential headers, endpoint policy, and provider-specific features cannot always be made identical. Their differences need an owner, not a comment claiming that the SDK handles everything.
A finite application contract. A useful port describes what the application depends on. It need not represent every feature a provider offers. But narrowing the contract creates a responsibility to reject unsupported requirements or disclose what is lost.
A bounded replacement claim. A TypeScript assignment proves structural compatibility, not that a particular account can call a model, that a gateway implements the selected protocol, or that the Northstar comparison remains correct.
Reading rule. For each setting and response field, ask three questions: who chooses it, who translates it, and what evidence shows that its meaning survived?
5.3 Separate the application port from the SDK seam
The reference has two interfaces with different audiences. The application-facing ModelAdapter in src/model.ts exposes a diagnostic provider label, model-scoped capability declarations, and a stream of application events. Its request includes typed history and optional tools, trusted tool context, output-token limit, step limit, and abort signal. Callers need not construct an AI SDK message or retain a provider SDK client.
The internal AiSdkProviderBoundary additionally exposes languageModel(modelId) and maxOutputTokens(request). The former returns an AI SDK LanguageModel. This is intentional implementation coupling: the seam is not a claim that the reference could replace its orchestration library without changing internal code. What remains stable is the application-facing request/event boundary.
Application scenario and policy
│ ModelRequest / ModelEvent
▼
ModelAdapter
│
▼
Shared SDK-backed loop
messages · tool policy · finish/usage · safe errors
│ SDK-facing provider seam
├───────────────────┐
▼ ▼
OpenAI factory Anthropic factory
Responses model Messages model
│ │
▼ ▼
Pinned provider packages translate HTTP and stream data
This diagram describes the reference baseline, not every agent architecture. A direct protocol implementation would replace the SDK-facing layer and take responsibility for framing, validation, and provider-specific lifecycle behavior. Merely replacing a factory does not transfer those responsibilities to application code.
The shared implementation in src/agent.ts converts typed history to SDK messages, registers validated tools, invokes streamText, and projects the resulting stream. AiSdkModelAdapter delegates its stream method to that same function. Neither provider subclass contains its own tool-continuation loop.
Share mechanics whose meaning must remain invariant. Isolate decisions whose value depends on the provider or deployment. Keep an SDK type at the SDK seam rather than pretending that all implementation dependencies have vanished.
An interface can therefore be provider-neutral without its implementation being dependency-free. The test of a boundary is whether the application can substitute an implementation at that boundary, not whether every layer beneath it is also interchangeable.
5.4 Bind deployment identity before generation
A model name is not a complete execution target. At least four identities matter:
| Identity | What it selects | What it does not prove |
|---|---|---|
| Application selection | A trusted deployment binding or routing decision | Provider availability |
| Provider model ID | The model passed to the provider factory | Endpoint, account, or region |
| Protocol family | Responses, Messages, or another API | Complete feature fidelity |
| Credential scope | The principal and service authority used for the call | Authorization of the user or its tools |
The deployment also carries endpoint configuration, dependency versions, and operational limits. Two clients using the same model ID can still have different credentials, regions, gateways, or policy. A routing prefix is therefore not a proof of deployment equivalence.
In v0.4.0, application code chooses an adapter object and supplies the provider model ID in the request. The tagged OpenAI factory constructs a provider with the supplied API key and optional endpoint, organization, project, and fetch implementation, then explicitly selects .responses. The Anthropic factory supplies its API key, optional endpoint and fetch implementation, then selects .messages.
These are real TypeScript implementation examples: inspect the constructors together, then inspect the common stream delegation. Their significance is not that the classes have similar names. They bind different protocol construction behind one application port. The pinned packages are ai 7.0.131, @ai-sdk/openai 4.0.87, and @ai-sdk/anthropic 4.0.75; current SDK documentation may describe additional settings that these wrappers do not expose.
The OpenAI provider documentation’s “Responses Models” and “Chat Models” sections distinguish those APIs even within the same package (Vercel, n.d.-b). Changing only a base URL cannot make a Chat-Completions-only gateway implement Responses. Select a protocol implementation first; then verify that the chosen endpoint accepts it.
5.4.1 A small, tested deployment-binding example
The following is an independently authored, executable book design, not the reference server’s routing API. It permits the application to select an exact deployment alias from trusted configuration. An adapter has already been constructed; the selection cannot supply a new endpoint or credential. The generic adapter value can be a reference ModelAdapter, but the binder does not require SDK types.
export interface Deployment<Adapter> {
readonly alias: string;
readonly modelId: string;
readonly adapter: Adapter;
}
export type ModelTarget<Adapter> = Readonly<Pick<Deployment<Adapter>, "modelId" | "adapter">>;
export class DeploymentSelectionError extends Error {
constructor(readonly code: "invalid_deployment" | "unknown_deployment") {
super(`Deployment selection failed (${code}).`);
this.name = "DeploymentSelectionError";
}
}
export const bindDeployments = <Adapter>(
deployments: readonly Deployment<Adapter>[],
): ((alias: string) => ModelTarget<Adapter>) => {
const targets = new Map<string, ModelTarget<Adapter>>();
if (deployments.length === 0) throw new DeploymentSelectionError("invalid_deployment");
for (const { alias, modelId, adapter } of deployments) {
if (
!/^[a-z][a-z0-9_-]{0,63}$/.test(alias) ||
modelId.length === 0 ||
modelId !== modelId.trim() ||
targets.has(alias)
) {
throw new DeploymentSelectionError("invalid_deployment");
}
targets.set(alias, Object.freeze({ modelId, adapter }));
}
return (alias) => {
const target = targets.get(alias);
if (target === undefined) throw new DeploymentSelectionError("unknown_deployment");
return target;
};
};The complete source is code/deployment-binding.ts. Tests cover exact adapter identity, opaque model IDs containing punctuation, duplicate and invalid declarations, unknown selections, and mutation of the original configuration after binding. Unknown aliases produce a fixed error rather than model-name inference or silent fallback.
This is a deliberately small boundary experiment. It is not account authentication, per-principal deployment authorization, endpoint validation, or capability discovery. Trusted configuration remains a precondition. If different readers may use different deployments, the application must authorize the selection before resolving it. Freezing a binding does not freeze or isolate the adapter object itself.
Constructing the lookup once also keeps registry scanning out of the event loop: construction is linear in deployment count; selection uses a map lookup. That is a complexity statement about this helper, not a production latency benchmark. A deployment registry does not justify adding a service, cache, or background refresh loop before there is a concrete requirement.
The current live-server snapshot instead routes configured prefixes such as openai/<model-id> and rejects unknown prefixes. It is not an alias allowlist of individual models. Both designs make selection explicit, but their admitted target sets differ. Do not describe this book helper as a control already enforced by that server.
5.5 The boundary output
This chapter’s output is deliberately narrow: an exact deployment binding plus a translation result whose retained and lost meanings are documented. It does not decide whether a fallback is acceptable, whether a failed attempt may repeat, whether history may be replayed, or whether a source supports a claim.
| Fact leaving this boundary | Owner that consumes it |
|---|---|
| exact deployment/adapter identity | Chapter 14 capability admission |
| normalized request and event meanings | Chapters 6–8 runtime contracts |
| explicit unsupported or lost meaning | caller task policy and Chapter 14 |
| sanitized provider failure category | Chapter 15 recovery policy |
| provider/SDK response identity, when present | Chapter 15 history without treating it as a run ID |
Selecting a deployment cannot authorize a tool, and a translation cannot certify evidence. The worked trace in Chapter 13 uses a scripted adapter precisely so local policy composition is not confused with a live-provider claim.
5.6 Assign every translation an owner
Walk a request through the reference before deciding where an extension belongs.
| Decision or transformation | Reference owner | Architectural consequence |
|---|---|---|
| Select adapter/deployment | Caller or composition layer | Do not infer authority from prompt text |
| Construct model, bind credentials and endpoint | Provider factory and SDK | Configuration is trusted deployment input |
| Convert messages, calls, and results | Shared modelMessage function |
One history conversion policy for both factories |
| Validate schemas and apply tool hooks | Shared loop and tool runtime | Provider replacement need not replace execution authority |
| Serialize provider request and parse wire events | Pinned provider package | A mock model does not prove wire correctness |
| Normalize finish, usage, and failures | Shared loop | Review information loss once, across providers |
| Decide whether to retry, fall back, or continue | Application/run policy | A failure category is not a recovery command |
The shared conversion places function calls in assistant messages and function results in tool messages for the SDK. The provider packages then produce their respective wire representations. The application owns the correlation fact; the SDK owns its provider encoding. Rewriting a tool result into prose at the application boundary would discard the fact before either provider receives it.
System instructions illustrate the same separation. The neutral request’s system field is passed separately to streamText. The Messages API reference, “Body parameters / system and messages,” uses a top-level system parameter rather than a system input role. The Responses reference, “instructions and input,” exposes its own instruction/role-based forms (Anthropic, n.d.-a; OpenAI, n.d.-b). A common application field does not imply identical wire fields or identical instruction-following behavior.
5.6.1 Required settings versus application budgets
The same API references’ max_tokens and max_output_tokens field descriptions distinguish an explicit Messages request field from an optional Responses bound including visible output and reasoning tokens (Anthropic, n.d.-a; OpenAI, n.d.-b). The reference preserves that request-shape difference:
- The base adapter returns the request’s
maxOutputTokensunchanged. If absent, the OpenAI path omits it from the SDK call. - The Anthropic subclass supplies
request.maxOutputTokenswhen present, otherwise its configured default; the implementation default is 1,024. - The shared loop includes the SDK setting only when a value exists.
A provider’s required field is not the application’s entire budget policy. The reference default is a construction decision, not a proven safe value for every model or task. A production run still needs an explicit ceiling and a policy for exhausting it. Conversely, imposing one small constant everywhere can preserve request validity while changing whether a reasoning model has enough budget to produce visible text (OpenAI, n.d.-a).
The declared application contract currently has no reasoning-effort, provider-option envelope, or provider-private continuation field. Those omissions are scope boundaries. They are not evidence that every model behaves acceptably with its defaults. Adding a setting requires deciding whether it expresses application intent, deployment policy, or a provider-specific extension. Arbitrary JSON passed through to a provider is not a substitute for that decision: it enlarges the authority and compatibility surface without defining its semantics.
5.7 Normalization is an information-loss decision
Let a provider response be richer than the application projection. Mapping both into a common event union can be useful even when the mapping is lossy. The important distinction is between information that the application has explicitly excluded and information it needs but the adapter silently discards.
The reference maps text, complete tool calls, tool outcomes, finish reasons, and optional input/output token counts. It does not project every provider item, reasoning artifact, source annotation, warning, raw finish detail, or token breakdown. Its response identifier is updated from SDK step metadata; consumers must not assume that this optional provider/SDK response ID is the same thing as a durable application run ID.
Provider APIs have different termination vocabularies. Anthropic streaming includes message and content-block lifecycle events; Open Responses uses its own response/item/content vocabulary (Anthropic, n.d.-b; “Open Responses Specification 2026-04-24,” n.d.). The SDK translates provider-specific outcomes before the shared loop maps its normalized finish reason. A normalized stop or length is therefore the result of two translations, not a raw provider assertion.
The current common loop’s unknown case prevents an unfamiliar SDK finish reason from being mislabeled as ordinary completion. It does not preserve the raw reason for diagnosis. Likewise, omitting an unknown token count avoids inventing a measured zero, but retaining two counts is not complete accounting of reasoning, caching, or provider billing.
Warnings deserve the same discipline. The streamText return reference, “warnings,” exposes unsupported-setting warnings, but the tagged event contract does not carry them (“AI SDK Core,” n.d.). A future extension must decide which warnings affect admission, which are safe for public output, and which belong in protected diagnostics. Do not claim that an SDK accepting a setting means the model honored it.
A useful design rule follows: loss is acceptable only relative to the consumer’s obligations. For the synthetic Northstar comparison, a text/tool subset may be sufficient. For a product whose answer must contain verified document citations, dropping annotations is a functional defect. For reasoning continuation, dropping opaque compatible state may alter the next turn even if the visible transcript looks unchanged. Chapters 15 and 13 treat those state and evidence obligations directly.
5.8 Provider-specific variation: a failed run is architectural evidence
The retained failed provider report records two different failures in one fixed text-generation scenario at tested commit 672d00c. OpenAI gpt-5 returned empty output under a 32-token request limit; the custom Anthropic endpoint returned HTTP 404. The report’s normalized usage records do not establish the full internal reason for the empty output. The official reasoning guide describes how output limits can be reached before visible output, but that mechanism must not be promoted into a diagnosis that the report itself did not capture (OpenAI, n.d.-a).
The passing report, at tested commit 1bbf572, uses a 512-token limit and provider-default endpoint configurations. It records three successful exact-match attempts each for gpt-5 and claude-sonnet-4-6.
This is not a controlled experiment isolating one parameter. It is evidence that deployment configuration belongs inside the compatibility claim. A short expected answer does not imply that all models require only a short generation budget. An endpoint override is not proof that the requested protocol exists there. Both failures could occur with unchanged orchestration code and structurally compatible adapters.
Retaining the failed run is more useful than erasing it behind a successful release label. It identifies assumptions that future substitution tests must exercise. It does not establish live Northstar-tool execution, broad semantic equivalence, or universal availability of either model. The tag containing a report also is not necessarily the commit that produced the report; inspect the recorded commit rather than treating the release directory as the experiment identity.
5.9 Failure modes
5.9.1 A provider adapter that becomes a second agent
If a subclass starts authorizing tools, selecting application memory, or deciding a task-specific stopping condition, it has crossed the ownership boundary. A new provider can then acquire a different authority model under the same application interface. Keep the provider subclass small for a reason: not aesthetic minimalism, but a smaller surface where replacement can change policy.
5.9.2 Endpoint changes presented as model changes
A gateway can change protocol support, authentication, metadata, streaming behavior, or retention. Recording only the model name hides these changes. Test the selected factory/endpoint pair, not a marketing label such as “compatible.”
5.9.3 Silent defaults mistaken for neutral semantics
A missing limit can become a provider default, a factory default, or a deployment guard. Record the effective setting and its owner. The later live bridge clamps output budgets separately; that is not behavior of every v0.3.0 adapter call.
5.9.5 Complete projection mistaken for complete fidelity
The application event union can be exhaustive while its mapping covers only a subset of the SDK stream. Type exhaustiveness protects the chosen contract, not all provider meanings. Keep a loss register: unsupported or dropped field, affected consumer obligation, disposition, test, and version.
5.10 Security considerations
The selected endpoint is a recipient of both credentials and request content. Moving it is a security change even when the adapter type is unchanged. The reference supports endpoint and fetch injection; it does not enforce an endpoint allowlist or bind a credential cryptographically to its intended recipient.
Treat model IDs and deployment aliases as input, but endpoint URLs, organization/project settings, and credentials as trusted configuration. Do not accept a request’s arbitrary URL and attach the server’s provider key to it. A custom fetch implementation, proxy, redirect, or environment override can affect the actual destination; audit the complete transport path rather than just the constructor argument. Even an allowlisted initial URL is not proof of where every redirected or delegated request goes.
The pinned SDK factories can obtain endpoint defaults from environment variables when an explicit endpoint is absent. Reproducible tests should therefore isolate ambient configuration and record a safe endpoint class. Do not publish keys, internal endpoint URLs, or raw headers as evidence. A model-availability placeholder in a loopback client is not a provider credential.
Client-to-server identity remains independent of server-to-provider identity. The post-release development server is loopback-only and unauthenticated; neither the adapter port nor possession of a provider key authorizes a reader’s tool call. Tool context must still originate from trusted application policy, as Chapter 8 establishes.
5.11 Observability handoff
The boundary must expose enough typed information for later instrumentation—deployment alias, provider/model ID under policy, protocol family, adapter revision, effective limit ownership, terminal category, and explicit loss decisions—without logging prompts, credentials, tool payloads, opaque continuation data, or raw provider failures. Chapter 16 owns span names, propagation, sampling, attributes, cardinality, and redaction tests. No OpenTelemetry exporter is claimed here.
5.12 How we test portability
Start with the current release TypeScript adapter tests. They are executable examples of replacement at the SDK seam, not provider-wire emulators.
| Tested example | Actual assertion | Limit of the proof |
|---|---|---|
| OpenAI model injection | Requested ID reaches the injected factory; output tokens remain unset; normalized text/usage collected | Real HTTP headers and endpoint not exercised |
| Anthropic model injection | Requested ID reaches the factory; configured default of 768 reaches the mock call | Fixture value is not the implementation default or an optimal budget |
| Common tool continuation | Executor receives 2 and 3; correlated output contains result 5; a second model step produces text; usage totals 12 input and 4 output |
Arithmetic fixture, not live Northstar selection or semantic equivalence |
| Invalid input/output and context | Invalid input does not execute; invalid output produces failure; policy hooks without context do not reach the model | Complete deployed authorization not established |
| Unknown usage and safe errors | Missing counts remain absent; sensitive test error text is not exposed | Full provider usage and protected diagnostics not established |
The provider constructors accept languageModelFactory, so these tagged fixtures prove forwarding and shared-loop behavior while bypassing the normal construction path. Duplicate-call prevention has its own runtime fixture; the arithmetic continuation fixture does not itself count executor invocations.
The independently authored factory and counterexample probe now exercises the normal tagged constructors through an injected fetch. No model-factory override is supplied. The probe inspects each POST route, synthetic authentication headers, system/history translation, and effective output setting. It supplies a minimal successful SSE stream, an HTTP 401, and an in-stream failure for each provider. All six paths make exactly one synthetic request and assert either normalized text/finish or a sanitized failure. The retained report records the baseline and assertion count; it contains no actual credential or endpoint.
OpenAI constructor → /v1/responses → output limit omitted
Anthropic constructor → /v1/messages → max_tokens = 1024
Injected SSE → SDK decoding → text "portable", finish "stop"
Explicit fixture keys and endpoints isolate ambient configuration. Injected transport proves the selected request path and the chosen synthetic responses—not account availability, regional routing, redirects, arbitrary provider events, or live task quality. This is new book-owned verification against unchanged v0.3.0, not a test retroactively added to the tag.
Reader access. The reader examples archive provides the complete approved book-owned designs and tests without repository credentials or dependencies. Reference-dependent probes are included, but require an authorized reference checkout; reference source links are not anonymously accessible in this preview. They are not prerequisites for the book-only test command. With such a checkout and Bun 1.3.9, select the immutable tag:
git worktree add --detach ../reference-v0.3.0 v0.3.0
cd ../reference-v0.3.0
bun install --frozen-lockfile
bun run check
# From the extracted reader archive, with the worktree path:
bun probes/reference-boundaries.ts /path/to/reference-v0.3.0The test phase is network-denied and credential-free; installation still requires the pinned dependency artifacts to be available locally or from their registry. Provider live tests are separate commands, can incur charges, and are not required to reproduce these deterministic assertions.
A replacement evidence ladder has four distinct gates: source/contract review, deterministic seam tests, dated live deployment checks, and task-level evaluation. Keep its language precise: similar API shape is resemblance; passing the application’s tested profile is compatibility; official-suite results are conformance evidence; only satisfying the declared formal profile warrants a conformance claim. Increasing evidence at one gate does not promote the others.
5.13 Portability and evidence ledger
| Dimension | Chapter claim | Evidence | Not established |
|---|---|---|---|
| Transport | SDK packages own wire translation behind the application port | Tagged factories/common loop; book-owned normal-factory probe with synthetic fetch/SSE | Full wire fidelity, arbitrary gateway replacement, or live availability |
| Provider | Two constructions use one common loop | v0.4.0 source/tests plus bounded historical v0.3.0 live text reports |
Equivalent tool behavior on every deployment |
| Protocol | Explicit protocol selection differs from endpoint selection | Source-reviewed .responses and .messages; official API references |
Open Responses conformance |
| Feature | Required settings and retained meanings have identifiable owners | Token-policy fixtures, tool outcomes, unknown-usage tests | Complete metadata, citations, reasoning, or option coverage |
| Semantic | Deployment configuration can change success without changing the core | Retained failures and passes under named configurations | Broad task-quality or safety equivalence |
The deployment binder and factory probe are implemented and deterministically tested book examples. The former makes a selection; the latter tests real tagged construction with synthetic transport. Neither implements deployment authorization or proves live-provider behavior.
5.14 Pattern summary
Problem: Provider replacement can silently change configuration, authority, or meaning while satisfying the same TypeScript interface.
Pattern: Bind trusted deployment configuration at construction, expose a finite application contract, share invariant mechanics, assign translation ownership, and test both retained meanings and known losses.
Result: A provider change becomes a reviewable substitution with explicit proof obligations rather than an undocumented rewrite of the agent.
Cost: The boundary needs version records, deployment-specific checks, loss accounting, and separate operational and semantic evaluation.
5.15 Exercises
- Add two deployments with the same provider model ID but different adapter instances. Explain which identity must appear in an incident report and which details must remain private.
- Extend the factory probe with a malformed synthetic stream frame. Assert a bounded failure, then explain why that test still says nothing about account availability.
- A product now requires citations. Identify the first boundary that currently loses the needed data and decide whether to extend the port or reject the requirement.
- Specify an output-budget policy that distinguishes visible output from total generation and names who may change each limit. Do not assume a single numeric value works identically across models.
- Write a loss-register entry for SDK warnings. Define what should cause admission rejection, what may be disclosed to a caller, and what belongs only in protected diagnostics.
5.16 Further reading
Read the provider API references for request semantics (OpenAI, n.d.-b; Anthropic, n.d.-a), then compare the SDK factory and stream contracts (Vercel, n.d.-b, n.d.-a; “AI SDK Core,” n.d.) with the pinned reference implementation. The official reasoning guide explains why a visible answer is not the whole generation budget (OpenAI, n.d.-a). RFC 9110 provides the baseline distinction between a repeatable request and an optimistic retry (Fielding et al. 2022). The dated claim register names the consequential sections and separates mutable documentation from newly executed synthetic paths. These sources constrain the boundary; they do not certify the implementation.
5.17 Chapter contract
- Scope: shared model contracts, deployment binding, translation, and replacement evidence.
- Canonical claims: provider neutrality is an application-owned boundary, not an equivalence claim.
- Migration inputs: former provider-boundary material and Northstar adapter trace.
- Evidence gates: strict adapter tests and dated live compatibility evidence.
- Demonstration slots: deterministic adapter fixture; no additional public recording required.
- Source-claim ledger: current adapter behavior is tied to
v0.4.0; dated live reports remain tied tov0.3.0and their recorded tested commits. - Exclusions: request/item/event mechanics belong to Chapter 6, streaming to Chapter 7, tools to Chapter 8, fallback to Chapter 14, recovery to Chapter 15, telemetry to Chapter 16, and conformance classification to Chapter 19.