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
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,14 @@ MiMo model Command Code serves.
**Auth:** `key` (`x-api-key` by default, or `Authorization: Bearer` with `apiKeyTransport: "bearer"`) or `oauth` (Bearer + `anthropic-beta`, for Claude Pro/Max).

- Converts messages to Anthropic content blocks (text, base64 image, `tool_use`, `thinking`).
- Routed Codex compaction keeps the client tool catalog, system prompt and tool-selection
options to preserve their prompt-cache prefix. Summary requests disable search/media
sidecars; upstream-hosted tools retain their declarations with `tool_choice: none`, as does a
mandatory tool choice, while the advertised catalog stays the same. A request that advertises a
sidecar web-search tool (for example Exa) still changes its tool prefix on compaction.
A summary that attempts a tool call fails without publishing a function call or replacing
history. A successful summary returns exactly one compaction item. Image normalization
and the existing one-shot tighter-image retry after HTTP 413 remain unchanged.
- Translated Anthropic Messages reasoning replay shares the request translation budget, including
encoding/decoding copy overhead. Requests exceeding it return HTTP 413 with
`translation_buffer_limit`; signatures and opaque reasoning data are never truncated to fit.
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout-replay.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"responses-anthropic-compaction-prefix.test.ts": "responses",
"responses-replay-metadata-principal.test.ts": "responses",
"responses-combo-replay-principal.test.ts": "responses",
"responses-translated-replay-principal.test.ts": "responses"
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/anthropic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,9 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
&& settledToolChoice.type !== "none") {
body.tool_choice = { ...settledToolChoice, disable_parallel_tool_use: true };
}
// A routed compaction summary keeps every tools/system byte derived from the caller's
// choice above, so only the final execution choice changes.
if (parsed._summaryProhibitsToolCalls && body.tools) body.tool_choice = { type: "none" };

const url = resolveAnthropicMessagesUrl(provider);
// Anthropic fast mode: `speed` is only accepted beside its beta; without it the API
Expand Down
45 changes: 45 additions & 0 deletions src/responses/compaction-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { AdapterEvent } from "../types";
import { releaseTranslatedEvent, type TranslatorBudget } from "../lib/translator-budget";
import { isTruncatedStopReason } from "./truncated-stop-reason";

/** Keep client/hosted tool events out of a routed Anthropic summary's compaction item. */
export async function* guardCompactionSummary(
events: AsyncIterable<AdapterEvent>,
budget: TranslatorBudget,
): AsyncGenerator<AdapterEvent> {
let hasText = false;
let attemptedTool = false;
for await (const event of events) {
if (event.type === "text_delta" && !event.anthropicThinkingSignature) {
hasText ||= event.text.trim().length > 0;
yield event;
continue;
}
if (event.type === "error" || event.type === "incomplete") {
yield event;
return;
}
if (event.type === "done") {
if (!isTruncatedStopReason(event.stopReason)
// An absent stop reason is accepted, as on every other completion path: some
// Anthropic-compatible endpoints omit it. An explicit non-terminal reason is not.
&& (attemptedTool || !hasText
|| (event.stopReason !== undefined && event.stopReason !== "end_turn" && event.stopReason !== "stop_sequence"))) {
releaseTranslatedEvent(event, budget);
yield {
type: "error", status: 502, errorType: "upstream_error",
code: "compaction_invalid_summary", retryable: false,
message: "Compaction requires a non-empty text summary without tool calls and a normal completion.",
usage: event.usage,
};
} else {
yield event;
}
return;
}
if (event.type === "tool_call_start" || event.type === "tool_call_delta" || event.type === "tool_call_end"
|| event.type === "web_search_call_begin" || event.type === "web_search_call_end") attemptedTool = true;
releaseTranslatedEvent(event, budget);
yield { type: "heartbeat" };
}
}
12 changes: 11 additions & 1 deletion src/server/responses/adapter-delivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
import { isNonReplayableResponse } from "../../lib/upstream-retry";
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
import { guardEmptyToolCatalogEvents, guardEmptyToolCatalogStream } from "../../lib/empty-tool-catalog";
import { guardCompactionSummary } from "../../responses/compaction-summary";

