Skip to content

Commit 2dd2d87

Browse files
authored
Merge pull request #92 from pavelhov/codex/fix-opus-turn-output
fix: restore native Opus limits and thinking continuity
2 parents 76bcb45 + 38b11cc commit 2dd2d87

20 files changed

Lines changed: 1235 additions & 73 deletions

‎docs-site/src/content/docs/reference/architecture.md‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ understands:
102102
| `done` | `response.completed` (with usage) |
103103
| `error` | `response.failed` (with `last_error`) |
104104

105+
Anthropic thinking signatures and redacted blocks survive tool-result continuation even when
106+
their thinking text is empty. Each signed block keeps its own text/signature pairing in both
107+
JSON and streaming responses. Opaque reasoning from other providers is not converted into
108+
invented Anthropic thinking text. Automatic terminal-guard continuations preserve the same
109+
block order. An unfinished thinking block fails instead of completing with unusable replay state.
110+
105111
The bridge also runs a **heartbeat keep-alive** (RC3): during upstream silence, it emits a
106112
parser-ignored `response.heartbeat` SSE event every 2 seconds to re-arm Codex's idle timer. The
107113
default **stall deadline** is 300 seconds (`stallTimeoutSec`); reaching it aborts the upstream and emits
@@ -112,12 +118,18 @@ Anthropic SSE comments and `ping` events count as upstream activity, including w
112118
thinking during context compaction. Proxy-generated keep-alives do not reset the stall deadline;
113119
an upstream connection that stops sending activity still times out.
114120

