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 d4615fb5036..7f617b790f4 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; `src/protocols/codecs/*` are named entry points over the existing translators (`chatCompletionsToResponsesBody`, `anthropicToResponsesTranslation`), not direct codecs | +| 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 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 new file mode 100644 index 00000000000..00c60be5e0e --- /dev/null +++ b/devlog/_plan/261014_antigravity_direct_protocols/000_plan.md @@ -0,0 +1,73 @@ +# 261014 Antigravity direct protocol translators + +Issue: #7079. Branch: `feat/antigravity-direct-protocols`. + +## Goal + +Give Google Antigravity (`google` adapter, Cloud Code Assist mode, wire `other`) a direct +translator per client protocol, with no internal Responses body between the client wire and the +IR the Google wire compiler reads. Rotation, errors, usage and effort policy keep their owners. + +## Earlier records this mirrors + +| Record | What it did | What this unit reuses | +|---|---|---| +| PF series, `devlog/_plan/260924_protocol_first_class/` | protocol vocabulary, trace, planner, guard, encoders; inventory row "Chat/Messages request decode still produces a Responses-shaped body" | the lane/trace/plan vocabulary; this unit closes that row for Chat -> Antigravity | +| #5918 (batch 6) | native `/v1/chat/completions` reset replacement | native Chat lane shape | +| #6559 | native Claude Messages through pooled dispatch (`messages-native*.ts`, decline reasons, `protocol-paths.md`) | a pure decline-reason function shared by ingress and planner; documented recorded gaps | +| #6562 | native pool preference with explicit opt-out precedence | rollout switch semantics: off by default, explicit value wins | +| #6790 | JEV initial effort on native Chat combo dispatch, asserted on the native lane | assert the lane in the trace, not just the answer | +| PF-09 (`src/protocols/encoders/`) | AdapterEvent -> Chat/Messages encoders behind `directEncoders`, golden parity against the bridge | the response half; parity-test style for the request half | + +## References (studied, not dependencies) + +- router-for-me/CLIProxyAPI @ `bf9341629ef2300ac9d846a2e80fe2756f63a671`: pairwise translators + registered per (client, upstream) in `internal/translator/antigravity/{openai/chat-completions,claude,openai/responses}` + (`sdk/translator/registry.go`). Chat and Claude build the Antigravity request directly; Responses + delegates through its Gemini translator. Chat streaming drops thought signatures; replayed tool + calls get `skip_thought_signature_validator`. +- Wei-Shaw/sub2api @ `4d9ac3e482106d2da3a39fe3e546d8d6801f1cc1`: Anthropic Messages is the pivot. + Messages -> v1internal is direct (`backend/internal/pkg/antigravity/request_transformer.go`); + Chat goes Chat -> Responses -> Anthropic -> Gemini + (`backend/internal/service/antigravity_gateway_compat.go`). Signature-400 recovery downgrades + thinking to text; no failover after bytes are written. + +In OpenCodex the IR is already the pivot and `src/adapters/google.ts` already owns signature +replay, sanitizing and the envelope, so the direct translators target the IR and the adapter is +unchanged. + +## Route map (eligible concrete Antigravity route) + +| Inbound | Before | After (`antigravityDirect` on) | +|---|---|---| +| 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) | + +## Design + +The core reads many fields of the Responses body before and after `parseRequest` +(`request-prepare.ts`), but on a concrete Antigravity Chat route the ones that read the +conversation are Codex-only, combo/policy-only, hosted-tool or other-wire consumers. So the lane +splits the bridge body: settings go to the core on `chatSettingsToResponsesBody` (one +`applyChatSettings` builds both bodies), and the conversation is decoded straight into the IR by +`chatConversationToIr` and applied at the parse seam. A restore hook puts the bridge conversation +back if the final route leaves Antigravity. The structure contract is +`structure/data-planes/protocol-paths.md#antigravity-direct-lane`. + +Parity is the safety argument: the lane's parsed request must equal +`parseRequest(chatCompletionsToResponsesBody(body))` (`tests/responses/protocol-chat-ir-parity.test.ts`), +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. +- 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 + the decode work. +- Combos whose candidates are Antigravity routes stay on the bridge, like PF-07 before + `nativeChatCombos`. diff --git a/docs-site/src/content/docs/guides/protocol-paths.md b/docs-site/src/content/docs/guides/protocol-paths.md index 847fae1e347..caaa8c7a3df 100644 --- a/docs-site/src/content/docs/guides/protocol-paths.md +++ b/docs-site/src/content/docs/guides/protocol-paths.md @@ -102,6 +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. | | `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` @@ -132,7 +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. + 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. - 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 ac9be7cc157..e28b3c0228f 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -613,6 +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.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 5b3c6161a4a..0c289a8fdfc 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -313,6 +313,9 @@ "protocol-direct-encoders-stall-disabled.test.ts": "responses", "protocol-direct-encoders-local-upstream.test.ts": "responses", "protocol-direct-encoders-messages.test.ts": "responses", + "protocol-chat-ir-parity.test.ts": "responses", + "protocol-antigravity-direct-chat.test.ts": "responses", + "protocol-antigravity-direct-eligibility.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/src/chat/inbound.ts b/src/chat/inbound.ts index 321cce06bf6..7286bb5c5c8 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -36,7 +36,7 @@ export function assertChatCompletionsRoutingBody(raw: unknown): asserts raw is C const OUTPUT_CONFIG_EFFORTS = new Set(["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); const OUTPUT_CONFIG_SUMMARIES = new Set(["auto", "concise", "detailed", "none"]); -function contentToText(content: unknown): string { +export function contentToText(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; @@ -62,7 +62,7 @@ const imageUrlFromPart = chatImageUrlFromPart; * understanding is requested by (#3271). The object form is the only one that * can carry it — `video_url` as a bare string has nowhere to put it. */ -function videoFromPart(part: Rec): { url: string; processing?: string } | null { +export function videoFromPart(part: Rec): { url: string; processing?: string } | null { if (part.type !== "video_url") return null; const videoUrl = part.video_url; if (typeof videoUrl === "string" && videoUrl.length > 0) return { url: videoUrl }; @@ -123,7 +123,7 @@ function userContentToBlocks(content: unknown): Rec[] { * was dropped in silence (#5212). A part with no inline bytes is still not translatable and is * left to the untranslated-media refusal, which runs before this loop. */ -function fileFromPart(part: Rec): Rec | null { +export function fileFromPart(part: Rec): Rec | null { if (part.type !== "file" && part.type !== "input_file") return null; const file = isRec(part.file) ? part.file : part; const fileData = file.file_data; @@ -143,7 +143,7 @@ function fileFromPart(part: Rec): Rec | null { * Only representable plaintext is read. No signature, encrypted payload or * provider-issued item id is reconstructed — see the reasoning item built below. */ -function assistantReasoningText(msg: Rec): string | undefined { +export function assistantReasoningText(msg: Rec): string | undefined { if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) { return msg.reasoning_content; } @@ -601,6 +601,32 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { ...(legacyFunctionsToResponses(raw.functions) ?? []), ]; if (tools.length > 0) body.tools = tools; + applyChatSettings(raw, body); + return body; +} + +/** + * The request settings of a Chat body as a Responses body with no conversation: `input` is + * empty and there are no `instructions` or `tools`. The Antigravity direct lane hands this to + * the Responses core so routing, effort policy, tier and logging keep reading the fields they + * read today, while the conversation is decoded straight into the IR + * (`src/protocols/codecs/chat-ir.ts`). Field order and validation are the bridge body's own: + * both are built by `applyChatSettings`. + */ +export function chatSettingsToResponsesBody(raw: unknown): Rec { + assertChatCompletionsRoutingBody(raw); + const body: Rec = { + model: raw.model, + input: [], + stream: raw.stream === true, + store: false, + }; + applyChatSettings(raw, body); + return body; +} + +/** Everything after the conversation and tool catalog: choice, limits, sampling, reasoning, format. */ +function applyChatSettings(raw: ChatCompletionsRoutingBody, body: Rec): void { if (raw.tool_choice !== undefined) toolChoiceToResponses(raw.tool_choice, body); else legacyFunctionChoiceToResponses(raw.function_call, body); @@ -637,6 +663,4 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { const text = responseFormatToText(raw.response_format); if (text) body.text = text; - - return body; } diff --git a/src/config/schema/config-schema.ts b/src/config/schema/config-schema.ts index 2d9db61341c..36a09ba78f9 100644 --- a/src/config/schema/config-schema.ts +++ b/src/config/schema/config-schema.ts @@ -84,6 +84,7 @@ export const protocolConfigSchema = z.object({ managedMessagesNative: z.boolean().optional(), managedMessagesNativeOAuth: z.boolean().optional(), directEncoders: z.boolean().optional(), + antigravityDirect: z.boolean().optional(), shadowPlan: z.boolean().optional(), }).strict().optional(), }).strict(); @@ -125,6 +126,7 @@ export const configSchema = z.object({ managedMessagesNative: false, managedMessagesNativeOAuth: false, directEncoders: fields.directEncoders === true, + antigravityDirect: fields.antigravityDirect === true, shadowPlan: fields.shadowPlan === true, }, }; diff --git a/src/protocols/codecs/chat-ir.ts b/src/protocols/codecs/chat-ir.ts new file mode 100644 index 00000000000..6116c4689dc --- /dev/null +++ b/src/protocols/codecs/chat-ir.ts @@ -0,0 +1,466 @@ +/** + * Chat Completions -> IR, directly (the Antigravity direct lane). + * + * The bridge decodes a Chat conversation twice: `chatCompletionsToResponsesBody` + * (`src/chat/inbound.ts`) projects it into Responses items, then `parseRequest` + * (`src/responses/parser.ts`) turns those items into the IR. This module walks the Chat messages + * once and builds the IR conversation itself: system text, messages, tool calls and results, + * prior reasoning and the tool catalog. It is the request-side twin of the direct client encoders + * (`src/protocols/encoders/`). + * + * Contract: for every Chat body the bridge accepts, `chatConversationToIr(body, model)` equals + * the `context` of `parseRequest(chatCompletionsToResponsesBody(body))`, and every body the bridge + * refuses is refused here with the same `ChatCompletionsRequestError` message in the same order. + * `tests/protocols/chat-ir-parity.test.ts` holds both sides over a fixture corpus. The rules + * mirrored here are the Chat translator's walk (leading-system hoist, held mid-batch + * instructions, tool-call naming, legacy function pairing) and the parser's assembly for the item + * kinds that walk produces (pending reasoning, the assistant placeholder, reasoning that arrives + * after its call, tool-result naming). + * + * Request settings (model, stream, reasoning, limits, tool choice, format) are not decoded here: + * they travel through the Responses core on `chatSettingsToResponsesBody`, so effort policy, + * tier and routing keep their single owner. + */ +import { + assistantReasoningText, + ChatCompletionsRequestError, + contentToText, + fileFromPart, + videoFromPart, +} from "../../chat/inbound"; +import { chatImageUrlFromPart } from "../../chat/image-parts"; +import { untranslatedChatInputMedia, untranslatedInputMediaMessage } from "../../responses/input-media"; +import { inlineDocumentFromDataUrl } from "../../responses/inline-document"; +import { functionCallArgumentsFromText } from "../../responses/parser"; +import { normalizeVisualizationContext } from "../../responses/visualization-directives"; +import { hasRoutedIdentity, nameRoutedIdentity } from "../../adapters/identity"; +import { createToolChoiceResolver, namespacedToolName } from "../../types"; +import type { + OcxAssistantMessage, + OcxContentPart, + OcxContext, + OcxMessage, + OcxParsedRequest, + OcxThinkingContent, + OcxTool, + OcxToolCall, +} from "../../types"; +import type { PolicyRequestEvidence } from "../../routing/evaluator"; + +type Rec = Record; + +function isRec(value: unknown): value is Rec { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** The parser's request-time naming of a proxy-authored identity sentence (#5217). */ +function nameDestination(text: string, modelId: string): string { + return hasRoutedIdentity(text) ? nameRoutedIdentity(text, modelId) : text; +} + +function imageDetail(part: Rec): string | undefined { + const detail = isRec(part.image_url) ? part.image_url.detail : part.detail; + return detail === "auto" || detail === "low" || detail === "high" ? detail : undefined; +} + +/** One file part as the IR carries inline bytes: a document, or the marker when undecodable. */ +function filePart(part: Rec): OcxContentPart | null { + const file = fileFromPart(part); + if (!file) return null; + const fileData = file.file_data as string; + const filename = typeof file.filename === "string" ? file.filename : undefined; + return inlineDocumentFromDataUrl(fileData, filename) + ?? { type: "text", text: filename ? `[file: ${filename}]` : "[file: inline data]" }; +} + +/** User or tool content parts in source order; `media: false` keeps only text and images. */ +function contentParts(content: unknown, media = true): OcxContentPart[] { + if (typeof content === "string") return content.length > 0 ? [{ type: "text", text: content }] : []; + if (!Array.isArray(content)) return []; + const parts: OcxContentPart[] = []; + for (const raw of content) { + if (typeof raw === "string") { + if (raw.length > 0) parts.push({ type: "text", text: raw }); + continue; + } + if (!isRec(raw)) continue; + if ((raw.type === "text" || raw.type === "input_text" || raw.type === "output_text") && typeof raw.text === "string") { + parts.push({ type: "text", text: raw.text }); + continue; + } + const imageUrl = chatImageUrlFromPart(raw); + if (imageUrl) { + const detail = imageDetail(raw); + parts.push({ type: "image", imageUrl, ...(detail ? { detail } : {}) }); + continue; + } + const video = videoFromPart(raw); + if (video) { + if (media) parts.push({ type: "video", videoUrl: video.url, ...(video.processing ? { processing: video.processing } : {}) }); + continue; + } + if (!media) continue; + const file = filePart(raw); + if (file) parts.push(file); + } + return parts; +} + +/** 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; +} + +function assistantTextParts(content: unknown): Array<{ type: "text"; text: string }> { + if (typeof content === "string") return content.length > 0 ? [{ type: "text", text: content }] : []; + if (!Array.isArray(content)) return []; + const parts: Array<{ type: "text"; text: string }> = []; + for (const raw of content) { + if (typeof raw === "string") { + if (raw.length > 0) parts.push({ type: "text", text: raw }); + continue; + } + if (isRec(raw) && (raw.type === "text" || raw.type === "output_text") && typeof raw.text === "string") { + parts.push({ type: "text", text: raw.text }); + } + } + return parts; +} + +/** + * The parser keeps an unsigned reasoning item's serialized form as its placeholder signature. + * The Chat translator's item is `{type, summary, content}` with one text in each, so the value is + * spelled out rather than produced by a Responses round trip. + */ +function unsignedReasoningSignature(text: string): string { + return JSON.stringify({ + type: "reasoning", + summary: [{ type: "summary_text", text }], + content: [{ type: "reasoning_text", text }], + }); +} + +/** + * 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. + */ +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)) { + for (const key of Object.keys(raw)) { + if (key !== "__proto__") copy[key] = raw[key]; + } + } + if (isRec(raw) && raw.type === "object") return copy; + return { ...copy, type: "object" }; +} + +function functionTool(name: string, description: unknown, parameters: unknown, strict: unknown): OcxTool { + const tool: OcxTool = { + name, + description: typeof description === "string" ? description : "", + parameters: normalizedParameters(isRec(parameters) ? parameters : undefined), + }; + if (typeof strict === "boolean") tool.strict = strict; + return tool; +} + +/** `tools` then legacy `functions`, deduplicated by name as the parser's catalog merge does. */ +function toolCatalog(raw: Rec): OcxTool[] { + const declared: OcxTool[] = []; + if (Array.isArray(raw.tools)) { + for (const entry of raw.tools) { + if (!isRec(entry) || entry.type !== "function") continue; + if (typeof entry.name === "string" && entry.name.length > 0) { + declared.push(functionTool(entry.name, entry.description, entry.parameters, entry.strict)); + } else if (isRec(entry.function) && typeof entry.function.name === "string" && entry.function.name.length > 0) { + const fn = entry.function; + declared.push(functionTool(fn.name as string, fn.description, fn.parameters, fn.strict)); + } + } + } + if (raw.functions !== undefined) { + if (!Array.isArray(raw.functions)) throw new ChatCompletionsRequestError("functions must be an array"); + for (const entry of raw.functions) { + if (!isRec(entry) || typeof entry.name !== "string" || entry.name.length === 0) { + throw new ChatCompletionsRequestError("functions entries require a name"); + } + declared.push(functionTool(entry.name, entry.description, entry.parameters, undefined)); + } + } + 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 ChatIrConversation { + /** The model the conversation was decoded for; identity naming and placeholders depend on it. */ + modelId: string; + context: OcxContext; +} + +/** + * Decode a Chat body's conversation straight into the IR context. Throws + * `ChatCompletionsRequestError` exactly where `chatCompletionsToResponsesBody` would. + */ +export function chatConversationToIr(raw: unknown, modelId: string): ChatIrConversation { + if (!isRec(raw) || !Array.isArray(raw.messages) || raw.messages.length === 0) { + throw new ChatCompletionsRequestError("messages must be a non-empty array"); + } + const unsupportedMedia = untranslatedChatInputMedia(raw); + if (unsupportedMedia) throw new ChatCompletionsRequestError(untranslatedInputMediaMessage(unsupportedMedia)); + + const now = Date.now(); + const systemParts: string[] = []; + const messages: OcxMessage[] = []; + let emitted = false; + const pending: OcxThinkingContent[] = []; + const knownNameByCallId = new Map(); + const legacyAwaiting: Array<{ callId: string; name: string }> = []; + let legacyCallSequence = 0; + const awaitingToolResult = new Set(); + const heldInstructions: string[] = []; + + const pushDeveloper = (text: string): void => { + emitted = true; + pending.length = 0; + messages.push({ role: "developer", content: nameDestination(text, modelId), timestamp: now }); + }; + const releaseHeldInstructions = (): void => { + if (heldInstructions.length === 0) return; + pushDeveloper(heldInstructions.join("\n\n")); + heldInstructions.length = 0; + }; + const beginConversationTurn = (): void => { + releaseHeldInstructions(); + awaitingToolResult.clear(); + legacyAwaiting.length = 0; + }; + const assistantHolder = (): OcxAssistantMessage => { + const last = messages[messages.length - 1]; + let holder: OcxAssistantMessage; + if (last && last.role === "assistant") holder = last; + else { + holder = { role: "assistant", content: [], model: modelId, timestamp: now }; + messages.push(holder); + } + if (pending.length > 0) { + holder.content.push(...pending); + pending.length = 0; + } + return holder; + }; + const pushToolCall = (callId: string, name: string, args: string): void => { + emitted = true; + const call: OcxToolCall = { type: "toolCall", id: callId, name, arguments: functionCallArgumentsFromText(callId, args) }; + assistantHolder().content.push(call); + awaitingToolResult.add(callId); + }; + const toolOwner = (callId: string): { name: string; namespace?: string } => { + 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) return { name: part.name, namespace: part.namespace }; + } + } + return { name: "" }; + }; + const pushToolResult = (callId: string, content: string | OcxContentPart[]): void => { + emitted = true; + // Reasoning replayed after the call it belongs to joins that call's assistant turn (#950). + if (pending.length > 0) { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]!; + if (message.role !== "assistant") continue; + if (message.content.some(part => part.type === "toolCall" && part.id === callId)) { + message.content = [...pending, ...message.content]; + break; + } + } + } + pending.length = 0; + const owner = toolOwner(callId); + messages.push({ + role: "toolResult", toolCallId: callId, toolName: owner.name, toolNamespace: owner.namespace, + content, isError: false, timestamp: now, + }); + awaitingToolResult.delete(callId); + if (awaitingToolResult.size === 0) releaseHeldInstructions(); + }; + + for (const msg of raw.messages) { + if (!isRec(msg)) continue; + const role = typeof msg.role === "string" ? msg.role : ""; + switch (role) { + case "system": + case "developer": { + if (!emitted) { + const text = contentToText(msg.content).trim(); + if (text) systemParts.push(text); + break; + } + const text = contentToText(msg.content).trim(); + if (!text) break; + if (awaitingToolResult.size > 0) heldInstructions.push(text); + else pushDeveloper(text); + break; + } + case "user": { + beginConversationTurn(); + const parts = contentParts(msg.content); + if (parts.length > 0) { + emitted = true; + pending.length = 0; + messages.push({ role: "user", content: collapsed(parts), timestamp: now }); + } + break; + } + case "assistant": { + beginConversationTurn(); + const reasoningText = assistantReasoningText(msg); + if (reasoningText !== undefined) { + emitted = true; + const part: OcxThinkingContent = { + type: "thinking", + thinking: reasoningText, + signature: unsignedReasoningSignature(reasoningText), + }; + // Consecutive unsigned reasoning folds into one block, as the parser does. + const previous = pending[pending.length - 1]; + if (previous && !previous.redacted) { + pending[pending.length - 1] = { ...part, thinking: `${previous.thinking}\n${part.thinking}` }; + } else { + pending.push(part); + } + } + const text = assistantTextParts(msg.content); + if (text.length > 0) { + emitted = true; + messages.push({ + role: "assistant", + content: pending.length > 0 ? [...pending, ...text] : text, + model: modelId, + timestamp: now, + }); + pending.length = 0; + } + if (msg.tool_calls !== undefined && Array.isArray(msg.tool_calls)) { + for (const entry of msg.tool_calls) { + if (!isRec(entry)) continue; + const fn = isRec(entry.function) ? entry.function : null; + let name = typeof fn?.name === "string" ? fn.name : typeof entry.name === "string" ? entry.name : ""; + const args = typeof fn?.arguments === "string" + ? fn.arguments + : typeof entry.arguments === "string" + ? entry.arguments + : JSON.stringify(fn?.arguments ?? entry.arguments ?? {}); + const callId = typeof entry.id === "string" && entry.id.length > 0 + ? entry.id + : typeof entry.call_id === "string" && entry.call_id.length > 0 + ? entry.call_id + : `call_${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; + if (!name) name = knownNameByCallId.get(callId) ?? ""; + if (!name) throw new ChatCompletionsRequestError("tool_calls entries require function.name"); + knownNameByCallId.set(callId, name); + pushToolCall(callId, name, args); + } + } + if (msg.function_call !== undefined && msg.function_call !== null) { + const value = msg.function_call; + if (!isRec(value) || typeof value.name !== "string" || value.name.length === 0) { + throw new ChatCompletionsRequestError("assistant function_call requires a name"); + } + const args = typeof value.arguments === "string" ? value.arguments : JSON.stringify(value.arguments ?? {}); + const callId = `call_legacy_${String(++legacyCallSequence).padStart(4, "0")}`; + knownNameByCallId.set(callId, value.name); + pushToolCall(callId, value.name, args); + legacyAwaiting.push({ callId, name: value.name }); + } + break; + } + case "function": { + if (Array.isArray(msg.content) && msg.content.some(part => isRec(part) && chatImageUrlFromPart(part))) { + throw new ChatCompletionsRequestError( + "Legacy function-result image translation is not implemented. Use tool_calls and role:tool with tool_call_id.", + ); + } + const name = typeof msg.name === "string" ? msg.name : ""; + if (!name) throw new ChatCompletionsRequestError("function messages require a name"); + const pendingIndex = legacyAwaiting.findIndex(call => call.name === name); + if (pendingIndex < 0) throw new ChatCompletionsRequestError(`function result has no pending call named ${name}`); + const [call] = legacyAwaiting.splice(pendingIndex, 1); + pushToolResult(call!.callId, contentToText(msg.content)); + break; + } + case "tool": { + const callId = typeof msg.tool_call_id === "string" ? msg.tool_call_id + : typeof msg.tool_use_id === "string" ? msg.tool_use_id + : ""; + if (!callId) throw new ChatCompletionsRequestError("tool messages require tool_call_id"); + const parts = contentParts(msg.content, false); + pushToolResult(callId, parts.some(part => part.type === "image") ? parts : contentToText(msg.content)); + break; + } + default: + break; + } + } + releaseHeldInstructions(); + + if (!emitted && systemParts.length === 0) { + throw new ChatCompletionsRequestError("messages must include at least one user/assistant/tool turn"); + } + const tools = toolCatalog(raw); + const context: OcxContext = { + ...(systemParts.length > 0 ? { systemPrompt: [nameDestination(systemParts.join("\n\n"), modelId)] } : {}), + messages, + ...(tools.length > 0 ? { tools } : {}), + }; + return { modelId, context: normalizeVisualizationContext(context) }; +} + +/** + * Put a directly decoded conversation onto the request the core parsed from the settings-only + * body. The conversation is re-decoded if the core settled a different model id than the one the + * ingress decoded for, because identity naming and assistant placeholders carry it. The tool + * choice is checked against the decoded catalog, which the settings-only parse could not see. + */ +export function withDirectChatConversation( + base: OcxParsedRequest, + chatBody: unknown, + decoded: ChatIrConversation, +): OcxParsedRequest { + const conversation = decoded.modelId === base.modelId ? decoded : chatConversationToIr(chatBody, 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 would have read from the bridge body + * (`evidenceFromBody`): a non-empty tool catalog, and an image in a user message. An image in a + * tool result sits under `output` on the bridge body, which that scan does not read, so it is + * not evidence here either. + */ +export function chatIrRouteEvidence(conversation: ChatIrConversation): PolicyRequestEvidence { + const tools = (conversation.context.tools?.length ?? 0) > 0; + const image = conversation.context.messages.some(message => message.role === "user" + && Array.isArray(message.content) && message.content.some(part => part.type === "image")); + return { + ...(tools ? { toolsRequired: true } : {}), + ...(image ? { imageInputRequired: true } : {}), + }; +} diff --git a/src/protocols/codecs/chat.ts b/src/protocols/codecs/chat.ts index a9308f48617..eddef8404ec 100644 --- a/src/protocols/codecs/chat.ts +++ b/src/protocols/codecs/chat.ts @@ -6,7 +6,7 @@ * (`src/chat/inbound.ts`), including its validation and the fields it drops, which * `src/protocols/features.ts` declares. */ -import { chatCompletionsToResponsesBody } from "../../chat/inbound"; +import { chatCompletionsToResponsesBody, chatSettingsToResponsesBody } from "../../chat/inbound"; import { featuresFromChatBody } from "../features"; /** Project a Chat Completions body onto the internal Responses bridge body. */ @@ -14,4 +14,13 @@ export function chatToResponsesBody(body: unknown): Record { return chatCompletionsToResponsesBody(body); } +/** + * The request settings alone, as a Responses body with an empty conversation: what the + * Antigravity direct lane hands the Responses core while the conversation is decoded by + * `chatConversationToIr` (`chat-ir.ts`). + */ +export function chatSettingsBody(body: unknown): Record { + return chatSettingsToResponsesBody(body); +} + export const chatFeatures = featuresFromChatBody; diff --git a/src/protocols/features.ts b/src/protocols/features.ts index 37860efe733..0b6d2926488 100644 --- a/src/protocols/features.ts +++ b/src/protocols/features.ts @@ -152,6 +152,12 @@ export function featureEffectsForPath( present: Iterable, ): PathFeatureEffects { const nodes = protocolNodes(path); + // A Chat or Messages body decoded straight into the IR (the direct lane, `[inbound, ir, other]`) + // keeps exactly what its Responses projection keeps: the direct codecs are held to + // `parseRequest(projection)` by parity tests. The decode hop therefore reads the + // `>responses` row, and the hop from the IR into `other` stays unknown as before. + const directIrDecode = inbound !== "responses" && path.length === 3 + && path[0] === inbound && path[1] === "ir" && path[2] === "other"; const effects: FeatureEffect[] = []; const unknown: ProtocolFeature[] = []; const seen = new Set(); @@ -160,8 +166,10 @@ export function featureEffectsForPath( seen.add(feature); let worst: FeatureDisposition = "passthrough"; let unknownHop = false; - for (let i = 1; i < nodes.length; i++) { - const hop = featureHopDisposition(feature, nodes[i - 1]!, nodes[i]!); + const hops = directIrDecode + ? [featureHopDisposition(feature, inbound, "responses"), undefined] + : nodes.slice(1).map((node, i) => featureHopDisposition(feature, nodes[i]!, node)); + for (const hop of hops) { if (hop === undefined) unknownHop = true; else if (RANK[hop] > RANK[worst]) worst = hop; } diff --git a/src/protocols/path.ts b/src/protocols/path.ts index 6884134f4c6..e2d218c1412 100644 --- a/src/protocols/path.ts +++ b/src/protocols/path.ts @@ -10,14 +10,23 @@ import { deliveryModeForPath, type DeliveryMode, type Protocol, type ProtocolHop /** * `native`: the ingress sent its own wire to a same-wire upstream. * `bridge`: the ingress projected the request into the Responses pipeline. + * `direct`: the ingress decoded its own wire straight into the IR the final adapter compiles + * from, with no internal Responses body in between (the Antigravity direct lane). Only a Chat or + * Messages ingress toward a non-protocol (`other`) upstream takes it; anything else falls back + * to the bridge rule so an inconsistent mark cannot claim a path that does not exist. */ -export type ProtocolLane = "native" | "bridge"; +export type ProtocolLane = "native" | "bridge" | "direct"; + +function directApplies(inbound: Protocol, lane: ProtocolLane, upstream: UpstreamWire): boolean { + return lane === "direct" && inbound !== "responses" && upstream === "other"; +} export function requestPathForLane(inbound: Protocol, lane: ProtocolLane, upstream: UpstreamWire): ProtocolHop[] { if (inbound === "responses") { return upstream === "responses" ? ["responses", "responses"] : ["responses", "ir", upstream]; } if (lane === "native") return [inbound, inbound]; + if (directApplies(inbound, lane, upstream)) return [inbound, "ir", upstream]; if (upstream === "responses") return [inbound, "responses"]; return [inbound, "responses-internal", "ir", upstream]; } @@ -26,6 +35,7 @@ export function requestPathForLane(inbound: Protocol, lane: ProtocolLane, upstre export function responsePathForLane(inbound: Protocol, lane: ProtocolLane, upstream: UpstreamWire): ProtocolHop[] { if (inbound === "responses") return upstream === "responses" ? ["responses", "responses"] : [upstream, "ir", "responses"]; if (lane === "native") return [inbound, inbound]; + if (directApplies(inbound, lane, upstream)) return [upstream, "ir", inbound]; if (upstream === "responses") return ["responses", inbound]; return [upstream, "ir", "responses-internal", inbound]; } diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index dda39101a12..0c7cf1a137d 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -23,6 +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 { messagesSelectorTargetsSecondaryInstance, messagesSecondaryInstanceUnavailable } from "../server/messages-native-selector"; import { parseSyntheticRowId } from "../server/fast-row"; import type { OcxConfig } from "../types"; @@ -125,6 +126,7 @@ function candidateFor( const adapter = provider.adapter ?? "openai-responses"; let declineReasons: ProtocolReasonCode[] = []; let nativeEligible = false; + let directEligible = false; // With `nativeChatCombos` on, the combo loop judges each Chat candidate as the concrete route it // is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge. const comboChildNative = inbound === "chat" && routeKind === "combo" @@ -136,9 +138,16 @@ function candidateFor( ...(routeKind === "direct" || comboChildNative ? {} : { routeKind }), }; if (inbound === "chat") { - const reason = rows.effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config); + const body = chatBodyForFeatures(features); + const reason = rows.effortRow ? "effort-row" : nativeChatDeclineReason(settled, body, config); nativeEligible = reason === undefined; if (reason) declineReasons = [reason]; + // The Antigravity direct lane, judged by the ingress's own rule, for a standalone route only: + // a combo child is dispatched from the full Responses body even when `nativeChatCombos` + // judges it as a concrete route above. A hosted web-search tool and the other body-content + // 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. @@ -154,7 +163,14 @@ function candidateFor( nativeEligible = reason === undefined; if (reason) declineReasons = [reason]; } - return { provider: route.providerName, model: route.modelId, adapter, nativeEligible, declineReasons }; + return { + provider: route.providerName, + model: route.modelId, + adapter, + nativeEligible, + ...(directEligible ? { directEligible } : {}), + declineReasons, + }; } /** Whether the Messages ingress could forward this selector with a caller's own credential. */ diff --git a/src/protocols/plan.ts b/src/protocols/plan.ts index f230a75522d..671e137ea1d 100644 --- a/src/protocols/plan.ts +++ b/src/protocols/plan.ts @@ -28,6 +28,12 @@ export interface ProtocolPlanCandidateInput { adapter: string; /** True when the ingress would send its own wire to this candidate. */ nativeEligible: boolean; + /** + * True when the ingress would decode its own wire straight into the IR for this candidate (the + * Antigravity direct lane). Only meaningful toward an `other` upstream; `path.ts` ignores it + * anywhere else. + */ + directEligible?: boolean; /** Why the ingress declined its native lane, when it did. */ declineReasons: readonly ProtocolReasonCode[]; } @@ -80,7 +86,9 @@ function planCandidate( const upstream = upstreamWireForAdapter(candidate.adapter); // A native lane only exists toward the ingress's own wire; an inconsistent snapshot must // not produce a path that claims the source body reached a different wire untouched. - const lane: ProtocolLane = candidate.nativeEligible && upstream === input.inbound ? "native" : "bridge"; + const lane: ProtocolLane = candidate.nativeEligible && upstream === input.inbound + ? "native" + : candidate.directEligible === true ? "direct" : "bridge"; const requestPath = requestPathForLane(input.inbound, lane, upstream); const responsePath = responsePathForLane(input.inbound, lane, upstream); const mode = deliveryModeForLane(input.inbound, lane, upstream); diff --git a/src/protocols/settings.ts b/src/protocols/settings.ts index 6ee60fbe181..2d715bd9594 100644 --- a/src/protocols/settings.ts +++ b/src/protocols/settings.ts @@ -64,6 +64,7 @@ export interface ProtocolRolloutSettings { managedMessagesNative: boolean; managedMessagesNativeOAuth: boolean; directEncoders: boolean; + antigravityDirect: boolean; shadowPlan: boolean; } @@ -101,6 +102,7 @@ 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"), shadowPlan: on("shadowPlan"), }), }); @@ -145,6 +147,7 @@ export function protocolPolicyRevision(config: ProtocolConfig & Pick | undef return undefined; } +/** + * Decode a function call's serialized arguments the way history replay tolerates them: empty + * or non-JSON text becomes `{}` instead of failing every later turn, and the one bounded + * lost-brace corruption above is repaired. Shared with the direct Chat decoder + * (`src/protocols/codecs/chat-ir.ts`) so both decode the same call to the same object. + */ +export function functionCallArgumentsFromText(callId: string, rawArguments: string | undefined): Record { + let args: Record = {}; + const rawArgs = rawArguments?.trim(); + if (rawArgs) { + try { + const parsed: unknown = JSON.parse(rawArgs); + if (isObj(parsed)) args = parsed; + } catch { + // One observed serialization corruption loses exactly the JSON object's opening + // brace; the closed envelope is tight enough to repair back into a call the + // routed model can still see and retry, instead of replaying {} forever. + const repaired = repairJsonObjectEnvelope(rawArgs); + if (repaired === undefined) { + console.warn(`[parser] function_call ${callId} has non-JSON arguments; defaulting to {}`); + } else { + args = repaired; + console.warn(`[parser] function_call ${callId} arguments lost the JSON opening brace; repaired from history`); + } + } + } + return args; +} + function ensureAssistantPlaceholder(messages: OcxMessage[], modelId: string, now: number): OcxAssistantMessage { @@ -397,25 +426,7 @@ export function parseRequest( const call = item as { id?: string; call_id: string; name: string; arguments?: string; namespace?: string; extra_content?: unknown }; // Tolerate empty/non-JSON arguments (e.g. a no-arg tool call serialized as "") instead of // throwing — a single poisoned history item would otherwise 400 every subsequent turn. - let args: Record = {}; - const rawArgs = call.arguments?.trim(); - if (rawArgs) { - try { - const parsed: unknown = JSON.parse(rawArgs); - if (isObj(parsed)) args = parsed; - } catch { - // One observed serialization corruption loses exactly the JSON object's opening - // brace; the closed envelope is tight enough to repair back into a call the - // routed model can still see and retry, instead of replaying {} forever. - const repaired = repairJsonObjectEnvelope(rawArgs); - if (repaired === undefined) { - console.warn(`[parser] function_call ${call.call_id} has non-JSON arguments; defaulting to {}`); - } else { - args = repaired; - console.warn(`[parser] function_call ${call.call_id} arguments lost the JSON opening brace; repaired from history`); - } - } - } + const args = functionCallArgumentsFromText(call.call_id, call.arguments); // Do NOT map Responses item `id` (fc_/ctc_/…) onto `thoughtSignature`. That field is // reserved for Gemini/Antigravity opaque thought tokens; forwarding item ids as // thoughtSignature 400s Antigravity (Base64 / TYPE_BYTES). Continuity for CCA comes from diff --git a/src/server/antigravity-direct-eligibility.ts b/src/server/antigravity-direct-eligibility.ts new file mode 100644 index 00000000000..e7ea4966e9a --- /dev/null +++ b/src/server/antigravity-direct-eligibility.ts @@ -0,0 +1,128 @@ +/** + * Whether a settled Chat route may take the Antigravity direct lane. + * + * On that lane the Chat 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. + * + * 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 + * disagree about the lane. + */ +import { chatBodyCarriesImage } from "../chat/image-parts"; +import { contentToText } from "../chat/inbound"; +import { isShadowSourceModel } from "../lib/shadow-call"; +import type { ProtocolReasonCode } from "../protocols/contract"; +import { resolveProtocolSettings } from "../protocols/settings"; +import { effectiveGoogleMode } from "../providers/registry"; +import type { RouteResult } from "../router"; +import type { OcxConfig } from "../types"; +import { requiresVisionPreprocessing } from "../vision"; + +/** Why the Antigravity direct lane declines a route, as a protocol reason code. */ +export type AntigravityDirectDeclineReason = Extract< + ProtocolReasonCode, + | "rollout-disabled" + | "not-migrated" + | "combo-or-policy-route" + | "hosted-tool" + | "vision-preprocessing" + | "bridge-only-policy" +>; + +type Rec = Record; +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +type RouteShape = Pick; + +/** A Google adapter route in Cloud Code Assist mode: the Antigravity wire. */ +export function isAntigravityRoute(route: Pick): boolean { + return effectiveGoogleMode(route.providerName, route.provider) === "cloud-code-assist"; +} + +/** + * A hosted web-search tool the bridge would hand to the web-search sidecar. The sidecar reads + * the hosted tool from the parsed Responses body, which this lane does not build. + */ +function declaresHostedWebSearch(body: Readonly): boolean { + return Array.isArray(body.tools) + && body.tools.some(tool => isRec(tool) && (tool.type === "web_search" || tool.type === "web_search_preview")); +} + +/** + * 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 { + 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); +} + +const SKILLS_CATALOG_MARKER = ""; +const GENERATED_IMAGE_LINK = "![Generated image](<"; + +/** + * Text the Responses core rewrites on the bridge body before parsing: a skills catalog in an + * instruction (the per-session skills snapshot substitutes a stored block) and a generated-image + * link in assistant history (hosted display paths are redacted to the artifact URL). The lane's + * settings-only body has neither, so such a request keeps the bridge. + */ +function carriesBridgeRewrittenText(body: Readonly): boolean { + if (!Array.isArray(body.messages)) return false; + return body.messages.some(message => { + if (!isRec(message)) return false; + if (message.role === "system" || message.role === "developer") { + return contentToText(message.content).includes(SKILLS_CATALOG_MARKER); + } + if (message.role === "assistant") return contentToText(message.content).includes(GENERATED_IMAGE_LINK); + return false; + }); +} + +/** Request facts the body and config cannot carry, supplied by a caller that is about to send. */ +export interface AntigravityDirectRuntime { + /** + * The cache diagnostic recorder is observing inbound bodies. It records the body the core + * receives, which on the lane has no conversation, so the request keeps the bridge while it + * observes. The planner never supplies this. + */ + cacheDiagnostics?: boolean; +} + +/** + * 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 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; + * - an image would reach a model the operator declared unable to read it (the vision sidecar + * rewrites images on the Responses body as well as the IR); + * - the shadow-call intercept could move the request to another target before parsing. + * - the core would rewrite conversation text on the bridge body (a skills catalog, a generated + * image link), or the cache diagnostic recorder is observing. + */ +export function antigravityDirectDeclineReason( + route: RouteShape, + body: Readonly, + 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"; + 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 (carriesBridgeRewrittenText(body) || runtime.cacheDiagnostics === true) return "bridge-only-policy"; + return undefined; +} diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 28cc0033261..d5339fba828 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -10,7 +10,9 @@ import { assertChatCompletionsRoutingBody, ChatCompletionsRequestError, } from "../chat/inbound"; -import { chatToResponsesBody } from "../protocols/codecs/chat"; +import { chatSettingsBody, chatToResponsesBody } from "../protocols/codecs/chat"; +import { chatConversationToIr, chatIrRouteEvidence, withDirectChatConversation, type ChatIrConversation } from "../protocols/codecs/chat-ir"; +import { antigravityDirectDeclineReason } from "./antigravity-direct-eligibility"; import { normalizeChatImageParts } from "../chat/image-parts"; import { chatCompletionsErrorResponse, @@ -45,6 +47,7 @@ import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { addFinalRequestLog, + cacheDiagnosticsObserving, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, @@ -273,6 +276,12 @@ async function handleChatCompletionsWithBudget( const protocolSettings = resolveProtocolSettings(config); const nativeChatCombos = protocolSettings.rollout.nativeChatCombos && settledRoute?.combo !== undefined && !effortRow; + // Antigravity direct lane (off by default): the conversation is decoded straight into the IR + // and the answer is encoded straight into Chat, with no internal Responses body either way. + const antigravityDecline = settledRoute && !chatNativeRoute + ? antigravityDirectDeclineReason(settledRoute, chatBody, config, { cacheDiagnostics: cacheDiagnosticsObserving() }) + : undefined; + const directRoute = settledRoute && !chatNativeRoute && antigravityDecline === undefined ? settledRoute : null; const envelope = protocolSettings.unrepresentable === "reject" || nativeChatCombos ? createProtocolEnvelope({ inbound: "chat", body: chatBody, translatorBudget }) : undefined; @@ -282,7 +291,7 @@ async function handleChatCompletionsWithBudget( inbound: "chat", requestPath: chatNativeRoute ? requestPathForLane("chat", "native", "chat") - : requestPathForLane("chat", "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), + : requestPathForLane("chat", directRoute ? "direct" : "bridge", upstreamWireForAdapter(settledRoute.provider.adapter)), features: envelope.features(), policy: "reject", }); @@ -295,8 +304,15 @@ async function handleChatCompletionsWithBudget( } markProtocolEntry(logCtx, { inbound: "chat", - lane: chatNativeRoute ? "native" : "bridge", - reasonCodes: !chatNativeRoute && nativeDecline ? [nativeDecline] : [], + lane: chatNativeRoute ? "native" : directRoute ? "direct" : "bridge", + reasonCodes: [ + ...(!chatNativeRoute && !directRoute && nativeDecline ? [nativeDecline] : []), + // Only a decline that says something about this route: with the switch off or a + // non-Antigravity target the path reason already explains the bridge. + ...(antigravityDecline && antigravityDecline !== "rollout-disabled" && antigravityDecline !== "not-migrated" + ? [antigravityDecline] + : []), + ], features: envelope ? () => envelope.features() : () => featuresFromChatBody(chatBody), }); recordProtocolShadowPlan(logCtx, config, { inbound: "chat", model: requestedModel }); @@ -315,10 +331,18 @@ async function handleChatCompletionsWithBudget( } let internalBody: Rec; + let directConversation: ChatIrConversation | undefined; try { // Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as // its wire source; this Responses projection is used only by the fallback path. - internalBody = chatToResponsesBody(chatBody); + // The direct lane validates the same boundary while decoding the conversation into the IR, + // and sends the core only the request settings. + if (directRoute) { + directConversation = chatConversationToIr(chatBody, chatBody.model as string); + internalBody = chatSettingsBody(chatBody); + } else { + internalBody = chatToResponsesBody(chatBody); + } if (effortRow) { internalBody.reasoning = { ...(isRec(internalBody.reasoning) ? internalBody.reasoning : {}), @@ -489,9 +513,30 @@ async function handleChatCompletionsWithBudget( ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForRequestLogTerminal(status, logCtx), { terminalStatus: status, closeReason: "terminal" }), onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }), - ...(directEncodersApply(config, settledRoute) - ? { clientEncoder: { protocol: "chat" as const, stream, model: requestedModel } } - : {}), + // The direct lane answers through the direct encoder whatever `directEncoders` says: the + // point of the lane is that neither direction passes through the internal Responses body. + ...(directRoute && directConversation + ? { + clientEncoder: { protocol: "chat" as const, stream, model: requestedModel, requestLane: "direct" as const }, + directRequest: { + protocol: "chat" as const, + providerName: directRoute.providerName, + modelId: directRoute.modelId, + evidence: chatIrRouteEvidence(directConversation), + decode: base => withDirectChatConversation(base, chatBody, directConversation!), + bridgeConversation: () => { + const bridge = chatToResponsesBody(chatBody); + return { + input: bridge.input, + ...(bridge.instructions !== undefined ? { instructions: bridge.instructions } : {}), + ...(bridge.tools !== undefined ? { tools: bridge.tools } : {}), + }; + }, + }, + } + : directEncodersApply(config, settledRoute) + ? { clientEncoder: { protocol: "chat" as const, stream, model: requestedModel } } + : {}), }); // Already in the Chat wire: no conversion. A direct-encoder body (PF-09) reports its own log // facts to the deferred request log. A native combo child (PF-07) carries none: its row is diff --git a/src/server/inference/client-encoder-delivery.ts b/src/server/inference/client-encoder-delivery.ts index 90ef88aff3d..bfd0ec4deb8 100644 --- a/src/server/inference/client-encoder-delivery.ts +++ b/src/server/inference/client-encoder-delivery.ts @@ -111,17 +111,18 @@ export interface ClientEncodedDelivery { } /** - * Record the attempt's observed path: the request still goes through the internal Responses - * bridge until the codecs decode to IR directly, so the request path and mode stay the bridge's; - * the response now reaches the client from the IR without the internal Responses hop. + * Record the attempt's observed path. The response reaches the client from the IR without the + * internal Responses hop. The request side is the bridge's unless the ingress also decoded the + * conversation straight into the IR (the Antigravity direct lane, `requestLane: "direct"`). */ function markDirectEncoderPath(logCtx: RequestLogContext, encoder: ClientEncoderOption, adapter: string): void { const attempt = logCtx.activeAttempt; if (!attempt) return; const upstream = upstreamWireForAdapter(attempt.adapter || adapter); + const lane = encoder.requestLane ?? "bridge"; markAttemptProtocolPath(attempt, { - mode: deliveryModeForLane(encoder.protocol, "bridge", upstream), - requestPath: requestPathForLane(encoder.protocol, "bridge", upstream), + mode: deliveryModeForLane(encoder.protocol, lane, upstream), + requestPath: requestPathForLane(encoder.protocol, lane, upstream), responsePath: [upstream, "ir", encoder.protocol], }); } diff --git a/src/server/management/protocol-settings-patch.ts b/src/server/management/protocol-settings-patch.ts index 2eb06ebd427..0dd2a82efe5 100644 --- a/src/server/management/protocol-settings-patch.ts +++ b/src/server/management/protocol-settings-patch.ts @@ -31,6 +31,7 @@ export const PROTOCOL_ROLLOUT_KEYS = [ "managedMessagesNative", "managedMessagesNativeOAuth", "directEncoders", + "antigravityDirect", "shadowPlan", ] as const satisfies readonly (keyof ProtocolRolloutSettings)[]; const ROLLOUT_KEYS = new Set(PROTOCOL_ROLLOUT_KEYS); diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 7d26b439777..c900542ede6 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -96,6 +96,8 @@ import type { const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1"); interface CacheDiagnosticHooks { + /** Whether inbound bodies are actually recorded; the hook is registered at import either way. */ + enabled(): boolean; observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft; rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void; finalize(facts: CacheDiagnosticFinalFacts): void; @@ -104,6 +106,11 @@ function cacheDiagnosticHooks(): CacheDiagnosticHooks | undefined { return (globalThis as Record)[CACHE_DIAGNOSTIC_HOOK]; } +/** Whether the cache diagnostic recorder will record inbound bodies (`OPENCODEX_CACHE_DEBUG=1`). */ +export function cacheDiagnosticsObserving(): boolean { + return cacheDiagnosticHooks()?.enabled() === true; +} + export interface RequestLogContext extends UpstreamErrorDiagnostics { model: string; provider: string; diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index b87e6187bef..8c75b514d2c 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -226,8 +226,13 @@ export async function prepareAdapterExchange( let selfContainedJudgment: boolean | undefined; + // On the Antigravity direct lane the raw body carries only settings; judge the body the bridge + // would have sent, built only if an ambiguous reset actually asks. + const directRequest = options.directRequest; const requestIsSelfContained = (): boolean => - selfContainedJudgment ??= selfContainedResponsesBody(parsed._rawBody); + selfContainedJudgment ??= selfContainedResponsesBody(directRequest + ? { ...(parsed._rawBody as Record), ...directRequest.bridgeConversation() } + : parsed._rawBody); const claimPreHeaderResend = (): boolean => authorizeResendForRecovery( "pre-header", "connection-reset", ambiguousResendAllowanceFor(route.provider, requestIsSelfContained, claimAmbiguousResend), diff --git a/src/server/responses/core-options.ts b/src/server/responses/core-options.ts index 6bc063dbc9d..c22e0691d60 100644 --- a/src/server/responses/core-options.ts +++ b/src/server/responses/core-options.ts @@ -22,6 +22,8 @@ import type { RequestLogContext } from "../request-log"; import type { UpstreamHostAdmissionLease } from "../../codex/upstream-host-health"; import type { AccountLease } from "../../oauth/kiro-account-load"; import type { PolicyRequestScope } from "./policy-request-scope"; +import type { OcxParsedRequest } from "../../types"; +import type { PolicyRequestEvidence } from "../../routing/evaluator"; export interface ConsumedComboFailure { response: Response; @@ -61,6 +63,32 @@ export interface ClientEncoderOption { stream: boolean; model: string; inputTokenFloor?: number; + /** + * `direct` when the request side also skipped the internal Responses body (the Antigravity + * direct lane), so the attempt's request path is traced as `[inbound, ir, upstream]`. + */ + requestLane?: "bridge" | "direct"; +} + +/** + * The Antigravity direct lane (`protocols.rollout.antigravityDirect`). The ingress sends the core + * a Responses body that carries only the request settings; the conversation is decoded straight + * from the client body. Request preparation applies `decode` at the parse seam, reads routing + * evidence from `evidence`, and calls `restoreBridgeConversation` if the final route is not the + * one the lane was decided for, so every `_rawBody` reader of another adapter sees the full + * bridge body. + */ +export interface DirectRequestOption { + protocol: "chat"; + /** The concrete route the ingress decided the lane for. */ + providerName: string; + modelId: string; + /** Policy-routing evidence from the client body; the settings-only body has no `input`. */ + evidence: PolicyRequestEvidence; + /** The settings-only parse with the directly decoded conversation in place of its empty one. */ + decode(base: OcxParsedRequest): OcxParsedRequest; + /** The bridge projection's conversation fields (`input`, `instructions`, `tools`). */ + bridgeConversation(): Record; } export interface HandleResponsesOptions { @@ -226,6 +254,8 @@ 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}. */ + directRequest?: DirectRequestOption; } /** Values shared by the call, not a bag of mutable pipeline state. */ diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 7341678d5d9..4cf050b1abd 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -1,4 +1,8 @@ -import type { ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options"; +import type { HandleResponsesOptions, ResponsesRequestContext, ResponsesAdmissionState, ResponsesDispatchers } from "./core-options"; +import type { PolicyRequestEvidence } from "../../routing/evaluator"; +import type { RequestLogContext } from "../request-log"; +import { markProtocolEntry, protocolMarkFeatures } from "../../protocols/trace"; +import { isAntigravityRoute } from "../antigravity-direct-eligibility"; import { capturePolicyRequestRoute, assertPolicyNormalizedRoute, recordPolicyPreparedDestination, policyCandidateRefusalResponse } from "./policy-request-scope"; import { agentTaskRecoveryConfig, @@ -155,6 +159,48 @@ import { import { redactHostedImageDisplayPaths } from "../responses-hosted-image-display"; +/** Policy-routing evidence. The direct lane's settings-only body has no conversation to scan. */ +function routeEvidence(options: Pick, parsed: OcxParsedRequest): PolicyRequestEvidence { + return options.directRequest?.evidence ?? evidenceFromBody(parsed._rawBody); +} + +/** + * Leave the Antigravity direct lane for this request: put the bridge conversation back on the + * Responses body so every `_rawBody` reader sees what the bridge would have sent, and trace the + * request as the bridge. The IR conversation already decoded stays: it equals what + * `parseRequest` builds from the restored body (`tests/protocols/chat-ir-parity.test.ts`). + */ +function restoreDirectRequestConversation(options: HandleResponsesOptions, body: unknown, logCtx: RequestLogContext): void { + const direct = options.directRequest; + if (!direct) return; + options.directRequest = undefined; + if (body && typeof body === "object" && !Array.isArray(body)) Object.assign(body, direct.bridgeConversation()); + if (options.clientEncoder) options.clientEncoder = { ...options.clientEncoder, requestLane: "bridge" }; + markProtocolEntry(logCtx, { + inbound: direct.protocol, + lane: "bridge", + reasonCodes: ["not-migrated"], + features: protocolMarkFeatures(logCtx) ?? [], + }); +} + +/** + * Leave the direct lane as soon as the route stops being the Antigravity route it was decided + * for. Called after every route assignment, so readers that run between a reroute and dispatch + * (canonical sanitization, ciphertext stripping, final normalization) see the bridge body. + */ +function leaveDirectLaneIfRerouted( + options: HandleResponsesOptions, + route: Pick, + parsed: OcxParsedRequest, + logCtx: RequestLogContext, +): void { + const direct = options.directRequest; + if (!direct) return; + if (route.providerName === direct.providerName && route.modelId === direct.modelId && isAntigravityRoute(route)) return; + restoreDirectRequestConversation(options, parsed._rawBody, logCtx); +} + /** Parses, selects, and admits one request without changing the dispatch policy. */ export async function prepareResponsesRequest( requestContext: Pick, @@ -306,6 +352,9 @@ export async function prepareResponsesRequest( } if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) { options.onRequestBodyRead?.(); + // The Antigravity direct lane is decided for one concrete route; a body that reached a combo + // goes through the combo's own child loop with the full bridge conversation. + restoreDirectRequestConversation(options, body, logCtx); return requestDispatchers.handleComboResponses(req, body, comboId, config, logCtx, { ...options, // Concrete combo child selectors no longer match the shadow source model. Carry the @@ -383,6 +432,10 @@ export async function prepareResponsesRequest( let toolBridgeMaps: ReturnType; try { parsed = parseRequest(body); + // Antigravity direct lane: the body carried only the request settings, so the parse above + // produced them with an empty conversation; the conversation itself comes straight from the + // client body. Inside this try so a decode error answers exactly like a parse error. + if (options.directRequest) parsed = options.directRequest.decode(parsed); if (options.inboundWire === "anthropic") { parsed._nativeReasoningReplay = options.nativeReasoningReplay; parsed._nativeReasoningMint = options.nativeReasoningMint; @@ -582,8 +635,8 @@ export async function prepareResponsesRequest( const resolved = concreteSelection ? routeConcreteModel(config, modelId) : parsed._compactionRequest === true - ? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody)) - : routeModel(config, modelId, evidenceFromBody(parsed._rawBody)); + ? routeCompactionModel(config, modelId, routeEvidence(options, parsed)) + : routeModel(config, modelId, routeEvidence(options, parsed)); // A Pool 2 selector may leave Pool 2 only through an operator-configured cross-provider // blocked-model redirect, which the router marks as a credential-domain rewrite. Any other // destination means the selector fell through to default resolution, which stays refused. @@ -654,6 +707,7 @@ export async function prepareResponsesRequest( if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true; route = memoryRoute ?? shadowRoute ?? resolveRoute(options.policyFallbackCandidate ? `${options.policyFallbackCandidate.provider}/${options.policyFallbackCandidate.model}` : parsed.modelId); + leaveDirectLaneIfRerouted(options, route, parsed, logCtx); // Name the phase in the persisted route decision, so the request log says why this turn went to // the memory destination instead of leaving it looking like a plain user selection. Set here, on // the resolved route, so a combo child's own route carries it too. @@ -893,8 +947,9 @@ export async function prepareResponsesRequest( if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { try { route = captureInboundRoutePolicy( - routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)), + routeModel(config, fallback.to, routeEvidence(options, parsed)), ); + leaveDirectLaneIfRerouted(options, route, parsed, logCtx); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; logCtx.policyEligibility = route.policyEligibility; @@ -1109,8 +1164,9 @@ export async function prepareResponsesRequest( if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) { try { route = captureInboundRoutePolicy( - routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)), + routeModel(config, fallback.to, routeEvidence(options, parsed)), ); + leaveDirectLaneIfRerouted(options, route, parsed, logCtx); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; logCtx.policyEligibility = route.policyEligibility; @@ -1257,6 +1313,8 @@ export async function prepareResponsesRequest( // for streaming clients. const clientRequestedStream = parsed.stream; const admittedPolicyRoute = { providerName: route.providerName, modelId: route.modelId }; + // Final guard before normalization may inject guidance into the raw body. + leaveDirectLaneIfRerouted(options, route, parsed, logCtx); await applyFinalRouteRequestNormalization({ parsed, route, diff --git a/src/types/config.ts b/src/types/config.ts index 56df5f181a8..9b56063c760 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -30,6 +30,11 @@ export interface OcxProtocolsConfig { managedMessagesNativeOAuth?: boolean; /** Chat and Messages clients are encoded directly from adapter events. */ directEncoders?: boolean; + /** + * Chat requests to a concrete Google Antigravity route decode straight into the IR and are + * answered through the direct encoders, with no internal Responses body in either direction. + */ + antigravityDirect?: boolean; /** Compare the dispatch plan with the observed path; never sends a second request. */ shadowPlan?: boolean; }; diff --git a/src/usage/cache-diagnostic.ts b/src/usage/cache-diagnostic.ts index cd087f6db53..14918c60568 100644 --- a/src/usage/cache-diagnostic.ts +++ b/src/usage/cache-diagnostic.ts @@ -294,11 +294,13 @@ export function appendFinalCacheDiagnostic(facts: CacheDiagnosticFinalFacts): vo const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1"); interface CacheDiagnosticHooks { + enabled(): boolean; observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft; rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void; finalize(facts: CacheDiagnosticFinalFacts): void; } (globalThis as Record)[CACHE_DIAGNOSTIC_HOOK] = { + enabled: isCacheDiagnosticEnabled, observeInbound, rebind: rebindCacheDiagnosticBodyAlias, finalize: appendFinalCacheDiagnostic, diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 7567171adb0..37be2ffaa9e 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -37,6 +37,10 @@ specifiers and fails on anything else. `src/protocols/path.ts` turns an ingress lane (`native` or `bridge`) and the final adapter's upstream wire into the request and response paths. The observed trace and the planner both call it, so a preview and the log of the same request apply one rule. +A third lane, `direct`, is the [Antigravity direct lane](#antigravity-direct-lane): a Chat or +Messages ingress toward an `other` upstream gets `[inbound, ir, other]` and +`[other, ir, inbound]`. Toward any other wire `direct` falls back to the bridge rule, so an +inconsistent mark cannot claim a path that does not exist. ## Feature dispositions @@ -47,6 +51,10 @@ Same-wire hops are passthrough. A hop into `other` has no entry, and an absent e unknown is spelled — never as a fifth disposition. `featureEffectsForPath` reports, for the features a request carries, the worst disposition met along its path, keeping a loss declared on a known hop even when a later hop is unknown. +On the direct path `[chat|messages, ir, other]` the decode hop reads the `>responses` +row: the direct codecs are held to `parseRequest` of the Responses projection, so they keep and +drop exactly what that projection does. The direct path therefore reports the same effects as the +bridge path, and the `reject` guard refuses the same features on both. The current-code claims follow the translators: `chatCompletionsToResponsesBody` in `src/chat/inbound.ts` copies an explicit field list without `n`, `logprobs`, `logit_bias`, @@ -238,7 +246,81 @@ The server side is `src/server/inference/client-encoder-delivery.ts`, described attempt is traced with `markAttemptProtocolPath`: the request path is still the bridge path (`[inbound, "responses-internal", "ir", upstream]`, mode `legacy-bridge`) because the request side still decodes through the Responses projection, and the response path is -`[upstream, "ir", inbound]`. +`[upstream, "ir", inbound]`. When the encoder option carries `requestLane: "direct"` (the +Antigravity direct lane) the request path is `[inbound, "ir", upstream]` and the mode +`translated`. + +## 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 +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. + +`antigravityDirectDeclineReason` in `src/server/antigravity-direct-eligibility.ts` names the +first rule that keeps a route off the lane: `rollout-disabled`; `not-migrated` (not Google in +Cloud Code Assist mode, judged with `effectiveGoogleMode` so a saved provider without +`googleMode` gets the registry's); `combo-or-policy-route`; `hosted-tool` (a `web_search` tool +the sidecar reads from the parsed Responses body); `vision-preprocessing` (an image for a model +`requiresVisionPreprocessing` declares unable to read it); `bridge-only-policy` (the shadow-call +intercept could rewrite the model before parsing; a `` block in a system or +developer message, which the per-session skills snapshot rewrites on the bridge body; a generated +image link in assistant history, whose hosted display path the bridge body redacts; or, as a +runtime fact only the ingress supplies, an observing cache diagnostic recorder, which records the +body the core receives). The Chat ingress and the planner both ask it; the planner judges a +standalone route only and cannot see body content no Chat feature describes (a hosted tool, the +rewritten text). A declined Antigravity route adds its reason to the bridge entry mark; +`rollout-disabled` and `not-migrated` add nothing. + +On the lane the ingress validates the Chat body by decoding it, with +`chatConversationToIr` (`src/protocols/codecs/chat-ir.ts`), and sends the core +`chatSettingsToResponsesBody`: the bridge body's settings (model, stream, store, tool choice, +limits, sampling, penalties, stop, user, tier, cache key, metadata, reasoning, text format) with +`input: []` and no `instructions` or `tools`. Both bodies are built by one `applyChatSettings`, +so the settings and their validation cannot drift. Everything the core decides from settings keeps +its owner: routing, Antigravity account rotation and failover, effort pins and caps, tier, spend, +usage and the request log. The conversation travels as `HandleResponsesOptions.directRequest`: + +- `decode` runs at the parse seam in `prepareResponsesRequest`, inside the same `try` as + `parseRequest`, and replaces the settings-only parse's empty context with the decoded one. It + decodes again if the core settled a different model id, because identity naming (#5217) and the + assistant placeholder carry it, and it re-checks a named tool choice against the decoded + catalog, which the settings-only parse could not see. +- `evidence` replaces `evidenceFromBody(parsed._rawBody)` for policy routing, with the values the + bridge body would have given (a non-empty catalog; an image in a user message, not in a tool + result, which that scan never read). +- `bridgeConversation` builds the bridge's `input`, `instructions` and `tools`. When a route + assignment (the initial resolution or a subagent fallback, checked by + `leaveDirectLaneIfRerouted` right after each, and once more before final-route normalization) + is not the Antigravity route the lane was decided for, or the body reaches the combo dispatcher, + `restoreDirectRequestConversation` puts them back on the raw body before canonical + sanitization, ciphertext stripping or injected guidance can read or write it, re-marks the entry + as the bridge with `not-migrated`, and drops the encoder's direct request lane. The decoded IR + stays, because it equals what the parser builds from the restored body. +- Ambiguous-resend replay (`adapter-dispatch.ts`) judges self-containment on the settings body + merged with `bridgeConversation()`, built only if a reset asks, so its answer is the bridge's. + +The contract that makes this safe is parity. For every Chat body the bridge accepts, the lane's +parsed request equals `parseRequest(chatCompletionsToResponsesBody(body))` apart from `_rawBody` +and timestamps; every body the bridge refuses is refused with the same message. The decoder mirrors +the Chat translator's walk (leading-system hoist, held mid-batch instructions, tool-call naming, +legacy function pairing) and the parser's assembly for the item kinds that walk produces (pending +reasoning and its merge, the assistant placeholder, reasoning after its call #950, tool-result +naming, the unsigned-reasoning placeholder signature, tool deduplication, visualization +directives). The Google adapter reads only the IR (never `_rawBody`), so it compiles the same +wire. `_rawBody` readers that matter only to other wires, Codex surfaces or the hosted tools 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. + +`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. ## Native Chat candidates in combos @@ -449,7 +531,8 @@ 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 the key-auth one. The Chat and Messages ingresses read the unrepresentable policy (above); -`directEncodersApply` reads `directEncoders` on both; the Chat ingress reads `nativeChatCombos` +`directEncodersApply` reads `directEncoders` on both; `antigravityDirectDeclineReason` reads +`antigravityDirect` for the Chat 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 c21f2e26d5f..c5691515888 100644 --- a/tests/config/protocol-settings.test.ts +++ b/tests/config/protocol-settings.test.ts @@ -58,6 +58,7 @@ describe("protocol settings resolution", () => { managedMessagesNative: false, managedMessagesNativeOAuth: false, directEncoders: false, + antigravityDirect: false, shadowPlan: false, }, }); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index d84e906afc8..67e3147d48d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -256,6 +256,8 @@ "protocol-ingress-guard.test.ts": "responses", "protocol-direct-encoders-chat.test.ts": "responses", "protocol-direct-encoders-stall-disabled.test.ts": "responses", "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-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 new file mode 100644 index 00000000000..4095631c2a2 --- /dev/null +++ b/tests/responses/protocol-antigravity-direct-chat.test.ts @@ -0,0 +1,196 @@ +/** + * 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 + * 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. + */ +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 { handleChatCompletions } from "../../src/server/chat-completions"; +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-codex-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-agy-direct-")); + 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: "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): 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"], + }, + }, + ...(direct ? { protocols: { rollout: { antigravityDirect: true } } } : {}), + } as OcxConfig; +} + +function chatBody(stream: boolean): Record { + return { + model: "google-antigravity/gemini-3.8-flash", + stream, + temperature: 0.3, + max_tokens: 200, + tools: [{ type: "function", function: { name: "lookup", description: "find", parameters: { type: "object", properties: { q: { type: "string" } } } } }], + messages: [ + { role: "system", content: "Answer briefly." }, + { role: "user", content: "look something up" }, + { role: "assistant", content: "Looking.", tool_calls: [{ id: "call_fixture", type: "function", function: { name: "lookup", arguments: "{\"q\":\"x\"}" } }] }, + { role: "tool", tool_call_id: "call_fixture", content: "found x" }, + { role: "user", content: "and now?" }, + ], + }; +} + +async function send(direct: boolean, stream: boolean): Promise<{ status: number; text: string; requestId: string }> { + const config = antigravityConfig(direct); + saveConfig(config); + const requestId = `agy-direct-${direct}-${stream}-${crypto.randomUUID()}`; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(chatBody(stream)), + }), 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 ids and creation times. */ +function answer(text: string, stream: boolean): unknown { + const scrub = (value: Record) => ({ ...value, id: "ID", created: 0 }); + if (!stream) return scrub(JSON.parse(text) as Record); + return text.split("\n\n").filter(block => block.startsWith("data:")).map(block => { + const data = block.slice(5).trim(); + return data === "[DONE]" ? data : scrub(JSON.parse(data) as Record); + }); +} + +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 Chat ingress", () => { + for (const stream of [false, true]) { + test(`same upstream wire and same answer as the bridge (stream=${stream})`, async () => { + const bridge = await send(false, stream); + const direct = await send(true, 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: ["chat", "responses-internal", "ir", "other"], + }); + expect(await traceFor(direct.requestId)).toMatchObject({ + mode: "translated", + requestPath: ["chat", "ir", "other"], + responsePath: ["other", "ir", "chat"], + }); + }); + } + + 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 OcxConfig; + saveConfig(config); + const requestId = `agy-direct-declined-${crypto.randomUUID()}`; + const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...chatBody(false), tools: [{ type: "web_search" }] }), + }), config, { model: "", provider: "" }, { requestId, start: Date.now() }); + await response.text(); + const trace = await traceFor(requestId); + expect(trace).toMatchObject({ mode: "legacy-bridge", requestPath: ["chat", "responses-internal", "ir", "other"] }); + expect(trace?.reasonCodes).toContain("hosted-tool"); + }); +}); diff --git a/tests/responses/protocol-antigravity-direct-eligibility.test.ts b/tests/responses/protocol-antigravity-direct-eligibility.test.ts new file mode 100644 index 00000000000..0f7cad95f73 --- /dev/null +++ b/tests/responses/protocol-antigravity-direct-eligibility.test.ts @@ -0,0 +1,162 @@ +/** + * Antigravity direct lane eligibility: the rule the Chat ingress and the protocol planner share. + */ +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 { cacheDiagnosticsObserving } from "../../src/server/request-log"; +import "../../src/usage/cache-diagnostic"; +import type { RouteResult } from "../../src/router"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const MODEL = "gemini-3.8-flash"; +const PNG = "data:image/png;base64,iVBORw0KGgo="; + +function config(extra: Partial = {}, rollout = true): OcxConfig { + return { + port: 0, + providers: {}, + ...(rollout ? { protocols: { rollout: { antigravityDirect: true } } } : {}), + ...extra, + } as OcxConfig; +} + +function route(provider: Partial = {}, extra: Partial = {}, providerName = "google-antigravity"): RouteResult { + return { + providerName, + modelId: MODEL, + provider: { adapter: "google", googleMode: "cloud-code-assist", baseUrl: "https://daily-cloudcode-pa.googleapis.com", ...provider }, + ...extra, + } as RouteResult; +} + +const chat = (extra: Record = {}) => ({ + model: `google-antigravity/${MODEL}`, + messages: [{ role: "user", content: "hi" }], + ...extra, +}); + +describe("antigravityDirectDeclineReason", () => { + test("an eligible concrete Antigravity route takes the lane", () => { + expect(antigravityDirectDeclineReason(route(), chat(), config())).toBeUndefined(); + }); + + test("the switch is off by default", () => { + expect(antigravityDirectDeclineReason(route(), chat(), config({}, false))).toBe("rollout-disabled"); + expect(antigravityDirectDeclineReason(route(), chat(), config({ protocols: { rollout: { antigravityDirect: "true" } } } as never, false))) + .toBe("rollout-disabled"); + }); + + test("only Google in Cloud Code Assist mode is Antigravity", () => { + expect(antigravityDirectDeclineReason(route({ googleMode: "vertex" }), chat(), config())).toBe("not-migrated"); + expect(antigravityDirectDeclineReason(route({ googleMode: "ai-studio" }), chat(), config())).toBe("not-migrated"); + expect(antigravityDirectDeclineReason(route({ adapter: "openai-chat", googleMode: undefined }), chat(), config())).toBe("not-migrated"); + }); + + test("the registry supplies the mode a saved Antigravity provider omits", () => { + expect(isAntigravityRoute(route({ googleMode: undefined }))).toBe(true); + expect(isAntigravityRoute(route({ googleMode: undefined }, {}, "google-vertex"))).toBe(false); + }); + + test("combo and policy routes keep the bridge", () => { + expect(antigravityDirectDeclineReason(route({}, { combo: { id: "c" } } as never), chat(), config())).toBe("combo-or-policy-route"); + expect(antigravityDirectDeclineReason(route({}, { routeKind: "policy" } as never), chat(), config())).toBe("combo-or-policy-route"); + }); + + test("a hosted web-search tool keeps the bridge for the sidecar", () => { + expect(antigravityDirectDeclineReason(route(), chat({ tools: [{ type: "web_search" }] }), config())).toBe("hosted-tool"); + expect(antigravityDirectDeclineReason(route(), chat({ tools: [{ type: "web_search_preview" }] }), config())).toBe("hosted-tool"); + expect(antigravityDirectDeclineReason(route(), chat({ tools: [{ type: "function", function: { name: "f" } }] }), config())) + .toBeUndefined(); + }); + + test("an image for a model declared text-only keeps the bridge for vision preprocessing", () => { + const image = chat({ messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: PNG } }] }] }); + const textOnly = route({ modelCapabilities: { [MODEL]: { inputModalities: ["text"] } } } as never); + expect(antigravityDirectDeclineReason(textOnly, image, config())).toBe("vision-preprocessing"); + expect(antigravityDirectDeclineReason(textOnly, chat(), config())).toBeUndefined(); + const vision = route({ modelCapabilities: { [MODEL]: { inputModalities: ["text", "image"] } } } as never); + expect(antigravityDirectDeclineReason(vision, image, config())).toBeUndefined(); + }); + + test("a shadow-call intercept that could move the request keeps the bridge", () => { + const intercept = { shadowCallIntercept: { enabled: true, model: "combo/helpers", sourceModels: ["gemini-"] } } as Partial; + expect(antigravityDirectDeclineReason(route(), chat({ model: MODEL }), config(intercept))).toBe("bridge-only-policy"); + expect(antigravityDirectDeclineReason(route(), chat(), config(intercept))).toBeUndefined(); + }); + + test("text the core rewrites on the bridge body keeps the bridge", () => { + const skills = chat({ messages: [ + { role: "system", content: "intro \n## Skills\n" }, + { role: "user", content: "hi" }, + ] }); + expect(antigravityDirectDeclineReason(route(), skills, config())).toBe("bridge-only-policy"); + const generatedImage = chat({ messages: [ + { role: "user", content: "draw" }, + { role: "assistant", content: [{ type: "text", text: "![Generated image]()" }] }, + { role: "user", content: "again" }, + ] }); + expect(antigravityDirectDeclineReason(route(), generatedImage, config())).toBe("bridge-only-policy"); + const userMention = chat({ messages: [{ role: "user", content: "what is ?" }] }); + expect(antigravityDirectDeclineReason(route(), userMention, config())).toBeUndefined(); + }); + + test("an observing cache diagnostic recorder keeps the bridge", () => { + expect(antigravityDirectDeclineReason(route(), chat(), config(), { cacheDiagnostics: true })).toBe("bridge-only-policy"); + expect(antigravityDirectDeclineReason(route(), chat(), config(), { cacheDiagnostics: false })).toBeUndefined(); + }); + + test("the recorder observes only when cache debugging is on, not merely because it is loaded", () => { + const previous = process.env.OPENCODEX_CACHE_DEBUG; + try { + delete process.env.OPENCODEX_CACHE_DEBUG; + expect(cacheDiagnosticsObserving()).toBe(false); + process.env.OPENCODEX_CACHE_DEBUG = "1"; + expect(cacheDiagnosticsObserving()).toBe(true); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_CACHE_DEBUG; + else process.env.OPENCODEX_CACHE_DEBUG = previous; + } + }); +}); + +describe("planner", () => { + function input(directEligible: boolean): ProtocolPlanInput { + return { + inbound: "chat", + requestedModel: `google-antigravity/${MODEL}`, + routeKind: "direct", + candidates: [{ + provider: "google-antigravity", + model: MODEL, + adapter: "google", + nativeEligible: false, + ...(directEligible ? { directEligible } : {}), + declineReasons: ["cross-wire-ir"], + }], + features: [], + surfaces: { responses: { enabled: true }, chat: { enabled: true }, messages: { enabled: true } }, + settings: { unrepresentable: "legacy" }, + policyRevision: "p1-00000000", + basis: "preview", + }; + } + + test("a direct-eligible Antigravity candidate is planned without the internal Responses hop", () => { + const [candidate] = planProtocol(input(true)).candidates; + expect(candidate).toMatchObject({ + mode: "translated", + requestPath: ["chat", "ir", "other"], + responsePath: ["other", "ir", "chat"], + }); + expect(candidate!.reasonCodes).toEqual(["cross-wire-ir", "upstream-other"]); + }); + + test("without the flag the plan is the bridge, as before", () => { + const [candidate] = planProtocol(input(false)).candidates; + expect(candidate).toMatchObject({ + mode: "legacy-bridge", + requestPath: ["chat", "responses-internal", "ir", "other"], + }); + }); +}); diff --git a/tests/responses/protocol-chat-ir-parity.test.ts b/tests/responses/protocol-chat-ir-parity.test.ts new file mode 100644 index 00000000000..515673d274b --- /dev/null +++ b/tests/responses/protocol-chat-ir-parity.test.ts @@ -0,0 +1,278 @@ +/** + * Antigravity direct lane: the Chat -> 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 `chatConversationToIr` for the conversation) must equal what + * `parseRequest(chatCompletionsToResponsesBody(body))` builds on the bridge, field for field + * except `_rawBody` and timestamps. Bodies the bridge refuses must be refused with the same + * message. If either side changes alone, this file fails; that is the point of it. + */ +import { describe, expect, test } from "bun:test"; +import { + chatCompletionsToResponsesBody, + chatSettingsToResponsesBody, +} from "../../src/chat/inbound"; +import { + chatConversationToIr, + chatIrRouteEvidence, + withDirectChatConversation, +} from "../../src/protocols/codecs/chat-ir"; +import { featureEffectsForPath, PROTOCOL_FEATURES } from "../../src/protocols/features"; +import { requestPathForLane } from "../../src/protocols/path"; +import { parseRequest } from "../../src/responses/parser"; +import { evidenceFromBody } from "../../src/routing/request-evidence"; +import type { 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 PDF = `data:application/pdf;base64,${Buffer.from("%PDF-1.4 fixture").toString("base64")}`; +const PNG = "data:image/png;base64,iVBORw0KGgo="; + +type Body = Record; + +function comparable(parsed: OcxParsedRequest): unknown { + const { _rawBody: _ignored, ...rest } = parsed; + return JSON.parse(JSON.stringify(rest, (key, value) => key === "timestamp" ? 0 : value)); +} + +function viaBridge(body: Body): OcxParsedRequest { + return parseRequest(chatCompletionsToResponsesBody(clone(body))); +} + +function viaDirect(body: Body): OcxParsedRequest { + const source = clone(body); + const decoded = chatConversationToIr(source, source.model as string); + return withDirectChatConversation(parseRequest(chatSettingsToResponsesBody(source)), source, decoded); +} + +/** 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; +} + +const CORPUS: Record = { + "a single user string": { + model: MODEL, + messages: [{ role: "user", content: "hello" }], + }, + "leading system and developer are hoisted and named for the destination": { + model: MODEL, + messages: [ + { role: "system", content: ` ${IDENTITY} ` }, + { role: "developer", content: [{ type: "text", text: "second instruction" }] }, + { role: "user", content: "hi" }, + ], + }, + "a later system message keeps its slot as a developer turn": { + model: MODEL, + messages: [ + { role: "user", content: "u1" }, + { role: "assistant", content: "a1" }, + { role: "system", content: IDENTITY }, + { role: "user", content: "u2" }, + ], + }, + "an empty user turn does not end the leading instruction block": { + model: MODEL, + messages: [ + { role: "user", content: "" }, + { role: "system", content: "still leading" }, + { role: "user", content: "real" }, + ], + }, + "user media: text, image with detail, video with processing, inline document, bare string": { + model: MODEL, + messages: [{ + role: "user", + content: [ + "plain string part", + { type: "text", text: "" }, + { type: "image_url", image_url: { url: PNG, detail: "low" } }, + { type: "image_url", image_url: "https://example.com/cat.png" }, + { type: "video_url", video_url: { url: "https://www.youtube.com/watch?v=abc", processing: "agentic" } }, + { type: "file", file: { file_data: PDF, filename: "spec.pdf" } }, + ], + }], + }, + "tool calls, results with images, held mid-batch instruction and reasoning": { + model: MODEL, + tools: [ + { type: "function", function: { name: "lookup", description: "find", parameters: { type: "object", properties: { q: { type: "string" } } }, strict: true } }, + { type: "function", name: "flat_tool", parameters: { properties: {} } }, + { type: "function", function: { name: "lookup", description: "duplicate is dropped" } }, + ], + messages: [ + { role: "system", content: "sys" }, + { role: "user", content: "find two things" }, + { + role: "assistant", + reasoning_content: "I should look both up.", + content: [{ type: "text", text: "Looking." }, { type: "output_text", text: "" }], + tool_calls: [ + { id: "call_a", type: "function", function: { name: "lookup", arguments: "{\"q\":\"a\"}" } }, + { id: "call_b", type: "function", function: { name: "flat_tool", arguments: { q: "b" } } }, + ], + }, + { role: "tool", tool_call_id: "call_a", content: "result a" }, + { role: "developer", content: "held until the batch drains" }, + { role: "tool", tool_use_id: "call_b", content: [{ type: "text", text: "see image" }, { type: "image_url", image_url: { url: PNG, detail: "high" } }] }, + { role: "user", content: "thanks" }, + ], + }, + "tool-only assistant turns merge and reasoning after the call joins its owner": { + model: MODEL, + messages: [ + { role: "user", content: "go" }, + { role: "assistant", reasoning_details: [{ text: "part one " }, { text: "part two" }], tool_calls: [ + { id: "call_1", function: { name: "first", arguments: "" } }, + ] }, + { role: "assistant", reasoning_content: "late reasoning" }, + { role: "tool", tool_call_id: "call_1", content: [{ type: "text", text: "one" }, { type: "text", text: "two" }] }, + { role: "assistant", reasoning_content: "r1" }, + { role: "assistant", reasoning_content: "r2", content: "answer" }, + { role: "user", content: "again" }, + { role: "assistant", tool_calls: [{ id: "call_2", function: { name: "first", arguments: "\"k\":1}" } }] }, + { role: "assistant", tool_calls: [{ id: "call_3", function: { name: "second", arguments: "not json" } }] }, + { role: "tool", tool_call_id: "call_2", content: "ok" }, + { role: "tool", tool_call_id: "call_3", content: "ok" }, + ], + }, + "a tool call without a name reuses the name its id already carries": { + model: MODEL, + messages: [ + { role: "user", content: "go" }, + { role: "assistant", tool_calls: [{ id: "call_x", function: { name: "named", arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_x", content: "ok" }, + { role: "assistant", tool_calls: [{ id: "call_x", function: { arguments: "{}" } }] }, + { role: "tool", tool_call_id: "call_x", content: "again" }, + ], + }, + "legacy functions and function_call pairing": { + model: MODEL, + functions: [{ name: "legacy", description: "old style", parameters: { type: "object" } }], + function_call: { name: "legacy" }, + messages: [ + { role: "user", content: "call it" }, + { role: "assistant", content: null, function_call: { name: "legacy", arguments: { a: 1 } } }, + { role: "function", name: "legacy", content: "legacy result" }, + { role: "assistant", function_call: { name: "legacy", arguments: "{}" } }, + { role: "function", name: "legacy", content: [{ type: "text", text: "second" }] }, + ], + }, + "settings ride the Responses body unchanged": { + model: MODEL, + stream: true, + max_completion_tokens: 512, + temperature: 0.2, + top_p: 0.9, + stop: ["END"], + presence_penalty: 0.1, + frequency_penalty: 0.2, + parallel_tool_calls: false, + service_tier: "priority", + prompt_cache_key: "cache-key", + user: "user-1", + reasoning_effort: "high", + include_reasoning: true, + response_format: { type: "json_schema", json_schema: { name: "out", schema: { type: "object" }, strict: true } }, + tools: [{ type: "function", function: { name: "pick" } }], + tool_choice: { type: "function", function: { name: "pick" } }, + messages: [{ role: "user", content: "fmt" }], + }, + "an allowed-tools choice and reasoning disabled": { + model: MODEL, + reasoning_effort: "none", + tools: [{ type: "function", function: { name: "a" } }, { type: "function", function: { name: "b" } }], + tool_choice: { type: "allowed_tools", allowed_tools: { mode: "required", tools: [{ type: "function", function: { name: "a" } }] } }, + messages: [{ role: "user", content: "pick" }], + }, + "only instructions": { + model: MODEL, + messages: [{ role: "system", content: "just instructions" }], + }, + "tool parameters with an own __proto__ key": JSON.parse( + `{"model":"${MODEL}","messages":[{"role":"user","content":"x"}],` + + `"tools":[{"type":"function","function":{"name":"p","parameters":{"type":"object","__proto__":{"x":1},"properties":{}}}},` + + `{"type":"function","function":{"name":"q","parameters":{"__proto__":{"y":2}}}}]}`, + ) as Body, +}; + +describe("Chat -> IR direct decode parity with the Responses bridge", () => { + for (const [name, body] of Object.entries(CORPUS)) { + test(name, () => { + expect(comparable(viaDirect(body))).toEqual(comparable(viaBridge(body))); + }); + } + + test("the settings-only body is the bridge body without its conversation", () => { + for (const body of Object.values(CORPUS)) { + const { input: _input, instructions: _instructions, tools: _tools, ...settings } = chatCompletionsToResponsesBody(clone(body)); + expect(chatSettingsToResponsesBody(clone(body))).toEqual({ ...settings, input: [] }); + } + }); + + test("routing evidence matches what the core reads from the bridge body", () => { + for (const body of Object.values(CORPUS)) { + const decoded = chatConversationToIr(clone(body), MODEL); + expect(chatIrRouteEvidence(decoded)).toEqual(evidenceFromBody(chatCompletionsToResponsesBody(clone(body)))); + } + }); + + test("a model the core settled differently is decoded again for that model", () => { + const body = CORPUS["leading system and developer are hoisted and named for the destination"]!; + const decoded = chatConversationToIr(clone(body), MODEL); + const renamed = { ...clone(body), model: "google-antigravity/other-model" }; + const base = parseRequest(chatSettingsToResponsesBody(renamed)); + expect(comparable(withDirectChatConversation(base, renamed, decoded))).toEqual(comparable(viaBridge(renamed))); + }); + + test("the decoder does not hand the caller's tool parameters to the IR by reference", () => { + const body = clone(CORPUS["tool calls, results with images, held mid-batch instruction and reasoning"]!); + const decoded = chatConversationToIr(body, MODEL); + const tool = decoded.context.tools!.find(entry => entry.name === "lookup")!; + tool.parameters.mutated = true; + const source = ((body.tools as Body[])[0]!.function as Body).parameters as Body; + expect(source.mutated).toBeUndefined(); + }); +}); + +describe("Chat -> IR direct decode refuses what the bridge refuses", () => { + const INVALID: Record = { + "tool message without an id": { model: MODEL, messages: [{ role: "tool", content: "x" }] }, + "tool call without any name": { model: MODEL, messages: [{ role: "assistant", tool_calls: [{ id: "c", function: { arguments: "{}" } }] }] }, + "function result without a name": { model: MODEL, messages: [{ role: "function", content: "x" }] }, + "function result with no pending call": { model: MODEL, messages: [{ role: "function", name: "f", content: "x" }] }, + "function result with an image": { model: MODEL, messages: [{ role: "function", name: "f", content: [{ type: "image_url", image_url: { url: PNG } }] }] }, + "assistant function_call without a name": { model: MODEL, messages: [{ role: "assistant", function_call: { arguments: "{}" } }] }, + "functions that are not an array": { model: MODEL, functions: {}, messages: [{ role: "user", content: "x" }] }, + "a function without a name": { model: MODEL, functions: [{ description: "x" }], messages: [{ role: "user", content: "x" }] }, + "no conversational turn": { model: MODEL, messages: [{ role: "user", content: "" }] }, + "untranslated audio": { model: MODEL, messages: [{ role: "user", content: [{ type: "input_audio", input_audio: { data: "AA==", format: "wav" } }] }] }, + "a file part without inline bytes": { model: MODEL, messages: [{ role: "user", content: [{ type: "file", file: { file_id: "file-1" } }] }] }, + }; + for (const [name, body] of Object.entries(INVALID)) { + test(name, () => { + let bridgeMessage: string | undefined; + try { chatCompletionsToResponsesBody(clone(body)); } catch (err) { bridgeMessage = (err as Error).message; } + expect(bridgeMessage).toBeDefined(); + expect(() => chatConversationToIr(clone(body), MODEL)).toThrow(bridgeMessage!); + }); + } +}); + +describe("feature effects on the direct path", () => { + test("every Chat feature meets the same disposition as on the bridge path", () => { + const direct = featureEffectsForPath("chat", requestPathForLane("chat", "direct", "other"), PROTOCOL_FEATURES); + const bridge = featureEffectsForPath("chat", requestPathForLane("chat", "bridge", "other"), PROTOCOL_FEATURES); + expect(direct).toEqual(bridge); + }); + + test("the direct lane exists only toward an other upstream", () => { + expect(requestPathForLane("chat", "direct", "other")).toEqual(["chat", "ir", "other"]); + expect(requestPathForLane("messages", "direct", "other")).toEqual(["messages", "ir", "other"]); + expect(requestPathForLane("chat", "direct", "responses")).toEqual(["chat", "responses"]); + expect(requestPathForLane("chat", "direct", "messages")).toEqual(["chat", "responses-internal", "ir", "messages"]); + expect(requestPathForLane("responses", "direct", "other")).toEqual(["responses", "ir", "other"]); + }); +});