diff --git a/apps/gateway/src/routes/execute.test.ts b/apps/gateway/src/routes/execute.test.ts index bde9d892..fbf26663 100644 --- a/apps/gateway/src/routes/execute.test.ts +++ b/apps/gateway/src/routes/execute.test.ts @@ -2698,6 +2698,74 @@ describe("createExecute — native protocol passthrough (#217)", () => { expect(okRow?.provider_model).toBe("claude-x"); }); + it("stabilizes Claude Code billing cch on Anthropic native passthrough so prompt cache survives turns", async () => { + const provider = anthropicProvider(NATIVE_RESP); + const execute = createExecute({ + defaultProvider: provider, + providers: new Map([["anthro", provider]]), + registry: protocolRegistry({ + a: { + providerName: "anthro", + providerModel: "claude-x", + targetProviderProtocol: "anthropic_messages", + }, + }), + breaker: breaker(), + catalog: new Map(), + now: clock(), + signal: new AbortController().signal, + nativeProtocolPassthroughEnabled: () => true, + }); + const carrier = (cch: string, text: string): NativePassthroughCarrier => ({ + protocol: "anthropic_messages", + body: { + model: "claude-x", + max_tokens: 16, + system: [ + { + type: "text", + text: `x-anthropic-billing-header: cc_version=2.1.175.baa; cc_entrypoint=cli; cch=${cch};`, + }, + { type: "text", text: "You are Claude Code.", cache_control: { type: "ephemeral" } }, + { type: "text", text: "stable project rules", cache_control: { type: "ephemeral" } }, + ], + tools: [{ name: "Read", input_schema: { type: "object" } }], + messages: [{ role: "user", content: [{ type: "text", text }] }], + }, + raw_body: `raw-${cch}`, + headers: { "x-api-key": "client" }, + mutations: {}, + }); + + const first = await execute( + plan(["a"]), + anthropicReq({ native_request: carrier("aaaaa", "first turn") }), + ); + const second = await execute( + plan(["a"]), + anthropicReq({ native_request: carrier("bbbbb", "a much longer follow-up turn") }), + ); + + const firstForwarded = provider.nativePassthrough.mock + .calls[0]?.[0] as NativePassthroughCarrier; + const secondForwarded = provider.nativePassthrough.mock + .calls[1]?.[0] as NativePassthroughCarrier; + const cchOf = (forwarded: NativePassthroughCarrier) => + String( + ((forwarded.body.system as Array>)[0] as { text?: unknown }).text, + ).match(/\bcch=([0-9a-f]{5});/)?.[1]; + expect(cchOf(firstForwarded)).toMatch(/^[0-9a-f]{5}$/); + expect(cchOf(secondForwarded)).toBe(cchOf(firstForwarded)); + expect(cchOf(firstForwarded)).not.toBe("aaaaa"); + expect(cchOf(secondForwarded)).not.toBe("bbbbb"); + expect(firstForwarded.raw_body).toBeUndefined(); + expect(firstForwarded.mutations.body_shims_applied).toEqual([ + "anthropic_billing_cch_stabilized", + ]); + expect(first.attempts[0]?.passthrough_mutations).toMatchObject(firstForwarded.mutations); + expect(second.attempts[0]?.passthrough_mutations).toMatchObject(secondForwarded.mutations); + }); + it("native passthrough strips Anthropic effort when the target model does not support it", async () => { const provider = anthropicProvider(NATIVE_RESP); const haikuEntry: CatalogEntry = { diff --git a/apps/gateway/src/routes/execute.ts b/apps/gateway/src/routes/execute.ts index db8cb499..afef315d 100644 --- a/apps/gateway/src/routes/execute.ts +++ b/apps/gateway/src/routes/execute.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { CircuitBreaker, ExecuteOutcome, @@ -50,6 +51,10 @@ import { usageFromResponsesResponse, } from "./payload-capture.js"; +const ANTHROPIC_BILLING_HEADER_PREFIX = "x-anthropic-billing-header:"; +const ANTHROPIC_BILLING_CCH_RE = /\bcch=([0-9a-f]{5});/i; +const ANTHROPIC_BILLING_CCH_PLACEHOLDER = "cch=00000;"; + // Gateway execution adapter — the `execute` injected into routeRequest. It walks // the resolved candidate chain (ExecutionPlan.candidate_chain) honoring the // EXECUTION-fallback rules (CLAUDE.md principle 5, docs/04): @@ -535,6 +540,59 @@ function sanitizeAnthropicNativeBody(body: Record): { : { body, strippedEmptyTextBlocks: 0 }; } +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map((item) => stableJson(item)).join(",")}]`; + if (isRecord(value)) { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`) + .join(",")}}`; + } + return JSON.stringify(value) ?? "null"; +} + +function stabilizeAnthropicBillingCch(body: Record): { + body: Record; + stabilized: boolean; +} { + const system = body.system; + if (!Array.isArray(system)) return { body, stabilized: false }; + const first = system[0]; + if (!isRecord(first)) return { body, stabilized: false }; + const text = first.text; + if ( + typeof text !== "string" || + !text.startsWith(ANTHROPIC_BILLING_HEADER_PREFIX) || + !ANTHROPIC_BILLING_CCH_RE.test(text) + ) { + return { body, stabilized: false }; + } + + const placeholderText = text.replace(ANTHROPIC_BILLING_CCH_RE, ANTHROPIC_BILLING_CCH_PLACEHOLDER); + const placeholderSystem = [{ ...first, text: placeholderText }, ...system.slice(1)]; + // Only cache-prefix material feeds this compatibility hash. Including messages would + // reproduce Claude Code's per-turn rotating cch and defeat prompt caching again. + const cachePrefixFingerprint = stableJson({ + model: typeof body.model === "string" ? body.model : null, + system: placeholderSystem, + tools: body.tools ?? null, + }); + const stableCch = createHash("sha256").update(cachePrefixFingerprint).digest("hex").slice(0, 5); + const nextText = text.replace(ANTHROPIC_BILLING_CCH_RE, `cch=${stableCch};`); + if (nextText === text) return { body, stabilized: false }; + return { + body: { + ...body, + system: [{ ...first, text: nextText }, ...system.slice(1)], + }, + stabilized: true, + }; +} + function prepareNativeRequestForUpstream( nativeRequest: InternalRequest["native_request"], providerModel: string, @@ -618,6 +676,14 @@ function prepareNativeRequestForUpstream( mutations.empty_anthropic_text_blocks_stripped = sanitized.strippedEmptyTextBlocks; } } + const stableBilling = stabilizeAnthropicBillingCch(body); + if (stableBilling.stabilized) { + body = stableBilling.body; + bodyChanged = true; + if (mutations) { + appendMutationList(mutations, "body_shims_applied", ["anthropic_billing_cch_stabilized"]); + } + } } // Lane-forced reasoning override (issue: lane-forced-reasoning): rewrite the diff --git a/implementation-notes.md b/implementation-notes.md index d247ff2c..9900ab83 100644 --- a/implementation-notes.md +++ b/implementation-notes.md @@ -7,6 +7,14 @@ --- +## 2026-07-06 · Anthropic native passthrough 稳定 Claude Code billing cch(Provider execution / prompt cache,docs/04/05,原则 3/5/7/8) + +- **背景(Lukin)**:线上 `claude-fable-5` 请求大多是 Anthropic native passthrough,理论上应保留 Claude Code 原始 body;但 production SQLite 样本显示同一会话里 `system[0]` 的 `x-anthropic-billing-header` 只有 `cch` 每轮变化(`cc_version`/`cc_entrypoint` 稳定),导致 Anthropic prompt cache 的严格前缀匹配被第一块内容打断,缓存读取只覆盖小前缀,成本显著偏高。 +- **问题归属**:这不是 Helm 协议转换 correctness bug;原样转发本身成立。这里是对 Claude Code 官方 billing header 与 Anthropic prompt cache 机制冲突的兼容性 workaround:为了自托管网关的成本边界,允许 native passthrough 做一个可审计的最小 body shim。 +- **修复边界**:只在 `protocol === anthropic_messages` 且 `system[0]` 明确是 `x-anthropic-billing-header`、含 5 位 hex `cch` 时触发;保留 `cc_version`、`cc_entrypoint`、system/messages/tools 正文和 cache_control,仅把 `cch` 替换成由稳定 cache-prefix 材料(resolved model、system、tools,排除 messages)计算出的 5 位值。没有该 header 的 native 请求完全不变。 +- **观测决策**:触发时写入 passthrough mutation `body_shims_applied: ["anthropic_billing_cch_stabilized"]`,并清掉 raw_body 走重序列化,避免 telemetry 显示仍是旧的客户端 raw body。后续线上可按该 mutation 与 `cached_tokens/cache_creation_tokens` 验证成本改善。 +- **风险边界**:如果 Anthropic 将来开始强校验官方 `cch` 与整请求字节一致,这个 shim 可能引发上游拒绝;届时可通过 native passthrough flag 或后续 runtime setting 回滚。当前生产证据显示 `cch` 更像缓存/归因指纹而非认证字段。 + ## 2026-07-06 · Admin 请求详情 payload 改为分段懒加载(Admin requests performance,docs/07/11,原则 1/7) - **背景(Lukin)**:线上 `/admin/requests/:traceId` 详情页会在首屏同时拉完整 request/response/upstream payload;部分记录超过 1MB,经公网和未压缩 JSON 传输后容易出现长时间白屏/卡顿。