Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
73 changes: 73 additions & 0 deletions devlog/_plan/261014_antigravity_direct_protocols/000_plan.md
Original file line number Diff line number Diff line change
@@ -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`.
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/guides/protocol-paths.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,9 @@
"protocol-direct-encoders-stall-disabled.test.ts": "responses",
"protocol-direct-encoders-local-upstream.test.ts": "responses",
"protocol-direct-encoders-messages.test.ts": "responses",
"protocol-chat-ir-parity.test.ts": "responses",
"protocol-antigravity-direct-chat.test.ts": "responses",
"protocol-antigravity-direct-eligibility.test.ts": "responses",
"chat-native-combo.test.ts": "responses", "jev-initial-effort-wire.test.ts": "responses",
"messages-native-eligibility.test.ts": "responses",
"responses-first-send-reservation.test.ts": "responses", "responses-first-send-spend-policy.test.ts": "responses", "responses-dispatch-receipt.test.ts": "responses", "responses-hosted-send-hop.test.ts": "responses", "responses-hosted-send-producer.test.ts": "responses", "responses-hosted-send-reservation.test.ts": "responses", "responses-hosted-send-round-parity.test.ts": "responses", "responses-hosted-send-unsent-hop.test.ts": "responses", "responses-hosted-send-ws.test.ts": "responses",
Expand Down
36 changes: 30 additions & 6 deletions src/chat/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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 };
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -637,6 +663,4 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec {

const text = responseFormatToText(raw.response_format);
if (text) body.text = text;

return body;
}
2 changes: 2 additions & 0 deletions src/config/schema/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
},
};
Expand Down
Loading
Loading