Skip to content
Closed
37 changes: 37 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,43 @@ modalities are declared; Kimi omits `capabilities` for unknown or text-only mode
a supported capability field keep their existing configuration shape. After updating opencodex,
regenerate or refresh the client configuration managed by opencodex to receive the updated metadata.

## Replayed Anthropic thinking signatures

On the Responses endpoint, an Anthropic-compatible upstream can reject a replayed thinking
signature because it belongs to a different conversation prefix. When the error explicitly
identifies this signature-binding problem, opencodex can remove replayed signed/redacted thinking
and retry once within the request's send budget. This covers HTTP 400 refusals and initial stream
errors before output or tool activity. Hosted web search uses the same one-recovery limit for
all model iterations: only the rejected iteration is resent, while completed searches and tool
results are retained. Older rejected thinking stays excluded in later iterations; newly observed
complete thinking blocks from the repaired request can be retained. A failed replacement is
returned without another signature retry. Other errors keep their existing handling.

Search recovery is disabled when enforced spend accounting cannot establish receipts for earlier
ordinary search sends. It never increases a caller or Combo send allowance.

Only a successful, nonempty completion remembers the rejection for that conversation and serving
identity for five minutes. Plaintext history remains available; a failed or cancelled retry does
not disable thinking replay on later requests.

When the rejected request's opaque payloads were fully captured, this memo can remember only those
payload fingerprints. Compatible new thinking from the successful repair can then survive later
requests after its full prefix checks. Already rejected payloads remain excluded even if their
visible text or metadata changes. Legacy/unrecognized blocks retain conservative stripping while
the memo is active. Old blanket memos or incomplete/oversized capture keep blanket behavior; the
fingerprint set is capped at 256, stays within the existing cache limits, and does not grant another
signature retry or persist across restarts.