115-
For Opus 5.5 through the canonical Anthropic API, routed context compaction uses at most
121+
For Opus 5.5 through the canonical Anthropic API, ordinary routed turns default to a
122+
128,000-token total output allowance when the caller omits a limit. This uses the model's
123+
[documented capacity for long coding turns](https://platform.claude.com/docs/en/build-with-claude/prompt-engineering/prompting-claude-opus-5-5#calibrate-effort)
124+
and preserves the requested reasoning effort. Adaptive thinking and answer text share the
125+
allowance; effort does not enforce a separate thinking budget. Explicit limits remain
126+
authoritative, and other models and custom Anthropic-compatible endpoints retain their defaults.
127+
128+
Routed context compaction for the same model and endpoint uses at most
116129
`medium` reasoning effort and defaults to a 64,000-token total output limit when the caller
117130
omits one. Adaptive thinking and summary text share that limit; effort does not reserve a
118131
fixed number of tokens for the summary. Explicit output limits and lower reasoning efforts
119-
are preserved. This maintenance policy applies to both compaction endpoints; ordinary work
120-
keeps its requested effort and existing defaults. A truncated summary still fails instead
132+
are preserved. This maintenance policy applies to both compaction endpoints. A truncated summary still fails instead
121133
of replacing the conversation history, and the proxy does not automatically retry it.
122134
The older compact endpoint streams routed Anthropic generation internally, then returns one
123135
JSON response to its caller. This lets upstream pings keep a long summary alive without
@@ -246,6 +258,12 @@ history, while `responses/parser.ts` and `bridge.ts` handle remote compaction v2
246258
[subagent models](/guides/codex-integration/#the-subagent-picker) first, filters
247259
`disabledModels`, and can fully restore the pristine catalog from a one-time backup.
248260

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

251269
`reasoning-effort.ts` translates Codex's reasoning labels into each provider's wire values. The

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codexcommander",
3-
"version": "0.1.24",
3+
"version": "0.1.25",
44
"private": true,
55
"description": "CodexCommander — universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
66
"type": "module",

‎src/adapters/anthropic.ts‎

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { IncomingMeta, ProviderAdapter } from "./base";
22
import { debugDroppedFrame } from "../lib/debug";
3+
import { isCanonicalAnthropicEndpoint } from "../lib/anthropic-endpoint";
34
import type {
45
AdapterEvent,
56
CodexCommanderAssistantMessage,
@@ -43,6 +44,8 @@ const REASONING_MAX_TOKENS_CEILING = 32_000;
4344
/** Ordinary adaptive-thinking sizing: heuristic max effort allowance (32k) + 8192.
4445
* Adaptive effort is soft guidance, so this total does not reserve a hard visible-output portion. */
4546
const ADAPTIVE_THINKING_CEILING = 40_192;
47+
/** Documented total-token allowance for long native Opus 5.5 agent turns. */
48+
const OPUS_5_5_MAX_TOKENS = 128_000;
4649
/** Bounded thinking + summary total for native Opus 5.5 maintenance compactions. */
4750
const OPUS_5_5_COMPACTION_MAX_TOKENS = 64_000;
4851
/** Anthropic's documented minimum `thinking.budget_tokens`. */
@@ -854,20 +857,22 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
854857
enforceAnthropicImageLimits(messages);
855858
const tools = toolsToAnthropicFormat(parsed, toolNames);
856859

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

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

994999
const budgetEncoder = new TextEncoder();
9951000
let currentBlockType = "";
1001+
// Accumulated signature_delta fragments for the open thinking block; emitted once, whole,
1002+
// at content_block_stop so downstream sees exactly one signature per signed block.
1003+
let currentThinkingSignature = "";
1004+
// Retained bytes charged to the translator budget for currentThinkingSignature. The SSE
1005+
// decoder releases its per-frame charge, so the cross-frame collector charges itself.
1006+
let currentThinkingSignatureBytes = 0;
1007+
const resetThinkingSignature = (): void => {
1008+
if (currentThinkingSignatureBytes > 0) {
1009+
budget.releaseRetained(currentThinkingSignatureBytes, { kind: "reasoning" });
1010+
}
1011+
currentThinkingSignature = "";
1012+
currentThinkingSignatureBytes = 0;
1013+
};
9961014
let currentToolCallId = "";
9971015
let currentToolCallName = "";
9981016
let currentToolCallJson = "";
@@ -1044,7 +1062,12 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
10441062
case "content_block_start": {
10451063
const block = data.content_block as { type: string; id?: string; name?: string; data?: string } | undefined;
10461064
if (!block) break;
1065+
if (currentBlockType === "thinking" || currentBlockType === "reasoning") {
1066+
yield { type: "error", message: "Anthropic stream started a new block before thinking content_block_stop — possible truncation" };
1067+
return;
1068+
}
10471069
currentBlockType = block.type;
1070+
resetThinkingSignature();
10481071
if (block.type === "tool_use") {
10491072
currentToolCallId = usableToolUseId(block.id);
10501073
currentToolCallName = toolNames.fromWire(block.name ?? "");
@@ -1075,9 +1098,20 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
10751098
// later text blocks independent.
10761099
yield { type: "thinking_delta", thinking: delta.reasoning };
10771100
} else if (delta.type === "signature_delta" && typeof delta.signature === "string" && (currentBlockType === "thinking" || currentBlockType === "reasoning")) {
1078-
// Arrives once, just before the thinking block's content_block_stop; block-scoped
1079-
// so a stray signature on a non-thinking block can never be captured.
1080-
yield { type: "thinking_signature", signature: delta.signature };
1101+
// Block-scoped so a stray signature on a non-thinking block can never be
1102+
// captured. Fragments are concatenated and released at content_block_stop.
1103+
const previousBytes = currentThinkingSignatureBytes;
1104+
const nextBytes = previousBytes + budgetEncoder.encode(delta.signature).byteLength;
1105+
const reservation = budget.reserveTransient(nextBytes, { kind: "reasoning" });
1106+
try {
1107+
currentThinkingSignature += delta.signature;
1108+
currentThinkingSignatureBytes = nextBytes;
1109+
reservation.commitRetained();
1110+
if (previousBytes > 0) budget.releaseRetained(previousBytes, { kind: "reasoning" });
1111+
} catch (error) {
1112+
reservation.release();
1113+
throw error;
1114+
}
10811115
} else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string" && currentBlockType === "tool_use") {
10821116
// Forwarded immediately: the bridge maps each delta to a client-visible
10831117
// response.function_call_arguments.delta frame, so withholding fragments until
@@ -1099,6 +1133,12 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
10991133
break;
11001134
}
11011135
case "content_block_stop": {
1136+
if ((currentBlockType === "thinking" || currentBlockType === "reasoning") && currentThinkingSignature) {
1137+
// A signature is only valid for its completed block; a block cut off before
1138+
// its stop never yields a partial, unreplayable signature.
1139+
yield { type: "thinking_signature", signature: currentThinkingSignature };
1140+
}
1141+
resetThinkingSignature();
11021142
if (currentBlockType === "tool_use") {
11031143
// The non-stream path repairs an unparseable payload in toolUseArguments(); the
11041144
// stream cannot, because the fragments are already downstream. Fail the turn
@@ -1128,6 +1168,10 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
11281168
break;
11291169
}
11301170
case "message_stop": {
1171+
if (currentBlockType === "thinking" || currentBlockType === "reasoning") {
1172+
yield { type: "error", message: "Anthropic stream ended before thinking content_block_stop — possible truncation" };
1173+
return;
1174+
}
11311175
yield* emitDone();
11321176
break;
11331177
}
@@ -1153,8 +1197,13 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
11531197
return;
11541198
} finally {
11551199
if (currentToolCallId) budget.closeCall(currentToolCallId);
1200+
resetThinkingSignature();
11561201
}
11571202
if (!emittedDone) {
1203+
if (currentBlockType === "thinking" || currentBlockType === "reasoning") {
1204+
yield { type: "error", message: "Anthropic stream ended before thinking content_block_stop — possible truncation" };
1205+
return;
1206+
}
11581207
// Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason.
11591208
if (pendingStopReason !== undefined) {
11601209
const stopReason = pendingStopReason === "max_tokens"

‎src/bridge.ts‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,7 @@ export function bridgeToResponsesSSE(
900900
}
901901
case "text_delta": {
902902
if (currentReasoning) closeCurrentReasoning();
903+
flushHiddenReasoningEnvelope();
903904
if (currentRawReasoning) closeCurrentRawReasoning();
904905
flushHiddenRawReasoning();
905906
// Reasoning consumed by a text turn, not a tool call: no cache target.
@@ -963,10 +964,13 @@ export function bridgeToResponsesSSE(
963964
case "thinking_signature": {
964965
pendingSignatureBytes = replaceRetainedString(pendingSignatureBytes, event.signature, "reasoning");
965966
pendingSignature = event.signature;
966-
// Signature arrives at the end of the thinking block. With a visible reasoning item
967-
// open, closeCurrentReasoning attaches the envelope; hidden/suppressed blocks flush
968-
// an envelope-only reasoning item now.
969-
if (!currentReasoning) flushHiddenReasoningEnvelope();
967+
// The adapter emits one complete signature per thinking block, just before its
968+
// content_block_stop, so it marks the block boundary. Close the visible item now
969+
// (matching buildResponseJSON): otherwise an adjacent thinking block would append
970+
// to this item and its text would be paired with only the later signature.
971+
// Hidden/suppressed blocks flush an envelope-only reasoning item.
972+
if (currentReasoning) closeCurrentReasoning();
973+
else flushHiddenReasoningEnvelope();
970974
break;
971975
}
972976
case "redacted_thinking": {
@@ -1023,6 +1027,7 @@ export function bridgeToResponsesSSE(
10231027
case "tool_call_start": {
10241028
if (currentMsg) closeCurrentMessage("commentary");
10251029
if (currentReasoning) closeCurrentReasoning();
1030+
flushHiddenReasoningEnvelope();
10261031
if (currentRawReasoning) closeCurrentRawReasoning();
10271032
flushHiddenRawReasoning();
10281033
if (rawReasoningForNextToolCall) {
@@ -1663,7 +1668,7 @@ function buildResponseJSONWithBudget(
16631668
// Only flush on an explicit phase change. A later delta that omits `phase` must keep
16641669
// appending under the previously established phase.
16651670
if (currentText && e.phase !== undefined && currentTextPhase !== e.phase) flushText("commentary");
1666-
if (currentSummaryReasoning) flushSummaryReasoning();
1671+
flushSummaryReasoning();
16671672
if (currentRawReasoning) flushRawReasoning();
16681673
rawReasoningForNextToolCall = "";
16691674
if (currentToolCallId) flushToolCall();
@@ -1736,7 +1741,7 @@ function buildResponseJSONWithBudget(
17361741
break;
17371742
case "tool_call_start":
17381743
if (currentText) flushText("commentary");
1739-
if (currentSummaryReasoning) flushSummaryReasoning();
1744+
flushSummaryReasoning();
17401745
if (currentRawReasoning) flushRawReasoning();
17411746
if (rawReasoningForNextToolCall) {
17421747
rememberReasoningForCall(e.id, rawReasoningForNextToolCall, replayCacheScope);

‎src/codex/catalog/provider-fetch.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -879,7 +879,24 @@ function modelInputModalities(
879879
return undefined;
880880
}
881881

882-
export function catalogHintsFromModelsApiItem(providerName: string, item: ProviderModelsApiItem): Partial<CatalogModel> {
882+
function isOfficialAnthropicModelsApi(source: { adapter: string; url: string } | undefined): boolean {
883+
if (source?.adapter !== "anthropic") return false;
884+
try {
885+
const url = new URL(source.url);
886+
return url.origin === "https://api.anthropic.com"
887+
&& url.pathname === "/v1/models"
888+
&& !url.username
889+
&& !url.password;
890+
} catch {
891+
return false;
892+
}
893+
}
894+
895+
export function catalogHintsFromModelsApiItem(
896+
providerName: string,
897+
item: ProviderModelsApiItem,
898+
source?: { adapter: string; url: string },
899+
): Partial<CatalogModel> {
883900
const metadata = plainRecord(item.metadata);
884901
const capabilityRecord = plainRecord(metadata?.capabilities) ?? plainRecord(item.capabilities);
885902
const limits = plainRecord(metadata?.limits);
@@ -891,7 +908,11 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
891908
item.context_size,
892909
item.max_model_len,
893910
item.max_context_length,
894-
);
911+
)
912+
// Anthropic defines this field as the model's maximum input context window.
913+
// Other APIs use it as a separate input ceiling, so require actual source evidence.
914+
// https://platform.claude.com/docs/en/api/models/list
915+
?? (isOfficialAnthropicModelsApi(source) ? positiveSafeInteger(item.max_input_tokens) : undefined);
895916
const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
896917
const rawReasoningEfforts = capabilityRecord?.reasoning_effort ?? item.reasoning_efforts;
897918
const listedReasoningEfforts = normalizedStringList(rawReasoningEfforts, 8, 24);
@@ -1137,7 +1158,7 @@ async function fetchProviderModelsWithAuth(
11371158
id: m.id,
11381159
provider: name,
11391160
...(ownedBy ? { owned_by: ownedBy } : {}),
1140-
...catalogHintsFromModelsApiItem(name, m),
1161+
...catalogHintsFromModelsApiItem(name, m, { adapter: prov.adapter, url: request.url }),
11411162
}, contextCap);
11421163
})
11431164
.filter(m => shouldExposeProviderModel(name, m.id));

‎src/lib/anthropic-endpoint.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
const NATIVE_ANTHROPIC_ORIGIN = "https://api.anthropic.com";
2+
3+
/** Canonical API root for supported native Anthropic base URLs; never rewrites custom destinations. */
4+
export function canonicalAnthropicApiRoot(baseUrl: string): string | undefined {
5+
try {
6+
const url = new URL(baseUrl);
7+
if (url.origin !== NATIVE_ANTHROPIC_ORIGIN
8+
|| url.username
9+
|| url.password
10+
|| /[?#]/.test(url.href)
11+
|| !/^(?:\/v1(?:\/messages)?)?$/i.test(url.pathname.replace(/\/+$/, ""))) {
12+
return undefined;
13+
}
14+
return NATIVE_ANTHROPIC_ORIGIN;
15+
} catch {
16+
return undefined;
17+
}
18+
}
19+
20+
/** Shared native endpoint boundary for model budgets and catalog metadata. */
21+
export function isCanonicalAnthropicEndpoint(baseUrl: string): boolean {
22+
return canonicalAnthropicApiRoot(baseUrl) !== undefined;
23+
}

0 commit comments

Comments
 (0)