From ef706e6c3163cc4f4a01b3904616840470ce7566 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 14 Oct 2026 04:05:49 +0900 Subject: [PATCH 1/4] feat(protocols): decode Chat straight into the IR on Antigravity routes Behind protocols.rollout.antigravityDirect (default off), a Chat request whose settled route is one concrete Google Antigravity provider no longer goes through the internal Responses body. The ingress validates and decodes the conversation with chatConversationToIr and hands the Responses core only the request settings (chatSettingsToResponsesBody, built by the same applyChatSettings as the bridge body). The decoded conversation replaces the settings-only parse at the parse seam; the answer uses the direct Chat encoder. The trace reads chat -> ir -> other both ways. The lane is held to the bridge it replaces: the parsed request equals parseRequest(chatCompletionsToResponsesBody(body)) apart from _rawBody and timestamps, refusals match, and the Google wire body is identical. A route that leaves Antigravity after the ingress decided gets the bridge conversation restored before any other reader sees the raw body. Refs #7079 --- scripts/test-layout/layout.json | 3 + src/chat/inbound.ts | 36 +- src/config/schema/config-schema.ts | 2 + src/protocols/codecs/chat-ir.ts | 459 ++++++++++++++++++ src/protocols/codecs/chat.ts | 11 +- src/protocols/features.ts | 12 +- src/protocols/path.ts | 12 +- src/protocols/plan-snapshot.ts | 18 +- src/protocols/plan.ts | 10 +- src/protocols/settings.ts | 3 + src/responses/parser.ts | 49 +- src/server/antigravity-direct-eligibility.ts | 92 ++++ src/server/chat-completions.ts | 60 ++- .../inference/client-encoder-delivery.ts | 11 +- .../management/protocol-settings-patch.ts | 1 + src/server/responses/core-options.ts | 30 ++ src/server/responses/request-prepare.ts | 56 ++- src/types/config.ts | 5 + tests/config/protocol-settings.test.ts | 1 + tests/fixtures/test-layout-expected.json | 2 + .../antigravity-direct-eligibility.test.ts | 126 +++++ .../protocol-antigravity-direct-chat.test.ts | 196 ++++++++ .../responses/protocol-chat-ir-parity.test.ts | 268 ++++++++++ 23 files changed, 1413 insertions(+), 50 deletions(-) create mode 100644 src/protocols/codecs/chat-ir.ts create mode 100644 src/server/antigravity-direct-eligibility.ts create mode 100644 tests/responses/antigravity-direct-eligibility.test.ts create mode 100644 tests/responses/protocol-antigravity-direct-chat.test.ts create mode 100644 tests/responses/protocol-chat-ir-parity.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 5b3c6161a4a..beb7bc8d57c 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", + "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..9cae1780622 --- /dev/null +++ b/src/protocols/codecs/chat-ir.ts @@ -0,0 +1,459 @@ +/** + * 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 { + if (isRec(raw) && raw.type === "object") return { ...raw }; + return { ...(isRec(raw) ? raw : {}), 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..dd117ba8609 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,14 @@ 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. A hosted web-search tool + // is body content no Chat feature describes, so a preview cannot predict that decline. + directEligible = !nativeEligible + && 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 +161,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..56d0cafec46 --- /dev/null +++ b/src/server/antigravity-direct-eligibility.ts @@ -0,0 +1,92 @@ +/** + * 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 { 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); +} + +/** + * 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. + */ +export function antigravityDirectDeclineReason( + route: RouteShape, + body: Readonly, + config: OcxConfig, +): AntigravityDirectDeclineReason | undefined { + if (!resolveProtocolSettings(config).rollout.antigravityDirect) return "rollout-disabled"; + if (!isAntigravityRoute(route)) return "not-migrated"; + if (route.combo !== undefined || route.routeKind === "combo" || route.routeKind === "policy") return "combo-or-policy-route"; + 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"; + return undefined; +} diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 28cc0033261..a8bc0722a33 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, @@ -273,6 +275,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) + : undefined; + const directRoute = settledRoute && !chatNativeRoute && antigravityDecline === undefined ? settledRoute : null; const envelope = protocolSettings.unrepresentable === "reject" || nativeChatCombos ? createProtocolEnvelope({ inbound: "chat", body: chatBody, translatorBudget }) : undefined; @@ -282,7 +290,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 +303,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 +330,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 +512,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/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..ccf2bcf05f3 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,31 @@ 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) ?? [], + }); +} + /** Parses, selects, and admits one request without changing the dispatch policy. */ export async function prepareResponsesRequest( requestContext: Pick, @@ -306,6 +335,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 +415,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 +618,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. @@ -893,7 +929,7 @@ 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)), ); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; @@ -1109,7 +1145,7 @@ 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)), ); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; @@ -1392,6 +1428,16 @@ export async function prepareResponsesRequest( substituteMainCredential = finalAuth.substituteMainCredential; } + // The direct lane was decided for one concrete Antigravity route. A later rewrite (a subagent + // fallback, an operator redirect) may have moved the request; restore the bridge conversation + // before any other adapter or account binding reads the raw body. + if (options.directRequest && ( + route.providerName !== options.directRequest.providerName + || route.modelId !== options.directRequest.modelId + || !isAntigravityRoute(route) + )) { + restoreDirectRequestConversation(options, parsed._rawBody, logCtx); + } try { route.provider = applyCodexAuthContextToProvider(route.provider, admissionState.authCtx, route.codexAccountMode); } catch (error) { 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/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..94ba642d2d3 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", + "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/antigravity-direct-eligibility.test.ts b/tests/responses/antigravity-direct-eligibility.test.ts new file mode 100644 index 00000000000..b6aca2b9b7d --- /dev/null +++ b/tests/responses/antigravity-direct-eligibility.test.ts @@ -0,0 +1,126 @@ +/** + * 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 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(); + }); +}); + +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-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-chat-ir-parity.test.ts b/tests/responses/protocol-chat-ir-parity.test.ts new file mode 100644 index 00000000000..a2c003ee189 --- /dev/null +++ b/tests/responses/protocol-chat-ir-parity.test.ts @@ -0,0 +1,268 @@ +/** + * 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(structuredClone(body))); +} + +function viaDirect(body: Body): OcxParsedRequest { + const source = structuredClone(body); + const decoded = chatConversationToIr(source, source.model as string); + return withDirectChatConversation(parseRequest(chatSettingsToResponsesBody(source)), source, decoded); +} + +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" }], + }, +}; + +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(structuredClone(body)); + expect(chatSettingsToResponsesBody(structuredClone(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(structuredClone(body), MODEL); + expect(chatIrRouteEvidence(decoded)).toEqual(evidenceFromBody(chatCompletionsToResponsesBody(structuredClone(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(structuredClone(body), MODEL); + const renamed = { ...structuredClone(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 = structuredClone(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(structuredClone(body)); } catch (err) { bridgeMessage = (err as Error).message; } + expect(bridgeMessage).toBeDefined(); + expect(() => chatConversationToIr(structuredClone(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"]); + }); +}); From fa4b1f8196e116d2d8a92a7cd3d58123ab83a3de Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 14 Oct 2026 04:05:49 +0900 Subject: [PATCH 2/4] docs(protocols): describe the Antigravity direct lane Refs #7079 --- .../040_acceptance_and_rollout.md | 2 +- .../000_plan.md | 73 +++++++++++++++++ .../src/content/docs/guides/protocol-paths.md | 4 +- .../docs/reference/configuration/server.md | 1 + structure/data-planes/protocol-paths.md | 78 ++++++++++++++++++- 5 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 devlog/_plan/261014_antigravity_direct_protocols/000_plan.md 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/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 7567171adb0..77a5defaa6e 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,72 @@ 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). The Chat ingress and the planner both ask it; +the planner cannot see a hosted tool, which no Chat feature describes. 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`. If the final + route is not the Antigravity route the lane was decided for (checked once the route is final, + before account binding), or if the body reaches the combo dispatcher, + `restoreDirectRequestConversation` puts them back on the raw body, 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. + +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/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 +522,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 From 60a62b014b14609f48009599d99ddb818acf2d30 Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 14 Oct 2026 04:18:21 +0900 Subject: [PATCH 3/4] fix(protocols): keep the Antigravity direct lane equal to the bridge under rewrites Address independent review of the first head: - decline when the core would rewrite conversation text on the bridge body (a skills catalog, a generated-image link) or the cache diagnostic recorder is observing; - restore the bridge conversation right after every route assignment, before sanitization, ciphertext stripping and injected guidance can touch the raw body; - judge ambiguous-resend replay on the body the bridge would have sent; - predict the lane only for standalone routes, not combo children; - skip an own __proto__ parameter key as the schema parse does; - rename the eligibility test so its seed domain matches its registration. Refs #7079 --- scripts/test-layout/layout.json | 2 +- src/protocols/codecs/chat-ir.ts | 11 ++++-- src/protocols/plan-snapshot.ts | 8 +++-- src/server/antigravity-direct-eligibility.ts | 36 +++++++++++++++++++ src/server/chat-completions.ts | 3 +- src/server/request-log.ts | 5 +++ src/server/responses/adapter-dispatch.ts | 7 +++- src/server/responses/request-prepare.ts | 32 +++++++++++------ structure/data-planes/protocol-paths.md | 29 +++++++++------ tests/fixtures/test-layout-expected.json | 2 +- ...ol-antigravity-direct-eligibility.test.ts} | 21 +++++++++++ .../responses/protocol-chat-ir-parity.test.ts | 32 +++++++++++------ 12 files changed, 148 insertions(+), 40 deletions(-) rename tests/responses/{antigravity-direct-eligibility.test.ts => protocol-antigravity-direct-eligibility.test.ts} (82%) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index beb7bc8d57c..0c289a8fdfc 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -315,7 +315,7 @@ "protocol-direct-encoders-messages.test.ts": "responses", "protocol-chat-ir-parity.test.ts": "responses", "protocol-antigravity-direct-chat.test.ts": "responses", - "antigravity-direct-eligibility.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/protocols/codecs/chat-ir.ts b/src/protocols/codecs/chat-ir.ts index 9cae1780622..6116c4689dc 100644 --- a/src/protocols/codecs/chat-ir.ts +++ b/src/protocols/codecs/chat-ir.ts @@ -146,8 +146,15 @@ function unsignedReasoningSignature(text: string): string { * must not reach the caller's body, which the lane may still need to re-decode. */ function normalizedParameters(raw: unknown): Record { - if (isRec(raw) && raw.type === "object") return { ...raw }; - return { ...(isRec(raw) ? raw : {}), type: "object" }; + // 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 { diff --git a/src/protocols/plan-snapshot.ts b/src/protocols/plan-snapshot.ts index dd117ba8609..0c7cf1a137d 100644 --- a/src/protocols/plan-snapshot.ts +++ b/src/protocols/plan-snapshot.ts @@ -142,9 +142,11 @@ function candidateFor( 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. A hosted web-search tool - // is body content no Chat feature describes, so a preview cannot predict that decline. - directEligible = !nativeEligible + // 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 diff --git a/src/server/antigravity-direct-eligibility.ts b/src/server/antigravity-direct-eligibility.ts index 56d0cafec46..e7ea4966e9a 100644 --- a/src/server/antigravity-direct-eligibility.ts +++ b/src/server/antigravity-direct-eligibility.ts @@ -13,6 +13,7 @@ * 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"; @@ -63,6 +64,37 @@ function shadowInterceptMayApply(body: Readonly, config: OcxConfig): boolea && 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. @@ -74,11 +106,14 @@ function shadowInterceptMayApply(body: Readonly, config: OcxConfig): boolea * - 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"; @@ -88,5 +123,6 @@ export function antigravityDirectDeclineReason( 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 a8bc0722a33..d5339fba828 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -47,6 +47,7 @@ import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { addFinalRequestLog, + cacheDiagnosticsObserving, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, @@ -278,7 +279,7 @@ async function handleChatCompletionsWithBudget( // 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) + ? antigravityDirectDeclineReason(settledRoute, chatBody, config, { cacheDiagnostics: cacheDiagnosticsObserving() }) : undefined; const directRoute = settledRoute && !chatNativeRoute && antigravityDecline === undefined ? settledRoute : null; const envelope = protocolSettings.unrepresentable === "reject" || nativeChatCombos diff --git a/src/server/request-log.ts b/src/server/request-log.ts index 7d26b439777..edf2670e911 100644 --- a/src/server/request-log.ts +++ b/src/server/request-log.ts @@ -104,6 +104,11 @@ function cacheDiagnosticHooks(): CacheDiagnosticHooks | undefined { return (globalThis as Record)[CACHE_DIAGNOSTIC_HOOK]; } +/** Whether the cache diagnostic recorder is registered and will observe inbound bodies. */ +export function cacheDiagnosticsObserving(): boolean { + return cacheDiagnosticHooks() !== undefined; +} + 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/request-prepare.ts b/src/server/responses/request-prepare.ts index ccf2bcf05f3..4cf050b1abd 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -184,6 +184,23 @@ function restoreDirectRequestConversation(options: HandleResponsesOptions, body: }); } +/** + * 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, @@ -690,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. @@ -931,6 +949,7 @@ export async function prepareResponsesRequest( route = captureInboundRoutePolicy( routeModel(config, fallback.to, routeEvidence(options, parsed)), ); + leaveDirectLaneIfRerouted(options, route, parsed, logCtx); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; logCtx.policyEligibility = route.policyEligibility; @@ -1147,6 +1166,7 @@ export async function prepareResponsesRequest( route = captureInboundRoutePolicy( routeModel(config, fallback.to, routeEvidence(options, parsed)), ); + leaveDirectLaneIfRerouted(options, route, parsed, logCtx); credentialDomainWasRewritten = true; logCtx.routeDecision = route.routeDecision; logCtx.policyEligibility = route.policyEligibility; @@ -1293,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, @@ -1428,16 +1450,6 @@ export async function prepareResponsesRequest( substituteMainCredential = finalAuth.substituteMainCredential; } - // The direct lane was decided for one concrete Antigravity route. A later rewrite (a subagent - // fallback, an operator redirect) may have moved the request; restore the bridge conversation - // before any other adapter or account binding reads the raw body. - if (options.directRequest && ( - route.providerName !== options.directRequest.providerName - || route.modelId !== options.directRequest.modelId - || !isAntigravityRoute(route) - )) { - restoreDirectRequestConversation(options, parsed._rawBody, logCtx); - } try { route.provider = applyCodexAuthContextToProvider(route.provider, admissionState.authCtx, route.codexAccountMode); } catch (error) { diff --git a/structure/data-planes/protocol-paths.md b/structure/data-planes/protocol-paths.md index 77a5defaa6e..37be2ffaa9e 100644 --- a/structure/data-planes/protocol-paths.md +++ b/structure/data-planes/protocol-paths.md @@ -264,9 +264,14 @@ Cloud Code Assist mode, judged with `effectiveGoogleMode` so a saved provider wi `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). The Chat ingress and the planner both ask it; -the planner cannot see a hosted tool, which no Chat feature describes. A declined Antigravity route -adds its reason to the bridge entry mark; `rollout-disabled` and `not-migrated` add nothing. +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 @@ -285,12 +290,16 @@ usage and the request log. The conversation travels as `HandleResponsesOptions.d - `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`. If the final - route is not the Antigravity route the lane was decided for (checked once the route is final, - before account binding), or if the body reaches the combo dispatcher, - `restoreDirectRequestConversation` puts them back on the raw body, 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. +- `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` @@ -308,7 +317,7 @@ so the response half is `[other, ir, chat]` as well. Messages to Antigravity is `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/antigravity-direct-eligibility.test.ts` pins the rule and the plan; +`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. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 94ba642d2d3..67e3147d48d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -257,7 +257,7 @@ "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", - "antigravity-direct-eligibility.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/antigravity-direct-eligibility.test.ts b/tests/responses/protocol-antigravity-direct-eligibility.test.ts similarity index 82% rename from tests/responses/antigravity-direct-eligibility.test.ts rename to tests/responses/protocol-antigravity-direct-eligibility.test.ts index b6aca2b9b7d..08e3496517f 100644 --- a/tests/responses/antigravity-direct-eligibility.test.ts +++ b/tests/responses/protocol-antigravity-direct-eligibility.test.ts @@ -82,6 +82,27 @@ describe("antigravityDirectDeclineReason", () => { 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(); + }); }); describe("planner", () => { diff --git a/tests/responses/protocol-chat-ir-parity.test.ts b/tests/responses/protocol-chat-ir-parity.test.ts index a2c003ee189..515673d274b 100644 --- a/tests/responses/protocol-chat-ir-parity.test.ts +++ b/tests/responses/protocol-chat-ir-parity.test.ts @@ -37,15 +37,20 @@ function comparable(parsed: OcxParsedRequest): unknown { } function viaBridge(body: Body): OcxParsedRequest { - return parseRequest(chatCompletionsToResponsesBody(structuredClone(body))); + return parseRequest(chatCompletionsToResponsesBody(clone(body))); } function viaDirect(body: Body): OcxParsedRequest { - const source = structuredClone(body); + 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, @@ -186,6 +191,11 @@ const CORPUS: Record = { 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", () => { @@ -197,28 +207,28 @@ describe("Chat -> IR direct decode parity with the Responses bridge", () => { 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(structuredClone(body)); - expect(chatSettingsToResponsesBody(structuredClone(body))).toEqual({ ...settings, input: [] }); + 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(structuredClone(body), MODEL); - expect(chatIrRouteEvidence(decoded)).toEqual(evidenceFromBody(chatCompletionsToResponsesBody(structuredClone(body)))); + 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(structuredClone(body), MODEL); - const renamed = { ...structuredClone(body), model: "google-antigravity/other-model" }; + 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 = structuredClone(CORPUS["tool calls, results with images, held mid-batch instruction and reasoning"]!); + 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; @@ -244,9 +254,9 @@ describe("Chat -> IR direct decode refuses what the bridge refuses", () => { for (const [name, body] of Object.entries(INVALID)) { test(name, () => { let bridgeMessage: string | undefined; - try { chatCompletionsToResponsesBody(structuredClone(body)); } catch (err) { bridgeMessage = (err as Error).message; } + try { chatCompletionsToResponsesBody(clone(body)); } catch (err) { bridgeMessage = (err as Error).message; } expect(bridgeMessage).toBeDefined(); - expect(() => chatConversationToIr(structuredClone(body), MODEL)).toThrow(bridgeMessage!); + expect(() => chatConversationToIr(clone(body), MODEL)).toThrow(bridgeMessage!); }); } }); From 339558d8c6a2c60f5503193b641e9bdc0f2e4fac Mon Sep 17 00:00:00 2001 From: JUN Date: Wed, 14 Oct 2026 04:23:35 +0900 Subject: [PATCH 4/4] fix(protocols): decline the direct lane only while cache diagnostics record The cache diagnostic hook is registered whenever its module loads; recording is gated by OPENCODEX_CACHE_DEBUG. Checking registration alone declined every request and made the lane unreachable. The hook now reports its enabled state. Refs #7079 --- src/server/request-log.ts | 6 ++++-- src/usage/cache-diagnostic.ts | 2 ++ ...rotocol-antigravity-direct-eligibility.test.ts | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/server/request-log.ts b/src/server/request-log.ts index edf2670e911..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,9 +106,9 @@ function cacheDiagnosticHooks(): CacheDiagnosticHooks | undefined { return (globalThis as Record)[CACHE_DIAGNOSTIC_HOOK]; } -/** Whether the cache diagnostic recorder is registered and will observe inbound bodies. */ +/** Whether the cache diagnostic recorder will record inbound bodies (`OPENCODEX_CACHE_DEBUG=1`). */ export function cacheDiagnosticsObserving(): boolean { - return cacheDiagnosticHooks() !== undefined; + return cacheDiagnosticHooks()?.enabled() === true; } export interface RequestLogContext extends UpstreamErrorDiagnostics { 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/tests/responses/protocol-antigravity-direct-eligibility.test.ts b/tests/responses/protocol-antigravity-direct-eligibility.test.ts index 08e3496517f..0f7cad95f73 100644 --- a/tests/responses/protocol-antigravity-direct-eligibility.test.ts +++ b/tests/responses/protocol-antigravity-direct-eligibility.test.ts @@ -4,6 +4,8 @@ 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"; @@ -103,6 +105,19 @@ describe("antigravityDirectDeclineReason", () => { 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", () => {