New reasoning envelopes also carry optional prefix-consistency observations. When comparable,
these let the adapter remove only thinking whose recorded context no longer matches, while
preserving compatible newer thinking, plaintext and tool results. They do not change the reasoning
mode or increase the retry allowance. A manual-thinking tool continuation that would lose required
thinking fails with an incompatible-history error before sending. Legacy or unrecognized metadata
keeps existing compatibility behavior. No client update or new persistent store is required; a
repair that produces no new signed/redacted thinking has no durable omission marker, and legacy
rejections can recur after restart. See the [architecture notes](/reference/architecture/#anthropic-thinking-prefix-continuity)
for the complete-block, scope and replay-shape limits.

## Auth modes

Provider configs accept three `authMode` values (`key` is the default). The built-in registry also
Expand Down
44 changes: 44 additions & 0 deletions docs-site/src/content/docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,50 @@ set, and the tool-search set captured by the parser — so MCP namespaces, `appl
tools, and client-executed `tool_search` all round-trip. A `buildResponseJSON()` variant produces a
single non-streaming response object from the same events.

### Anthropic thinking-prefix continuity

The existing `ocxr1` reasoning envelope may include versioned `anthropic-prefix-v1` observations.
Both streaming and buffered Responses paths preserve these alongside the original signature or
redacted payload; no client update is needed. The envelope is base64 JSON, not encryption, and the
observation is a consistency check rather than cryptographic validation of Anthropic's signature.

The adapter compares the final normalized request's effective system instructions, tool definitions
and ordered non-thinking history, plus a separate parent/block chain for signed and redacted
thinking. Its scope includes the provider destination, credential identity and model. Protocol-level
cache annotations are excluded; similarly named fields inside tool arguments or schemas still count.
Known mismatches can remove an older incompatible segment while retaining compatible thinking,
including new thinking produced after a repair. Plaintext and completed tool results remain intact.
A manual-thinking tool continuation that cannot preserve the required history fails before sending
instead of turning thinking off or running tools again.

Hints describe complete blocks: streaming issuance waits for the matching block stop, including
redacted blocks. An unfinished redacted block keeps its payload without a hint where error/EOF
processing can return it. A completed block can retain its observation even if the response later
fails; this never proves response success, grants another retry, or records a successful repair.
Because Responses cannot round-trip the original assistant-message shape for thinking after text
or tool content, those client-facing blocks keep legacy behavior. Lossless in-memory hosted-search
snapshots can preserve observations for those shapes.

After a validated successful repair, the existing five-minute rejection memo can retain up to 256
fingerprints of the opaque payloads actually present in the rejected final request. The capture is
privately bound to that exact request/response pair. Type-separated signature/redacted-data hashes
exclude plaintext and prefix metadata, so editing those fields cannot bypass an existing rejection.
Selective memos merge rejected fingerprints within the existing 64-entry/256 KiB cache bounds.
Incomplete, invalid or oversized capture, and an existing blanket memo, retain blanket stripping.

With a selective memo active, rejected payloads and legacy/unrecognized blocks remain excluded.
A different payload carrying a recognized same-scope observation can survive only after the normal
block, prefix and parent-chain checks; a new hint alone does not make it valid. This preserves
compatible thinking generated by a successful repair on later requests without giving an active
memo another signature retry. The memo still contains no raw opaque content and adds no persistence.

Legacy envelopes, malformed/unknown versions and unknown scopes do not by themselves authorize
removing provider payloads. Recognized observations travel with reasoning items without a new key,
database or persistent store. There is no durable omission marker if a repair returns no new
signed/redacted thinking, so that case and legacy histories can still encounter the old rejection
after restart. The existing short-lived memo and one-shot recovery rules remain separate; see
[replayed Anthropic thinking signatures](/guides/providers/#replayed-anthropic-thinking-signatures).

## Management API, OAuth, and usage

`server/management-api.ts` backs the dashboard and dispatches focused route groups to
Expand Down
9 changes: 7 additions & 2 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@
"anthropic-stream-hardening.test.ts": "adapters/anthropic",
"anthropic-tail-guard.test.ts": "adapters/anthropic",
"anthropic-thinking-signature.test.ts": "adapters/anthropic",
"anthropic-replay.test.ts": "adapters/anthropic",
"anthropic-opaque-strip.test.ts": "adapters/anthropic",
"anthropic-tool-call-id.test.ts": "adapters/anthropic",
"anthropic-messages-passthrough.test.ts": "adapters/anthropic",
Expand Down Expand Up @@ -257,7 +258,7 @@
"bridge-live-delivery.test.ts": "server",
"bridge-nonstreaming-terminal.test.ts": "adapters",
"bridge-raw-reasoning-hidden.test.ts": "adapters",
"bridge-reasoning-replay-batch.test.ts": "adapters",
"bridge-reasoning-replay-batch.test.ts": "adapters", "bridge-anthropic-prefix.test.ts": "adapters",
"bridge-stall.test.ts": "adapters",
"bridge-terminal-singleness.test.ts": "adapters",
"bridge.test.ts": "adapters",
Expand Down Expand Up @@ -1399,7 +1400,7 @@
"rate-limit-retry.test.ts": "providers",
"reasoning-effort-summary-default.test.ts": "responses",
"reasoning-effort.test.ts": "codex-integration",
"reasoning-envelope.test.ts": "responses",
"reasoning-envelope.test.ts": "responses", "reasoning-envelope-prefix.test.ts": "responses",
"reasoning-metadata.test.ts": "codex-integration",
"reasoning-replay-identity.test.ts": "adapters",
"reasoning-replay-robustness.test.ts": "adapters",
Expand Down Expand Up @@ -1452,6 +1453,10 @@
"responses-account-change-scrub.test.ts": "responses",
"responses-account-label.test.ts": "responses",
"responses-anthropic-fast-downgrade.test.ts": "responses",
"responses-anthropic-thinking-signature-recovery.test.ts": "responses",
"responses-anthropic-prefix-consistency.test.ts": "responses",
"responses-web-search-thinking-signature-recovery.test.ts": "responses",
"responses-anthropic-thinking-recovery.test.ts": "responses",
"responses-azure-opaque-blob-recovery.test.ts": "responses",
"responses-bare-echo-helper-fence.test.ts": "responses",
"responses-canonical-only-top-level-fields.test.ts": "responses",
Expand Down
11 changes: 11 additions & 0 deletions src/adapters/anthropic-prefix-hints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { AdapterEvent, AnthropicPrefixHint } from "../types";

// Neither events nor provider blocks expose these observations in public JSON/log surfaces.
const observations = new WeakMap<object, AnthropicPrefixHint>();
const locallyIssued = new WeakMap<object, object>();
export function getAnthropicPrefixHint(value: object): AnthropicPrefixHint | undefined { return observations.get(value); }
export function setAnthropicPrefixHint(value: object, hint: AnthropicPrefixHint): void { observations.set(value, hint); }
export function issueAnthropicPrefixHint(event: AdapterEvent, hint: AnthropicPrefixHint, generation: object = hint): void {
locallyIssued.set(hint, generation); observations.set(event, hint);
}
export function isLocallyIssuedAnthropicPrefixHint(hint: AnthropicPrefixHint, generation?: object): boolean { return generation ? locallyIssued.get(hint) === generation : locallyIssued.has(hint); }
135 changes: 135 additions & 0 deletions src/adapters/anthropic-prefix-observer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { AdapterEvent, AnthropicPrefixHint } from "../types";
import { isTranslatorBudgetExceededError, type TranslatorBudget, type TranslatorTransientReservation } from "../lib/translator-budget";
import { jsonUtf8Bytes } from "../lib/json-byte-size";
import { appendAnthropicPrefix, hintForAnthropicBlock, nextAnthropicParent, takeAnthropicPrefixSeed } from "./anthropic-prefix";
import { issueAnthropicPrefixHint } from "./anthropic-prefix-hints";

type Block = Record<string, unknown>;
/** Bound parsed JSON storage before allocation, without copying or validating its tokens. */
function parsedJsonBytes(source: string): number {
let bytes = source.length * 2 + 64;
let quoted = false, escaped = false, scalar = false;
for (let index = 0; index < source.length; index++) {
const code = source.charCodeAt(index);
if (quoted) {
if (escaped) escaped = false;
else if (code === 92) escaped = true;
else if (code === 34) quoted = false;
continue;
}
if (code === 34) { bytes += 64; quoted = true; scalar = false; }
else if (code === 123 || code === 91) { bytes += 64; scalar = false; }
else if (code === 44 || code === 58) { bytes += code === 44 ? 16 : 32; scalar = false; }
else if (code === 125 || code === 93 || code === 32 || code === 9 || code === 10 || code === 13) scalar = false;
else if (!scalar) { bytes += 64; scalar = true; }
}
return bytes;
}
/** Incremental observation of a complete block, never an attestation of response success. */
export function createAnthropicPrefixObserver(response: Response, budget: TranslatorBudget) {
const seed = takeAnthropicPrefixSeed(response);
if (!seed) return;
seed.budget = budget;
let valid = true, started = false, index = 0, bytes = 0;
let clientRepresentable = true;
let block: Block | undefined;
let text: string[] = [], args: string[] = [];
let textLength = 0, argsLength = 0, signatureBytes = 0, hasArgs = false;
const release = () => {
budget.releaseRetained(bytes, { kind: "reasoning" }); bytes = 0; block = undefined; text = []; args = [];
textLength = 0; argsLength = 0; signatureBytes = 0; hasArgs = false;
};
const invalidate = () => { valid = false; release(); };
const retain = (size: number) => { budget.chargeRetained(size, { kind: "reasoning" }); bytes += size; };
const append = (parts: string[], value: string) => {
// Only the payload survives a delta frame. Bound each nonempty string and array slot;
// empty deltas need neither a retained value nor another slot.
if (value.length === 0) return;
retain(value.length * 2 + 48); parts.push(value);
};
return {
invalidate,
accept(data: Block): { event: AdapterEvent; hint: AnthropicPrefixHint; index: number } | undefined {
if (!valid) return;
try {
const type = data.type;
if (type === "ping" || type === "message_delta") return;
if (type === "message_start") {
const message = data.message as Block | undefined;
if (started || (Array.isArray(message?.content) && message.content.length > 0)) invalidate();
else started = true;
return;
}
if (!started) { invalidate(); return; }
if (type === "content_block_start") {
const incoming = data.content_block as Block | undefined;
if (block || data.index !== index || !incoming || !["text", "thinking", "redacted_thinking", "tool_use"].includes(String(incoming.type))) { invalidate(); return; }
if (incoming.type === "thinking" && Object.keys(incoming).some(key => !["type", "thinking", "signature"].includes(key))) clientRepresentable = false;
if (incoming.type === "redacted_thinking" && Object.keys(incoming).some(key => !["type", "data"].includes(key))) clientRepresentable = false;
retain(jsonUtf8Bytes(incoming) * 4 + 64);
// This portion of the admitted initial block belongs to its replaceable signature.
signatureBytes = incoming.type === "thinking" && incoming.signature !== undefined ? jsonUtf8Bytes(incoming.signature) * 4 : 0;
block = { ...incoming, ...(Array.isArray(incoming.citations) ? { citations: [...incoming.citations] } : {}) };
if (block.type === "text" || block.type === "thinking") {
const initial = block.type === "text" ? block.text : block.thinking;
if (typeof initial !== "string") { invalidate(); return; }
if (initial.length) { retain(16); text = [initial]; }
textLength = initial.length;
}
} else if (type === "content_block_delta") {
const delta = data.delta as Block | undefined;
if (!block || data.index !== index || !delta) { invalidate(); return; }
if (delta.type === "text_delta" && block.type === "text" && typeof delta.text === "string") { append(text, delta.text); textLength += delta.text.length; }
else if (delta.type === "thinking_delta" && block.type === "thinking" && typeof delta.thinking === "string") { append(text, delta.thinking); textLength += delta.thinking.length; }
else if (delta.type === "signature_delta" && block.type === "thinking" && typeof delta.signature === "string") {
const nextBytes = jsonUtf8Bytes(delta.signature) * 4;
// Admit growth before changing ownership. A refusal must never refund a value
// that was not admitted, or release another reasoning owner's charge.
if (nextBytes > signatureBytes) retain(nextBytes - signatureBytes);
block.signature = delta.signature;
if (nextBytes < signatureBytes) { budget.releaseRetained(signatureBytes - nextBytes, { kind: "reasoning" }); bytes -= signatureBytes - nextBytes; }
signatureBytes = nextBytes;
} else if (delta.type === "input_json_delta" && block.type === "tool_use" && typeof delta.partial_json === "string") {
append(args, delta.partial_json); argsLength += delta.partial_json.length; hasArgs = true;
} else if (delta.type === "citations_delta" && block.type === "text" && delta.citation && typeof delta.citation === "object") {
if (block.citations !== undefined && !Array.isArray(block.citations)) { invalidate(); return; }
retain(jsonUtf8Bytes(delta.citation) * 4 + 64);
(block.citations ??= [] as unknown[] as unknown);
(block.citations as unknown[]).push(delta.citation);
} else invalidate();
} else if (type === "content_block_stop") {
if (!block || data.index !== index) { invalidate(); return; }
let reservation: TranslatorTransientReservation | undefined, parsedReservation: TranslatorTransientReservation | undefined;
try {
// Fragment headers/signature snapshots are not copied. Admit joined strings, then
// parsed token/container storage; hashing owns its bounded canonical scratch.
reservation = budget.reserveTransient((textLength + argsLength) * 2 + 64, { kind: "reasoning" });
if (block.type === "text") block.text = text.join("");
if (block.type === "thinking") block.thinking = text.join("");
if (block.type === "tool_use" && hasArgs) {
const joined = args.join("");
parsedReservation = budget.reserveTransient(parsedJsonBytes(joined), { kind: "reasoning" });
block.input = JSON.parse(joined);
}
let event: AdapterEvent | undefined;
if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) event = { type: "thinking_signature", signature: block.signature };
else if (block.type === "redacted_thinking" && typeof block.data === "string") event = { type: "redacted_thinking", data: block.data };
else if (block.type === "thinking" || block.type === "redacted_thinking") { invalidate(); return; }
if (event) {
const hint = hintForAnthropicBlock(seed, block);
// Responses currently splits text-before-thinking into separate assistant items.
// Preserve the payload as legacy rather than issuing a hint it cannot round-trip.
issueAnthropicPrefixHint(clientRepresentable ? event : { type: "heartbeat" }, hint, seed.generation);
seed.parent = nextAnthropicParent(seed.parent, block, budget);
return { event, hint, index: index++ };
}
appendAnthropicPrefix(seed.hash, block, budget); clientRepresentable = false; index++;
} catch (error) { invalidate(); if (isTranslatorBudgetExceededError(error)) throw error; }
finally { parsedReservation?.release(); reservation?.release(); release(); }
} else if (type === "message_stop") { if (block) invalidate(); }
else invalidate();
} catch (error) { invalidate(); throw error; }
},
dispose: invalidate,
};
}
Loading
Loading