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
11 changes: 11 additions & 0 deletions docs-site/src/content/docs/reference/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,17 @@ 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
`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
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
extending the connection-header timeout or removing the silence watchdog.

Tool calls are disambiguated into three Responses item types using the namespace map, the freeform
set, and the tool-search set captured by the parser — so MCP namespaces, `apply_patch`-style freeform
tools, and client-executed `tool_search` all round-trip. A `buildResponseJSON()` variant produces a
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.23",
"version": "0.1.24",
"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
43 changes: 31 additions & 12 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ function toAnthropicContentPart(p: CodexCommanderContentPart): unknown {
const DEFAULT_MAX_TOKENS = 8192;
/** Safe ceiling for `max_tokens` (thinking + visible output) across current Claude 4.x models. */
const REASONING_MAX_TOKENS_CEILING = 32_000;
/** Adaptive-thinking ceiling: max effort budget (32k) + OUTPUT_HEADROOM (8k).
* Must exceed REASONING_MAX_TOKENS_CEILING so effort=max actually preserves visible-output room. */
/** 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;
/** 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`. */
const MIN_THINKING_BUDGET = 1024;
/** Visible-output room added above the thinking budget when sizing `max_tokens`. */
Expand Down Expand Up @@ -852,11 +854,28 @@ 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);
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);
// Do not mutate parsed.options: requested-effort diagnostics describe the client.
// Anthropic has no reasoningLog field for output_config.effort in the shared contract.

