diff --git a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md index 7f617b790f4..2193249fb06 100644 --- a/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md +++ b/devlog/_plan/260924_protocol_first_class/040_acceptance_and_rollout.md @@ -50,7 +50,7 @@ branch (`feat/pf12-protocol-rollout`); PF-10 is developed in parallel and is not | Path | State after this unit | |---|---| -| Chat/Messages request decode | still produces a Responses-shaped body before the IR (`responses-internal`) on every non-native path, with one exception: behind `antigravityDirect`, a Chat request to one concrete Antigravity route decodes straight into the IR (`src/protocols/codecs/chat-ir.ts`, path `chat,ir,other`; see `devlog/_plan/261014_antigravity_direct_protocols/`). Messages decode and every other route still use the named entry points over the existing translators (`chatCompletionsToResponsesBody`, `anthropicToResponsesTranslation`) | +| Chat/Messages request decode | still produces a Responses-shaped body before the IR (`responses-internal`) on every non-native path, with one exception: under `antigravityDirect` (on by default), a Chat or Messages request to one concrete Antigravity route decodes straight into the IR (`src/protocols/codecs/chat-ir.ts`, `messages-ir.ts`, paths `chat,ir,other` and `messages,ir,other`; see `devlog/_plan/261014_antigravity_direct_protocols/`). Every other route still uses the named entry points over the existing translators (`chatCompletionsToResponsesBody`, `anthropicToResponsesTranslation`) | | Chat ↔ Messages direct codecs | not implemented: `chat,ir,messages` and `messages,ir,chat` are baseline targets only; both pairs travel `responses-internal` | | Chat/Messages response encode (PF-09) | migrated behind `directEncoders` only where `directEncodersApply` and `clientEncoderForDelivery` both agree: one concrete, non-Responses-wire route in the streaming adapter delivery, giving response path `[upstream, ir, client]` while the request path stays the bridge path. Still through `responses-internal`: combo and policy children (`comboAttempt`, `routeKind` combo/policy), routed compaction, run-turn adapters (Cursor, Devin, coding-agent CLIs, CodeBuddy), sidecar turns, the buffered `parseResponse` branch (unused by these ingresses, which always stream internally), and every route while the switch is off. Responses-wire upstreams keep their existing codec path | | Policy-route children | not migrated (PF-07): `routeModel` evaluates the policy and returns one concrete candidate, so a policy request never reaches the combo child loop and keeps the Chat bridge | diff --git a/devlog/_plan/261014_antigravity_direct_protocols/000_plan.md b/devlog/_plan/261014_antigravity_direct_protocols/000_plan.md index 00c60be5e0e..1091260ebad 100644 --- a/devlog/_plan/261014_antigravity_direct_protocols/000_plan.md +++ b/devlog/_plan/261014_antigravity_direct_protocols/000_plan.md @@ -38,11 +38,11 @@ unchanged. ## Route map (eligible concrete Antigravity route) -| Inbound | Before | After (`antigravityDirect` on) | +| Inbound | Before | After (`antigravityDirect`, on by default) | |---|---|---| | Responses | `responses -> ir -> other`, answer `other -> ir -> responses` | unchanged; already direct (`parseRequest`, `bridgeToResponsesSSE`) | | Chat | `chat -> responses-internal -> ir -> other`; answer `other -> ir -> responses-internal -> chat` (`other -> ir -> chat` only with `directEncoders`) | `chat -> ir -> other`; answer `other -> ir -> chat` | -| Messages | `messages -> responses-internal -> ir -> other` | unchanged in this unit (follow-up below) | +| Messages | `messages -> responses-internal -> ir -> other`; answer `other -> ir -> responses-internal -> messages` (`other -> ir -> messages` only with `directEncoders`) | `messages -> ir -> other`; answer `other -> ir -> messages` | ## Design @@ -61,10 +61,12 @@ and the end-to-end test requires the same Antigravity wire body on both lanes. ## Follow-ups -- Messages -> Antigravity direct decode. The Messages ingress applies more bridge-body policy - (model resolution, prompt-cache key, blocked-skill elision, cache stabilization, `ocxr1` - reasoning envelopes and the native reasoning replay map), so it needs its own parity corpus and - decline rules; stack it on this branch. +- Messages -> Antigravity direct decode: done in the follow-up PR (`messages-ir.ts`, + `src/server/messages-antigravity-direct.ts`). The lane is planned before translation on the + settings-only body (resolved model, prompt-cache key) against a route preview, reproduces + blocked-skill elision, cache stabilization, envelopes and the native replay map, and declines + hosted web search, vision preprocessing, the shadow intercept and core-rewritten text. The same + PR turns `antigravityDirect` on by default for both lanes; an explicit `false` turns it off. - Claude-on-Antigravity thinking continuity: the Google adapter sends assistant thinking parts nowhere today; only call-bound `thoughtSignature`s survive. Carrying Claude thinking blocks as `thought: true` parts (as CLIProxyAPI does) is an adapter change for both lanes, not part of diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md index caaa8c7a3df..0c0f5c93fb9 100644 --- a/docs-site/src/content/docs/guides/protocol-paths.md +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -94,7 +94,7 @@ each candidate and skips one that cannot carry the request. ## Rollout switches -These switches stage the new paths. Each defaults to off, and a switch that is off changes nothing. +These switches stage the new paths. Each defaults to off except `antigravityDirect`, and a switch that is off changes nothing. | Switch | When on | | --- | --- | @@ -102,7 +102,7 @@ These switches stage the new paths. Each defaults to off, and a switch that is o | `managedMessagesNative` | A Messages request whose route is a direct, key-authenticated Anthropic provider is sent as Messages instead of through the bridge. Routes that need bridge-only behaviour (a pinned effort, blocked-skill elision, the web-search sidecar, vision preprocessing, synthetic rows) stay on the bridge. | | `managedMessagesNativeOAuth` | Native Messages for the unpooled `anthropic` OAuth provider on `api.anthropic.com`. Effective only together with `managedMessagesNative`; a pooled Anthropic OAuth account set stays on the bridge. | | `directEncoders` | For a non-Responses upstream, the answer to a Chat or Messages client is encoded directly from the adapter's events instead of through the internal Responses stream. The request side is unchanged. | -| `antigravityDirect` | A Chat request whose route is one Google Antigravity provider skips the internal Responses body in both directions: the conversation is decoded from the Chat body straight into what the Google adapter compiles from, and the answer is encoded directly as with `directEncoders`. The path reads `chat -> ir -> other`. Request settings (model, effort, limits, tool choice) still pass through the usual policies, so account rotation, effort rules and usage are unchanged. Combos, policies, a hosted web-search tool, images for a model marked text-only, and a model the shadow-call intercept could rewrite stay on the bridge, and the log row names the reason. | +| `antigravityDirect` | **On by default**; set it to `false` to turn it off. A Chat or Messages request whose route is one Google Antigravity provider skips the internal Responses body in both directions: the conversation is decoded from the client body straight into what the Google adapter compiles from, and the answer is encoded directly as with `directEncoders`. The path reads `chat -> ir -> other` or `messages -> ir -> other`. Request settings (model, effort, limits, tool choice) still pass through the usual policies, so account rotation, effort rules and usage are unchanged. Combos, policies, a hosted web-search tool, images for a model marked text-only, a model the shadow-call intercept could rewrite, and text the proxy rewrites (a skills catalog, a generated-image link) stay on the bridge, and the log row names the reason. | | `shadowPlan` | At the end of each Chat or Messages request, the plan a preview would have predicted is compared with the path the request took; a disagreement adds `planMismatch: true` to the log row's path record. No second request is sent. | Change them with the CLI or by editing `protocols` in `config.json` @@ -133,8 +133,8 @@ does not display it yet. These paths still use the internal Responses bridge or are not covered: - Chat and Messages requests are still decoded into a Responses-shaped body before the IR, including - on a translated path. The exception is a Chat request to a single Google Antigravity route with - `antigravityDirect` on. Messages requests to Antigravity still use the bridge. + on a translated path. The exception is a Chat or Messages request to a single Google Antigravity route, + unless `antigravityDirect` is set to `false`. - Routing-policy candidates, combos reached through an effort row, combo candidates while `nativeChatCombos` is off, and Messages combos. - Direct encoding (`directEncoders`) covers only a concrete, non-Responses route in streaming diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e28b3c0228f..45da7168de8 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -613,7 +613,7 @@ modes, the preview, and the per-request trace. | `protocols.rollout.managedMessagesNative?` | `boolean` | `false` | Send Messages natively to a direct, key-authenticated Anthropic provider instead of through the internal Responses bridge. | | `protocols.rollout.managedMessagesNativeOAuth?` | `boolean` | `false` | Native Messages for the `anthropic` OAuth provider on `api.anthropic.com`, including stored account pools. Requires `managedMessagesNative`. For the settled `anthropic` provider, an enabled pool supplies true for absent native flags when `anthropicAccountPool.nativeMessages` is absent or true. Explicit false/malformed flags and a false/malformed pool preference remain off. Other providers and pool-off keep explicit rollout behavior. Eligible pooled requests retain shared selection, affinity and bounded pre-output recovery. Recognized JSON-string `metadata.user_id` account UUIDs are aligned with the serving OAuth credential; device and session fields are preserved. | | `protocols.rollout.directEncoders?` | `boolean` | `false` | Encode Chat and Messages answers from a non-Responses upstream directly from adapter events. | -| `protocols.rollout.antigravityDirect?` | `boolean` | `false` | Send a Chat request to a single Google Antigravity route without the internal Responses body in either direction. Request settings still pass through the usual policies. | +| `protocols.rollout.antigravityDirect?` | `boolean` | `true` | Send a Chat or Messages request to a single Google Antigravity route without the internal Responses body in either direction. Request settings still pass through the usual policies, and a request the lane cannot carry uses the bridge. Set `false` to turn it off. | | `protocols.rollout.shadowPlan?` | `boolean` | `false` | Compare each Chat or Messages request's path with the plan a preview predicts and mark a disagreement as `planMismatch` on its log row. Sends nothing extra. | Malformed protocol blocks retain a conservative disabled native policy when loaded from disk. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 0c289a8fdfc..cab30b7e16d 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -316,6 +316,8 @@ "protocol-chat-ir-parity.test.ts": "responses", "protocol-antigravity-direct-chat.test.ts": "responses", "protocol-antigravity-direct-eligibility.test.ts": "responses", + "protocol-messages-ir-parity.test.ts": "responses", + "protocol-antigravity-direct-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", "jev-initial-effort-wire.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", "responses-first-send-reservation.test.ts": "responses", "responses-first-send-spend-policy.test.ts": "responses", "responses-dispatch-receipt.test.ts": "responses", "responses-hosted-send-hop.test.ts": "responses", "responses-hosted-send-producer.test.ts": "responses", "responses-hosted-send-reservation.test.ts": "responses", "responses-hosted-send-round-parity.test.ts": "responses", "responses-hosted-send-unsent-hop.test.ts": "responses", "responses-hosted-send-ws.test.ts": "responses", diff --git a/skills/ocx/references/01_surface_access-remote.md b/skills/ocx/references/01_surface_access-remote.md index f623de7c275..a4a5cd35285 100644 --- a/skills/ocx/references/01_surface_access-remote.md +++ b/skills/ocx/references/01_surface_access-remote.md @@ -330,7 +330,7 @@ State-changing: yes. |---|---|---| | `--messages` | string | Open or close the Messages API: on or off. Off also turns the Claude integration off. | | `--unrepresentable` | string | legacy keeps today's behavior; reject refuses a request its path cannot carry. | -| `--rollout` | string | One switch as name=on or name=off; repeatable. Every switch defaults off. | +| `--rollout` | string | One switch as name=on or name=off; repeatable. Every switch defaults off except antigravityDirect. | | `--json` | boolean | Emit the resulting GET /api/protocols body. | JSON mode: `payload`. diff --git a/src/claude/inbound.ts b/src/claude/inbound.ts index 7378c6498ac..478be44037e 100644 --- a/src/claude/inbound.ts +++ b/src/claude/inbound.ts @@ -24,7 +24,12 @@ import { createTranslatorBudget, type TranslatorBudget } from "../lib/translator -function imageBlockToInputImage(block: Rec): Rec | null { +/** + * The reference an Anthropic image block carries, as the Responses `image_url` string the bridge + * builds from it, or `null` for a source this route does not read. A file-backed source refuses. + * Shared with the direct Messages decoder (`src/protocols/codecs/messages-ir.ts`). + */ +export function anthropicImageBlockUrl(block: Rec): string | null { const source = block.source; if (!isRec(source)) return null; if (source.type === "file") { @@ -34,20 +39,25 @@ function imageBlockToInputImage(block: Rec): Rec | null { } if (source.type === "base64" && typeof source.data === "string") { const media = typeof source.media_type === "string" ? source.media_type : "image/png"; - return { type: "input_image", image_url: `data:${media};base64,${source.data}` }; + return `data:${media};base64,${source.data}`; } if (source.type === "url" && typeof source.url === "string") { - return { type: "input_image", image_url: source.url }; + return source.url; } return null; } -function documentTitle(block: Rec): string | undefined { +function imageBlockToInputImage(block: Rec): Rec | null { + const url = anthropicImageBlockUrl(block); + return url === null ? null : { type: "input_image", image_url: url }; +} + +export function documentTitle(block: Rec): string | undefined { return typeof block.title === "string" && block.title.length > 0 ? block.title : undefined; } -/** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */ -function documentBlockToInputFile(block: Rec): Rec | null { +/** A base64 document's bytes as a `data:` URL plus its title, or `null` for any other source. */ +export function anthropicDocumentData(block: Rec): { fileData: string; title?: string } | null { const source = block.source; if (!isRec(source) || source.type !== "base64") return null; const mediaType = typeof source.media_type === "string" && source.media_type.length > 0 @@ -55,10 +65,17 @@ function documentBlockToInputFile(block: Rec): Rec | null { : "application/octet-stream"; if (typeof source.data !== "string" || source.data.length === 0) return null; const title = documentTitle(block); + return { fileData: `data:${mediaType};base64,${source.data}`, ...(title !== undefined ? { title } : {}) }; +} + +/** An Anthropic base64 document as the Responses `input_file` block that carries its bytes. */ +function documentBlockToInputFile(block: Rec): Rec | null { + const document = anthropicDocumentData(block); + if (!document) return null; return { type: "input_file", - file_data: `data:${mediaType};base64,${source.data}`, - ...(title !== undefined ? { filename: title } : {}), + file_data: document.fileData, + ...(document.title !== undefined ? { filename: document.title } : {}), }; } @@ -145,7 +162,7 @@ const NO_ELISION: SkillElisionContext = { callIds: new Set(), names: [] }; * `Base directory for this skill: /`. Stub that block when the * directory basename matches a blocked skill. */ -function maybeElideSkillText(text: string, names: readonly string[]): string { +export function maybeElideSkillText(text: string, names: readonly string[]): string { if (names.length === 0 || text.length < SKILL_ELISION_MIN_CHARS) return text; if (!text.startsWith(SKILL_TEXT_MARKER)) return text; const pathStart = SKILL_TEXT_MARKER.length; @@ -162,14 +179,14 @@ function maybeElideSkillText(text: string, names: readonly string[]): string { + "(claudeCode.blockedSkills). The skill is loaded; answer from general knowledge instead of citing the bundle."; } -function skillElisionStub(callId: string): string { +export function skillElisionStub(callId: string): string { return "[opencodex] Skill document bundle elided for routed models (claudeCode.blockedSkills). " + `The skill loaded, but its reference documents were removed to save context (call ${callId}). ` + "Answer from general knowledge instead of citing the bundle."; } /** Collect Skill-tool call ids whose input names a blocked skill. */ -function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly string[]): Set { +export function blockedSkillCallIds(messages: readonly unknown[], blocked: readonly string[]): Set { const ids = new Set(); if (blocked.length === 0) return ids; const needles = blocked.map(name => name.toLowerCase()).filter(name => name.length > 0); @@ -222,7 +239,7 @@ export function anthropicBodyElidesBlockedSkill(body: unknown, cc?: Pick(); - const systemParts: string[] = []; - const topLevelSystem = systemToInstructions(raw.system); - if (topLevelSystem !== undefined) systemParts.push(topLevelSystem); const blockedNames = effectiveBlockedSkillNames(cc); const elide: SkillElisionContext = { callIds: blockedSkillCallIds(raw.messages, blockedNames), @@ -451,36 +459,99 @@ function translateAnthropicRequest( stream: raw.stream === true, }; + const system = anthropicSystemLayout(raw.system, cc); + if (system.instructions !== undefined) body.instructions = system.instructions; + if (system.dynamicNotice !== undefined) { + input.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text: system.dynamicNotice }], + }); + } + + const tools = toolsToResponses(raw.tools); + if (tools) body.tools = tools; + const cacheKeySource = applyAnthropicSettings(raw, body, system.cacheSystem, tools); + return { body, cacheKeySource, nativeReasoningReplay }; +} + +function assertAnthropicRequestShape(raw: unknown): asserts raw is Rec & { model: string; messages: unknown[] } { + if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object"); + if (typeof raw.model !== "string" || raw.model.length === 0) { + throw new AnthropicRequestError("model is required"); + } + if (!Array.isArray(raw.messages) || raw.messages.length === 0) { + throw new AnthropicRequestError("messages must be a non-empty array"); + } +} + +/** Where the top-level `system` lands on the bridge body, and what the cache-cohort key hashes. */ +export interface AnthropicSystemLayout { + /** The Responses `instructions`, when there are any. */ + instructions?: string; + /** A dynamic notice `stabilizePromptCache` moved out of the system text into a trailing user turn. */ + dynamicNotice?: string; + cacheSystem: string | string[]; +} + +/** The system text placement `claudeCode.stabilizePromptCache` decides; shared with the direct decoder. */ +export function anthropicSystemLayout(system: unknown, cc: OcxClaudeCodeConfig | undefined): AnthropicSystemLayout { + const systemParts: string[] = []; + const topLevelSystem = systemToInstructions(system); + if (topLevelSystem !== undefined) systemParts.push(topLevelSystem); const joinedSystem = systemParts.length > 0 ? systemParts.join("\n\n") : ""; const stabilizePromptCache = cc?.stabilizePromptCache === true; // Desktop fallback hashes raw systemParts unless the caller opted into // harness cleanup. Opt-in then hashes the same string as body.instructions. - let cacheSystem: string | string[] = systemParts; - if (joinedSystem) { - if (stabilizePromptCache) { - // Claude Code appends growing N tokens left - // footers (and occasional TaskCreate nudges) into system text. That churn - // breaks Muse/Go prefix cache on the Responses instructions prefix even - // when tools stay stable. Relocation is caller-opted, not inferred from - // a matching suffix or metadata.user_id. - const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem); - if (stabilized.instructions) body.instructions = stabilized.instructions; - // Token footers are dropped; append nothing if no other notice remains. - if (stabilized.dynamicNotice) { - input.push({ - type: "message", - role: "user", - content: [{ type: "input_text", text: stabilized.dynamicNotice }], - }); - } - cacheSystem = stabilized.instructions; - } else { - body.instructions = joinedSystem; - } - } + if (!joinedSystem) return { cacheSystem: systemParts }; + if (!stabilizePromptCache) return { instructions: joinedSystem, cacheSystem: systemParts }; + // Claude Code appends growing N tokens left + // footers (and occasional TaskCreate nudges) into system text. That churn + // breaks Muse/Go prefix cache on the Responses instructions prefix even + // when tools stay stable. Relocation is caller-opted, not inferred from + // a matching suffix or metadata.user_id. + const stabilized = stabilizeClaudeInstructionsForPromptCache(joinedSystem); + return { + ...(stabilized.instructions ? { instructions: stabilized.instructions } : {}), + // Token footers are dropped; append nothing if no other notice remains. + ...(stabilized.dynamicNotice ? { dynamicNotice: stabilized.dynamicNotice } : {}), + cacheSystem: stabilized.instructions, + }; +} - const tools = toolsToResponses(raw.tools); - if (tools) body.tools = tools; +/** + * The request settings of a Messages body as a Responses body with no conversation: `input` is + * empty and there are no `instructions` or `tools`, but the prompt-cache key is still derived + * from the system text and translated tool catalog exactly as the bridge derives it. The + * Antigravity direct lane hands this to the Responses core while the conversation is decoded + * straight into the IR (`src/protocols/codecs/messages-ir.ts`); both bodies are completed by + * `applyAnthropicSettings`. It validates the same request shape, but a conversation error the + * bridge would report first is not looked for here: a caller that gets a throw must translate + * the full body to learn the bridge's answer. + */ +export function anthropicSettingsTranslation( + raw: unknown, + cc?: OcxClaudeCodeConfig, +): { body: Rec; cacheKeySource: ClaudeCacheKeySource } { + assertAnthropicRequestShape(raw); + const body: Rec = { + model: resolveInboundModel(raw.model, cc), + input: [], + store: false, + stream: raw.stream === true, + }; + const system = anthropicSystemLayout(raw.system, cc); + const cacheKeySource = applyAnthropicSettings(raw, body, system.cacheSystem, toolsToResponses(raw.tools)); + return { body, cacheKeySource }; +} + +/** Everything after the conversation and tool catalog: choice, limits, format, cache key, reasoning. */ +function applyAnthropicSettings( + raw: Rec, + body: Rec, + cacheSystem: string | string[], + tools: Rec[] | undefined, +): ClaudeCacheKeySource { toolChoiceToResponses(raw.tool_choice, body); if (typeof raw.max_tokens === "number") body.max_output_tokens = raw.max_tokens; @@ -505,7 +576,7 @@ function translateAnthropicRequest( // per-session key with a bounded length/charset. body.prompt_cache_key = userIdHash.slice(0, 32); cacheKeySource = "metadata"; - } else if (systemParts.length > 0) { + } else if (typeof cacheSystem === "string" || cacheSystem.length > 0) { // Claude Desktop sends no metadata.user_id (H1, devlog 130): without any key the // ChatGPT/OpenAI backends reported cached_tokens:0 on every turn. Fall back to a // cache-cohort hash (devlog 260712 B4 + Pro review 012): fingerprint what the @@ -526,7 +597,7 @@ function translateAnthropicRequest( version: 2, model: body.model, system: cacheSystem, - tools: Array.isArray(body.tools) ? body.tools : [], + tools: tools ?? [], })) .digest("hex").slice(0, 32); cacheKeySource = "system"; @@ -556,7 +627,7 @@ function translateAnthropicRequest( body.include = ["reasoning.encrypted_content"]; } - return { body, cacheKeySource, nativeReasoningReplay }; + return cacheKeySource; } /** diff --git a/src/cli/capabilities-base.ts b/src/cli/capabilities-base.ts index 24dfeb21660..4a374354b43 100644 --- a/src/cli/capabilities-base.ts +++ b/src/cli/capabilities-base.ts @@ -786,7 +786,7 @@ export const CAPABILITIES: readonly Capability[] = [ command: ["api","policy"], summary: "Read the protocol policy, or change the Messages surface, unrepresentable policy and rollout switches.", routes: [{"method":"GET","path":"/api/protocols"},{"method":"PATCH","path":"/api/protocols/settings"}], - flags: [{"name":"--messages","value":"string","summary":"Open or close the Messages API: on or off. Off also turns the Claude integration off."},{"name":"--unrepresentable","value":"string","summary":"legacy keeps today's behavior; reject refuses a request its path cannot carry."},{"name":"--rollout","value":"string","summary":"One switch as name=on or name=off; repeatable. Every switch defaults off."},{"name":"--json","value":"boolean","summary":"Emit the resulting GET /api/protocols body."}], + flags: [{"name":"--messages","value":"string","summary":"Open or close the Messages API: on or off. Off also turns the Claude integration off."},{"name":"--unrepresentable","value":"string","summary":"legacy keeps today's behavior; reject refuses a request its path cannot carry."},{"name":"--rollout","value":"string","summary":"One switch as name=on or name=off; repeatable. Every switch defaults off except antigravityDirect."},{"name":"--json","value":"boolean","summary":"Emit the resulting GET /api/protocols body."}], mutates: true, json: "payload", details: ["A bare invocation reads and never writes.","A setting flag changes the operator's config; run it only when the operator asks for that change."], diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 43031f4d84e..f31b42cbda5 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -481,7 +481,7 @@ export const CLI_COMMANDS: CliCommandEntry[] = [ "protocols [--provider ] Contract version, API surfaces, protocol settings and feature vocabulary.", "explain --model --inbound [--feature ]... Preview the request path; sends nothing upstream.", "policy Read the protocol policy; with --messages, --unrepresentable or --rollout = it changes config.", - "Every rollout switch defaults off. `ocx api policy` writes only when a setting flag is given.", + "Every rollout switch defaults off except antigravityDirect. `ocx api policy` writes only when a setting flag is given.", ], }, { diff --git a/src/protocols/codecs/chat-ir.ts b/src/protocols/codecs/chat-ir.ts index 6116c4689dc..d97dd0362ac 100644 --- a/src/protocols/codecs/chat-ir.ts +++ b/src/protocols/codecs/chat-ir.ts @@ -144,8 +144,9 @@ function unsignedReasoningSignature(text: string): string { * The parser's parameter normalization. The bridge's schema parse hands the parser a shallow * copy of the caller's record, so this copies too: a sanitizer that edits the catalog in place * must not reach the caller's body, which the lane may still need to re-decode. + * Shared with the direct Messages decoder (`messages-ir.ts`), whose tools pass the same schema. */ -function normalizedParameters(raw: unknown): Record { +export function normalizedParameters(raw: unknown): Record { // The bridge's `z.record` copies own keys except `__proto__`, which JSON.parse can create. const copy: Record = {}; if (isRec(raw)) { diff --git a/src/protocols/codecs/messages-ir.ts b/src/protocols/codecs/messages-ir.ts new file mode 100644 index 00000000000..1528eb6826d --- /dev/null +++ b/src/protocols/codecs/messages-ir.ts @@ -0,0 +1,500 @@ +/** + * Anthropic Messages -> IR, directly (the Antigravity direct lane). + * + * The bridge decodes a Messages conversation twice: `anthropicToResponsesTranslation` + * (`src/claude/inbound.ts`) projects it into Responses items, then `parseRequest` + * (`src/responses/parser.ts`) turns those items into the IR. This module walks the Messages body + * once and builds the IR conversation itself: the system prompt (including the + * `stabilizePromptCache` relocation), user turns with images and documents, tool calls and + * results (including `tool_reference` markers, tool errors and blocked-skill elision), thinking + * and redacted thinking with their ocxr1 envelopes, the native reasoning replay map, and the + * tool catalog. It is the Messages twin of `chat-ir.ts`. + * + * Contract: for every Messages body the bridge accepts, `messagesConversationToIr(body, cc, + * budget, model)` equals the `context` of `parseRequest(anthropicToResponsesTranslation(body, + * cc).body)` (reasoning item ids are random on both sides), and every body the bridge refuses in + * its conversation walk is refused here with the same `AnthropicRequestError` message. + * `tests/responses/protocol-messages-ir-parity.test.ts` holds both sides over a fixture corpus. + * Envelopes are encoded and charged against the request budget exactly as the translator does, + * then decoded exactly as the parser does, so the signature an adapter reads is the same string. + * + * Request settings (model resolution, sampling, tool choice, format, prompt-cache key, reasoning) + * are not decoded here: they travel through the Responses core on `anthropicSettingsTranslation`. + * A hosted web-search tool never reaches this decoder: the lane's eligibility rule keeps it on the + * bridge, where the sidecar reads it. + */ +import { + anthropicDocumentData, + anthropicImageBlockUrl, + anthropicSystemLayout, + blockedSkillCallIds, + documentTitle, + effectiveBlockedSkillNames, + maybeElideSkillText, + skillElisionStub, + systemMessageText, +} from "../../claude/inbound"; +import { toolsToResponses } from "../../claude/inbound-content-options"; +import { AnthropicRequestError, isRec, type Rec } from "../../claude/inbound-records"; +import { hasRoutedIdentity, nameRoutedIdentity } from "../../adapters/identity"; +import type { TranslatorBudget } from "../../lib/translator-budget"; +import { inlineDocumentFromDataUrl, inlineDocumentMarker } from "../../responses/inline-document"; +import { functionCallArgumentsFromText } from "../../responses/parser"; +import { decodeReasoningEnvelope, encodeReasoningEnvelope, OCX_REASONING_PREFIX } from "../../responses/reasoning-envelope"; +import { normalizeVisualizationContext } from "../../responses/visualization-directives"; +import type { PolicyRequestEvidence } from "../../routing/evaluator"; +import { createToolChoiceResolver, namespacedToolName } from "../../types"; +import type { + OcxAssistantMessage, + OcxClaudeCodeConfig, + OcxContentPart, + OcxContext, + OcxMessage, + OcxParsedRequest, + OcxTextContent, + OcxThinkingContent, + OcxTool, +} from "../../types"; +import { normalizedParameters } from "./chat-ir"; + +/** The translator refuses an undecodable proxy envelope with this message; the ingress retries it. */ +export const MALFORMED_REASONING_SIGNATURE = "malformed ocxr1 reasoning signature"; + +function nameDestination(text: string, modelId: string): string { + return hasRoutedIdentity(text) ? nameRoutedIdentity(text, modelId) : text; +} + +function reasoningItemId(): string { + return `rs_${crypto.randomUUID().replace(/-/g, "")}`; +} + +/** The parser collapses a lone text part to a plain string; images and documents stay structured. */ +function collapsed(parts: OcxContentPart[]): string | OcxContentPart[] { + return parts.length === 1 && parts[0]!.type === "text" ? parts[0]!.text : parts; +} + +/** One entry of a tool result as the translator lists it, before the parser reads it. */ +type ToolResultEntry = { text: string } | { image: string }; + +/** + * A `tool_result` block's content as the IR carries it: the translator's `toolResultOutput` + * (error prefix, `tool_reference` markers, image references, document markers) read through the + * parser's `outputToToolResultContent` (text joined unless an image survives). + */ +function toolResultContent(block: Rec): string | OcxContentPart[] { + const isError = block.is_error === true; + const content = block.content; + if (typeof content === "string") return isError ? `[tool error] ${content}` : content; + if (!Array.isArray(content)) return isError ? "[tool error]" : ""; + const out: ToolResultEntry[] = []; + for (const item of content) { + if (!isRec(item)) continue; + if (item.type === "text" && typeof item.text === "string") { + out.push({ text: item.text }); + } else if (item.type === "tool_reference" && typeof item.tool_name === "string" && item.tool_name.length > 0) { + const prev = out[out.length - 1]; + const lead = prev !== undefined && "text" in prev && !prev.text.endsWith("\n") ? "\n" : ""; + out.push({ text: `${lead}Tool loaded: ${item.tool_name}\n` }); + } else if (item.type === "image") { + const url = anthropicImageBlockUrl(item); + if (url !== null) out.push({ image: url }); + } else if (item.type === "document") { + out.push({ text: inlineDocumentMarker(documentTitle(item)) }); + } + } + if (isError) out.unshift({ text: "[tool error]" }); + if (out.length === 0) return isError ? "[tool error]" : ""; + const parts: OcxContentPart[] = []; + let hasImage = false; + for (const entry of out) { + if ("text" in entry) parts.push({ type: "text", text: entry.text }); + else if (entry.image.length > 0) { + parts.push({ type: "image", imageUrl: entry.image }); + hasImage = true; + } + } + if (!hasImage) return parts.map(part => (part.type === "text" ? part.text : "")).join(""); + return parts; +} + +/** + * The tool catalog: the translator's function declarations (a hosted web-search tool and + * schema-less server tools are not function tools) read through the parser's `buildTools` and + * deduplicated by name as its catalog merge does. + */ +function toolCatalog(raw: unknown): OcxTool[] { + if (!Array.isArray(raw)) return []; + const declared: OcxTool[] = []; + for (const entry of raw) { + if (!isRec(entry)) continue; + const type = typeof entry.type === "string" ? entry.type : ""; + if (type.startsWith("web_search")) continue; + if (typeof entry.name !== "string" || entry.name.length === 0 || !isRec(entry.input_schema)) continue; + const tool: OcxTool = { + name: entry.name, + description: typeof entry.description === "string" ? entry.description : "", + parameters: normalizedParameters(entry.input_schema), + strict: typeof entry.strict === "boolean" ? entry.strict : false, + }; + if (Array.isArray(entry.allowed_callers)) { + tool.allowedCallers = entry.allowed_callers.filter((caller): caller is string => typeof caller === "string"); + } + declared.push(tool); + } + const seen = new Set(); + return declared.filter(tool => { + const key = namespacedToolName(tool.namespace, tool.name); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export interface MessagesIrConversation { + /** The resolved model the conversation was decoded for; identity naming and placeholders use it. */ + modelId: string; + context: OcxContext; + /** The native reasoning blobs the translator would have recorded, keyed by blob. */ + nativeReasoningReplay: ReadonlyMap; +} + +/** + * Decode a Messages body's conversation straight into the IR context. `modelId` is the resolved + * model the bridge body would carry. Throws `AnthropicRequestError` exactly where the translator's + * conversation walk would, and `TranslatorBudgetExceededError` where its envelope charges would. + */ +export function messagesConversationToIr( + raw: unknown, + cc: OcxClaudeCodeConfig | undefined, + budget: TranslatorBudget, + modelId: string, +): MessagesIrConversation { + if (!isRec(raw)) throw new AnthropicRequestError("request body must be a JSON object"); + if (typeof raw.model !== "string" || raw.model.length === 0) throw new AnthropicRequestError("model is required"); + if (!Array.isArray(raw.messages) || raw.messages.length === 0) { + throw new AnthropicRequestError("messages must be a non-empty array"); + } + const requestedModel = raw.model; + const now = Date.now(); + const messages: OcxMessage[] = []; + const nativeReasoningReplay = new Map(); + const blockedNames = effectiveBlockedSkillNames(cc); + const blockedCalls = blockedSkillCallIds(raw.messages, blockedNames); + // The parser's pending reasoning: it belongs to the next assistant turn, and turn boundaries + // clear it after keeping any signed or redacted replay. + const pending: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }> = []; + + const placeholder = (): OcxAssistantMessage => { + const last = messages[messages.length - 1]; + if (last && last.role === "assistant") return last; + const holder: OcxAssistantMessage = { role: "assistant", content: [], model: modelId, timestamp: now }; + messages.push(holder); + return holder; + }; + const holderWithReasoning = (): OcxAssistantMessage => { + const holder = placeholder(); + if (pending.length > 0) { + holder.content.push(...pending.map(entry => entry.part)); + pending.length = 0; + } + return holder; + }; + const preservePendingReplay = (): void => { + const replay = pending.filter(entry => entry.envelopeSigned || entry.part.redacted?.length); + if (replay.length > 0) placeholder().content.push(...replay.map(entry => entry.part)); + }; + const pushUser = (content: string | OcxContentPart[]): void => { + preservePendingReplay(); + pending.length = 0; + messages.push({ role: "user", content, timestamp: now }); + }; + const pushDeveloper = (text: string): void => { + preservePendingReplay(); + pending.length = 0; + messages.push({ role: "developer", content: nameDestination(text, modelId), timestamp: now }); + }; + const pushAssistantText = (parts: OcxTextContent[]): void => { + messages.push({ + role: "assistant", + content: pending.length > 0 ? [...pending.map(entry => entry.part), ...parts] : parts, + model: modelId, + timestamp: now, + }); + pending.length = 0; + }; + const pushToolCall = (id: string, name: string, input: unknown): void => { + holderWithReasoning().content.push({ + type: "toolCall", + id, + name, + arguments: functionCallArgumentsFromText(id, JSON.stringify(input ?? {})), + }); + }; + const pushToolResult = (callId: string, content: string | OcxContentPart[]): void => { + // Reasoning replayed after the call it belongs to joins that call's assistant turn (#950). + if (pending.length > 0) { + outer: for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role !== "assistant") continue; + for (const part of message.content) { + if (part.type === "toolCall" && part.id === callId) { + message.content = [...pending.map(entry => entry.part), ...message.content]; + break outer; + } + } + } + } + pending.length = 0; + let owner: { name: string; namespace?: string } = { name: "" }; + search: for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role !== "assistant") continue; + for (const part of message.content) { + if (part.type === "toolCall" && part.id === callId) { + owner = { name: part.name, namespace: part.namespace }; + break search; + } + } + } + messages.push({ + role: "toolResult", toolCallId: callId, toolName: owner.name, toolNamespace: owner.namespace, + content, isError: false, timestamp: now, + }); + }; + /** + * One translator reasoning item, read as the parser reads it. `encrypted` is the item's + * `encrypted_content`; the placeholder signature is the item's serialized form, spelled in the + * schema's key order. + */ + const pushReasoning = (id: string, summaryText: string, encrypted: string | undefined): void => { + const item: Rec = { + type: "reasoning", + id, + summary: summaryText.length > 0 ? [{ type: "summary_text", text: summaryText }] : [], + ...(encrypted !== undefined ? { encrypted_content: encrypted } : {}), + }; + const envelope = encrypted !== undefined ? decodeReasoningEnvelope(encrypted) : null; + const thinkingText = envelope?.txt ?? summaryText; + if (envelope?.krc && thinkingText.length === 0) { + const previous = messages[messages.length - 1]; + if (previous?.role === "assistant") previous.kiroRedactedReasoning = envelope.krc; + return; + } + if (!(thinkingText.length > 0 || envelope?.sig || envelope?.red?.length)) return; + const part: OcxThinkingContent = { + type: "thinking", + thinking: thinkingText, + signature: envelope?.sig ?? JSON.stringify(item), + ...(envelope?.red ? { redacted: envelope.red } : {}), + itemId: id, + }; + const envelopeSigned = typeof envelope?.sig === "string"; + const previous = pending[pending.length - 1]; + if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned && !previous.part.redacted) { + previous.part = { ...part, thinking: `${previous.part.thinking}\n${part.thinking}` }; + } else { + pending.push({ part, envelopeSigned }); + } + }; + + const userContent = (content: unknown): void => { + if (typeof content === "string") { + if (content.length > 0) pushUser(content); + return; + } + if (!Array.isArray(content)) return; + // Contiguous text, image and document blocks form one user turn; a tool_result ends it. + let parts: OcxContentPart[] = []; + let blocks = 0; + const flush = (): void => { + if (blocks > 0) pushUser(collapsed(parts)); + parts = []; + blocks = 0; + }; + for (const block of content) { + if (!isRec(block)) continue; + switch (block.type) { + case "text": + if (typeof block.text === "string") { + blocks++; + parts.push({ type: "text", text: maybeElideSkillText(block.text, blockedNames) }); + } + break; + case "image": { + const url = anthropicImageBlockUrl(block); + if (url !== null) { + blocks++; + // An empty reference is a block the parser drops. + if (url.length > 0) parts.push({ type: "image", imageUrl: url }); + } + break; + } + case "tool_result": { + flush(); + if (typeof block.tool_use_id !== "string" || block.tool_use_id.length === 0) { + throw new AnthropicRequestError("tool_result requires tool_use_id"); + } + pushToolResult( + block.tool_use_id, + blockedCalls.has(block.tool_use_id) ? skillElisionStub(block.tool_use_id) : toolResultContent(block), + ); + break; + } + case "document": { + blocks++; + const document = anthropicDocumentData(block); + parts.push(document + ? inlineDocumentFromDataUrl(document.fileData, document.title) + ?? { type: "text", text: document.title ? `[file: ${document.title}]` : "[file: inline data]" } + : { type: "text", text: inlineDocumentMarker(documentTitle(block)) }); + break; + } + default: + break; + } + } + flush(); + }; + + const assistantContent = (content: unknown): void => { + if (typeof content === "string") { + if (content.length > 0) pushAssistantText([{ type: "text", text: content }]); + return; + } + if (!Array.isArray(content)) return; + let text: OcxTextContent[] = []; + const flush = (): void => { + if (text.length > 0) pushAssistantText(text); + text = []; + }; + for (const block of content) { + if (!isRec(block)) continue; + switch (block.type) { + case "text": + if (typeof block.text === "string") text.push({ type: "text", text: block.text }); + break; + case "tool_use": + flush(); + if (typeof block.id !== "string" || block.id.length === 0 || typeof block.name !== "string" || block.name.length === 0) { + throw new AnthropicRequestError("tool_use requires id and name"); + } + pushToolCall(block.id, block.name, block.input); + break; + case "thinking": { + flush(); + const thinking = typeof block.thinking === "string" ? block.thinking : ""; + const signature = typeof block.signature === "string" ? block.signature : ""; + if (signature.startsWith(OCX_REASONING_PREFIX)) { + const owned = decodeReasoningEnvelope(signature, budget); + if (!owned) throw new AnthropicRequestError(MALFORMED_REASONING_SIGNATURE); + if (Object.hasOwn(owned, "sig")) { + throw new AnthropicRequestError("OpenCodex reasoning continuity cannot be replayed as an Anthropic signature"); + } + if (owned.nat) { + // The provider's own blob goes back only to the model that minted it. + const nat = owned.nat.model === requestedModel ? owned.nat : undefined; + if (nat) { + const previous = nativeReasoningReplay.get(nat.enc); + nativeReasoningReplay.set(nat.enc, previous === undefined || previous === nat.tag ? nat.tag : ""); + budget.chargeRetained(2 * nat.enc.length, { kind: "reasoning" }); + } + if (thinking.length === 0 && !nat) break; + pushReasoning(nat?.id ?? reasoningItemId(), thinking, nat?.enc); + break; + } + } + const encrypted = signature.length === 0 + ? undefined + : signature.startsWith(OCX_REASONING_PREFIX) ? signature : encodeReasoningEnvelope({ sig: signature }, budget); + if (encrypted) budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); + if (thinking.length === 0 && !encrypted) break; + pushReasoning(reasoningItemId(), thinking, encrypted); + break; + } + case "redacted_thinking": { + flush(); + const data = typeof block.data === "string" ? block.data : ""; + if (data.length > 0) { + const encrypted = encodeReasoningEnvelope({ red: [data] }, budget); + budget.chargeRetained(2 * encrypted.length, { kind: "reasoning" }); + pushReasoning(reasoningItemId(), "", encrypted); + } + break; + } + default: + break; + } + } + flush(); + }; + + for (const message of raw.messages) { + if (!isRec(message)) throw new AnthropicRequestError("each message must be an object"); + if (message.role === "user") userContent(message.content); + else if (message.role === "assistant") assistantContent(message.content); + else if (message.role === "system") { + const text = systemMessageText(message.content); + if (text.length > 0) pushDeveloper(text); + } else throw new AnthropicRequestError(`unsupported message role: ${String(message.role)}`); + } + + const system = anthropicSystemLayout(raw.system, cc); + if (system.dynamicNotice !== undefined) pushUser(system.dynamicNotice); + preservePendingReplay(); + + const tools = toolCatalog(raw.tools); + const context: OcxContext = { + ...(system.instructions ? { systemPrompt: [nameDestination(system.instructions, modelId)] } : {}), + messages, + ...(tools.length > 0 ? { tools } : {}), + }; + return { modelId, context: normalizeVisualizationContext(context), nativeReasoningReplay }; +} + +/** + * Put a directly decoded conversation onto the request the core parsed from the settings-only + * body, re-decoding if the core settled a different model id than the one decoded for. The tool + * choice is checked against the decoded catalog, which the settings-only parse could not see. + */ +export function withDirectMessagesConversation( + base: OcxParsedRequest, + decoded: MessagesIrConversation, + decodeFor: (modelId: string) => MessagesIrConversation, +): OcxParsedRequest { + const conversation = decoded.modelId === base.modelId ? decoded : decodeFor(base.modelId); + const choice = base.options.toolChoice; + if (choice && typeof choice === "object") { + const selectors = "allowedTools" in choice ? choice.allowedTools : [choice.name]; + const resolver = createToolChoiceResolver(conversation.context.tools ?? []); + for (const selector of selectors) { + if (resolver.candidateCount(selector) > 1) throw new Error(`ambiguous tool_choice name: ${selector}`); + } + } + return { ...base, context: conversation.context }; +} + +/** + * The policy-routing evidence the core reads from the bridge body (`evidenceFromBody`): a + * non-empty translated tool catalog, and an image block in a user turn. Images inside a + * tool_result sit under `output` on the bridge body, which that scan does not read. + */ +export function messagesRouteEvidence(raw: unknown): PolicyRequestEvidence { + if (!isRec(raw)) return {}; + const tools = toolsToResponses(raw.tools) !== undefined; + const image = Array.isArray(raw.messages) && raw.messages.some(message => isRec(message) + && message.role === "user" && Array.isArray(message.content) + && message.content.some(block => isRec(block) && block.type === "image" && imageReference(block))); + return { + ...(tools ? { toolsRequired: true } : {}), + ...(image ? { imageInputRequired: true } : {}), + }; +} + +function imageReference(block: Rec): boolean { + try { + return anthropicImageBlockUrl(block) !== null; + } catch { + return false; + } +} + diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index 0c7cf1a137d..8c65b642cb9 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -23,7 +23,7 @@ import { getRoutingProfile, POLICY_NAMESPACE, resolvePolicyProfileId } from "../ import { resolveWireProtocolOverride } from "../server/adapter-resolve"; import { nativeChatDeclineReason } from "../server/chat-native-eligibility"; import { nativeMessagesDeclineReason } from "../server/messages-native-eligibility"; -import { antigravityDirectDeclineReason } from "../server/antigravity-direct-eligibility"; +import { antigravityDirectDeclineReason, antigravityMessagesDeclineReason } from "../server/antigravity-direct-eligibility"; import { messagesSelectorTargetsSecondaryInstance, messagesSecondaryInstanceUnavailable } from "../server/messages-native-selector"; import { parseSyntheticRowId } from "../server/fast-row"; import type { OcxConfig } from "../types"; @@ -148,20 +148,27 @@ function candidateFor( // declines are not described by any Chat feature, so a preview cannot predict them. directEligible = !nativeEligible && routeKind === "direct" && antigravityDirectDeclineReason(settled, { ...body, model: rows.routeSelector }, config) === undefined; - } else if (inbound === "messages" && resolveProtocolSettings(config, settled.providerName).rollout.managedMessagesNative) { - // The runtime rule itself. With the switch off nothing is judged, so the default preview - // is exactly what it was before the managed native lane existed. - // Pinned effort is judged from config and the route; blocked-skill elision and the web-search - // sidecar depend on body content no feature describes, so a preview cannot predict them. - // Anthropic OAuth (PF-10) is judged from config alone: the rollout switch, the provider, its - // host and `anthropicAccountPool.enabled`. The stored-account quorum a sender supplies is - // never read here, so a preview selects, resolves and refreshes no account. - const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, { - ...rows, - claudeCode: config.claudeCode, - }); - nativeEligible = reason === undefined; - if (reason) declineReasons = [reason]; + } else if (inbound === "messages") { + const body = messagesBodyForFeatures(features); + if (resolveProtocolSettings(config, settled.providerName).rollout.managedMessagesNative) { + // The runtime rule itself. With the switch off nothing is judged, so the default preview + // is exactly what it was before the managed native lane existed. + // Pinned effort is judged from config and the route; blocked-skill elision and the web-search + // sidecar depend on body content no feature describes, so a preview cannot predict them. + // Anthropic OAuth (PF-10) is judged from config alone: the rollout switch, the provider, its + // host and `anthropicAccountPool.enabled`. The stored-account quorum a sender supplies is + // never read here, so a preview selects, resolves and refreshes no account. + const reason = nativeMessagesDeclineReason(settled, body, config, { + ...rows, + claudeCode: config.claudeCode, + }); + nativeEligible = reason === undefined; + if (reason) declineReasons = [reason]; + } + // The Antigravity direct lane for a standalone route, as for Chat. The ingress plans it on the + // resolved selector, which is `rows.routeSelector`. + directEligible = !nativeEligible && routeKind === "direct" + && antigravityMessagesDeclineReason(settled, body, rows.routeSelector, config) === undefined; } return { provider: route.providerName, diff --git a/src/protocols/settings.ts b/src/protocols/settings.ts index 2d715bd9594..139b00eca5c 100644 --- a/src/protocols/settings.ts +++ b/src/protocols/settings.ts @@ -102,7 +102,12 @@ export function resolveProtocolSettings(config: ProtocolConfig, providerName?: s // OAuth extension is meaningless without the key-auth path it extends. managedMessagesNativeOAuth: managedMessagesNative && nativeOn("managedMessagesNativeOAuth"), directEncoders: on("directEncoders"), - antigravityDirect: on("antigravityDirect"), + // Default on: an absent switch takes the Antigravity direct lane, which falls back to the + // bridge per request whenever its eligibility rule declines. Only an explicit `false` turns + // it off; a non-boolean value or a malformed container fails closed like every other switch. + antigravityDirect: validContainers && (Object.hasOwn(rollout, "antigravityDirect") + ? rollout.antigravityDirect === true + : true), shadowPlan: on("shadowPlan"), }), }); diff --git a/src/server/antigravity-direct-eligibility.ts b/src/server/antigravity-direct-eligibility.ts index e7ea4966e9a..0c96735b9fa 100644 --- a/src/server/antigravity-direct-eligibility.ts +++ b/src/server/antigravity-direct-eligibility.ts @@ -1,19 +1,20 @@ /** - * Whether a settled Chat route may take the Antigravity direct lane. + * Whether a settled Chat or Messages route may take the Antigravity direct lane. * - * On that lane the Chat ingress hands the Responses core a body that carries only the request + * On that lane the ingress hands the Responses core a body that carries only the request * settings (model, stream, reasoning, limits, tool choice, tier) and decodes the conversation - * straight from the Chat body into the IR at the parse seam (`src/protocols/codecs/chat-ir.ts`), - * so the Google adapter compiles from the caller's own messages. Everything that is decided from - * those settings keeps its current owner in the core: routing, account rotation, effort policy, - * usage and logging. + * straight from the client body into the IR at the parse seam (`src/protocols/codecs/chat-ir.ts`, + * `messages-ir.ts`), so the Google adapter compiles from the caller's own messages. Everything + * that is decided from those settings keeps its current owner in the core: routing, account + * rotation, effort policy, usage and logging. * - * PURE: reads the route, the body's structure and config. The Chat ingress and the protocol - * planner (`src/protocols/plan-snapshot.ts`) both ask here, so a preview and the real send cannot + * PURE: reads the route, the body's structure and config. The ingresses and the protocol planner + * (`src/protocols/plan-snapshot.ts`) both ask here, so a preview and the real send cannot * disagree about the lane. */ import { chatBodyCarriesImage } from "../chat/image-parts"; import { contentToText } from "../chat/inbound"; +import { isClaudeWebSearchToolName } from "../claude/outbound"; import { isShadowSourceModel } from "../lib/shadow-call"; import type { ProtocolReasonCode } from "../protocols/contract"; import { resolveProtocolSettings } from "../protocols/settings"; @@ -58,10 +59,10 @@ function declaresHostedWebSearch(body: Readonly): boolean { * The shadow-call intercept rewrites the model to another target (often a combo) before the * core parses the body, so the request would leave the route this lane was decided for. */ -function shadowInterceptMayApply(body: Readonly, config: OcxConfig): boolean { +function shadowInterceptMayApply(model: unknown, config: OcxConfig): boolean { const intercept = config.shadowCallIntercept; return intercept?.enabled === true && typeof intercept.model === "string" && intercept.model.length > 0 - && typeof body.model === "string" && isShadowSourceModel(body.model, intercept.sourceModels); + && typeof model === "string" && isShadowSourceModel(model, intercept.sourceModels); } const SKILLS_CATALOG_MARKER = ""; @@ -99,7 +100,7 @@ export interface AntigravityDirectRuntime { * The first rule that keeps a Chat request off the Antigravity direct lane, or `undefined` when * the route is eligible. * - * - the `protocols.rollout.antigravityDirect` switch is off; + * - the `protocols.rollout.antigravityDirect` switch is explicitly off (it defaults on); * - the final adapter is not Google in Cloud Code Assist mode; * - a combo or policy route owns multi-candidate execution in the Responses pipeline; * - a hosted web-search tool would engage the sidecar, which reads the Responses body; @@ -115,14 +116,93 @@ export function antigravityDirectDeclineReason( config: OcxConfig, runtime: AntigravityDirectRuntime = {}, ): AntigravityDirectDeclineReason | undefined { - if (!resolveProtocolSettings(config).rollout.antigravityDirect) return "rollout-disabled"; - if (!isAntigravityRoute(route)) return "not-migrated"; - if (route.combo !== undefined || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route"; + const routeDecline = antigravityRouteDeclineReason(route, config); + if (routeDecline) return routeDecline; if (declaresHostedWebSearch(body)) return "hosted-tool"; if (chatBodyCarriesImage(body) && requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName)) { return "vision-preprocessing"; } - if (shadowInterceptMayApply(body, config)) return "bridge-only-policy"; + if (shadowInterceptMayApply(body.model, config)) return "bridge-only-policy"; if (carriesBridgeRewrittenText(body) || runtime.cacheDiagnostics === true) return "bridge-only-policy"; return undefined; } + +/** The declines that read only the route and config, shared by both ingresses. */ +function antigravityRouteDeclineReason(route: RouteShape, config: OcxConfig): AntigravityDirectDeclineReason | undefined { + if (!resolveProtocolSettings(config).rollout.antigravityDirect) return "rollout-disabled"; + if (!isAntigravityRoute(route)) return "not-migrated"; + if (route.combo !== undefined || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route"; + return undefined; +} + +function anthropicBlocks(content: unknown): Rec[] { + return Array.isArray(content) ? content.filter(isRec) : []; +} + +/** Text of an Anthropic `system` or message content: a string or its text blocks. */ +function anthropicText(content: unknown): string { + if (typeof content === "string") return content; + return anthropicBlocks(content) + .map(block => (block.type === "text" && typeof block.text === "string" ? block.text : "")) + .join("\n"); +} + +/** A Messages hosted web-search tool (or a forced WebSearch choice) would engage the sidecar. */ +function messagesDeclaresHostedWebSearch(body: Readonly): boolean { + const tools = Array.isArray(body.tools) + && body.tools.some(tool => isRec(tool) && typeof tool.type === "string" && tool.type.startsWith("web_search")); + const choice = body.tool_choice; + return tools || (isRec(choice) && choice.type === "tool" && typeof choice.name === "string" + && isClaudeWebSearchToolName(choice.name)); +} + +/** An image block in a user turn or inside a tool result. */ +function messagesBodyCarriesImage(body: Readonly): boolean { + if (!Array.isArray(body.messages)) return false; + return body.messages.some(message => isRec(message) && message.role === "user" + && anthropicBlocks(message.content).some(block => block.type === "image" + || (block.type === "tool_result" && anthropicBlocks(block.content).some(inner => inner.type === "image")))); +} + +/** The Messages counterpart of `carriesBridgeRewrittenText`: system text and assistant history. */ +function messagesCarryBridgeRewrittenText(body: Readonly): boolean { + if (anthropicText(body.system).includes(SKILLS_CATALOG_MARKER)) return true; + if (!Array.isArray(body.messages)) return false; + return body.messages.some(message => { + if (!isRec(message)) return false; + if (message.role === "system") return anthropicText(message.content).includes(SKILLS_CATALOG_MARKER); + if (message.role === "assistant") return anthropicText(message.content).includes(GENERATED_IMAGE_LINK); + return false; + }); +} + +/** + * The first rule that keeps a Messages request off the Antigravity direct lane, or `undefined` + * when the route is eligible. `model` is the resolved model the bridge body would carry, which is + * what the shadow-call intercept matches. + * + * The route and config rules are the Chat ones. The body rules are the Messages spellings of the + * same hazards: a hosted web-search tool (`web_search_*`) or forced WebSearch choice, an image + * (in a user turn or a tool result) for a model the operator declared unable to read it, a + * shadow-call intercept, a skills catalog in the system text or a generated-image link in + * assistant history, or an observing cache diagnostic recorder. Blocked-skill elision, the + * `stabilizePromptCache` relocation and the ocxr1 envelopes are reproduced by the decoder and + * decline nothing. + */ +export function antigravityMessagesDeclineReason( + route: RouteShape, + body: Readonly, + model: string, + config: OcxConfig, + runtime: AntigravityDirectRuntime = {}, +): AntigravityDirectDeclineReason | undefined { + const routeDecline = antigravityRouteDeclineReason(route, config); + if (routeDecline) return routeDecline; + if (messagesDeclaresHostedWebSearch(body)) return "hosted-tool"; + if (messagesBodyCarriesImage(body) && requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName)) { + return "vision-preprocessing"; + } + if (shadowInterceptMayApply(model, config)) return "bridge-only-policy"; + if (messagesCarryBridgeRewrittenText(body) || runtime.cacheDiagnostics === true) return "bridge-only-policy"; + return undefined; +} diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 878ba151003..7bf4e6fa456 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -21,6 +21,13 @@ import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize" import { createToolCallIdAllocator } from "../adapters/tool-call-id"; import { openAIChatSerializesThinking } from "../adapters/openai-chat/messages"; import { messagesToResponsesTranslation } from "../protocols/codecs/messages"; +import { + messagesDirectPlanHolds, + messagesDirectRequestOption, + messagesDirectTraceDecline, + planMessagesAntigravityDirect, + type MessagesDirectPlan, +} from "./messages-antigravity-direct"; import { AnthropicRequestError, DesktopModelMappingUnavailableError, extractOcxEffortDirective, extractOcxRouteDirective, nativeAnthropicProjection, resolveInboundModel, type ClaudeCacheKeySource } from "../claude/inbound"; import { nativeReasoningTag, type NativeReasoningOwner } from "../responses/reasoning-replay-cache"; import { isKnownDesktop3pModelId, resolveDesktop3pAlias } from "../claude/desktop-3p"; @@ -851,6 +858,21 @@ export async function handleClaudeMessages( } } +/** The client answer for a Messages body the translation (or the direct decode) refused. */ +function translationFailure(err: unknown, logCtx: RequestLogContext, logIds: { requestId: string; start: number } | undefined): Response { + const overflow = isTranslatorBudgetExceededError(err); + const unavailable = err instanceof DesktopModelMappingUnavailableError; + const status = overflow ? 413 : unavailable ? 503 : err instanceof AnthropicRequestError ? 400 : 500; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" }); + if (unavailable) return desktopMappingUnavailableResponse(err); + return anthropicErrorResponse( + status, + overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err), + overflow ? "request_too_large" : undefined, + overflow ? "translation_buffer_limit" : undefined, + ); +} + /** * Translate a Claude Messages request, route it through the Responses pipeline, * and translate the reply back. Runs under a translator budget owned by the @@ -886,6 +908,11 @@ async function handleClaudeMessagesWithBudget( let effortRow: ParsedEffortRowId | null = null; let fastRow: ParsedFastRowId | null = null; let requestedModel = ""; + // Antigravity direct lane (off by default): planned before translation, kept only if routing + // settles on the route the plan was made for. + let directPlan: MessagesDirectPlan | undefined; + /** The retained charge for the settings-only body, handed back if the plan is left. */ + let directSettingsBodyBytes = 0; // Built only under the reject policy; the legacy default leaves this request untouched. let envelope: ProtocolEnvelope | undefined; let messagesFeatures: () => Iterable = () => []; @@ -1035,9 +1062,16 @@ async function handleClaudeMessagesWithBudget( }; delete anthropicBody.thinking; } - let translation; + // On the direct lane the core receives only the request settings; the conversation is + // already decoded into the IR. Without a plan the translation below runs as before. + directPlan = planMessagesAntigravityDirect(anthropicBody, cc, config, translatorBudget); + let translation = directPlan && { + body: directPlan.settingsBody, + cacheKeySource: directPlan.cacheKeySource, + nativeReasoningReplay: directPlan.conversation.nativeReasoningReplay, + }; try { - translation = messagesToResponsesTranslation(anthropicBody, cc, translatorBudget); + translation ??= messagesToResponsesTranslation(anthropicBody, cc, translatorBudget); } catch (err) { // A native Messages route can discard an undecodable proxy envelope. Keep the // original error for any route that ultimately translates to Responses. @@ -1053,21 +1087,13 @@ async function handleClaudeMessagesWithBudget( // fields only, so the caller intent is applied to the TRANSLATED body rather than the // inbound one. if (fastRow) internalBody.service_tier = "priority"; - translatorBudget.chargeRetained(jsonUtf8Bytes(internalBody), { kind: "request_copies" }); + const internalBodyBytes = jsonUtf8Bytes(internalBody); + translatorBudget.chargeRetained(internalBodyBytes, { kind: "request_copies" }); + if (directPlan) directSettingsBodyBytes = internalBodyBytes; cacheKeySource = translation.cacheKeySource; nativeReasoningReplay = translation.nativeReasoningReplay; } catch (err) { - const overflow = isTranslatorBudgetExceededError(err); - const unavailable = err instanceof DesktopModelMappingUnavailableError; - const status = overflow ? 413 : unavailable ? 503 : err instanceof AnthropicRequestError ? 400 : 500; - if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" }); - if (unavailable) return desktopMappingUnavailableResponse(err); - return anthropicErrorResponse( - status, - overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err), - overflow ? "request_too_large" : undefined, - overflow ? "translation_buffer_limit" : undefined, - ); + return translationFailure(err, logCtx, logIds); } if (!requestedModel) requestedModel = (anthropicBody as Rec).model as string; @@ -1120,8 +1146,30 @@ async function handleClaudeMessagesWithBudget( } return requestTokenFloor; }; + /** Leave the direct plan: the core gets the full bridge translation, as without the switch. */ + const leaveDirectPlan = (): unknown => { + // Hand back everything the plan retained: its decode and the settings-only body it replaces. + directPlan?.releaseDecodeCharges(); + directPlan = undefined; + nativeReasoningReplay = undefined; + translatorBudget.releaseRetained(directSettingsBodyBytes, { kind: "request_copies" }); + directSettingsBodyBytes = 0; + try { + const translation = messagesToResponsesTranslation(anthropicBody, cc, translatorBudget); + internalBody = translation.body; + if (fastRow) internalBody.service_tier = "priority"; + internalBody.stream = true; + translatorBudget.chargeRetained(jsonUtf8Bytes(internalBody), { kind: "request_copies" }); + cacheKeySource = translation.cacheKeySource; + nativeReasoningReplay = translation.nativeReasoningReplay; + return undefined; + } catch (err) { + return err; + } + }; + let directLeaveError: unknown; try { - const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + const route = routeModel(config, internalBody.model as string, directPlan ? directPlan.evidence : evidenceFromBody(internalBody)); // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.staticPolicy = captureRouteStaticPolicy( @@ -1139,6 +1187,8 @@ async function handleClaudeMessagesWithBudget( route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy); logCtx.routeDecision = route.routeDecision; settledRoute = route; + // Before any route-specific shaping, so a request that leaves the lane is shaped as the bridge's. + if (directPlan && !messagesDirectPlanHolds(directPlan, route, anthropicBody as Rec, config)) directLeaveError = leaveDirectPlan(); if (directEncodersApply(config, route)) { clientEncoder = { protocol: "messages", stream, model: requestedModel, inputTokenFloor: claudeRequestTokenFloor() }; } @@ -1184,6 +1234,14 @@ async function handleClaudeMessagesWithBudget( } /* unknown model: let handleResponses shape the 404 */ } + if (directPlan && !settledRoute) directLeaveError ??= leaveDirectPlan(); + if (directLeaveError !== undefined) return translationFailure(directLeaveError, logCtx, logIds); + const directRoute = directPlan && settledRoute ? settledRoute : undefined; + // The direct lane answers through the direct encoder whatever `directEncoders` says: neither + // direction passes through the internal Responses body. + if (directRoute) { + clientEncoder = { protocol: "messages", stream, model: requestedModel, inputTokenFloor: claudeRequestTokenFloor(), requestLane: "direct" }; + } // PF-08: a managed-key Anthropic route sends its Messages body natively. The caller-forward // passthrough above was decided on the caller's own credential and never reaches this point. @@ -1207,11 +1265,20 @@ async function handleClaudeMessagesWithBudget( return anthropicErrorResponse(400, nativeProjectionOnlyError.message, "invalid_request_error"); } // With the switch on, a declined route says why on its bridge mark, as native Chat does. - if (nativeDecline && nativeDecline !== "rollout-disabled") { + const antigravityDecline = settledRoute && !nativeMessagesRoute && !directRoute + ? messagesDirectTraceDecline(settledRoute, anthropicBody, String(internalBody.model ?? ""), config) + : undefined; + if (directRoute) { + markProtocolEntry(logCtx, { inbound: "messages", lane: "direct", features: messagesFeatures }); + } else if ((nativeDecline && nativeDecline !== "rollout-disabled") || antigravityDecline) { markProtocolEntry(logCtx, { inbound: "messages", lane: "bridge", - reasonCodes: [...(effortRow ? ["effort-row" as const] : fastRow ? ["fast-row" as const] : []), nativeDecline], + reasonCodes: [ + ...(effortRow ? ["effort-row" as const] : fastRow ? ["fast-row" as const] : []), + ...(nativeDecline && nativeDecline !== "rollout-disabled" ? [nativeDecline] : []), + ...(antigravityDecline ? [antigravityDecline] : []), + ], features: messagesFeatures, }); } @@ -1219,7 +1286,11 @@ async function handleClaudeMessagesWithBudget( if (envelope && settledRoute && !settledRoute.combo && settledRoute.routeKind !== "policy") { const verdict = checkRepresentable({ inbound: "messages", - requestPath: requestPathForLane("messages", nativeMessagesRoute ? "native" : "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), + requestPath: requestPathForLane( + "messages", + nativeMessagesRoute ? "native" : directRoute ? "direct" : "bridge", + upstreamWireForAdapter(settledRoute.provider.adapter), + ), features: envelope.features(), policy: "reject", }); @@ -1369,6 +1440,9 @@ async function handleClaudeMessagesWithBudget( onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), ...(clientEncoder ? { clientEncoder } : {}), + ...(directRoute && directPlan + ? { directRequest: messagesDirectRequestOption(directPlan, anthropicBody, cc, translatorBudget) } + : {}), }); const response = logIds ? responseWithDeferredRequestLog(upstream, logIds.requestId, logIds.start, logCtx) : upstream; // Already in the Messages wire (direct encoder): no conversion. diff --git a/src/server/messages-antigravity-direct.ts b/src/server/messages-antigravity-direct.ts new file mode 100644 index 00000000000..47fe3c3760a --- /dev/null +++ b/src/server/messages-antigravity-direct.ts @@ -0,0 +1,195 @@ +/** + * The Antigravity direct lane for the Messages ingress (`protocols.rollout.antigravityDirect`). + * + * The Messages ingress translates before it routes: the route is decided on the translated body's + * resolved model. So the lane is planned ahead of translation on the settings-only body + * (`anthropicSettingsTranslation`), whose model is that same resolved model, against a preview of + * the route; the conversation is decoded straight into the IR only when that preview is an + * eligible Antigravity route. The ingress then routes the settings-only body as usual and keeps + * the lane only if the settled route is still the one the plan was made for; otherwise it builds + * the full bridge translation, as it would have without the switch. + * + * Refusals stay the bridge's by construction: a settings error, or an undecodable proxy envelope + * (which the bridge retries through the native projection), returns no plan and the ingress takes + * its ordinary translation path, which answers exactly as before. Any other decode error is one + * the translator's walk raises with the same message at the same point + * (`tests/responses/protocol-messages-ir-parity.test.ts`). + */ +import { anthropicSettingsTranslation, AnthropicRequestError, type ClaudeCacheKeySource } from "../claude/inbound"; +import { messagesToResponsesTranslation } from "../protocols/codecs/messages"; +import { + MALFORMED_REASONING_SIGNATURE, + messagesConversationToIr, + messagesRouteEvidence, + withDirectMessagesConversation, + type MessagesIrConversation, +} from "../protocols/codecs/messages-ir"; +import { resolveProtocolSettings } from "../protocols/settings"; +import type { TranslatorBudget, TranslatorBufferKind } from "../lib/translator-budget"; +import type { PolicyRequestEvidence } from "../routing/evaluator"; +import { previewRouteModel, type RouteResult } from "../router"; +import type { OcxClaudeCodeConfig, OcxConfig } from "../types"; +import { antigravityMessagesDeclineReason, type AntigravityDirectDeclineReason } from "./antigravity-direct-eligibility"; +import { cacheDiagnosticsObserving } from "./request-log"; +import type { DirectRequestOption } from "./responses/core-options"; + +type Rec = Record; + +export interface MessagesDirectPlan { + /** The concrete route the plan was made for. */ + providerName: string; + modelId: string; + /** The settings-only Responses body the core receives, and its prompt-cache key provenance. */ + settingsBody: Rec; + cacheKeySource: ClaudeCacheKeySource; + conversation: MessagesIrConversation; + /** What the core would have read from the bridge body for policy routing. */ + evidence: PolicyRequestEvidence; + /** + * Release the retained charges the plan's decode made. The ingress calls it when it abandons + * the plan for the full translation, which charges the same reasoning copies again. + */ + releaseDecodeCharges(): void; +} + +/** + * The request budget, with the retained charges made through it recorded so a decode the ingress + * abandons can hand them back. Without that, the bridge translation that follows would be held to + * a budget already spent on a copy nobody keeps, and could answer 413 where the bridge alone fits. + */ +function recordingBudget(budget: TranslatorBudget): { budget: TranslatorBudget; release(): void } { + const charged = new Map(); + const recording: TranslatorBudget = { + openCall: id => budget.openCall(id), + closeCall: id => budget.closeCall(id), + reserveTransient: (bytes, scope) => budget.reserveTransient(bytes, scope), + chargeRetained: (delta, scope) => { + budget.chargeRetained(delta, scope); + charged.set(scope.kind, (charged.get(scope.kind) ?? 0) + delta); + }, + releaseRetained: (bytes, scope) => { + budget.releaseRetained(bytes, scope); + charged.set(scope.kind, (charged.get(scope.kind) ?? 0) - bytes); + }, + observeAcceptedRequestCopy: bytes => budget.observeAcceptedRequestCopy(bytes), + observeExternallyCapped: (kind, bytes) => budget.observeExternallyCapped(kind, bytes), + snapshot: () => budget.snapshot(), + dispose: () => budget.dispose(), + }; + return { + budget: recording, + release: () => { + for (const [kind, bytes] of charged) if (bytes > 0) budget.releaseRetained(bytes, { kind }); + charged.clear(); + }, + }; +} + +/** + * Plan the direct lane for a Messages body, or return `undefined` to translate as before. Throws + * only what the translator's conversation walk throws for the same body. + */ +export function planMessagesAntigravityDirect( + body: unknown, + cc: OcxClaudeCodeConfig | undefined, + config: OcxConfig, + budget: TranslatorBudget, +): MessagesDirectPlan | undefined { + if (!resolveProtocolSettings(config).rollout.antigravityDirect) return undefined; + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + let settings: ReturnType; + let route: RouteResult; + try { + settings = anthropicSettingsTranslation(body, cc); + route = previewRouteModel(config, settings.body.model as string); + } catch { + return undefined; + } + const model = settings.body.model as string; + if (!messagesDirectRouteHolds(route, body as Rec, model, config)) return undefined; + const recorded = recordingBudget(budget); + let conversation: MessagesIrConversation; + try { + conversation = messagesConversationToIr(body, cc, recorded.budget, model); + } catch (err) { + if (err instanceof AnthropicRequestError && err.message === MALFORMED_REASONING_SIGNATURE) { + // The bridge retries this body through the native projection; give it the whole budget. + recorded.release(); + return undefined; + } + throw err; + } + return { + providerName: route.providerName, + modelId: route.modelId, + settingsBody: settings.body, + cacheKeySource: settings.cacheKeySource, + conversation, + evidence: messagesRouteEvidence(body), + releaseDecodeCharges: recorded.release, + }; +} + +/** Whether a route may carry this body on the direct lane, judged as the ingress judges it. */ +export function messagesDirectRouteHolds( + route: Pick, + body: Rec, + model: string, + config: OcxConfig, +): boolean { + return antigravityMessagesDeclineReason(route, body, model, config, { cacheDiagnostics: cacheDiagnosticsObserving() }) === undefined; +} + +/** Whether the route the ingress settled is still the one `plan` was made for, and still eligible. */ +export function messagesDirectPlanHolds(plan: MessagesDirectPlan, route: RouteResult, body: Rec, config: OcxConfig): boolean { + return route.providerName === plan.providerName && route.modelId === plan.modelId + && messagesDirectRouteHolds(route, body, plan.settingsBody.model as string, config); +} + +/** + * The lane's decline for a bridged request's trace, when it says something about this route: with + * the switch off or a non-Antigravity target the path already explains the bridge. + */ +export function messagesDirectTraceDecline( + route: RouteResult, + body: unknown, + model: string, + config: OcxConfig, +): AntigravityDirectDeclineReason | undefined { + if (!body || typeof body !== "object" || Array.isArray(body)) return undefined; + const reason = antigravityMessagesDeclineReason(route, body as Rec, model, config, { cacheDiagnostics: cacheDiagnosticsObserving() }); + return reason === "rollout-disabled" || reason === "not-migrated" ? undefined : reason; +} + +/** + * The core option for a planned request. The bridge conversation is translated at most once, and + * only if the core asks for it (a reroute, or an ambiguous-reset judgment). + */ +export function messagesDirectRequestOption( + plan: MessagesDirectPlan, + body: unknown, + cc: OcxClaudeCodeConfig | undefined, + budget: TranslatorBudget, +): DirectRequestOption { + let bridge: Rec | undefined; + return { + protocol: "messages", + providerName: plan.providerName, + modelId: plan.modelId, + evidence: plan.evidence, + decode: base => withDirectMessagesConversation( + base, + plan.conversation, + modelId => messagesConversationToIr(body, cc, budget, modelId), + ), + bridgeConversation: () => { + bridge ??= messagesToResponsesTranslation(body, cc, budget).body; + return { + input: bridge.input, + ...(bridge.instructions !== undefined ? { instructions: bridge.instructions } : {}), + ...(bridge.tools !== undefined ? { tools: bridge.tools } : {}), + }; + }, + }; +} + diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index c22e0691d60..580b3fa1fc6 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -79,7 +79,7 @@ export interface ClientEncoderOption { * bridge body. */ export interface DirectRequestOption { - protocol: "chat"; + protocol: "chat" | "messages"; /** The concrete route the ingress decided the lane for. */ providerName: string; modelId: string; @@ -254,7 +254,7 @@ export interface HandleResponsesOptions { * returning a Responses body, which the ingress converts as before. */ clientEncoder?: ClientEncoderOption; - /** Set only by the Chat ingress on an eligible Antigravity route; see {@link DirectRequestOption}. */ + /** Set only by the Chat or Messages ingress on an eligible Antigravity route; see {@link DirectRequestOption}. */ directRequest?: DirectRequestOption; } diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 37be2ffaa9e..6c08185aa9a 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -252,9 +252,12 @@ Antigravity direct lane) the request path is `[inbound, "ir", upstream]` and the ## Antigravity direct lane -Behind `protocols.rollout.antigravityDirect` (default off). A Chat request whose settled route is -one concrete Google provider in Cloud Code Assist mode (Antigravity) skips the internal Responses -body in both directions. Antigravity's `google` adapter has the `other` wire, so neither native +Behind `protocols.rollout.antigravityDirect`, the one rollout switch that defaults on: an absent value +takes the lane, only an explicit `false` turns it off, and a non-boolean value or a malformed +`protocols` container fails closed. Every request the eligibility rule declines falls back to the +bridge. A Chat or Messages request whose settled route is one concrete Google provider in Cloud +Code Assist mode (Antigravity) skips the internal Responses body in both directions. +Antigravity's `google` adapter has the `other` wire, so neither native lane can take it, and on the bridge its Chat traffic was decoded twice (Chat body -> Responses items -> IR) and encoded twice unless `directEncoders` was on. @@ -313,14 +316,31 @@ wire. `_rawBody` readers that matter only to other wires, Codex surfaces or the lane declines see an empty conversation, which is why the lane is limited to this route. The ingress asks for the direct encoder (`requestLane: "direct"`) whatever `directEncoders` says, -so the response half is `[other, ir, chat]` as well. Messages to Antigravity is not on this lane. +so the response half is `[other, ir, chat]` as well. + +### Messages on the lane + +The Messages ingress routes after translating, so `planMessagesAntigravityDirect` (`src/server/messages-antigravity-direct.ts`) +plans first on `anthropicSettingsTranslation`: the bridge body's settings with `input: []`, completed by the same +`applyAnthropicSettings`, so the resolved model, prompt-cache key, tool choice and reasoning are the bridge's. It +previews the route on that model, asks `antigravityMessagesDeclineReason` (the Chat rules, spelled for Messages: +`web_search_*` tools or a forced WebSearch choice, images in user turns or tool results, the intercept on the +resolved model, system and assistant text the core rewrites), then decodes with `messagesConversationToIr` +(`src/protocols/codecs/messages-ir.ts`). Routing uses `messagesRouteEvidence`; a settled route that is not the +planned eligible one gets the full translation from `leaveDirectPlan` before route shaping. A settings error or an +undecodable ocxr1 envelope (retried by the bridge through `nativeAnthropicProjection`) yields no plan, so it +refuses exactly as before. The decoder reproduces blocked-skill elision, the `stabilizePromptCache` relocation, +tool-result errors, images, documents and `tool_reference` markers, and thinking envelopes (encoded and charged as +the translator does, decoded as the parser does) plus the native replay map. The Google adapter still sends no +thinking text on either lane, only call-bound `thoughtSignature`s. `tests/responses/protocol-chat-ir-parity.test.ts` holds the decoder to the bridge over a fixture corpus, refusals included, and pins routing evidence and feature effects; `tests/responses/protocol-antigravity-direct-eligibility.test.ts` pins the rule and the plan; `tests/responses/protocol-antigravity-direct-chat.test.ts` sends the same Chat request on both lanes to a fake Cloud Code Assist upstream and requires the same wire body, the same answer and -the `chat -> ir -> other` trace. +the `chat -> ir -> other` trace. `tests/responses/protocol-messages-ir-parity.test.ts` and +`protocol-antigravity-direct-messages.test.ts` do the same for Messages. ## Native Chat candidates in combos @@ -529,10 +549,11 @@ only readers of the `apiSurfaces` and `protocols` config keys. Responses and Cha always served. The Messages surface uses an explicit `apiSurfaces.messages.enabled` boolean when present, closes when that value is present but malformed, and otherwise inherits `claudeCode.enabled !== false`. The unrepresentable policy defaults to `legacy` and every -`protocols.rollout` switch defaults off; the OAuth native-Messages switch is effective only with +`protocols.rollout` switch defaults off except `antigravityDirect`; the OAuth native-Messages switch is effective only with the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above); `directEncodersApply` reads `directEncoders` on both; `antigravityDirectDeclineReason` reads -`antigravityDirect` for the Chat ingress and the planner; the Chat ingress reads `nativeChatCombos` +`antigravityDirect` for the Chat ingress and the planner, and `antigravityMessagesDeclineReason` for +the Messages ingress and the planner; the Chat ingress reads `nativeChatCombos` for combo routes (above); `managedMessagesNative` and `managedMessagesNativeOAuth` are read through `nativeMessagesDeclineReason` by the Messages ingress, `count_tokens` and the planner (above); `shadowPlan` is read by `recordProtocolShadowPlan` ([Shadow plan](#shadow-plan)). Every diff --git a/tests/config/protocol-settings.test.ts b/tests/config/protocol-settings.test.ts index c5691515888..32ca462c438 100644 --- a/tests/config/protocol-settings.test.ts +++ b/tests/config/protocol-settings.test.ts @@ -50,7 +50,7 @@ describe("API surface resolution", () => { }); describe("protocol settings resolution", () => { - test("defaults are legacy policy with every rollout switch off", () => { + test("defaults are legacy policy with every rollout switch off except the Antigravity direct lane", () => { expect(resolveProtocolSettings(cfg({}))).toEqual({ unrepresentable: "legacy", rollout: { @@ -58,12 +58,23 @@ describe("protocol settings resolution", () => { managedMessagesNative: false, managedMessagesNativeOAuth: false, directEncoders: false, - antigravityDirect: false, + antigravityDirect: true, shadowPlan: false, }, }); }); + test("the Antigravity direct lane is on unless explicitly off, and fails closed on malformed input", () => { + const lane = (protocols: unknown) => resolveProtocolSettings(cfg({ protocols } as never)).rollout.antigravityDirect; + expect(lane(undefined)).toBe(true); + expect(lane({ rollout: {} })).toBe(true); + expect(lane({ rollout: { antigravityDirect: true } })).toBe(true); + expect(lane({ rollout: { antigravityDirect: false } })).toBe(false); + expect(lane({ rollout: { antigravityDirect: "true" } })).toBe(false); + expect(lane({ rollout: "on" })).toBe(false); + expect(lane("on")).toBe(false); + }); + test("only literal true enables a switch and OAuth requires the key-auth switch", () => { const settings = resolveProtocolSettings(cfg({ protocols: { unrepresentable: "reject", rollout: { directEncoders: "true", managedMessagesNativeOAuth: true } }, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 67e3147d48d..aefd061f2a2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -258,6 +258,7 @@ "protocol-direct-encoders-local-upstream.test.ts": "responses", "protocol-chat-ir-parity.test.ts": "responses", "protocol-antigravity-direct-chat.test.ts": "responses", "protocol-antigravity-direct-eligibility.test.ts": "responses", + "protocol-messages-ir-parity.test.ts": "responses", "protocol-antigravity-direct-messages.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", "chat-native-combo.test.ts": "responses", "jev-initial-effort-wire.test.ts": "responses", "messages-native-eligibility.test.ts": "responses", "messages-native-bridge-policy.test.ts": "responses", "responses-first-send-reservation.test.ts": "responses", "responses-first-send-spend-policy.test.ts": "responses", "responses-dispatch-receipt.test.ts": "responses", "responses-hosted-send-hop.test.ts": "responses", "responses-hosted-send-producer.test.ts": "responses", "responses-hosted-send-reservation.test.ts": "responses", "responses-hosted-send-round-parity.test.ts": "responses", "responses-hosted-send-unsent-hop.test.ts": "responses", "responses-hosted-send-ws.test.ts": "responses", diff --git a/tests/responses/protocol-antigravity-direct-chat.test.ts b/tests/responses/protocol-antigravity-direct-chat.test.ts index 4095631c2a2..b82c180e177 100644 --- a/tests/responses/protocol-antigravity-direct-chat.test.ts +++ b/tests/responses/protocol-antigravity-direct-chat.test.ts @@ -2,7 +2,7 @@ * Antigravity direct lane, end to end through the Chat ingress against a fake Cloud Code Assist * upstream. * - * With `protocols.rollout.antigravityDirect` on, the same Chat request must reach Antigravity as + * With `protocols.rollout.antigravityDirect` on (the default), the same Chat request must reach Antigravity as * the same wire body it gets on the bridge, reach the client as the same answer, and be traced as * `chat -> ir -> other` both ways. The core receives only the request settings: the conversation is * decoded straight from the Chat body. @@ -94,7 +94,8 @@ function antigravityConfig(direct: boolean): OcxConfig { models: ["gemini-3.8-flash"], }, }, - ...(direct ? { protocols: { rollout: { antigravityDirect: true } } } : {}), + // Explicit both ways: the switch is on by default, so the bridge side has to turn it off. + protocols: { rollout: { antigravityDirect: direct } }, } as OcxConfig; } diff --git a/tests/responses/protocol-antigravity-direct-eligibility.test.ts b/tests/responses/protocol-antigravity-direct-eligibility.test.ts index 0f7cad95f73..ecac4a988a9 100644 --- a/tests/responses/protocol-antigravity-direct-eligibility.test.ts +++ b/tests/responses/protocol-antigravity-direct-eligibility.test.ts @@ -3,7 +3,7 @@ */ import { describe, expect, test } from "bun:test"; import { planProtocol, type ProtocolPlanInput } from "../../src/protocols/plan"; -import { antigravityDirectDeclineReason, isAntigravityRoute } from "../../src/server/antigravity-direct-eligibility"; +import { antigravityDirectDeclineReason, antigravityMessagesDeclineReason, isAntigravityRoute } from "../../src/server/antigravity-direct-eligibility"; import { cacheDiagnosticsObserving } from "../../src/server/request-log"; import "../../src/usage/cache-diagnostic"; import type { RouteResult } from "../../src/router"; @@ -16,7 +16,7 @@ function config(extra: Partial = {}, rollout = true): OcxConfig { return { port: 0, providers: {}, - ...(rollout ? { protocols: { rollout: { antigravityDirect: true } } } : {}), + protocols: { rollout: { antigravityDirect: rollout } }, ...extra, } as OcxConfig; } @@ -41,9 +41,11 @@ describe("antigravityDirectDeclineReason", () => { expect(antigravityDirectDeclineReason(route(), chat(), config())).toBeUndefined(); }); - test("the switch is off by default", () => { + test("the switch is on by default and an explicit false turns it off", () => { + expect(antigravityDirectDeclineReason(route(), chat(), { port: 0, providers: {} } as OcxConfig)).toBeUndefined(); + expect(antigravityMessagesDeclineReason(route(), { messages: [] }, MODEL, { port: 0, providers: {} } as OcxConfig)).toBeUndefined(); expect(antigravityDirectDeclineReason(route(), chat(), config({}, false))).toBe("rollout-disabled"); - expect(antigravityDirectDeclineReason(route(), chat(), config({ protocols: { rollout: { antigravityDirect: "true" } } } as never, false))) + expect(antigravityDirectDeclineReason(route(), chat(), config({ protocols: { rollout: { antigravityDirect: "true" } } } as never))) .toBe("rollout-disabled"); }); @@ -120,6 +122,72 @@ describe("antigravityDirectDeclineReason", () => { }); }); +const messages = (extra: Record = {}) => ({ + model: `google-antigravity/${MODEL}`, + messages: [{ role: "user", content: "hi" }], + ...extra, +}); +const RESOLVED = `google-antigravity/${MODEL}`; + +describe("antigravityMessagesDeclineReason", () => { + test("an eligible concrete Antigravity route takes the lane; the route rules are the Chat ones", () => { + expect(antigravityMessagesDeclineReason(route(), messages(), RESOLVED, config())).toBeUndefined(); + expect(antigravityMessagesDeclineReason(route(), messages(), RESOLVED, config({}, false))).toBe("rollout-disabled"); + expect(antigravityMessagesDeclineReason(route({ googleMode: "vertex" }), messages(), RESOLVED, config())).toBe("not-migrated"); + expect(antigravityMessagesDeclineReason(route({}, { routeKind: "policy" } as never), messages(), RESOLVED, config())) + .toBe("combo-or-policy-route"); + }); + + test("a hosted web-search tool or a forced WebSearch choice keeps the bridge for the sidecar", () => { + expect(antigravityMessagesDeclineReason(route(), messages({ tools: [{ type: "web_search_20250305", name: "web_search" }] }), RESOLVED, config())) + .toBe("hosted-tool"); + expect(antigravityMessagesDeclineReason(route(), messages({ tool_choice: { type: "tool", name: "WebSearch" } }), RESOLVED, config())) + .toBe("hosted-tool"); + expect(antigravityMessagesDeclineReason(route(), messages({ tools: [{ name: "f", input_schema: { type: "object" } }] }), RESOLVED, config())) + .toBeUndefined(); + }); + + test("an image for a text-only model keeps the bridge, in a user turn or a tool result", () => { + const textOnly = route({ modelCapabilities: { [MODEL]: { inputModalities: ["text"] } } } as never); + const image = { type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }; + expect(antigravityMessagesDeclineReason(textOnly, messages({ messages: [{ role: "user", content: [image] }] }), RESOLVED, config())) + .toBe("vision-preprocessing"); + const inResult = messages({ messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t", content: [image] }] }] }); + expect(antigravityMessagesDeclineReason(textOnly, inResult, RESOLVED, config())).toBe("vision-preprocessing"); + expect(antigravityMessagesDeclineReason(textOnly, messages(), RESOLVED, config())).toBeUndefined(); + }); + + test("the shadow-call intercept is matched on the resolved model", () => { + const intercept = { shadowCallIntercept: { enabled: true, model: "combo/helpers", sourceModels: ["gemini-"] } } as Partial; + expect(antigravityMessagesDeclineReason(route(), messages(), MODEL, config(intercept))).toBe("bridge-only-policy"); + expect(antigravityMessagesDeclineReason(route(), messages(), RESOLVED, config(intercept))).toBeUndefined(); + }); + + test("text the core rewrites on the bridge body, or an observing recorder, keeps the bridge", () => { + const catalog = "intro \n## Skills\n"; + expect(antigravityMessagesDeclineReason(route(), messages({ system: [{ type: "text", text: catalog }] }), RESOLVED, config())) + .toBe("bridge-only-policy"); + expect(antigravityMessagesDeclineReason(route(), messages({ messages: [{ role: "system", content: catalog }, { role: "user", content: "hi" }] }), RESOLVED, config())) + .toBe("bridge-only-policy"); + const generatedImage = messages({ messages: [ + { role: "user", content: "draw" }, + { role: "assistant", content: [{ type: "text", text: "![Generated image]()" }] }, + { role: "user", content: "again" }, + ] }); + expect(antigravityMessagesDeclineReason(route(), generatedImage, RESOLVED, config())).toBe("bridge-only-policy"); + expect(antigravityMessagesDeclineReason(route(), messages(), RESOLVED, config(), { cacheDiagnostics: true })).toBe("bridge-only-policy"); + }); + + test("the policies the decoder reproduces decline nothing", () => { + const cc = { claudeCode: { stabilizePromptCache: true } } as Partial; + const skill = messages({ messages: [ + { role: "assistant", content: [{ type: "tool_use", id: "s", name: "Skill", input: { skill: "claude-api" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "s", content: "Launching skill: claude-api" }] }, + ] }); + expect(antigravityMessagesDeclineReason(route(), skill, RESOLVED, config(cc))).toBeUndefined(); + }); +}); + describe("planner", () => { function input(directEligible: boolean): ProtocolPlanInput { return { diff --git a/tests/responses/protocol-antigravity-direct-messages.test.ts b/tests/responses/protocol-antigravity-direct-messages.test.ts new file mode 100644 index 00000000000..c964bac086e --- /dev/null +++ b/tests/responses/protocol-antigravity-direct-messages.test.ts @@ -0,0 +1,217 @@ +/** + * Antigravity direct lane, end to end through the Messages ingress against a fake Cloud Code + * Assist upstream. + * + * With `protocols.rollout.antigravityDirect` on (the default), the same Messages request (system prompt, tool + * catalog, signed thinking, a tool call and its result in history) must reach Antigravity as the + * same wire body it gets on the bridge, reach the client as the same answer, and be traced as + * `messages -> ir -> other` both ways. The core receives only the request settings: the + * conversation is decoded straight from the Messages body. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { saveCredential } from "../../src/oauth/store"; +import { clearGenericFailoverHealth } from "../../src/oauth/generic-account-failover"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; +import { getRequestLogEntries } from "../../src/server/request-log"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const DAILY_API_BASE = "https://daily-cloudcode-pa.googleapis.com"; +const GENERATE_PATHS = ["/v1internal:streamGenerateContent", "/v1internal:generateContent"]; + +let testDir = ""; +let previousHome: string | undefined; +let isolatedCodexHome: IsolatedCodexHome | null = null; +let releaseSpendHome: (() => void) | undefined; +let originalFetch: typeof fetch; +let upstreamBodies: string[] = []; + +beforeEach(async () => { + clearGenericFailoverHealth(); + originalFetch = globalThis.fetch; + previousHome = process.env.OPENCODEX_HOME; + isolatedCodexHome = installIsolatedCodexHome("ocx-agy-direct-msg-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-agy-direct-msg-")); + process.env.OPENCODEX_HOME = testDir; + releaseSpendHome = acquireOwnedSpendHome(); + upstreamBodies = []; + await saveCredential("google-antigravity", { + access: "fixture-access", + refresh: "fixture-refresh", + expires: Date.now() + 3_600_000, + accountId: "antigravity-fixture-account", + projectId: "fixture-project", + source: "oauth", + }); + globalThis.fetch = (async (input, init) => { + const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin === DAILY_API_BASE && GENERATE_PATHS.includes(url.pathname)) { + upstreamBodies.push(typeof init?.body === "string" ? init.body : ""); + const payload = { + response: { + candidates: [{ + content: { + role: "model", + parts: [ + { text: "weighing it", thought: true, thoughtSignature: "c2lnLWFuc3dlcg==" }, + { text: "direct answer" }, + ], + }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 11, candidatesTokenCount: 3, totalTokenCount: 14 }, + }, + }; + return url.searchParams.get("alt") === "sse" + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + } + if (url.hostname === "127.0.0.1" || url.hostname === "localhost") return originalFetch(input, init); + return new Response("not found", { status: 404 }); + }) as typeof fetch; +}); + +afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + clearGenericFailoverHealth(); + globalThis.fetch = originalFetch; + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + isolatedCodexHome?.restore(); + isolatedCodexHome = null; + if (testDir) removeTreeWithRetry(testDir); +}); + +function antigravityConfig(direct: boolean, extra: Partial = {}): OcxConfig { + return { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: DAILY_API_BASE, + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "fixture-project", + models: ["gemini-3.8-flash"], + }, + }, + // Explicit both ways: the switch is on by default, so the bridge side has to turn it off. + protocols: { rollout: { antigravityDirect: direct } }, + ...extra, + } as OcxConfig; +} + +function messagesBody(stream: boolean): Record { + return { + model: "google-antigravity/gemini-3.8-flash", + stream, + max_tokens: 200, + temperature: 0.3, + system: "Answer briefly.", + thinking: { type: "enabled", budget_tokens: 2048 }, + tools: [{ name: "lookup", description: "find", input_schema: { type: "object", properties: { q: { type: "string" } } } }], + messages: [ + { role: "user", content: "look something up" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "I should look it up.", signature: "c2lnLWhpc3Rvcnk=" }, + { type: "text", text: "Looking." }, + { type: "tool_use", id: "toolu_fixture", name: "lookup", input: { q: "x" } }, + ] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_fixture", content: "found x" }, + { type: "text", text: "and now?" }, + ] }, + ], + }; +} + +async function send(config: OcxConfig, body: Record, tag: string): Promise<{ status: number; text: string; requestId: string }> { + saveConfig(config); + const requestId = `agy-direct-msg-${tag}-${crypto.randomUUID()}`; + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + return { status: response.status, text: await response.text(), requestId }; +} + +/** The wire body without the per-request random id. */ +function wire(body: string): Record { + const parsed = JSON.parse(body) as Record; + delete parsed.requestId; + return parsed; +} + +/** Client answers without their random message ids and keepalive pings. */ +function answer(text: string, stream: boolean): unknown { + const scrubbed = text.replace(/msg_[A-Za-z0-9_-]+/g, "msg_ID"); + if (!stream) return JSON.parse(scrubbed); + return scrubbed.split("\n\n").filter(block => block.trim().length > 0 && !block.includes("event: ping")); +} + +async function traceFor(requestId: string): Promise | undefined> { + for (let i = 0; i < 100; i++) { + const row = getRequestLogEntries().find(entry => entry.requestId === requestId); + if (row?.protocolTrace) return row.protocolTrace as unknown as Record; + await Bun.sleep(10); + } + return undefined; +} + +describe("Antigravity direct lane through the Messages ingress", () => { + for (const stream of [false, true]) { + test(`same upstream wire and same answer as the bridge (stream=${stream})`, async () => { + const bridge = await send(antigravityConfig(false), messagesBody(stream), `bridge-${stream}`); + const direct = await send(antigravityConfig(true), messagesBody(stream), `direct-${stream}`); + expect(bridge.status).toBe(200); + expect(direct.status).toBe(200); + expect(upstreamBodies).toHaveLength(2); + expect(wire(upstreamBodies[1]!)).toEqual(wire(upstreamBodies[0]!)); + expect(answer(direct.text, stream)).toEqual(answer(bridge.text, stream)); + expect(direct.text).toContain("direct answer"); + + expect(await traceFor(bridge.requestId)).toMatchObject({ + mode: "legacy-bridge", + requestPath: ["messages", "responses-internal", "ir", "other"], + }); + expect(await traceFor(direct.requestId)).toMatchObject({ + mode: "translated", + requestPath: ["messages", "ir", "other"], + responsePath: ["other", "ir", "messages"], + }); + }); + } + + test("a declined request names the reason and keeps the bridge", async () => { + // The sidecar is off so the hosted tool is simply dropped on the bridge: the case is about the + // lane decision, not about running a search. + const config = antigravityConfig(true, { webSearchSidecar: { enabled: false } } as Partial); + const body = { ...messagesBody(false), tools: [{ type: "web_search_20250305", name: "web_search" }] }; + const declined = await send(config, body, "declined"); + expect(declined.status).toBe(200); + const trace = await traceFor(declined.requestId); + expect(trace).toMatchObject({ mode: "legacy-bridge", requestPath: ["messages", "responses-internal", "ir", "other"] }); + expect(trace?.reasonCodes).toContain("hosted-tool"); + }); + + test("a refusal is the bridge's refusal", async () => { + const body = { ...messagesBody(false), messages: [{ role: "assistant", content: [{ type: "tool_use", id: "toolu_x" }] }] }; + const bridge = await send(antigravityConfig(false), body, "refused-bridge"); + const direct = await send(antigravityConfig(true), body, "refused-direct"); + expect(direct.status).toBe(400); + expect(direct.status).toBe(bridge.status); + expect(direct.text).toBe(bridge.text); + expect(upstreamBodies).toHaveLength(0); + }); +}); + diff --git a/tests/responses/protocol-messages-ir-parity.test.ts b/tests/responses/protocol-messages-ir-parity.test.ts new file mode 100644 index 00000000000..edb851a9523 --- /dev/null +++ b/tests/responses/protocol-messages-ir-parity.test.ts @@ -0,0 +1,422 @@ +/** + * Antigravity direct lane: the Messages -> IR decoder is held to the bridge it replaces. + * + * For every body below, the request the core prepares on the direct lane (the settings-only body + * parsed by `parseRequest`, plus `messagesConversationToIr` for the conversation) must equal what + * `parseRequest(anthropicToResponsesTranslation(body).body)` builds on the bridge, field for field + * except `_rawBody`, timestamps and the random reasoning item ids both sides mint. The native + * reasoning replay map must match too, and bodies the bridge's conversation walk refuses must be + * refused with the same message. If either side changes alone, this file fails. + */ +import { describe, expect, test } from "bun:test"; +import { anthropicSettingsTranslation, anthropicToResponsesTranslation } from "../../src/claude/inbound"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { + messagesConversationToIr, + messagesRouteEvidence, + withDirectMessagesConversation, +} from "../../src/protocols/codecs/messages-ir"; +import { featureEffectsForPath, PROTOCOL_FEATURES } from "../../src/protocols/features"; +import { requestPathForLane } from "../../src/protocols/path"; +import { parseRequest } from "../../src/responses/parser"; +import { encodeReasoningEnvelope } from "../../src/responses/reasoning-envelope"; +import { evidenceFromBody } from "../../src/routing/request-evidence"; +import { planMessagesAntigravityDirect } from "../../src/server/messages-antigravity-direct"; +import type { OcxClaudeCodeConfig, OcxConfig, OcxParsedRequest } from "../../src/types"; + +const MODEL = "google-antigravity/gemini-3.8-flash"; +const IDENTITY = "You are a coding agent powered by the gpt-6. If asked which model you are, identify as gpt-6. " + + "Do not claim to be a different model or to have a different creator."; +const PNG = "iVBORw0KGgo="; +const PDF = Buffer.from("%PDF-1.4 fixture").toString("base64"); +const NUDGE = "The task tools haven't been used recently. If you're working on tasks that would benefit from tracking, " + + "consider using TaskCreate to add them. Only use these if relevant to the current work. This is just a gentle reminder - ignore if not applicable."; +const SKILL_BUNDLE = `Base directory for this skill: /home/u/.claude/skills/claude-api\n${"x".repeat(12_000)}`; + +type Body = Record; + +/** A JSON round trip, as the ingress reads bodies: keeps an own `__proto__` key. */ +function clone(body: Body): Body { + return JSON.parse(JSON.stringify(body)) as Body; +} + +function comparable(parsed: OcxParsedRequest): unknown { + const { _rawBody: _ignored, ...rest } = parsed; + const json = JSON.stringify(rest, (key, value) => key === "timestamp" ? 0 : value); + return JSON.parse(json.replace(/rs_[0-9a-f]{32}/g, "rs_ID")); +} + +function viaBridge(body: Body, cc?: OcxClaudeCodeConfig): { parsed: OcxParsedRequest; replay: unknown } { + const translation = anthropicToResponsesTranslation(clone(body), cc); + return { parsed: parseRequest(translation.body), replay: [...translation.nativeReasoningReplay] }; +} + +function viaDirect(body: Body, cc?: OcxClaudeCodeConfig): { parsed: OcxParsedRequest; replay: unknown } { + const source = clone(body); + const budget = createTranslatorBudget(); + const settings = anthropicSettingsTranslation(source, cc); + const decoded = messagesConversationToIr(source, cc, budget, settings.body.model as string); + const parsed = withDirectMessagesConversation( + parseRequest(settings.body), + decoded, + modelId => messagesConversationToIr(source, cc, budget, modelId), + ); + return { parsed, replay: [...decoded.nativeReasoningReplay] }; +} + +const SIGNED = encodeReasoningEnvelope({ txt: "hidden reasoning" }); +const NATIVE_OWN = encodeReasoningEnvelope({ nat: { enc: "native-blob-a", model: MODEL, tag: "tag-a", id: "rs_native_a" } }); +const NATIVE_OTHER = encodeReasoningEnvelope({ nat: { enc: "native-blob-b", model: "other-model", tag: "tag-b" } }); +const KIRO = encodeReasoningEnvelope({ krc: "kiro-blob" }); + +const CORPUS: Record = { + "a single user string": { + body: { model: MODEL, max_tokens: 64, messages: [{ role: "user", content: "hello" }] }, + }, + "system blocks, the billing line, identity naming and a mid-conversation system turn": { + body: { + model: MODEL, + system: [ + { type: "text", text: `x-anthropic-billing-header: cc_version=1; cch=abc;\n${IDENTITY}` }, + { type: "text", text: "second block" }, + ], + messages: [ + { role: "user", content: "u1" }, + { role: "assistant", content: "a1" }, + { role: "system", content: [{ type: "text", text: IDENTITY }, { type: "text", text: "reminder" }] }, + { role: "system", content: "" }, + { role: "user", content: "u2" }, + ], + }, + }, + "user media: text, images, an empty reference, documents of every source": { + body: { + model: MODEL, + system: "plain system", + messages: [{ + role: "user", + content: [ + { type: "text", text: "" }, + { type: "text", text: "look" }, + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: PNG } }, + { type: "image", source: { type: "base64", data: PNG } }, + { type: "image", source: { type: "url", url: "https://example.com/cat.png" } }, + { type: "image", source: { type: "url", url: "" } }, + { type: "image", source: { type: "unknown" } }, + { type: "document", title: "spec.pdf", source: { type: "base64", media_type: "application/pdf", data: PDF } }, + { type: "document", source: { type: "base64", data: PDF } }, + { type: "document", title: "remote", source: { type: "url", url: "https://example.com/a.pdf" } }, + { type: "document", source: { type: "text", data: "inline" } }, + { type: "unknown_block" }, + ], + }], + }, + }, + "a user turn of a lone empty-reference image": { + body: { model: MODEL, messages: [{ role: "user", content: [{ type: "image", source: { type: "url", url: "" } }] }] }, + }, + "tool calls and every tool_result shape": { + body: { + model: MODEL, + tools: [{ name: "lookup", description: "find", input_schema: { type: "object", properties: { q: { type: "string" } } } }], + messages: [ + { role: "user", content: "go" }, + { role: "assistant", content: [ + { type: "text", text: "Looking." }, + { type: "text", text: "" }, + { type: "tool_use", id: "toolu_1", name: "lookup", input: { q: "a" } }, + { type: "tool_use", id: "toolu_2", name: "lookup", input: "not an object" }, + { type: "tool_use", id: "toolu_3", name: "lookup" }, + { type: "tool_use", id: "toolu_4", name: "unknown_tool", input: {} }, + { type: "tool_use", id: "toolu_5", name: "lookup", input: { nested: { deep: [1, 2] } } }, + { type: "tool_use", id: "toolu_6", name: "lookup", input: {} }, + { type: "tool_use", id: "toolu_7", name: "lookup", input: {} }, + ] }, + { role: "user", content: [ + { type: "text", text: "before results" }, + { type: "tool_result", tool_use_id: "toolu_1", content: "plain" }, + { type: "tool_result", tool_use_id: "toolu_2", is_error: true, content: "boom" }, + { type: "tool_result", tool_use_id: "toolu_3", content: [ + { type: "text", text: "see" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: PNG } }, + { type: "document", title: "out.pdf", source: { type: "base64", data: PDF } }, + ] }, + { type: "tool_result", tool_use_id: "toolu_4", is_error: true, content: [] }, + { type: "tool_result", tool_use_id: "toolu_5", content: [ + { type: "text", text: "loaded" }, + { type: "tool_reference", tool_name: "Alpha" }, + { type: "tool_reference", tool_name: "Beta" }, + { type: "tool_reference", tool_name: "" }, + ] }, + { type: "tool_result", tool_use_id: "toolu_6", content: [{ type: "image", source: { type: "url", url: "" } }, { type: "text", text: "t" }] }, + { type: "tool_result", tool_use_id: "toolu_7" }, + { type: "text", text: "after results" }, + ] }, + ], + }, + }, + "thinking: plain signatures, unsigned folding, redacted, hidden text and replay preservation": { + body: { + model: MODEL, + thinking: { type: "enabled", budget_tokens: 8000 }, + messages: [ + { role: "user", content: "think" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "first unsigned", signature: "" }, + { type: "thinking", thinking: "second unsigned" }, + { type: "redacted_thinking", data: "opaque-redacted" }, + { type: "redacted_thinking", data: "" }, + { type: "thinking", thinking: "signed", signature: "gemini-thought-signature" }, + { type: "thinking", thinking: "", signature: "" }, + { type: "tool_use", id: "toolu_t", name: "act", input: {} }, + ] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_t", content: "done" }] }, + { role: "assistant", content: [{ type: "thinking", thinking: "", signature: SIGNED }, { type: "text", text: "answer" }] }, + { role: "assistant", content: [{ type: "thinking", thinking: "dangling signed", signature: "sig-only" }] }, + { role: "user", content: "next" }, + { role: "assistant", content: [{ type: "thinking", thinking: "dangling unsigned" }] }, + { role: "user", content: "and again" }, + ], + }, + }, + "native reasoning envelopes for this model and another, and a Kiro blob": { + body: { + model: MODEL, + messages: [ + { role: "user", content: "q" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "own native", signature: NATIVE_OWN }, + { type: "thinking", thinking: "", signature: NATIVE_OWN }, + { type: "thinking", thinking: "other native", signature: NATIVE_OTHER }, + { type: "thinking", thinking: "", signature: NATIVE_OTHER }, + { type: "text", text: "a" }, + ] }, + { role: "assistant", content: [{ type: "thinking", thinking: "", signature: KIRO }] }, + { role: "user", content: "q2" }, + ], + }, + }, + "reasoning after its call joins the call's turn": { + body: { + model: MODEL, + messages: [ + { role: "user", content: "go" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_late", name: "f", input: {} }] }, + { role: "assistant", content: [{ type: "thinking", thinking: "late", signature: "late-sig" }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_late", content: "r" }] }, + ], + }, + }, + "blocked skill bundles are elided in results and in text": { + body: { + model: MODEL, + messages: [ + { role: "user", content: "use the skill" }, + { role: "assistant", content: [{ type: "tool_use", id: "toolu_skill", name: "Skill", input: { skill: "claude-api" } }] }, + { role: "user", content: [ + { type: "tool_result", tool_use_id: "toolu_skill", content: "Launching skill: claude-api" }, + { type: "text", text: SKILL_BUNDLE }, + ] }, + ], + }, + }, + "stabilizePromptCache relocates the footer and appends the notice": { + cc: { stabilizePromptCache: true } as OcxClaudeCodeConfig, + body: { + model: MODEL, + system: `Base prompt\n1200 tokens left\n${NUDGE}\n`, + messages: [ + { role: "user", content: "hi" }, + { role: "assistant", content: [{ type: "thinking", thinking: "t", signature: "sig-before-notice" }] }, + ], + }, + }, + "stabilizePromptCache with nothing to relocate": { + cc: { stabilizePromptCache: true } as OcxClaudeCodeConfig, + body: { model: MODEL, system: "steady", messages: [{ role: "user", content: "hi" }] }, + }, + "the tool catalog: strictness, callers, schemas, duplicates and dropped server tools": { + body: { + model: MODEL, + tools: [ + { name: "strict_tool", description: "s", strict: true, input_schema: { type: "object", properties: {} } }, + { name: "callers", input_schema: { properties: { a: { type: "number" } } }, allowed_callers: ["direct", 3, "code_execution"] }, + { name: "strict_tool", description: "duplicate is dropped", input_schema: { type: "object" } }, + { type: "bash_20250124", name: "bash" }, + { type: "text_editor_20250124", name: "str_replace_editor" }, + { name: "no_schema" }, + { name: "", input_schema: { type: "object" } }, + "not a tool", + ], + tool_choice: { type: "tool", name: "strict_tool", disable_parallel_tool_use: true }, + messages: [{ role: "user", content: "pick" }], + }, + }, + "settings ride the Responses body unchanged": { + body: { + model: MODEL, + stream: true, + max_tokens: 512, + temperature: 0.2, + top_p: 0.9, + top_k: 5, + stop_sequences: ["END", 3], + metadata: { user_id: `{"device_id":"${"d".repeat(80)}","session_id":"s"}` }, + output_config: { effort: "high" }, + thinking: { type: "adaptive", display: "omitted" }, + tool_choice: { type: "any" }, + tools: [{ name: "t", input_schema: { type: "object" } }], + messages: [{ role: "user", content: "fmt" }], + }, + }, + "thinking disabled and a short user id": { + body: { model: MODEL, thinking: { type: "disabled" }, metadata: { user_id: "short" }, tool_choice: { type: "none" }, messages: [{ role: "user", content: "x" }] }, + }, + "only a system prompt and an empty user turn": { + body: { model: MODEL, system: "just instructions", messages: [{ role: "user", content: "" }] }, + }, + "tool parameters with an own __proto__ key": { + body: JSON.parse( + `{"model":"${MODEL}","messages":[{"role":"user","content":"x"}],` + + `"tools":[{"name":"p","input_schema":{"type":"object","__proto__":{"x":1},"properties":{}}},` + + `{"name":"q","input_schema":{"__proto__":{"y":2}}}]}`, + ) as Body, + }, +}; + +describe("Messages -> IR direct decode parity with the Responses bridge", () => { + for (const [name, { body, cc }] of Object.entries(CORPUS)) { + test(name, () => { + const direct = viaDirect(body, cc); + const bridge = viaBridge(body, cc); + expect(comparable(direct.parsed)).toEqual(comparable(bridge.parsed)); + expect(direct.replay).toEqual(bridge.replay); + }); + } + + test("the corpus reaches the cases it names", () => { + const thinking = viaDirect(CORPUS["thinking: plain signatures, unsigned folding, redacted, hidden text and replay preservation"]!.body); + const parts = thinking.parsed.context.messages.flatMap(message => message.role === "assistant" ? message.content : []); + expect(parts.some(part => part.type === "thinking" && part.signature === "gemini-thought-signature")).toBe(true); + expect(parts.some(part => part.type === "thinking" && part.redacted?.[0] === "opaque-redacted")).toBe(true); + const native = viaDirect(CORPUS["native reasoning envelopes for this model and another, and a Kiro blob"]!.body); + expect(native.replay).toEqual([["native-blob-a", "tag-a"]]); + const skill = viaDirect(CORPUS["blocked skill bundles are elided in results and in text"]!.body); + expect(JSON.stringify(skill.parsed.context)).toContain("elided for routed models"); + const stabilized = viaDirect(CORPUS["stabilizePromptCache relocates the footer and appends the notice"]!.body, { stabilizePromptCache: true } as OcxClaudeCodeConfig); + expect(stabilized.parsed.context.systemPrompt).toEqual(["Base prompt"]); + expect(stabilized.parsed.context.messages.at(-1)).toMatchObject({ role: "user", content: NUDGE }); + }); + + test("the settings-only body is the bridge body without its conversation", () => { + for (const { body, cc } of Object.values(CORPUS)) { + const { input: _input, instructions: _instructions, tools: _tools, ...settings } = anthropicToResponsesTranslation(clone(body), cc).body; + const direct = anthropicSettingsTranslation(clone(body), cc); + expect(direct.body).toEqual({ ...settings, input: [] }); + expect(direct.cacheKeySource).toBe(anthropicToResponsesTranslation(clone(body), cc).cacheKeySource); + } + }); + + test("routing evidence matches what the core reads from the bridge body", () => { + for (const { body, cc } of Object.values(CORPUS)) { + expect(messagesRouteEvidence(clone(body))).toEqual(evidenceFromBody(anthropicToResponsesTranslation(clone(body), cc).body)); + } + }); + + test("a model the core settled differently is decoded again for that model", () => { + const body = CORPUS["system blocks, the billing line, identity naming and a mid-conversation system turn"]!.body; + const budget = createTranslatorBudget(); + const decoded = messagesConversationToIr(clone(body), undefined, budget, MODEL); + const renamed = { ...clone(body), model: "google-antigravity/other-model" }; + const base = parseRequest(anthropicSettingsTranslation(renamed).body); + const parsed = withDirectMessagesConversation(base, decoded, modelId => messagesConversationToIr(renamed, undefined, budget, modelId)); + expect(comparable(parsed)).toEqual(comparable(viaBridge(renamed).parsed)); + }); + + test("the decoder does not hand the caller's tool schema to the IR by reference", () => { + const body = clone(CORPUS["tool calls and every tool_result shape"]!.body); + const decoded = messagesConversationToIr(body, undefined, createTranslatorBudget(), MODEL); + decoded.context.tools![0]!.parameters.mutated = true; + expect(((body.tools as Body[])[0]!.input_schema as Body).mutated).toBeUndefined(); + }); +}); + +describe("Messages -> IR direct decode refuses what the bridge refuses", () => { + const INVALID: Record = { + "a message that is not an object": { model: MODEL, messages: ["hi"] }, + "an unsupported role": { model: MODEL, messages: [{ role: "tool", content: "x" }] }, + "a tool_result without an id": { model: MODEL, messages: [{ role: "user", content: [{ type: "tool_result", content: "x" }] }] }, + "a tool_use without a name": { model: MODEL, messages: [{ role: "assistant", content: [{ type: "tool_use", id: "t" }] }] }, + "a file-backed image": { model: MODEL, messages: [{ role: "user", content: [{ type: "image", source: { type: "file", file_id: "f" } }] }] }, + "a file-backed image in a tool result": { model: MODEL, messages: [{ role: "user", content: [{ type: "tool_result", tool_use_id: "t", content: [{ type: "image", source: { type: "file", file_id: "f" } }] }] }] }, + "a malformed proxy envelope": { model: MODEL, messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "t", signature: "ocxr1:not-base64-json" }] }] }, + "a proxy envelope carrying an Anthropic signature": { + model: MODEL, + messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "t", signature: encodeReasoningEnvelope({ sig: "s" }) }] }], + }, + "no messages": { model: MODEL, messages: [] }, + "no model": { messages: [{ role: "user", content: "x" }] }, + }; + for (const [name, body] of Object.entries(INVALID)) { + test(name, () => { + let bridgeMessage: string | undefined; + try { anthropicToResponsesTranslation(clone(body)); } catch (err) { bridgeMessage = (err as Error).message; } + expect(bridgeMessage).toBeDefined(); + expect(() => messagesConversationToIr(clone(body), undefined, createTranslatorBudget(), MODEL)).toThrow(bridgeMessage!); + }); + } +}); + +describe("feature effects on the Messages direct path", () => { + test("every Messages feature meets the same disposition as on the bridge path", () => { + const direct = featureEffectsForPath("messages", requestPathForLane("messages", "direct", "other"), PROTOCOL_FEATURES); + const bridge = featureEffectsForPath("messages", requestPathForLane("messages", "bridge", "other"), PROTOCOL_FEATURES); + expect(direct).toEqual(bridge); + }); +}); + + +describe("an abandoned direct plan hands its translator budget back", () => { + const config = { + port: 0, + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "fixture-project", + models: ["gemini-3.8-flash"], + }, + }, + } as OcxConfig; + const charged = (signature: string): Body => ({ + model: MODEL, + messages: [ + { role: "user", content: "q" }, + { role: "assistant", content: [ + { type: "thinking", thinking: "t", signature: "plain-signature-".repeat(64) }, + { type: "thinking", thinking: "t", signature }, + ] }, + { role: "user", content: "x" }, + ], + }); + + test("a malformed envelope the bridge retries leaves the budget as it found it", () => { + const budget = createTranslatorBudget(); + const before = budget.snapshot().currentBytes; + expect(planMessagesAntigravityDirect(clone(charged("ocxr1:not-base64-json")), undefined, config, budget)).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(before); + }); + + test("a plan the ingress leaves releases what its decode retained", () => { + const budget = createTranslatorBudget(); + const before = budget.snapshot().currentBytes; + const plan = planMessagesAntigravityDirect(clone(charged("second-signature")), undefined, config, budget); + expect(plan).toBeDefined(); + expect(budget.snapshot().currentBytes).toBeGreaterThan(before); + plan!.releaseDecodeCharges(); + expect(budget.snapshot().currentBytes).toBe(before); + }); +});