From 5e8ac7e36db25ca641f642a239aa02363fe287fc Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:00:18 +0900 Subject: [PATCH 1/6] fix(anthropic): preserve cache prefixes during routed compaction Carry the client tool/system prefix preservation and summary output guard from #7308 for #7307, scoped to the resolved Anthropic Messages adapter. Prohibit upstream server tools and retain existing image/413 recovery. Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 6 + scripts/test-layout/layout-replay.json | 1 + src/responses/compaction-summary.ts | 43 +++ src/server/responses/adapter-delivery.ts | 12 +- src/server/responses/request-sidecar-auth.ts | 2 + src/server/responses/response-effects.ts | 29 +- src/server/responses/sidecar-execution.ts | 2 +- structure/providers-and-adapters.md | 2 +- structure/transports/responses-failover.md | 13 + structure/transports/responses.md | 8 +- tests/fixtures/test-layout-expected.json | 1 + ...ponses-anthropic-compaction-prefix.test.ts | 268 ++++++++++++++++++ 12 files changed, 370 insertions(+), 17 deletions(-) create mode 100644 src/responses/compaction-summary.ts create mode 100644 tests/responses/responses-anthropic-compaction-prefix.test.ts diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b4eb39b789e..81f5c3c0e22 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -329,6 +329,12 @@ 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`. + 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. diff --git a/scripts/test-layout/layout-replay.json b/scripts/test-layout/layout-replay.json index 3b8ab38bea1..602b94337f2 100644 --- a/scripts/test-layout/layout-replay.json +++ b/scripts/test-layout/layout-replay.json @@ -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" diff --git a/src/responses/compaction-summary.ts b/src/responses/compaction-summary.ts new file mode 100644 index 00000000000..2c6c8bd2be3 --- /dev/null +++ b/src/responses/compaction-summary.ts @@ -0,0 +1,43 @@ +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, + budget: TranslatorBudget, +): AsyncGenerator { + 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) + && (attemptedTool || !hasText || event.endTurn === false + || (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" }; + } +} diff --git a/src/server/responses/adapter-delivery.ts b/src/server/responses/adapter-delivery.ts index 01a1af78cdc..37ccbc4554e 100644 --- a/src/server/responses/adapter-delivery.ts +++ b/src/server/responses/adapter-delivery.ts @@ -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( @@ -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. @@ -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"); diff --git a/src/server/responses/request-sidecar-auth.ts b/src/server/responses/request-sidecar-auth.ts index 63a00d9655e..d345f01db16 100644 --- a/src/server/responses/request-sidecar-auth.ts +++ b/src/server/responses/request-sidecar-auth.ts @@ -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); @@ -158,6 +159,7 @@ export async function prepareResponsesSidecarAuth( return { openAiSidecar, routedCompaction, + preserveCompactionPrefix, }; } diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index e901594e401..3bde90139b6 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -26,11 +26,11 @@ export function createResponsesEffects( | "route" | "substituteMainCredential" >, - sidecarState: Pick, + sidecarState: Pick & Partial>, ) { const { options, req, config } = requestContext; const { parsed, poolAffinityKey, route, substituteMainCredential } = requestState; - const { routedCompaction } = sidecarState; + const { routedCompaction, preserveCompactionPrefix } = sidecarState; const recordTerminalOutcomes = options.recordTerminalOutcomes !== false; @@ -67,8 +67,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 @@ -87,12 +87,19 @@ 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) { + // Hosted tools execute upstream: retain their cached definitions but prohibit execution. + // tool_choice changes invalidate message cache, not tools/system (Anthropic caching docs). + parsed.options.toolChoice = "none"; + } + // 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 @@ -105,7 +112,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() }, ]; } diff --git a/src/server/responses/sidecar-execution.ts b/src/server/responses/sidecar-execution.ts index e474c4bcd5b..4e290d4a415 100644 --- a/src/server/responses/sidecar-execution.ts +++ b/src/server/responses/sidecar-execution.ts @@ -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). // diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 32a4a3789c1..395ce58622a 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -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. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index e328c85e012..bcacd29ad48 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -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 `toolChoice: 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 the choice unchanged. +`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 diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 6275ba66101..0215d23a786 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -92,8 +92,8 @@ a `functions` child of the same name are the duplicate the parser already tolera Replayed call items are lowered whether or not this turn declares the group they name. A catalog can be absent or change mid-session, but the client is still replaying items this layer's own response -restoration stamped with a private `namespace`. Routed compaction runs this boundary before removing -the tool surface so request-local aliases remain available for response restoration. Only +restoration stamped with a private `namespace`. Routed compaction runs this boundary before applying +the summary tool policy so request-local aliases remain available for response restoration. Only `tool_choice` resolves a bare name through the catalog: a history item records which tool actually ran, so re-pointing it at a same-named namespace child would rewrite that record on a coincidence rather than translate it. @@ -418,7 +418,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `shadow-target-availability.ts` | Shadow-call target resolution for `request-prepare.ts`: an unavailable target fails once with `409 intercept_target_unavailable` instead of reaching the native source model or the default provider. | | `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. Copilot Auto renewal onto Chat (including negotiation-401 refresh) signals the native owner before inference; `passthrough-dispatch.ts` reuses its inference-401/429 adapter handoff so JSON and SSE retain the Responses envelope. | | `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. | -| `response-effects.ts` | Completion notification, replay publication and live request-tool aliases. | +| `response-effects.ts` | Completion notification, replay publication, live request-tool aliases and routed summary controls; Anthropic keeps client tools/system/options, prohibiting upstream-hosted tools with `toolChoice: none`. | | `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. | | `reset-replay.ts` | Self-contained-request eligibility and the shared operator replacement grant for native Responses and generic translated initial/rebuilt pre-header sends; adapter-owned transports and translated post-header failures are excluded. | | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | @@ -429,7 +429,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `sidecar-execution.ts`, `sidecar-send-budget.ts` | Image/video versus web-search execution, shared rotation, and adapter-aware prepaid inference/producer ownership under the [spend contract](responses-spend.md#prepaid-initial-sends). | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns; refreshed retries reject previously failed key bindings and revalidate after each queued admission; adapter-owned CLI spawns receive the post-setup `IncomingMeta.beforePhysicalSend` fence, with [replacement-key readmission](responses-failover.md#upstream-key-attempt-accounting). | | `adapter-dispatch.ts` | Translated initial dispatch, bounded recovery (including [Anthropic thinking-signature recovery](anthropic-thinking-recovery.md)) and the shared continuation retry counter. Initial and rebuilt adapter budget refusals retain the HTTP 413 [byte-accounting contract](byte-accounting.md#request-copy-accounting); ordinary build errors remain 400. | -| `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; a streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | +| `adapter-continuation.ts`, `adapter-delivery.ts` | Continuation event sources and final streaming/buffered bridging; Anthropic routed summaries pass through `src/responses/compaction-summary.ts` before either bridge. A streamed turn with a `clientEncoder` option is handed to `src/server/inference/client-encoder-delivery.ts` instead of the bridge. | Reusable helpers live in `core-auth.ts`, `core-codex-account.ts`, `core-combo.ts`, `core-combo-failure.ts`, `core-combo-native.ts`, `combo-requested-effort.ts`, `core-errors.ts`, `core-lifetime.ts`, `core-normalize.ts`, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 31970b8bde5..69977a71b78 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1,5 +1,6 @@ { "workbuddy-quota.test.ts": "providers", "workbuddy-login.test.ts": "oauth", "workbuddy-adapter.test.ts": "adapters", "workbuddy-provider.test.ts": "providers", "link-tunnel-history.test.ts": "clients", "link-readiness.test.ts": "clients", "link-diagnostics.test.ts": "clients", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "responses-phase-inference.test.ts": "responses", "tool-identity-hash.test.ts": "adapters", + "responses-anthropic-compaction-prefix.test.ts": "responses", "service-proxy-env.test.ts": "service", "codex-quota-persistence.test.ts": "codex-integration", "bridge-terminal-refusal.test.ts": "adapters", diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts new file mode 100644 index 00000000000..bd5e5e869ca --- /dev/null +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -0,0 +1,268 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createResponsesEffects } from "../../src/server/responses/response-effects"; +import { prepareResponsesSidecarAuth } from "../../src/server/responses/request-sidecar-auth"; +import type { HandleResponsesOptions } from "../../src/server/responses/core-options"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { createTranslatorBudget, releaseTranslatedEvent, retainTranslatedEvent } from "../../src/lib/translator-budget"; +import { guardCompactionSummary } from "../../src/responses/compaction-summary"; +import { handleResponses } from "../../src/server/responses/core"; +import { decodeCompactionSummary } from "../../src/responses/compaction"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest } from "../../src/types"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { drainCompactionResponseState, installCompactionRoutingAclFixture, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; + +const tools = [{ name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }]; +function applyEffects(preserveCompactionPrefix: boolean, hosted = false) { + const parsed = { + options: { toolChoice: "auto", parallelToolCalls: false }, + context: { tools: structuredClone(tools), systemPrompt: ["stable system"], messages: [] }, + _webSearch: { type: "web_search" }, + ...(hosted ? { _anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }] } : {}), + } as unknown as OcxParsedRequest; + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix } as Args[3], + ); + return parsed; +} + +test("Anthropic compaction preserves client tool and system cache prefixes and tool choice", () => { + const parsed = applyEffects(true); + expect(parsed.context.tools).toEqual(tools); + expect(parsed.context.systemPrompt).toEqual(["stable system"]); + expect(parsed.options.toolChoice).toBe("auto"); + expect(parsed.options.parallelToolCalls).toBe(false); + expect(parsed._webSearch).toBeUndefined(); + expect(parsed.context.messages.at(-1)?.content).toContain("Do not call tools"); +}); + +test("hosted server declarations stay cacheable but their execution is explicitly prohibited", () => { + const parsed = applyEffects(true, true); + expect(parsed._anthropicServerTools).toEqual([{ type: "web_search_20250305", name: "web_search" }]); + expect(parsed.options.toolChoice).toBe("none"); + expect(parsed._webSearch).toBeUndefined(); +}); + +test("other adapters retain the existing compaction deletion behavior", () => { + const parsed = applyEffects(false, true); + expect(parsed.context.tools).toBeUndefined(); + expect(parsed.options.toolChoice).toBeUndefined(); + expect(parsed.options.parallelToolCalls).toBeUndefined(); + expect(parsed._anthropicServerTools).toBeUndefined(); + expect(parsed._webSearch).toBeUndefined(); +}); + +test("summary preservation follows the resolved adapter rather than the provider label", async () => { + type Args = Parameters; + for (const name of ["anthropic", "openai-chat"]) { + const budget = createTranslatorBudget(); + try { + const parsed = { modelId: "fixture", _compactionRequest: true, + context: { tools, messages: [], systemPrompt: [] }, options: {} } as unknown as OcxParsedRequest; + const provider = { adapter: name === "anthropic" ? "openai-chat" : "anthropic", baseUrl: "https://fixture.invalid", authMode: "key" }; + const state = await prepareResponsesSidecarAuth({ options: {}, req: new Request("http://localhost/v1/responses"), + config: { providers: { custom: provider } } } as Args[0], + { parsed, route: { providerName: "custom", modelId: "fixture", provider }, translatorBudget: budget } as Args[1], + { adapter: { name }, isPassthrough: false } as Args[2]); + expect(state.routedCompaction).toBe(true); + expect(state.preserveCompactionPrefix).toBe(name === "anthropic"); + } finally { budget.dispose(); } + } +}); + +test("hosted tools keep the wire prefix with tool_choice none, prohibiting upstream execution", async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key" as const, apiKey: "synthetic-test-key" }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider, "short")); + const parsed = applyEffects(true, true); + parsed.modelId = "claude-sonnet-4-6"; + parsed.context.messages.unshift({ role: "user", content: "earlier turn", timestamp: 1 }); + const ordinary = structuredClone(parsed); + ordinary.options.toolChoice = "auto"; + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(parsed)).body as string); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); + expect(after.tools).toContainEqual(expect.objectContaining({ type: "web_search_20250305", name: "web_search" })); + expect(after.tool_choice).toEqual({ type: "none" }); +}); + +for (const kind of ["text", "tool", "search", "empty", "progress", "abnormal", "eof", "truncated", "error", "incomplete"] as const) { + test(`summary guard validates completion and releases discarded event leases: ${kind}`, async () => { + const budget = createTranslatorBudget(); + budget.chargeRetained(17, { kind: "retained_collectors" }); + const input: AdapterEvent[] = []; + if (kind === "tool") input.push({ type: "tool_call_start", id: "call-summary", name: "read" }, + { type: "tool_call_delta", arguments: "{}" }, { type: "tool_call_end" }); + if (kind === "search") input.push({ type: "web_search_call_begin", id: "srvtoolu-summary" }, + { type: "web_search_call_end", id: "srvtoolu-summary", queries: ["synthetic"] }); + if (kind !== "empty") input.push({ type: "text_delta", text: "summary", + ...(kind === "progress" ? { anthropicThinkingSignature: "synthetic-signature", phase: "commentary" as const } : {}) }); + input.push(kind === "error" ? { type: "error", message: "upstream refused" } + : kind === "incomplete" ? { type: "incomplete", reason: "content_filter", retryable: false } + : { type: "done", stopReason: kind === "eof" ? undefined : kind === "abnormal" ? "unexpected_stop" : kind === "truncated" ? "max_tokens" : "end_turn", endTurn: true }); + try { + const output: AdapterEvent[] = []; + async function* source() { + for (const event of input) { retainTranslatedEvent(event, budget); yield event; } + } + for await (const event of guardCompactionSummary(source(), budget)) { + output.push(event); + releaseTranslatedEvent(event, budget); + } + expect(output.some(event => event.type.startsWith("tool_call") || event.type.startsWith("web_search_call"))).toBe(false); + if (["text", "truncated", "error", "incomplete"].includes(kind)) expect(output.at(-1)?.type).toBe(input.at(-1)?.type); + else expect(output.at(-1)).toMatchObject({ type: "error", code: "compaction_invalid_summary", retryable: false }); + if (kind === "progress") expect(output.some(event => event.type === "text_delta")).toBe(false); + expect(budget.snapshot().currentBytes).toBe(17); + } finally { budget.dispose(); } + }); +} + +describe("Anthropic compaction through Responses delivery", () => { + const originalFetch = globalThis.fetch; + const originalHome = process.env.OPENCODEX_HOME; + let home: string; + let releaseSpendHome: () => void; + installCompactionRoutingAclFixture(); + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-compaction-prefix-")); + process.env.OPENCODEX_HOME = home; + releaseSpendHome = acquireOwnedSpendHome(); + }); + afterEach(async () => { + await drainCompactionResponseState(); + releaseSpendHome(); + await removeCompactionFixture(home); + globalThis.fetch = originalFetch; + if (originalHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = originalHome; + }); + + function config(adapter = "anthropic", modelAdapter?: string): OcxConfig { + return { defaultProvider: "custom", cacheRetention: "short", providers: { + custom: { adapter, baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "synthetic-test-key", + ...(modelAdapter ? { modelAdapters: { "claude-sonnet-4-6": modelAdapter } } : {}), + }, + } } as OcxConfig; + } + function body(stream: boolean, compact = true) { + return { model: "custom/claude-sonnet-4-6", stream, store: false, instructions: "stable system", + tools: [{ type: "function", ...tools[0] }], tool_choice: "auto", parallel_tool_calls: false, + input: [{ type: "message", role: "user", content: "earlier turn" }, ...(compact ? [{ type: "compaction_trigger" }] : [])], + }; + } + async function send(input: ReturnType, cfg = config(), options: HandleResponsesOptions = {}) { + return handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(input), + }), cfg, { model: "", provider: "" }, options); + } + function upstream(stream: boolean, tool = false): Response { + if (!stream) return Response.json({ id: "msg-summary", type: "message", role: "assistant", model: "claude-sonnet-4-6", + content: [{ type: "text", text: "handoff summary" }, ...(tool ? [{ type: "tool_use", id: "call-summary", name: "read", input: {} }] : [])], + stop_reason: tool ? "tool_use" : "end_turn", usage: { input_tokens: 10, output_tokens: 5 }, + }); + const events = [ + { type: "message_start", message: { id: "msg-summary", type: "message", role: "assistant", model: "claude-sonnet-4-6", content: [], usage: { input_tokens: 10, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "handoff summary" } }, + { type: "content_block_stop", index: 0 }, + ...(tool ? [ + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "call-summary", name: "read", input: {} } }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: "{}" } }, + { type: "content_block_stop", index: 1 }, + ] : []), + { type: "message_delta", delta: { stop_reason: tool ? "tool_use" : "end_turn" }, usage: { output_tokens: 5 } }, + { type: "message_stop" }, + ]; + return new Response(events.map(event => `event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`).join(""), { + headers: { "content-type": "text/event-stream" }, + }); + } + + for (const stream of [false, true]) { + test(`ordinary and summary requests keep identical tools/system/options (stream=${stream})`, async () => { + const seen: Record[] = []; + globalThis.fetch = (async (_input, init) => { + seen.push(JSON.parse(String(init?.body))); + return upstream(stream); + }) as typeof fetch; + await (await send(body(stream, false))).text(); + const response = await send(body(stream)); + const text = await response.text(); + expect(seen).toHaveLength(2); + for (const key of ["tools", "system", "tool_choice"]) { + expect(seen[1]?.[key]).toEqual(seen[0]?.[key]); + expect(seen[1]?.[key]).toBeDefined(); + } + const json = stream + ? JSON.parse(text.split("\n").find(line => line.startsWith("data: ") && line.includes('"type":"response.completed"'))!.slice(6)).response + : JSON.parse(text); + expect(json.status).toBe("completed"); + expect(json.output).toHaveLength(1); + expect(json.output[0].type).toBe("compaction"); + expect(decodeCompactionSummary(json.output[0].encrypted_content)).toBe("handoff summary"); + }); + + test(`summary client tool output is rejected before publication or execution (stream=${stream})`, async () => { + let sends = 0; + globalThis.fetch = (async () => { sends++; return upstream(stream, true); }) as typeof fetch; + const response = await send(body(stream)); + const text = await response.text(); + expect(sends).toBe(1); + expect(text).not.toContain('"type":"function_call"'); + expect(text).not.toContain('"type":"compaction"'); + expect(text).toContain("compaction_invalid_summary"); + if (stream) expect(text).toContain('"type":"response.failed"'); + else expect(JSON.parse(text).status).toBe("failed"); + }); + } + + test("hosted search is prohibited upstream and the configured search sidecar never executes", async () => { + const cfg = config(); + cfg.webSearchSidecar = { backend: "exa", exaApiKey: "synthetic-search-key" }; + let primarySends = 0; + let hostedExecutions = 0; + let sidecarSends = 0; + globalThis.fetch = (async (input, init) => { + if (!String(input).startsWith("https://api.anthropic.com/")) { + sidecarSends++; + throw new Error("Summary must not dispatch a search sidecar"); + } + primarySends++; + const wire = JSON.parse(String(init?.body)); + expect(wire.tools).toContainEqual(expect.objectContaining({ type: "web_search_20250305", name: "web_search" })); + if (wire.tool_choice?.type !== "none") hostedExecutions++; + return upstream(false); + }) as typeof fetch; + const request = { ...body(false), tools: [...body(false).tools, { type: "web_search", name: "web_search", description: "Search", parameters: {} }] }; + const response = await send(request, cfg, { inboundWire: "anthropic", + anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }] }); + const result = await response.json(); + expect(result).toMatchObject({ status: "completed" }); + expect(result.output).toHaveLength(1); + expect(result.output[0].type).toBe("compaction"); + expect(primarySends).toBe(1); + expect(hostedExecutions).toBe(0); + expect(sidecarSends).toBe(0); + }); + + test("an Anthropic provider resolved onto Chat still deletes the compaction tool catalog", async () => { + let sent: Record = {}; + globalThis.fetch = (async (_input, init) => { + sent = JSON.parse(String(init?.body)); + return Response.json({ choices: [{ message: { role: "assistant", content: "handoff summary" }, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 5 } }); + }) as typeof fetch; + const response = await send(body(false), config("anthropic", "openai-chat")); + expect((await response.json()).output).toHaveLength(1); + expect(sent.tools).toBeUndefined(); + expect(sent.tool_choice).toBeUndefined(); + expect(sent.parallel_tool_calls).toBeUndefined(); + }); +}); From e9fad01253b3761727641db75bf6d2848e42ed91 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:33:16 +0900 Subject: [PATCH 2/6] fix(anthropic): prohibit tool calls for a summary under a mandatory tool choice Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- src/server/responses/response-effects.ts | 4 +++- .../responses-anthropic-compaction-prefix.test.ts | 10 ++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index 3bde90139b6..b55f3d9c293 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -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"; @@ -94,7 +95,8 @@ export function createResponsesEffects( delete parsed._anthropicServerReplay; delete parsed.options.toolChoice; delete parsed.options.parallelToolCalls; - } else if (parsed._anthropicServerTools?.length) { + } else if (parsed._anthropicServerTools?.length || requiresToolCall(parsed.options.toolChoice)) { + // A mandatory choice would force the summary into a tool call the guard must reject. // Hosted tools execute upstream: retain their cached definitions but prohibit execution. // tool_choice changes invalidate message cache, not tools/system (Anthropic caching docs). parsed.options.toolChoice = "none"; diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts index bd5e5e869ca..779948417a6 100644 --- a/tests/responses/responses-anthropic-compaction-prefix.test.ts +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -16,9 +16,9 @@ import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { drainCompactionResponseState, installCompactionRoutingAclFixture, removeCompactionFixture } from "../helpers/compaction-routing-fixtures"; const tools = [{ name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }]; -function applyEffects(preserveCompactionPrefix: boolean, hosted = false) { +function applyEffects(preserveCompactionPrefix: boolean, hosted = false, toolChoice: unknown = "auto") { const parsed = { - options: { toolChoice: "auto", parallelToolCalls: false }, + options: { toolChoice, parallelToolCalls: false }, context: { tools: structuredClone(tools), systemPrompt: ["stable system"], messages: [] }, _webSearch: { type: "web_search" }, ...(hosted ? { _anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }] } : {}), @@ -49,6 +49,12 @@ test("hosted server declarations stay cacheable but their execution is explicitl expect(parsed._webSearch).toBeUndefined(); }); +for (const choice of ["required", { type: "function", name: "read" }]) test(`a mandatory tool choice cannot force the summary into a tool call: ${JSON.stringify(choice)}`, () => { + const parsed = applyEffects(true, false, choice); + expect(parsed.context.tools).toEqual(tools); + expect(parsed.options.toolChoice).toBe("none"); +}); + test("other adapters retain the existing compaction deletion behavior", () => { const parsed = applyEffects(false, true); expect(parsed.context.tools).toBeUndefined(); From 67ad2766f1838bc4f3b0bd57d07adf46687b4cf2 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:59:02 +0900 Subject: [PATCH 3/6] fix(anthropic): keep the advertised catalog when a summary prohibits tool calls Freeze anthropicDeclaredTools before overriding a mandatory or hosted-tool choice with none, so allowed-tools and named selections keep identical tools/system bytes. Document the existing sidecar-search limitation. Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- .../src/content/docs/reference/adapters.md | 4 ++- src/server/responses/response-effects.ts | 4 +++ structure/transports/responses-failover.md | 2 +- ...ponses-anthropic-compaction-prefix.test.ts | 25 +++++++++++++++++++ 4 files changed, 33 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 81f5c3c0e22..97550597e35 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -331,7 +331,9 @@ MiMo model Command Code serves. - 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`. + 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. diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index b55f3d9c293..2f6687b8d3f 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -1,4 +1,5 @@ import { requiresToolCall } from "../../types"; +import { anthropicDeclaredTools } from "../../adapters/anthropic-tool-catalog"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesSidecarAuth } from "./request-sidecar-auth"; @@ -97,6 +98,9 @@ export function createResponsesEffects( delete parsed.options.parallelToolCalls; } else if (parsed._anthropicServerTools?.length || requiresToolCall(parsed.options.toolChoice)) { // A mandatory choice would force the summary into a tool call the guard must reject. + // Freeze the catalog the ordinary request advertised: an allowed-tools or named choice + // narrows it, and "none" alone would advertise every tool and move the cached prefix. + parsed.context.tools = anthropicDeclaredTools(parsed); // Hosted tools execute upstream: retain their cached definitions but prohibit execution. // tool_choice changes invalidate message cache, not tools/system (Anthropic caching docs). parsed.options.toolChoice = "none"; diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index bcacd29ad48..d4303343ac7 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -268,7 +268,7 @@ Search/media sidecar execution stays disabled; Anthropic server-tool declaration with `toolChoice: 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 the choice unchanged. +changing tool choice invalidates message cache only. Client-only summaries keep an optional choice unchanged; a mandatory choice (required, named, or required allowed tools) becomes `toolChoice: none` after the request freezes the catalog the choice advertised, 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. diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts index 779948417a6..15d594d035e 100644 --- a/tests/responses/responses-anthropic-compaction-prefix.test.ts +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -98,6 +98,31 @@ test("hosted tools keep the wire prefix with tool_choice none, prohibiting upstr expect(after.tool_choice).toEqual({ type: "none" }); }); +for (const [label, modelId, choice] of [ + ["required allowed tools", "claude-sonnet-4-6", { allowedTools: ["read"], mode: "required" }], + ["named tool on a model that rejects forced choice", "claude-opus-5-5", { type: "function", name: "read" }], +] as const) test(`a narrowed catalog keeps the same tools and system after prohibiting calls: ${label}`, async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key" as const, apiKey: "synthetic-test-key" }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider, "short")); + const catalog = [...tools, { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } }]; + const ordinary = { modelId, options: { toolChoice: structuredClone(choice), parallelToolCalls: false }, + context: { tools: structuredClone(catalog), systemPrompt: ["stable system"], + messages: [{ role: "user", content: "earlier turn", timestamp: 1 }] } } as unknown as OcxParsedRequest; + const summary = structuredClone(ordinary); + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], + ); + expect(summary.options.toolChoice).toBe("none"); + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(summary)).body as string); + expect(before.tools.map((tool: { name: string }) => tool.name)).toEqual(["read"]); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); +}); + for (const kind of ["text", "tool", "search", "empty", "progress", "abnormal", "eof", "truncated", "error", "incomplete"] as const) { test(`summary guard validates completion and releases discarded event leases: ${kind}`, async () => { const budget = createTranslatorBudget(); From 54a02b8a543d8f86542fbdb528e9a7dcb1fc0718 Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:16:48 +0900 Subject: [PATCH 4/6] fix(anthropic): freeze hosted declarations with the summary tool catalog Keep hosted server tools that the ordinary request suppressed (same-named function, forced-choice downgrade) out of the summary when the choice becomes none, so the tools prefix and cache breakpoint stay identical. Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- src/adapters/anthropic-tool-catalog.ts | 18 +++++++++++++ src/server/responses/response-effects.ts | 4 +-- ...ponses-anthropic-compaction-prefix.test.ts | 26 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/adapters/anthropic-tool-catalog.ts b/src/adapters/anthropic-tool-catalog.ts index 34bd84cd07d..0797af25dc6 100644 --- a/src/adapters/anthropic-tool-catalog.ts +++ b/src/adapters/anthropic-tool-catalog.ts @@ -9,3 +9,21 @@ export function anthropicDeclaredTools(parsed: OcxParsedRequest) { || (rejectsForcedToolChoice(parsed.modelId) && typeof choice === "object" && choice !== null && "name" in choice) ? tools.filter(toolChoiceToolPredicate(choice, tools)) : tools; } + +/** + * Pin the combined function and server-tool catalog an ordinary request would advertise, so a + * caller can switch the choice to "none" without changing the cached tools prefix. Mirrors the + * adapter: hosted tools yield to a same-named function in the full catalog, and a forced-choice + * downgrade keeps only the named tool. + */ +export function freezeAnthropicAdvertisedCatalog(parsed: OcxParsedRequest): void { + const functions = parsed.context.tools ?? []; + const choice = parsed.options.toolChoice; + const namedDowngrade = rejectsForcedToolChoice(parsed.modelId) + && typeof choice === "object" && choice !== null && "name" in choice; + if (parsed._anthropicServerTools) { + parsed._anthropicServerTools = parsed._anthropicServerTools.filter(tool => + !functions.some(f => f.name === tool.name) && (!namedDowngrade || tool.name === choice.name)); + } + if (parsed.context.tools) parsed.context.tools = anthropicDeclaredTools(parsed); +} diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index 2f6687b8d3f..2976e6170ea 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -1,5 +1,5 @@ import { requiresToolCall } from "../../types"; -import { anthropicDeclaredTools } from "../../adapters/anthropic-tool-catalog"; +import { freezeAnthropicAdvertisedCatalog } from "../../adapters/anthropic-tool-catalog"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesSidecarAuth } from "./request-sidecar-auth"; @@ -100,7 +100,7 @@ export function createResponsesEffects( // A mandatory choice would force the summary into a tool call the guard must reject. // Freeze the catalog the ordinary request advertised: an allowed-tools or named choice // narrows it, and "none" alone would advertise every tool and move the cached prefix. - parsed.context.tools = anthropicDeclaredTools(parsed); + freezeAnthropicAdvertisedCatalog(parsed); // Hosted tools execute upstream: retain their cached definitions but prohibit execution. // tool_choice changes invalidate message cache, not tools/system (Anthropic caching docs). parsed.options.toolChoice = "none"; diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts index 15d594d035e..d17c3939ee1 100644 --- a/tests/responses/responses-anthropic-compaction-prefix.test.ts +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -123,6 +123,32 @@ for (const [label, modelId, choice] of [ expect(after.system).toEqual(before.system); }); +for (const [label, modelId, functions, choice] of [ + ["an excluded same-named function still suppresses the hosted tool", "claude-sonnet-4-6", ["read", "web_search"], { allowedTools: ["read"], mode: "auto" }], + ["a forced-choice downgrade still drops the hosted tool", "claude-opus-5-5", ["read", "write"], { type: "function", name: "read" }], +] as const) test(`hosted declarations keep the ordinary tool prefix: ${label}`, async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key" as const, apiKey: "synthetic-test-key" }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider, "short")); + const ordinary = { modelId, options: { toolChoice: structuredClone(choice), parallelToolCalls: false }, + _anthropicServerTools: [{ type: "web_search_20250305", name: "web_search" }], + context: { tools: functions.map(name => ({ name, description: name, parameters: { type: "object", properties: {} } })), + systemPrompt: ["stable system"], messages: [{ role: "user", content: "earlier turn", timestamp: 1 }] } } as unknown as OcxParsedRequest; + const summary = structuredClone(ordinary); + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], + ); + expect(summary.options.toolChoice).toBe("none"); + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(summary)).body as string); + expect(before.tools.map((tool: { name: string }) => tool.name)).toEqual(["read"]); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); + expect(after.tool_choice).toEqual({ type: "none" }); +}); + for (const kind of ["text", "tool", "search", "empty", "progress", "abnormal", "eof", "truncated", "error", "incomplete"] as const) { test(`summary guard validates completion and releases discarded event leases: ${kind}`, async () => { const budget = createTranslatorBudget(); From 674fecdeff23345cb9556e764f4db91b34bf725f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:42:17 +0900 Subject: [PATCH 5/6] fix(anthropic): let the adapter prohibit summary tool calls after building the prefix Replace the server-side catalog freeze with a _summaryProhibitsToolCalls flag. The Anthropic adapter derives tools and system from the caller's choice, including OAuth and escaped built-in name transforms, then sends tool_choice none. Adds OAuth web_fetch and escaped web_search wire regressions. Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- src/adapters/anthropic-tool-catalog.ts | 18 ----------- src/adapters/anthropic.ts | 3 ++ src/server/responses/response-effects.ts | 11 +++---- src/types/request.ts | 5 +++ structure/transports/responses-failover.md | 4 +-- structure/transports/responses.md | 2 +- ...ponses-anthropic-compaction-prefix.test.ts | 32 ++++++++++++++++--- 7 files changed, 43 insertions(+), 32 deletions(-) diff --git a/src/adapters/anthropic-tool-catalog.ts b/src/adapters/anthropic-tool-catalog.ts index 0797af25dc6..34bd84cd07d 100644 --- a/src/adapters/anthropic-tool-catalog.ts +++ b/src/adapters/anthropic-tool-catalog.ts @@ -9,21 +9,3 @@ export function anthropicDeclaredTools(parsed: OcxParsedRequest) { || (rejectsForcedToolChoice(parsed.modelId) && typeof choice === "object" && choice !== null && "name" in choice) ? tools.filter(toolChoiceToolPredicate(choice, tools)) : tools; } - -/** - * Pin the combined function and server-tool catalog an ordinary request would advertise, so a - * caller can switch the choice to "none" without changing the cached tools prefix. Mirrors the - * adapter: hosted tools yield to a same-named function in the full catalog, and a forced-choice - * downgrade keeps only the named tool. - */ -export function freezeAnthropicAdvertisedCatalog(parsed: OcxParsedRequest): void { - const functions = parsed.context.tools ?? []; - const choice = parsed.options.toolChoice; - const namedDowngrade = rejectsForcedToolChoice(parsed.modelId) - && typeof choice === "object" && choice !== null && "name" in choice; - if (parsed._anthropicServerTools) { - parsed._anthropicServerTools = parsed._anthropicServerTools.filter(tool => - !functions.some(f => f.name === tool.name) && (!namedDowngrade || tool.name === choice.name)); - } - if (parsed.context.tools) parsed.context.tools = anthropicDeclaredTools(parsed); -} diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 4251de21e29..643250d9680 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -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 diff --git a/src/server/responses/response-effects.ts b/src/server/responses/response-effects.ts index 2976e6170ea..199a8beb36a 100644 --- a/src/server/responses/response-effects.ts +++ b/src/server/responses/response-effects.ts @@ -1,5 +1,4 @@ import { requiresToolCall } from "../../types"; -import { freezeAnthropicAdvertisedCatalog } from "../../adapters/anthropic-tool-catalog"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; import type { ResponsesSidecarAuth } from "./request-sidecar-auth"; @@ -97,13 +96,11 @@ export function createResponsesEffects( delete parsed.options.toolChoice; delete parsed.options.parallelToolCalls; } else if (parsed._anthropicServerTools?.length || requiresToolCall(parsed.options.toolChoice)) { - // A mandatory choice would force the summary into a tool call the guard must reject. - // Freeze the catalog the ordinary request advertised: an allowed-tools or named choice - // narrows it, and "none" alone would advertise every tool and move the cached prefix. - freezeAnthropicAdvertisedCatalog(parsed); - // Hosted tools execute upstream: retain their cached definitions but prohibit execution. + // 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.options.toolChoice = "none"; + 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 diff --git a/src/types/request.ts b/src/types/request.ts index ef1577f3b27..b184d4ca450 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -76,6 +76,11 @@ export interface OcxParsedRequest { * them, and only for api.anthropic.com; every other destination never sees them. */ _anthropicServerTools?: readonly Record[]; + /** + * 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:` / `text:`). Same audience as `_anthropicServerTools`. diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index d4303343ac7..840859268d0 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -265,10 +265,10 @@ including when the destination is a combo, so request history names the phase th 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 `toolChoice: none` when server tools exist, preventing upstream execution. +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) becomes `toolChoice: none` after the request freezes the catalog the choice advertised, 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. +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. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 0215d23a786..d7db87c9cef 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -418,7 +418,7 @@ The continuation-note and continuation-wire tests establish prompt and wire cont | `shadow-target-availability.ts` | Shadow-call target resolution for `request-prepare.ts`: an unavailable target fails once with `409 intercept_target_unavailable` instead of reaching the native source model or the default provider. | | `request-transport.ts` | Live credential selection, dispatch bindings, adapter replacement and same-target request identity. Copilot Auto renewal onto Chat (including negotiation-401 refresh) signals the native owner before inference; `passthrough-dispatch.ts` reuses its inference-401/429 adapter handoff so JSON and SSE retain the Responses envelope. | | `request-sidecar-auth.ts` | Routed-compaction image projection, sidecar credential resolution and vision preprocessing. | -| `response-effects.ts` | Completion notification, replay publication, live request-tool aliases and routed summary controls; Anthropic keeps client tools/system/options, prohibiting upstream-hosted tools with `toolChoice: none`. | +| `response-effects.ts` | Completion notification, replay publication, live request-tool aliases and routed summary controls; Anthropic keeps client tools/system/options, and flags hosted-tool or mandatory-choice summaries so the adapter sends wire `tool_choice: none`. | | `request-send-budget.ts` | Request-wide send accounting, remaining allowance, the pending recovery permit and the shared ambiguous-resend grant. | | `reset-replay.ts` | Self-contained-request eligibility and the shared operator replacement grant for native Responses and generic translated initial/rebuilt pre-header sends; adapter-owned transports and translated post-header failures are excluded. | | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts index d17c3939ee1..22352ecb960 100644 --- a/tests/responses/responses-anthropic-compaction-prefix.test.ts +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -45,14 +45,14 @@ test("Anthropic compaction preserves client tool and system cache prefixes and t test("hosted server declarations stay cacheable but their execution is explicitly prohibited", () => { const parsed = applyEffects(true, true); expect(parsed._anthropicServerTools).toEqual([{ type: "web_search_20250305", name: "web_search" }]); - expect(parsed.options.toolChoice).toBe("none"); + expect(parsed._summaryProhibitsToolCalls).toBe(true); expect(parsed._webSearch).toBeUndefined(); }); for (const choice of ["required", { type: "function", name: "read" }]) test(`a mandatory tool choice cannot force the summary into a tool call: ${JSON.stringify(choice)}`, () => { const parsed = applyEffects(true, false, choice); expect(parsed.context.tools).toEqual(tools); - expect(parsed.options.toolChoice).toBe("none"); + expect(parsed._summaryProhibitsToolCalls).toBe(true); }); test("other adapters retain the existing compaction deletion behavior", () => { @@ -115,7 +115,7 @@ for (const [label, modelId, choice] of [ {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], ); - expect(summary.options.toolChoice).toBe("none"); + expect(summary._summaryProhibitsToolCalls).toBe(true); const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); const after = JSON.parse((await adapter.buildRequest(summary)).body as string); expect(before.tools.map((tool: { name: string }) => tool.name)).toEqual(["read"]); @@ -140,7 +140,7 @@ for (const [label, modelId, functions, choice] of [ {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], ); - expect(summary.options.toolChoice).toBe("none"); + expect(summary._summaryProhibitsToolCalls).toBe(true); const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); const after = JSON.parse((await adapter.buildRequest(summary)).body as string); expect(before.tools.map((tool: { name: string }) => tool.name)).toEqual(["read"]); @@ -149,6 +149,30 @@ for (const [label, modelId, functions, choice] of [ expect(after.tool_choice).toEqual({ type: "none" }); }); +for (const [label, auth, escape, hosted] of [ + ["OAuth renames a hosted web_fetch selector", "oauth", false, { type: "web_fetch_20250910", name: "web_fetch" }], + ["escaped built-in names rename a hosted web_search selector", "key", true, { type: "web_search_20250305", name: "web_search" }], +] as const) test(`provider wire-name transforms keep the ordinary tool prefix: ${label}`, async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: auth, apiKey: "synthetic-test-key", + ...(auth === "oauth" ? { oauthToken: "synthetic-oauth-token" } : {}), ...(escape ? { escapeBuiltinToolNames: true } : {}) }; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider as never, "short")); + const ordinary = { modelId: "claude-opus-5-5", options: { toolChoice: { type: "function", name: hosted.name }, parallelToolCalls: false }, + _anthropicServerTools: [hosted], + context: { tools: structuredClone(tools), systemPrompt: ["stable system"], messages: [{ role: "user", content: "earlier turn", timestamp: 1 }] } } as unknown as OcxParsedRequest; + const summary = structuredClone(ordinary); + type Args = Parameters; + createResponsesEffects( + { options: {}, req: new Request("http://localhost/v1/responses"), config: {} } as Args[0], + {} as Args[1], { parsed: summary, route: { provider: {} } } as Args[2], + { routedCompaction: true, preserveCompactionPrefix: true } as Args[3], + ); + const before = JSON.parse((await adapter.buildRequest(ordinary)).body as string); + const after = JSON.parse((await adapter.buildRequest(summary)).body as string); + expect(after.tools).toEqual(before.tools); + expect(after.system).toEqual(before.system); + expect(after.tool_choice).toEqual({ type: "none" }); +}); + for (const kind of ["text", "tool", "search", "empty", "progress", "abnormal", "eof", "truncated", "error", "incomplete"] as const) { test(`summary guard validates completion and releases discarded event leases: ${kind}`, async () => { const budget = createTranslatorBudget(); From 06ff79f9c6e13f774c97a504c3c94a7c8a72e65f Mon Sep 17 00:00:00 2001 From: JUN <243035832+lidge-jun@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:06:57 +0900 Subject: [PATCH 6/6] fix(anthropic): accept a summary whose completion omits the stop reason The summary guard rejected a done event without stop_reason, turning routed /v1/responses/compact into 502 for Anthropic-compatible upstreams that omit it (tests/server/server-auth.test.ts). Reject only tool attempts, empty summaries and explicit non-terminal stop reasons. Co-authored-by: S0RYUASUKA <64878945+S0RYUASUKA@users.noreply.github.com> --- src/responses/compaction-summary.ts | 6 ++++-- .../responses/responses-anthropic-compaction-prefix.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/responses/compaction-summary.ts b/src/responses/compaction-summary.ts index 2c6c8bd2be3..77f4905daa5 100644 --- a/src/responses/compaction-summary.ts +++ b/src/responses/compaction-summary.ts @@ -21,8 +21,10 @@ export async function* guardCompactionSummary( } if (event.type === "done") { if (!isTruncatedStopReason(event.stopReason) - && (attemptedTool || !hasText || event.endTurn === false - || (event.stopReason !== "end_turn" && event.stopReason !== "stop_sequence"))) { + // 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", diff --git a/tests/responses/responses-anthropic-compaction-prefix.test.ts b/tests/responses/responses-anthropic-compaction-prefix.test.ts index 22352ecb960..cd2e49800a7 100644 --- a/tests/responses/responses-anthropic-compaction-prefix.test.ts +++ b/tests/responses/responses-anthropic-compaction-prefix.test.ts @@ -197,7 +197,7 @@ for (const kind of ["text", "tool", "search", "empty", "progress", "abnormal", " releaseTranslatedEvent(event, budget); } expect(output.some(event => event.type.startsWith("tool_call") || event.type.startsWith("web_search_call"))).toBe(false); - if (["text", "truncated", "error", "incomplete"].includes(kind)) expect(output.at(-1)?.type).toBe(input.at(-1)?.type); + if (["text", "eof", "truncated", "error", "incomplete"].includes(kind)) expect(output.at(-1)?.type).toBe(input.at(-1)?.type); else expect(output.at(-1)).toMatchObject({ type: "error", code: "compaction_invalid_summary", retryable: false }); if (kind === "progress") expect(output.some(event => event.type === "text_delta")).toBe(false); expect(budget.snapshot().currentBytes).toBe(17);