const body: Record<string, unknown> = {
model: parsed.modelId,
messages,
stream: parsed.stream,
max_tokens: parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: maxOutputTokens ?? DEFAULT_MAX_TOKENS,
};
if (isOAuth) {
// Claude OAuth (Pro/Max) requires the first system block to be the Claude Code identity.
Expand All @@ -880,21 +899,21 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
// anyway, and thinking shares the caller's `max_tokens` — which truncates a small-budget
// request before it can emit its stop sequence (#545). Say "disabled" out loud where the
// model both defaults to thinking and accepts being told not to.
if (parsed.options.reasoning === "none" && supportsExplicitThinkingDisable(parsed.modelId)) {
if (reasoningEffort === "none" && supportsExplicitThinkingDisable(parsed.modelId)) {
body.thinking = { type: "disabled" };
} else if (typeof parsed.options.reasoning === "string" && parsed.options.reasoning !== "none") {
} else if (typeof reasoningEffort === "string" && reasoningEffort !== "none") {
if (usesAdaptiveThinking(parsed.modelId)) {
// Adaptive-thinking models replace the token budget with an effort knob and reject
// `thinking.type: "enabled"` outright. `max_tokens` still caps thinking plus visible
// output, so high effort needs the same total-token headroom as budget thinking or a
// default 8192-token request can spend everything on thought and return empty text.
body.thinking = { type: "adaptive" };
body.output_config = { effort: adaptiveEffort(parsed.options.reasoning) };
const explicitMaxOut = parsed.options.maxOutputTokens;
const wantBudget = reasoningBudget(parsed.options.reasoning);
body.output_config = { effort: adaptiveEffort(reasoningEffort) };
const explicitMaxOut = maxOutputTokens;
const wantBudget = reasoningBudget(reasoningEffort);
const floor = wantBudget + OUTPUT_HEADROOM;
// Preserve explicit caller limits as-is; for omitted limits use the adaptive ceiling
// so effort=max (budget=32k) still leaves OUTPUT_HEADROOM tokens for visible output.
// Preserve caller limits and the scoped compaction total. Ordinary 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 All @@ -903,8 +922,8 @@ export function createAnthropicAdapter(provider: CodexCommanderProviderConfig, c
// visible output) and budget_tokens >= 1024. Codex sends the SAME value for both, which
// 400s ("max_tokens must be greater than thinking.budget_tokens"). Size them so max_tokens
// always exceeds the budget within a model-safe ceiling, reserving room for visible output.
const maxOut = parsed.options.maxOutputTokens ?? DEFAULT_MAX_TOKENS;
const wantBudget = reasoningBudget(parsed.options.reasoning);
const maxOut = maxOutputTokens ?? DEFAULT_MAX_TOKENS;
const wantBudget = reasoningBudget(reasoningEffort);
const maxTokens = Math.min(REASONING_MAX_TOKENS_CEILING, Math.max(maxOut, wantBudget + OUTPUT_HEADROOM));
const budget = Math.max(MIN_THINKING_BUDGET, Math.min(wantBudget, maxTokens - OUTPUT_FLOOR));
body.max_tokens = maxTokens;
Expand Down
137 changes: 95 additions & 42 deletions src/server/responses/compact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { resolveCodexTaskIdentity } from "../../codex/task-identity";
import { rememberNativeArtifacts } from "../../codex/native-ownership";
import { nativeClientMetadata, nativeCompatibilityPolicy, nativeRequestOwner, nativeReplayScope, nativeTurnId, NATIVE_RESPONSES_HEADERS } from "../../responses/native-policy";
import { UpstreamSendBudget } from "../../lib/upstream-send-budget";
import { decodeServerSentEvents } from "../../lib/sse-decoder";
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
import { initializeRequestDispatch, requestDispatchContext } from "../request-log";
import { cleanupResponseDispatch, responseDispatch, observeDispatch } from "../../usage/dispatch-http";
import type { Server } from "bun";
Expand Down Expand Up @@ -122,6 +124,37 @@ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel }

export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;

type CompactTurnResponse = { output?: unknown[]; status?: unknown; error?: unknown };

/** Consume the bounded internal Responses stream; the external compact protocol stays JSON. */
async function readCompactTurnStream(response: Response, signal: AbortSignal, budget: TranslatorBudget): Promise<CompactTurnResponse> {
if (!response.body) throw new Error("missing compaction stream");
for await (const record of decodeServerSentEvents(response.body, { signal, translatorBudget: budget })) {
if (signal.aborted) throw signal.reason;
if (record.data === "[DONE]") break;
const reservation = budget.reserveTransient(Buffer.byteLength(record.data), { kind: "retained_collectors" });
try {
const frame = JSON.parse(record.data) as { type?: string; response?: CompactTurnResponse; error?: unknown };
const type = frame.type ?? record.event;
if (type === "response.completed" || type === "response.incomplete" || type === "response.failed") {
if (!frame.response || typeof frame.response !== "object" || Array.isArray(frame.response)) {
throw new Error("invalid compaction terminal");
}
// The parsed terminal remains live until v1 history is materialized below.
reservation.commitRetained();
return frame.response;
}
if (type === "error") {
reservation.commitRetained();
return { error: frame.error ?? "compaction stream failed" };
}
} finally {
reservation.release();
}
}
throw new Error("compaction stream ended without a terminal response");
}

export function compactResponseTooLargeError(): Response {
return new Response(JSON.stringify({
error: {
Expand Down Expand Up @@ -583,9 +616,13 @@ export async function handleResponsesCompact(
// ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no
// tools) and decode the resulting ccx1 envelope into plain v1 replacement-history items.
const inputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : [];
// Anthropic's nonstreaming headers can arrive only after all thinking and summary
// generation, exceeding the connection deadline. Use the normal streaming bridge so
// upstream headers arrive promptly and genuine pings keep its stall watchdog alive.
const streamInternally = route.provider.adapter === "anthropic";
const internalBody = {
...raw,
stream: false,
stream: streamInternally,
input: [...inputItems, { type: "compaction_trigger" }],
};
const internalHeaders = new Headers({ "content-type": "application/json" });
Expand All @@ -598,48 +635,64 @@ export async function handleResponsesCompact(
headers: internalHeaders,
body: JSON.stringify(internalBody),
});
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease });
if (!response.ok) return response;
let json: { output?: unknown[]; status?: unknown; error?: unknown };
const budget = streamInternally ? createTranslatorBudget() : undefined;
try {
json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown };
} catch {
return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response");
}
// The internal turn answers 200 even when it failed or was truncated, so the body
// has to be inspected. Reporting a failure beats installing "(no summary
// available)" as replacement history and silently losing the conversation (#422).
if (json.error) {
const message = typeof json.error === "string"
? json.error
: (json.error as { message?: unknown })?.message;
return formatErrorResponse(502, "upstream_error", typeof message === "string" ? message : "compaction turn failed");
}
if (json.status !== "completed") {
return formatErrorResponse(
502,
"upstream_error",
`compaction turn did not complete (status: ${String(json.status ?? "unknown")})`,
);
}
const compactionItems = (json.output ?? []).filter(
(item): item is { type: string; encrypted_content?: string } =>
!!item && typeof item === "object" && (item as { type?: string }).type === "compaction",
);
if (compactionItems.length !== 1) {
return formatErrorResponse(
502,
"invalid_response_error",
`compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`,
const response = await handleResponses(internalReq, config, logCtx, {
abortSignal: req.signal, turnAdmissionLease, ...(budget ? { translatorBudget: budget } : {}),
});
if (!response.ok) return response;
let json: CompactTurnResponse;
try {
json = budget
? await readCompactTurnStream(response, req.signal, budget)
: await response.json() as CompactTurnResponse;
} catch (error) {
if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
return formatErrorResponse(502, "server_error", isTranslatorBudgetExceededError(error)
? "compaction turn exceeded the safe translation buffer limit"
: budget ? "compaction turn returned an invalid or incomplete response"
: "compaction turn returned a non-JSON response");
}
if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
// The internal turn answers 200 even when it failed or was truncated, so the body
// has to be inspected. Reporting a failure beats installing "(no summary
// available)" as replacement history and silently losing the conversation (#422).
if (json.error) {
const message = typeof json.error === "string"
? json.error
: (json.error as { message?: unknown })?.message;
return formatErrorResponse(502, "upstream_error", typeof message === "string" ? message : "compaction turn failed");
}
if (json.status !== "completed") {
return formatErrorResponse(
502,
"upstream_error",
`compaction turn did not complete (status: ${String(json.status ?? "unknown")})`,
);
}
const compactionItems = (json.output ?? []).filter(
(item): item is { type: string; encrypted_content?: string } =>
!!item && typeof item === "object" && (item as { type?: string }).type === "compaction",
);
if (compactionItems.length !== 1) {
return formatErrorResponse(
502,
"invalid_response_error",
`compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`,
);
}
const encrypted = compactionItems[0]!.encrypted_content;
const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null;
// An empty `ccx1:` envelope decodes to "" rather than null, so length is what matters.
if (decoded === null || decoded.trim().length === 0) {
return formatErrorResponse(502, "invalid_response_error", "compaction turn produced an empty summary");
}
const summary = decoded;
const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
} finally {
// The compact handler owns the shared budget until its internal stream and
// decoder have both released their readers, including abort/error paths.
budget?.dispose();
}
const encrypted = compactionItems[0]!.encrypted_content;
const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null;
// An empty `ccx1:` envelope decodes to "" rather than null, so length is what matters.
if (decoded === null || decoded.trim().length === 0) {
return formatErrorResponse(502, "invalid_response_error", "compaction turn produced an empty summary");
}
const summary = decoded;
const output = buildCompactV1Output(extractCompactUserMessages(inputItems), summary);
return new Response(JSON.stringify({ output }), { headers: { "Content-Type": "application/json" } });
}
13 changes: 13 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ within their route; neither route falls through to the other. See
and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through
to GUI static serving.

The Anthropic adapter applies a maintenance-only budget to fresh routed compaction requests
for the verified Opus 5.5 model on the canonical Anthropic API: effort is capped at `medium`
without raising lower efforts, and an omitted output limit becomes 64,000 total tokens.
Explicit limits remain authoritative. Adaptive thinking has no fixed `budget_tokens`
reservation, so the ordinary effort-to-output heuristic cannot guarantee room for summary
text. Both v1 compact and v2 `compaction_trigger` requests carry `_compactionRequest` to this
boundary; replayed summaries do not activate it. Other models, custom destinations, and ordinary
work retain their existing behavior. Token-limit stops remain incomplete and install no partial
compaction item; there is no automatic retry or model substitution.
Routed Anthropic v1 compact generation streams internally and consumes the bounded Responses
terminal through the SSE decoder before building the v1 JSON replacement history. This preserves
the header deadline, ping-aware stall watchdog, and client cancellation for long summaries.

### Responses reasoning effort boundary

`openai-responses` preserves the caller's Responses request shape and the upstream response shape;
Expand Down
Loading
Loading