diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 0b0f36739d5..f864bd06468 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -122,6 +122,8 @@ function applyCacheControlToLastText(blocks: Array>, cc: type PromptCachingOptions = { maxExplicitBreakpoints?: number; skipLastUser?: boolean; + /** The last system block is the caller's uncached per-turn tail; mark the block before it. */ + uncachedSystemTail?: boolean; }; /** Place explicit cache_control breakpoints on the built Anthropic body. */ @@ -158,7 +160,8 @@ function applyPromptCaching( // 2. system const system = body.system as Array> | undefined; if (system && system.length > 0) { - applyCacheControlToLast(system, cc); + const i = system.length - (options.uncachedSystemTail && system.length > 1 ? 2 : 1); + system[i] = { ...system[i], cache_control: cc }; used++; } if (used >= explicitLimit || !messages) return; @@ -759,7 +762,7 @@ function messagesToAnthropicFormat( parsed: OcxParsedRequest, toolNames: { toWire: (name: string) => string }, serverReplay?: ReadonlyMap[]>, -): { system: string | undefined; messages: unknown[] } { +): { system: string[]; messages: unknown[] } { // The client's server-tool exchanges go back ahead of the block they preceded. Their anchors are // unique in the caller's history (replayedAnthropicServerBlocks drops repeated ones); each is used once. const pendingReplay = new Map(serverReplay); @@ -787,9 +790,14 @@ function messagesToAnthropicFormat( tool => toolNames.toWire(namespacedToolName(tool.namespace, tool.name)), ); const systemParts = [...(parsed.context.systemPrompt ?? []), ...(toolCatalogNudge ? [toolCatalogNudge] : [])]; - const system = systemParts.length - ? identifyRoutedModel(systemParts.join("\n\n"), parsed.modelId) || undefined - : undefined; + // End a block at the caller's cached prefix so its breakpoint skips the per-turn tail. + const prefix = parsed._anthropicSystemCachePrefix + ? identifyRoutedModel(parsed._anthropicSystemCachePrefix, parsed.modelId) : undefined; + const head = prefix && systemParts[0] ? identifyRoutedModel(systemParts[0], parsed.modelId) : systemParts[0]; + const texts = prefix && head?.startsWith(`${prefix}\n\n`) + ? [prefix, [head.slice(prefix.length + 2), ...systemParts.slice(1)].join("\n\n")] + : [systemParts.join("\n\n")]; + const system = texts.map(text => identifyRoutedModel(text, parsed.modelId)).filter(Boolean); const messages: unknown[] = []; for (let i = 0; i < parsed.context.messages.length; i++) { @@ -1078,10 +1086,10 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti // Claude OAuth (Pro/Max) requires the first system block to be the Claude Code identity. body.system = [ { type: "text", text: CLAUDE_CODE_SYSTEM_INSTRUCTION }, - ...(system ? [{ type: "text", text: system }] : []), + ...system.map(text => ({ type: "text", text })), ]; - } else if (system) { - body.system = [{ type: "text", text: system }]; + } else if (system.length > 0) { + body.system = system.map(text => ({ type: "text", text })); } if (tools) body.tools = tools; if (parsed.options.temperature !== undefined) body.temperature = parsed.options.temperature; @@ -1277,6 +1285,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti applyPromptCaching(body, cc, { maxExplicitBreakpoints: explicitLimit, skipLastUser: !!automaticPromptCaching, + uncachedSystemTail: system.length > 1, }); enforceCacheControlLimit(body, explicitLimit); normalizeTtlOrdering(body); diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index e826afea5b2..0a8cf70fd84 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -41,6 +41,18 @@ export function systemToInstructions(system: unknown): string | undefined { return undefined; } +/** + * The `instructions` prefix ending at the caller's last cache-marked system block, when blocks + * follow it. Claude Code ends its system with a per-turn env block; joining everything into one + * Anthropic system block moved the breakpoint onto those changing bytes, so system and every + * later breakpoint missed on each turn. The Anthropic adapter splits its system here instead. + */ +export function systemCachePrefix(system: unknown): string | undefined { + if (!Array.isArray(system)) return undefined; + const last = system.findLastIndex(block => isRec(block) && block.cache_control != null); + return last >= 0 && last < system.length - 1 ? systemToInstructions(system.slice(0, last + 1)) : undefined; +} + export function toolsToResponses(tools: unknown): Rec[] | undefined { if (!Array.isArray(tools) || tools.length === 0) return undefined; // The shared hosted-search lane cannot enforce these constraints on every destination or diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 37f9523613b..f3ff2a3b4ae 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -45,7 +45,7 @@ import { stripOneMillionMarker } from "../claude/context-windows"; import { captureAdmissionPolicy } from "./capture-admission-policy"; import type { CapturedAdmissionPolicy } from "../types/admission-policy"; import { captureClaudeInbound } from "../claude/inbound-debug"; -import { declaredAnthropicServerTools, replayedAnthropicServerBlocks } from "../claude/inbound-content-options"; +import { declaredAnthropicServerTools, replayedAnthropicServerBlocks, systemCachePrefix } from "../claude/inbound-content-options"; import { claudeCodeForIngress } from "../claude/intercept/model-bindings"; import { classifyInterceptClient } from "../claude/intercept/client-class"; import { analyzeClaudeCompatibility, isClaudeCompatibilityMode } from "../claude/compatibility"; @@ -1286,6 +1286,7 @@ async function handleClaudeMessagesWithBudget( // Out of body like the replay above: the core hands these to an Anthropic destination only. anthropicServerTools: declaredAnthropicServerTools(anthropicBody), anthropicServerReplay: replayedAnthropicServerBlocks(anthropicBody), + anthropicSystemCachePrefix: isRec(anthropicBody) ? systemCachePrefix(anthropicBody.system) : undefined, // Preserve the original catalog through late fallback; only the physical adapter projects it. anthropicToolLoadingSource: cacheKeySource === "metadata" && isRec(anthropicBody) && Array.isArray(cc?.appendOnlyToolLoadingModels) && cc.appendOnlyToolLoadingModels.length > 0 diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index d6687bb0143..be3aa8d2791 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -198,6 +198,8 @@ export interface HandleResponsesOptions { anthropicServerTools?: readonly Record[]; /** The Claude client's replayable server-tool exchanges (`replayedAnthropicServerBlocks`); same scope. */ anthropicServerReplay?: ReadonlyMap[]>; + /** The caller's cached system prefix (`systemCachePrefix`); same scope. */ + anthropicSystemCachePrefix?: string; /** Metadata-qualified Messages discovery evidence, never caller JSON or a preselected destination. */ anthropicToolLoadingSource?: Readonly>; /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */ diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 100178f86c4..bf97d8f3aa2 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -515,6 +515,7 @@ export async function prepareResponsesRequest( parsed._nativeReasoningMint = options.nativeReasoningMint; parsed._anthropicServerTools = options.anthropicServerTools; parsed._anthropicServerReplay = options.anthropicServerReplay; + parsed._anthropicSystemCachePrefix = options.anthropicSystemCachePrefix; if (parsed.options.textFormat?.type === "json_schema") parsed._structuredOutputConformance = true; } parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort; diff --git a/src/types/request.ts b/src/types/request.ts index c5423f1a568..1e5878e0b5f 100644 --- a/src/types/request.ts +++ b/src/types/request.ts @@ -86,6 +86,11 @@ export interface OcxParsedRequest { * followed them (`call:` / `text:`). Same audience as `_anthropicServerTools`. */ _anthropicServerReplay?: ReadonlyMap[]>; + /** + * The caller's `instructions` prefix through its last cache-marked system block. The anthropic + * adapter ends a system block there so the breakpoint stays off the per-turn tail. + */ + _anthropicSystemCachePrefix?: string; /** * Boundary between replayed history and this turn's newly appended input. Usually the * items the proxy restored from local previous_response_id state; also set when the diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index 36068f40dbf..61139a17b46 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -205,7 +205,7 @@ rewrite rules and the routed-id settlement. | `src/adapters/openai-responses/transport.ts` | Shared Responses URL, configured auth and caller-header construction for ordinary requests and explicitly enabled custom native v1 compact. Its compact-handoff metadata copier uses the existing client-header allowlist, never credentials, through internal request construction and Codex auth materialization. Protocol capability never grants canonical identity; v2 trigger and native blob replay remain independently declared. On the noncanonical forward path, configured `baseUrl` trailing-slash stripping is a linear scan that preserves interior slash runs and leaves the canonical forward base and non-forward auth modes untouched; `tests/responses/responses-forward-transport-baseurl.test.ts` pins it. | | `src/responses/muse-tool-name-alias.ts` | Host-gated Meta Muse 64-char tool-name alias/restore used by the Responses passthrough. | | `src/adapters/openai-chat.ts`, `src/adapters/openai-chat/` | OpenAI-compatible Chat Completions bridge, split into leaves (`wire.ts`, `messages.ts`, `response-events.ts`, `passthrough.ts`, `parallel-tool-calls.ts`, `reasoning-wire.ts`, `serialized-tool-call-content.ts`, `serialized-tool-call-markup.ts`, `standalone-bare-tool-calls.ts`, `tool-call-validation.ts`, `tool-call-id-remint.ts`, `tool-schema.ts`, `errors.ts`). `parallel-tool-calls.ts` owns the `parallel_tool_calls` wire value for both the translated and native builders, so the three provider states — configured opt-out, configured opt-in, and the unset default that forwards only a caller's explicit `false` — cannot drift between them. `reasoning-wire.ts` applies explicit gateway-object and tool-bearing effort-omission declarations to both builders; absent declarations leave native raw forwarding unchanged. Its client delivery shapes in `src/chat/outbound.ts` and `src/server/chat-native-sse.ts` relay the upstream `service_tier` echo on non-stream, folded-stream, and synthesized-SSE bodies, never inventing the key when the upstream omits it. | -| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. Prompt-cache TTL: an omitted `cacheRetention` resolves through `src/adapters/anthropic/cache-retention.ts` to 1h (`ttl: "1h"`) for first-party `api.anthropic.com` (API key and OAuth pools) and to 5m for Anthropic-compatible endpoints, which do not document the 1h TTL; an explicit value wins everywhere. The native Messages passthrough upgrades caller breakpoints without a TTL to 1h under the same rule unless the caller pinned any breakpoint to another TTL. Usage carries `cache_creation.ephemeral_1h_input_tokens` as `cacheCreation1hInputTokens`, which `src/usage/cost.ts` prices at 1.6x the 5m write rate (2x base input). `tests/adapters/adapter-usage.test.ts`, `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts` and `tests/claude-integration/claude-translated-cache-ttl.test.ts` pin the defaults. | +| `src/adapters/anthropic.ts` | Anthropic Messages bridge. A `refusal` or `content_filter` stop reason yields an explicit `incomplete` event with `retryable: false` rather than `done` with that stopReason (#4312); `max_tokens` remains `done`. For a Responses client the bridge delivers that event, and the same one from the Kiro adapter, as a terminal `response.failed` ([Adapter-to-Responses bridge](transports/responses.md#adapter-to-responses-bridge)). It is the wire that defines `tools[*].strict` and `tools[*].allowed_callers`, so a rebuilt declaration carries both: an explicit `strict: true` and any `allowed_callers` the caller declared. An absent `strict` stays absent, because the Messages inbound records it as `false` and a `false` on the wire would read as an opt-out nobody asked for. Anthropic Fast uses the native `anthropic-speed` FastWire: a set decision sends `speed: "fast"` with `fast-mode-2026-02-01` in one case-insensitively merged, deduplicated `anthropic-beta` header that preserves OAuth betas. Stream and buffered `usage.speed` echoes confirm fast or downgrade to standard; no echo leaves the request assumed. `tests/adapters/anthropic/anthropic-fast-speed.test.ts` pins the wire and echoes. Anthropic Fast is opt-in: the registry marks both Anthropic entries `fastOptIn`, and `src/providers/fast-opt-in.ts` (`providerFastSwitchOff`) keeps Fast off until `providers..fastEnabled` is `true`. An off switch is provider capability `false`, applied in the FastPolicy authority (`service-tier.ts`), `resolveModelPolicy`, and router registry enrichment, so no model-level Fast toggle, `--fast` row, or proxy-generated `speed` field is produced. Native Claude Messages passthrough still forwards a `speed` field the caller sends itself, outside the proxy Fast policy. `tests/adapters/anthropic/anthropic-fast-opt-in.test.ts` pins the default, the switch, and the management PATCH/GET. For `api.anthropic.com` only, the Claude client's typed web tools (`parsed._anthropicServerTools`) follow the function tools unchanged, minus the caller's `cache_control`; a function tool of the same name wins, so the web-search sidecar's `web_search` keeps priority and `web_fetch` is still carried. No beta header is added: both tools are GA. Each streamed `web_search_tool_result` or `web_fetch_tool_result` becomes a `web_search_call_begin`/`web_search_call_end` pair (the query of its `server_tool_use` in the same response, hit URLs and titles, or `failed` for an error result) carrying `anthropic`: that `server_tool_use` with its complete streamed input and the result block, verbatim. An orphan result, a result with a different call id, or a result whose call input cannot be parsed is dropped with no synthetic call; the surrounding text is preserved. Streamed server-tool input retains at most 4,096 characters, slicing each incoming fragment to the remaining capacity; an oversized input that cannot be recovered drops its exchange under the same rule. The bridge and the direct encoder keep both on the `web_search_call` item as `anthropic_blocks` (a fetch is an `open_page` action), and the Claude outbound renders them back unchanged, with the original `srvtoolu_` id, `encrypted_content` and `page_age`; a fetch is not counted in `web_search_requests`. A search Anthropic defers beside a client `tool_use` has no result in that response and surfaces nothing. On the next turn, `messagesToAnthropicFormat` re-inserts the client's replayed pairs (`parsed._anthropicServerReplay`) ahead of the `tool_use`, text or thinking block that followed them, for `api.anthropic.com` only. An anchor must occur exactly once across assistant history, counting string and block content alike; repeated anchors drop their exchanges and preserve the other turns. Replay requires one-to-one call/result ids and matching tool types within the same assistant turn, with the call preceding its result in the original sequence. A single forward pass tracks pending calls and rejects reversed pairs, duplicate ids and orphan results. Calls require a plain input object with a nonempty `web_search.query` or `web_fetch.url` string; malformed input, a pair without `encrypted_content`, or a pair with no following anchor stays dropped. Stored `anthropic_blocks` restore only when call id, result id, tool/result types and input object agree; malformed metadata falls back to the rebuilt pair. Citations, a deferred search and `pause_turn` are not resumed. `tests/claude-integration/claude-translated-server-tools.test.ts` pins the wire, the sidecar priority, the bridge's client stream and JSON, and the direct encoder's stream. On translated Claude Messages requests, the inbound path carries the caller's last cache-marked system prefix out of the request body. When that prefix still matches the translated instructions after routed-model identity normalization and has a trailing system block, the Anthropic adapter splits it from the per-turn tail and moves its existing system cache breakpoint to the prefix; absent or mismatched prefixes keep the prior single-block behavior. `tests/claude-integration/claude-translated-cache-ttl.test.ts` checks the stable cached bytes across changing tails. Prompt-cache TTL: an omitted `cacheRetention` resolves through `src/adapters/anthropic/cache-retention.ts` to 1h (`ttl: "1h"`) for first-party `api.anthropic.com` (API key and OAuth pools) and to 5m for Anthropic-compatible endpoints, which do not document the 1h TTL; an explicit value wins everywhere. The native Messages passthrough upgrades caller breakpoints without a TTL to 1h under the same rule unless the caller pinned any breakpoint to another TTL. Usage carries `cache_creation.ephemeral_1h_input_tokens` as `cacheCreation1hInputTokens`, which `src/usage/cost.ts` prices at 1.6x the 5m write rate (2x base input). `tests/adapters/adapter-usage.test.ts`, `tests/adapters/anthropic/anthropic-messages-passthrough.test.ts` and `tests/claude-integration/claude-translated-cache-ttl.test.ts` pin the defaults. | | `src/adapters/anthropic-model-contract.ts` | Per-family Anthropic Messages wire rules, shared by the adapter and the web-search and vision sidecars, measured live on 2026-09-29, with Haiku 5.5 documented on 2026-10-08: adaptive vs budget thinking (Haiku 5.5+ is adaptive); explicit `thinking: disabled` (Sonnet 5.0 up to but excluding 5.5, Haiku 5.5+ without effort); the `between_tools` floor that replaces it on Sonnet 5.5+ (sent without an effort, since xhigh/max reject it); forced `tool_choice` downgraded to `auto` on Opus 5.5, Fable 5.1+ and Sonnet 5.5+; `temperature`/`top_p` dropped on Opus 4.7+, Sonnet 5+, Haiku 5.5+ and every Fable, which 400 on any non-default value; `top_p` dropped when sent with `temperature` on the 4.5/4.6 families, which take either alone but not both; and sidecar thinking-off fields, a low effort with no `thinking` for Opus 5.5 and Fable, which reject both off switches. Dotted Bedrock ids do not parse as a family, and the Messages-native passthrough forwards caller fields unchanged. `tests/adapters/anthropic/anthropic-sonnet-5-5-contract.test.ts` and `tests/adapters/anthropic/anthropic-haiku-5-5-contract.test.ts` pin the family table. | | `src/adapters/google.ts` | Gemini bridge. The final wire compiler owns [endpoint-scoped tool-schema loss policy](providers/google.md#google-tool-schema-loss-reporting): compatible mode changes no request bytes, strict initial loss creates no physical send, and strict non-direct repair creates no changed repair send. A caller-declared strict tool selects `functionCallingConfig.mode: "VALIDATED"` in place of the absent-choice default; `NONE`, `ANY` and a forced-name choice are stronger constraints the caller asked for and are never overwritten. | | `src/adapters/unique-tool-call-ids.ts` | Request-scoped tool-call-id uniqueness for every `openai-chat` provider. An upstream that mints an id from the call's position in its response repeats `call-0-0` on every turn; a Messages client has already paired that id, drops the duplicate, and is left with a call that has no result, so the turn reads as empty and the model re-issues it indefinitely. Only a **repeat** is rewritten — the first occurrence stays byte-identical, leaving prompt-cache keys, reasoning-replay lookups and already-unique upstreams untouched. The ids to avoid come from the caller's history, captured in `buildRequest` (the only point that sees it) and applied at emission, never at ingestion: ingestion matches streamed deltas against the id upstream sent, so rewriting there would strip a pending call of its identity mid-stream. A repeat takes a `-` suffix, never `_`, because `_` reads as a batch sub-call of ``; occupied-set search resumes by suffix width and retained base prefix, including when siblings converge as `-9` becomes `-10`. Covered by `tests/adapters/openai/openai-chat-tool-call-id-remint.test.ts`. | diff --git a/tests/claude-integration/claude-translated-cache-ttl.test.ts b/tests/claude-integration/claude-translated-cache-ttl.test.ts index 154423fa9f4..8eb99000d38 100644 --- a/tests/claude-integration/claude-translated-cache-ttl.test.ts +++ b/tests/claude-integration/claude-translated-cache-ttl.test.ts @@ -4,9 +4,12 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import { callerCacheRetention } from "../../src/claude/inbound"; -import type { OcxConfig } from "../../src/types"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { NEUTRAL_IDENTITY_LINE } from "../../src/adapters/identity"; +import { createAnthropicAdapter } from "../../src/adapters/anthropic"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; // A combo route declines the native Messages lane, so the anthropic adapter places its own // breakpoints. Their TTL follows the operator setting, then the caller's explicit TTLs, then the @@ -29,6 +32,19 @@ afterAll(() => { removeTreeWithRetry(home); }); +function sseReply(): Response { + const frames = [ + { type: "message_start", message: { id: "msg_x", type: "message", role: "assistant", model: MODEL, content: [], stop_reason: null, usage: { input_tokens: 1, 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: "ok" } }, + { type: "content_block_stop", index: 0 }, + { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, + { type: "message_stop" }, + ]; + return new Response(frames.map(f => `event: ${f.type}\ndata: ${JSON.stringify(f)}\n\n`).join(""), + { headers: { "content-type": "text/event-stream" } }); +} + async function wireTtls( callerTtl: "1h" | undefined, cacheRetention?: "short" | "long", @@ -39,16 +55,7 @@ async function wireTtls( const wires: string[] = []; const fetch = (async (_input: unknown, init?: RequestInit) => { wires.push(String(init?.body)); - const frames = [ - { type: "message_start", message: { id: "msg_x", type: "message", role: "assistant", model: MODEL, content: [], stop_reason: null, usage: { input_tokens: 1, 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: "ok" } }, - { type: "content_block_stop", index: 0 }, - { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 1 } }, - { type: "message_stop" }, - ]; - return new Response(frames.map(f => `event: ${f.type}\ndata: ${JSON.stringify(f)}\n\n`).join(""), - { headers: { "content-type": "text/event-stream" } }); + return sseReply(); }) as typeof globalThis.fetch; const config = { port: 0, defaultProvider: "anth", @@ -153,3 +160,78 @@ describe("callerCacheRetention walks every block container", () => { })).toBeUndefined(); }); }); + +describe("translated Claude lane system breakpoint", () => { + // Claude Code marks its stable system block and ends with a per-turn env block. The single + // joined system block put the breakpoint on the env bytes, so no turn could read the cache. + async function wireSystem(env: string, markPrefix = true, stable = "Stable instructions."): Promise { + const wires: string[] = []; + const fetch = (async (_input: unknown, init?: RequestInit) => { + wires.push(String(init?.body)); + return sseReply(); + }) as typeof globalThis.fetch; + const config = { + port: 0, defaultProvider: "anth", claudeCode: { nativePassthrough: false }, + providers: { anth: { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "sk-ant-fixture", models: [MODEL], fetch } }, + } as unknown as OcxConfig; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: MODEL, max_tokens: 64, + system: [ + { type: "text", text: "You are Claude Code." }, + { type: "text", text: stable, ...(markPrefix ? { cache_control: { type: "ephemeral" } } : {}) }, + { type: "text", text: env }, + ], + messages: [{ role: "user", content: "Hi." }], + }), + }), config, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(wires).toHaveLength(1); + return JSON.parse(wires[0]!).system; + } + + test("the breakpoint ends at the caller's cached block, before the per-turn tail", async () => { + const first = await wireSystem("Today: 2026-11-03"); + const second = await wireSystem("Today: 2026-11-04"); + const cached = (system: unknown[]) => system.filter(b => (b as { cache_control?: unknown }).cache_control); + expect(cached(first)).toHaveLength(1); + expect(cached(first)).toEqual(cached(second)); + expect((cached(first)[0] as { text: string }).text).toBe("You are Claude Code.\n\nStable instructions."); + expect(JSON.stringify(cached(first))).not.toContain(""); + expect(JSON.stringify(first.at(-1))).toContain("2026-11-03"); + }); + + test("without a marked system prefix the previous single-block behavior remains", async () => { + const system = await wireSystem("turn", false); + expect(system).toHaveLength(1); + expect(JSON.stringify(system[0])).toContain("Stable instructions."); + expect(JSON.stringify(system[0])).toContain("turn"); + expect((system[0] as { cache_control?: unknown }).cache_control).toBeDefined(); + }); + + test("a mismatched prefix keeps multiple translated system parts in one cached block", async () => { + const provider = { adapter: "anthropic", baseUrl: "https://api.anthropic.com", authMode: "key", apiKey: "sk-ant-fixture" } as OcxProviderConfig; + const parsed = { modelId: MODEL, stream: false, options: {}, + context: { systemPrompt: ["Changed instructions.\n\nTail.", "Additional instructions."], messages: [{ role: "user", content: "Hi." }] }, + _anthropicSystemCachePrefix: "Original instructions." } as OcxParsedRequest; + const adapter = withTestTranslatorBudget(createAnthropicAdapter(provider)); + const { body } = await adapter.buildRequest(parsed); + const system = JSON.parse(typeof body === "string" ? body : JSON.stringify(body)).system; + expect(system).toEqual([{ type: "text", text: "Changed instructions.\n\nTail.\n\nAdditional instructions.", + cache_control: { type: "ephemeral", ttl: "1h" } }]); + }); + + test("identity normalization preserves the marked boundary across changing tails", async () => { + const first = await wireSystem("first", true, NEUTRAL_IDENTITY_LINE); + const second = await wireSystem("second", true, NEUTRAL_IDENTITY_LINE); + const cached = (system: unknown[]) => system.filter(b => (b as { cache_control?: unknown }).cache_control); + expect(cached(first)).toHaveLength(1); + expect(cached(first)).toEqual(cached(second)); + expect(JSON.stringify(cached(first))).toContain(MODEL); + expect(JSON.stringify(cached(first))).not.toContain(""); + expect(JSON.stringify(first.at(-1))).toContain("first"); + }); +});