Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions docs-site/src/content/docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ understands:
| `done` | `response.completed` (with usage) |
| `error` | `response.failed` (with `last_error`) |

Anthropic thinking signatures and redacted blocks survive tool-result continuation even when
their thinking text is empty. Each signed block keeps its own text/signature pairing in both
JSON and streaming responses. Opaque reasoning from other providers is not converted into
invented Anthropic thinking text. Automatic terminal-guard continuations preserve the same
block order. An unfinished thinking block fails instead of completing with unusable replay state.

The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits a
parser-ignored `response.heartbeat` SSE event every 2 seconds to re-arm Codex's idle timer. The
default **stall deadline** is 300 seconds (`stallTimeoutSec`); reaching it aborts the upstream and emits
Expand All @@ -112,12 +118,18 @@ Anthropic SSE comments and `ping` events count as upstream activity, including w
thinking during context compaction. Proxy-generated keep-alives do not reset the stall deadline;
an upstream connection that stops sending activity still times out.

For Opus 5.5 through the canonical Anthropic API, routed context compaction uses at most
For Opus 5.5 through the canonical Anthropic API, ordinary routed turns default to a
128,000-token total output allowance when the caller omits a limit. This uses the model's
[documented capacity for long coding turns](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-5-5#calibrate-effort)
and preserves the requested reasoning effort. Adaptive thinking and answer text share the
allowance; effort does not enforce a separate thinking budget. Explicit limits remain
authoritative, and other models and custom Anthropic-compatible endpoints retain their defaults.

Routed context compaction for the same model and endpoint uses at most
`medium` reasoning effort and defaults to a 64,000-token total output limit when the caller
omits one. Adaptive thinking and summary text share that limit; effort does not reserve a
fixed number of tokens for the summary. Explicit output limits and lower reasoning efforts
are preserved. This maintenance policy applies to both compaction endpoints; ordinary work
keeps its requested effort and existing defaults. A truncated summary still fails instead
are preserved. This maintenance policy applies to both compaction endpoints. A truncated summary still fails instead
of replacing the conversation history, and the proxy does not automatically retry it.
The older compact endpoint streams routed Anthropic generation internally, then returns one
JSON response to its caller. This lets upstream pings keep a long summary alive without
Expand Down Expand Up @@ -246,6 +258,12 @@ history, while `responses/parser.ts` and `bridge.ts` handle remote compaction v2
[subagent models](/guides/codex-integration/#the-subagent-picker) first, filters
`disabledModels`, and can fully restore the pristine catalog from a one-time backup.

Canonical Anthropic model discovery recognizes `max_input_tokens` as the input context window.
Native Opus 5.5 also has a known 1,000,000-token context fallback when live discovery is unavailable;
the default Codex compaction threshold is 900,000. Explicit context limits remain authoritative,
and custom destinations do not inherit native Anthropic context assumptions. Existing Codex
processes must reload the refreshed catalog before using its larger window.

## Reasoning effort

`reasoning-effort.ts` translates Codex's reasoning labels into each provider's wire values. The
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codexcommander",
"version": "0.1.24",
"version": "0.1.25",
"private": true,
"description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
"type": "module",
Expand Down
75 changes: 62 additions & 13 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { IncomingMeta, ProviderAdapter } from "./base";
import { debugDroppedFrame } from "../lib/debug";
import { isCanonicalAnthropicEndpoint } from "../lib/anthropic-endpoint";
import type {
AdapterEvent,
CodexCommanderAssistantMessage,
Expand Down Expand Up @@ -43,6 +44,8 @@ const REASONING_MAX_TOKENS_CEILING = 32_000;
/** Ordinary adaptive-thinking sizing: heuristic max effort allowance (32k) + 8192.
* Adaptive effort is soft guidance, so this total does not reserve a hard visible-output portion. */
const ADAPTIVE_THINKING_CEILING = 40_192;
/** Documented total-token allowance for long native Opus 5.5 agent turns. */
const OPUS_5_5_MAX_TOKENS = 128_000;
/** Bounded thinking + summary total for native Opus 5.5 maintenance compactions. */
const OPUS_5_5_COMPACTION_MAX_TOKENS = 64_000;
/** Anthropic's documented minimum `thinking.budget_tokens`. */
Expand Down Expand Up @@ -854,20 +857,22 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
enforceAnthropicImageLimits(messages);
const tools = toolsToAnthropicFormat(parsed, toolNames);

// Compaction is a handoff summary, not another maximum-effort agent turn. Opus 5.5
// always thinks; adaptive effort has no fixed thinking budget and can consume the
// ordinary 40192-token total before finishing its summary. Keep this policy local
// to the known model and native endpoint: compatible gateways may have lower limits.
const version = parsed._compactionRequest === true ? claudeFamilyVersion(parsed.modelId) : undefined;
const opusCompaction = version?.family === "opus" && version.major === 5 && version.minor === 5
&& usesNativeAnthropicEndpoint(provider);
// Adaptive effort has no fixed thinking reservation: the legacy effort heuristic
// can exhaust its shared total before an ordinary Opus 5.5 turn finishes. Use the
// documented long-turn allowance only for this known model and native endpoint;
// compatible gateways may have lower limits. Compaction keeps its smaller summary
// allowance and effort cap, while an explicit caller total always wins.
const version = claudeFamilyVersion(parsed.modelId);
const nativeOpus55 = version?.family === "opus" && version.major === 5 && version.minor === 5
&& isCanonicalAnthropicEndpoint(provider.baseUrl);
const opusCompaction = nativeOpus55 && parsed._compactionRequest === true;
const requestedEffort = parsed.options.reasoning;
const reasoningEffort = opusCompaction
&& requestedEffort !== "none" && requestedEffort !== "minimal" && requestedEffort !== "low"
? "medium"
: requestedEffort;
const maxOutputTokens = parsed.options.maxOutputTokens
?? (opusCompaction ? OPUS_5_5_COMPACTION_MAX_TOKENS : undefined);
?? (nativeOpus55 ? (opusCompaction ? OPUS_5_5_COMPACTION_MAX_TOKENS : OPUS_5_5_MAX_TOKENS) : undefined);
// Do not mutate parsed.options: requested-effort diagnostics describe the client.
// Anthropic has no reasoningLog field for output_config.effort in the shared contract.

Expand Down Expand Up @@ -912,8 +917,8 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
const explicitMaxOut = maxOutputTokens;
const wantBudget = reasoningBudget(reasoningEffort);
const floor = wantBudget + OUTPUT_HEADROOM;
// Preserve caller limits and the scoped compaction total. Ordinary omitted limits
// use heuristic sizing; adaptive thinking does not enforce wantBudget separately.
// Preserve caller limits and scoped model totals. Other omitted limits use
// heuristic sizing; adaptive thinking does not enforce wantBudget separately.
body.max_tokens = explicitMaxOut !== undefined
? explicitMaxOut
: Math.min(ADAPTIVE_THINKING_CEILING, Math.max(DEFAULT_MAX_TOKENS, floor));
Expand Down Expand Up @@ -993,6 +998,19 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c

const budgetEncoder = new TextEncoder();
let currentBlockType = "";
// Accumulated signature_delta fragments for the open thinking block; emitted once, whole,
// at content_block_stop so downstream sees exactly one signature per signed block.
let currentThinkingSignature = "";
// Retained bytes charged to the translator budget for currentThinkingSignature. The SSE
// decoder releases its per-frame charge, so the cross-frame collector charges itself.
let currentThinkingSignatureBytes = 0;
const resetThinkingSignature = (): void => {
if (currentThinkingSignatureBytes > 0) {
budget.releaseRetained(currentThinkingSignatureBytes, { kind: "reasoning" });
}
currentThinkingSignature = "";
currentThinkingSignatureBytes = 0;
};
let currentToolCallId = "";
let currentToolCallName = "";
let currentToolCallJson = "";
Expand Down Expand Up @@ -1044,7 +1062,12 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
case "content_block_start": {
const block = data.content_block as { type: string; id?: string; name?: string; data?: string } | undefined;
if (!block) break;
if (currentBlockType === "thinking" || currentBlockType === "reasoning") {
yield { type: "error", message: "Anthropic stream started a new block before thinking content_block_stop — possible truncation" };
return;
}
currentBlockType = block.type;
resetThinkingSignature();
if (block.type === "tool_use") {
currentToolCallId = usableToolUseId(block.id);
currentToolCallName = toolNames.fromWire(block.name ?? "");
Expand Down Expand Up @@ -1075,9 +1098,20 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
// later text blocks independent.
yield { type: "thinking_delta", thinking: delta.reasoning };
} else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) {
// Arrives once, just before the thinking block's content_block_stop; block-scoped
// so a stray signature on a non-thinking block can never be captured.
yield { type: "thinking_signature", signature: delta.signature };
// Block-scoped so a stray signature on a non-thinking block can never be
// captured. Fragments are concatenated and released at content_block_stop.
const previousBytes = currentThinkingSignatureBytes;
const nextBytes = previousBytes + budgetEncoder.encode(delta.signature).byteLength;
const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" });
try {
currentThinkingSignature += delta.signature;
currentThinkingSignatureBytes = nextBytes;
reservation.commitRetained();
if (previousBytes > 0) budget.releaseRetained(previousBytes, { kind: "reasoning" });
} catch (error) {
reservation.release();
throw error;
}
} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") {
// Forwarded immediately: the bridge maps each delta to a client-visible
// response.function_call_arguments.delta frame, so withholding fragments until
Expand All @@ -1099,6 +1133,12 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
break;
}
case "content_block_stop": {
if ((currentBlockType === "thinking" || currentBlockType === "reasoning") && currentThinkingSignature) {
// A signature is only valid for its completed block; a block cut off before
// its stop never yields a partial, unreplayable signature.
yield { type: "thinking_signature", signature: currentThinkingSignature };
}
resetThinkingSignature();
if (currentBlockType === "tool_use") {
// The non-stream path repairs an unparseable payload in toolUseArguments(); the
// stream cannot, because the fragments are already downstream. Fail the turn
Expand Down Expand Up @@ -1128,6 +1168,10 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
break;
}
case "message_stop": {
if (currentBlockType === "thinking" || currentBlockType === "reasoning") {
yield { type: "error", message: "Anthropic stream ended before thinking content_block_stop — possible truncation" };
return;
}
yield* emitDone();
break;
}
Expand All @@ -1153,8 +1197,13 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
return;
} finally {
if (currentToolCallId) budget.closeCall(currentToolCallId);
resetThinkingSignature();
}
if (!emittedDone) {
if (currentBlockType === "thinking" || currentBlockType === "reasoning") {
yield { type: "error", message: "Anthropic stream ended before thinking content_block_stop — possible truncation" };
return;
}
// Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
if (pendingStopReason !== undefined) {
const stopReason = pendingStopReason === "max_tokens"
Expand Down
17 changes: 11 additions & 6 deletions src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -900,6 +900,7 @@ export function bridgeToResponsesSSE(
}
case "text_delta": {
if (currentReasoning) closeCurrentReasoning();
flushHiddenReasoningEnvelope();
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
// Reasoning consumed by a text turn, not a tool call: no cache target.
Expand Down Expand Up @@ -963,10 +964,13 @@ export function bridgeToResponsesSSE(
case "thinking_signature": {
pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning");
pendingSignature = event.signature;
// Signature arrives at the end of the thinking block. With a visible reasoning item
// open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush
// an envelope-only reasoning item now.
if (!currentReasoning) flushHiddenReasoningEnvelope();
// The adapter emits one complete signature per thinking block, just before its
// content_block_stop, so it marks the block boundary. Close the visible item now
// (matching buildResponseJSON): otherwise an adjacent thinking block would append
// to this item and its text would be paired with only the later signature.
// Hidden/suppressed blocks flush an envelope-only reasoning item.
if (currentReasoning) closeCurrentReasoning();
else flushHiddenReasoningEnvelope();
break;
}
case "redacted_thinking": {
Expand Down Expand Up @@ -1023,6 +1027,7 @@ export function bridgeToResponsesSSE(
case "tool_call_start": {
if (currentMsg) closeCurrentMessage("commentary");
if (currentReasoning) closeCurrentReasoning();
flushHiddenReasoningEnvelope();
if (currentRawReasoning) closeCurrentRawReasoning();
flushHiddenRawReasoning();
if (rawReasoningForNextToolCall) {
Expand Down Expand Up @@ -1663,7 +1668,7 @@ function buildResponseJSONWithBudget(
// Only flush on an explicit phase change. A later delta that omits `phase` must keep
// appending under the previously established phase.
if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
flushSummaryReasoning();
if (currentRawReasoning) flushRawReasoning();
rawReasoningForNextToolCall = "";
if (currentToolCallId) flushToolCall();
Expand Down Expand Up @@ -1736,7 +1741,7 @@ function buildResponseJSONWithBudget(
break;
case "tool_call_start":
if (currentText) flushText("commentary");
if (currentSummaryReasoning) flushSummaryReasoning();
flushSummaryReasoning();
if (currentRawReasoning) flushRawReasoning();
if (rawReasoningForNextToolCall) {
rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);
Expand Down
27 changes: 24 additions & 3 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -879,7 +879,24 @@ function modelInputModalities(
return undefined;
}

export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
function isOfficialAnthropicModelsApi(source: { adapter: string; url: string } | undefined): boolean {
if (source?.adapter !== "anthropic") return false;
try {
const url = new URL(source.url);
return url.origin === "https://api.anthropic.com"
&& url.pathname === "/v1/models"
&& !url.username
&& !url.password;
} catch {
return false;
}
}

export function catalogHintsFromModelsApiItem(
providerName: string,
item: ProviderModelsApiItem,
source?: { adapter: string; url: string },
): Partial<CatalogModel> {
const metadata = plainRecord(item.metadata);
const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
const limits = plainRecord(metadata?.limits);
Expand All @@ -891,7 +908,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
item.context_size,
item.max_model_len,
item.max_context_length,
);
)
// Anthropic defines this field as the model's maximum input context window.
// Other APIs use it as a separate input ceiling, so require actual source evidence.
// https://platform.claude.com/docs/en/api/models/list
?? (isOfficialAnthropicModelsApi(source) ? positiveSafeInteger(item.max_input_tokens) : undefined);
const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts;
const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24);
Expand Down Expand Up @@ -1137,7 +1158,7 @@ async function fetchProviderModelsWithAuth(
id: m.id,
provider: name,
...(ownedBy ? { owned_by: ownedBy } : {}),
...catalogHintsFromModelsApiItem(name, m),
...catalogHintsFromModelsApiItem(name, m, { adapter: prov.adapter, url: request.url }),
}, contextCap);
})
.filter(m => shouldExposeProviderModel(name, m.id));
Expand Down
23 changes: 23 additions & 0 deletions src/lib/anthropic-endpoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
const NATIVE_ANTHROPIC_ORIGIN = "https://api.anthropic.com";

/** Canonical API root for supported native Anthropic base URLs; never rewrites custom destinations. */
export function canonicalAnthropicApiRoot(baseUrl: string): string | undefined {
try {
const url = new URL(baseUrl);
if (url.origin !== NATIVE_ANTHROPIC_ORIGIN
|| url.username
|| url.password
|| /[?#]/.test(url.href)
|| !/^(?:\/v1(?:\/messages)?)?$/i.test(url.pathname.replace(/\/+$/, ""))) {
return undefined;
}
return NATIVE_ANTHROPIC_ORIGIN;
} catch {
return undefined;
}
}

/** Shared native endpoint boundary for model budgets and catalog metadata. */
export function isCanonicalAnthropicEndpoint(baseUrl: string): boolean {
return canonicalAnthropicApiRoot(baseUrl) !== undefined;
}
Loading
Loading