Migrate a bounded text workflow between OpenAI and Claude
Build a runnable native OpenAI Responses and Claude Messages router for an explicitly limited non-streaming text contract, with strict unsupported-feature rejection, provider-specific adapters, durable turn idempotency, native evidence, sanitized errors, timeout reconciliation, live parity fixtures, tenant canaries, and rollback without replay.
Move an authorized text-only workload between providers without claiming that tools, streaming, structured output, citations, state, errors, stop reasons, retention, or usage are interchangeable, and without retrying an ambiguous turn at another provider.
- Node.js 22.22.2
- OpenAI Node SDK 7.1.0
- Anthropic TypeScript SDK 0.115.0
- SQLite Node 22 node:sqlite
- Text-only workload inventory Prove the selected cohort needs only ordered user/assistant text, optional system text, one non-streaming result, application-owned history, and no unsupported capability.
- Dedicated provider projects Create separate bounded OpenAI and Anthropic projects/workspaces with independent keys, model allowlists, budgets, retention review, and operational ownership.
- Durable application identity The application already authenticates tenants and can assign immutable conversation and turn IDs before a provider request.
- Synthetic parity fixtures Prepare authorized normal, malformed, refusal, truncation, timeout, duplicate, ambiguous, and unsupported-feature cases without mirroring production data.
OneLiners never runs these steps or stores secrets. Review placeholders, versions, current state, and change-control requirements before using a command.
Full guide
What you will build
- A strict provider-neutral contract limited to non-streaming text generation.
- Complete native OpenAI Responses and Claude Messages adapters with explicit terminal-state and usage handling.
- A durable SQLite turn state machine preventing duplicate and cross-provider replay.
- Deterministic and live parity tests, a tenant canary, and a provider-aware rollback procedure.
- Unsupported tools, streaming, structured output, citations, multimodal content, and hosted state fail before billing.
- Each completed result preserves the provider, native request ID, native status/stop reason, and provider-specific usage.
- Identical completed retries reuse durable state; changed or ambiguous turns cannot be silently replayed.
- A cohort can return to the previous adapter without losing completed history or rewriting provider identity.
Architecture
How the parts fit together
An authenticated application validates one text-only request and resolves a server-owned tenant policy. A durable turn store claims the semantic request before network work. The selected native adapter maps the narrow input to Responses or Messages, enforces a timeout, rejects unsupported terminal/content states, and returns a normalized result that still carries native evidence. A capability matrix, deterministic fixtures, live provider tests, and a tenant routing kill switch govern expansion and rollback.
- The server authenticates the tenant and strictly validates the bounded text request.
- Provider policy chooses one allowlisted native model; the client cannot override it.
- The turn store atomically claims tenant, turn, semantic hash, and provider before network work.
- The native adapter sends one bounded request under a timeout and parses typed provider output.
- A strict normalized result is persisted with provider-native identity and returned; identical completed retries reuse it.
- Failure or timeout blocks the turn for reconciliation; migration operations never fall back across providers automatically.
Assumptions
- The selected cohort is genuinely text-only and application-owned conversation history can be replayed within provider token limits.
- Provider keys, model policy, tenant routing, and database access are server-only and separately authorized.
- SQLite is used by one service instance; multi-replica production replaces it with an equivalent transactional shared store.
- Current provider pricing, model access, data handling, rate limits, and regional requirements are reviewed independently.
Key concepts
- Portable intersection
- The smallest behavior both native adapters implement and the product actually requires; unsupported capabilities are not approximated.
- Native evidence
- Provider request ID, typed status or stop reason, content block/item type, usage fields, and sanitized error class preserved for diagnosis.
- Semantic turn hash
- A digest of system text, ordered messages, and output bound that prevents one durable turn ID from being reused for a changed request.
- Ambiguous attempt
- A timed-out or disconnected provider call whose final provider state is not yet authoritative and therefore cannot be retried safely.
- Tenant-scoped canary
- A bounded routing cohort moved to a new native adapter while other tenants remain on the evaluated baseline.
Before you copy
Values used in this guide
{{policyJson}}secretServer-side tenant map containing provider and an allowlisted model, never accepted from the browser.
Example: {"tenant-a":{"provider":"openai","model":"reviewed-openai-model"},"tenant-b":{"provider":"claude","model":"reviewed-claude-model"}}{{openAiModels}}Comma-separated evaluated OpenAI model identifiers.
Example: reviewed-openai-model{{claudeModels}}Comma-separated evaluated Claude model identifiers.
Example: reviewed-claude-model{{cohort}}Opaque authorized migration cohort identifier.
Example: internal-text-canary{{policyRevision}}Immutable evaluated tenant-routing revision used for canary attribution and rollback.
Example: provider-policy-rev-3Security and production boundaries
- Never mirror protected production prompts to two providers solely for comparison; use synthetic or separately authorized fixtures.
- Do not expose provider/model selection, keys, raw errors, tenant policy, native objects, or database paths to the browser.
- A timeout is ambiguous, not proof of failure. Reconcile before any retry and never change provider under the same turn ID.
- Normalized output does not erase provider-specific retention, state, usage, stop, error, or deletion obligations.
- Schema validity and similar prose do not prove factual or behavioral parity.
Stop before continuing if
- The workload requires a capability marked unsupported and no dedicated provider-native contract exists.
- The application cannot assign durable tenant/turn IDs or atomically claim a semantic request before provider work.
- A timeout, failure, or process restart can trigger automatic replay or cross-provider fallback.
- Provider/model policy is client-controlled or keys, raw errors, private prompts, or tenant identifiers enter ordinary logs.
- The previous adapter, schema-compatible state, cohort kill switch, or reconciliation procedure is unavailable.
decision
Freeze the portable intersection
This reference migration supports only ordered user/assistant text messages, one optional system string, one non-streaming text response, application-owned history, explicit output limits, provider-native identifiers, and token accounting. Every other capability fails before a provider request.
Why this step matters
A migration is safe only when the application names the true common contract instead of silently approximating provider-specific behavior.
What to understand
The OpenAI SDK compatibility layer offered by Anthropic is an onboarding alternative, not proof of native Responses/Messages parity.
Expand the intersection one capability at a time with provider-native implementation, fixtures, failure handling, and rollback.
System changes
- Creates a versioned provider capability matrix and unsupported-feature policy.
Syntax explained
supported- The reference code implements, validates, observes, and tests this behavior for both native providers.
explicitlyUnsupported- The strict request schema rejects this capability before routing or billing; it is not silently dropped or approximated.
docs/provider-capability-matrix.json{
"supported": {
"input": "ordered user/assistant text messages",
"output": "one non-streaming text result",
"system": "one optional system instruction string",
"state": "application-owned history and durable turn id",
"usage": "provider-native token fields preserved beside normalized totals"
},
"explicitlyUnsupported": [
"streaming",
"tools or function calls",
"structured model output",
"citations",
"images, files, audio, or computer use",
"provider-hosted conversation state",
"automatic fallback after an ambiguous request"
]
}scope=text-only streaming=unsupported tools=unsupported structured_output=unsupported provider_state=unsupported
Checkpoint: Approve the capability matrix
Continue whenEvery product requirement is marked supported, deliberately provider-specific, or blocked; unsupported input fails before billing.
Stop whenStop if the current workload requires tools, citations, images, files, streaming, structured model output, or provider-hosted state and no dedicated adapter/evaluation exists.
If this step fails
A product team sends a tools or streaming field and expects best-effort behavior.
Likely causeThe portable contract was treated as a generic provider proxy rather than a deliberately limited product interface.
Inspect strict-schema rejection metrics by authenticated client revisionCompare the client feature inventory with the approved capability matrix
ResolutionKeep the request rejected, move the client back to its provider-specific path, and design a separately versioned capability only with native mappings and tests.
config
Pin both native SDKs and one validation contract
Use the native OpenAI and Anthropic SDKs so provider differences remain visible. Pin Node and dependency versions, keep keys in the server secret store, and use a private package with no browser bundle.
Why this step matters
Native SDKs preserve provider request, status, content, error, and usage semantics that compatibility wrappers can flatten or omit.
What to understand
Install both SDKs on the server but instantiate clients only inside the production constructor. Deterministic router tests import schemas, state, and dependency-injection functions without requiring either credential or contacting a provider.
Review each native SDK changelog and API documentation before updating the lock. A semver-compatible release can still add response variants, usage fields, retry behavior, or defaults that require new fixtures and a policy revision.
System changes
- Creates a private Node.js server package and dependency lock.
Syntax explained
private: true- Prevents accidental publication of a package that contains application routing and lifecycle policy.
native openai and @anthropic-ai/sdk dependencies- Keeps provider request and result semantics explicit rather than relying on a compatibility translation layer.
package.json{
"name": "openai-claude-text-router",
"private": true,
"type": "module",
"engines": {
"node": "22.22.2"
},
"scripts": {
"check": "node --check src/provider-router.mjs",
"smoke": "node test/provider-parity.mjs",
"test:failures": "node test/provider-failures.mjs"
},
"dependencies": {
"@anthropic-ai/sdk": "0.115.0",
"openai": "7.1.0",
"zod": "4.4.3"
}
}openai=7.1.0 anthropic=0.115.0 zod=4.4.3 node=22.22.2 browser_credentials=0
Checkpoint: Inspect the dependency lock
npm ci && npm ls openai @anthropic-ai/sdk zodContinue whenThe reviewed versions install on Node 22 and neither provider credential appears in tracked files or client assets.
Stop whenStop if a frontend receives either provider key or dependency versions float outside the evaluated lock.
If this step fails
A dependency update passes syntax checks but changes a provider result shape.
Likely causeThe lock changed without replaying native completed, refusal, incomplete, error, usage, and timeout fixtures.
Compare SDK versions and serialized fixture types with the last evaluated revisionRun each provider's synthetic live contract in a dedicated project
ResolutionRestore the previous lock, add a fixture for the changed shape, and release the SDK update as an immutable adapter revision.
instruction
Own one strict application request
Validate tenant ID, durable turn ID, optional system text, ordered user/assistant messages, per-message bounds, message count, and output limit. Reject unknown fields with a strict schema so a client cannot smuggle tools, provider state, files, or a model override into the portable route.
Why this step matters
A narrow server-owned request is the only stable place to authorize tenants, enforce quotas, prevent feature drift, and compare providers.
System changes
- Adds one strict text-only request schema and server-side bounds.
request=accepted tenant=tenant-a turn=turn-20260729-001 messages=4 max_output_tokens=1024 unknown_fields=0
Checkpoint: Run malformed request fixtures
Continue whenUnknown fields, unsupported roles, empty content, excessive history, invalid tenant/turn IDs, and large output limits fail before policy or provider use.
Stop whenStop if the client can select a provider/model outside policy or pass provider-native objects directly into the alternate SDK.
instruction
Claim every turn durably before the provider call
Hash the normalized semantic payload and insert a unique tenant/turn record before network work. An identical completed retry returns the stored normalized result. A changed payload conflicts, while an in-progress or failed turn requires reconciliation rather than automatic replay.
Why this step matters
Timeouts and connection loss can leave a provider request ambiguous; a process-local map or regenerated key cannot prevent duplicate spend and divergent history.
What to understand
The runnable reference uses Node's SQLite database for a single service instance and durable local state.
For multiple replicas, replace it with a transactional shared database that preserves the same unique key and state machine.
System changes
- Creates the durable turns table and atomic claim/complete/fail transitions.
tenant=tenant-a turn=turn-20260729-001 claim=created request_hash=7f4a… retry_policy=reconcile
Checkpoint: Prove duplicate suppression
npm run smokeContinue whenTwo identical calls with one tenant/turn invoke the fake provider once; a changed payload under the same ID is rejected.
Stop whenStop if a failed or timed-out turn is automatically sent to another provider before authoritative reconciliation.
instruction
Implement the native OpenAI Responses adapter
Translate only the supported text contract into instructions plus ordered input messages, set max_output_tokens and store=false, pass a timeout signal, parse output items by type, reject refusal or non-completed status, and preserve response ID plus OpenAI-native usage fields.
Why this step matters
Responses output is a typed item collection; output array position, output_text convenience, and HTTP success alone are not sufficient migration evidence.
provider=openai request=resp_01 state=completed text_bytes=9 input_tokens=12 output_tokens=3 cached_input_tokens=0
Checkpoint: Inspect completed, refused, and incomplete fixtures
Continue whenOnly completed output_text becomes a result; refusal and incomplete remain distinct sanitized failures with the provider response ID available to operations.
Stop whenStop if provider-hosted state, tools, citations, or structured output enter this adapter without an updated capability matrix and tests.
instruction
Implement the native Claude Messages adapter
Map the system text outside the messages array, send the ordered user/assistant history, cap max_tokens, pass the same timeout policy, accept only end_turn or stop_sequence text content, reject max_tokens and non-text blocks, and preserve the message ID, stop reason, and Claude-native cache usage fields.
Why this step matters
Claude Messages uses content blocks and stop reasons that do not correspond one-to-one with Responses statuses and output items.
provider=claude request=msg_01 state=completed stop_reason=end_turn text_bytes=9 input_tokens=12 output_tokens=3
Checkpoint: Inspect end_turn, stop_sequence, max_tokens, and non-text fixtures
Continue whenOnly the explicitly supported text terminal states complete; truncation, tools, thinking, or unknown blocks fail without approximation.
Stop whenStop if the adapter drops a content block, maps max_tokens to success, or loses the native stop reason needed for diagnosis.
config
Assemble the runnable bounded router
The complete module below contains strict schemas, the capability matrix, SQLite state, provider-specific model allowlists, both adapters, sanitized error classification, timeout signals, idempotent result reuse, and a production constructor. It does not claim support for unimplemented features.
Why this step matters
Keeping policy, translation, lifecycle, and normalized output in inspectable modules makes migration decisions reversible and testable.
What to understand
The SQLite store intentionally records no prompt or message content. Its semantic hash detects changed payloads while the normalized completed result supports idempotent client retry. Apply separate encryption, access, backup, retention, and deletion policy if result text contains protected data.
SQLite is appropriate for the runnable single-instance reference because claim, completion, and failure are visible and durable. Multiple replicas require a shared transactional store with the same unique tenant/turn constraint; copying the database between active nodes is not a concurrency design.
A provider policy contains both provider and model. The model must appear in a provider-specific server allowlist, which prevents a client, database typo, or migration flag from sending an OpenAI model ID to Claude or vice versa.
The normalized result preserves common fields for application use and provider-native state, stop reason, request ID, and usage for operations. New native fields should be added without overwriting old meanings or inventing a false cross-provider equivalent.
System changes
- Creates one server-only provider router and a local SQLite lifecycle database.
Syntax explained
store: false- Disables ordinary OpenAI Response storage for this stateless route; it does not delete application state or logs.
AbortSignal.timeout- Bounds each provider attempt but still leaves a timed-out request ambiguous until reconciled.
strict()- Rejects unreviewed feature fields instead of silently forwarding them to one provider.
src/provider-router.mjsimport Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import { createHash } from "node:crypto";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { z } from "zod";
export const CAPABILITY_MATRIX = Object.freeze({
streaming: "unsupported",
tools: "unsupported",
structuredOutput: "unsupported",
citations: "unsupported",
multimodal: "unsupported",
providerHostedState: "unsupported",
textMessages: "supported",
applicationIdempotency: "supported"
});
const Message = z.object({
role: z.enum(["user", "assistant"]),
content: z.string().min(1).max(32_000)
}).strict();
const Request = z.object({
tenantId: z.string().regex(/^[a-z0-9_-]{1,80}$/),
turnId: z.string().regex(/^[a-zA-Z0-9_-]{8,120}$/),
system: z.string().max(16_000).default(""),
messages: z.array(Message).min(1).max(100),
maxOutputTokens: z.number().int().min(1).max(4096).default(1024)
}).strict().superRefine((request, context) => {
if (request.messages[0]?.role !== "user") {
context.addIssue({
code: "custom",
path: ["messages", 0, "role"],
message: "conversation_must_start_with_user"
});
}
if (request.messages.at(-1)?.role !== "user") {
context.addIssue({
code: "custom",
path: ["messages", request.messages.length - 1, "role"],
message: "conversation_must_end_with_user"
});
}
for (let index = 1; index < request.messages.length; index += 1) {
if (request.messages[index - 1].role === request.messages[index].role) {
context.addIssue({
code: "custom",
path: ["messages", index, "role"],
message: "conversation_roles_must_alternate"
});
}
}
});
const Result = z.object({
turnId: z.string(),
provider: z.enum(["openai", "claude"]),
providerRequestId: z.string(),
state: z.literal("completed"),
text: z.string().max(64_000),
nativeState: z.string(),
stopReason: z.string().nullable(),
usage: z.record(z.string(), z.number().int().nonnegative().nullable())
}).strict();
class ProviderFailure extends Error {
constructor(code, retryable = false, evidence = {}) {
super(code);
this.code = code;
this.retryable = retryable;
this.providerRequestId = evidence.providerRequestId ?? null;
this.nativeState = evidence.nativeState ?? null;
}
}
export class SqliteTurnStore {
constructor(filename) {
this.db = new DatabaseSync(filename);
this.db.exec([
"PRAGMA journal_mode=WAL;",
"CREATE TABLE IF NOT EXISTS turns (",
"tenant_id TEXT NOT NULL,",
"turn_id TEXT NOT NULL,",
"request_hash TEXT NOT NULL,",
"provider TEXT NOT NULL,",
"status TEXT NOT NULL CHECK(status IN ('in_progress','completed','failed')),",
"result_json TEXT,",
"error_code TEXT,",
"provider_request_id TEXT,",
"native_state TEXT,",
"retryable INTEGER NOT NULL DEFAULT 0,",
"updated_at TEXT NOT NULL,",
"PRIMARY KEY (tenant_id, turn_id)",
");"
].join(" "));
this.insert = this.db.prepare(
"INSERT OR IGNORE INTO turns " +
"(tenant_id,turn_id,request_hash,provider,status,updated_at) " +
"VALUES (?,?,?,?, 'in_progress', ?)"
);
this.select = this.db.prepare(
"SELECT * FROM turns WHERE tenant_id=? AND turn_id=?"
);
this.completeStatement = this.db.prepare(
"UPDATE turns SET status='completed', result_json=?, error_code=NULL, retryable=0, updated_at=? " +
"WHERE tenant_id=? AND turn_id=? AND status='in_progress'"
);
this.failStatement = this.db.prepare(
"UPDATE turns SET status='failed', error_code=?, provider_request_id=?, native_state=?, " +
"retryable=?, updated_at=? " +
"WHERE tenant_id=? AND turn_id=? AND status='in_progress'"
);
}
claim(request, provider, requestHash) {
const now = new Date().toISOString();
const inserted = this.insert.run(
request.tenantId,
request.turnId,
requestHash,
provider,
now
);
const row = this.select.get(request.tenantId, request.turnId);
if (row.request_hash !== requestHash) throw new Error("turn_id_payload_conflict");
if (inserted.changes === 1) return { state: "claimed" };
if (row.status === "completed") {
return { state: "completed", result: JSON.parse(row.result_json) };
}
return {
state: "blocked",
status: row.status,
errorCode: row.error_code,
retryable: Boolean(row.retryable)
};
}
complete(request, result) {
const update = this.completeStatement.run(
JSON.stringify(result),
new Date().toISOString(),
request.tenantId,
request.turnId
);
if (update.changes !== 1) throw new Error("turn_completion_conflict");
}
fail(request, failure) {
this.failStatement.run(
failure.code,
failure.providerRequestId,
failure.nativeState,
failure.retryable ? 1 : 0,
new Date().toISOString(),
request.tenantId,
request.turnId
);
}
close() {
this.db.close();
}
}
function hashRequest(request) {
return createHash("sha256")
.update(JSON.stringify({
system: request.system,
messages: request.messages,
maxOutputTokens: request.maxOutputTokens
}))
.digest("hex");
}
function requireEnvironment(name, env) {
const value = env[name];
if (!value) throw new Error("missing_" + name.toLowerCase());
return value;
}
export function policyFromEnvironment(env = process.env) {
const policies = JSON.parse(requireEnvironment("TENANT_PROVIDER_POLICY_JSON", env));
const openAiAllowed = new Set(
requireEnvironment("OPENAI_ALLOWED_MODELS", env).split(",").map((item) => item.trim())
);
const claudeAllowed = new Set(
requireEnvironment("CLAUDE_ALLOWED_MODELS", env).split(",").map((item) => item.trim())
);
return (tenantId) => {
const policy = policies[tenantId];
if (!policy || !["openai", "claude"].includes(policy.provider)) {
throw new Error("tenant_provider_not_configured");
}
const allowed = policy.provider === "openai" ? openAiAllowed : claudeAllowed;
if (!allowed.has(policy.model)) throw new Error("provider_model_not_allowed");
return { provider: policy.provider, model: policy.model };
};
}
function classifyFailure(error) {
if (error instanceof ProviderFailure) {
return {
code: error.code,
retryable: error.retryable,
providerRequestId: error.providerRequestId,
nativeState: error.nativeState
};
}
if (error && error.name === "AbortError") {
return {
code: "provider_timeout",
retryable: true,
providerRequestId: error.request_id ?? null,
nativeState: "timeout"
};
}
const status = Number(error && error.status);
if ([408, 409, 429].includes(status) || status >= 500) {
return {
code: "provider_retryable_" + status,
retryable: true,
providerRequestId: error.request_id ?? null,
nativeState: String(status)
};
}
if (status >= 400) {
return {
code: "provider_rejected_" + status,
retryable: false,
providerRequestId: error.request_id ?? null,
nativeState: String(status)
};
}
return {
code: "provider_unknown_failure",
retryable: false,
providerRequestId: error?.request_id ?? null,
nativeState: null
};
}
function openAiText(response) {
const parts = [];
let refused = false;
for (const item of response.output || []) {
if (item.type !== "message") continue;
for (const content of item.content || []) {
if (content.type === "output_text") parts.push(content.text);
if (content.type === "refusal") refused = true;
}
}
if (refused) {
throw new ProviderFailure("openai_refusal", false, {
providerRequestId: response.id,
nativeState: response.status
});
}
if (response.status !== "completed") {
throw new ProviderFailure("openai_" + response.status, false, {
providerRequestId: response.id,
nativeState: response.status
});
}
const text = parts.join("");
if (!text) {
throw new ProviderFailure("openai_empty_text", false, {
providerRequestId: response.id,
nativeState: response.status
});
}
return text;
}
export function createOpenAiAdapter(client) {
return {
async generate(request, policy, signal) {
const response = await client.responses.create({
model: policy.model,
instructions: request.system || undefined,
input: request.messages.map((message) => ({
role: message.role,
content: message.content
})),
max_output_tokens: request.maxOutputTokens,
store: false
}, { signal });
return {
turnId: request.turnId,
provider: "openai",
providerRequestId: response.id,
state: "completed",
text: openAiText(response),
nativeState: response.status,
stopReason: null,
usage: {
inputTokens: response.usage?.input_tokens ?? null,
outputTokens: response.usage?.output_tokens ?? null,
totalTokens: response.usage?.total_tokens ?? null,
cachedInputTokens:
response.usage?.input_tokens_details?.cached_tokens ?? null
}
};
}
};
}
export function createClaudeAdapter(client) {
return {
async generate(request, policy, signal) {
const message = await client.messages.create({
model: policy.model,
system: request.system || undefined,
messages: request.messages,
max_tokens: request.maxOutputTokens
}, { signal });
if (message.stop_reason === "max_tokens") {
throw new ProviderFailure("claude_max_tokens", false, {
providerRequestId: message.id,
nativeState: message.stop_reason
});
}
if (!["end_turn", "stop_sequence"].includes(message.stop_reason)) {
throw new ProviderFailure("claude_" + String(message.stop_reason), false, {
providerRequestId: message.id,
nativeState: message.stop_reason
});
}
if (message.content.some((block) => block.type !== "text")) {
throw new ProviderFailure("claude_unsupported_content_block", false, {
providerRequestId: message.id,
nativeState: message.stop_reason
});
}
const text = message.content.map((block) => block.text).join("");
if (!text) {
throw new ProviderFailure("claude_empty_text", false, {
providerRequestId: message.id,
nativeState: message.stop_reason
});
}
return {
turnId: request.turnId,
provider: "claude",
providerRequestId: message.id,
state: "completed",
text,
nativeState: "message_completed",
stopReason: message.stop_reason,
usage: {
inputTokens: message.usage.input_tokens,
outputTokens: message.usage.output_tokens,
cacheCreationInputTokens: message.usage.cache_creation_input_tokens ?? null,
cacheReadInputTokens: message.usage.cache_read_input_tokens ?? null
}
};
}
};
}
export function createRouter({ policyForTenant, adapters, store, timeoutMs = 30_000 }) {
return {
async generate(rawRequest) {
const request = Request.parse(rawRequest);
const policy = policyForTenant(request.tenantId);
const adapter = adapters[policy.provider];
if (!adapter) throw new Error("provider_adapter_missing");
const requestHash = hashRequest(request);
const claim = store.claim(request, policy.provider, requestHash);
if (claim.state === "completed") return Result.parse(claim.result);
if (claim.state === "blocked") {
throw new Error(
claim.status === "failed"
? "turn_failed_requires_reconciliation"
: "turn_already_in_progress"
);
}
try {
const result = Result.parse(
await adapter.generate(request, policy, AbortSignal.timeout(timeoutMs))
);
store.complete(request, result);
return result;
} catch (error) {
const classified = classifyFailure(error);
store.fail(request, classified);
const safeError = new Error(classified.code);
safeError.retryable = classified.retryable;
throw safeError;
}
}
};
}
export function createProductionRouter(env = process.env) {
const openai = new OpenAI({ apiKey: requireEnvironment("OPENAI_API_KEY", env) });
const claude = new Anthropic({ apiKey: requireEnvironment("ANTHROPIC_API_KEY", env) });
const databasePath = path.resolve(env.TURN_DATABASE || "data/provider-turns.sqlite");
mkdirSync(path.dirname(databasePath), { recursive: true, mode: 0o700 });
const store = new SqliteTurnStore(databasePath);
return {
router: createRouter({
policyForTenant: policyFromEnvironment(env),
adapters: {
openai: createOpenAiAdapter(openai),
claude: createClaudeAdapter(claude)
},
store
}),
store
};
}router=ready providers=openai,claude policy=server-owned state=sqlite unsupported=fail-closed
Checkpoint: Check the complete module
npm run checkContinue whenThe module parses on Node 22, initializes the database, validates policy/model allowlists, and exposes both native adapters.
Stop whenStop if an undefined helper, implicit model fallback, raw provider error, or automatic cross-provider retry remains.
config
Run a provider-independent contract smoke
Use dependency-injected fake adapters to test the portable application contract without spend. The fixture proves both routes, persisted duplicate suppression, payload-conflict rejection, and fail-closed handling of an unsupported tools field.
Why this step matters
A deterministic smoke validates routing and lifecycle invariants; it deliberately does not pretend to prove live provider quality or current API compatibility.
What to understand
The fake adapters return a stable acceptance token and provider-specific identity but share the same application result schema. Call counters prove an identical completed retry is served from durable state rather than producing a second external attempt.
The payload-conflict case uses the same tenant/turn ID with changed text, while the unsupported case adds a tools property. These fixtures exercise two critical fail-closed boundaries without relying on a model to notice the mistake.
System changes
- Creates a temporary local turn database during the test and deletes it after deterministic provider, duplicate, conflict, and unsupported-feature assertions.
Syntax explained
dependency-injected adapters- Exercise router, schema, idempotency, and persistence logic deterministically without credentials, spend, rate limits, or model variance.
temporary SQLite database- Uses the real durable claim implementation and removes the isolated fixture only after assertions complete.
test/provider-parity.mjsimport assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
createRouter,
SqliteTurnStore
} from "../src/provider-router.mjs";
const temp = await fs.mkdtemp(path.join(os.tmpdir(), "provider-parity-"));
const store = new SqliteTurnStore(path.join(temp, "turns.sqlite"));
const calls = { openai: 0, claude: 0 };
const adapters = Object.fromEntries(
["openai", "claude"].map((provider) => [provider, {
async generate(request) {
calls[provider] += 1;
return {
turnId: request.turnId,
provider,
providerRequestId: provider + "_request_01",
state: "completed",
text: "PARITY_OK",
nativeState: "completed",
stopReason: provider === "claude" ? "end_turn" : null,
usage: { inputTokens: 12, outputTokens: 3 }
};
}
}])
);
const router = createRouter({
policyForTenant: (tenantId) => ({
provider: tenantId === "tenant-openai" ? "openai" : "claude",
model: "fixture-model"
}),
adapters,
store,
timeoutMs: 1000
});
const base = {
system: "Return exactly PARITY_OK.",
messages: [{ role: "user", content: "health check" }],
maxOutputTokens: 20
};
const first = await router.generate({
...base,
tenantId: "tenant-openai",
turnId: "turn-openai-001"
});
const duplicate = await router.generate({
...base,
tenantId: "tenant-openai",
turnId: "turn-openai-001"
});
const second = await router.generate({
...base,
tenantId: "tenant-claude",
turnId: "turn-claude-001"
});
assert.equal(first.text, "PARITY_OK");
assert.deepEqual(duplicate, first);
assert.equal(second.text, "PARITY_OK");
assert.equal(calls.openai, 1);
assert.equal(calls.claude, 1);
await assert.rejects(
router.generate({
...base,
messages: [{ role: "user", content: "different payload" }],
tenantId: "tenant-openai",
turnId: "turn-openai-001"
}),
/turn_id_payload_conflict/
);
await assert.rejects(
router.generate({
...base,
tenantId: "tenant-openai",
turnId: "turn-unsupported-001",
tools: [{ name: "write_file" }]
})
);
store.close();
await fs.rm(temp, { recursive: true, force: true });
console.log(
"SMOKE_OK scope=text-only providers=2 duplicate_provider_calls=0 unsupported=blocked"
);SMOKE_OK scope=text-only providers=2 duplicate_provider_calls=0 unsupported=blocked
Checkpoint: Run the deterministic parity smoke
npm run smokeContinue whenBoth fake providers return the same application text contract, duplicates do not call them again, and unsupported fields are blocked.
Stop whenStop if the smoke calls a real provider, relies on similar prose, or reports streaming/tools/citations as passed.
If this step fails
The duplicate fixture calls the fake provider twice.
Likely causeThe store claim is process-local, the unique key changed, completion was not persisted, or request hashing is unstable.
Inspect tenant ID, turn ID, semantic hash, insert changes, and final row statusRepeat after closing and reopening the SQLite store
ResolutionBlock release, restore the durable unique transition, and add restart plus concurrent-claim fixtures before any live provider test.
verification
Run separate live provider fixtures
Against dedicated bounded projects, send the same synthetic text cases to each native adapter and assess only the approved contract: completed state, exact acceptance token, native request ID, non-empty text, output bound, native usage, timeout, refusal/incomplete handling, and no provider storage assumption.
Why this step matters
Fake adapters cannot prove current authentication, model access, stop behavior, token accounting, or provider response shapes.
What to understand
Run each provider fixture separately and record the exact adapter/model/policy revision. The test compares contract assertions, not word-for-word prose: valid terminal state, required token or rubric, native request identity, bounded text, and expected usage dimensions.
Include refusal, output-limit, Unicode, long history near the approved bound, alternating roles, provider 429/500 simulation where official test support exists, and AbortSignal timeout. Retain only synthetic inputs and privacy-safe metrics under a short test retention.
System changes
- Consumes bounded requests in dedicated OpenAI and Anthropic test projects and records sanitized native protocol, usage, latency, and cost evidence.
Syntax explained
TENANT_PROVIDER_POLICY_JSON- Routes named synthetic tenants through server-owned policies so the test exercises the same authorization boundary as production.
provider-specific allowed-model lists- Prevent a fixture or stale tenant policy from selecting an unreviewed or cross-provider model.
Values stay on this page and are never sent or saved.
TENANT_PROVIDER_POLICY_JSON={{policyJson}} OPENAI_ALLOWED_MODELS={{openAiModels}} CLAUDE_ALLOWED_MODELS={{claudeModels}} npm run test:livecases=20 openai_contract=passed claude_contract=passed unsupported=blocked quality_review=passed cost_delta=recorded
Checkpoint: Review native evidence, not prose equality
Continue whenBoth adapters satisfy the frozen application assertions while provider IDs, native state/stop reason, usage, latency, and cost remain separately visible.
Stop whenStop if real user prompts are mirrored without approval or if semantically similar prose is called protocol parity.
If this step fails
One live provider returns acceptable text but no expected native usage field.
Likely causeThe SDK/API shape changed, a model does not report the same dimension, or normalization erased provider-specific evidence.
Inspect the native response type and current official usage documentationCompare adapter and SDK revisions with the last passing fixture
ResolutionKeep the provider revision out of the canary, preserve the field as nullable only when officially absent, and update cost policy before retesting.
Security notes
- Use synthetic authorized data and independent provider budgets; do not duplicate regulated or secret content merely to compare output.
verification
Exercise errors, timeout, and reconciliation
Inject 400, 401, 429, 500, refusal, truncation, unknown content, invalid result, timeout before headers, timeout after possible provider acceptance, database completion conflict, and process restart. Confirm sanitized classification and that every failed or ambiguous turn remains blocked until an operator resolves it.
Why this step matters
The dangerous migration failure is a request that may have completed at one provider and is silently replayed at the other.
What to understand
Classify transport and HTTP errors without saving raw provider messages, which may contain request fragments or internal details. The application exposes a stable safe code and retryability hint, but the durable failed row remains blocked until an explicit reconciliation decision.
A timeout before local receipt is still ambiguous because the provider may have accepted the request. Where a provider supports retrieval by native ID, reconcile it; when no authoritative lookup is possible, require a new user action and turn ID rather than guessing.
Test failure between provider completion and SQLite completion. This is the most important ambiguous window: retrying the same semantic work can duplicate spend or divergent output even though no local completed result exists.
System changes
- Adds typed sanitized failure state, retryability evidence, ambiguous-turn alerts, and an operator reconciliation queue without automatic replay.
Syntax explained
retryable metadata- Describes the provider error class for operations but never authorizes an automatic replay of an already claimed turn.
cross_provider_fallback=0- A failed or ambiguous attempt remains associated with its original provider and policy until an operator reconciles it.
npm run test:failuresinvalid=nonretryable rate_limit=retryable timeout=ambiguous auto_replay=0 cross_provider_fallback=0 reconciliation=required
Checkpoint: Review the turn state machine
Continue whenNo automatic retry crosses providers, raw errors are absent, and an operator can correlate tenant/turn with native request evidence.
Stop whenStop if timeout automatically changes provider or if a failed row can be overwritten without a recorded reconciliation decision.
If this step fails
An operator cannot determine whether a timed-out turn completed.
Likely causeNo native request ID was received and the provider offers no authoritative lookup for the attempt.
Preserve tenant/turn, policy revision, start time, timeout class, and provider without prompt contentCheck provider usage and request evidence through authorized operations
ResolutionMark the original turn terminal-ambiguous, do not replay it, tell the user the outcome is unknown, and require a separately authorized new action with a new turn ID.
verification
Move one tenant cohort with a reversible policy
Choose a small authorized text-only cohort, freeze its provider and model policy by revision, prevent provider changes during an in-progress turn, compare contract failures, quality review, refusal, truncation, latency, native usage, and cost, and retain the previous adapter for completed-history replay.
Why this step matters
A tenant-scoped canary limits impact and preserves an application-owned routing boundary for immediate rollback.
What to understand
Select a cohort whose product path cannot introduce unsupported features through hidden flags or future clients. Freeze the cohort membership and policy revision so evaluation results remain attributable instead of changing while traffic is measured.
Compare failure rate, refusal, truncation, reviewed task acceptance, p50/p95 latency, input/output/cache usage, calculated charge, and support incidents by native provider. Never collapse incompatible usage fields into a single unexplained token count.
Prevent a policy change while a turn is in_progress. Completed history may contain results from both providers over time, but each result and new turn must retain one explicit provider identity and model revision.
System changes
- Moves only new turns for one authorized text-only cohort to the candidate provider policy and starts provider-separated release monitoring.
Syntax explained
--cohort- Selects an opaque, pre-authorized migration population rather than accepting arbitrary tenant IDs from a browser.
--policy- Pins the evaluated provider/model routing revision used for metrics, diagnosis, and rollback.
Values stay on this page and are never sent or saved.
npm run eval -- --cohort {{cohort}} --policy {{policyRevision}}cohort=internal-text policy=rev-3 openai=baseline claude=canary critical_failures=0 rollback=tenant-scoped
Checkpoint: Approve cohort expansion
Continue whenCritical contract and privacy failures remain zero, reviewed quality is acceptable, native metrics are explainable, and rollback was rehearsed.
Stop whenStop on unsupported content, state loss, duplicate turn, unexplained cost, provider mismatch, or evidence that the cohort is not text-only.
If this step fails
A tenant begins a turn on one provider and finishes under another policy.
Likely causeRouting was resolved per request or policy changed after the durable turn claim.
Compare the provider stored on the turn row with current policy revisionInspect deployment and cohort-policy changes around the claim timestamp
ResolutionFreeze the cohort, keep the turn bound to its claimed provider for reconciliation, and make policy updates apply only before a new turn claim.
verification
Rehearse tenant-scoped rollback
Freeze new turns for the cohort, allow or cancel known in-flight attempts according to native evidence, reconcile every in-progress/failed row, route only new turn IDs back to the previous adapter, preserve provider-native IDs and normalized completed results, and rerun the baseline smoke before reopening.
Why this step matters
Changing a routing flag is not enough when provider calls or durable turn records are ambiguous.
What to understand
Freezing the cohort must occur at the authorization/routing boundary before another durable claim. A deployment rollback that leaves the database policy accepting candidate turns is not a functional rollback.
Classify every in-progress row using native evidence and do not rewrite completed rows to look as though the baseline provider created them. Provider identity is part of lifecycle, support, deletion, cost, and audit truth.
Validate that the previous adapter can read application-owned history created before and during the canary. Only new turn IDs use the restored provider; completed normalized results remain immutable and available under retention policy.
System changes
- Freezes cohort routing, reconciles candidate-provider turns, restores the previous policy for new turns, and preserves immutable completed history.
Syntax explained
migration:freeze- Stops creation of new candidate-provider turn claims while authenticated status and reconciliation remain available.
migration:reconcile- Enumerates and resolves existing candidate-provider states before baseline routing reopens.
Values stay on this page and are never sent or saved.
npm run migration:freeze -- --cohort {{cohort}} && npm run migration:reconcile -- --cohort {{cohort}}cohort=frozen in_progress=0 failed_reconciled=2 new_provider=openai completed_history=preserved reopen=approved
Checkpoint: Complete rollback without replay
Continue whenNo existing turn is resent, completed results remain readable, ambiguous work has an explicit decision, and new turns use the previous evaluated adapter.
Stop whenStop reopening if schema compatibility, policy revision, turn reconciliation, or the previous provider smoke is unresolved.
If this step fails
Rollback restores routing but new requests cannot use the prior adapter.
Likely causeThe old model was removed, its key or quota expired, schema compatibility drifted, or previous-provider smoke was not maintained.
Run the previous adapter's synthetic live fixture before reopeningCheck model allowlist, credentials, quota, schema version, and application history bounds
ResolutionKeep the cohort frozen, restore a currently supported evaluated baseline, and complete compatibility plus live smoke gates before accepting new turns.
Finish line
Verification checklist
npm run smokeBoth injected providers satisfy the text contract, duplicate turns cause no second provider call, changed payloads conflict, and unsupported fields fail.npm run test:failuresProvider errors, refusal, truncation, timeout, database conflict, and restart remain typed, sanitized, and never trigger cross-provider replay.TENANT_PROVIDER_POLICY_JSON={{policyJson}} OPENAI_ALLOWED_MODELS={{openAiModels}} CLAUDE_ALLOWED_MODELS={{claudeModels}} npm run test:liveEach native adapter passes only the frozen text assertions and preserves its request ID, terminal evidence, usage, latency, and cost.npm run migration:freeze -- --cohort {{cohort}} && npm run migration:reconcile -- --cohort {{cohort}}In-flight turns are reconciled without replay, completed results remain readable, and only new turn IDs return to the previous adapter.Recovery guidance
Common problems and safe checks
A turn ID is rejected with turn_id_payload_conflict.
Likely causeThe caller reused an existing durable turn ID with different system text, messages, or output bound.
Compare the stored request hash and authenticated client operation IDDo not print the underlying prompt
ResolutionTreat it as a client/idempotency defect or abuse attempt; use a new turn ID only for a genuinely new user action.
A retry reports turn_already_in_progress.
Likely causeAnother process owns the durable claim or the prior process ended before authoritative completion.
Check row timestamp, provider, policy revision, and native request IDInspect provider state through authorized operations
ResolutionKeep the turn blocked until reconciliation proves completion or a reviewed operator transition permits a new action.
A failed turn says reconciliation is required.
Likely causeThe adapter recorded a typed failure and intentionally prevents automatic replay.
Inspect sanitized error code and retryable flagCorrelate the provider-native request ID where one exists
ResolutionResolve the existing provider attempt, record the operator decision, and issue a new turn ID only when replay semantics are safe.
Claude returns claude_unsupported_content_block.
Likely causeThe model returned a tool, thinking, or another block outside the frozen text-only contract.
Record block types without contentConfirm no provider feature or model policy changed
ResolutionKeep the request failed and either restore the evaluated model/policy or design a new explicit capability revision with fixtures.
OpenAI returns an incomplete or refusal state.
Likely causeThe request hit a provider output limit, safety response, or another typed non-completed state.
Inspect response ID, status, incomplete reason, and usage without raw contentRun the matching synthetic fixture
ResolutionSurface a safe typed application state; do not map it to empty success or automatically send the prompt to Claude.
The providers differ in cost or answer quality.
Likely causeModels, token accounting, cache behavior, stop semantics, and generation behavior are provider-specific.
Compare native usage and latency by immutable policy revisionReview blinded synthetic acceptance cases
ResolutionAdjust the tenant policy or product expectation through a new evaluated revision; do not hide the difference in normalization.
Reference
Frequently asked questions
Can one neutral schema expose every OpenAI and Claude feature?
Not honestly. Use a narrow tested intersection and keep provider-specific capabilities in explicit modules with their own evidence and rollback.
Why is streaming unsupported in this reference?
Responses and Messages expose different event types, ordering, errors, and cancellation behavior. Supporting streaming requires a separate event contract and fixture matrix.
Can a timed-out OpenAI request be retried immediately on Claude?
No. The first request may have completed. Reconcile its native state and durable turn record before any new user action; never reuse the same turn ID across providers.
Does store=false make OpenAI and Claude retention identical?
No. It controls ordinary Response storage for that call, not application databases, provider abuse logs, backups, exports, or Anthropic's separate data handling.
Why preserve provider-specific usage fields?
Input, output, total, prompt-cache creation, and cache-read dimensions differ. A normalized total alone is insufficient for cost and migration diagnosis.
Should we use Anthropic's OpenAI SDK compatibility layer instead?
It can reduce initial code changes for supported features, but official documentation lists compatibility differences. It is an alternative, not proof that native Responses and Messages are equivalent.
How is conversation history migrated?
The application owns ordered text history and replays only the bounded portion required for a new turn. Provider-hosted conversation objects are outside this reference contract.
How often should the migration layer be reviewed?
At least every 90 days and whenever either SDK, model, API shape, stop/error behavior, data policy, pricing, capability matrix, or routing policy changes.
Recovery
Rollback
Freeze the migration cohort before changing routing, reconcile every in-progress and failed turn using provider-native evidence, route only new turn IDs back to the previous evaluated adapter, preserve completed normalized results and native IDs, and reopen after the previous provider smoke and schema-compatibility gates pass.
- Activate the cohort kill switch and reject new turns while keeping authenticated read and incident status available.
- Record policy revision and enumerate in-progress, failed, and completed tenant/turn rows without copying prompt content.
- Use native request IDs and provider dashboards/support evidence to classify ambiguous attempts; never replay merely because the client timed out.
- Route only new turn IDs to the previous provider/model policy and retain completed results under their original provider identity.
- Rerun request-schema, duplicate, failure, live-provider, privacy, cost, and previous-adapter smoke gates before reopening the cohort.
Evidence