OneLinersCommand workbench
Guides
AI/ML / Software Engineering / High Availability & Reliability

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.

300 min12 stepsChanges system stateRevision 2
Save or explore
Save to collectionCreate a collection in the sidebar first.
0 of 12 steps completed
Goal

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.

Supported environments
  • Node.js 22.22.2
  • OpenAI Node SDK 7.1.0
  • Anthropic TypeScript SDK 0.115.0
  • SQLite Node 22 node:sqlite
Prerequisites
  • 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.
Operating boundary

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

System
  • 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.
Observable outcome
  • 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.

Strict application contractAccepts only bounded text messages, a system string, tenant/turn IDs, and an output limit; unknown features fail closed.
Tenant provider policySelects one provider and an allowlisted model server-side and freezes that decision for the claimed turn.
Durable turn storeUses a semantic request hash and unique tenant/turn key to prevent changed payloads, duplicate calls, and unsafe replay.
Native provider adaptersTranslate only the supported contract and preserve Responses status/items or Messages content/stop reason plus native usage.
Parity and migration operationsRun deterministic/live fixtures, compare native metrics, freeze cohorts, reconcile ambiguous state, and execute rollback.
  1. The server authenticates the tenant and strictly validates the bounded text request.
  2. Provider policy chooses one allowlisted native model; the client cannot override it.
  3. The turn store atomically claims tenant, turn, semantic hash, and provider before network work.
  4. The native adapter sends one bounded request under a timeout and parses typed provider output.
  5. A strict normalized result is persisted with provider-native identity and returned; identical completed retries reuse it.
  6. 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}}secret

Server-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-3

Security 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.
01

decision

Freeze the portable intersection

read-only

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.
File docs/provider-capability-matrix.json
Configuration
{
  "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"
  ]
}
Example output / evidence
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.

Safe checks
  • Inspect strict-schema rejection metrics by authenticated client revision
  • Compare 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.

02

config

Pin both native SDKs and one validation contract

caution

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.
File package.json
Configuration
{
  "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"
  }
}
Example output / evidence
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 zod

Continue 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.

Safe checks
  • Compare SDK versions and serialized fixture types with the last evaluated revision
  • Run 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.

03

instruction

Own one strict application request

caution

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.
Example output / evidence
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.

04

instruction

Claim every turn durably before the provider call

caution

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.
Example output / evidence
tenant=tenant-a turn=turn-20260729-001 claim=created request_hash=7f4a… retry_policy=reconcile

Checkpoint: Prove duplicate suppression

npm run smoke

Continue 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.

05

instruction

Implement the native OpenAI Responses adapter

caution

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.

Example output / 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.

06

instruction

Implement the native Claude Messages adapter

caution

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.

Example output / evidence
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.

07

config

Assemble the runnable bounded router

caution

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.
File src/provider-router.mjs
Configuration
import 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
  };
}
Example output / evidence
router=ready providers=openai,claude policy=server-owned state=sqlite unsupported=fail-closed

Checkpoint: Check the complete module

npm run check

Continue 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.

08

config

Run a provider-independent contract smoke

read-only

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.
File test/provider-parity.mjs
Configuration
import 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"
);
Example output / evidence
SMOKE_OK scope=text-only providers=2 duplicate_provider_calls=0 unsupported=blocked

Checkpoint: Run the deterministic parity smoke

npm run smoke

Continue 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.

Safe checks
  • Inspect tenant ID, turn ID, semantic hash, insert changes, and final row status
  • Repeat 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.

09

verification

Run separate live provider fixtures

caution

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.
Command
Fill variables0/3 ready

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:live
Example output / evidence
cases=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.

Safe checks
  • Inspect the native response type and current official usage documentation
  • Compare 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.
10

verification

Exercise errors, timeout, and reconciliation

caution

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.
Command
npm run test:failures
Example output / evidence
invalid=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.

Safe checks
  • Preserve tenant/turn, policy revision, start time, timeout class, and provider without prompt content
  • Check 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.

11

verification

Move one tenant cohort with a reversible policy

caution

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.
Command
Fill variables0/2 ready

Values stay on this page and are never sent or saved.

npm run eval -- --cohort {{cohort}} --policy {{policyRevision}}
Example output / evidence
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.

Safe checks
  • Compare the provider stored on the turn row with current policy revision
  • Inspect 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.

12

verification

Rehearse tenant-scoped rollback

caution

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.
Command
Fill variables0/1 ready

Values stay on this page and are never sent or saved.

npm run migration:freeze -- --cohort {{cohort}} && npm run migration:reconcile -- --cohort {{cohort}}
Example output / evidence
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.

Safe checks
  • Run the previous adapter's synthetic live fixture before reopening
  • Check 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

Deterministic router contractnpm run smokeBoth injected providers satisfy the text contract, duplicate turns cause no second provider call, changed payloads conflict, and unsupported fields fail.
Failure and reconciliation suitenpm run test:failuresProvider errors, refusal, truncation, timeout, database conflict, and restart remain typed, sanitized, and never trigger cross-provider replay.
Live provider contractTENANT_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.
Tenant rollback drillnpm 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.

Safe checks
  • Compare the stored request hash and authenticated client operation ID
  • Do 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.

Safe checks
  • Check row timestamp, provider, policy revision, and native request ID
  • Inspect 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.

Safe checks
  • Inspect sanitized error code and retryable flag
  • Correlate 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.

Safe checks
  • Record block types without content
  • Confirm 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.

Safe checks
  • Inspect response ID, status, incomplete reason, and usage without raw content
  • Run 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.

Safe checks
  • Compare native usage and latency by immutable policy revision
  • Review blinded synthetic acceptance cases

ResolutionAdjust the tenant policy or product expectation through a new evaluated revision; do not hide the difference in normalization.

After the procedure

Alternatives and next steps

Consider these alternatives

  • Use Anthropic's documented OpenAI SDK compatibility layer for a temporary, limited transition after reviewing its official compatibility differences.
  • Keep provider-specific product paths when tools, citations, multimodal input, streaming, or hosted state are core requirements.
  • Use one provider only when a portability layer adds complexity without a real operational or product need.

Operate it safely

  • Replace single-instance SQLite with a transactional shared store before horizontal scaling.
  • Add a separately versioned streaming contract only after mapping and testing every native event and cancellation path.
  • Add tools or structured output as independent capability modules rather than expanding the text route silently.
  • Automate provider documentation, SDK, model, pricing, and retention freshness review every 90 days.

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.

  1. Activate the cohort kill switch and reject new turns while keeping authenticated read and incident status available.
  2. Record policy revision and enumerate in-progress, failed, and completed tenant/turn rows without copying prompt content.
  3. Use native request IDs and provider dashboards/support evidence to classify ambiguous attempts; never replay merely because the client timed out.
  4. Route only new turn IDs to the previous provider/model policy and retain completed results under their original provider identity.
  5. Rerun request-schema, duplicate, failure, live-provider, privacy, cost, and previous-adapter smoke gates before reopening the cohort.

Evidence

Sources and review

Verified 2026-07-29Review due 2026-10-27
Responses API create reference | OpenAIofficialResponses API guide | OpenAIofficialConversation state | OpenAIofficialStreaming API responses | OpenAIofficialFunction calling | OpenAIofficialStructured model outputs | OpenAIofficialMessages API reference | AnthropicofficialStreaming Messages | AnthropicofficialTool use with Claude | AnthropicofficialHandle stop reasons | AnthropicofficialOpenAI SDK compatibility | Anthropicofficial