/** One responsibility of the Responses request pipeline; state owners are explicit. */
export async function deliverAdapterResponse(
Expand Down Expand Up @@ -183,7 +184,9 @@ export async function deliverAdapterResponse(
continuation: fetchGuardedEmptyCompletionRetry,
})
: advisorStream;
const guardedEventStream = guardEmptyToolCatalogStream(completionGuardedEventStream, clientToolCatalog, translatorBudget);
const catalogGuardedEventStream = guardEmptyToolCatalogStream(completionGuardedEventStream, clientToolCatalog, translatorBudget);
const guardedEventStream = routedCompaction && transportState.activeAdapter.name === "anthropic"
? guardCompactionSummary(catalogGuardedEventStream, translatorBudget) : catalogGuardedEventStream;
const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, bareCustomToolNames, toolSearchToolNames } = deliveryToolBridgeMaps;
// One completion owner for both deliveries: the bridge calls it from its terminal, the
// direct client encoder from the fold of the same events.
Expand Down Expand Up @@ -329,6 +332,13 @@ export async function deliverAdapterResponse(
} else {
events = guardedEvents;
}
if (routedCompaction && transportState.activeAdapter.name === "anthropic") {
const summaryEvents: AdapterEvent[] = [];
for await (const event of guardCompactionSummary((async function* () { yield* events; })(), translatorBudget)) {
summaryEvents.push(event);
}
events = summaryEvents;
}
} catch (error) {
if (error instanceof ResponseBodyInactivityError) {
return formatErrorResponse(504, "upstream_error", "Upstream response body stalled before completing");
Expand Down
2 changes: 2 additions & 0 deletions src/server/responses/request-sidecar-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export async function prepareResponsesSidecarAuth(
const visionDescribeTerminal = options.visionDescribeTerminal === true;
const routedCompaction = parsed._compactionRequest === true
&& (!supportsNativeResponsesCompactionTrigger(route.provider) || parsed._portableCompaction === true);
const preserveCompactionPrefix = routedCompaction && transportState.adapter.name === "anthropic";
if (routedCompaction) parsed.context.messages = omitEarlierCompactionImages(parsed.context.messages);
const needsOpenAiVision = !visionDescribeTerminal
&& shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName);
Expand Down Expand Up @@ -158,6 +159,7 @@ export async function prepareResponsesSidecarAuth(
return {
openAiSidecar,
routedCompaction,
preserveCompactionPrefix,
};
}

Expand Down
32 changes: 22 additions & 10 deletions src/server/responses/response-effects.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { requiresToolCall } from "../../types";
import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
import type { PreparedResponsesRequest } from "./request-prepare";
import type { ResponsesSidecarAuth } from "./request-sidecar-auth";
Expand Down Expand Up @@ -26,11 +27,11 @@ export function createResponsesEffects(
| "route"
| "substituteMainCredential"
>,
sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction">,
sidecarState: Pick<ResponsesSidecarAuth, "routedCompaction"> & Partial<Pick<ResponsesSidecarAuth, "preserveCompactionPrefix">>,
) {
const { options, req, config } = requestContext;
const { parsed, poolAffinityKey, route, substituteMainCredential } = requestState;
const { routedCompaction } = sidecarState;
const { routedCompaction, preserveCompactionPrefix } = sidecarState;


const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
Expand Down Expand Up @@ -67,8 +68,8 @@ export function createResponsesEffects(

// Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
// one `{type:"compaction"}` output item (codex-rs compact_remote_v2.rs). Passthrough handles it
// natively upstream; here we run the routed model as a plain summarizer — no tools, no web-search
// sidecar — and the bridge appends the synthetic compaction item (src/responses/compaction.ts).
// natively upstream; here the routed model summarizes without sidecar execution, and the
// bridge appends the synthetic compaction item (src/responses/compaction.ts).
// A Responses-shaped wire does not imply support for Codex's private
// `compaction_trigger` item — only the canonical ChatGPT backend speaks that
// contract. An API-key gateway would receive the trigger, answer with an ordinary
Expand All @@ -87,12 +88,21 @@ export function createResponsesEffects(
}
};
if (routedCompaction) {
delete parsed.context.tools;
delete parsed._webSearch;
delete parsed._anthropicServerTools;
delete parsed._anthropicServerReplay;
delete parsed.options.toolChoice;
delete parsed.options.parallelToolCalls;
if (!preserveCompactionPrefix) {
delete parsed.context.tools;
delete parsed._anthropicServerTools;
delete parsed._anthropicServerReplay;
delete parsed.options.toolChoice;
delete parsed.options.parallelToolCalls;
} else if (parsed._anthropicServerTools?.length || requiresToolCall(parsed.options.toolChoice)) {
// Hosted tools execute upstream, and a mandatory choice would force a call the summary
// guard must reject. Keep the choice itself so the adapter derives the same tools and
// system bytes; the adapter replaces only the final wire tool_choice with "none".
// tool_choice changes invalidate message cache, not tools/system (Anthropic caching docs).
parsed._summaryProhibitsToolCalls = true;
}
// Client tool events are rejected by guardCompactionSummary before reaching Codex.
// The compaction turn is a plain prose summary; a surviving structured-output format
// would force schema-constrained JSON into the synthetic compaction item. The flag and
// the raw `text` control go too: the key-mode openai-responses adapter builds from
Expand All @@ -105,7 +115,9 @@ export function createResponsesEffects(
}
parsed.context.messages = [
...parsed.context.messages,
{ role: "user", content: COMPACT_PROMPT, timestamp: Date.now() },
{ role: "user", content: preserveCompactionPrefix
? `${COMPACT_PROMPT}\n\nReturn only the handoff summary. Do not call tools or continue the task.`
: COMPACT_PROMPT, timestamp: Date.now() },
];
}

Expand Down
2 changes: 1 addition & 1 deletion src/server/responses/sidecar-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ export async function executeResponsesSidecars(
}

// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
// Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
// Routed-compaction turns must NOT hit the image bridge: compaction disables tool execution but
// leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
// completion instead of the synthetic compaction item Codex expects (#424).
//
Expand Down
5 changes: 5 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ export interface OcxParsedRequest {
* them, and only for api.anthropic.com; every other destination never sees them.
*/
_anthropicServerTools?: readonly Record<string, unknown>[];
/**
* Routed Anthropic compaction summary: build tools and system exactly as the ordinary request
* would (prompt-cache prefix), then send tool_choice none so no client or hosted tool runs.
*/
_summaryProhibitsToolCalls?: boolean;
/**
* Completed server-tool exchanges from the Claude client's history, keyed by the block that
* followed them (`call:<tool_use id>` / `text:<text>`). Same audience as `_anthropicServerTools`.
Expand Down
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Claude CLI replay, client tool capture and usage admission follow [Claude CLI re

API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint.

Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md).
Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). Routed Anthropic compaction preserves the client tool/system prefix and guards summary output under the [compaction contract](transports/responses-failover.md#compaction-routing-overrides); image normalization and HTTP 413 recovery retain their existing policy.
A shared-quota Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. Exact revoked-token 401s persist generation-fenced reauthentication under [the Anthropic recovery contract](providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery).

The Anthropic helper sends share the same routing authority: `getAnthropicSidecarAccessToken` resolves the vision-describe and web-search sidecars' helper model through the same first-match route decision, so a routed send authenticates as the route's own account rather than whatever pool account happens to be active. A strict route with no eligible account fails the helper locally instead of silently falling back to the active outsider, matching the primary-traffic contract; callers without a pool config keep the plain stored-credential path.
Expand Down
13 changes: 13 additions & 0 deletions structure/transports/responses-failover.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,19 @@ including when the destination is a combo, so request history names the phase th

## Compaction routing overrides

For routed v2 summaries served by the resolved Anthropic Messages adapter, `src/server/responses/response-effects.ts`
preserves client tool definitions, system text, `toolChoice` and `parallelToolCalls`. Other adapters retain tool removal.
Search/media sidecar execution stays disabled; Anthropic server-tool declarations and replay remain,
with wire `tool_choice: none` when server tools exist, preventing upstream execution.
[Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching#what-invalidates-the-cache)
orders the prefix as tools, system, messages: changing tool definitions invalidates all three;
changing tool choice invalidates message cache only. Client-only summaries keep an optional choice unchanged; a mandatory choice (required, named, or required allowed tools) keeps the caller's choice for catalog derivation and sets `_summaryProhibitsToolCalls`; `src/adapters/anthropic.ts` builds tools and system from that choice, provider name transforms included, then sends `tool_choice: none`, so tools and system stay byte-identical. Search sidecar declarations are not injected for summaries, so a request whose ordinary turn advertised a sidecar search tool still changes its tool prefix.
`src/responses/compaction-summary.ts` suppresses tool/search events and rejects tool attempts,
empty summaries and abnormal completions before delivery;
only successful prose reaches the single synthetic compaction item. Truncated completions retain the existing incomplete outcome.
Signed progress is not summary prose.
`tests/responses/responses-anthropic-compaction-prefix.test.ts` covers the prefix and both delivery modes; image normalization and 413 recovery retain their existing policy.

`src/server/responses/compaction-routing.ts` applies `compactionRouting` before model routing in
both `request-prepare.ts` and `compact.ts`. It requires explicit `request_kind: "compaction"` in
`x-codex-turn-metadata`, supplied as a header or embedded in Responses `client_metadata`, and on
Expand Down
Loading
Loading