From b8aaf18f37bf5a16afb37bbd521ecbf5cccb19b9 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:20:53 +0200 Subject: [PATCH 1/3] mason: match Claude Code 2.1.302 beta lists and guard mutations Select beta lists by request class instead of body shape: main turns now use the captured main lists even without tools/companion fields; auxiliary quota probes and title/summary requests use their captured lists. This deliberately changes the old shape-distinction and binding-beta expectations, so those tests are rewritten and the contract change is documented in CHANGELOG. Remove obsolete effort/system, advisor and fallback-credit betas; retain request-dependent server fallback and other features. Advertise binding-controls and display-updates on main requests without changing binding fields, summarized thinking or effort-marker bodies. Regenerate the seven identity wire vectors with unchanged inputs, bodies and fixed values. Add five catalogue controls for gzip CCH reset, both rejection branches, process latch and Fable per-turn control; all five were CAUGHT through the pinned wrapper runner. Build, typechecks, lint/format, Core (1872), Pi (305) and vectors (11) passed on Bun 1.3.14. OpenCode ran 2230 tests with 3 unrelated failures: offline packed-host refusal, sidebar tokenCalls expected 0/received 1, and quota-admission timeout; no retry-to-green or deadline changes. --- ARCHITECTURE.md | 4 +- CHANGELOG.md | 3 +- docs/models.md | 12 +- mutations.toml | 84 ++++++++++++- packages/core/src/claude-code.ts | 70 +++++------ .../src/mid-conversation-output-config.ts | 29 +---- .../core/src/tests/claude-code-betas.test.ts | 117 ++++++++++++++++++ .../mid-conversation-output-config.test.ts | 49 +++++--- packages/e2e-tests/tests/tool-prefix.test.ts | 2 +- .../opencode/src/tests/claude-code.test.ts | 46 ++++--- .../fixtures/claude-oauth-wire/README.md | 43 +++++-- .../fixtures/claude-oauth-wire/SHA256SUMS | 4 +- .../fixtures/claude-oauth-wire/vectors.json | 24 ++-- packages/opencode/src/tests/haiku55.test.ts | 4 +- packages/opencode/src/tests/index.test.ts | 2 +- packages/opencode/src/tests/transform.test.ts | 23 ++-- packages/opencode/src/transform.ts | 10 -- packages/pi/src/stream.ts | 18 +-- packages/pi/src/tests/scoped-stream.test.ts | 16 +-- packages/pi/src/tests/stream.test.ts | 3 + 20 files changed, 390 insertions(+), 173 deletions(-) create mode 100644 packages/core/src/tests/claude-code-betas.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5b7e08fbd..7af8e0f9d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -166,12 +166,12 @@ **ThinkingBindingControls:** - Purpose: Controls how Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 handle replayed thinking whose original conversation prefix changed after host compaction or prompt transformation - Location: `packages/core/src/thinking-binding.ts` -- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. When an OAuth request replays thinking from an earlier assistant response, changes to the messages, instructions or tools before that response can cause Anthropic to reject its signature. For Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 requests using adaptive thinking, `thinkingBinding.prefixMismatchBehavior` controls that case: `account-default` sends no override and leaves the decision to Anthropic, `error` requests rejection of mismatched thinking, and `drop_block` requests removal of the affected thinking blocks. Both explicit settings add `thinking.block_binding.prefix_mismatch_behavior` and beta `thinking-binding-controls-2026-08-01`. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes remain unchanged. +- Pattern: Shared outgoing-body inspection recognizes non-empty signed `thinking` and `redacted_thinking` blocks. When an OAuth request replays thinking from an earlier assistant response, changes to the messages, instructions or tools before that response can cause Anthropic to reject its signature. For Fable 5.1, Opus 5.5, Sonnet 5.5, and Haiku 5.5 requests using adaptive thinking, `thinkingBinding.prefixMismatchBehavior` controls that case: `account-default` sends no override and leaves the decision to Anthropic, `error` requests rejection of mismatched thinking, and `drop_block` requests removal of the affected thinking blocks. Both explicit settings add `thinking.block_binding.prefix_mismatch_behavior`. OAuth main requests advertise `thinking-binding-controls-2026-08-01` regardless of whether a binding field is present, matching Claude Code 2.1.302; auxiliary title/quota lists omit it. Sonnet 5.5's `between_tools` mode rejects this field and does not receive it. First turns, Mythos 5.1, older models, and API-key routes receive no new binding field. **MidConversationOutputConfig:** - Purpose: Preserves Fable 5.1, Opus 5.5 and Sonnet 5.5 prompt-cache prefixes while users change reasoning effort between turns - Location: `packages/core/src/mid-conversation-output-config.ts`, with host history adapters in `packages/opencode/src/effort-history.ts` and `packages/pi/src/effort-history.ts` -- Pattern: The top-level `output_config.effort` remains anchored to the earliest retained effort. Later changes become empty `role: "system"` messages immediately before their user/tool-result boundary. OAuth Fable 5.1 requests always include `mid-conversation-output-config-2026-07-01`. OAuth Opus 5.5 and Sonnet 5.5 requests always include `per-turn-control-2026-07-01`, the beta Claude Code sends for those two models, and receive effort markers only with adaptive thinking (Sonnet 5.5 `between_tools` rejects a changed per-message effort). For those models a request without an effort change is left as the host built it, and a turn without a selected effort uses the model default (`medium` on Opus 5.5, `high` on Sonnet 5.5). Anthropic also documents per-message effort for Mythos 5.1, Opus 5 and Haiku 5.5, but there is no Claude Code capture to match, so those models, other models and API-key routes get neither beta nor markers. Each model family keeps its own timeline, so turns sent to another family in the same session are ignored. Before OpenCode lowers host records into Anthropic messages, it adds stable, checksum-bound transition markers to lowerable user boundaries and, when transitions exist, a plan-bound anchor at the current user boundary. The messages hook records the expected timeline in a bounded request tracker; the later `chat.headers` hook attaches a fixed-size plan through an internal header after every message transform has run and retains it for automatic retries of the same message. The fetch transform validates the lowered markers against that trusted timeline. A current-boundary anchor may precede a trailing user-role `tool_result` only when every such block matches a `tool_use` in the immediately preceding assistant. Consecutive host user records can merge into one wire user message: the flat marker sequence must still match the plan, and the last transition on that boundary supplies the effective effort. If a downstream transform legitimately trims a history prefix, the surviving markers must be an exact suffix and the current-boundary anchor must match the original plan; consumed transitions are folded into the retained top-level baseline. A bounded, message-revocable history retains an older request plan if the same user message is re-planned after compaction while its header is still in flight. Total marker loss without a surviving authenticated anchor remains fail-closed: the remaining body cannot establish that only a prefix was removed. Holes, reorderings, mutations, or a missing/mismatched anchor still fail locally before cache shaping, billing-suffix derivation, dumping, signing, or transport. A missing-all refusal logs only its marker count, resolved-plan status, last retained user index/block counts and expected anchor hash; no prompt content or marker bytes are emitted. The transform strips all markers and the internal header before dispatch. Stable marker identity prevents a downstream transform's persisted source text from resurrecting an obsolete per-pass nonce. This avoids both assistant-record count assumptions and transform-order coupling in tool-heavy or compacted histories. Pi rebuilds transitions directly from active compaction-aware session entries on each turn. +- Pattern: The top-level `output_config.effort` remains anchored to the earliest retained effort. Later changes become empty `role: "system"` messages immediately before their user/tool-result boundary. OAuth Fable 5.1, Opus 5.5 and Sonnet 5.5 requests always include `per-turn-control-2026-07-01`, matching Claude Code 2.1.302. Opus 5.5 and Sonnet 5.5 receive effort markers only with adaptive thinking (Sonnet 5.5 `between_tools` rejects a changed per-message effort). For those models a request without an effort change is left as the host built it, and a turn without a selected effort uses the model default (`medium` on Opus 5.5, `high` on Sonnet 5.5). Haiku 5.5 also carries the per-turn-control beta to match Claude Code 2.1.302, but still uses top-level effort without markers. Mythos 5.1, Opus 5, other models and API-key routes get neither this beta nor effort markers. Each model family keeps its own timeline, so turns sent to another family in the same session are ignored. Before OpenCode lowers host records into Anthropic messages, it adds stable, checksum-bound transition markers to lowerable user boundaries and, when transitions exist, a plan-bound anchor at the current user boundary. The messages hook records the expected timeline in a bounded request tracker; the later `chat.headers` hook attaches a fixed-size plan through an internal header after every message transform has run and retains it for automatic retries of the same message. The fetch transform validates the lowered markers against that trusted timeline. A current-boundary anchor may precede a trailing user-role `tool_result` only when every such block matches a `tool_use` in the immediately preceding assistant. Consecutive host user records can merge into one wire user message: the flat marker sequence must still match the plan, and the last transition on that boundary supplies the effective effort. If a downstream transform legitimately trims a history prefix, the surviving markers must be an exact suffix and the current-boundary anchor must match the original plan; consumed transitions are folded into the retained top-level baseline. A bounded, message-revocable history retains an older request plan if the same user message is re-planned after compaction while its header is still in flight. Total marker loss without a surviving authenticated anchor remains fail-closed: the remaining body cannot establish that only a prefix was removed. Holes, reorderings, mutations, or a missing/mismatched anchor still fail locally before cache shaping, billing-suffix derivation, dumping, signing, or transport. A missing-all refusal logs only its marker count, resolved-plan status, last retained user index/block counts and expected anchor hash; no prompt content or marker bytes are emitted. The transform strips all markers and the internal header before dispatch. Stable marker identity prevents a downstream transform's persisted source text from resurrecting an obsolete per-pass nonce. This avoids both assistant-record count assumptions and transform-order coupling in tool-heavy or compacted histories. Pi rebuilds transitions directly from active compaction-aware session entries on each turn. **FableFallbackManager:** - Purpose: Provides deterministic 10-successful-response Opus 4.8 recovery as the default server policy's refusal backstop and as the exclusive policy under `OPENCODE_ANTHROPIC_AUTH_FALLBACK_MODE=legacy` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b09b06a8..e20341b22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ This repo is a CortexKit-maintained Anthropic auth monorepo for OpenCode and Pi. ### Claude Code identity - OAuth requests now present as Claude Code 2.1.302 instead of 2.1.280, matched against an interactive 2.1.302 capture: `User-Agent: claude-cli/2.1.302 (external, cli)`, `x-stainless-package-version: 0.128.0`, and the 2.1.302 billing block. A request for a user prompt now also carries `cc_turn_origin=human; cc_prompt_index=N; cc_turn_index=N;`, where `N` counts the prompts in the conversation and stays the same for tool-result follow-ups, plus the `x-claude-code-prompt-id` header. Every request carries `x-claude-code-request-class` (`main`, or `auxiliary` for title, summary and quota-window requests). The `cc_version` suffix and `cch` rules are unchanged. -- Beta lists follow 2.1.302: `claude-code-20250219` comes first in the full-agent list, `per-turn-control-2026-07-01` sits directly after `prompt-caching-scope-2026-01-05` and is now also sent on Fable 5.1 and Haiku 5.5, and full-agent requests add `mid-conversation-tool-changes-2026-07-01`, `mid-conversation-system-clear-at-2026-08-21` and `thinking-binding-controls-2026-08-01`. Betas Claude Code only sends behind a feature flag (`advisor-tool`, `fallback-credit`, `mid-conversation-system`) and `mid-conversation-output-config` for Fable 5.1 effort changes are kept. `thinking-display-updates-2026-08-18` is not sent because thinking stays `display: "summarized"`. +- Beta lists now follow Claude Code 2.1.302's request classes rather than body-shape heuristics: all main turns use the captured main list, even without tools or companion fields. Auxiliary one-token quota probes use the quota list; other auxiliary requests use title/summary betas, including redact-thinking. JSON schema, fast mode, context-window and server-side-fallback feature betas remain request-dependent. This deliberately replaces the previous shape-distinction contract and its tests. +- Main requests advertise `thinking-binding-controls-2026-08-01` even without replayed thinking; the binding field itself still requires replayed thinking and an explicit setting. `per-turn-control-2026-07-01` follows prompt-caching-scope (after claude-code on Haiku 5.5) on Sonnet 5.5, Opus 5.5, Fable 5.1 and Haiku 5.5. Fable 5.1 effort changes use it with the same empty system messages and `output_config.effort`. The obsolete mid-conversation-system and mid-conversation-output-config betas are no longer sent. Advisor-tool and fallback-credit are also removed: the plugin enables neither an advisor nor a fallback credit code. `server-side-fallback` is unchanged. `thinking-display-updates-2026-08-18` is also sent on main requests, so no baseline beta difference remains from the capture. The body keeps `thinking.display: "summarized"` where Claude Code sends `"updates"`, because `"updates"` streams no thinking text; the beta alone does not suppress summarized thinking. ### Request compression diff --git a/docs/models.md b/docs/models.md index c17d181d0..8f3505c76 100644 --- a/docs/models.md +++ b/docs/models.md @@ -33,7 +33,7 @@ OpenCode's OAuth model costs are zeroed by default because subscription use is q Fable/Mythos reasoning uses `thinking: { type: "adaptive", display: "summarized" }` with `output_config.effort`, not manual `budget_tokens`. Opus 5/5.5 and Sonnet 5/5.5 also request summarized adaptive thinking so the host can display reasoning. -OpenCode exposes native `low`, `medium`, `high`, `xhigh` and `max` effort variants for Fable 5.1, Opus 5/5.5, Sonnet 5.5 and Haiku 5.5. On OAuth routes, effort changes for Fable 5.1, Opus 5.5 and Sonnet 5.5 are carried as mid-conversation system messages rather than rewriting the cached prefix. Opus 5.5 and Sonnet 5.5 need adaptive thinking for this; Sonnet 5.5 `between_tools` keeps the host's top-level effort. Opus 5, Haiku 5.5 and Mythos 5.1 still change the top-level effort. OpenCode 2 supplies its own system messages recording each effort change at that point in the conversation; the plugin does not insert a second copy of those effort-change system messages or alter earlier cached history. +OpenCode exposes native `low`, `medium`, `high`, `xhigh` and `max` effort variants for Fable 5.1, Opus 5/5.5, Sonnet 5.5 and Haiku 5.5. On OAuth routes, effort changes for Fable 5.1, Opus 5.5 and Sonnet 5.5 are carried as empty mid-conversation system messages with `output_config.effort`, under `per-turn-control-2026-07-01`, rather than rewriting the cached prefix. Opus 5.5 and Sonnet 5.5 need adaptive thinking for this; Sonnet 5.5 `between_tools` keeps the host's top-level effort. Opus 5, Haiku 5.5 and Mythos 5.1 still change the top-level effort; Haiku 5.5 carries the per-turn-control beta to match Claude Code but does not receive effort markers. OpenCode 2 supplies its own system messages recording each effort change at that point in the conversation; the plugin does not insert a second copy of those effort-change system messages or alter earlier cached history. - **Opus 5.5:** adaptive thinking is always on. Disabled thinking and manual budgets are replaced with summarized adaptive thinking. Forced tool choice is removed. - **Opus 5:** an explicit disabled-thinking request is preserved; incompatible `xhigh`/`max` effort is capped to `high`. @@ -50,7 +50,15 @@ Anthropic associates replayed reasoning blocks with the messages, instructions a | `error` | Ask Anthropic to reject a prefix mismatch | | `drop_block` | Ask Anthropic to discard mismatched thinking blocks | -For an explicit `error` or `drop_block` value, the plugin sends the selected server policy as `thinking.block_binding` and enables the thinking-binding beta, but only when adaptive thinking replays a signed `thinking` or nonempty `redacted_thinking` block. It does not apply to Sonnet's `between_tools` mode or a first turn without replayed thinking. See [settings ownership](configuration.md#settings). +For an explicit `error` or `drop_block` value, the plugin sends the selected server policy as `thinking.block_binding`, but only when adaptive thinking replays a signed `thinking` or nonempty `redacted_thinking` block. The field does not apply to Sonnet's `between_tools` mode or a first turn without replayed thinking. OAuth main requests advertise the thinking-binding beta even without a binding field, matching Claude Code 2.1.302; advertising support alone does not select a server policy. See [settings ownership](configuration.md#settings). + +## OAuth beta lists + +Beta selection follows the request class, not the presence of tools or companion body fields. Conversation turns use Claude Code 2.1.302's main list, including `thinking-binding-controls-2026-08-01`. Auxiliary one-token, non-streaming quota probes without thinking, tools or structured output use the smaller quota list; other auxiliary requests use the title/summary list, including `redact-thinking-2026-02-12`. JSON-schema requests additionally advertise structured output; fast mode, server-side fallback and context-window feature betas retain their existing request-dependent selection. + +The main list sends `per-turn-control-2026-07-01` for Sonnet 5.5, Opus 5.5, Fable 5.1 and Haiku 5.5, but not Opus 4.8. Haiku 5.5 places `claude-code-20250219` after the prompt-caching-scope beta. The obsolete mid-conversation-system and mid-conversation-output-config betas are absent. Advisor-tool and fallback-credit are also absent because the plugin neither enables an advisor nor supplies a fallback credit code. `server-side-fallback` is a separate request feature and is unchanged. + +The beta lists match the capture, including `thinking-display-updates-2026-08-18` on main requests. Our bodies keep `thinking.display: "summarized"` where Claude Code sends `"updates"`, because `"updates"` streams no thinking text. The beta alone does not suppress summarized thinking. The binding fields and effort-marker bodies are unchanged. ## Claude fast mode diff --git a/mutations.toml b/mutations.toml index 791e51cb9..758ae76d6 100644 --- a/mutations.toml +++ b/mutations.toml @@ -7,6 +7,86 @@ prebuild = [ { name = "opencode-current-source", command = ["bun", "run", "--cwd", "packages/opencode", "build"] }, ] +[[control]] +id = "gzip-resets-cch-before-compression" +guards = "The compressed body must carry cch=00000 rather than the identity body's signature." +file = "packages/core/src/request-gzip.ts" +old = "const text = resetBillingHeaderCCH(identityBody) + requestGzipPadding(random)" +new = "const text = identityBody + requestGzipPadding(random) // NON-VACUITY BREAK: compress the signed cch" +test_file = "packages/core/src/tests/request-gzip.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/request-gzip.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["request-body gzip as Claude Code 2.1.302 sends it > a large OAuth body goes out gzipped with cch=00000 and valid padding"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "gzip-classifies-non-anthropic-rejection" +guards = "A 400, 403 or 415 without an Anthropic request-id or cf-ray must trigger an identity resend." +file = "packages/core/src/request-gzip.ts" +old = "if (!hasAnthropicEdgeHeader(response.headers))" +new = "if (false && !hasAnthropicEdgeHeader(response.headers)) // NON-VACUITY BREAK: ignore proxy gzip rejections" +test_file = "packages/core/src/tests/request-gzip.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/request-gzip.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["a rejected gzipped body > 400 without request-id or cf-ray: one identity resend with the same request id, then latched"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "gzip-classifies-json-parse-400" +guards = "An Anthropic JSON-parse 400 must trigger an identity resend even with a valid request-id." +file = "packages/core/src/request-gzip.ts" +old = "if (status === 400 && isInvalidJsonBody(await readStart(response)))" +new = "if (status === 400 && false && isInvalidJsonBody(await readStart(response))) // NON-VACUITY BREAK: ignore JSON-parse gzip rejections" +test_file = "packages/core/src/tests/request-gzip.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/request-gzip.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["a rejected gzipped body > 400 from Anthropic: request body is not valid JSON: one identity resend with the same request id, then latched"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "gzip-rejection-latches-process" +guards = "After a gzip rejection, the next request must be identity even when no latch is persisted." +file = "packages/core/src/request-gzip.ts" +old = " options.latch.latchProcess()" +new = " void options.latch // NON-VACUITY BREAK: retry gzip after an origin rejection" +test_file = "packages/core/src/tests/request-gzip.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/request-gzip.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["a rejected gzipped body > 400 behind Cloudflare: plain bad json text: one identity resend with the same request id, then latched"] +expect_message = "error: expect(received).toBe(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + +[[control]] +id = "fable-51-effort-uses-per-turn-control" +guards = "Fable 5.1 effort markers need per-turn-control, without either obsolete mid-conversation beta." +file = "packages/core/src/mid-conversation-output-config.ts" +old = " isClaudeFable51Model(model) ||" +new = " false || // NON-VACUITY BREAK: omit Fable 5.1 per-turn control" +test_file = "packages/core/src/tests/mid-conversation-output-config.test.ts" +runner = "command" +command = ["bun", "scripts/run-mutation-bun.ts", "packages/core/src/tests/mid-conversation-output-config.test.ts", "{test}"] +test_count_pattern = "CKDEV executed {count} tests" +expect_red = ["Fable 5.1 mid-conversation output configuration > carries Fable 5.1 effort changes under per-turn control without obsolete betas"] +expect_message = "error: expect(received).toContain(expected)" +broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core", "tmp/mutations/core.xml"] +broad_report = "tmp/mutations/core.xml" +broad_id = "{name}" + [[control]] id = "history-refuses-meaningful-assistant" guards = "Meaningful assistant trailers must be refused without deleting history or replaying the preceding question." @@ -903,7 +983,7 @@ broad_id = "{name}" [[control]] id = "pi-cachekeep-prewarm-body-betas" -guards = "Pi's CacheKeep prewarm must send the betas the model request sends for the same body, including the Fable 5.1 effort beta its system effort markers need." +guards = "Pi's CacheKeep prewarm must send the betas the model request sends for the same body, including per-turn control for Fable 5.1 system effort markers." file = "packages/pi/src/stream.ts" old = " applyPiOAuthRequestHeaders(headers, piAttemptAccessToken(served), {" new = " applyClaudeCodeHeaders(headers, piAttemptAccessToken(served), { extraBetas: [CACHE_KEEP_EXTENDED_TTL_BETA], // NON-VACUITY BREAK: rebuild prewarm betas without the body's betas" @@ -911,7 +991,7 @@ test_file = "packages/pi/src/tests/scoped-stream.test.ts" runner = "command" command = ["bun", "scripts/run-mutation-bun.ts", "packages/pi/src/tests/scoped-stream.test.ts", "{test}"] test_count_pattern = "CKDEV executed {count} tests" -expect_red = ["Pi CacheKeep prewarm of a Fable 5.1 body with effort markers sends the mid-conversation output config beta", "Pi CacheKeep prewarm of a Fable 5.1 body without effort markers sends the model request betas"] +expect_red = ["Pi CacheKeep prewarm of a Fable 5.1 body with effort markers sends the per-turn control beta", "Pi CacheKeep prewarm of a Fable 5.1 body without effort markers sends the model request betas"] expect_message = "error: expect(received).toContain(expected)" broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] broad_report = "tmp/mutations/pi.xml" diff --git a/packages/core/src/claude-code.ts b/packages/core/src/claude-code.ts index 5bb87c992..fa98901dc 100644 --- a/packages/core/src/claude-code.ts +++ b/packages/core/src/claude-code.ts @@ -384,15 +384,10 @@ export function applyClaudeCodeMetadata( return true } -// Claude Code sends redact-thinking-2026-02-12 (2.1.258 to 2.1.302), but it -// suppresses thinking-block content that OpenCode displays; deliberately -// omitted after A/B proof on 2026-09-01. -// -// The lists follow Claude Code 2.1.302's order. Some betas Claude Code adds -// only when a feature flag or entitlement is on (advisor-tool, fallback-credit, -// mid-conversation-system): they are kept because real users' requests carry -// them. thinking-display-updates-2026-08-18 is omitted because it goes with -// `thinking.display: "updates"`, and these requests keep `"summarized"`. +// The lists follow Claude Code 2.1.302's main, title and quota requests. +// Advisor and fallback-credit betas require features these requests do not use. +// The display-updates beta does not change the body: summarized thinking still +// streams text, unlike Claude Code's `thinking.display: "updates"`. export const CLAUDE_CODE_FULL_AGENT_BETAS = [ 'claude-code-20250219', 'oauth-2025-04-20', @@ -401,41 +396,35 @@ export const CLAUDE_CODE_FULL_AGENT_BETAS = [ 'context-management-2025-06-27', 'prompt-caching-scope-2026-01-05', 'mid-conversation-tool-changes-2026-07-01', - 'advisor-tool-2026-03-01', 'advanced-tool-use-2025-11-20', - 'mid-conversation-system-2026-04-07', 'mid-conversation-system-clear-at-2026-08-21', 'effort-2025-11-24', - 'fallback-credit-2026-06-01', 'thinking-binding-controls-2026-08-01', + 'thinking-display-updates-2026-08-18', 'extended-cache-ttl-2025-04-11', 'cache-diagnosis-2026-04-07', ] as const -const CLAUDE_CODE_STRUCTURED_OUTPUT_BETAS = [ +const CLAUDE_CODE_TITLE_BETAS = [ 'oauth-2025-04-20', 'interleaved-thinking-2025-05-14', + 'redact-thinking-2026-02-12', 'thinking-token-count-2026-05-13', 'context-management-2025-06-27', 'prompt-caching-scope-2026-01-05', 'mid-conversation-tool-changes-2026-07-01', - 'advisor-tool-2026-03-01', 'mid-conversation-system-clear-at-2026-08-21', 'effort-2025-11-24', - 'structured-outputs-2025-12-15', 'cache-diagnosis-2026-04-07', ] as const -const CLAUDE_CODE_BASE_BETAS = [ +const CLAUDE_CODE_QUOTA_BETAS = [ 'oauth-2025-04-20', 'interleaved-thinking-2025-05-14', + 'redact-thinking-2026-02-12', 'thinking-token-count-2026-05-13', 'context-management-2025-06-27', 'prompt-caching-scope-2026-01-05', - 'advisor-tool-2026-03-01', - 'advanced-tool-use-2025-11-20', - 'extended-cache-ttl-2025-04-11', - 'cache-diagnosis-2026-04-07', ] as const const CLAUDE_CODE_BETA = 'claude-code-20250219' @@ -447,29 +436,29 @@ function hasStructuredOutput(body: Record) { return format?.type === 'json_schema' } -function hasFullAgentShape(body: Record) { +function isQuotaProbe(body: Record) { return ( - Array.isArray(body.tools) && - body.tools.length > 0 && - Array.isArray(body.system) && - isRecord(body.thinking) && - isRecord(body.context_management) && - isRecord(body.output_config) && - isRecord(body.diagnostics) + body.max_tokens === 1 && + !body.stream && + !body.thinking && + (!Array.isArray(body.tools) || body.tools.length === 0) && + !hasStructuredOutput(body) ) } export function selectClaudeCodeBetas( body?: Record | null, extraBetas: string[] = [], + requestClass: ClaudeCodeRequestClass = 'main', ) { - const selected: string[] = body - ? hasFullAgentShape(body) + // A conversation turn uses the main list even without tools or companion + // fields. Only auxiliary one-token probes use the smaller quota list. + const selected: string[] = + requestClass === 'main' ? [...CLAUDE_CODE_FULL_AGENT_BETAS] - : hasStructuredOutput(body) - ? [...CLAUDE_CODE_STRUCTURED_OUTPUT_BETAS] - : [...CLAUDE_CODE_BASE_BETAS] - : [...CLAUDE_CODE_BASE_BETAS] + : body && isQuotaProbe(body) + ? [...CLAUDE_CODE_QUOTA_BETAS] + : [...CLAUDE_CODE_TITLE_BETAS] // Claude Code 2.1.302 puts claude-code-20250219 after the prompt-caching // scope beta on Haiku 5.5 requests instead of first. @@ -495,6 +484,14 @@ export function selectClaudeCodeBetas( else selected.push(PER_TURN_CONTROL_BETA) } + if (body && hasStructuredOutput(body)) { + const index = selected.indexOf('cache-diagnosis-2026-04-07') + selected.splice( + index < 0 ? selected.length : index, + 0, + 'structured-outputs-2025-12-15', + ) + } if (body?.speed === 'fast') selected.push(FAST_MODE_BETA) for (const beta of extraBetas) { const trimmed = beta.trim() @@ -558,7 +555,10 @@ export function applyClaudeCodeHeaders( headers.set('authorization', `Bearer ${accessToken}`) headers.set('content-type', 'application/json') headers.set('user-agent', USER_AGENT) - headers.set('anthropic-beta', selectClaudeCodeBetas(options.body, extraBetas)) + headers.set( + 'anthropic-beta', + selectClaudeCodeBetas(options.body, extraBetas, options.requestClass), + ) headers.set('anthropic-dangerous-direct-browser-access', 'true') headers.set('anthropic-version', '2023-06-01') headers.set('x-app', 'cli') diff --git a/packages/core/src/mid-conversation-output-config.ts b/packages/core/src/mid-conversation-output-config.ts index b1ecaf740..de5c24d52 100644 --- a/packages/core/src/mid-conversation-output-config.ts +++ b/packages/core/src/mid-conversation-output-config.ts @@ -5,14 +5,10 @@ import { isClaudeSonnet55Model, } from './models.ts' -/** Per-message effort beta sent with every OAuth Fable 5.1 request. */ -export const MID_CONVERSATION_OUTPUT_CONFIG_BETA = - 'mid-conversation-output-config-2026-07-01' - /** - * Per-turn control beta that Claude Code sends on every Opus 5.5 and Sonnet - * 5.5 request. It enables effort changes carried by an empty `role: "system"` - * message on those two models. + * Per-turn control beta that Claude Code sends on Opus 5.5, Sonnet 5.5, + * Fable 5.1 and Haiku 5.5. It enables effort changes carried by empty + * `role: "system"` messages on the eligible models below. */ export const PER_TURN_CONTROL_BETA = 'per-turn-control-2026-07-01' @@ -45,22 +41,9 @@ export function normalizeAdaptiveEffort( } /** - * Whether an OAuth request for this model carries the Fable 5.1 per-message - * effort beta (`MID_CONVERSATION_OUTPUT_CONFIG_BETA`). Opus 5.5 and Sonnet - * 5.5 use `PER_TURN_CONTROL_BETA` instead. - */ -export function usesMidConversationOutputConfig( - body: Record, -): boolean { - return isClaudeFable51Model(body.model) -} - -/** - * Opus 5.5 and Sonnet 5.5, including dated and `[1m]` context-window ids. - * Their OAuth requests always carry `PER_TURN_CONTROL_BETA`, as Claude Code's - * do. Anthropic also documents per-message effort for Mythos 5.1, Opus 5 and - * Haiku 5.5, but without a Claude Code capture to match, those models keep - * sending requests without the beta or effort markers. + * Models that require adaptive thinking for per-message effort: Opus 5.5 + * and Sonnet 5.5, including dated and `[1m]` context-window ids. Header + * eligibility is separate; `sendsPerTurnControlBeta` follows Claude Code. */ export function usesPerTurnControl(model: unknown): boolean { return isClaudeOpus55Model(model) || isClaudeSonnet55Model(model) diff --git a/packages/core/src/tests/claude-code-betas.test.ts b/packages/core/src/tests/claude-code-betas.test.ts new file mode 100644 index 000000000..b7b4e0ec8 --- /dev/null +++ b/packages/core/src/tests/claude-code-betas.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from 'bun:test' +import { + applyClaudeCodeHeaders, + selectClaudeCodeBetas, +} from '../claude-code.ts' + +// Literal beta lists from recorded interactive Claude Code 2.1.302 requests, +// not computed by the selector under test. They include display-updates while +// our bodies keep summarized thinking so that thinking text still streams. +const MAIN = + 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07' +const OPUS48_MAIN = + 'claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07' +const HAIKU55_MAIN = + 'oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07' +const TITLE = + 'oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,structured-outputs-2025-12-15,cache-diagnosis-2026-04-07' +const QUOTA = + 'oauth-2025-04-20,interleaved-thinking-2025-05-14,redact-thinking-2026-02-12,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05' + +for (const [model, expected] of [ + ['claude-sonnet-5-5', MAIN], + ['claude-opus-5-5', MAIN], + ['claude-fable-5-1', MAIN], + ['claude-haiku-5-5', HAIKU55_MAIN], + ['claude-opus-4-8', OPUS48_MAIN], +] as const) { + test(`${model} main betas match the capture without changing the body`, () => { + const body = { + model, + max_tokens: 16, + thinking: { type: 'adaptive', display: 'summarized' }, + messages: [{ role: 'user', content: 'ok' }], + } + const before = JSON.stringify(body) + const headers = applyClaudeCodeHeaders(new Headers(), 'test-token', { + body, + }) + expect(headers.get('anthropic-beta')).toBe(expected) + expect(headers.get('x-claude-code-request-class')).toBe('main') + expect(JSON.stringify(body)).toBe(before) + expect('block_binding' in body.thinking).toBe(false) + }) +} + +test('Haiku 5.5 auxiliary structured title betas match the capture', () => { + const body = { + model: 'claude-haiku-5-5', + max_tokens: 100, + messages: [{ role: 'user', content: 'title' }], + output_config: { format: { type: 'json_schema' } }, + } + const before = JSON.stringify(body) + const headers = applyClaudeCodeHeaders(new Headers(), 'test-token', { + body, + requestClass: 'auxiliary', + }) + expect(headers.get('anthropic-beta')).toBe(TITLE) + expect(headers.get('x-claude-code-request-class')).toBe('auxiliary') + expect(JSON.stringify(body)).toBe(before) +}) + +test('Haiku 4.5 auxiliary one-token quota betas match the capture', () => { + const body = { + model: 'claude-haiku-4-5-20251001', + max_tokens: 1, + messages: [{ role: 'user', content: '0' }], + metadata: { user_id: 'test' }, + } + const before = JSON.stringify(body) + const headers = applyClaudeCodeHeaders(new Headers(), 'test-token', { + body, + requestClass: 'auxiliary', + }) + expect(headers.get('anthropic-beta')).toBe(QUOTA) + expect(headers.get('x-claude-code-request-class')).toBe('auxiliary') + expect(JSON.stringify(body)).toBe(before) + expect(selectClaudeCodeBetas(body)).toBe(OPUS48_MAIN) +}) + +test('auxiliary summaries use title betas without requesting structured output', () => { + expect( + selectClaudeCodeBetas({ model: 'claude-haiku-5-5' }, [], 'auxiliary'), + ).toBe(TITLE.replace(',structured-outputs-2025-12-15', '')) + expect( + selectClaudeCodeBetas( + { + model: 'claude-haiku-5-5', + max_tokens: 1, + stream: true, + }, + [], + 'auxiliary', + ), + ).toBe(TITLE.replace(',structured-outputs-2025-12-15', '')) +}) + +test('main feature betas retain their positions without changing binding fields', () => { + const body = { + model: 'claude-opus-4-8', + speed: 'fast', + output_config: { format: { type: 'json_schema' } }, + } + const before = JSON.stringify(body) + const betas = selectClaudeCodeBetas(body, [ + 'context-1m-2025-08-07', + 'server-side-fallback-2026-07-01', + ]) + expect(betas).toBe( + OPUS48_MAIN.replace( + ',cache-diagnosis-2026-04-07', + ',structured-outputs-2025-12-15,cache-diagnosis-2026-04-07', + ) + + ',fast-mode-2026-02-01,context-1m-2025-08-07,server-side-fallback-2026-07-01', + ) + expect(JSON.stringify(body)).toBe(before) +}) diff --git a/packages/core/src/tests/mid-conversation-output-config.test.ts b/packages/core/src/tests/mid-conversation-output-config.test.ts index 259aedb01..48fc9c046 100644 --- a/packages/core/src/tests/mid-conversation-output-config.test.ts +++ b/packages/core/src/tests/mid-conversation-output-config.test.ts @@ -3,11 +3,10 @@ import { selectClaudeCodeBetas } from '../claude-code.ts' import { applyMidConversationOutputConfig, defaultAdaptiveEffort, - MID_CONVERSATION_OUTPUT_CONFIG_BETA, normalizeAdaptiveEffort, PER_TURN_CONTROL_BETA, + sendsPerTurnControlBeta, supportsPerMessageEffort, - usesMidConversationOutputConfig, usesPerTurnControl, } from '../mid-conversation-output-config.ts' @@ -110,15 +109,34 @@ describe('Fable 5.1 mid-conversation output configuration', () => { ]), ).toEqual({ applied: false, inserted: 0 }) expect(request).toEqual(before) - expect(usesMidConversationOutputConfig(request)).toBe(false) + expect(sendsPerTurnControlBeta(model)).toBe(false) } }) - test('always identifies Fable 5.1 requests for the beta header', () => { - expect(usesMidConversationOutputConfig(body())).toBe(true) - expect(MID_CONVERSATION_OUTPUT_CONFIG_BETA).toBe( - 'mid-conversation-output-config-2026-07-01', - ) + test('carries Fable 5.1 effort changes under per-turn control without obsolete betas', () => { + for (const model of [ + 'claude-fable-5-1', + 'claude-fable-5-1[1m]', + 'claude-fable-5-1-20260918', + ]) { + const request = body(model) + expect( + applyMidConversationOutputConfig(request, [ + { afterAssistantMessages: 0, effort: 'low' }, + { afterAssistantMessages: 1, effort: 'high' }, + ]), + ).toEqual({ applied: true, inserted: 1 }) + expect(request.messages).toContainEqual({ + role: 'system', + content: [], + output_config: { effort: 'high' }, + }) + expect(request.output_config).toMatchObject({ effort: 'low' }) + const betas = selectClaudeCodeBetas(request).split(',') + expect(betas).toContain(PER_TURN_CONTROL_BETA) + expect(betas).not.toContain('mid-conversation-output-config-2026-07-01') + expect(betas).not.toContain('mid-conversation-system-2026-04-07') + } }) test('normalizes host effort names', () => { @@ -190,14 +208,14 @@ describe('per-message effort beyond Fable 5.1', () => { } }) - test(`${model} carries the per-turn control beta, not the Fable 5.1 beta`, () => { + test(`${model} carries the per-turn control beta without obsolete betas`, () => { const request = adaptiveBody(model) expect(supportsPerMessageEffort(request)).toBe(true) expect(usesPerTurnControl(model)).toBe(true) - expect(usesMidConversationOutputConfig(request)).toBe(false) const betas = selectClaudeCodeBetas(request).split(',') expect(betas).toContain(PER_TURN_CONTROL_BETA) - expect(betas).not.toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) + expect(betas).not.toContain('mid-conversation-output-config-2026-07-01') + expect(betas).not.toContain('mid-conversation-system-2026-04-07') }) } @@ -224,14 +242,11 @@ describe('per-message effort beyond Fable 5.1', () => { } }) - test('keeps Fable 5.1 eligible without inspecting thinking and on its own beta', () => { + test('keeps Fable 5.1 eligible without inspecting thinking', () => { const request = body() expect(supportsPerMessageEffort(request)).toBe(true) expect(usesPerTurnControl(request.model)).toBe(false) - // Claude Code 2.1.302 also sends per-turn control on Fable 5.1; its - // effort markers still rely on the mid-conversation output-config beta, - // which the OAuth header helpers add for every Fable 5.1 request. - expect(usesMidConversationOutputConfig(request)).toBe(true) + expect(sendsPerTurnControlBeta(request.model)).toBe(true) expect(selectClaudeCodeBetas(request).split(',')).toContain( PER_TURN_CONTROL_BETA, ) @@ -273,7 +288,7 @@ describe('per-message effort beyond Fable 5.1', () => { model === 'claude-haiku-5-5', ) expect(betas.split(',')).not.toContain( - MID_CONVERSATION_OUTPUT_CONFIG_BETA, + 'mid-conversation-output-config-2026-07-01', ) } }) diff --git a/packages/e2e-tests/tests/tool-prefix.test.ts b/packages/e2e-tests/tests/tool-prefix.test.ts index cf8c6faee..af0e91b20 100644 --- a/packages/e2e-tests/tests/tool-prefix.test.ts +++ b/packages/e2e-tests/tests/tool-prefix.test.ts @@ -170,7 +170,7 @@ describe('OpenCode Anthropic auth e2e', () => { const serialized = JSON.stringify(request.body) expect(serialized).not.toContain('cortexkit-internal-effort') expect(request.headers['anthropic-beta']?.split(',')).toContain( - 'mid-conversation-output-config-2026-07-01', + 'per-turn-control-2026-07-01', ) } }, 90_000) diff --git a/packages/opencode/src/tests/claude-code.test.ts b/packages/opencode/src/tests/claude-code.test.ts index 27b5d9378..ca529dd94 100644 --- a/packages/opencode/src/tests/claude-code.test.ts +++ b/packages/opencode/src/tests/claude-code.test.ts @@ -112,16 +112,17 @@ describe('Claude Code fingerprint helpers', () => { expect(fullBetas).not.toContain('redact-thinking-2026-02-12') expect(fullBetas).not.toContain('context-1m-2025-08-07') expect(fullBetas).toContain('effort-2025-11-24') - expect(fullBetas).toContain('mid-conversation-system-2026-04-07') - expect(fullBetas).toContain('fallback-credit-2026-06-01') + expect(fullBetas).not.toContain('mid-conversation-system-2026-04-07') + expect(fullBetas).not.toContain('mid-conversation-output-config-2026-07-01') + expect(fullBetas).not.toContain('advisor-tool-2026-03-01') + expect(fullBetas).not.toContain('fallback-credit-2026-06-01') // Claude Code 2.1.302 sends these on every full-agent request; 2.1.280 // did not. expect(fullBetas).toContain('mid-conversation-tool-changes-2026-07-01') expect(fullBetas).toContain('mid-conversation-system-clear-at-2026-08-21') expect(fullBetas).toContain('thinking-binding-controls-2026-08-01') - // Claude Code sends this beta with thinking.display "updates"; these - // requests keep "summarized", so they leave it out. - expect(fullBetas).not.toContain('thinking-display-updates-2026-08-18') + // Advertising display-updates does not opt the body out of summarized text. + expect(fullBetas).toContain('thinking-display-updates-2026-08-18') }) test('adds the per-turn control beta after the prompt-caching scope beta for Opus 5.5, Sonnet 5.5, Fable 5.1 and Haiku 5.5', () => { @@ -177,15 +178,19 @@ describe('Claude Code fingerprint helpers', () => { ).not.toContain('per-turn-control-2026-07-01') }) - test('selects structured-output betas without full-agent private betas', () => { - const betas = selectClaudeCodeBetas({ - model: 'claude-haiku-4-5-20251001', - messages: [{ role: 'user', content: 'title' }], - system: [{ type: 'text', text: 'system' }], - tools: [], - output_config: { format: { type: 'json_schema' } }, - stream: true, - }).split(',') + test('selects auxiliary structured-output betas without main-only betas', () => { + const betas = selectClaudeCodeBetas( + { + model: 'claude-haiku-4-5-20251001', + messages: [{ role: 'user', content: 'title' }], + system: [{ type: 'text', text: 'system' }], + tools: [], + output_config: { format: { type: 'json_schema' } }, + stream: true, + }, + [], + 'auxiliary', + ).split(',') expect(betas).toContain('structured-outputs-2025-12-15') expect(betas).toContain('thinking-token-count-2026-05-13') @@ -195,7 +200,7 @@ describe('Claude Code fingerprint helpers', () => { expect(betas).not.toContain('extended-cache-ttl-2025-04-11') }) - test('does not add full-agent-only betas for tool requests missing captured companion fields', () => { + test('uses main betas for tool requests even without companion fields', () => { const betas = selectClaudeCodeBetas({ model: 'claude-sonnet-4-6', messages: [{ role: 'user', content: 'hi' }], @@ -207,18 +212,19 @@ describe('Claude Code fingerprint helpers', () => { expect(betas).toContain('thinking-token-count-2026-05-13') expect(betas).toContain('advanced-tool-use-2025-11-20') expect(betas).toContain('extended-cache-ttl-2025-04-11') - expect(betas).not.toContain('claude-code-20250219') + expect(betas).toContain('claude-code-20250219') expect(betas).not.toContain('context-1m-2025-08-07') - expect(betas).not.toContain('effort-2025-11-24') + expect(betas).toContain('effort-2025-11-24') + expect(betas).toContain('thinking-binding-controls-2026-08-01') }) - test('does not add full-agent betas when request shape is unavailable', () => { + test('defaults to main betas when request shape is unavailable', () => { const betas = selectClaudeCodeBetas(null).split(',') for (const beta of REQUIRED_BETAS) expect(betas).toContain(beta) - expect(betas[0]).toBe('oauth-2025-04-20') + expect(betas[0]).toBe('claude-code-20250219') expect(betas).toContain('thinking-token-count-2026-05-13') - expect(betas).not.toContain('claude-code-20250219') + expect(betas).toContain('claude-code-20250219') }) test('applies Claude Code headers and couples session id to metadata', () => { diff --git a/packages/opencode/src/tests/fixtures/claude-oauth-wire/README.md b/packages/opencode/src/tests/fixtures/claude-oauth-wire/README.md index 2e43e563d..64a34d3c6 100644 --- a/packages/opencode/src/tests/fixtures/claude-oauth-wire/README.md +++ b/packages/opencode/src/tests/fixtures/claude-oauth-wire/README.md @@ -12,13 +12,13 @@ field-by-field explanation is `blueprint.md` (written for 2.1.280; see | id | model | what it covers | |----|-------|----------------| -| `fable51-first-turn` | `claude-fable-5-1` | first turn: billing block with `cc_prompt_id` only; system sanitising and identity block; forced adaptive thinking; effort pinned; `fallbacks:"default"` + server-side-fallback beta; mid-conversation-output-config beta; `diagnostics.previous_message_id: null`; explicit cache (host breakpoints → `ttl:"1h"`) | +| `fable51-first-turn` | `claude-fable-5-1` | first turn: billing block with `cc_prompt_id` only; system sanitising and identity block; forced adaptive thinking; effort pinned; `fallbacks:"default"` + server-side-fallback beta; per-turn-control beta; `diagnostics.previous_message_id: null`; explicit cache (host breakpoints → `ttl:"1h"`) | | `opus55-continuation-replayed-thinking` | `claude-opus-5-5` | continuation replaying a signed thinking block; `cc_prev_req` + `cc_prompt_id`; per-turn-control beta; `thinking.block_binding` + thinking-binding beta (account setting `drop_block`); hybrid cache anchors | | `opus55-effort-change` | `claude-opus-5-5` | effort high → low on turn 2: inserted `{"role":"system","content":[],"output_config":{"effort":"low"}}`, top-level effort = baseline `high`, internal marker blocks removed; cache off (5-minute breakpoints) | | `sonnet55-between-tools` | `claude-sonnet-5-5` | host `thinking: disabled` → `between_tools`, effort `xhigh` → `high`; no fallback; automatic cache (one top-level `cache_control`) | | `haiku55-tools-continuation` | `claude-haiku-5-5` | tools: `mcp_Read`/`mcp_Bash` in `tools[]` and replayed `tool_use`; trailing whitespace text after `tool_use` removed; `temperature` removed; `tool_result` continuation | -| `sonnet55-full-agent-subagent` | `claude-sonnet-5-5` | body with `tools` + `context_management` → full-agent beta list, per-turn-control after prompt-caching-scope; forced `tool_choice` removed; subagent: no 1h cache, no lineage | -| `opus48-structured-output-fast` | `claude-opus-4-8` | `json_schema` output → structured-output beta list; `speed:"fast"` + fast-mode beta; host budget thinking unchanged | +| `sonnet55-full-agent-subagent` | `claude-sonnet-5-5` | main beta list, per-turn-control after prompt-caching-scope; forced `tool_choice` removed; subagent: no 1h cache, no lineage | +| `opus48-structured-output-fast` | `claude-opus-4-8` | main beta list plus structured-output beta for `json_schema`; `speed:"fast"` + fast-mode beta; host budget thinking unchanged | ## Shape of each case @@ -78,16 +78,35 @@ the same fixed values and placeholders. Compared with 2.1.280: besides tool results). - New headers: `x-claude-code-request-class` (`main`) on every request and `x-claude-code-prompt-id` (the billing `cc_prompt_id`) when there is one. -- Beta lists follow 2.1.302's order: `claude-code-20250219` first in the - full-agent list, `per-turn-control-2026-07-01` directly after - `prompt-caching-scope-2026-01-05` (also on Fable 5.1 and Haiku 5.5), and - the 2.1.302 additions `mid-conversation-tool-changes-2026-07-01`, - `mid-conversation-system-clear-at-2026-08-21` and - `thinking-binding-controls-2026-08-01` in the full-agent list. +- Beta selection now follows request class, not body-shape heuristics. All + main turns use the captured main list, including thinking-binding-controls + and thinking-display-updates even without a binding field. The binding + field itself still needs replayed thinking and an explicit setting. + `claude-code-20250219` comes first except on Haiku 5.5, where it follows + prompt-caching-scope. Per-turn-control follows that scope beta (and + claude-code on Haiku 5.5) for Sonnet 5.5, Opus 5.5, Fable 5.1 and Haiku + 5.5, but not Opus 4.8. Main JSON-schema output adds structured-output at + its existing position before cache-diagnosis; fast mode and server-side + fallback retain their existing feature-beta selection and order. - Every vector is an uncompressed (identity) send. Claude Code 2.1.302 sends bodies of 4,096 characters or more to api.anthropic.com gzipped, with `cch=00000` and whitespace padding; that encoding is not part of these vectors. +- The obsolete `mid-conversation-system-2026-04-07` and + `mid-conversation-output-config-2026-07-01` betas are absent. Fable 5.1 + effort changes use `per-turn-control-2026-07-01`; effort-marker bodies + remain empty system messages with `output_config.effort`. +- Advisor-tool and fallback-credit are absent: these requests enable no + advisor and carry no fallback credit code. Server-side-fallback remains + a separate feature beta, not a fallback-credit substitute. +- Auxiliary one-token quota probes use the captured quota list; other + auxiliary requests use title/summary betas (with structured-output only + for JSON schema). Those classes are covered by Core tests, not these + main-request vectors. +- No baseline beta difference remains from the capture. The body still uses + `thinking.display: "summarized"` where Claude Code sends `"updates"`, + because `"updates"` streams no thinking text. Advertising the + thinking-display-updates beta alone does not suppress summarized thinking. ## How the outputs were generated @@ -105,9 +124,9 @@ body equals the signed body, that recomputing `cch` from `cch_canonical_body` and re-signing reproduce it, and that the suffix follows from the first user text. -Bun 1.4.2 on macOS and Bun 1.3.14 on Linux (the version CI pins) produced -byte-identical outputs. `vectors.json` has SHA-256 -`8f769683452e8da8502b2cd4856a6585615939938e2fa5f97b0da572d12d9623`. +The beta-corrected outputs were regenerated with Bun 1.3.14 on Linux (the +version CI pins). `vectors.json` has SHA-256 +`47072a18eb3640dd03502a4f24e7e9b5325160faffacdf47cb99db6462cb4ba5`. Not covered: Pi and OpenCode 2 (their differences are described in `blueprint.md`), lane-start warm requests, and CacheKeep prewarms. diff --git a/packages/opencode/src/tests/fixtures/claude-oauth-wire/SHA256SUMS b/packages/opencode/src/tests/fixtures/claude-oauth-wire/SHA256SUMS index b659d4dc2..d20a50478 100644 --- a/packages/opencode/src/tests/fixtures/claude-oauth-wire/SHA256SUMS +++ b/packages/opencode/src/tests/fixtures/claude-oauth-wire/SHA256SUMS @@ -1,2 +1,2 @@ -8f769683452e8da8502b2cd4856a6585615939938e2fa5f97b0da572d12d9623 vectors.json -9ec0916a84e26b3e486c3c9d46c901bdf637d581a3fc6e0bee4c15d3798c9588 README.md +47072a18eb3640dd03502a4f24e7e9b5325160faffacdf47cb99db6462cb4ba5 vectors.json +5cbb02ffdd4ecb8ea8024a1ea5e88212a107e1957ddbc334873f3f1629aac5db README.md diff --git a/packages/opencode/src/tests/fixtures/claude-oauth-wire/vectors.json b/packages/opencode/src/tests/fixtures/claude-oauth-wire/vectors.json index 0e002659b..c230afd0f 100644 --- a/packages/opencode/src/tests/fixtures/claude-oauth-wire/vectors.json +++ b/packages/opencode/src/tests/fixtures/claude-oauth-wire/vectors.json @@ -34,7 +34,7 @@ "Fable 5.1 thinking replaced with adaptive/summarized", "output_config.effort pinned from the effort plan baseline", "server-side fallback opt-in (fallbacks: \"default\") and its beta", - "mid-conversation-output-config beta", + "per-turn-control beta", "diagnostics.previous_message_id null on the first turn", "explicit cache mode: host breakpoints get ttl 1h" ], @@ -95,7 +95,7 @@ ], [ "anthropic-beta", - "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14,server-side-fallback-2026-07-01,mid-conversation-output-config-2026-07-01" + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14,server-side-fallback-2026-07-01" ], [ "anthropic-dangerous-direct-browser-access", @@ -194,7 +194,7 @@ "covers": [ "billing header with cc_prev_req and cc_prompt_id", "per-turn-control beta on Opus 5.5", - "thinking.block_binding and thinking-binding-controls beta (account setting drop_block)", + "thinking.block_binding for replayed thinking (account setting drop_block); binding beta is on every main request", "replayed thinking block kept byte-for-byte; no cache_control on it", "hybrid cache mode: host breakpoints removed, 1h anchors on the last system block, messages[0], messages[1] and the latest user message", "server-side fallback opt-in for Opus 5.5", @@ -259,7 +259,7 @@ ], [ "anthropic-beta", - "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14,server-side-fallback-2026-07-01,thinking-binding-controls-2026-08-01" + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14,server-side-fallback-2026-07-01" ], [ "anthropic-dangerous-direct-browser-access", @@ -421,7 +421,7 @@ ], [ "anthropic-beta", - "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14,server-side-fallback-2026-07-01" + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14,server-side-fallback-2026-07-01" ], [ "anthropic-dangerous-direct-browser-access", @@ -581,7 +581,7 @@ ], [ "anthropic-beta", - "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14" + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14" ], [ "anthropic-dangerous-direct-browser-access", @@ -682,7 +682,7 @@ "whitespace-only text after the latest tool_use removed", "temperature removed for Haiku 5.5; thinking set to adaptive/summarized", "tool_choice auto kept", - "base beta list (no context_management in the body, so not the full-agent list)" + "main beta list even without context_management; claude-code follows prompt-caching-scope on Haiku 5.5" ], "input": { "host": "opencode-1", @@ -734,7 +734,7 @@ ], [ "anthropic-beta", - "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14" + "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14" ], [ "anthropic-dangerous-direct-browser-access", @@ -831,7 +831,7 @@ "source": "reconstructed", "title": "Sonnet 5.5 subagent request whose body has the full agent shape (tools + context_management)", "covers": [ - "full-agent beta list (claude-code-20250219 first, mid-conversation-system, effort, fallback-credit, and the 2.1.302 additions) with per-turn-control directly after prompt-caching-scope", + "main beta list (claude-code-20250219 first, effort, binding controls and display updates) with per-turn-control directly after prompt-caching-scope", "forced tool_choice {type:\"any\"} removed for Sonnet 5.5", "subagent (x-parent-session-id): 1h cache off, host breakpoints sent as 5-minute ephemeral, no lineage fields" ], @@ -893,7 +893,7 @@ ], [ "anthropic-beta", - "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advisor-tool-2026-03-01,advanced-tool-use-2025-11-20,mid-conversation-system-2026-04-07,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,fallback-credit-2026-06-01,thinking-binding-controls-2026-08-01,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14" + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,per-turn-control-2026-07-01,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,cache-diagnosis-2026-04-07,fine-grained-tool-streaming-2025-05-14" ], [ "anthropic-dangerous-direct-browser-access", @@ -986,7 +986,7 @@ "source": "reconstructed", "title": "Opus 4.8 structured-output request with fast mode on", "covers": [ - "structured-output beta list (json_schema output format): no extended-cache-ttl, no advanced-tool-use", + "main beta list plus structured-output before cache-diagnosis for the json_schema output format", "speed \"fast\" and fast-mode beta", "Opus 4.8 keeps host budget thinking unchanged; no per-turn-control, no server-side fallback" ], @@ -1040,7 +1040,7 @@ ], [ "anthropic-beta", - "oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-tool-changes-2026-07-01,advisor-tool-2026-03-01,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,structured-outputs-2025-12-15,cache-diagnosis-2026-04-07,fast-mode-2026-02-01,fine-grained-tool-streaming-2025-05-14" + "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,mid-conversation-tool-changes-2026-07-01,advanced-tool-use-2025-11-20,mid-conversation-system-clear-at-2026-08-21,effort-2025-11-24,thinking-binding-controls-2026-08-01,thinking-display-updates-2026-08-18,extended-cache-ttl-2025-04-11,structured-outputs-2025-12-15,cache-diagnosis-2026-04-07,fast-mode-2026-02-01,fine-grained-tool-streaming-2025-05-14" ], [ "anthropic-dangerous-direct-browser-access", diff --git a/packages/opencode/src/tests/haiku55.test.ts b/packages/opencode/src/tests/haiku55.test.ts index 530f62722..4ed1b0035 100644 --- a/packages/opencode/src/tests/haiku55.test.ts +++ b/packages/opencode/src/tests/haiku55.test.ts @@ -161,7 +161,7 @@ test.each(['error', 'drop_block'] as const)( }, ) -test('OpenCode Haiku first turns, account-default replay and disabled thinking do not opt in', async () => { +test('OpenCode Haiku advertises binding support without opting first turns, account-default replay or disabled thinking into a binding field', async () => { const bodies = [ await rewrite({}, { thinkingPrefixMismatchBehavior: 'drop_block' }), await rewrite( @@ -178,7 +178,7 @@ test('OpenCode Haiku first turns, account-default replay and disabled thinking d const headers = setOAuthHeaders(new Headers(), 'synthetic-access-token', { body, }) - expect(headers.get('anthropic-beta')?.split(',')).not.toContain( + expect(headers.get('anthropic-beta')?.split(',')).toContain( THINKING_BINDING_CONTROLS_BETA, ) } diff --git a/packages/opencode/src/tests/index.test.ts b/packages/opencode/src/tests/index.test.ts index 49033c372..ab92bcce5 100644 --- a/packages/opencode/src/tests/index.test.ts +++ b/packages/opencode/src/tests/index.test.ts @@ -6081,7 +6081,7 @@ describe('Fable 5.1 request-scoped effort history', () => { prefix_mismatch_behavior: 'error', }) expect(sentHeaders?.get('anthropic-beta')).toContain( - 'mid-conversation-output-config-2026-07-01', + 'per-turn-control-2026-07-01', ) expect(sentHeaders?.get('anthropic-beta')).toContain( 'thinking-binding-controls-2026-08-01', diff --git a/packages/opencode/src/tests/transform.test.ts b/packages/opencode/src/tests/transform.test.ts index a7ffb9b58..a49350552 100644 --- a/packages/opencode/src/tests/transform.test.ts +++ b/packages/opencode/src/tests/transform.test.ts @@ -5,7 +5,6 @@ import { CLAUDE_CODE_VERSION, computeCcVersionSuffix, FAST_MODE_BETA, - MID_CONVERSATION_OUTPUT_CONFIG_BETA, OPENCODE_IDENTITY_PREFIX, PER_TURN_CONTROL_BETA, REQUIRED_BETAS, @@ -514,7 +513,7 @@ describe('setOAuthHeaders', () => { }) const firstTurnHeaders = new Headers() setOAuthHeaders(firstTurnHeaders, 'token', { body: firstTurnBody }) - expect(firstTurnHeaders.get('anthropic-beta')).not.toContain( + expect(firstTurnHeaders.get('anthropic-beta')).toContain( 'thinking-binding-controls-2026-08-01', ) @@ -564,7 +563,7 @@ describe('setOAuthHeaders', () => { expect(body.thinking.block_binding).toBeUndefined() const headers = new Headers() setOAuthHeaders(headers, 'token', { body }) - expect(headers.get('anthropic-beta')).not.toContain( + expect(headers.get('anthropic-beta')).toContain( 'thinking-binding-controls-2026-08-01', ) } @@ -663,7 +662,7 @@ describe('setOAuthHeaders', () => { expect(betweenTools.thinking).toEqual({ type: 'between_tools' }) const headers = new Headers() setOAuthHeaders(headers, 'token', { body: betweenTools }) - expect(headers.get('anthropic-beta')).not.toContain( + expect(headers.get('anthropic-beta')).toContain( 'thinking-binding-controls-2026-08-01', ) }) @@ -693,18 +692,24 @@ describe('setOAuthHeaders', () => { setOAuthHeaders(headers, 'token', { body }) expect(body.thinking.block_binding).toBeUndefined() - expect(headers.get('anthropic-beta')).not.toContain( + expect(headers.get('anthropic-beta')).toContain( THINKING_BINDING_CONTROLS_BETA, ) }) - test('always adds the mid-conversation output-config beta for OAuth Fable 5.1', () => { + test('always adds per-turn control without obsolete betas for OAuth Fable 5.1', () => { const fable51 = new Headers() setOAuthHeaders(fable51, 'token', { body: { model: 'claude-fable-5-1', messages: [] }, }) expect(fable51.get('anthropic-beta')?.split(',')).toContain( - MID_CONVERSATION_OUTPUT_CONFIG_BETA, + PER_TURN_CONTROL_BETA, + ) + expect(fable51.get('anthropic-beta')?.split(',')).not.toContain( + 'mid-conversation-output-config-2026-07-01', + ) + expect(fable51.get('anthropic-beta')?.split(',')).not.toContain( + 'mid-conversation-system-2026-04-07', ) for (const model of [ @@ -715,7 +720,7 @@ describe('setOAuthHeaders', () => { const headers = new Headers() setOAuthHeaders(headers, 'token', { body: { model, messages: [] } }) expect(headers.get('anthropic-beta')?.split(',')).not.toContain( - MID_CONVERSATION_OUTPUT_CONFIG_BETA, + PER_TURN_CONTROL_BETA, ) } }) @@ -1024,7 +1029,7 @@ describe('setOAuthHeaders', () => { setOAuthHeaders(headers, 'token', { body }) const betas = headers.get('anthropic-beta')?.split(',') expect(betas).toContain(PER_TURN_CONTROL_BETA) - expect(betas).not.toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) + expect(betas).not.toContain('mid-conversation-output-config-2026-07-01') // Sonnet 5.5's thinking opt-out becomes between_tools, which rejects a // changed per-message effort, so the host's top-level effort stays. diff --git a/packages/opencode/src/transform.ts b/packages/opencode/src/transform.ts index 1dd099897..07d8a90b1 100644 --- a/packages/opencode/src/transform.ts +++ b/packages/opencode/src/transform.ts @@ -22,7 +22,6 @@ import { countUserPrompts, createSseFramer, FAST_MODE_BETA, - hasThinkingBindingControls, isClaudeFableOrMythos5Model, isClaudeFableOrMythos51Model, isClaudeHaiku55Model, @@ -32,7 +31,6 @@ import { isClaudeSonnet55Model, isFastModeSupportedModel, isOpenAIReasoningSignature, - MID_CONVERSATION_OUTPUT_CONFIG_BETA, mergeAnthropicBetas, OPENCODE_IDENTITY_PREFIX, orderClaudeCodeBody, @@ -46,11 +44,9 @@ import { stripBillingLineageFromBody, stripEmptyTrailingAssistantMessages, TEXT_REPLACEMENTS, - THINKING_BINDING_CONTROLS_BETA, type ThinkingPrefixMismatchBehavior, TOOL_PREFIX, TrailingAssistantHistoryError, - usesMidConversationOutputConfig, } from '@cortexkit/anthropic-auth-core' import { applyEdits, @@ -282,12 +278,6 @@ export function setOAuthHeaders( ...(options.body?.fallbacks === 'default' ? [SERVER_SIDE_FALLBACK_BETA] : []), - ...(options.body && hasThinkingBindingControls(options.body) - ? [THINKING_BINDING_CONTROLS_BETA] - : []), - ...(options.body && usesMidConversationOutputConfig(options.body) - ? [MID_CONVERSATION_OUTPUT_CONFIG_BETA] - : []), ], }) } diff --git a/packages/pi/src/stream.ts b/packages/pi/src/stream.ts index 9b5f754a7..f52aeb4ac 100644 --- a/packages/pi/src/stream.ts +++ b/packages/pi/src/stream.ts @@ -24,7 +24,6 @@ import { getRoutingMode, getScopedQuotaWindowForModel, getThinkingPrefixMismatchBehavior, - hasThinkingBindingControls, isCache1hPersistentlyEnabled, isCacheKeepHybridActive, isDumpPersistentlyEnabled, @@ -36,7 +35,6 @@ import { isValidApiBaseURL, killswitchPassesPolicy, logger, - MID_CONVERSATION_OUTPUT_CONFIG_BETA, type MidConversationEffortTransition, mergeAnthropicBetas, type NativeAccountSnapshot, @@ -67,8 +65,6 @@ import { stickyQuotaSnapshotIsFresh, stickyRetryAfterWithJitter, stickyRouteFamilyForModel, - THINKING_BINDING_CONTROLS_BETA, - usesMidConversationOutputConfig, } from '@cortexkit/anthropic-auth-core' import { type Api, @@ -270,8 +266,8 @@ function getPiCacheKeepRegistry() { * betas this body needs. Model requests and CacheKeep prewarms both call it: * a prewarm replays a model request's body, and Anthropic needs the same * betas to accept it (for example, the effort beta for the empty - * `role: "system"` effort markers in a Fable 5.1 body). `leadingBetas` are - * placed before the body's betas. + * `role: "system"` effort markers in a Fable 5.1 body). `leadingBetas` appends + * adapter-requested feature betas without changing Core's model-beta selection. */ function applyPiOAuthRequestHeaders( headers: Headers, @@ -286,15 +282,7 @@ function applyPiOAuthRequestHeaders( applyClaudeCodeHeaders(headers, accessToken, { body, identity: options.identity, - extraBetas: [ - ...(options.leadingBetas ?? []), - ...(hasThinkingBindingControls(body) - ? [THINKING_BINDING_CONTROLS_BETA] - : []), - ...(usesMidConversationOutputConfig(body) - ? [MID_CONVERSATION_OUTPUT_CONFIG_BETA] - : []), - ], + extraBetas: options.leadingBetas, }) if (body.speed === 'fast') headers.set( diff --git a/packages/pi/src/tests/scoped-stream.test.ts b/packages/pi/src/tests/scoped-stream.test.ts index 24862c6a0..7f079d32e 100644 --- a/packages/pi/src/tests/scoped-stream.test.ts +++ b/packages/pi/src/tests/scoped-stream.test.ts @@ -9,7 +9,6 @@ import { type NativeCustodyClient as ClaustrumScopedClient, getLogLevel, type LogTestRecord, - MID_CONVERSATION_OUTPUT_CONFIG_BETA, PER_TURN_CONTROL_BETA, resolveNativePoolPaths, setLogLevel, @@ -1322,7 +1321,7 @@ function expectPrewarmHeadersMatchModelRequest( expect(others(warm.headers)).toEqual(others(main.headers)) } -test('Pi CacheKeep prewarm of a Fable 5.1 body with effort markers sends the mid-conversation output config beta', async () => { +test('Pi CacheKeep prewarm of a Fable 5.1 body with effort markers sends the per-turn control beta', async () => { const f = await fixture() const { main, warm } = await sendAndPrewarm(f, 'claude-fable-5-1', [ { afterAssistantMessages: 0, effort: 'low' }, @@ -1330,8 +1329,11 @@ test('Pi CacheKeep prewarm of a Fable 5.1 body with effort markers sends the mid ]) expect(hasEffortMarker(main.json)).toBe(true) expect(hasEffortMarker(warm.json)).toBe(true) - expect(main.betas).toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) - expect(warm.betas).toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) + expect(main.betas).toContain(PER_TURN_CONTROL_BETA) + expect(warm.betas).toContain(PER_TURN_CONTROL_BETA) + expect(main.betas).not.toContain('mid-conversation-output-config-2026-07-01') + expect(warm.betas).not.toContain('mid-conversation-output-config-2026-07-01') + expect(warm.betas).not.toContain('mid-conversation-system-2026-04-07') expectPrewarmHeadersMatchModelRequest(main, warm) }) @@ -1341,8 +1343,8 @@ test('Pi CacheKeep prewarm of a Fable 5.1 body without effort markers sends the expect(hasEffortMarker(main.json)).toBe(false) expect(hasEffortMarker(warm.json)).toBe(false) // Every OAuth Fable 5.1 model request carries this beta, markers or not. - expect(main.betas).toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) - expect(warm.betas).toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) + expect(main.betas).toContain(PER_TURN_CONTROL_BETA) + expect(warm.betas).toContain(PER_TURN_CONTROL_BETA) expectPrewarmHeadersMatchModelRequest(main, warm) }) @@ -1355,7 +1357,7 @@ test('Pi CacheKeep prewarm of an Opus 5.5 body with effort markers keeps the per expect(hasEffortMarker(main.json)).toBe(true) expect(hasEffortMarker(warm.json)).toBe(true) expect(warm.betas).toContain(PER_TURN_CONTROL_BETA) - expect(warm.betas).not.toContain(MID_CONVERSATION_OUTPUT_CONFIG_BETA) + expect(warm.betas).not.toContain('mid-conversation-output-config-2026-07-01') expectPrewarmHeadersMatchModelRequest(main, warm) }) diff --git a/packages/pi/src/tests/stream.test.ts b/packages/pi/src/tests/stream.test.ts index 4204e781b..c46eb9ba5 100644 --- a/packages/pi/src/tests/stream.test.ts +++ b/packages/pi/src/tests/stream.test.ts @@ -296,6 +296,9 @@ describe('Pi API fallback routing helpers', () => { 'thinking-binding-controls-2026-08-01', ) expect(requestHeaders?.get('anthropic-beta')).toContain( + 'per-turn-control-2026-07-01', + ) + expect(requestHeaders?.get('anthropic-beta')?.split(',')).not.toContain( 'mid-conversation-output-config-2026-07-01', ) }) From 1ac950cc254ff36362496ddae227d1f3f95d8cd7 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:59:15 +0200 Subject: [PATCH 2/3] test: pin the Claude OAuth wire vectors to cortexkit-anthropic-policy 0.1.3 commons 3ce6295d publishes the corrected 2.1.302 beta lists byte-identical to the regenerated fixtures. --- packages/opencode/src/tests/fixtures/claude-oauth-wire/SOURCE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/tests/fixtures/claude-oauth-wire/SOURCE b/packages/opencode/src/tests/fixtures/claude-oauth-wire/SOURCE index ec1157371..859223461 100644 --- a/packages/opencode/src/tests/fixtures/claude-oauth-wire/SOURCE +++ b/packages/opencode/src/tests/fixtures/claude-oauth-wire/SOURCE @@ -2,5 +2,5 @@ # copy README.md, vectors.json and SHA256SUMS from that commit into this folder. # CI (.github/workflows/ci.yml) reads these three keys and fails on any drift. repository=https://github.com/cortexkit/commons.git -commit=d2e88a554662227b126aec9f9250ed84563d8f25 +commit=3ce6295dd1cd523ee857da8adfe79af2b1100b8f path=crates/cortexkit-anthropic-policy/test-vectors/claude-oauth-wire From f27445c78510de1cf4a2cc5237e1b31afe6b2042 Mon Sep 17 00:00:00 2001 From: ualtinok <94532+ualtinok@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:02:28 +0200 Subject: [PATCH 3/3] mutations: drop the Pi prewarm body-beta row With request-class beta selection, Core gives a prewarm the same main list with or without its body, so rebuilding the prewarm headers without the body no longer changes what is sent: the mutant is equivalent and survives. The property it guarded is now held by Core's selection, which its own rows guard. --- mutations.toml | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/mutations.toml b/mutations.toml index 758ae76d6..a4604ce38 100644 --- a/mutations.toml +++ b/mutations.toml @@ -981,22 +981,6 @@ broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/core broad_report = "tmp/mutations/core.xml" broad_id = "{name}" -[[control]] -id = "pi-cachekeep-prewarm-body-betas" -guards = "Pi's CacheKeep prewarm must send the betas the model request sends for the same body, including per-turn control for Fable 5.1 system effort markers." -file = "packages/pi/src/stream.ts" -old = " applyPiOAuthRequestHeaders(headers, piAttemptAccessToken(served), {" -new = " applyClaudeCodeHeaders(headers, piAttemptAccessToken(served), { extraBetas: [CACHE_KEEP_EXTENDED_TTL_BETA], // NON-VACUITY BREAK: rebuild prewarm betas without the body's betas" -test_file = "packages/pi/src/tests/scoped-stream.test.ts" -runner = "command" -command = ["bun", "scripts/run-mutation-bun.ts", "packages/pi/src/tests/scoped-stream.test.ts", "{test}"] -test_count_pattern = "CKDEV executed {count} tests" -expect_red = ["Pi CacheKeep prewarm of a Fable 5.1 body with effort markers sends the per-turn control beta", "Pi CacheKeep prewarm of a Fable 5.1 body without effort markers sends the model request betas"] -expect_message = "error: expect(received).toContain(expected)" -broad_command = ["bun", "scripts/run-mutation-bun.ts", "--broad", "packages/pi", "tmp/mutations/pi.xml"] -broad_report = "tmp/mutations/pi.xml" -broad_id = "{name}" - [[control]] id = "oc1-cancelled-401-never-reaches-routing" guards = "A request cancelled after its vault 401 arrives, before or during re-authorization, must end as cancelled after one report, not return the 401 that moves the session's sticky account."