From fcd0b69cdb70f40abfe170d94c7c2954fbf4d665 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 13:42:26 +0000 Subject: [PATCH 001/102] fix(responses): bridge routed tool search passthrough --- .../src/content/docs/ja/reference/adapters.md | 9 +- .../src/content/docs/ko/reference/adapters.md | 13 +- .../src/content/docs/reference/adapters.md | 13 +- .../content/docs/zh-cn/reference/adapters.md | 11 +- src/adapters/base.ts | 2 + src/adapters/openai-responses.ts | 10 + src/responses/parser.ts | 12 +- src/responses/tool-search-compat.ts | 291 ++++++++++ src/server/responses-tool-search-repair.ts | 197 +++++++ src/server/responses/core.ts | 20 +- structure/04_transports-and-sidecars.md | 8 + tests/openai-responses-passthrough.test.ts | 30 + tests/responses-parser.test.ts | 22 + tests/responses-tool-search-repair.test.ts | 518 ++++++++++++++++++ 14 files changed, 1134 insertions(+), 22 deletions(-) create mode 100644 src/responses/tool-search-compat.ts create mode 100644 src/server/responses-tool-search-repair.ts create mode 100644 tests/responses-tool-search-repair.test.ts diff --git a/docs-site/src/content/docs/ja/reference/adapters.md b/docs-site/src/content/docs/ja/reference/adapters.md index e637c4cc26..54bd07eef8 100644 --- a/docs-site/src/content/docs/ja/reference/adapters.md +++ b/docs-site/src/content/docs/ja/reference/adapters.md @@ -43,8 +43,13 @@ interface ProviderAdapter { ## `openai-responses` -**対象:** OpenAI **Responses API**。**`passthrough: true`** — 元のリクエスト本文をそのまま渡し、レスポンスを **変換せずに** ストリーミングします。 -**認証:** `forward`(呼び出し元ヘッダー中継)または `key`。 +**対象:** OpenAI **Responses API**。**`passthrough: true`** — 通常は元のリクエストとレスポンスをそのまま渡し、ルーティング先ゲートウェイに必要な限定的な互換変換だけを適用します。 +**認証:** canonical OpenAI `forward` は安全な呼び出し元ヘッダー許可リストだけを中継します。非 canonical な `forward` は呼び出し元の authorization を中継せず、設定済みの静的ヘッダーだけを使用します。`key` は設定済み provider key を使用します。 + +非 canonical な Responses ゲートウェイには、Codex のクライアント実行型 `tool_search` +宣言を既存の公開 function tool と衝突しない名前で送り、対応するリクエスト履歴と JSON/SSE +function call をクライアント向けの非公開 `tool_search` ライフサイクルに復元します。 +canonical OpenAI forward はネイティブな非公開型を維持します。 `key` 認証では、[`retryOn429`](/ja/reference/configuration/) もここに適用されます: プリストリームの 429 は、翻訳された `openai-chat` / Anthropic リクエスト経路と同様に、他の処理やフェイルオーバーに diff --git a/docs-site/src/content/docs/ko/reference/adapters.md b/docs-site/src/content/docs/ko/reference/adapters.md index 58c61cd7e4..06eedf2773 100644 --- a/docs-site/src/content/docs/ko/reference/adapters.md +++ b/docs-site/src/content/docs/ko/reference/adapters.md @@ -49,9 +49,16 @@ interface ProviderAdapter { ## `openai-responses` -**대상:** OpenAI **Responses API**. **`passthrough: true`** — 원본 요청 본문을 전달하고 응답을 -**변환하지 않은 채** 스트리밍합니다. -**인증:** `forward`(호출자 헤더 중계) 또는 `key`. +**대상:** OpenAI **Responses API**. **`passthrough: true`** — 일반적으로 원본 요청과 응답을 +그대로 전달하되, 라우팅된 게이트웨이에 필요한 좁은 호환성 변환만 적용합니다. +**인증:** 정규 OpenAI `forward`는 안전한 호출자 헤더 허용 목록만 중계합니다. 비정규 +`forward`는 호출자 authorization을 중계하지 않고 설정된 정적 헤더만 사용하며, `key`는 +설정된 provider 키를 사용합니다. + +비정규 Responses 게이트웨이에는 Codex의 클라이언트 실행형 `tool_search` 선언을 공개 function +도구와 충돌하지 않는 이름으로 전달합니다. 일치하는 요청 기록과 JSON/SSE function call은 +클라이언트용 비공개 `tool_search` 수명 주기로 복원합니다. 정규 OpenAI forward 경로는 +네이티브 비공개 타입을 그대로 유지합니다. `key` 인증에서는 [`retryOn429`](/ko/reference/configuration/)도 여기에 적용됩니다: 사전 스트림 429는 번역된 `openai-chat`/Anthropic 요청 경로와 동일하게 다른 처리나 페일오버보다 먼저 diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 93a1c42f63..0d9e79597b 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -49,9 +49,16 @@ provider — xAI, Kimi, DeepSeek, GLM, Groq, OpenRouter, Ollama (local & cloud), ## `openai-responses` -**Targets:** the OpenAI **Responses API**. **`passthrough: true`** — forwards the raw request body and -streams the response back **untranslated**. -**Auth:** `forward` (relay the caller's headers) or `key`. +**Targets:** the OpenAI **Responses API**. **`passthrough: true`** — normally forwards the raw request +body and response, with narrow compatibility rewrites for routed gateways. +**Auth:** canonical OpenAI `forward` relays only the safe caller-header allowlist; noncanonical +`forward` uses configured static headers without relaying caller authorization; `key` uses the +configured provider key. + +Noncanonical Responses gateways receive Codex's client-executed `tool_search` declaration as a +collision-safe public function tool. Matching request history and JSON/SSE function calls are +translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward +keeps the native private type unchanged. For `key` auth, [`retryOn429`](/reference/configuration/) applies here too: a pre-stream 429 waits and replays the identical request on the same key before any other handling, exactly like diff --git a/docs-site/src/content/docs/zh-cn/reference/adapters.md b/docs-site/src/content/docs/zh-cn/reference/adapters.md index dc39d307b7..16585c6946 100644 --- a/docs-site/src/content/docs/zh-cn/reference/adapters.md +++ b/docs-site/src/content/docs/zh-cn/reference/adapters.md @@ -45,9 +45,14 @@ interface ProviderAdapter { ## `openai-responses` -**目标:** OpenAI **Responses API**。**`passthrough: true`** —— 转发原始请求 body,并把响应 -**不经转换**地流式传回。 -**认证:** `forward`(转发调用方 header)或 `key`。 +**目标:** OpenAI **Responses API**。**`passthrough: true`** —— 通常原样转发请求与响应,仅对 +路由网关应用范围有限的兼容性转换。 +**认证:** 规范 OpenAI `forward` 只转发安全的调用方 header allowlist;非规范 `forward` 不会 +转发调用方 authorization,只使用已配置的静态 header;`key` 使用已配置的 provider key。 + +对于非规范 Responses 网关,Codex 的客户端执行型 `tool_search` 声明会作为公共 function tool +以不与现有 function 名称冲突的方式发送;匹配的请求历史和 JSON/SSE function call 会恢复为 +客户端私有的 `tool_search` 生命周期。规范 OpenAI forward 路径仍保持原生私有类型不变。 使用 `key` 认证时,[`retryOn429`](/zh-cn/reference/configuration/) 同样适用:流开始前的 429 会等待并先于其他处理或故障转移,在相同 key 上重放完全相同请求,与翻译后的 diff --git a/src/adapters/base.ts b/src/adapters/base.ts index 06b5f6f087..dda78b8640 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -52,6 +52,8 @@ export interface AdapterRequest { body: string; /** Custom-tool names actually lowered to upstream function calls while building this request. */ convertedRoutedCustomToolNames?: ReadonlySet; + /** Client tool-search names actually lowered to upstream function calls for this request. */ + convertedRoutedToolSearchNames?: ReadonlySet; /** Releases observation of a serialized request body after its final fetch attempt settles. */ releaseBodyObservation?: () => void; /** Exact reasoning parameter emitted by the adapter, for request-log diagnostics only. */ diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index a8bbab8bfd..7bc0bf9cec 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -11,6 +11,7 @@ import { OCX_REASONING_PREFIX } from "../responses/reasoning-envelope"; import { modelRecordValue } from "../reasoning-effort"; import type { TranslatorBudget } from "../lib/translator-budget"; import { rewriteRoutedCustomToolsForUpstream } from "../responses/custom-tool-compat"; +import { rewriteRoutedToolSearchForUpstream } from "../responses/tool-search-compat"; import { openaiResponsesUrl } from "./openai-responses-url"; import { createAdapterTierMetadata, @@ -1468,6 +1469,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const forward = provider.authMode === "forward"; let convertedRoutedCustomToolNames: Set | undefined; + let convertedRoutedToolSearchNames: Set | undefined; const unexpandedMiss = !!parsed.previousResponseId && parsed._previousResponseInputExpanded !== true; let outBody = stripPreviousResponseId( parsed._rawBody, @@ -1522,6 +1524,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } + if (!isCanonicalOpenAiForwardProvider(provider)) { + // Run after custom-tool lowering so the search compatibility layer can choose a + // collision-free public function name against the final routed function catalog. + const rewritten = rewriteRoutedToolSearchForUpstream(outBody); + outBody = rewritten.body; + convertedRoutedToolSearchNames = rewritten.names; + } const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true }))))))); const finalBody = stripDisabledReasoningSummaries( normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId), @@ -1549,6 +1558,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): body, releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), + ...(convertedRoutedToolSearchNames ? { convertedRoutedToolSearchNames } : {}), ...(tierLog ? { tierLog } : {}), }; }, diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 7c2393bd2e..76bb3a30a1 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -20,6 +20,7 @@ import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; +import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat"; function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -214,15 +215,8 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { // Expose as a function so chat models can call it; the bridge relays it as a tool_search_call. out.push({ name: "tool_search", - description: (t.description as string) ?? "Search for additional tools to load for the next turn.", - parameters: (isObj(t.parameters) ? t.parameters : { - type: "object", - properties: { - query: { type: "string", description: "Search query for tools to load." }, - limit: { type: "number", description: "Maximum number of tools to return." }, - }, - required: ["query"], - }) as Record, + description: toolSearchDescription(t), + parameters: normalizeParameters(toolSearchParameters(t)), toolSearch: true, }); } diff --git a/src/responses/tool-search-compat.ts b/src/responses/tool-search-compat.ts new file mode 100644 index 0000000000..4f40f23aee --- /dev/null +++ b/src/responses/tool-search-compat.ts @@ -0,0 +1,291 @@ +export const TOOL_SEARCH_FUNCTION_NAME = "tool_search"; +export const TOOL_SEARCH_DEFAULT_DESCRIPTION = "Search for additional tools to load for the next turn."; +const TOOL_SEARCH_WIRE_ALIAS_PREFIX = "opencodex_tool_search"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +export function toolSearchDescription(tool: unknown): string { + return isPlainObject(tool) && typeof tool.description === "string" + ? tool.description + : TOOL_SEARCH_DEFAULT_DESCRIPTION; +} + +export function toolSearchParameters(tool: unknown): Record { + if (isPlainObject(tool) && isPlainObject(tool.parameters)) return tool.parameters; + // Fresh per parse/build: downstream normalizers are allowed to clone or extend schemas, and a + // shared mutable default would couple otherwise unrelated requests. + return { + type: "object", + properties: { + query: { type: "string", description: "Search query for tools to load." }, + limit: { type: "number", description: "Maximum number of tools to return." }, + }, + required: ["query"], + }; +} + +function collectToolDeclarationNames(tools: unknown[], names: Set, namespace?: string): void { + for (const tool of tools) { + if (!isPlainObject(tool) || tool.type === "tool_search") continue; + if (tool.type === "function" && isPlainObject(tool.function) && typeof tool.function.name === "string") { + names.add(tool.function.name); + } + if (typeof tool.name === "string") { + names.add(tool.name); + if (namespace) names.add(`${namespace}__${tool.name}`); + } + if (tool.type === "namespace" && Array.isArray(tool.tools)) { + collectToolDeclarationNames(tool.tools, names, typeof tool.name === "string" ? tool.name : undefined); + } + } +} + +function declaredToolNames(body: Record): Set { + const names = new Set(); + if (Array.isArray(body.tools)) collectToolDeclarationNames(body.tools, names); + if (Array.isArray(body.input)) { + for (const item of body.input) { + if (isPlainObject(item) && item.type === "additional_tools" && Array.isArray(item.tools)) { + collectToolDeclarationNames(item.tools, names); + } + } + } + return names; +} + +function hasToolSearchDeclaration(tools: unknown[]): boolean { + return tools.some(tool => isPlainObject(tool) && tool.type === "tool_search"); +} + +function chooseToolSearchWireName(usedNames: ReadonlySet): string { + if (!usedNames.has(TOOL_SEARCH_FUNCTION_NAME)) return TOOL_SEARCH_FUNCTION_NAME; + if (!usedNames.has(TOOL_SEARCH_WIRE_ALIAS_PREFIX)) return TOOL_SEARCH_WIRE_ALIAS_PREFIX; + for (let suffix = 2; ; suffix++) { + const candidate = `${TOOL_SEARCH_WIRE_ALIAS_PREFIX}_${suffix}`; + if (!usedNames.has(candidate)) return candidate; + } +} + +function rewriteToolList(tools: unknown[], wireName: string): { tools: unknown[]; changed: boolean } { + let changed = false; + const rewritten = tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "tool_search") return tool; + const { + execution: _execution, + defer_loading: _deferLoading, + ...rest + } = tool; + changed = true; + return { + ...rest, + type: "function", + name: wireName, + description: toolSearchDescription(tool), + parameters: toolSearchParameters(tool), + }; + }); + return changed ? { tools: rewritten, changed: true } : { tools, changed: false }; +} + +function rewriteToolChoice(choice: unknown, wireName: string): unknown { + if (!isPlainObject(choice)) return choice; + if (choice.type === "tool_search") { + return { type: "function", name: wireName }; + } + if (choice.type !== "allowed_tools" || !Array.isArray(choice.tools)) return choice; + let changed = false; + const tools = choice.tools.map(tool => { + if (!isPlainObject(tool) || tool.type !== "tool_search") return tool; + changed = true; + return { type: "function", name: wireName }; + }); + return changed ? { ...choice, tools } : choice; +} + +function toolChoiceAllowsPrivateSearch(choice: unknown): boolean { + if (choice === undefined || choice === null || choice === "auto" || choice === "required") return true; + if (choice === "none") return false; + if (!isPlainObject(choice)) return true; + if (choice.type === "tool_search") return true; + if (choice.type === "allowed_tools" && Array.isArray(choice.tools)) { + return choice.tools.some(tool => isPlainObject(tool) && tool.type === "tool_search"); + } + // A forced ordinary function named `tool_search` is distinct from the private tool kind. + return false; +} + +function upstreamToolSearchItemId(id: unknown): unknown { + if (typeof id !== "string") return id; + return id.startsWith("tsc_") ? `fc_${id.slice(4)}` : id; +} + +function toolSearchArgumentsText(value: unknown): string { + if (typeof value === "string") return value; + return JSON.stringify(isPlainObject(value) ? value : {}); +} + +function toolSearchOutputText(value: Record): string { + const payload: Record = { + tools: Array.isArray(value.tools) ? value.tools : [], + }; + if (typeof value.status === "string") payload.status = value.status; + return JSON.stringify(payload); +} + +function rewriteHistoryItem(item: Record, wireName: string): Record { + if (item.type === "tool_search_call") { + const { + execution: _execution, + arguments: argumentsValue, + id, + ...rest + } = item; + return { + ...rest, + type: "function_call", + ...(id === undefined ? {} : { id: upstreamToolSearchItemId(id) }), + name: wireName, + arguments: toolSearchArgumentsText(argumentsValue), + }; + } + if (item.type === "tool_search_output") { + const { + execution: _execution, + id: _id, + tools: _tools, + status: _status, + ...rest + } = item; + return { + ...rest, + type: "function_call_output", + output: toolSearchOutputText(item), + }; + } + return item; +} + +/** + * Third-party Responses gateways generally implement the public function-tool schema, not Codex's + * private client-executed `tool_search` declaration. Lower only request catalogs sent to a + * noncanonical upstream; the caller-facing response is restored separately. + */ +export function rewriteRoutedToolSearchForUpstream(body: unknown): { + body: unknown; + names: Set; +} { + const names = new Set(); + if (!isPlainObject(body)) return { body, names }; + + const topLevelSearch = Array.isArray(body.tools) && hasToolSearchDeclaration(body.tools); + const inputItems = Array.isArray(body.input) ? body.input : []; + const additionalSearch = inputItems.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + && hasToolSearchDeclaration(item.tools)); + const historySearch = inputItems.some(item => + isPlainObject(item) + && (item.type === "tool_search_call" || item.type === "tool_search_output")); + if (!topLevelSearch && !additionalSearch && !historySearch) return { body, names }; + + const wireName = chooseToolSearchWireName(declaredToolNames(body)); + const declarationChanged = topLevelSearch || additionalSearch; + + let tools = body.tools; + if (Array.isArray(tools)) { + const result = rewriteToolList(tools, wireName); + tools = result.tools; + } + + let input = body.input; + if (Array.isArray(input)) { + input = input.map(item => { + if (!isPlainObject(item)) return item; + if (item.type === "additional_tools" && Array.isArray(item.tools)) { + const result = rewriteToolList(item.tools, wireName); + return result.changed ? { ...item, tools: result.tools } : item; + } + return rewriteHistoryItem(item, wireName); + }); + } + + if (declarationChanged && toolChoiceAllowsPrivateSearch(body.tool_choice)) names.add(wireName); + const toolChoice = declarationChanged ? rewriteToolChoice(body.tool_choice, wireName) : body.tool_choice; + return { + body: { + ...body, + ...(tools !== body.tools ? { tools } : {}), + ...(input !== body.input ? { input } : {}), + ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}), + }, + names, + }; +} + +export function toolSearchItemId(id: unknown): unknown { + if (typeof id !== "string") return id; + return id.startsWith("fc_") ? `tsc_${id.slice(3)}` : id; +} + +function toolSearchArguments(value: unknown): Record { + if (isPlainObject(value)) return value; + if (typeof value !== "string" || value.length === 0) return {}; + try { + const parsed: unknown = JSON.parse(value); + return isPlainObject(parsed) ? parsed : {}; + } catch { + return {}; + } +} + +export function restoreRoutedToolSearchCalls( + value: unknown, + names: ReadonlySet, +): { value: unknown; changed: boolean } { + if (Array.isArray(value)) { + let changed = false; + const restored = value.map(entry => { + const result = restoreRoutedToolSearchCalls(entry, names); + changed ||= result.changed; + return result.value; + }); + return changed ? { value: restored, changed: true } : { value, changed: false }; + } + if (!isPlainObject(value)) return { value, changed: false }; + + let changed = false; + const restored: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const result = restoreRoutedToolSearchCalls(entry, names); + restored[key] = result.value; + changed ||= result.changed; + } + + if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) { + restored.type = "tool_search_call"; + restored.id = toolSearchItemId(value.id); + restored.execution = "client"; + restored.arguments = toolSearchArguments(value.arguments); + delete restored.name; + changed = true; + } + return changed ? { value: restored, changed: true } : { value, changed: false }; +} + +export function restoreRoutedToolSearchCallsInJson( + text: string, + names: ReadonlySet, +): string { + if (names.size === 0) return text; + let payload: unknown; + try { + payload = JSON.parse(text); + } catch { + return text; + } + const restored = restoreRoutedToolSearchCalls(payload, names); + return restored.changed ? JSON.stringify(restored.value) : text; +} diff --git a/src/server/responses-tool-search-repair.ts b/src/server/responses-tool-search-repair.ts new file mode 100644 index 0000000000..71f1c0001e --- /dev/null +++ b/src/server/responses-tool-search-repair.ts @@ -0,0 +1,197 @@ +import { + isTranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { + restoreRoutedToolSearchCalls, +} from "../responses/tool-search-compat"; +import { + replaceSseDataPayload, + sseDataPayload, + type SseBlockRewrite, +} from "./sse-payload-rewrite"; + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +type PendingArgumentBlock = { + block: string; + itemId?: string; + outputIndex?: number; + retainedBytes: number; +}; + +const MAX_PENDING_ARGUMENT_FRAMES = 256; +const MAX_PENDING_ARGUMENT_BYTES = 1024 * 1024; + +/** + * Public Responses gateways stream a lowered search as a normal function lifecycle. Codex expects + * only `tool_search_call` items, so classify each item before dropping its function-argument + * frames. Unknown early argument frames stay bounded until their item arrives. + */ +export function createRoutedToolSearchRestoreBlockRewrite( + names: ReadonlySet, + budget?: TranslatorBudget, +): SseBlockRewrite { + const routedItemIds = new Set(); + const ordinaryItemIds = new Set(); + let pendingArguments: PendingArgumentBlock[] = []; + let pendingArgumentBytes = 0; + let passthrough = false; + let disposed = false; + + const releaseAll = (): void => { + if (disposed) return; + disposed = true; + if (pendingArgumentBytes > 0) { + budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" }); + } + pendingArguments = []; + pendingArgumentBytes = 0; + routedItemIds.clear(); + ordinaryItemIds.clear(); + }; + + const retainPending = ( + block: string, + itemId: string | undefined, + outputIndex: number | undefined, + ): readonly string[] | null => { + const retainedBytes = Buffer.byteLength(block, "utf8"); + const overflow = pendingArguments.length >= MAX_PENDING_ARGUMENT_FRAMES + || pendingArgumentBytes + retainedBytes > MAX_PENDING_ARGUMENT_BYTES; + if (overflow) { + const flushed = [...pendingArguments.map(pending => pending.block), block]; + if (pendingArgumentBytes > 0) { + budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" }); + } + pendingArguments = []; + pendingArgumentBytes = 0; + passthrough = true; + routedItemIds.clear(); + ordinaryItemIds.clear(); + return flushed; + } + if (retainedBytes > 0) { + try { + budget?.chargeRetained(retainedBytes, { kind: "retained_collectors" }); + } catch (error) { + if (!isTranslatorBudgetExceededError(error)) throw error; + const flushed = [...pendingArguments.map(pending => pending.block), block]; + if (pendingArgumentBytes > 0) { + budget?.releaseRetained(pendingArgumentBytes, { kind: "retained_collectors" }); + } + pendingArguments = []; + pendingArgumentBytes = 0; + passthrough = true; + routedItemIds.clear(); + ordinaryItemIds.clear(); + return flushed; + } + } + pendingArguments.push({ block, itemId, outputIndex, retainedBytes }); + pendingArgumentBytes += retainedBytes; + return null; + }; + + const takePending = ( + itemId: string | undefined, + outputIndex: number | undefined, + ): string[] => { + const matched: PendingArgumentBlock[] = []; + const remaining: PendingArgumentBlock[] = []; + for (const pending of pendingArguments) { + const matches = pending.itemId !== undefined + ? itemId !== undefined && pending.itemId === itemId + : outputIndex !== undefined && pending.outputIndex === outputIndex; + (matches ? matched : remaining).push(pending); + } + pendingArguments = remaining; + const retainedBytes = matched.reduce((total, pending) => total + pending.retainedBytes, 0); + if (retainedBytes > 0) { + budget?.releaseRetained(retainedBytes, { kind: "retained_collectors" }); + pendingArgumentBytes = Math.max(0, pendingArgumentBytes - retainedBytes); + } + return matched.map(pending => { + if (pending.itemId !== undefined || itemId === undefined) return pending.block; + const payload = sseDataPayload(pending.block); + if (payload === null) return pending.block; + try { + const parsed: unknown = JSON.parse(payload); + return isPlainObject(parsed) + ? replaceSseDataPayload(pending.block, JSON.stringify({ ...parsed, item_id: itemId })) + : pending.block; + } catch { + return pending.block; + } + }); + }; + + const rewrite: SseBlockRewrite = (block: string): readonly string[] => { + if (disposed) return [block]; + if (passthrough) return [block]; + const payload = sseDataPayload(block); + if (payload === null || payload === "[DONE]") return [block]; + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return [block]; + } + if (!isPlainObject(parsed)) return [block]; + + const type = typeof parsed.type === "string" ? parsed.type : ""; + const outputIndex = typeof parsed.output_index === "number" + && Number.isInteger(parsed.output_index) + && parsed.output_index >= 0 + ? parsed.output_index + : undefined; + if ( + (type === "response.output_item.added" || type === "response.output_item.done") + && isPlainObject(parsed.item) + && parsed.item.type === "function_call" + && typeof parsed.item.name === "string" + ) { + const itemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined; + const routed = names.has(parsed.item.name); + if (itemId) { + if (routed) { + routedItemIds.add(itemId); + ordinaryItemIds.delete(itemId); + } else { + ordinaryItemIds.add(itemId); + routedItemIds.delete(itemId); + } + } + const pending = takePending(itemId, outputIndex); + const restored = routed ? restoreRoutedToolSearchCalls(parsed, names) : { value: parsed, changed: false }; + const restoredBlock = restored.changed + ? replaceSseDataPayload(block, JSON.stringify(restored.value)) + : block; + if (type === "response.output_item.done" && itemId) { + routedItemIds.delete(itemId); + ordinaryItemIds.delete(itemId); + } + return routed ? [restoredBlock] : [...pending, restoredBlock]; + } + + const itemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined; + const argumentEvent = type === "response.function_call_arguments.delta" + || type === "response.function_call_arguments.done"; + if (argumentEvent && (!itemId || (!routedItemIds.has(itemId) && !ordinaryItemIds.has(itemId)))) { + return retainPending(block, itemId, outputIndex) ?? []; + } + if (argumentEvent && itemId && routedItemIds.has(itemId)) return []; + + const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete"; + if (!terminal) return [block]; + const restored = restoreRoutedToolSearchCalls(parsed, names); + releaseAll(); + return restored.changed + ? [replaceSseDataPayload(block, JSON.stringify(restored.value))] + : [block]; + }; + rewrite.dispose = releaseAll; + return rewrite; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 59d7f1bfd4..f142f03c32 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -265,6 +265,8 @@ import { } from "../sse-payload-rewrite"; import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair"; +import { restoreRoutedToolSearchCallsInJson } from "../../responses/tool-search-compat"; +import { createRoutedToolSearchRestoreBlockRewrite } from "../responses-tool-search-repair"; import { collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, @@ -2360,6 +2362,7 @@ async function handleResponsesInner( ? new Map() : imageGenToolCallAliases(toolBridgeMaps.toolNsMap, parsed._rawBody, translatorBudget); const routedCustomToolNames = new Set(); + const routedToolSearchNames = new Set(); // Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with // previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex // REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY @@ -2393,6 +2396,12 @@ async function handleResponsesInner( if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name); } } + for (const name of request.convertedRoutedToolSearchNames ?? []) { + // The adapter already keeps this set empty when tool_choice forbids the private search. + // Its wire name may be collision-aliased, so comparing it to the caller-facing name here + // would incorrectly disable restoration for the exact ambiguous-name case the alias fixes. + routedToolSearchNames.add(name); + } // #1700: the bridged paths refuse a call to a tool the request never declared // (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed // provider's top-level `apply_patch` — which under Codex code mode exists only as a nested @@ -2855,6 +2864,9 @@ async function handleResponsesInner( routedCustomToolNames.size > 0 ? createRoutedCustomToolRestoreBlockRewrite(routedCustomToolNames, translatorBudget) : undefined, + routedToolSearchNames.size > 0 + ? createRoutedToolSearchRestoreBlockRewrite(routedToolSearchNames, translatorBudget) + : undefined, githubCopilotRepairEnabled ? createGithubCopilotResponsesBlockRewrite(translatorBudget) : undefined, @@ -3052,9 +3064,13 @@ async function handleResponsesInner( restoreImageGenCallsInJson(text, imageGenCallAliases), routedCustomToolNames, ); + const restoredToolSearch = restoreRoutedToolSearchCallsInJson( + restored, + routedToolSearchNames, + ); const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair) - ? repairResponsesSnapshotJson(restored, outboundRequestBody) - : restored; + ? repairResponsesSnapshotJson(restoredToolSearch, outboundRequestBody) + : restoredToolSearch; const modelRewritten = parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId ? rewriteResponsesModelJson(backfillResponsesFieldsJson(repaired), parsed._responseModelId) : backfillResponsesFieldsJson(repaired); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 717edc2a01..e89922adff 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -31,6 +31,14 @@ Responses-compatible streaming output. - 다른 대안 대신 이 방식을 선택한 이유: Model guidance is not an enforcement boundary, while automatic translation would invent executable caller intent and arguments after generation. - 장점, 단점 및 영향: Streaming and non-streaming routed responses now fail closed with an actionable provider-contract error; providers that emit aliases they never advertised must correct their adapter mapping instead of relying on client abort behavior. +[Decision Log] +- 목적과 의도: Keep Codex client-side deferred tool discovery usable through third-party Responses-compatible gateways that implement public function tools but reject the private `tool_search` declaration. +- 기존 구현 및 제약 조건: The chat translation path already exposed search as a function and bridged its call back to `tool_search_call`; passthrough only promoted definitions returned by an earlier search, so it could not initiate discovery on a strict third-party Responses endpoint. +- 검토한 주요 대안: Require every gateway to implement Codex-private tool types; route affected models through `openai-chat`; lower the declaration only; lower the noncanonical request and restore both JSON and SSE response lifecycles. +- 선택한 방식: On noncanonical Responses passthrough only, lower an actually declared `tool_search` to a collision-free public function name, translate its replayed call/output history to public function pairs, record only caller-authorized request-local conversions, and restore matching JSON/SSE calls to client `tool_search_call` items. Canonical OpenAI forward remains byte-shape native. +- 다른 대안 대신 이 방식을 선택한 이유: Provider-specific workarounds fragment the contract, while unconditional restoration could turn an untrusted ordinary function call into a privileged client discovery action. +- 장점, 단점 및 영향: Strict third-party Responses gateways can start and continue deferred discovery without changing native ChatGPT behavior; ordinary same-named functions remain distinct, and the proxy performs a capped SSE lifecycle rewrite only when the request actually required compatibility translation. + The option-aware `openai` provider uses `openai-responses` with `authMode: "forward"`. Pool mode resolves main plus added accounts through affinity/quota/cooldown ownership; Direct forwards only the allowed Codex/OpenAI auth/session headers from the current request and short-circuits pool diff --git a/tests/openai-responses-passthrough.test.ts b/tests/openai-responses-passthrough.test.ts index c2e9d38a71..ed5c484d1a 100644 --- a/tests/openai-responses-passthrough.test.ts +++ b/tests/openai-responses-passthrough.test.ts @@ -346,6 +346,34 @@ describe("OpenAI Responses passthrough sanitization", () => { .not.toHaveProperty("defer_loading"); expect(namespace?.tools?.find(tool => tool.name === "deferred_read")) .not.toHaveProperty("defer_loading"); + expect(body.tools.find(tool => tool.name === "tool_search")).toMatchObject({ + type: "function", + name: "tool_search", + description: "Search deferred tools", + }); + }); + + test("noncanonical forward passthrough also lowers tool_search without caller credential relay", () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", + baseUrl: "https://provider.example/v1", + authMode: "forward", + headers: { authorization: "Bearer provider-static" }, + }); + const request = adapter.buildRequest({ + modelId: deferredToolBody.model, + context: { messages: [] }, + stream: true, + options: {}, + _rawBody: deferredToolBody, + }, { headers: new Headers({ authorization: "Bearer caller-secret" }) }); + const body = JSON.parse(request.body) as { tools: Array> }; + + expect(body.tools.find(tool => tool.name === "tool_search")).toMatchObject({ + type: "function", + name: "tool_search", + }); + expect(request.headers.authorization).toBe("Bearer provider-static"); }); test("canonical forward passthrough leaves tool-search loading to the native backend", () => { @@ -397,6 +425,8 @@ describe("OpenAI Responses passthrough sanitization", () => { "declared_deferred_read", "deferred_read", ]); + expect(additionalTools?.find(tool => tool.name === "tool_search")) + .toMatchObject({ type: "function", name: "tool_search" }); }); test("normalizes top-level function schemas in the serialized raw body (#745)", () => { diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index be6258f6a1..8b25b93447 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -31,6 +31,28 @@ describe("Responses parser", () => { ]); }); + test("normalizes tool_search parameter schemas to the same object-root contract", () => { + const parsed = parseRequest({ + model: "test-model", + input: "find a tool", + tools: [{ + type: "tool_search", + parameters: { properties: { query: { type: "string" } }, required: ["query"] }, + }], + }); + + expect(parsed.context.tools).toEqual([{ + name: "tool_search", + description: "Search for additional tools to load for the next turn.", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + }, + toolSearch: true, + }]); + }); + test("unwraps Chat-shaped function tools while retaining flat function tools", () => { const parameters = { type: "object", diff --git a/tests/responses-tool-search-repair.test.ts b/tests/responses-tool-search-repair.test.ts new file mode 100644 index 0000000000..31bd374d8c --- /dev/null +++ b/tests/responses-tool-search-repair.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, test } from "bun:test"; +import { + restoreRoutedToolSearchCallsInJson, + rewriteRoutedToolSearchForUpstream, +} from "../src/responses/tool-search-compat"; +import { createRoutedToolSearchRestoreBlockRewrite } from "../src/server/responses-tool-search-repair"; +import { handleResponses } from "../src/server/responses"; +import type { OcxConfig } from "../src/types"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; + +function frame(event: string, payload: Record): string { + return `event: ${event}\ndata: ${JSON.stringify({ type: event, ...payload })}`; +} + +function dataPayload(block: string): Record { + const line = block.split(/\r?\n/).find(entry => entry.startsWith("data:")); + if (!line) throw new Error("missing SSE data line"); + return JSON.parse(line.slice(5).trim()) as Record; +} + +const searchTool = { + type: "tool_search", + execution: "client", + description: "Search deferred tools", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + }, +}; + +function fixtureConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; +} + +describe("routed Responses tool-search compatibility", () => { + test("lowers top-level and Responses Lite declarations plus tool choice without mutating input", () => { + const raw = { + model: "deepseek-v4-flash", + input: [{ type: "additional_tools", tools: [searchTool] }], + tools: [searchTool, { type: "function", name: "ordinary", parameters: { type: "object" } }], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [{ type: "tool_search" }, { type: "function", name: "ordinary" }], + }, + }; + + const rewritten = rewriteRoutedToolSearchForUpstream(raw); + const body = rewritten.body as { + tools: Array>; + input: Array<{ tools: Array> }>; + tool_choice: { mode: string; tools: Array> }; + }; + + expect(rewritten.names).toEqual(new Set(["tool_search"])); + expect(rewritten.body).not.toBe(raw); + expect(raw.tools[0]).toHaveProperty("type", "tool_search"); + expect(body.tools[0]).toEqual({ + type: "function", + name: "tool_search", + description: "Search deferred tools", + parameters: searchTool.parameters, + }); + expect(body.input[0]?.tools[0]).toMatchObject({ type: "function", name: "tool_search" }); + expect(body.tool_choice.mode).toBe("required"); + expect(body.tool_choice.tools[0]).toEqual({ type: "function", name: "tool_search" }); + expect(body.tools[1]).toEqual(raw.tools[1]); + }); + + test("does not mark an ordinary same-named function for privileged restoration", () => { + const raw = { + tools: [{ type: "function", name: "tool_search", parameters: { type: "object" } }], + }; + const rewritten = rewriteRoutedToolSearchForUpstream(raw); + + expect(rewritten.body).toBe(raw); + expect(rewritten.names).toEqual(new Set()); + }); + + test("aliases a private search away from an ordinary same-named function", () => { + const raw = { + tools: [ + searchTool, + { type: "function", name: "tool_search", parameters: { type: "object" } }, + ], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "tool_search" }, + { type: "function", name: "tool_search" }, + ], + }, + }; + const rewritten = rewriteRoutedToolSearchForUpstream(raw); + const body = rewritten.body as { + tools: Array>; + tool_choice: { tools: Array> }; + }; + + expect(rewritten.names).toEqual(new Set(["opencodex_tool_search"])); + expect(body.tools[0]).toMatchObject({ type: "function", name: "opencodex_tool_search" }); + expect(body.tools[1]).toEqual(raw.tools[1]); + expect(body.tool_choice.tools).toEqual([ + { type: "function", name: "opencodex_tool_search" }, + { type: "function", name: "tool_search" }, + ]); + + const restored = JSON.parse(restoreRoutedToolSearchCallsInJson(JSON.stringify({ + output: [ + { type: "function_call", name: "opencodex_tool_search", arguments: "{}" }, + { type: "function_call", name: "tool_search", arguments: "{}" }, + ], + }), rewritten.names)) as { output: Array> }; + expect(restored.output[0]?.type).toBe("tool_search_call"); + expect(restored.output[1]).toMatchObject({ type: "function_call", name: "tool_search" }); + }); + + test("a forced ordinary same-named function does not authorize private-search restoration", () => { + const rewritten = rewriteRoutedToolSearchForUpstream({ + tools: [ + searchTool, + { type: "function", name: "tool_search", parameters: { type: "object" } }, + ], + tool_choice: { type: "function", name: "tool_search" }, + }); + const body = rewritten.body as { + tools: Array>; + tool_choice: Record; + }; + + expect(body.tools[0]).toMatchObject({ type: "function", name: "opencodex_tool_search" }); + expect(body.tool_choice).toEqual({ type: "function", name: "tool_search" }); + expect(rewritten.names).toEqual(new Set()); + }); + + test("rewrites prior private search history to public function call pairs", () => { + const previousTools = [{ type: "function", name: "deferred_read", parameters: { type: "object" } }]; + const rewritten = rewriteRoutedToolSearchForUpstream({ + tools: [searchTool], + input: [ + { + type: "tool_search_call", + id: "tsc_previous", + call_id: "call_previous", + execution: "client", + arguments: { query: "database" }, + status: "completed", + }, + { + type: "tool_search_output", + call_id: "call_previous", + execution: "client", + status: "completed", + tools: previousTools, + }, + ], + }); + const input = (rewritten.body as { input: Array> }).input; + + expect(input[0]).toEqual({ + type: "function_call", + id: "fc_previous", + call_id: "call_previous", + name: "tool_search", + arguments: '{"query":"database"}', + status: "completed", + }); + expect(input[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_previous", + }); + expect(JSON.parse(String(input[1]?.output))).toEqual({ tools: previousTools, status: "completed" }); + }); + + test("restores only the converted function name in bounded JSON", () => { + const restored = JSON.parse(restoreRoutedToolSearchCallsInJson(JSON.stringify({ + id: "resp_1", + output: [ + { type: "function_call", id: "fc_search", call_id: "call_search", name: "tool_search", arguments: '{"query":"database"}', status: "completed" }, + { type: "function_call", id: "fc_other", call_id: "call_other", name: "ordinary", arguments: "{}", status: "completed" }, + ], + }), new Set(["tool_search"]))) as { output: Array> }; + + expect(restored.output[0]).toEqual({ + type: "tool_search_call", + id: "tsc_search", + call_id: "call_search", + execution: "client", + arguments: { query: "database" }, + status: "completed", + }); + expect(restored.output[1]).toMatchObject({ type: "function_call", name: "ordinary" }); + }); + + test("restores the SSE item lifecycle and suppresses function argument frames", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: "fc_search", + delta: '{"query":', + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + + const added = rewrite(frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_search", + call_id: "call_search", + name: "tool_search", + arguments: "", + status: "in_progress", + }, + })); + expect(added).toHaveLength(1); + expect(dataPayload(added[0]!).item).toEqual({ + type: "tool_search_call", + id: "tsc_search", + call_id: "call_search", + execution: "client", + arguments: {}, + status: "in_progress", + }); + expect(budget.snapshot().currentBytes).toBe(0); + + expect(rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: "fc_search", + arguments: '{"query":"database"}', + }))).toEqual([]); + + const done = rewrite(frame("response.output_item.done", { + output_index: 0, + item: { + type: "function_call", + id: "fc_search", + call_id: "call_search", + name: "tool_search", + arguments: '{"query":"database"}', + status: "completed", + }, + })); + expect(dataPayload(done[0]!).item).toMatchObject({ + type: "tool_search_call", + id: "tsc_search", + arguments: { query: "database" }, + }); + rewrite.dispose?.(); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("replays an ordinary pending argument frame and releases unmatched terminal state", () => { + const budget = createTestTranslatorBudget(); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 1, + delta: '{"path":', + }))).toEqual([]); + const ordinary = rewrite(frame("response.output_item.added", { + output_index: 1, + item: { + type: "function_call", + id: "fc_regular", + call_id: "call_regular", + name: "ordinary", + arguments: "", + status: "in_progress", + }, + })); + expect(ordinary).toHaveLength(2); + expect(dataPayload(ordinary[0]!).item_id).toBe("fc_regular"); + expect(dataPayload(ordinary[1]!).item).toMatchObject({ type: "function_call", name: "ordinary" }); + + expect(rewrite(frame("response.function_call_arguments.delta", { + output_index: 9, + delta: "unmatched", + }))).toEqual([]); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + expect(rewrite(frame("response.completed", { + response: { id: "resp_done", status: "completed", output: [] }, + }))).toHaveLength(1); + expect(budget.snapshot().currentBytes).toBe(0); + }); + + test("falls back to raw pass-through when the pending collector budget is exhausted", () => { + const budget = createTestTranslatorBudget({ maxTurnBytes: 64 }); + const rewrite = createRoutedToolSearchRestoreBlockRewrite(new Set(["tool_search"]), budget); + const pending = frame("response.function_call_arguments.delta", { + output_index: 0, + delta: "x".repeat(256), + }); + + expect(rewrite(pending)).toEqual([pending]); + expect(budget.snapshot().currentBytes).toBe(0); + const added = frame("response.output_item.added", { + output_index: 0, + item: { + type: "function_call", + id: "fc_search", + call_id: "call_search", + name: "tool_search", + arguments: "", + }, + }); + expect(rewrite(added)).toEqual([added]); + }); + + test("handleResponses lowers and restores a streaming search call", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + const upstreamItem = { + type: "function_call", + id: "fc_search", + call_id: "call_search", + name: "tool_search", + arguments: '{"query":"database"}', + status: "completed", + }; + const upstream = [ + frame("response.output_item.added", { output_index: 0, item: { ...upstreamItem, arguments: "", status: "in_progress" } }), + frame("response.function_call_arguments.delta", { output_index: 0, item_id: "fc_search", delta: upstreamItem.arguments }), + frame("response.function_call_arguments.done", { output_index: 0, item_id: "fc_search", arguments: upstreamItem.arguments }), + frame("response.output_item.done", { output_index: 0, item: upstreamItem }), + frame("response.completed", { response: { id: "resp_1", status: "completed", output: [upstreamItem] } }), + "data: [DONE]", + ].join("\n\n") + "\n\n"; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(upstream, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "find a database tool" }] }], + tools: [ + searchTool, + { type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }, + ], + }), + }), fixtureConfig(), { model: "", provider: "" }); + const clientSse = await response.text(); + const outboundTools = outboundBody?.tools as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "tool_search" }); + expect(outboundTools?.[1]).toMatchObject({ type: "function", name: "exec" }); + expect(clientSse).toContain('"type":"tool_search_call"'); + expect(clientSse).toContain('"id":"tsc_search"'); + expect(clientSse).not.toContain("response.function_call_arguments"); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses restores a non-streaming search call", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ + id: "resp_json", + status: "completed", + output: [{ + type: "function_call", + id: "fc_search", + call_id: "call_search", + name: "tool_search", + arguments: '{"query":"database"}', + status: "completed", + }], + }), { headers: { "content-type": "application/json" } }); + }) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "find a database tool" }] }], + tools: [searchTool], + }), + }), fixtureConfig(), { model: "", provider: "" }); + const body = await response.json() as { output: Array> }; + const outboundTools = outboundBody?.tools as Array> | undefined; + + expect(outboundTools?.[0]).toMatchObject({ type: "function", name: "tool_search" }); + expect(body.output[0]).toEqual({ + type: "tool_search_call", + id: "tsc_search", + call_id: "call_search", + execution: "client", + arguments: { query: "database" }, + status: "completed", + }); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("handleResponses forwards second-turn search history in public function form", async () => { + const savedFetch = globalThis.fetch; + let outboundBody: Record | undefined; + globalThis.fetch = (async (_input, init) => { + outboundBody = JSON.parse(String(init?.body)) as Record; + return new Response(JSON.stringify({ id: "resp_next", status: "completed", output: [] }), { + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + try { + await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + tools: [searchTool], + input: [ + { + type: "tool_search_call", + id: "tsc_previous", + call_id: "call_previous", + execution: "client", + arguments: { query: "database" }, + status: "completed", + }, + { + type: "tool_search_output", + call_id: "call_previous", + execution: "client", + status: "completed", + tools: [{ type: "function", name: "deferred_read", parameters: { type: "object" } }], + }, + { role: "user", content: [{ type: "input_text", text: "use the loaded tool" }] }, + ], + }), + }), fixtureConfig(), { model: "", provider: "" }); + const input = outboundBody?.input as Array> | undefined; + + expect(input?.[0]).toMatchObject({ + type: "function_call", + id: "fc_previous", + call_id: "call_previous", + name: "tool_search", + }); + expect(typeof input?.[0]?.arguments).toBe("string"); + expect(input?.[1]).toMatchObject({ + type: "function_call_output", + call_id: "call_previous", + }); + expect(input?.some(item => item.type === "tool_search_call" || item.type === "tool_search_output")).toBe(false); + } finally { + globalThis.fetch = savedFetch; + } + }); + + test("tool_choice none prevents restoring an ignored upstream search call", async () => { + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(JSON.stringify({ + id: "resp_json", + status: "completed", + output: [{ + type: "function_call", + id: "fc_search", + call_id: "call_search", + name: "tool_search", + arguments: '{"query":"database"}', + status: "completed", + }], + }), { headers: { "content-type": "application/json" } })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "do not call tools" }] }], + tools: [searchTool], + tool_choice: "none", + }), + }), fixtureConfig(), { model: "", provider: "" }); + const body = await response.json() as { output: Array> }; + + expect(body.output[0]).toMatchObject({ + type: "function_call", + name: "tool_search", + }); + expect(body.output[0]).not.toHaveProperty("execution"); + } finally { + globalThis.fetch = savedFetch; + } + }); +}); From 108d8d0bf72d1c8b5db54ced55bbff8085234c34 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 15:33:02 +0000 Subject: [PATCH 002/102] fix(oauth): reject superseded login credential commits --- src/oauth/index.ts | 25 +++++++-- src/oauth/store.ts | 16 ++++-- tests/oauth-public-surface.test.ts | 81 ++++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+), 8 deletions(-) diff --git a/src/oauth/index.ts b/src/oauth/index.ts index 0162492f9a..8a18a0b007 100644 --- a/src/oauth/index.ts +++ b/src/oauth/index.ts @@ -322,6 +322,13 @@ export class OAuthReauthIdentityUnverifiedError extends Error { } } +class OAuthLoginSupersededError extends Error { + constructor() { + super("OAuth login was superseded before credential persistence"); + this.name = "OAuthLoginSupersededError"; + } +} + /** Project arbitrary OAuth failures onto the small, stable public error vocabulary. */ export function publicOAuthAuthenticationErrorMessage(error: unknown): string { if (error instanceof OAuthMutationBusyError) { @@ -1096,6 +1103,7 @@ interface RunLoginDeps { settleKiroLoginTransaction?: typeof settleKiroLoginTransaction; removeAccount?: typeof removeAccount; setActiveAccount?: typeof setActiveAccount; + assertCurrentOwner?: () => void; } /** Roll back only accounts created by this forced login, preserving concurrent refreshes of others. */ @@ -1145,6 +1153,7 @@ export async function runLogin( const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" }; const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction; try { + deps.assertCurrentOwner?.(); // Validate the provider row before credential persistence. A namespace claimed during the // credential write is handled again below before the latest row is re-upserted. if (provider !== "chatgpt") { @@ -1165,10 +1174,13 @@ export async function runLogin( if (!identityMatches) { throw new OAuthReauthIdentityMismatchError(); } - await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred); + await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, { + assertBeforePersist: deps.assertCurrentOwner, + }); } else { await (deps.saveCredential ?? saveCredential)(provider, cred, { preserveIdentityless: opts?.forceLogin === true, + assertBeforePersist: deps.assertCurrentOwner, }); } if (provider !== "chatgpt") { @@ -1235,6 +1247,7 @@ export async function runLogin( */ const loginState = new Map(); const loginAbort = new Map(); +const kiroLoginSettling = new Set(); /** Pending paste for a login in progress: either a waiter or a stashed early submission. */ interface ManualCodeSlot { @@ -1403,13 +1416,14 @@ export async function startLoginFlow( const def = OAUTH_PROVIDERS[provider]; if (!def) throw new UnsupportedOAuthProviderError(provider); const existing = loginState.get(provider); - if (existing && !existing.done) { + if ((existing && !existing.done) || (provider === "kiro" && kiroLoginSettling.has(provider))) { throw new Error(`A login for ${provider} is already in progress`); } clearManualCodeSlot(provider); loginState.set(provider, { done: false }); const abort = new AbortController(); loginAbort.set(provider, abort); + if (provider === "kiro") kiroLoginSettling.add(provider); return new Promise((resolve, reject) => { let urlResolved = false; const ctrl: OAuthController = { @@ -1460,7 +1474,10 @@ export async function startLoginFlow( }; // Background: runLogin persists the credential + provider entry to disk. The lifecycle hook // lets a long-lived server config adopt that settled state before clients observe done=true. - void runLogin(provider, ctrl, opts).then( + const assertCurrentOwner = (): void => { + if (loginAbort.get(provider) !== abort) throw new OAuthLoginSupersededError(); + }; + void runLogin(provider, ctrl, opts, { assertCurrentOwner }).then( () => settle(), (e: unknown) => settle(e), ).catch((e: unknown) => { @@ -1471,6 +1488,8 @@ export async function startLoginFlow( const msg = publicOAuthAuthenticationErrorMessage(e); loginState.set(provider, { done: true, error: msg }); if (!urlResolved) reject(e); + }).finally(() => { + if (provider === "kiro") kiroLoginSettling.delete(provider); }); }); } diff --git a/src/oauth/store.ts b/src/oauth/store.ts index 31924afc33..ed2d3bf87f 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -465,10 +465,11 @@ function serializeMutation(work: () => Promise, retainedValues: readonly u drainOAuthMutations(); return result; } -export function mutateStore(fn:(store:AuthStore)=>T|Promise, retainedValues: readonly unknown[] = [], options?: { waitMs?: number }):Promise{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{ +export function mutateStore(fn:(store:AuthStore)=>T|Promise, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void }):Promise{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{ const { store, hadLegacy } = loadAuthStoreInternal(); if (hadLegacy) backupLegacyOnce(); const result = await fn(store); + options?.assertBeforePersist?.(); persist(store); return result; }finally{guard.release();}}, retainedValues, options?.waitMs); @@ -491,7 +492,7 @@ export function getCredential(provider: string): OAuthCredentials | null { export async function saveCredential( provider: string, cred: OAuthCredentials, - opts: { preserveIdentityless?: boolean } = {}, + opts: { preserveIdentityless?: boolean; assertBeforePersist?: () => void } = {}, ): Promise { const safe = normalizeCredential(cred); if (!safe) return; @@ -542,7 +543,7 @@ export async function saveCredential( set.accounts.push({ id, credential: safe, addedAt: Date.now() }); set.activeAccountId = id; } - }, [provider, safe]); + }, [provider, safe], { assertBeforePersist: opts.assertBeforePersist }); } /** @@ -632,7 +633,12 @@ export function getAccountCredential(provider: string, accountId: string): OAuth } /** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */ -export async function saveAccountCredential(provider: string, accountId: string, cred: OAuthCredentials): Promise { +export async function saveAccountCredential( + provider: string, + accountId: string, + cred: OAuthCredentials, + opts: { assertBeforePersist?: () => void } = {}, +): Promise { const safe = normalizeCredential(cred); if (!safe) return; await mutateStore(store => { @@ -640,7 +646,7 @@ export async function saveAccountCredential(provider: string, accountId: string, if (!account) return; account.credential = safe; delete account.needsReauth; - }, [provider, accountId, safe]); + }, [provider, accountId, safe], { assertBeforePersist: opts.assertBeforePersist }); } export async function setActiveAccount(provider: string, accountId: string): Promise { diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index 788e5e4d56..20caf88de8 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -448,6 +448,87 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { } }); + test("a superseded OAuth flow cannot commit after its replacement owns the provider", async () => { + saveConfig(config()); + const originalLogin = OAUTH_PROVIDERS.xai.login; + let loginCalls = 0; + OAUTH_PROVIDERS.xai.login = async (ctrl) => { + loginCalls += 1; + const call = loginCalls; + ctrl.onAuth({ url: `https://auth.example.test/${call}`, deviceCode: `flow-${call}` }); + return { + access: `access-${call}`, + refresh: `refresh-${call}`, + accountId: `account-${call}`, + email: `account-${call}@example.test`, + expires: Date.now() + 60_000, + }; + }; + + let releaseHead!: () => void; + let signalHeadStarted!: () => void; + const headStarted = new Promise(resolve => { signalHeadStarted = resolve; }); + const headGate = new Promise(resolve => { releaseHead = resolve; }); + const blockingMutation = oauthStore.mutateStore(async () => { + signalHeadStarted(); + await headGate; + }); + + const waitForMutationCount = async (minimum: number): Promise => { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (oauthStore.oauthMutationTailSnapshot().active >= minimum) return; + await Bun.sleep(5); + } + throw new Error(`OAuth mutation queue did not reach ${minimum} active rows`); + }; + + try { + await headStarted; + await startLoginFlow("xai"); + await waitForMutationCount(2); + expect(cancelLoginFlow("xai")).toBe(true); + + await startLoginFlow("xai"); + await waitForMutationCount(3); + releaseHead(); + await blockingMutation; + + const status = await waitForOAuthDone("xai"); + expect(status).toMatchObject({ done: true, loggedIn: true }); + expect(getCredential("xai")).toMatchObject({ + access: "access-2", + accountId: "account-2", + }); + expect(oauthStore.getAccountSet("xai")?.accounts.map(account => account.credential.accountId)) + .toEqual(["account-2"]); + } finally { + releaseHead(); + await blockingMutation.catch(() => {}); + OAUTH_PROVIDERS.xai.login = originalLogin; + clearLoginState("xai"); + } + }); + + test("Kiro does not start a replacement until the canceled external CLI flow settles", async () => { + const originalLogin = OAUTH_PROVIDERS.kiro.login; + OAUTH_PROVIDERS.kiro.login = async (ctrl) => { + ctrl.onAuth({ url: "", deviceCode: "kiro-cancel-flow" }); + await new Promise((_, reject) => { + ctrl.signal.addEventListener("abort", () => reject(new Error("Kiro login cancelled")), { once: true }); + }); + }; + + try { + await startLoginFlow("kiro"); + expect(cancelLoginFlow("kiro")).toBe(true); + await expect(startLoginFlow("kiro")).rejects.toThrow("A login for kiro is already in progress"); + await Bun.sleep(0); + } finally { + OAUTH_PROVIDERS.kiro.login = originalLogin; + clearLoginState("kiro"); + } + }); + test("management OAuth safely reconciles live config after a late namespace claim", async () => { const liveConfig = config(); liveConfig.hostname = "0.0.0.0"; From d092e91e502c7f42dbadc2cf3ebb932455609910 Mon Sep 17 00:00:00 2001 From: Ingwannu Date: Tue, 18 Aug 2026 16:06:05 +0000 Subject: [PATCH 003/102] test(oauth): prove Kiro replacement admission recovers --- tests/oauth-public-surface.test.ts | 36 +++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/tests/oauth-public-surface.test.ts b/tests/oauth-public-surface.test.ts index 20caf88de8..a0ae4617f9 100644 --- a/tests/oauth-public-surface.test.ts +++ b/tests/oauth-public-surface.test.ts @@ -510,19 +510,45 @@ describe("legacy ChatGPT OAuth public-surface exclusion", () => { }); test("Kiro does not start a replacement until the canceled external CLI flow settles", async () => { + saveConfig(config()); const originalLogin = OAUTH_PROVIDERS.kiro.login; + let loginCalls = 0; OAUTH_PROVIDERS.kiro.login = async (ctrl) => { - ctrl.onAuth({ url: "", deviceCode: "kiro-cancel-flow" }); - await new Promise((_, reject) => { - ctrl.signal.addEventListener("abort", () => reject(new Error("Kiro login cancelled")), { once: true }); - }); + loginCalls += 1; + const call = loginCalls; + ctrl.onAuth({ url: "", deviceCode: `kiro-flow-${call}` }); + if (call === 1) { + await new Promise((_, reject) => { + ctrl.signal.addEventListener("abort", () => reject(new Error("Kiro login cancelled")), { once: true }); + }); + } + return { + access: "kiro-replacement-access", + refresh: "kiro-replacement-refresh", + accountId: "kiro-replacement-account", + email: "kiro-replacement@example.test", + expires: Date.now() + 60_000, + }; }; try { await startLoginFlow("kiro"); expect(cancelLoginFlow("kiro")).toBe(true); await expect(startLoginFlow("kiro")).rejects.toThrow("A login for kiro is already in progress"); - await Bun.sleep(0); + + let replacement: Awaited> | undefined; + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + replacement = await startLoginFlow("kiro"); + break; + } catch (error) { + if (!(error instanceof Error) || !error.message.includes("already in progress")) throw error; + await Bun.sleep(5); + } + } + expect(replacement).toMatchObject({ deviceCode: "kiro-flow-2" }); + expect(await waitForOAuthDone("kiro")).toMatchObject({ done: true, loggedIn: true }); + expect(loginCalls).toBe(2); } finally { OAUTH_PROVIDERS.kiro.login = originalLogin; clearLoginState("kiro"); From b0602491b85bef40416bd97909d36c196f337ed6 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Tue, 18 Aug 2026 15:52:49 -0700 Subject: [PATCH 004/102] fix(chat): apply the Fast capability gate to the native passthrough MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native /v1/chat/completions passthrough decided service-tier forwarding with a single provider-level check: if (provider.chatServiceTier && rawBody.service_tier !== undefined) That bypassed the FastWire policy layer entirely, and the worst consequence was not the one A0 recorded. An explicit supportsServiceTier: false — the fail-closed declaration meaning "this upstream is documented not to accept the field" — was ignored here, so a caller tier still reached that upstream whenever chatServiceTier happened to be true. Exact-model false was likewise ignored (the bug A0 locked), and B1's split between capability-authorized canonical Fast and CallerTierForward-authorized foreign tiers never reached this surface. Both Chat surfaces now call one shared gate, so they cannot drift apart by construction rather than by convention. The passthrough resolves its policy in chat-native.ts, where the route and config already live, and the key-rotation rebuild path resolves it too. What deliberately still differs: the Responses surface normalizes a caller's "fast" spelling to the provider's canonical wire value, while the passthrough forwards the caller's exact bytes. That is this builder's documented contract — caller fields retain their exact wire representation while capability gates stay centralized — and the difference is now asserted by a test and explained at the call site, so a future reader does not mistake it for a missed translation. Known gap, left for its own unit: the passthrough neither runs through the adapter registry decorator nor calls recordAdapterTier, so it produces no AttemptTierOutcome and a stripped tier is not yet visible as callerTierDropped. Building half of that pipeline here would have been worse than naming it. Full suite: 13332 pass / 10 skip / 2 fail — the pre-existing dev-side key-login-live-update regression, and one load-sensitive codex-shim timeout that passes in isolation (69 pass). Co-Authored-By: Claude Fable 5 --- src/adapters/openai-chat.ts | 40 +++- src/server/chat-native.ts | 12 +- tests/fastwire-characterization-wire.test.ts | 26 ++- tests/openai-chat-native-policy.test.ts | 221 +++++++++++++++++++ tests/openrouter-provider-routing.test.ts | 3 +- 5 files changed, 280 insertions(+), 22 deletions(-) create mode 100644 tests/openai-chat-native-policy.test.ts diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index a6d13fa1f4..b565f1f14d 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -19,6 +19,7 @@ import { import { canonicalFastTierMarker, createAdapterTierMetadata, + type ResolvedFastPolicy, } from "../providers/fastwire"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema"; @@ -95,6 +96,7 @@ export function buildOpenAIChatPassthroughRequest( rawBody: Record, modelId: string, stream: boolean, + fastPolicy: ResolvedFastPolicy, ): AdapterRequest { const { url, headers, hasCredential } = openAIChatTransport(provider); @@ -118,7 +120,10 @@ export function buildOpenAIChatPassthroughRequest( } if (modelInList(provider.noStructuredOutputModels, modelId)) delete body.response_format; - if (provider.chatServiceTier && rawBody.service_tier !== undefined) { + // Share the ordinary openai-chat adapter's capability gate, while preserving the + // caller's exact wire representation promised by this passthrough contract. The + // Responses surface may normalize canonical Fast spelling; this difference is intentional. + if (canSerializeOpenAIChatServiceTier(provider, modelId, rawBody.service_tier, undefined, fastPolicy)) { body.service_tier = rawBody.service_tier; } if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) { @@ -1280,6 +1285,26 @@ function thinkingBudgetForEffort(parsed: OcxParsedRequest, reasoningEffort: stri return fraction === undefined ? undefined : Math.max(1, Math.floor(maxBudget * fraction)); } +function canSerializeOpenAIChatServiceTier( + provider: OcxProviderConfig, + modelId: string, + serviceTier: unknown, + tierDecision?: OcxParsedRequest["options"]["tierDecision"], + resolvedPolicy?: Pick, +): boolean { + if (serviceTier === undefined) return false; + if (tierDecision !== undefined) { + return tierDecision.kind === "set" || tierDecision.kind === "forward-caller"; + } + const callerTier = typeof serviceTier === "string" ? serviceTier : undefined; + const callerCanonicalFast = canonicalFastTierMarker(callerTier) !== undefined; + const capability = resolvedPolicy === undefined + ? supportsServiceTierForModel(provider, modelId) + : resolvedPolicy.capability; + const callerTierForwardAllowed = canForwardForeignServiceTierForChatModel(provider, modelId); + return callerTierForwardAllowed || (callerCanonicalFast && capability === true); +} + export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter { return { name: "openai-chat", @@ -1302,13 +1327,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // unclassified Chat routes remain behind the caller-forwarding opt-in. const serviceTier = parsed.options.serviceTier; const tierDecision = parsed.options.tierDecision; - const callerCanonicalFast = canonicalFastTierMarker(serviceTier) !== undefined; - const callerTierForwardAllowed = canForwardForeignServiceTierForChatModel(provider, parsed.modelId); - const canonicalFastCapability = callerCanonicalFast - && supportsServiceTierForModel(provider, parsed.modelId) === true; - const canSerializeServiceTier = tierDecision?.kind === "set" - || tierDecision?.kind === "forward-caller" - || (tierDecision === undefined && (callerTierForwardAllowed || canonicalFastCapability)); + const canSerializeServiceTier = canSerializeOpenAIChatServiceTier( + provider, + parsed.modelId, + serviceTier, + tierDecision, + ); if (canSerializeServiceTier && serviceTier !== undefined) { body.service_tier = serviceTier; } diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index cc6738b313..5aa4a89cf8 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -27,6 +27,7 @@ import { rateLimitRetryPolicyFor, rotateProviderTransportOn429, } from "../providers/key-failover"; +import { fastPolicyForModel } from "../providers/service-tier"; import type { RouteResult } from "../router"; import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers"; @@ -154,8 +155,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio translatorBudget.chargeRetained(bytes, { kind: "request_copies" }); retainedRequestBytes = bytes; }; + const buildActiveRequest = () => buildOpenAIChatPassthroughRequest( + activeProvider, + options.chatBody, + route.modelId, + requestedStream, + fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"), + ); try { - activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); + activeRequest = buildActiveRequest(); retainRequest(activeRequest); } catch (error) { releaseRetainedRequest(); @@ -222,7 +230,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio activeProvider = rotated; activeAdapter = createOpenAIChatAdapter(activeProvider); releaseRetainedRequest(); - activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); + activeRequest = buildActiveRequest(); retainRequest(activeRequest); response = await send(activeRequest, "key-429"); } diff --git a/tests/fastwire-characterization-wire.test.ts b/tests/fastwire-characterization-wire.test.ts index 0b4e89fbf3..4ae9e38242 100644 --- a/tests/fastwire-characterization-wire.test.ts +++ b/tests/fastwire-characterization-wire.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import { buildOpenAIChatPassthroughRequest } from "../src/adapters/openai-chat"; import { chatCompletionsToResponsesBody } from "../src/chat/inbound"; +import { fastPolicyForModel } from "../src/providers/service-tier"; import * as adapterResolveModule from "../src/server/adapter-resolve"; import type { RequestLogContext } from "../src/server/request-log"; import { handleResponses } from "../src/server/responses/core"; @@ -364,17 +365,19 @@ describe("FastWire characterization: rawBody observation point", () => { }); describe("FastWire characterization: known bugs", () => { - test("characterization (known bug): native chat passthrough ignores exact-model false", () => { + test("characterization: native chat passthrough honors exact-model false", () => { + // FastWire #1886 native-chat policy fix: exact-model false now strips the caller tier. + const provider: OcxProviderConfig = { + adapter: "openai-chat", + baseUrl: "https://native-chat.example.test/v1", + authMode: "key", + apiKey: "sk-test", + supportsServiceTier: true, + chatServiceTier: true, + modelSupportsServiceTier: { model: false }, + }; const request = buildOpenAIChatPassthroughRequest( - { - adapter: "openai-chat", - baseUrl: "https://native-chat.example.test/v1", - authMode: "key", - apiKey: "sk-test", - supportsServiceTier: true, - chatServiceTier: true, - modelSupportsServiceTier: { model: false }, - }, + provider, { model: "model", messages: [{ role: "user", content: "ping" }], @@ -382,9 +385,10 @@ describe("FastWire characterization: known bugs", () => { }, "model", false, + fastPolicyForModel(provider, "model", "native-chat", "chat"), ); const body = JSON.parse(request.body) as Record; - expect(body.service_tier).toBe("flex"); + expect(body).not.toHaveProperty("service_tier"); }); test("characterization: chat-to-responses conversion preserves service_tier", () => { diff --git a/tests/openai-chat-native-policy.test.ts b/tests/openai-chat-native-policy.test.ts new file mode 100644 index 0000000000..4910652cae --- /dev/null +++ b/tests/openai-chat-native-policy.test.ts @@ -0,0 +1,221 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + buildOpenAIChatPassthroughRequest, + createOpenAIChatAdapter, +} from "../src/adapters/openai-chat"; +import { + decideTier, + tierValueAfterDecision, +} from "../src/providers/fastwire"; +import { fastPolicyForModel } from "../src/providers/service-tier"; +import { handleChatCompletions } from "../src/server/chat-completions"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const PROVIDER_NAME = "native-tier-fixture"; +const MODEL_ID = "model"; +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +function provider(overrides: Partial = {}): OcxProviderConfig { + return { + adapter: "openai-chat", + baseUrl: "https://native-tier.example.test/v1", + authMode: "key", + apiKey: "sk-test", + ...overrides, + }; +} + +function nativeBody( + target: OcxProviderConfig, + callerTier: string, + modelId = MODEL_ID, +): Record { + const policy = fastPolicyForModel(target, modelId, PROVIDER_NAME, "chat"); + const request = buildOpenAIChatPassthroughRequest( + target, + { + model: modelId, + messages: [{ role: "user", content: "ping" }], + service_tier: callerTier, + }, + modelId, + false, + policy, + ); + return JSON.parse(request.body) as Record; +} + +function mainPathBody( + target: OcxProviderConfig, + callerTier: string, + modelId = MODEL_ID, +): Record { + const policy = fastPolicyForModel(target, modelId, PROVIDER_NAME, "chat"); + const tierDecision = decideTier(policy, undefined, callerTier); + const serviceTier = tierValueAfterDecision(tierDecision, callerTier); + const parsed: OcxParsedRequest = { + modelId, + stream: false, + context: { messages: [{ role: "user", content: "ping" }], tools: [] }, + options: { + ...(serviceTier === undefined ? {} : { serviceTier }), + tierDecision, + }, + }; + const request = createOpenAIChatAdapter(target).buildRequest(parsed); + return JSON.parse(request.body) as Record; +} + +function forwardsTier(body: Record): boolean { + return Object.hasOwn(body, "service_tier"); +} + +describe("native Chat passthrough service-tier policy", () => { + test.each([ + { + name: "provider false stays fail-closed even with CallerTierForward", + config: { supportsServiceTier: false, chatServiceTier: true }, + callerTier: "priority", + expectedTier: undefined, + }, + { + name: "exact-model false narrows provider support", + config: { + supportsServiceTier: true, + chatServiceTier: true, + modelSupportsServiceTier: { [MODEL_ID]: false }, + }, + callerTier: "priority", + expectedTier: undefined, + }, + { + name: "exact-model true authorizes canonical Fast without CallerTierForward", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "FAST", + expectedTier: "FAST", + }, + { + name: "exact-model true does not authorize a foreign tier", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "flex", + expectedTier: undefined, + }, + { + name: "unclassified support drops a caller tier without CallerTierForward", + config: {}, + callerTier: "flex", + expectedTier: undefined, + }, + { + name: "unclassified support forwards a caller tier with CallerTierForward", + config: { chatServiceTier: true }, + callerTier: "flex", + expectedTier: "flex", + }, + ] as const)("$name", ({ config, callerTier, expectedTier }) => { + const body = nativeBody(provider(config), callerTier); + if (expectedTier === undefined) expect(body).not.toHaveProperty("service_tier"); + else expect(body.service_tier).toBe(expectedTier); + }); + + test("the native handler passes its resolved fail-closed policy to the builder", async () => { + const captured: Record[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + captured.push(JSON.parse(String(init?.body ?? "{}")) as Record); + return Response.json({ + id: "chatcmpl_native_tier", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }) as typeof fetch; + const target = provider({ supportsServiceTier: false, chatServiceTier: true }); + const config = { + port: 0, + defaultProvider: PROVIDER_NAME, + providers: { [PROVIDER_NAME]: target }, + } as OcxConfig; + + const response = await handleChatCompletions( + new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${PROVIDER_NAME}/${MODEL_ID}`, + messages: [{ role: "user", content: "ping" }], + service_tier: "priority", + }), + }), + config, + { model: "", provider: "" }, + ); + + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0]).not.toHaveProperty("service_tier"); + }); +}); + +describe("main and native Chat tier authorization parity", () => { + test.each([ + { + name: "provider fail-closed", + config: { supportsServiceTier: false, chatServiceTier: true }, + callerTier: "priority", + forwarded: false, + }, + { + name: "exact-model fail-closed", + config: { + supportsServiceTier: true, + chatServiceTier: true, + modelSupportsServiceTier: { [MODEL_ID]: false }, + }, + callerTier: "priority", + forwarded: false, + }, + { + name: "exact-model canonical Fast", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "fast", + forwarded: true, + mainTier: "priority", + nativeTier: "fast", + }, + { + name: "exact-model foreign tier", + config: { modelSupportsServiceTier: { [MODEL_ID]: true } }, + callerTier: "flex", + forwarded: false, + }, + { + name: "unclassified without CallerTierForward", + config: {}, + callerTier: "priority", + forwarded: false, + }, + { + name: "unclassified with CallerTierForward", + config: { chatServiceTier: true }, + callerTier: "flex", + forwarded: true, + mainTier: "flex", + nativeTier: "flex", + }, + ] as const)("$name makes the same forward/drop decision", row => { + const target = provider(row.config); + const main = mainPathBody(target, row.callerTier); + const native = nativeBody(target, row.callerTier); + + expect(forwardsTier(main)).toBe(row.forwarded); + expect(forwardsTier(native)).toBe(row.forwarded); + expect(forwardsTier(native)).toBe(forwardsTier(main)); + if (row.forwarded) { + expect(main.service_tier).toBe(row.mainTier); + expect(native.service_tier).toBe(row.nativeTier); + } + }); +}); diff --git a/tests/openrouter-provider-routing.test.ts b/tests/openrouter-provider-routing.test.ts index b6ee368f3f..7a833a5b7a 100644 --- a/tests/openrouter-provider-routing.test.ts +++ b/tests/openrouter-provider-routing.test.ts @@ -7,6 +7,7 @@ import { openRouterRoutingConfigError, openRouterProviderPayload, } from "../src/providers/openrouter-routing"; +import { fastPolicyForModel } from "../src/providers/service-tier"; import { clearKeyCooldowns, rotateProviderTransportOn429 } from "../src/providers/key-failover"; import { routeModel } from "../src/router"; import { providerManagementConfigError, safeConfigDTO } from "../src/server/auth-cors"; @@ -39,7 +40,7 @@ function passthroughBody( const request = buildOpenAIChatPassthroughRequest(providerConfig, { messages: [{ role: "user", content: "hello" }], ...rawBody, - }, modelId, false); + }, modelId, false, fastPolicyForModel(providerConfig, modelId, undefined, "chat")); return JSON.parse(request.body as string) as Record; } From 08d01bbdf60e8cffb73df4e7d98fb149b4ea43a4 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:34 -0600 Subject: [PATCH 005/102] feat(antigravity): live quota RPC and geoblock classification Probe retrieveUserQuota with catalog fallback, skip http Bearer destinations, and surface Cloud Code Assist location blocks before generic 403s. Co-authored-by: Cursor --- src/adapters/google-antigravity-hosts.ts | 24 ++ src/adapters/google-errors.ts | 7 + src/providers/antigravity-quota.ts | 212 +++++++++++++++ src/providers/quota.ts | 87 +++++-- tests/antigravity-quota.test.ts | 319 +++++++++++++++++++++++ tests/google-antigravity-errors.test.ts | 59 +++++ 6 files changed, 682 insertions(+), 26 deletions(-) create mode 100644 src/adapters/google-antigravity-hosts.ts create mode 100644 src/providers/antigravity-quota.ts create mode 100644 tests/antigravity-quota.test.ts create mode 100644 tests/google-antigravity-errors.test.ts diff --git a/src/adapters/google-antigravity-hosts.ts b/src/adapters/google-antigravity-hosts.ts new file mode 100644 index 0000000000..b9dc041b17 --- /dev/null +++ b/src/adapters/google-antigravity-hosts.ts @@ -0,0 +1,24 @@ +const DAILY_ANTIGRAVITY_HOST = "https://daily-cloudcode-pa.googleapis.com"; +const PROD_ANTIGRAVITY_HOST = "https://cloudcode-pa.googleapis.com"; + +/** + * Return the configured Antigravity endpoint followed by its daily/production peer. + * The configured value is preserved so tests and future pinned environments keep their + * explicit first choice; the fallback is always one of Google's two known hosts. + */ +export function antigravityHostCandidates(configuredBase: string): string[] { + const configured = configuredBase.replace(/\/+$/, ""); + const other = configured === DAILY_ANTIGRAVITY_HOST + ? PROD_ANTIGRAVITY_HOST + : DAILY_ANTIGRAVITY_HOST; + return [...new Set([configured, other])]; +} + +/** OAuth bearer requests must not use a cleartext host, even if generic baseUrl config allows http. */ +export function isAntigravityHttpsHost(host: string): boolean { + try { + return new URL(host).protocol === "https:"; + } catch { + return false; + } +} diff --git a/src/adapters/google-errors.ts b/src/adapters/google-errors.ts index 69e6d0cef5..371d2b70b9 100644 --- a/src/adapters/google-errors.ts +++ b/src/adapters/google-errors.ts @@ -15,6 +15,12 @@ function googleErrorDetail(payloadText: string): { message?: string; status?: st }; } +const ANTIGRAVITY_GEO_BLOCKED_MARKER = "user location is not supported for the api use"; + +export function isAntigravityGeoBlockedBody(payloadText: string): boolean { + return payloadText.toLowerCase().includes(ANTIGRAVITY_GEO_BLOCKED_MARKER); +} + function classifyGoogle(label: string, status: number | undefined, enumStatus: string | undefined, text: string): string { const lower = `${enumStatus ?? ""} ${text}`.toLowerCase(); const quotaExhausted = @@ -29,6 +35,7 @@ function classifyGoogle(label: string, status: number | undefined, enumStatus: s if (status === 401 || enumStatus === "UNAUTHENTICATED" || lower.includes("unauthenticated") || lower.includes("invalid authentication") || lower.includes("expired")) { return `${label} authentication failed`; } + if (isAntigravityGeoBlockedBody(lower)) return `${label} location not supported`; if (status === 403 || enumStatus === "PERMISSION_DENIED" || lower.includes("permission denied") || lower.includes("access denied")) { return `${label} access denied`; } diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts new file mode 100644 index 0000000000..339df293ab --- /dev/null +++ b/src/providers/antigravity-quota.ts @@ -0,0 +1,212 @@ +import { antigravityUserAgent } from "../adapters/client-fingerprint"; +import { antigravityHostCandidates, isAntigravityHttpsHost } from "../adapters/google-antigravity-hosts"; +import { readProviderQuotaJsonForTests } from "./quota"; +import type { ProviderQuota, ProviderQuotaWindow } from "./quota"; + +const LIVE_QUOTA_PATH = "/v1internal:retrieveUserQuota"; +const LIVE_SUMMARY_PATH = "/v1internal:retrieveUserQuotaSummary"; + +type FetchImpl = typeof fetch; + +export interface AntigravityLiveQuotaArgs { + accessToken: string; + projectId: string; + baseUrl: string; + timeoutMs: number; + fetchImpl?: FetchImpl; +} + +interface QuotaCandidate { + record: Record; + path: string[]; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function finiteNumber(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function normalizePercent(value: unknown): number | undefined { + const numeric = finiteNumber(value); + return numeric === undefined ? undefined : Math.max(0, Math.min(100, numeric)); +} + +function resetAt(value: unknown): number | undefined { + const numeric = finiteNumber(value); + if (numeric !== undefined && numeric > 0) return numeric > 10_000_000_000 ? numeric : numeric * 1000; + if (typeof value !== "string" || !value.trim()) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; +} + +function remainingPercent(record: Record): number | undefined { + const fraction = finiteNumber(record.remainingFraction); + if (fraction !== undefined) return normalizePercent(fraction * 100); + const percentage = finiteNumber( + record.remainingPercentage + ?? record.remainingPercent + ?? record.remaining_percent, + ); + if (percentage !== undefined) return normalizePercent(percentage <= 1 ? percentage * 100 : percentage); + return undefined; +} + +function usedPercent(record: Record): number | undefined { + const remaining = remainingPercent(record); + return remaining === undefined ? undefined : normalizePercent(100 - remaining); +} + +function recordResetAt(record: Record): number | undefined { + return resetAt(record.resetTime ?? record.resetAt ?? record.resetsAt ?? record.reset_time ?? record.nextReset); +} + +function collectCandidates(value: unknown, path: string[] = [], output: QuotaCandidate[] = []): QuotaCandidate[] { + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) collectCandidates(item, [...path, String(index)], output); + return output; + } + const record = asRecord(value); + if (!record) return output; + output.push({ record, path }); + for (const [key, child] of Object.entries(record)) { + if (child && typeof child === "object") collectCandidates(child, [...path, key], output); + } + return output; +} + +function candidateModelName(candidate: QuotaCandidate): string { + const { record, path } = candidate; + const explicit = record.modelId ?? record.model_id ?? record.modelName ?? record.model ?? record.name; + return `${typeof explicit === "string" ? explicit : ""} ${path.join(" ")}`.toLowerCase(); +} + +function parseGeminiWindow(payload: unknown): ProviderQuotaWindow | undefined { + for (const candidate of collectCandidates(payload)) { + if (!candidateModelName(candidate).includes("gemini")) continue; + const percent = usedPercent(candidate.record); + if (percent === undefined) continue; + const reset = recordResetAt(candidate.record); + return { + label: "Gem", + percent, + ...(reset !== undefined ? { resetAt: reset } : {}), + }; + } + return undefined; +} + +function isWeeklyPath(path: string[]): boolean { + return path.some(part => /weekly|week|seven[_-]?day/i.test(part)); +} + +function parseWeeklyWindow(payload: unknown): { percent: number; resetAt?: number } | undefined { + const candidates = collectCandidates(payload); + const ordered = [ + ...candidates.filter(candidate => isWeeklyPath(candidate.path)), + ...candidates.filter(candidate => !isWeeklyPath(candidate.path)), + ]; + for (const candidate of ordered) { + const percent = usedPercent(candidate.record); + if (percent === undefined) continue; + const reset = recordResetAt(candidate.record); + return { percent, ...(reset !== undefined ? { resetAt: reset } : {}) }; + } + return undefined; +} + +async function readJson(response: Response, timeoutMs: number): Promise { + return await readProviderQuotaJsonForTests(response, timeoutMs); +} + +class AntigravityQuotaRpcError extends Error { + constructor(readonly status: number) { + super(`Antigravity quota RPC failed: ${status}`); + } +} + +function shouldRetryPeer(status: number): boolean { + return status === 404 || status === 503; +} + +async function fetchRpc( + fetchImpl: FetchImpl, + host: string, + method: "retrieveUserQuota" | "retrieveUserQuotaSummary", + args: AntigravityLiveQuotaArgs, +): Promise { + const path = method === "retrieveUserQuota" ? LIVE_QUOTA_PATH : LIVE_SUMMARY_PATH; + const response = await fetchImpl(`${host}${path}`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + Authorization: `Bearer ${args.accessToken}`, + }, + body: JSON.stringify({ project: args.projectId }), + redirect: "error", + signal: AbortSignal.timeout(args.timeoutMs), + }); + if (!response.ok) throw new AntigravityQuotaRpcError(response.status); + return readJson(response, args.timeoutMs); +} + +async function fetchHostQuota( + fetchImpl: FetchImpl, + host: string, + args: AntigravityLiveQuotaArgs, +): Promise { + const [quotaResult, summaryResult] = await Promise.allSettled([ + fetchRpc(fetchImpl, host, "retrieveUserQuota", args), + fetchRpc(fetchImpl, host, "retrieveUserQuotaSummary", args), + ]); + for (const result of [quotaResult, summaryResult]) { + if ( + result.status === "rejected" + && result.reason instanceof AntigravityQuotaRpcError + && !shouldRetryPeer(result.reason.status) + ) { + throw result.reason; + } + } + if (quotaResult.status === "rejected" || summaryResult.status === "rejected") return null; + const quotaPayload = quotaResult.value; + const summaryPayload = summaryResult.value; + const gem = parseGeminiWindow(quotaPayload); + const weekly = parseWeeklyWindow(summaryPayload); + if (!gem && !weekly) return null; + return { + ...(gem ? { customWindows: [gem] } : {}), + ...(weekly ? { + weeklyPercent: weekly.percent, + ...(weekly.resetAt !== undefined ? { weeklyResetAt: weekly.resetAt } : {}), + } : {}), + updatedAt: Date.now(), + }; +} + +export async function fetchAntigravityLiveQuota( + args: AntigravityLiveQuotaArgs, +): Promise { + const fetchImpl = args.fetchImpl ?? fetch; + for (const host of antigravityHostCandidates(args.baseUrl)) { + if (!isAntigravityHttpsHost(host)) continue; + try { + const quota = await fetchHostQuota(fetchImpl, host, args); + if (quota) return quota; + } catch { + return null; + } + } + return null; +} diff --git a/src/providers/quota.ts b/src/providers/quota.ts index bb3bab2837..d97321439d 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -28,6 +28,8 @@ import { type CodexCapacityAggregation, type CodexCapacityQuota, } from "./codex-capacity"; +import { fetchAntigravityLiveQuota } from "./antigravity-quota"; +import { antigravityHostCandidates, isAntigravityHttpsHost } from "../adapters/google-antigravity-hosts"; /** Match oauth/index REFRESH_SKEW_MS — use stored access without refresh when still fresh. */ const ACCOUNT_TOKEN_SKEW_MS = 60_000; @@ -2009,39 +2011,72 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig return null; } const baseUrl = (config.baseUrl || "https://daily-cloudcode-pa.googleapis.com").replace(/\/+$/, ""); - const response = await fetch(`${baseUrl}/v1internal:fetchAvailableModels`, { - method: "POST", - headers: { - Accept: "application/json", - "Content-Type": "application/json", - "User-Agent": antigravityUserAgent(), - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ project: credential.projectId }), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + const liveQuota = await fetchAntigravityLiveQuota({ + accessToken, + projectId: credential.projectId, + baseUrl, + timeoutMs: REQUEST_TIMEOUT_MS, }); - if (!response.ok) return null; - const body = asRecord(await readQuotaJson(response)); - const models = asRecord(body?.models); - if (!models) return null; const windows = new Map(); - for (const [modelId, rawModelInfo] of Object.entries(models)) { - const modelInfo = asRecord(rawModelInfo); - if (!modelInfo) continue; - for (const quotaInfo of quotaInfoEntries(modelInfo)) { - const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); - if (!label || windows.has(label)) continue; - const percent = antigravityUsedPercent(quotaInfo); - if (percent === undefined) continue; - windows.set(label, { - label, - percent, - ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), + for (const [index, host] of antigravityHostCandidates(baseUrl).entries()) { + if (!isAntigravityHttpsHost(host)) continue; + try { + const response = await fetch(`${host}/v1internal:fetchAvailableModels`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "User-Agent": antigravityUserAgent(), + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ project: credential.projectId }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); + if (!response.ok) { + if (index === 0 && (response.status === 404 || response.status === 503)) continue; + break; + } + const body = asRecord(await readQuotaJson(response)); + const models = asRecord(body?.models); + if (models) { + for (const [modelId, rawModelInfo] of Object.entries(models)) { + const modelInfo = asRecord(rawModelInfo); + if (!modelInfo) continue; + for (const quotaInfo of quotaInfoEntries(modelInfo)) { + const label = classifyAntigravityFamily(modelId, modelInfo, quotaInfo); + if (!label || windows.has(label)) continue; + const percent = antigravityUsedPercent(quotaInfo); + if (percent === undefined) continue; + windows.set(label, { + label, + percent, + ...(normalizeResetAt(quotaInfo.resetTime) !== undefined ? { resetAt: normalizeResetAt(quotaInfo.resetTime) } : {}), + }); + } + } + } + break; + } catch { + if (index === 0) continue; + break; } } + if (liveQuota) { + const liveWindows = liveQuota.customWindows ?? []; + const catalogClaude = windows.get("Cla"); + const customWindows = [ + ...liveWindows, + ...(liveWindows.some(window => window.label === "Cla") || !catalogClaude ? [] : [catalogClaude]), + ]; + return report(provider, "google-antigravity:retrieveUserQuota", { + ...liveQuota, + ...(customWindows.length > 0 ? { customWindows } : {}), + updatedAt: Date.now(), + }); + } + const customWindows = ["Gem", "Cla"].flatMap(label => { const window = windows.get(label); return window ? [window] : []; diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts new file mode 100644 index 0000000000..2564a90924 --- /dev/null +++ b/tests/antigravity-quota.test.ts @@ -0,0 +1,319 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; +import { + clearProviderQuotaCache, + fetchProviderQuotaReports, + QUOTA_RESPONSE_MAX_BYTES, +} from "../src/providers/quota"; +import { saveCredential } from "../src/oauth/store"; +import type { OcxConfig } from "../src/types"; + +const originalFetch = globalThis.fetch; +const previousOpencodexHome = process.env.OPENCODEX_HOME; +let opencodexHome: string; + +const DAILY_HOST = "https://daily-cloudcode-pa.googleapis.com"; +const PROD_HOST = "https://cloudcode-pa.googleapis.com"; +const TOKEN = "antigravity-access-token"; +const PROJECT = "antigravity-project"; + +function liveGeminiQuota(): Response { + return jsonResponse({ + buckets: [ + { modelId: "gemini-3.6-pro", remainingFraction: 0.4, resetTime: "2026-08-19T12:00:00Z" }, + ], + }); +} + +function liveWeeklySummary(): Response { + return jsonResponse({ + weekly: { remainingPercentage: 75, resetTime: "2026-08-25T00:00:00Z" }, + }); +} + +function config(baseUrl = DAILY_HOST): OcxConfig { + return { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { adapter: "google", authMode: "oauth", baseUrl }, + }, + } as OcxConfig; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function catalogResponse(): Response { + return jsonResponse({ + models: { + "gemini-3.6-flash-medium": { + displayName: "Gemini 3.6 Flash (Medium)", + quotaInfo: { remainingFraction: 0.64, resetTime: "2026-08-20T14:00:00Z" }, + }, + "claude-sonnet-4.6": { + displayName: "Claude Sonnet", + quotaInfo: { remainingFraction: 0.21, resetTime: "2026-08-21T15:00:00Z" }, + }, + }, + }); +} + +function oversizedJsonResponse(value: Record): Response { + return new Response(JSON.stringify({ + ...value, + padding: "x".repeat(QUOTA_RESPONSE_MAX_BYTES), + }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(async () => { + opencodexHome = mkdtempSync(join(tmpdir(), "ocx-antigravity-quota-")); + process.env.OPENCODEX_HOME = opencodexHome; + await saveCredential("google-antigravity", { + access: TOKEN, + refresh: "antigravity-refresh-token", + expires: Date.now() + 3_600_000, + projectId: PROJECT, + }); + clearProviderQuotaCache(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + clearProviderQuotaCache(); + if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousOpencodexHome; + rmSync(opencodexHome, { recursive: true, force: true }); +}); + +describe("Antigravity live quota", () => { + test("merges live Gemini and weekly quota with catalog-only Claude windows", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return jsonResponse({ + buckets: [ + { modelId: "gemini-3.6-pro", remainingFraction: 0.4, resetTime: "2026-08-19T12:00:00Z" }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return jsonResponse({ + weekly: { remainingPercentage: 75, resetTime: "2026-08-25T00:00:00Z" }, + }); + } + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + const report = result.reports[0]; + + expect(report?.source).toBe("google-antigravity:retrieveUserQuota"); + expect(report?.quota.customWindows).toEqual([ + { label: "Gem", percent: 60, resetAt: Date.parse("2026-08-19T12:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-08-21T15:00:00Z") }, + ]); + expect(report?.quota.weeklyPercent).toBe(25); + expect(report?.quota.weeklyResetAt).toBe(Date.parse("2026-08-25T00:00:00Z")); + }); + + test("retries the production host after daily retrieveUserQuota returns 404", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.startsWith(DAILY_HOST) && url.includes(":retrieveUserQuota")) return jsonResponse({}, 404); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested).toContain(`${PROD_HOST}/v1internal:retrieveUserQuota`); + expect(result.reports[0]?.source).toBe("google-antigravity:retrieveUserQuota"); + }); + + test("falls back to the catalog when both live RPCs return 404", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(":retrieveUserQuota")) return jsonResponse({}, 404); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + const report = result.reports[0]; + + expect(report?.source).toBe("google-antigravity:fetchAvailableModels"); + expect(report?.quota.customWindows).toEqual([ + { label: "Gem", percent: 36, resetAt: Date.parse("2026-08-20T14:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-08-21T15:00:00Z") }, + ]); + expect(report?.quota.weeklyPercent).toBeUndefined(); + }); + + test("falls back to the catalog when live RPC fetch throws", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(":retrieveUserQuota")) throw new Error("simulated timeout"); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + await expect(fetchProviderQuotaReports(config(), true)).resolves.toMatchObject({ + reports: [{ + source: "google-antigravity:fetchAvailableModels", + quota: { customWindows: expect.any(Array) }, + }], + }); + }); + + test("fails open to the catalog when live RPC bodies exceed the quota JSON limit", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return oversizedJsonResponse({ + buckets: [ + { modelId: "gemini-3.6-pro", remainingFraction: 0.01 }, + ], + }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return oversizedJsonResponse({ + weekly: { remainingPercentage: 1 }, + }); + } + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + const report = result.reports[0]; + + expect(report?.source).toBe("google-antigravity:fetchAvailableModels"); + expect(report?.quota.customWindows).toEqual([ + { label: "Gem", percent: 36, resetAt: Date.parse("2026-08-20T14:00:00Z") }, + { label: "Cla", percent: 79, resetAt: Date.parse("2026-08-21T15:00:00Z") }, + ]); + expect(report?.quota.weeklyPercent).toBeUndefined(); + }); + + test("does not fetch the production host after daily retrieveUserQuota returns 401", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuota`) return jsonResponse({}, 401); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); + }); + + test("does not fetch the production host when daily retrieveUserQuota 401 races a 404 summary", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuota`) { + await Bun.sleep(20); + return jsonResponse({}, 401); + } + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuotaSummary`) return jsonResponse({}, 404); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); + }); + + test("does not fetch the production host after daily retrieveUserQuota returns 429", async () => { + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url === `${DAILY_HOST}/v1internal:retrieveUserQuota`) return jsonResponse({}, 429); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config(), true); + + expect(requested).toContain(`${DAILY_HOST}/v1internal:retrieveUserQuota`); + expect(requested.filter(url => url.startsWith(PROD_HOST))).toEqual([]); + expect(result.reports[0]?.source).toBe("google-antigravity:fetchAvailableModels"); + }); + + test("does not POST retrieveUserQuota or retrieveUserQuotaSummary to an http host", async () => { + const httpHost = "http://daily-cloudcode-pa.googleapis.com"; + const requested: string[] = []; + const fetchImpl = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + return jsonResponse({}, 404); + }) as typeof fetch; + + const quota = await fetchAntigravityLiveQuota({ + accessToken: TOKEN, + projectId: PROJECT, + baseUrl: httpHost, + timeoutMs: 8_000, + fetchImpl, + }); + + expect(requested.filter(url => url.startsWith("http://"))).toEqual([]); + expect(requested).not.toContain(`${httpHost}/v1internal:retrieveUserQuota`); + expect(requested).not.toContain(`${httpHost}/v1internal:retrieveUserQuotaSummary`); + expect(quota?.customWindows).toEqual([ + { label: "Gem", percent: 60, resetAt: Date.parse("2026-08-19T12:00:00Z") }, + ]); + }); + + test("does not POST fetchAvailableModels to an http host", async () => { + const httpHost = "http://daily-cloudcode-pa.googleapis.com"; + const requested: string[] = []; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + requested.push(url); + if (url.endsWith(":retrieveUserQuota")) return liveGeminiQuota(); + if (url.endsWith(":retrieveUserQuotaSummary")) return liveWeeklySummary(); + if (url.endsWith(":fetchAvailableModels")) return catalogResponse(); + return jsonResponse({}, 404); + }) as typeof fetch; + + await fetchProviderQuotaReports(config(httpHost), true); + + expect(requested.filter(url => url.startsWith("http://"))).toEqual([]); + expect(requested).not.toContain(`${httpHost}/v1internal:fetchAvailableModels`); + }); +}); diff --git a/tests/google-antigravity-errors.test.ts b/tests/google-antigravity-errors.test.ts new file mode 100644 index 0000000000..fccbbf63ad --- /dev/null +++ b/tests/google-antigravity-errors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { + isAntigravityGeoBlockedBody, + isQuotaExhaustedBody, + retryableGoogleStatus, + safeAntigravityHttpErrorMessage, +} from "../src/adapters/google-errors"; + +const GEO_BLOCKED_DETAIL = "User location is not supported for the API use"; + +describe("Antigravity Google error classification", () => { + test("classifies geo-blocked 403 responses and redacts echoed credentials", () => { + const leakedToken = "geo-access-token-value-123456"; + const payload = JSON.stringify({ + error: { + status: "PERMISSION_DENIED", + message: `${GEO_BLOCKED_DETAIL}; accessToken=${leakedToken}`, + }, + }); + + expect(isAntigravityGeoBlockedBody(payload)).toBe(true); + const message = safeAntigravityHttpErrorMessage(403, payload); + expect(message).toContain("Antigravity location not supported"); + expect(message).not.toContain(leakedToken); + }); + + test("keeps ordinary permission-denied 403 responses as access denied", () => { + const payload = JSON.stringify({ + error: { + status: "PERMISSION_DENIED", + message: "The caller does not have permission to use this resource", + }, + }); + + expect(isAntigravityGeoBlockedBody(payload)).toBe(false); + expect(safeAntigravityHttpErrorMessage(403, payload)).toContain("Antigravity access denied"); + }); + + test("preserves quota and rate-limit classification for 429 responses", () => { + const quotaPayload = JSON.stringify({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Quota exceeded for this project", + }, + }); + const rateLimitPayload = JSON.stringify({ + error: { + status: "RESOURCE_EXHAUSTED", + message: "Rate limit exceeded", + }, + }); + + expect(safeAntigravityHttpErrorMessage(429, quotaPayload)).toContain("Antigravity quota exhausted"); + expect(safeAntigravityHttpErrorMessage(429, rateLimitPayload)).toContain("Antigravity rate limit exceeded"); + expect(isQuotaExhaustedBody(quotaPayload)).toBe(true); + expect(isQuotaExhaustedBody(rateLimitPayload)).toBe(false); + expect(retryableGoogleStatus(403)).toBe(false); + }); +}); From 4438760cbdc6c5d1c396c28927a111b9012db06f Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:32:11 -0600 Subject: [PATCH 006/102] fix(antigravity): harden quota interpretation and catalog fetch Prevent quota probes from following redirects or promoting daily summaries as weekly usage, and interpret explicit percentage fields without converting small percentages into fractions. Co-authored-by: Cursor --- src/providers/antigravity-quota.ts | 9 ++---- src/providers/quota.ts | 3 +- tests/antigravity-quota.test.ts | 30 +++++++++++++++++++ tests/provider-quota.test.ts | 48 ++++++++++++++++++++++++++++-- 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts index 339df293ab..91d1d75cc9 100644 --- a/src/providers/antigravity-quota.ts +++ b/src/providers/antigravity-quota.ts @@ -57,7 +57,7 @@ function remainingPercent(record: Record): number | undefined { ?? record.remainingPercent ?? record.remaining_percent, ); - if (percentage !== undefined) return normalizePercent(percentage <= 1 ? percentage * 100 : percentage); + if (percentage !== undefined) return normalizePercent(percentage); return undefined; } @@ -111,11 +111,8 @@ function isWeeklyPath(path: string[]): boolean { function parseWeeklyWindow(payload: unknown): { percent: number; resetAt?: number } | undefined { const candidates = collectCandidates(payload); - const ordered = [ - ...candidates.filter(candidate => isWeeklyPath(candidate.path)), - ...candidates.filter(candidate => !isWeeklyPath(candidate.path)), - ]; - for (const candidate of ordered) { + for (const candidate of candidates) { + if (!isWeeklyPath(candidate.path)) continue; const percent = usedPercent(candidate.record); if (percent === undefined) continue; const reset = recordResetAt(candidate.record); diff --git a/src/providers/quota.ts b/src/providers/quota.ts index d97321439d..bcc4659d0f 100644 --- a/src/providers/quota.ts +++ b/src/providers/quota.ts @@ -1995,7 +1995,7 @@ function antigravityUsedPercent(quotaInfo: Record): number | un const remaining = normalizePercent(toFiniteNumber(quotaInfo.remainingFraction) !== undefined ? toFiniteNumber(quotaInfo.remainingFraction)! * 100 : toFiniteNumber(quotaInfo.remainingPercentage) !== undefined - ? toFiniteNumber(quotaInfo.remainingPercentage)! * 100 + ? toFiniteNumber(quotaInfo.remainingPercentage)! : undefined); if (remaining === undefined) return undefined; return normalizePercent(100 - remaining); @@ -2031,6 +2031,7 @@ async function fetchAntigravityQuota(provider: string, config: OcxProviderConfig Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ project: credential.projectId }), + redirect: "error", signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) { diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index 2564a90924..518248c7aa 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -1,3 +1,33 @@ +import { expect, test } from "bun:test"; +import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; + +test("does not classify an unlabelled daily summary window as weekly", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return new Response(JSON.stringify({ + models: { + "gemini-test": { quotaInfo: { remainingFraction: 0.5 } }, + }, + }), { status: 200 }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + daily: { remainingFraction: 0.75 }, + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + + expect(result?.customWindows?.[0]?.label).toBe("Gem"); + expect(result?.weeklyPercent).toBeUndefined(); +}); import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/tests/provider-quota.test.ts b/tests/provider-quota.test.ts index 1c4e733683..db1bad354a 100644 --- a/tests/provider-quota.test.ts +++ b/tests/provider-quota.test.ts @@ -121,11 +121,16 @@ describe("fetchProviderQuotaReports", () => { await saveCredential("google-antigravity", { access: "agy-access-secret", refresh: "agy-refresh-secret", expires: Date.now() + 3600_000, projectId: "agy-project-secret" }); await saveCredential("kimi", { access: "kimi-access-secret", refresh: "kimi-refresh-secret", expires: Date.now() + 3600_000 }); - const seen: { url: string; authorization?: string; body?: string }[] = []; + const seen: { url: string; authorization?: string; body?: string; redirect?: RequestRedirect }[] = []; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); const headers = init?.headers as Record | undefined; - seen.push({ url, authorization: headers?.Authorization, body: typeof init?.body === "string" ? init.body : undefined }); + seen.push({ + url, + authorization: headers?.Authorization, + body: typeof init?.body === "string" ? init.body : undefined, + redirect: init?.redirect, + }); if (url === "https://chatgpt.com/backend-api/wham/usage") { return new Response(JSON.stringify({ email: "person@example.com", @@ -255,9 +260,48 @@ describe("fetchProviderQuotaReports", () => { expect(seen.find(row => row.url.includes("anthropic.com"))?.authorization).toBe("Bearer claude-access-secret"); expect(seen.find(row => row.url.includes("cloudcode-pa.googleapis.com"))?.authorization).toBe("Bearer agy-access-secret"); expect(seen.find(row => row.url.includes("cloudcode-pa.googleapis.com"))?.body).toBe(JSON.stringify({ project: "agy-project-secret" })); + expect(seen.find(row => row.url.endsWith("/v1internal:fetchAvailableModels"))?.redirect).toBe("error"); expect(seen.find(row => row.url === "https://api.kimi.com/coding/v1/usages")?.authorization).toBe("Bearer kimi-access-secret"); }); + test("treats remainingPercentage as a percentage at values one and below", async () => { + await saveCredential("google-antigravity", { + access: "agy-access-secret", + refresh: "agy-refresh-secret", + expires: Date.now() + 3600_000, + projectId: "agy-project-secret", + }); + const config = { + defaultProvider: "google-antigravity", + providers: { + "google-antigravity": { + adapter: "google", + authMode: "oauth", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + }, + }, + } as OcxConfig; + + for (const [remainingPercentage, expectedUsedPercentage] of [[1, 99], [0.75, 99.25], [75, 25]]) { + clearProviderQuotaCache(); + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes(":retrieveUserQuota")) return new Response("not found", { status: 404 }); + if (url.endsWith("/v1internal:fetchAvailableModels")) { + return new Response(JSON.stringify({ + models: { + "gemini-test": { quotaInfo: { remainingPercentage } }, + }, + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }) as typeof fetch; + + const result = await fetchProviderQuotaReports(config, true); + expect(result.reports[0]?.quota.customWindows?.[0]?.percent).toBe(expectedUsedPercentage); + } + }); + function kimiOnlyConfig(baseUrl = "https://api.kimi.com/coding/v1"): OcxConfig { return { defaultProvider: "kimi", From a7aa31afcc047ede7ad3c2e6816e72e1c441c2ae Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:09:10 -0600 Subject: [PATCH 007/102] fix(antigravity): preserve usable quota when summary fails Treat unreadable daily quota JSON as an RPC failure while allowing the optional summary call to fail without discarding the daily window. Co-authored-by: Cursor --- src/providers/antigravity-quota.ts | 22 ++++++------- tests/antigravity-quota.test.ts | 51 ++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/src/providers/antigravity-quota.ts b/src/providers/antigravity-quota.ts index 91d1d75cc9..0e4a66d2ff 100644 --- a/src/providers/antigravity-quota.ts +++ b/src/providers/antigravity-quota.ts @@ -122,7 +122,9 @@ function parseWeeklyWindow(payload: unknown): { percent: number; resetAt?: numbe } async function readJson(response: Response, timeoutMs: number): Promise { - return await readProviderQuotaJsonForTests(response, timeoutMs); + const payload = await readProviderQuotaJsonForTests(response, timeoutMs); + if (payload === null) throw new Error("Antigravity quota RPC returned unreadable JSON"); + return payload; } class AntigravityQuotaRpcError extends Error { @@ -167,18 +169,16 @@ async function fetchHostQuota( fetchRpc(fetchImpl, host, "retrieveUserQuota", args), fetchRpc(fetchImpl, host, "retrieveUserQuotaSummary", args), ]); - for (const result of [quotaResult, summaryResult]) { - if ( - result.status === "rejected" - && result.reason instanceof AntigravityQuotaRpcError - && !shouldRetryPeer(result.reason.status) - ) { - throw result.reason; - } + if ( + quotaResult.status === "rejected" + && quotaResult.reason instanceof AntigravityQuotaRpcError + && !shouldRetryPeer(quotaResult.reason.status) + ) { + throw quotaResult.reason; } - if (quotaResult.status === "rejected" || summaryResult.status === "rejected") return null; + if (quotaResult.status === "rejected") return null; const quotaPayload = quotaResult.value; - const summaryPayload = summaryResult.value; + const summaryPayload = summaryResult.status === "fulfilled" ? summaryResult.value : null; const gem = parseGeminiWindow(quotaPayload); const weekly = parseWeeklyWindow(summaryPayload); if (!gem && !weekly) return null; diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index 518248c7aa..aef37eb3fc 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; +import { QUOTA_RESPONSE_MAX_BYTES } from "../src/providers/quota"; test("does not classify an unlabelled daily summary window as weekly", async () => { const result = await fetchAntigravityLiveQuota({ @@ -28,6 +29,56 @@ test("does not classify an unlabelled daily summary window as weekly", async () expect(result?.customWindows?.[0]?.label).toBe("Gem"); expect(result?.weeklyPercent).toBeUndefined(); }); + +test("keeps the daily quota when the summary RPC fails", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return new Response(JSON.stringify({ + buckets: [ + { modelId: "gemini-test", remainingFraction: 0.5 }, + ], + }), { status: 200 }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) return new Response("unavailable", { status: 503 }); + return new Response("not found", { status: 404 }); + }, + }); + + expect(result?.customWindows).toEqual([{ label: "Gem", percent: 50 }]); + expect(result?.weeklyPercent).toBeUndefined(); +}); + +test("treats a daily quota JSON read failure as an RPC failure", async () => { + const result = await fetchAntigravityLiveQuota({ + accessToken: "agy-access-secret", + projectId: "agy-project-secret", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + timeoutMs: 1_000, + fetchImpl: async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith(":retrieveUserQuota")) { + return new Response(JSON.stringify({ + padding: "x".repeat(QUOTA_RESPONSE_MAX_BYTES), + }), { status: 200 }); + } + if (url.endsWith(":retrieveUserQuotaSummary")) { + return new Response(JSON.stringify({ + weekly: { remainingPercentage: 75 }, + }), { status: 200 }); + } + return new Response("not found", { status: 404 }); + }, + }); + + expect(result).toBeNull(); +}); + import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; From ae764b39eb518102db932a07e2f3559b3338ba32 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:34 -0600 Subject: [PATCH 008/102] feat(antigravity): Claude CCA wire fidelity Send the interleaved-thinking header and preamble replacement, strip trailing Claude prefills, repair orphan tool pairs, and parse unary CCA as SSE with a pre-decode frame cap. Co-authored-by: Cursor --- src/adapters/google-antigravity-tools.ts | 87 ++++++++++++++++ src/adapters/google.ts | 74 +++++++++----- tests/google-adapter.test.ts | 27 +++++ tests/google-antigravity-wire.test.ts | 125 +++++++++++++++++++++-- tests/google-sse-frame-cap.test.ts | 116 +++++++++++++++++++++ 5 files changed, 394 insertions(+), 35 deletions(-) create mode 100644 src/adapters/google-antigravity-tools.ts create mode 100644 tests/google-sse-frame-cap.test.ts diff --git a/src/adapters/google-antigravity-tools.ts b/src/adapters/google-antigravity-tools.ts new file mode 100644 index 0000000000..08998da332 --- /dev/null +++ b/src/adapters/google-antigravity-tools.ts @@ -0,0 +1,87 @@ +import type { + OcxAssistantMessage, + OcxMessage, + OcxToolCall, + OcxToolResultMessage, +} from "../types"; + +function isAssistantToolCall(message: OcxMessage): message is OcxAssistantMessage { + return message.role === "assistant"; +} + +function isToolResult(message: OcxMessage): message is OcxToolResultMessage { + return message.role === "toolResult"; +} + +/** + * Repair incomplete tool exchanges before assigning provider-visible ids. + * + * CCA translates Gemini function calls and responses into Anthropic tool blocks, + * which requires both sides of every exchange. A result is valid only when its + * call appeared earlier in the history, and a call is valid only when a result + * appears later. Filtering the history first also prevents orphan results from + * reserving ids in the request-scoped allocator. + */ +export function repairGoogleToolPairs(messages: readonly OcxMessage[]): OcxMessage[] { + const matchedCallIds = new Set(); + const callIdsBeforeResult = new Set(); + + for (let index = 0; index < messages.length; index++) { + const message = messages[index]!; + if (isAssistantToolCall(message)) { + for (const part of message.content) { + if (part.type !== "toolCall") continue; + const toolCall = part as OcxToolCall; + for (let later = index + 1; later < messages.length; later++) { + const candidate = messages[later]!; + if (isToolResult(candidate) && candidate.toolCallId === toolCall.id) { + matchedCallIds.add(toolCall.id); + break; + } + } + } + } else if (isToolResult(message)) { + for (let earlier = index - 1; earlier >= 0; earlier--) { + const candidate = messages[earlier]!; + if (!isAssistantToolCall(candidate)) continue; + if (candidate.content.some(part => part.type === "toolCall" && (part as OcxToolCall).id === message.toolCallId)) { + callIdsBeforeResult.add(message.toolCallId); + break; + } + } + } + } + + const repaired: OcxMessage[] = []; + for (const message of messages) { + if (isToolResult(message)) { + if (callIdsBeforeResult.has(message.toolCallId)) repaired.push(message); + continue; + } + if (!isAssistantToolCall(message)) { + repaired.push(message); + continue; + } + + const content = message.content.filter(part => + part.type !== "toolCall" || matchedCallIds.has((part as OcxToolCall).id)); + if (content.length > 0) { + repaired.push(content.length === message.content.length ? message : { ...message, content }); + } + } + return repaired; +} + +/** + * Claude interprets a final model turn as a prefilled assistant response. + * CCA expects the next turn to be generated instead, except when that model + * turn is the entire conversation and must remain as the initial context. + */ +export function stripTrailingClaudePrefill(contents: unknown[]): unknown[] { + while (contents.length >= 2) { + const last = contents[contents.length - 1]; + if (typeof last !== "object" || last === null || (last as { role?: unknown }).role !== "model") break; + contents.pop(); + } + return contents; +} diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9a71dc9ea4..6ba91f1ed9 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -21,6 +21,7 @@ import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; +import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "./google-antigravity-tools"; import { compileGoogleWireBody } from "./google-wire-compiler"; import { identifyRoutedModel } from "./identity"; import { antigravityUsesReplayCache, applyAntigravityReplay, clearAntigravityReplay, observeAntigravityReplay } from "./google-antigravity-replay"; @@ -158,9 +159,10 @@ function messagesToGeminiFormat( const systemInstruction = { parts: [{ text: systemText }] }; const contents: unknown[] = []; + const messages = repairGoogleToolPairs(parsed.context.messages); const callIds = createToolCallIdAllocator(); - for (const msg of parsed.context.messages) { + for (const msg of messages) { if (msg.role === "assistant") { for (const part of (msg as OcxAssistantMessage).content) { if (part.type === "toolCall") callIds.reserve((part as OcxToolCall).id); @@ -169,7 +171,7 @@ function messagesToGeminiFormat( callIds.reserve((msg as OcxToolResultMessage).toolCallId); } } - for (const msg of parsed.context.messages) { + for (const msg of messages) { switch (msg.role) { case "user": case "developer": { @@ -308,6 +310,12 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | */ const MAX_RESPONSE_BYTES = 100 * 1024 * 1024; const MAX_SSE_FRAME_BYTES = MAX_RESPONSE_BYTES; +let sseFrameMaxBytes = MAX_SSE_FRAME_BYTES; + +/** Test-only: lower the SSE frame byte cap without allocating a 100 MiB fixture. */ +export function setGoogleSseFrameMaxBytesForTests(bytes?: number): void { + sseFrameMaxBytes = bytes ?? MAX_SSE_FRAME_BYTES; +} // Note: imagen-* models use a different API surface (prediction/image-generation // schema) and must NOT be treated as responseModalities-capable Gemini models. @@ -439,8 +447,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig; - const method = parsed.stream ? "streamGenerateContent" : "generateContent"; - const streamParam = parsed.stream ? "?alt=sse" : ""; + const ccaAlwaysSse = provider.googleMode === "cloud-code-assist"; + const method = ccaAlwaysSse || parsed.stream ? "streamGenerateContent" : "generateContent"; + const streamParam = ccaAlwaysSse || parsed.stream ? "?alt=sse" : ""; const headers: Record = { "Content-Type": "application/json" }; if (provider.headers) Object.assign(headers, provider.headers); @@ -475,6 +484,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // nested) — matching CLIProxyAPI `generateStableSessionID`. An extra top-level/snake_case // spelling is a non-first-party key, so we send the single canonical location. const draftRequest: Record = { ...body, sessionId }; + if (systemInstruction) { + draftRequest.preambleConfig = { mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }; + } // Claude-on-Antigravity forces VALIDATED function calling (the real client always sets it). if (/claude/i.test(wireModelId)) { // VALIDATED would defeat a client's tool_choice "none": honor it by dropping the @@ -489,10 +501,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const compiled = compileGoogleWireBody(draftRequest); const request = compiled.body; + if (systemInstruction) { + request.preambleConfig = { mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }; + } restoreGoogleToolName = compiled.restoreToolName; // Compile names before replay: signatures are keyed by the exact provider-visible name. if (Array.isArray((request as { contents?: unknown[] }).contents)) { const contents = (request as { contents: unknown[] }).contents; + if (/claude/i.test(wireModelId)) stripTrailingClaudePrefill(contents); if (antigravityUsesReplayCache(wireModelId)) { applyAntigravityReplay(wireModelId, sessionId, contents); } else { @@ -523,6 +539,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }; headers["User-Agent"] = ANTIGRAVITY_REQUEST_UA; headers["Authorization"] = `Bearer ${token}`; + if (/claude/i.test(wireModelId)) { + headers["anthropic-beta"] = "interleaved-thinking-2025-05-14"; + } return { url, method: "POST", headers, body: JSON.stringify(envelope) }; } @@ -598,8 +617,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); if (!payload) return "continue"; - if (payload.length > MAX_SSE_FRAME_BYTES) { - yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; + if (payload.length > sseFrameMaxBytes) { + yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; return "terminate"; } let emittedContentEvent = false; @@ -737,6 +756,15 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte while (true) { const { done, value } = await reader.read(); if (done) break; + const incomingBytes = value?.byteLength ?? 0; + // Cap incomplete frames before decode and before waiting for a newline — + // otherwise a single unterminated data: payload can grow without bound, and + // buffer.length is UTF-16 units rather than bytes. + if (bufferBytes + incomingBytes > sseFrameMaxBytes) { + yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; + try { await reader.cancel(); } catch { /* ignore */ } + return; + } const nextBuffer = buffer + decoder.decode(value, { stream: true }); const nextBufferBytes = budgetEncoder.encode(nextBuffer).byteLength; const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" }); @@ -744,13 +772,6 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte appendReservation.commitRetained(); budget.releaseRetained(bufferBytes, { kind: "live_transient" }); bufferBytes = nextBufferBytes; - // Cap incomplete frames before waiting for a newline — otherwise a single - // unterminated data: payload can grow without bound. - if (buffer.length > MAX_SSE_FRAME_BYTES) { - yield { type: "error", message: `upstream SSE data frame exceeds ${MAX_SSE_FRAME_BYTES} bytes` }; - try { await reader.cancel(); } catch { /* ignore */ } - return; - } const lines = buffer.split("\n"); buffer = lines.pop() ?? ""; @@ -823,6 +844,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte }, async parseResponse(response: Response, budget: TranslatorBudget): Promise { + // Cloud Code Assist exposes only the SSE transport. Unary callers still use this + // buffered adapter entry point, so collect the exact same events parseStream emits + // instead of maintaining a second CCA JSON parser. + if (provider.googleMode === "cloud-code-assist") { + const events: AdapterEvent[] = []; + for await (const event of this.parseStream(response, budget)) events.push(event); + return events; + } // Reject oversized responses before JSON parse. Prefer Content-Length when // present and truthful; always stream-read with a hard byte cap so a missing // or lying Content-Length cannot force a full in-memory buffer + parse. @@ -893,15 +922,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const err = raw.error as { message?: string }; return finish([{ type: "error", message: err.message ?? "upstream error" }]); } - // Antigravity (CCA) nests the standard Gemini payload under `response`; unwrap it. - let json = raw; - if (provider.googleMode === "cloud-code-assist") { - const wrapped = raw.response; - if (!wrapped || typeof wrapped !== "object" || Array.isArray(wrapped)) { - return finish([{ type: "error", message: "google-antigravity response missing response wrapper" }]); - } - json = wrapped as Record; - } + const json = raw; const events: AdapterEvent[] = []; const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined; @@ -913,10 +934,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (candidates?.[0]?.content?.parts) { // Non-streaming Google-family response: observe thought signatures for the next turn, // using the same transport-scoped namespace as the streaming path. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") - && replayModel && replaySession) { + const replayModel = vertexReplayModel; + const replaySession = vertexReplaySession; + if (provider.googleMode === "vertex" && replayModel && replaySession) { observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); } for (const part of candidates[0].content.parts) { @@ -953,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done. - if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") + if (provider.googleMode === "vertex" && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) { return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]); } diff --git a/tests/google-adapter.test.ts b/tests/google-adapter.test.ts index 15af3a264a..4d65b99740 100644 --- a/tests/google-adapter.test.ts +++ b/tests/google-adapter.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter } from "../src/adapters/google"; +import { anthropicToolCallId } from "../src/adapters/tool-call-id"; import type { OcxParsedRequest } from "../src/types"; const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; @@ -133,6 +134,30 @@ describe("google adapter — tool-call ids on the wire", () => { expect(frPart.functionResponse.id).toBe("call_abc"); }); + test("orphan tool results are omitted instead of emitting an unmatched functionResponse", async () => { + const contents = await geminiContents(parsedWith([ + { role: "toolResult", toolCallId: "orphan", toolName: "missing", content: "discard", isError: false }, + { role: "user", content: "continue" }, + ])); + + expect(contents.flatMap(content => content.parts).some(part => "functionResponse" in part)).toBe(false); + expect(JSON.stringify(contents)).not.toContain("orphan"); + }); + + test("orphan result ids do not reserve allocator slots", async () => { + const rawId = "call:a"; + const normalizedId = anthropicToolCallId(rawId)!; + const contents = await geminiContents(parsedWith([ + { role: "assistant", content: [{ type: "toolCall", id: rawId, name: "bash", arguments: {} }] }, + { role: "toolResult", toolCallId: rawId, toolName: "bash", content: "ok", isError: false }, + { role: "toolResult", toolCallId: normalizedId, toolName: "missing", content: "discard", isError: false }, + ])); + + const functionCall = contents.find(content => content.role === "model")!.parts + .find(part => "functionCall" in part) as { functionCall: { id?: string } }; + expect(functionCall.functionCall.id).toBe(normalizedId); + }); + test("ids are normalized to Anthropic's tool_use.id charset, preserving call/response pairing", async () => { const contents = await geminiContents(parsedWith([ { role: "assistant", content: [{ type: "toolCall", id: "fc:weird/id#1", name: "bash", arguments: {} }] }, @@ -153,6 +178,8 @@ describe("google adapter — tool-call ids on the wire", () => { { type: "toolCall", id: "call:a", name: "bash", arguments: {} }, { type: "toolCall", id: "call/a", name: "bash", arguments: {} }, ] }, + { role: "toolResult", toolCallId: "call:a", toolName: "bash", content: "one", isError: false }, + { role: "toolResult", toolCallId: "call/a", toolName: "bash", content: "two", isError: false }, ])); const ids = contents.find(c => c.role === "model")!.parts .filter(p => "functionCall" in p) diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 986b3eef45..f7ac7fb4ae 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; import { antigravitySessionId, isLikelyRealThoughtSignature } from "../src/adapters/google-antigravity-wire"; +import { antigravityHostCandidates } from "../src/adapters/google-antigravity-hosts"; +import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "../src/adapters/google-antigravity-tools"; import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_EFFORTS, canonicalAntigravityUsageModel, parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels, resolveAntigravityEffortWireModel, resolveAntigravityWireModelId } from "../src/providers/antigravity-models"; import { MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, MODEL_DISCOVERY_MAX_MODELS } from "../src/providers/model-discovery"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; @@ -44,7 +46,7 @@ describe("antigravity CCA envelope", () => { test("wraps the gemini body in the CCA envelope with project/userAgent/requestType/requestId/sessionId", async () => { const req = await createGoogleAdapter(provider).buildRequest(parsed()); const env = JSON.parse(req.body); - expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent"); + expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); expect(env.model).toBe("gemini-3-pro"); // The envelope BODY userAgent is the protocol constant; the versioned CLI UA rides in the header. expect(env.userAgent).toBe("antigravity"); @@ -76,6 +78,56 @@ describe("antigravity CCA envelope", () => { expect(req.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); }); + test("host candidates keep the configured host first and use only daily/prod", () => { + expect(antigravityHostCandidates("https://daily-cloudcode-pa.googleapis.com")).toEqual([ + "https://daily-cloudcode-pa.googleapis.com", + "https://cloudcode-pa.googleapis.com", + ]); + expect(antigravityHostCandidates("https://cloudcode-pa.googleapis.com/")).toEqual([ + "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.googleapis.com", + ]); + }); + + test("Claude CCA adds the interleaved-thinking beta header and preamble mode", async () => { + const req = await createGoogleAdapter(provider).buildRequest(parsed("x", false, "claude-sonnet-4-6")); + const env = JSON.parse(req.body); + + expect(req.headers["anthropic-beta"]).toBe("interleaved-thinking-2025-05-14"); + expect(env.request.preambleConfig).toEqual({ mode: "SYSTEM_INSTRUCTION_MODE_REPLACE" }); + }); + + test("Gemini CCA does not receive the Claude beta header", async () => { + const req = await createGoogleAdapter(provider).buildRequest(parsed()); + expect(req.headers["anthropic-beta"]).toBeUndefined(); + }); + + test("Claude CCA strips a trailing prefill model turn but keeps a lone model turn", async () => { + const withPrefill = { + ...parsed("x", false, "claude-sonnet-4-6"), + context: { + messages: [ + { role: "user", content: "question" }, + { role: "assistant", content: [{ type: "text", text: "prefill" }] }, + ], + systemPrompt: [], + tools: [], + }, + } as unknown as OcxParsedRequest; + const prefillEnv = JSON.parse((await createGoogleAdapter(provider).buildRequest(withPrefill)).body); + expect(prefillEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["user"]); + + const loneModel = { + ...withPrefill, + context: { + ...withPrefill.context, + messages: [{ role: "assistant", content: [{ type: "text", text: "only turn" }] }], + }, + } as unknown as OcxParsedRequest; + const loneEnv = JSON.parse((await createGoogleAdapter(provider).buildRequest(loneModel)).body); + expect(loneEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["model"]); + }); + test("exposes Gemini 3.7 Flash while retired Flash ids resolve to it", async () => { // Collapsed picker: base models only. expect(ANTIGRAVITY_MODELS).toEqual([ @@ -631,6 +683,50 @@ describe("antigravity CCA envelope", () => { }); }); +describe("Google Antigravity history repair", () => { + test("drops orphan tool results and unmatched trailing calls", () => { + const messages = [ + { role: "user", content: "run tools" }, + { + role: "assistant", + content: [ + { type: "toolCall", id: "call-1", name: "one", arguments: {} }, + { type: "toolCall", id: "call-2", name: "two", arguments: {} }, + ], + }, + { role: "toolResult", toolCallId: "call-1", toolName: "one", content: "ok", isError: false }, + { role: "toolResult", toolCallId: "orphan", toolName: "missing", content: "discard", isError: false }, + ] as unknown as Parameters[0]; + + const repaired = repairGoogleToolPairs(messages); + expect(repaired).toHaveLength(3); + expect((repaired[1] as { content: { id: string }[] }).content.map(part => part.id)).toEqual(["call-1"]); + expect((repaired[2] as { toolCallId: string }).toolCallId).toBe("call-1"); + }); + + test("keeps parallel calls when every call has a later result", () => { + const messages = [ + { role: "assistant", content: [ + { type: "toolCall", id: "call-1", name: "one", arguments: {} }, + { type: "toolCall", id: "call-2", name: "two", arguments: {} }, + ] }, + { role: "toolResult", toolCallId: "call-1", toolName: "one", content: "one", isError: false }, + { role: "toolResult", toolCallId: "call-2", toolName: "two", content: "two", isError: false }, + ] as unknown as Parameters[0]; + + const repaired = repairGoogleToolPairs(messages); + expect((repaired[0] as { content: { id: string }[] }).content.map(part => part.id)).toEqual(["call-1", "call-2"]); + expect(repaired).toHaveLength(3); + }); + + test("strips only trailing model turns when another content turn remains", () => { + expect(stripTrailingClaudePrefill([{ role: "user" }, { role: "model" }, { role: "model" }])) + .toEqual([{ role: "user" }]); + expect(stripTrailingClaudePrefill([{ role: "model" }])) + .toEqual([{ role: "model" }]); + }); +}); + function sseResponse(chunks: unknown[]): Response { const body = chunks.map(c => `data: ${JSON.stringify(c)}\n`).join("\n") + "\n"; return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); @@ -653,10 +749,20 @@ describe("antigravity parseStream unwraps response", () => { }); describe("antigravity parseResponse unwraps response (non-streaming)", () => { + test("buffers CCA SSE frames for unary callers", async () => { + const adapter = createGoogleAdapter(provider); + const events = await adapter.parseResponse!(sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ])); + expect(events).toContainEqual({ type: "text_delta", text: "hello" }); + expect(events.at(-1)?.type).toBe("done"); + }); + test("reads response.candidates + response.usageMetadata from the CCA envelope", async () => { const adapter = createGoogleAdapter(provider); - const body = JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "hello" }] } }], usageMetadata: { promptTokenCount: 9, candidatesTokenCount: 2, cachedContentTokenCount: 7 } } }); - const events = await adapter.parseResponse!(new Response(body, { status: 200 })); + const body = { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }], usageMetadata: { promptTokenCount: 9, candidatesTokenCount: 2, cachedContentTokenCount: 7 } } }; + const events = await adapter.parseResponse!(sseResponse([body])); expect(events.some(e => e.type === "text_delta" && e.text === "hello")).toBe(true); const done = events.find(e => e.type === "done"); expect((done as Extract).usage?.inputTokens).toBe(9); @@ -669,8 +775,8 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { const adapter = createGoogleAdapter(provider); // buildRequest first to set the per-adapter model/session, then parseResponse to observe. await adapter.buildRequest(parsed("hello world")); - const body = JSON.stringify({ response: { candidates: [{ content: { parts: [{ functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-nonstream0000000" } ] } }] } }); - await adapter.parseResponse!(new Response(body, { status: 200 })); + const body = { response: { candidates: [{ content: { parts: [{ functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-nonstream0000000" } ] } }] } }; + await adapter.parseResponse!(sseResponse([body])); // A follow-up request's history should now get the signature re-injected. const followup = parsed("hello world"); const contents = [{ role: "model", parts: [{ functionCall: { name: "do_x", args: { a: 1 } } }] }]; @@ -689,7 +795,7 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { __resetAntigravityReplayCache(); const adapter = createGoogleAdapter(provider); await adapter.buildRequest(parsed("hello world")); - const body = JSON.stringify({ + const body = { response: { candidates: [{ content: { @@ -700,8 +806,8 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { }, }], }, - }); - const events = await adapter.parseResponse!(new Response(body, { status: 200 })); + }; + const events = await adapter.parseResponse!(sseResponse([body])); expect(events).not.toContainEqual({ type: "text_delta", text: "deciding which tool to call" }); @@ -721,6 +827,7 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { messages: [ { role: "user", content: "go" }, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "get_x", namespace: "mcp__t", arguments: { a: 1 }, thoughtSignature: "sig-abcdef0123456789" }] }, + { role: "toolResult", toolCallId: "c1", toolName: "get_x", content: "ok", isError: false }, ], systemPrompt: [], tools: [], }, @@ -741,6 +848,7 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { messages: [ { role: "user", content: "go" }, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "get_x", namespace: "mcp__t", arguments: {}, thoughtSignature: "fc_d8df7548e31a4130b7624f3d27571cdd" }] }, + { role: "toolResult", toolCallId: "c1", toolName: "get_x", content: "ok", isError: false }, ], systemPrompt: [], tools: [], }, @@ -761,6 +869,7 @@ describe("antigravity history preserves tool-call thoughtSignature", () => { messages: [ { role: "user", content: "go" }, { role: "assistant", content: [{ type: "toolCall", id: "c1", name: "get_x", namespace: "mcp__t", arguments: {}, thoughtSignature: "ctc_038f26d3f20962bc016a54f0fcfa208190a8ec0f289c2ba211" }] }, + { role: "toolResult", toolCallId: "c1", toolName: "get_x", content: "ok", isError: false }, ], systemPrompt: [], tools: [], }, diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts new file mode 100644 index 0000000000..29b1341874 --- /dev/null +++ b/tests/google-sse-frame-cap.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + createGoogleAdapter as createGoogleAdapterProduction, + setGoogleSseFrameMaxBytesForTests, +} from "../src/adapters/google"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const CAP = 32; +const originalDecode = TextDecoder.prototype.decode; +let decodedOverflowByteLength = 0; + +function installDecodeProbe(): void { + decodedOverflowByteLength = 0; + TextDecoder.prototype.decode = function ( + this: TextDecoder, + input?: AllowSharedBufferSource, + options?: TextDecodeOptions, + ): string { + const size = input && typeof (input as ArrayBufferView).byteLength === "number" + ? (input as ArrayBufferView).byteLength + : 0; + if (size > CAP) decodedOverflowByteLength = size; + return originalDecode.call(this, input as ArrayBuffer, options); + }; +} + +afterEach(() => { + setGoogleSseFrameMaxBytesForTests(); + TextDecoder.prototype.decode = originalDecode; + decodedOverflowByteLength = 0; +}); + +function googleProvider(): OcxProviderConfig { + return { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "google-test-key", + authMode: "key", + }; +} + +function ccaProvider(): OcxProviderConfig { + return { + adapter: "google", + baseUrl: "https://daily-cloudcode-pa.googleapis.com", + apiKey: "antigravity-test-token", + authMode: "oauth", + googleMode: "cloud-code-assist", + project: "project-test", + }; +} + +/** 20 × U+4E2D: 60 UTF-8 bytes, 20 UTF-16 units — over a 32-byte cap, under it as string length. */ +function oversizedMultibyteChunk(): Uint8Array { + const charUtf8 = new TextEncoder().encode("中"); + const repeats = 20; + const chunk = new Uint8Array(charUtf8.byteLength * repeats); + for (let i = 0; i < repeats; i++) chunk.set(charUtf8, i * charUtf8.byteLength); + return chunk; +} + +function byteStreamResponse(chunks: Uint8Array[]): Response { + return new Response(new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(chunk); + controller.close(); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +async function collect(events: AsyncGenerator): Promise { + const collected: AdapterEvent[] = []; + for await (const event of events) collected.push(event); + return collected; +} + +describe("google SSE frame byte cap", () => { + test("rejects an oversize UTF-8 chunk before TextDecoder.decode", async () => { + const chunk = oversizedMultibyteChunk(); + expect(chunk.byteLength).toBeGreaterThan(CAP); + expect(new TextDecoder().decode(chunk).length).toBeLessThan(CAP); + + setGoogleSseFrameMaxBytesForTests(CAP); + installDecodeProbe(); + + const events = await collect( + createGoogleAdapter(googleProvider()).parseStream(byteStreamResponse([chunk])), + ); + + expect(decodedOverflowByteLength).toBe(0); + expect(events).toContainEqual({ + type: "error", + message: `upstream SSE data frame exceeds ${CAP} bytes`, + }); + }); + + test("CCA unary parseResponse applies the same SSE byte cap", async () => { + const chunk = oversizedMultibyteChunk(); + setGoogleSseFrameMaxBytesForTests(CAP); + installDecodeProbe(); + + const events = await createGoogleAdapter(ccaProvider()).parseResponse!( + byteStreamResponse([chunk]), + ); + + expect(decodedOverflowByteLength).toBe(0); + expect(events).toContainEqual({ + type: "error", + message: `upstream SSE data frame exceeds ${CAP} bytes`, + }); + }); +}); From 3cc9c2b58d947843d04dc4f6896e6d7e5c0e5987 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:14:53 -0600 Subject: [PATCH 009/102] fix(google): retain CCA events and cap SSE frames correctly Keep unary Cloud Code Assist events charged to the translator budget and measure SSE limits per byte-delimited frame so valid frames sharing a transport chunk are accepted. Co-authored-by: Cursor --- src/adapters/google.ts | 22 ++++++++++++++++++---- tests/google-antigravity-wire.test.ts | 14 +++++++++++++- tests/google-sse-frame-cap.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 6ba91f1ed9..8bd4cd5c75 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -317,6 +317,17 @@ export function setGoogleSseFrameMaxBytesForTests(bytes?: number): void { sseFrameMaxBytes = bytes ?? MAX_SSE_FRAME_BYTES; } +function maxSseLineBytes(bufferBytes: number, incoming: Uint8Array): number { + let lineBytes = bufferBytes; + let maximum = lineBytes; + for (const byte of incoming) { + lineBytes += 1; + maximum = Math.max(maximum, lineBytes); + if (byte === 0x0a) lineBytes = 0; + } + return maximum; +} + // Note: imagen-* models use a different API surface (prediction/image-generation // schema) and must NOT be treated as responseModalities-capable Gemini models. // Explicit allowlist only — never `/gemini/ && /image/` (resurrects media-gen IDs). @@ -617,7 +628,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const handleDataLine = async function* (line: string): AsyncGenerator { const payload = line.slice(5).trim(); if (!payload) return "continue"; - if (payload.length > sseFrameMaxBytes) { + if (budgetEncoder.encode(payload).byteLength > sseFrameMaxBytes) { yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; return "terminate"; } @@ -757,10 +768,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const { done, value } = await reader.read(); if (done) break; const incomingBytes = value?.byteLength ?? 0; - // Cap incomplete frames before decode and before waiting for a newline — + // Cap each incomplete line before decode and before waiting for a newline — // otherwise a single unterminated data: payload can grow without bound, and - // buffer.length is UTF-16 units rather than bytes. - if (bufferBytes + incomingBytes > sseFrameMaxBytes) { + // buffer.length is UTF-16 units rather than bytes. Reset at each newline so + // several sub-cap frames delivered in one network chunk are not rejected as + // one oversized frame. + if (maxSseLineBytes(bufferBytes, value) > sseFrameMaxBytes) { yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; try { await reader.cancel(); } catch { /* ignore */ } return; @@ -850,6 +863,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (provider.googleMode === "cloud-code-assist") { const events: AdapterEvent[] = []; for await (const event of this.parseStream(response, budget)) events.push(event); + retainTranslatedEventBatch(events, budget); return events; } // Reject oversized responses before JSON parse. Prefer Content-Length when diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index f7ac7fb4ae..d47e8d922b 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -6,7 +6,7 @@ import { repairGoogleToolPairs, stripTrailingClaudePrefill } from "../src/adapte import { ANTIGRAVITY_MODELS, ANTIGRAVITY_MODEL_EFFORTS, canonicalAntigravityUsageModel, parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels, resolveAntigravityEffortWireModel, resolveAntigravityWireModelId } from "../src/providers/antigravity-models"; import { MODEL_DISCOVERY_MAX_MODEL_ID_LENGTH, MODEL_DISCOVERY_MAX_MODELS } from "../src/providers/model-discovery"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; -import { withTestTranslatorBudget } from "./helpers/translator-budget"; +import { createTestTranslatorBudget, withTestTranslatorBudget } from "./helpers/translator-budget"; const createGoogleAdapter = (...args: Parameters) => withTestTranslatorBudget(createGoogleAdapterProduction(...args)); @@ -759,6 +759,18 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { expect(events.at(-1)?.type).toBe("done"); }); + test("unary CCA responses retain the translated event batch in the translator budget", async () => { + const adapter = createGoogleAdapter(provider); + const budget = createTestTranslatorBudget({ maxTurnBytes: 1024 }); + const events = await adapter.parseResponse!(sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]), budget); + + expect(events).toContainEqual({ type: "text_delta", text: "hello" }); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + }); + test("reads response.candidates + response.usageMetadata from the CCA envelope", async () => { const adapter = createGoogleAdapter(provider); const body = { response: { candidates: [{ content: { parts: [{ text: "hello" }] } }], usageMetadata: { promptTokenCount: 9, candidatesTokenCount: 2, cachedContentTokenCount: 7 } } }; diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts index 29b1341874..a6b9a6cbd4 100644 --- a/tests/google-sse-frame-cap.test.ts +++ b/tests/google-sse-frame-cap.test.ts @@ -113,4 +113,26 @@ describe("google SSE frame byte cap", () => { message: `upstream SSE data frame exceeds ${CAP} bytes`, }); }); + + test("accepts multiple sub-cap data frames delivered in one oversized chunk", async () => { + const cap = 96; + const body = [ + `data: ${JSON.stringify({ response: { candidates: [{ content: { parts: [{ text: "a" }] } }] } })}\n`, + `data: ${JSON.stringify({ response: { candidates: [{ finishReason: "STOP" }] } })}\n`, + ].join("\n"); + const lines = body.split("\n").filter(Boolean); + expect(new TextEncoder().encode(body).byteLength).toBeGreaterThan(cap); + expect(Math.max(...lines.map(line => new TextEncoder().encode(line).byteLength))).toBeLessThanOrEqual(cap); + + setGoogleSseFrameMaxBytesForTests(cap); + const events = await collect( + createGoogleAdapter(ccaProvider()).parseStream(byteStreamResponse([ + new TextEncoder().encode(body), + ])), + ); + + expect(events).toContainEqual({ type: "text_delta", text: "a" }); + expect(events).toContainEqual({ type: "done", usage: undefined }); + expect(events.some(event => event.type === "error")).toBe(false); + }); }); From 3a379b8b4e7d0a2234a314c0354ed82f12347d41 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:01:27 -0600 Subject: [PATCH 010/102] fix(google): count raw SSE line bytes across UTF-8 chunk splits Decoded bufferBytes undercounts when TextDecoder holds a pending multibyte sequence, so a 33-byte line split mid-character could pass a 32-byte cap. Cap on the raw incomplete line instead. Co-authored-by: Cursor --- src/adapters/google.ts | 30 ++++++++++++------- tests/google-sse-frame-cap.test.ts | 46 ++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 10 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 8bd4cd5c75..563e9cffcf 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -317,15 +317,18 @@ export function setGoogleSseFrameMaxBytesForTests(bytes?: number): void { sseFrameMaxBytes = bytes ?? MAX_SSE_FRAME_BYTES; } -function maxSseLineBytes(bufferBytes: number, incoming: Uint8Array): number { - let lineBytes = bufferBytes; +function scanSseLineBytes(incompleteLineBytes: number, incoming: Uint8Array): { + maximum: number; + residual: number; +} { + let lineBytes = incompleteLineBytes; let maximum = lineBytes; for (const byte of incoming) { lineBytes += 1; maximum = Math.max(maximum, lineBytes); if (byte === 0x0a) lineBytes = 0; } - return maximum; + return { maximum, residual: lineBytes }; } // Note: imagen-* models use a different API surface (prediction/image-generation @@ -619,6 +622,10 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const budgetEncoder = new TextEncoder(); let buffer = ""; let bufferBytes = 0; + // Raw unterminated-line bytes, independent of TextDecoder's pending UTF-8 + // state. `bufferBytes` is the decoded residual and can undercount by 1–3 + // bytes when a chunk ends mid-character. + let incompleteLineBytes = 0; let pendingUsage: OcxUsage | undefined; let toolCallsStarted = 0; let lastFinishReason: string | undefined; @@ -767,17 +774,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte while (true) { const { done, value } = await reader.read(); if (done) break; - const incomingBytes = value?.byteLength ?? 0; - // Cap each incomplete line before decode and before waiting for a newline — - // otherwise a single unterminated data: payload can grow without bound, and - // buffer.length is UTF-16 units rather than bytes. Reset at each newline so - // several sub-cap frames delivered in one network chunk are not rejected as - // one oversized frame. - if (maxSseLineBytes(bufferBytes, value) > sseFrameMaxBytes) { + const incoming = value ?? new Uint8Array(); + // Cap each incomplete line on raw bytes before decode and before waiting + // for a newline — otherwise a single unterminated data: payload can grow + // without bound, buffer.length is UTF-16 units, and a mid-character + // decode residual undercounts the true line. Reset at each newline so + // several sub-cap frames in one network chunk are not rejected as one + // oversized frame. + const lineScan = scanSseLineBytes(incompleteLineBytes, incoming); + if (lineScan.maximum > sseFrameMaxBytes) { yield { type: "error", message: `upstream SSE data frame exceeds ${sseFrameMaxBytes} bytes` }; try { await reader.cancel(); } catch { /* ignore */ } return; } + incompleteLineBytes = lineScan.residual; const nextBuffer = buffer + decoder.decode(value, { stream: true }); const nextBufferBytes = budgetEncoder.encode(nextBuffer).byteLength; const appendReservation = budget.reserveTransient(nextBufferBytes, { kind: "live_transient" }); diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts index a6b9a6cbd4..80cd498b4f 100644 --- a/tests/google-sse-frame-cap.test.ts +++ b/tests/google-sse-frame-cap.test.ts @@ -114,6 +114,52 @@ describe("google SSE frame byte cap", () => { }); }); + test("rejects a split oversized multibyte line before decoding the completing chunk", async () => { + const zhong = new TextEncoder().encode("中"); + expect(zhong.byteLength).toBe(3); + const prefix = new TextEncoder().encode("data: "); + const fill = new Uint8Array(CAP + 1 - prefix.byteLength - zhong.byteLength).fill(0x61); + const first = new Uint8Array(prefix.byteLength + fill.byteLength + 1); + first.set(prefix, 0); + first.set(fill, prefix.byteLength); + first.set(zhong.subarray(0, 1), prefix.byteLength + fill.byteLength); + const completing = zhong.subarray(1); + expect(first.byteLength).toBe(CAP - 1); + expect(first.byteLength + completing.byteLength).toBe(CAP + 1); + + setGoogleSseFrameMaxBytesForTests(CAP); + let decodedCompletingChunk = false; + TextDecoder.prototype.decode = function ( + this: TextDecoder, + input?: AllowSharedBufferSource, + options?: TextDecodeOptions, + ): string { + const view = input instanceof Uint8Array + ? input + : input && typeof (input as ArrayBufferView).byteLength === "number" + ? new Uint8Array(input as ArrayBufferView) + : null; + if ( + view + && view.byteLength === completing.byteLength + && completing.every((byte, index) => view[index] === byte) + ) { + decodedCompletingChunk = true; + } + return originalDecode.call(this, input as ArrayBuffer, options); + }; + + const events = await collect( + createGoogleAdapter(googleProvider()).parseStream(byteStreamResponse([first, completing])), + ); + + expect(decodedCompletingChunk).toBe(false); + expect(events).toContainEqual({ + type: "error", + message: `upstream SSE data frame exceeds ${CAP} bytes`, + }); + }); + test("accepts multiple sub-cap data frames delivered in one oversized chunk", async () => { const cap = 96; const body = [ From bbe89eeab29656ccc40ed83e7894a680edc5a509 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 18:55:16 -0600 Subject: [PATCH 011/102] test(google): account for Claude continuation nudge Keep the CCA wire regression aligned with the upstream Claude prefill guard after restacking onto current dev. --- tests/google-antigravity-wire.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index d47e8d922b..a811748715 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -125,7 +125,7 @@ describe("antigravity CCA envelope", () => { }, } as unknown as OcxParsedRequest; const loneEnv = JSON.parse((await createGoogleAdapter(provider).buildRequest(loneModel)).body); - expect(loneEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["model"]); + expect(loneEnv.request.contents.map((content: { role: string }) => content.role)).toEqual(["model", "user"]); }); test("exposes Gemini 3.7 Flash while retired Flash ids resolve to it", async () => { From 0c0d419894d5ca082db38da8b3b6484674211d82 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:08:34 -0600 Subject: [PATCH 012/102] feat(antigravity): CCA host failover and non-retryable image POST Always-SSE with daily/prod failover, classify peer quota/geoblock frames, and return 400 on ambiguous image transport failure so Codex does not replay a paid POST. Co-authored-by: Cursor --- .../src/content/docs/fr/guides/providers.md | 2 +- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- src/adapters/google-http.ts | 314 ++++++++++++- src/server/images.ts | 14 +- structure/04_transports-and-sidecars.md | 20 +- tests/google-hardening.test.ts | 423 +++++++++++++++++- tests/server-images.test.ts | 62 ++- 13 files changed, 822 insertions(+), 27 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 59381edd60..c5a95b2d01 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -120,7 +120,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Modèles de programmation Kimi K2.7/K2.6/K2.5. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. Le quota est sondé via `retrieveUserQuota` et `retrieveUserQuotaSummary` (délai de 8 secondes). CCA utilise toujours SSE, met en mémoire tampon SSE pour les appels unitaires et réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte ; les délais 429 sont locaux au processus et propres au compte. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index e1451dcfbd..97e2333843 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA always uses SSE, buffers SSE for unary callers, and retries the daily/production peer on first-host transport, 404, or unavailable failures; 429 cooldowns are process-local per account. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 81a19e5e5b..4606b35c74 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -110,7 +110,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し、429 cooldown はアカウント単位のプロセス内状態です。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 4f57dab7cc..bc9aa3b7c0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -109,7 +109,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. 429 cooldown은 계정별 프로세스 로컬 상태입니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 1966d9db63..16e53ca009 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -119,7 +119,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. Cooldown для 429 хранится локально в процессе и привязан к аккаунту. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index ee153a0780..040d631795 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -134,7 +134,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 kodlama modelleri. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. Kota `retrieveUserQuota` ve `retrieveUserQuotaSummary` RPC'leriyle (8 saniyelik zaman aşımı) sorgulanır. CCA her zaman SSE kullanır ve tekli çağrılar için SSE'yi arabelleğe alır; ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer denenir. 429 cooldown hesabı temel alan işlem-yerel durumdur. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 4e924458ee..9e9049207e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -100,7 +100,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。429 cooldown 按账户保存在进程内。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index ee7d709880..f443dac55f 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -108,7 +108,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding 模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。429 cooldown 為按帳號的 process-local 狀態。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index f7b90de87e..1a514aec97 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -1,7 +1,14 @@ import type { AdapterFetchContext, AdapterRequest } from "./base"; -import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors"; +import { + isAntigravityGeoBlockedBody, + isQuotaExhaustedBody, + retryableGoogleStatus, + safeGoogleHttpErrorMessage, +} from "./google-errors"; import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; +import { antigravityHostCandidates } from "./google-antigravity-hosts"; +import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { abortError, cancelResponseBodyBestEffort, @@ -13,6 +20,263 @@ import { const GOOGLE_RETRY_ATTEMPTS = 3; const GOOGLE_RETRY_BASE_MS = 250; const GOOGLE_RETRY_MAX_MS = 2_000; +export const CCA_STREAM_PROBE_MAX_BYTES = 100 * 1024 * 1024; +export const CCA_STREAM_CLASSIFY_MAX_BYTES = 256 * 1024; + +function isAntigravitySseRequest(request: AdapterRequest): boolean { + return request.url.includes("/v1internal:streamGenerateContent?alt=sse"); +} + +function requestForHost(request: AdapterRequest, host: string): AdapterRequest { + const current = new URL(request.url); + const replacement = new URL(host); + current.protocol = replacement.protocol; + current.host = replacement.host; + return { ...request, url: current.toString() }; +} + +function retryAfterMs(value: string | null, now = Date.now()): number | undefined { + const text = value?.trim(); + if (!text) return undefined; + if (/^\d+(?:\.\d+)?$/.test(text)) { + const seconds = Number(text); + return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds * 1000) : undefined; + } + const timestamp = Date.parse(text); + return Number.isFinite(timestamp) && timestamp > now ? timestamp - now : undefined; +} + +type CcaSseProbe = "empty" | "candidate" | "unavailable" | "quota_exhausted" | "geo_blocked" | "terminal"; + +function probeCcaSseEvent(bytes: Uint8Array): CcaSseProbe { + const text = new TextDecoder().decode(bytes); + let sawData = false; + for (const line of text.split(/\r?\n/)) { + if (!line.startsWith("data:")) continue; + sawData = true; + const payload = line.slice(5).trim(); + if (!payload) continue; + let frame: unknown; + try { + frame = JSON.parse(payload); + } catch { + return "terminal"; + } + if (!frame || typeof frame !== "object" || Array.isArray(frame)) return "terminal"; + const record = frame as Record; + if (record.error) { + const error = record.error; + const errorRecord = error && typeof error === "object" && !Array.isArray(error) + ? error as Record + : {}; + const status = String(errorRecord.status ?? "").toUpperCase(); + const code = errorRecord.code; + if (status === "UNAVAILABLE" || status === "503" || code === 503 || code === "503") { + return "unavailable"; + } + const serialized = JSON.stringify(frame); + if (isQuotaExhaustedBody(serialized)) return "quota_exhausted"; + if (isAntigravityGeoBlockedBody(serialized)) return "geo_blocked"; + return "terminal"; + } + const response = record.response; + if (!response || typeof response !== "object" || Array.isArray(response)) return "terminal"; + const root = response as Record; + if (Array.isArray(root.candidates) && root.candidates.length > 0) return "candidate"; + } + return sawData ? "empty" : "empty"; +} + +export class CcaProbeBuffer { + private storage: Uint8Array; + length = 0; + + constructor(private readonly maxBytes = CCA_STREAM_PROBE_MAX_BYTES) { + this.storage = new Uint8Array(Math.min(64 * 1024, maxBytes)); + } + + append(next: Uint8Array): boolean { + const required = this.length + next.byteLength; + if (required > this.maxBytes) return false; + if (required > this.storage.byteLength) { + let capacity = Math.max(this.storage.byteLength, 1); + while (capacity < required) { + const grownCapacity = Math.min(this.maxBytes, capacity * 2); + capacity = grownCapacity <= capacity ? this.maxBytes : grownCapacity; + } + const grown = new Uint8Array(capacity); + grown.set(this.storage.subarray(0, this.length)); + this.storage = grown; + } + this.storage.set(next, this.length); + this.length = required; + return true; + } + + view(): Uint8Array { + return this.storage.subarray(0, this.length); + } +} + +function firstSseEventEnd(bytes: Uint8Array, from: number): number | undefined { + for (let index = from; index + 1 < bytes.byteLength; index++) { + if (bytes[index] === 10 && bytes[index + 1] === 10) return index + 2; + if (index + 3 < bytes.byteLength + && bytes[index] === 13 && bytes[index + 1] === 10 + && bytes[index + 2] === 13 && bytes[index + 3] === 10) { + return index + 4; + } + } + return undefined; +} + +function responseWithBufferedBody( + response: Response, + buffered: Uint8Array, + reader: ReadableStreamDefaultReader, + pending?: Uint8Array, + status = response.status, +): Response { + const body = new ReadableStream({ + start(controller) { + void (async () => { + try { + if (buffered.byteLength > 0) controller.enqueue(buffered); + if (pending?.byteLength) controller.enqueue(pending); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (value?.byteLength) controller.enqueue(value); + } + controller.close(); + } catch (error) { + controller.error(error); + } finally { + reader.releaseLock(); + } + })(); + }, + cancel(reason) { + void reader.cancel(reason).catch(() => {}); + }, + }); + return new Response(body, { + status, + statusText: response.statusText, + headers: response.headers, + }); +} + +async function prepareCcaSseResponse( + response: Response, + fetchPeer: (() => Promise) | undefined, + accountId?: string, +): Promise { + if (!response.body) return fetchPeer ? fetchPeer() : response; + const reader = response.body.getReader(); + const probeBuffer = new CcaProbeBuffer(); + let scanned = 0; + const passthrough = (pending?: Uint8Array, status = response.status) => + responseWithBufferedBody(response, probeBuffer.view(), reader, pending, status); + const failoverOrPassthrough = async (): Promise => { + if (!fetchPeer) return passthrough(); + await reader.cancel().catch(() => {}); + reader.releaseLock(); + return fetchPeer(); + }; + try { + while (probeBuffer.length < CCA_STREAM_PROBE_MAX_BYTES) { + if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES || probeBuffer.length >= CCA_STREAM_CLASSIFY_MAX_BYTES) { + return passthrough(); + } + const { done, value } = await reader.read(); + if (done) { + const buffered = probeBuffer.view(); + const residual = buffered.subarray(scanned); + if (residual.byteLength > 0) { + const probe = probeCcaSseEvent(residual); + if (probe === "candidate" || probe === "terminal") { + return passthrough(); + } + if (probe === "unavailable") { + return failoverOrPassthrough(); + } + if (probe === "quota_exhausted" || probe === "geo_blocked") { + const status = probe === "quota_exhausted" ? 429 : 403; + if (accountId) { + recordAntigravityCooldown( + accountId, + probe === "quota_exhausted" ? "quota_exhausted" : "geo_blocked", + ); + } + return passthrough(undefined, status); + } + } + return failoverOrPassthrough(); + } + if (!value?.byteLength) continue; + const available = CCA_STREAM_PROBE_MAX_BYTES - probeBuffer.length; + const overflow = value.byteLength > available ? value.subarray(available) : undefined; + probeBuffer.append(overflow ? value.subarray(0, available) : value); + const buffered = probeBuffer.view(); + while (true) { + if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES) return passthrough(overflow); + const eventEnd = firstSseEventEnd(buffered, scanned); + if (eventEnd === undefined) break; + const probe = probeCcaSseEvent(buffered.subarray(scanned, eventEnd)); + scanned = eventEnd; + if (probe === "candidate") return passthrough(overflow); + if (probe === "unavailable") { + if (overflow?.byteLength) { + return passthrough(overflow); + } + return failoverOrPassthrough(); + } + if (probe === "quota_exhausted" || probe === "geo_blocked") { + const status = probe === "quota_exhausted" ? 429 : 403; + if (accountId) { + recordAntigravityCooldown( + accountId, + probe === "quota_exhausted" ? "quota_exhausted" : "geo_blocked", + ); + } + return passthrough(overflow, status); + } + if (probe === "terminal") return passthrough(overflow); + } + if (overflow?.byteLength) return passthrough(overflow); + if (scanned >= CCA_STREAM_CLASSIFY_MAX_BYTES || probeBuffer.length >= CCA_STREAM_CLASSIFY_MAX_BYTES) { + return passthrough(); + } + } + return passthrough(); + } catch (error) { + try { await reader.cancel(error); } catch { /* cleanup only */ } + reader.releaseLock(); + throw error; + } +} + +function isUnavailableResponse(response: Response): boolean { + return response.status === 503; +} + +function recordAntigravityHttpCooldown( + response: Response, + payloadText: string, + accountId: string | undefined, +): void { + if (!accountId) return; + if (response.status === 429) { + recordAntigravityCooldown( + accountId, + isQuotaExhaustedBody(payloadText) ? "quota_exhausted" : "rate_limited", + retryAfterMs(response.headers.get("retry-after")), + ); + } else if (response.status === 403 && isAntigravityGeoBlockedBody(payloadText)) { + recordAntigravityCooldown(accountId, "geo_blocked"); + } +} export interface GoogleRetryOptions { /** Repair-and-replay structurally invalid 400 bodies (Vertex/Antigravity behavior). */ @@ -33,15 +297,20 @@ async function normalizeFinalGoogleError(label: string, res: Response, signal?: * classified + redacted final error body. `label` is the provider-facing prefix used in error * messages. */ -export async function fetchGoogleWithRetry( +async function fetchGoogleWithRetryInternal( label: string, request: AdapterRequest, - ctx: AdapterFetchContext = {}, + ctx: AdapterFetchContext, + allowAntigravityHostFailover: boolean, opts: GoogleRetryOptions = {}, ): Promise { const repairInvalid400 = opts.repairInvalid400 ?? true; const timeoutMs = ctx.timeoutMs ?? 200_000; const executor = ctx.executor ?? globalThis.fetch; + const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(request) + ? antigravityHostCandidates(new URL(request.url).origin) + : []; + let antigravityHostIndex = 0; let lastError: unknown; let activeRequest = request; let compatibilityReplayUsed = false; @@ -53,6 +322,31 @@ export async function fetchGoogleWithRetry( headers: activeRequest.headers, body: activeRequest.body, }, timeoutMs, ctx.abortSignal, ctx.stream, executor); + if (antigravityHosts.length > 1 && antigravityHostIndex === 0) { + const shouldTryPeer = res.status === 404 || isUnavailableResponse(res); + if (shouldTryPeer) { + cancelResponseBodyBestEffort(res); + antigravityHostIndex = 1; + activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + continue; + } + } + if (label === "Antigravity" && isAntigravitySseRequest(activeRequest) && res.ok) { + const fetchPeer = antigravityHosts.length > 1 && antigravityHostIndex === 0 + ? () => fetchGoogleWithRetryInternal( + label, + requestForHost(request, antigravityHosts[1]!), + ctx, + false, + opts, + ) + : undefined; + return prepareCcaSseResponse(res, fetchPeer, ctx.accountId); + } + if (label === "Antigravity" && (res.status === 429 || res.status === 403)) { + const body = await readDisplaySafeErrorPayloadText(res.clone(), ctx.abortSignal); + recordAntigravityHttpCooldown(res, body, ctx.accountId); + } if (res.status === 400 && repairInvalid400 && !compatibilityReplayUsed) { let payloadText = ""; try { @@ -93,6 +387,11 @@ export async function fetchGoogleWithRetry( } catch (err) { if (ctx.abortSignal?.aborted) throw err; lastError = err; + if (antigravityHosts.length > 1 && antigravityHostIndex === 0) { + antigravityHostIndex = 1; + activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + continue; + } if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err; await sleepWithAbort(retryBackoffDelayMs(attempt, { baseDelayMs: GOOGLE_RETRY_BASE_MS, @@ -103,6 +402,15 @@ export async function fetchGoogleWithRetry( throw lastError ?? new Error(`${label} fetch failed`); } +export function fetchGoogleWithRetry( + label: string, + request: AdapterRequest, + ctx: AdapterFetchContext = {}, + opts: GoogleRetryOptions = {}, +): Promise { + return fetchGoogleWithRetryInternal(label, request, ctx, true, opts); +} + /** * AI Studio direct (`generativelanguage.googleapis.com`) retry wrapper. * diff --git a/src/server/images.ts b/src/server/images.ts index 5e65a1a170..8818c25407 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -223,9 +223,12 @@ async function tryCcaImageGeneration( sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`, }, }; - let upstream: Response; + let upstream: Response | undefined; try { try { + // Image generation is a paid, non-idempotent POST. A transport failure is + // ambiguous: the upstream may have accepted the request before the + // connection failed, so never replay it on a peer host. upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { method: "POST", headers: { @@ -246,12 +249,19 @@ async function tryCcaImageGeneration( // lives in an Authorization header, not in the URL, but sanitize defensively // so no upstream-rejected credential or query param can reach the client, // and strip the internal base URL host from the surfaced message. + // 400, not 5xx: the POST is paid and non-idempotent. Codex retries every + // 5xx up to 5 attempts, which would duplicate generation after an ambiguous + // transport failure (the upstream may already have accepted the request). const rawMsg = err instanceof Error ? err.message : String(err); const safeMsg = sanitizeUpstreamErrorText(rawMsg).replace( /https?:\/\/[^\s"'<>]+/gi, "[upstream-url]", ); - return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${safeMsg}`); + return formatErrorResponse( + 400, + "invalid_request_error", + `CCA image generation may have started and must not be blindly retried: ${safeMsg}`, + ); } // Stream the upstream body with a bounded reader so an oversized or malicious diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index bdb0d15e0c..ecfe290673 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -159,9 +159,11 @@ These are standalone Images API routes, not the hosted Responses `image_generati selects a custom API-key `openai-responses` provider. Explicit selection fails closed when the provider is missing, disabled, registry-managed, incompatible, or lacks a usable key; it never falls through to another paid upstream. The relay accepts bounded JSON generation and edit requests, -then forwards the decoded JSON without rewriting Codex's edit schema. Each paid Images POST receives -one upstream attempt; client cancellation aborts the upstream and pool-only failures update the -existing account-health state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. +then forwards the decoded JSON without rewriting Codex's edit schema. Each paid Images POST, +including the Google Antigravity fallback, receives one upstream attempt; an ambiguous transport +failure is never replayed on a peer host because the generation may already have been accepted. +Client cancellation aborts the upstream and pool-only failures update the existing account-health +state. Unknown Images subpaths still reach the JSON `/v1/*` 404 guard. When the OpenAI credential path is unavailable or its authentication fails, `generations` (not `edits`) may fall back to Google Antigravity if that provider is logged in. The fallback is @@ -848,6 +850,16 @@ combo whose remaining eligible targets use other providers. - 장점, 단점 및 영향: Same-account model fallback works without weakening explicit upstream backoff; the account health map intentionally does not remember that one deferred reset-derived failure, while the combo target map does. ``` +## Antigravity transport failover + +Cloud Code Assist requests always use `streamGenerateContent?alt=sse`, including unary callers; +the adapter buffers those SSE events for the unary contract. The configured daily or production +host is tried first, then only its maintained peer (`daily-cloudcode-pa.googleapis.com` or +`cloudcode-pa.googleapis.com`) is eligible for one first-host transport, 404, `UNAVAILABLE`, or +empty-stream retry. Authentication, geoblock, invalid-request, and exhausted-quota responses do +not trigger host failover. Antigravity 429 cooldowns are process-local and keyed by OAuth account; +geoblock records cooldown without starting an account carousel. + ## Transport inventory The sections above cover the transports with load-bearing invariants. The rest of the transport @@ -856,7 +868,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | -| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. | +| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-antigravity-hosts.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/oauth/antigravity-routing.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. CCA host failover is daily/prod only (`google-antigravity-hosts.ts`); process-local 429/quota/geoblock cooldowns are keyed by OAuth account (`antigravity-routing.ts`). | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 207dddd8bc..2af6ac0f4d 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -3,6 +3,13 @@ import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/ada import { getDebugLogEntries, resetDebugLogBufferForTests } from "../src/lib/debug-log-buffer"; import { resetDebugSettingsForTests, setDebugSettings } from "../src/lib/debug-settings"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; +import { + CCA_STREAM_CLASSIFY_MAX_BYTES, + CCA_STREAM_PROBE_MAX_BYTES, + CcaProbeBuffer, + fetchAntigravityWithRetry, +} from "../src/adapters/google-http"; +import { isAntigravityAccountInCooldown, clearAntigravityAccountCooldown } from "../src/oauth/antigravity-routing"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -84,13 +91,427 @@ describe("google provider hardening", () => { ); }); + test("CCA unary requests use the always-SSE endpoint", async () => { + const request = await createGoogleAdapter(antigravityProvider()).buildRequest(parsed(false)); + expect(request.url).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); + }); + + test("AI Studio and Vertex unary requests retain generateContent", async () => { + const aiStudio = await createGoogleAdapter(provider()).buildRequest(parsed(false)); + const vertex = await createGoogleAdapter(provider({ + baseUrl: "https://aiplatform.googleapis.com", + googleMode: "vertex", + apiKey: "vertex-test-key", + })).buildRequest(parsed(false)); + expect(aiStudio.url).toContain(":generateContent"); + expect(aiStudio.url).not.toContain(":streamGenerateContent"); + expect(vertex.url).toContain(":generateContent"); + expect(vertex.url).not.toContain(":streamGenerateContent"); + }); + + test("CCA empty first-host stream fails over to the production host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) return sseResponse([{ response: { candidates: [] } }]); + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + const events = await adapter.parseResponse!(response); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(events.at(-1)?.type).toBe("done"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA auth failure does not fail over to the second host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(JSON.stringify({ error: { status: "UNAUTHENTICATED", message: "bad token" } }), { status: 401 }); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + expect(response.status).toBe(401); + expect(calls).toHaveLength(1); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA EOF terminal error does not fail over to the second host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response( + 'data: {"error":{"status":"UNAUTHENTICATED","message":"bad token"}}', + { status: 200, headers: { "content-type": "text/event-stream" } }, + ); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + + expect(response.status).toBe(200); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(await response.text()).toContain("UNAUTHENTICATED"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA valid oversized SSE output stays on the first host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + const largeText = "x".repeat(300 * 1024); + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: largeText }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + const events = await adapter.parseResponse!(response); + expect(calls).toHaveLength(1); + expect(events).toContainEqual({ type: "text_delta", text: largeText }); + expect(events.at(-1)?.type).toBe("done"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA probe buffer refuses bytes beyond its hard cap", () => { + const cap = 16; + const buffer = new CcaProbeBuffer(cap); + expect(CCA_STREAM_PROBE_MAX_BYTES).toBe(100 * 1024 * 1024); + expect(buffer.append(new Uint8Array(cap))).toBe(true); + expect(buffer.append(new Uint8Array(1))).toBe(false); + expect(buffer.length).toBe(cap); + }); + + test("CCA open empty SSE stream passes through at the classify cap without buffering 100 MiB", async () => { + expect(CCA_STREAM_CLASSIFY_MAX_BYTES).toBe(256 * 1024); + const encoder = new TextEncoder(); + const emptyFrame = encoder.encode("data:\n\n"); + const calls: string[] = []; + const realFetch = globalThis.fetch; + let controller: ReadableStreamDefaultController | undefined; + let enqueuedBytes = 0; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + }, + pull(streamController) { + // Keep the stream open so this is not the EOF-empty failover path, but stop + // enqueueing well below the 100 MiB hard cap so the test itself never allocates it. + if (enqueuedBytes >= CCA_STREAM_CLASSIFY_MAX_BYTES * 2) return; + streamController.enqueue(emptyFrame); + enqueuedBytes += emptyFrame.byteLength; + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + const abortController = new AbortController(); + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await Promise.race([ + fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + abortSignal: abortController.signal, + stream: true, + }), + new Promise((_, reject) => { + setTimeout(() => reject(new Error("CCA classification hung past the classify cap")), 2_000); + }), + ]); + expect(response.status).toBe(200); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(enqueuedBytes).toBeGreaterThanOrEqual(CCA_STREAM_CLASSIFY_MAX_BYTES); + expect(enqueuedBytes).toBeLessThan(CCA_STREAM_PROBE_MAX_BYTES); + await response.body?.cancel(); + } finally { + abortController.abort(); + try { controller?.close(); } catch { /* already closed */ } + globalThis.fetch = realFetch; + } + }); + + test("CCA peer failures use retry and final error normalization", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + return new Response( + JSON.stringify({ error: { status: "UNAVAILABLE", message: "peer overloaded" } }), + { status: 503, headers: { "retry-after": "0" } }, + ); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + + expect(response.status).toBe(503); + expect(await response.text()).toBe("Antigravity server overloaded: peer overloaded"); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA returns the first event before an open upstream stream ends", async () => { + const realFetch = globalThis.fetch; + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | undefined; + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(streamController) { + controller = streamController; + streamController.enqueue(encoder.encode( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"first"}]}}]}}\n\n', + )); + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })) as typeof fetch; + const abortController = new AbortController(); + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(true)); + const responsePromise = adapter.fetchResponse!(request, { + timeoutMs: 5_000, + abortSignal: abortController.signal, + stream: true, + }); + const returnedBeforeEof = await Promise.race([ + responsePromise.then(() => true), + new Promise(resolve => setTimeout(() => resolve(false), 100)), + ]); + expect(returnedBeforeEof).toBe(true); + const response = await responsePromise; + await response.body?.cancel(); + } finally { + abortController.abort(); + controller?.error(new Error("test stream closed")); + globalThis.fetch = realFetch; + } + }); + + test("CCA inline UNAVAILABLE fails over to the production host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const adapter = createGoogleAdapter(antigravityProvider()); + const request = await adapter.buildRequest(parsed(false)); + const response = await adapter.fetchResponse!(request, { timeoutMs: 5_000, stream: false }); + const events = await adapter.parseResponse!(response); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(events).toContainEqual({ type: "text_delta", text: "ok" }); + expect(events.at(-1)?.type).toBe("done"); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA geoblock records cooldown without account carousel", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const realFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return new Response(JSON.stringify({ + error: { status: "PERMISSION_DENIED", message: "user location is not supported for the api use" }, + }), { status: 403 }); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(403); + expect(calls).toBe(1); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + + test("CCA inline quota error becomes a cooldown-aware 429 without host failover", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const realFetch = globalThis.fetch; + let calls = 0; + globalThis.fetch = (async () => { + calls += 1; + return sseResponse([{ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message: "Quota exceeded for this account", + }, + }]); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(429); + expect(calls).toBe(1); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + + test("CCA peer HTTP 200 RESOURCE_EXHAUSTED after first-host 404 becomes a cooldown-aware 429", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (String(input).includes("daily-cloudcode-pa.googleapis.com")) { + return new Response("not found", { status: 404 }); + } + return sseResponse([{ + error: { + code: 429, + status: "RESOURCE_EXHAUSTED", + message: "Quota exceeded for this account", + }, + }]); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(429); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + + test("CCA peer HTTP 200 geoblock after first-host 503 records cooldown without a third host", async () => { + clearAntigravityAccountCooldown("test-antigravity-account"); + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (String(input).includes("daily-cloudcode-pa.googleapis.com")) { + return new Response("unavailable", { status: 503 }); + } + return sseResponse([{ + error: { + status: "PERMISSION_DENIED", + message: "user location is not supported for the api use", + }, + }]); + }) as typeof fetch; + try { + const request = { + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { + timeoutMs: 5_000, + accountId: "test-antigravity-account", + }); + expect(response.status).toBe(403); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); + } finally { + globalThis.fetch = realFetch; + clearAntigravityAccountCooldown("test-antigravity-account"); + } + }); + test("Antigravity rejects flat Gemini payloads without the response wrapper", async () => { const adapter = createGoogleAdapter(antigravityProvider()); const flatPayload = { candidates: [{ content: { parts: [{ text: "unexpected" }] } }] }; const streamEvents = await collect(adapter.parseStream(sseResponse([flatPayload]))); const responseEvents = await adapter.parseResponse!( - new Response(JSON.stringify(flatPayload), { status: 200 }), + sseResponse([flatPayload]), ); const expected = [{ diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 9499de4d85..fd829eb5bb 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -1234,6 +1234,42 @@ test("CCA image fallback preserves upstream 429 status", async () => { } }); +test("CCA image fallback does not retry a transport failure on the peer host", async () => { + let calls = 0; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + const hostname = new URL(requestUrl).hostname; + if (hostname === "daily-cloudcode-pa.googleapis.com") { + calls += 1; + throw new TypeError("fetch failed: connection reset after acceptance"); + } + if (hostname === "cloudcode-pa.googleapis.com") { + calls += 1; + return Response.json({ + response: { + candidates: [{ + content: { parts: [{ inlineData: { mimeType: "image/png", data: CCA_TINY_PNG } }] }, + }], + }, + }); + } + return originalFetch(input); + }) as typeof fetch; + + saveConfig(ccaConfig()); + await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); + + const request = new Request("http://localhost:0/v1/images/generations", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ prompt: "a cat" }), + }); + const response = await handleImages(request, ccaConfig(), "generations", { model: "", provider: "" } as never); + + expect(response.status).toBe(400); + expect(calls).toBe(1); +}); + test("CCA fallback does not serve image edits", async () => { saveConfig(ccaConfig()); await saveCredential("google-antigravity", { ...CCA_CREDENTIAL }); @@ -1399,16 +1435,19 @@ test("CCA OAuth no credential saved returns 401 (login required), not a misleadi } }); -test("CCA fetch network failure returns 502 without leaking the timeout timer", async () => { - // Mock: CCA fetch always fails with a network error. The bug was that the - // fetch catch returned 502 without calling linkedSignal.cleanup(), leaving - // the timeout timer alive. With a short timeout this would keep the process - // alive. The fix wraps everything in try/finally so cleanup always runs. +test("CCA fetch network failure returns 400 without leaking the timeout timer", async () => { + // Mock: CCA fetch always fails with a network error after the POST is attempted. + // Image generation is a paid non-idempotent POST; Codex retries every 5xx up to 5 + // attempts, so transport failure after fetch is attempted must be non-5xx (400). + // The timeout timer still must not leak: linkedSignal.cleanup() runs in finally. + // With a 10s images timeout and a 5s test timeout, a leaked timer fails this test. + let ccaPosts = 0; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const requestUrl = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const url = new URL(requestUrl); if (url.hostname === "daily-cloudcode-pa.googleapis.com") { - throw new TypeError("fetch failed: connection refused"); + if ((init?.method ?? "GET").toUpperCase() === "POST") ccaPosts += 1; + throw new TypeError("fetch failed: https://daily-cloudcode-pa.googleapis.com/v1internal:generateContent connection refused"); } return originalFetch(input, init); }) as typeof fetch; @@ -1423,9 +1462,14 @@ test("CCA fetch network failure returns 502 without leaking the timeout timer", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: "a cat" }), }); - expect(response.status).toBe(502); - const json = await response.json() as { error: { message: string } }; - expect(json.error.message).toContain("CCA image generation failed"); + expect(response.status).toBe(400); + const json = await response.json() as { error: { message: string; type: string } }; + expect(json.error.type).toBe("invalid_request_error"); + expect(json.error.message).toMatch(/may have started/i); + expect(json.error.message).toMatch(/must not be blindly retried/i); + expect(json.error.message).not.toContain("https://"); + expect(json.error.message).not.toContain("daily-cloudcode-pa.googleapis.com"); + expect(ccaPosts).toBe(1); } finally { await server.stop(true); } From bc1704bbda8ee955f3b910f483203d2931a58743 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 15:22:41 -0600 Subject: [PATCH 013/102] fix(antigravity): skip http hosts on CCA generateContent OAuth bearer streamGenerateContent must not go to a configured http origin; rewrite to the HTTPS daily/prod peer first. Co-authored-by: Cursor --- src/adapters/google-http.ts | 15 +++++++++++---- tests/google-hardening.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 1a514aec97..52a17305e6 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -7,7 +7,7 @@ import { } from "./google-errors"; import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; -import { antigravityHostCandidates } from "./google-antigravity-hosts"; +import { antigravityHostCandidates, isAntigravityHttpsHost } from "./google-antigravity-hosts"; import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { abortError, @@ -307,12 +307,19 @@ async function fetchGoogleWithRetryInternal( const repairInvalid400 = opts.repairInvalid400 ?? true; const timeoutMs = ctx.timeoutMs ?? 200_000; const executor = ctx.executor ?? globalThis.fetch; - const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(request) - ? antigravityHostCandidates(new URL(request.url).origin) + let activeRequest = request; + if (label === "Antigravity" && isAntigravitySseRequest(activeRequest)) { + const origin = new URL(activeRequest.url).origin; + if (!isAntigravityHttpsHost(origin)) { + const httpsHost = antigravityHostCandidates(origin).find(isAntigravityHttpsHost); + if (httpsHost) activeRequest = requestForHost(activeRequest, httpsHost); + } + } + const antigravityHosts = allowAntigravityHostFailover && label === "Antigravity" && isAntigravitySseRequest(activeRequest) + ? antigravityHostCandidates(new URL(activeRequest.url).origin).filter(isAntigravityHttpsHost) : []; let antigravityHostIndex = 0; let lastError: unknown; - let activeRequest = request; let compatibilityReplayUsed = false; for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) { if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal); diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 2af6ac0f4d..83bc1a4d7e 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -362,6 +362,32 @@ describe("google provider hardening", () => { } }); + test("CCA does not POST the OAuth bearer to an http host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "ok" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + try { + const request = { + url: "http://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: { Authorization: "Bearer secret-token" }, + body: "{}", + }; + const response = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000 }); + expect(response.ok).toBe(true); + expect(calls.filter(url => url.startsWith("http://"))).toEqual([]); + expect(calls[0]).toBe("https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"); + } finally { + globalThis.fetch = realFetch; + } + }); + test("CCA geoblock records cooldown without account carousel", async () => { clearAntigravityAccountCooldown("test-antigravity-account"); const realFetch = globalThis.fetch; From 3735e1e00f1ab8752d563d2d3121f356f9620698 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:08:26 -0600 Subject: [PATCH 014/102] fix(antigravity): remove oauth cooldown coupling Co-authored-by: Cursor --- src/adapters/google-http.ts | 48 +--------------------------------- tests/google-hardening.test.ts | 25 +++--------------- 2 files changed, 5 insertions(+), 68 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 52a17305e6..4a4f6f75fd 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -8,7 +8,6 @@ import { import { repairGoogleInvalidRequestBody } from "./google-wire-compiler"; import { normalizeUpstreamHttpErrorResponse, readDisplaySafeErrorPayloadText } from "./upstream-http-error"; import { antigravityHostCandidates, isAntigravityHttpsHost } from "./google-antigravity-hosts"; -import { recordAntigravityCooldown } from "../oauth/antigravity-routing"; import { abortError, cancelResponseBodyBestEffort, @@ -35,17 +34,6 @@ function requestForHost(request: AdapterRequest, host: string): AdapterRequest { return { ...request, url: current.toString() }; } -function retryAfterMs(value: string | null, now = Date.now()): number | undefined { - const text = value?.trim(); - if (!text) return undefined; - if (/^\d+(?:\.\d+)?$/.test(text)) { - const seconds = Number(text); - return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds * 1000) : undefined; - } - const timestamp = Date.parse(text); - return Number.isFinite(timestamp) && timestamp > now ? timestamp - now : undefined; -} - type CcaSseProbe = "empty" | "candidate" | "unavailable" | "quota_exhausted" | "geo_blocked" | "terminal"; function probeCcaSseEvent(bytes: Uint8Array): CcaSseProbe { @@ -170,7 +158,6 @@ function responseWithBufferedBody( async function prepareCcaSseResponse( response: Response, fetchPeer: (() => Promise) | undefined, - accountId?: string, ): Promise { if (!response.body) return fetchPeer ? fetchPeer() : response; const reader = response.body.getReader(); @@ -203,12 +190,6 @@ async function prepareCcaSseResponse( } if (probe === "quota_exhausted" || probe === "geo_blocked") { const status = probe === "quota_exhausted" ? 429 : 403; - if (accountId) { - recordAntigravityCooldown( - accountId, - probe === "quota_exhausted" ? "quota_exhausted" : "geo_blocked", - ); - } return passthrough(undefined, status); } } @@ -234,12 +215,6 @@ async function prepareCcaSseResponse( } if (probe === "quota_exhausted" || probe === "geo_blocked") { const status = probe === "quota_exhausted" ? 429 : 403; - if (accountId) { - recordAntigravityCooldown( - accountId, - probe === "quota_exhausted" ? "quota_exhausted" : "geo_blocked", - ); - } return passthrough(overflow, status); } if (probe === "terminal") return passthrough(overflow); @@ -261,23 +236,6 @@ function isUnavailableResponse(response: Response): boolean { return response.status === 503; } -function recordAntigravityHttpCooldown( - response: Response, - payloadText: string, - accountId: string | undefined, -): void { - if (!accountId) return; - if (response.status === 429) { - recordAntigravityCooldown( - accountId, - isQuotaExhaustedBody(payloadText) ? "quota_exhausted" : "rate_limited", - retryAfterMs(response.headers.get("retry-after")), - ); - } else if (response.status === 403 && isAntigravityGeoBlockedBody(payloadText)) { - recordAntigravityCooldown(accountId, "geo_blocked"); - } -} - export interface GoogleRetryOptions { /** Repair-and-replay structurally invalid 400 bodies (Vertex/Antigravity behavior). */ repairInvalid400?: boolean; @@ -348,11 +306,7 @@ async function fetchGoogleWithRetryInternal( opts, ) : undefined; - return prepareCcaSseResponse(res, fetchPeer, ctx.accountId); - } - if (label === "Antigravity" && (res.status === 429 || res.status === 403)) { - const body = await readDisplaySafeErrorPayloadText(res.clone(), ctx.abortSignal); - recordAntigravityHttpCooldown(res, body, ctx.accountId); + return prepareCcaSseResponse(res, fetchPeer); } if (res.status === 400 && repairInvalid400 && !compatibilityReplayUsed) { let payloadText = ""; diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 83bc1a4d7e..86d6f44463 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -9,7 +9,6 @@ import { CcaProbeBuffer, fetchAntigravityWithRetry, } from "../src/adapters/google-http"; -import { isAntigravityAccountInCooldown, clearAntigravityAccountCooldown } from "../src/oauth/antigravity-routing"; import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; import { withTestTranslatorBudget } from "./helpers/translator-budget"; @@ -388,8 +387,7 @@ describe("google provider hardening", () => { } }); - test("CCA geoblock records cooldown without account carousel", async () => { - clearAntigravityAccountCooldown("test-antigravity-account"); + test("CCA geoblock maps to 403 without account carousel", async () => { const realFetch = globalThis.fetch; let calls = 0; globalThis.fetch = (async () => { @@ -407,19 +405,15 @@ describe("google provider hardening", () => { }; const response = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000, - accountId: "test-antigravity-account", }); expect(response.status).toBe(403); expect(calls).toBe(1); - expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); } finally { globalThis.fetch = realFetch; - clearAntigravityAccountCooldown("test-antigravity-account"); } }); - test("CCA inline quota error becomes a cooldown-aware 429 without host failover", async () => { - clearAntigravityAccountCooldown("test-antigravity-account"); + test("CCA inline quota error becomes a 429 without host failover", async () => { const realFetch = globalThis.fetch; let calls = 0; globalThis.fetch = (async () => { @@ -441,19 +435,15 @@ describe("google provider hardening", () => { }; const response = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000, - accountId: "test-antigravity-account", }); expect(response.status).toBe(429); expect(calls).toBe(1); - expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); } finally { globalThis.fetch = realFetch; - clearAntigravityAccountCooldown("test-antigravity-account"); } }); - test("CCA peer HTTP 200 RESOURCE_EXHAUSTED after first-host 404 becomes a cooldown-aware 429", async () => { - clearAntigravityAccountCooldown("test-antigravity-account"); + test("CCA peer HTTP 200 RESOURCE_EXHAUSTED after first-host 404 becomes a 429", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { @@ -478,22 +468,18 @@ describe("google provider hardening", () => { }; const response = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000, - accountId: "test-antigravity-account", }); expect(response.status).toBe(429); expect(calls).toEqual([ "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", ]); - expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); } finally { globalThis.fetch = realFetch; - clearAntigravityAccountCooldown("test-antigravity-account"); } }); - test("CCA peer HTTP 200 geoblock after first-host 503 records cooldown without a third host", async () => { - clearAntigravityAccountCooldown("test-antigravity-account"); + test("CCA peer HTTP 200 geoblock after first-host 503 remains 403 without a third host", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; globalThis.fetch = (async (input: string | URL | Request) => { @@ -517,17 +503,14 @@ describe("google provider hardening", () => { }; const response = await fetchAntigravityWithRetry(request, { timeoutMs: 5_000, - accountId: "test-antigravity-account", }); expect(response.status).toBe(403); expect(calls).toEqual([ "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", ]); - expect(isAntigravityAccountInCooldown("test-antigravity-account")).toBe(true); } finally { globalThis.fetch = realFetch; - clearAntigravityAccountCooldown("test-antigravity-account"); } }); From 689c1833a35c36d74635ff697f35fd6a9979c56f Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:13:33 -0600 Subject: [PATCH 015/102] Drop Antigravity cooldown docs from host-failover slice. Account cooldown and antigravity-routing coupling belongs on #2069; this PR documents CCA host failover and image behavior only. Co-authored-by: Cursor --- docs-site/src/content/docs/fr/guides/providers.md | 2 +- docs-site/src/content/docs/guides/providers.md | 2 +- docs-site/src/content/docs/ja/guides/providers.md | 2 +- docs-site/src/content/docs/ko/guides/providers.md | 2 +- docs-site/src/content/docs/ru/guides/providers.md | 2 +- docs-site/src/content/docs/tr/guides/providers.md | 2 +- docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- docs-site/src/content/docs/zh-tw/guides/providers.md | 2 +- structure/04_transports-and-sidecars.md | 5 ++--- 9 files changed, 10 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index c5a95b2d01..82f04b6986 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -120,7 +120,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Modèles de programmation Kimi K2.7/K2.6/K2.5. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. Le quota est sondé via `retrieveUserQuota` et `retrieveUserQuotaSummary` (délai de 8 secondes). CCA utilise toujours SSE, met en mémoire tampon SSE pour les appels unitaires et réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte ; les délais 429 sont locaux au processus et propres au compte. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. Le quota est sondé via `retrieveUserQuota` et `retrieveUserQuotaSummary` (délai de 8 secondes). CCA utilise toujours SSE, met en mémoire tampon SSE pour les appels unitaires et réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 97e2333843..72f86cda79 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA always uses SSE, buffers SSE for unary callers, and retries the daily/production peer on first-host transport, 404, or unavailable failures; 429 cooldowns are process-local per account. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA always uses SSE, buffers SSE for unary callers, and retries the daily/production peer on first-host transport, 404, or unavailable failures. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 4606b35c74..61115f6efa 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -110,7 +110,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し、429 cooldown はアカウント単位のプロセス内状態です。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index bc9aa3b7c0..13768c80eb 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -109,7 +109,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. 429 cooldown은 계정별 프로세스 로컬 상태입니다. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index 16e53ca009..dd0f088253 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -119,7 +119,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. Cooldown для 429 хранится локально в процессе и привязан к аккаунту. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 040d631795..9f83498f50 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -134,7 +134,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 kodlama modelleri. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. Kota `retrieveUserQuota` ve `retrieveUserQuotaSummary` RPC'leriyle (8 saniyelik zaman aşımı) sorgulanır. CCA her zaman SSE kullanır ve tekli çağrılar için SSE'yi arabelleğe alır; ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer denenir. 429 cooldown hesabı temel alan işlem-yerel durumdur. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. Kota `retrieveUserQuota` ve `retrieveUserQuotaSummary` RPC'leriyle (8 saniyelik zaman aşımı) sorgulanır. CCA her zaman SSE kullanır ve tekli çağrılar için SSE'yi arabelleğe alır; ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer denenir. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 9e9049207e..03d20514a1 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -100,7 +100,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。429 cooldown 按账户保存在进程内。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index f443dac55f..5bc1b9e812 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -108,7 +108,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding 模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。429 cooldown 為按帳號的 process-local 狀態。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index ecfe290673..6e6b044c0a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -857,8 +857,7 @@ the adapter buffers those SSE events for the unary contract. The configured dail host is tried first, then only its maintained peer (`daily-cloudcode-pa.googleapis.com` or `cloudcode-pa.googleapis.com`) is eligible for one first-host transport, 404, `UNAVAILABLE`, or empty-stream retry. Authentication, geoblock, invalid-request, and exhausted-quota responses do -not trigger host failover. Antigravity 429 cooldowns are process-local and keyed by OAuth account; -geoblock records cooldown without starting an account carousel. +not trigger host failover. ## Transport inventory @@ -868,7 +867,7 @@ surface is listed here so a maintainer can find the owner without grepping: | Transport | Owner | Invariant worth knowing | | --- | --- | --- | | Azure OpenAI Responses | `src/adapters/azure.ts` | Deployment-shaped URLs on top of the Responses contract. | -| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-antigravity-hosts.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts`, `src/oauth/antigravity-routing.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. CCA host failover is daily/prod only (`google-antigravity-hosts.ts`); process-local 429/quota/geoblock cooldowns are keyed by OAuth account (`antigravity-routing.ts`). | +| Google / Vertex / Antigravity | `src/adapters/google.ts`, `src/adapters/google-http.ts`, `src/adapters/google-antigravity-hosts.ts`, `src/adapters/google-wire-compiler.ts`, `src/adapters/google-tool-schema.ts`, `src/adapters/google-truncation.ts`, `src/adapters/google-errors.ts`, `src/adapters/google-antigravity-wire.ts`, `src/adapters/google-antigravity-replay.ts` | Vertex and Antigravity install a Google-family `fetchResponse` and so own their retry policy, while AI Studio Gemini leaves it undefined and uses the default server fetch path. The Google-family wrapper reuses the shared abort/deadline helpers (`src/lib/upstream-retry.ts`), wire-body repair, and upstream error normalization. CCA host failover is daily/prod only (`google-antigravity-hosts.ts`). | | Mimo Free | `src/adapters/mimo-free.ts` | Client identity and JWT handling are transport-local; the per-install client id lives in the opencodex state root. | | Anthropic image ingress | `src/adapters/anthropic-image-guard.ts`, `src/adapters/anthropic-image-normalize.ts` | Oversized or unsupported images are normalized or rejected before reaching upstream. | | Adapter execution support | `src/adapters/run-turn-queue.ts`, `src/adapters/tool-catalog-nudge.ts`, `src/adapters/identity.ts`, `src/adapters/image.ts`, `src/adapters/upstream-http-error.ts` | Shared machinery: turn ordering, tool-catalog nudging, client fingerprinting, image conversion, upstream error normalization. | From 60b6fcadbe5238d2337346b30bda053cea61f9a1 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:24:04 -0600 Subject: [PATCH 016/102] fix(antigravity): preserve passthrough backpressure Drive CCA response tails from downstream pulls so open streams do not eagerly buffer unread upstream output. Co-authored-by: Cursor --- src/adapters/google-http.ts | 46 ++++++++++++++++++---------- tests/google-hardening.test.ts | 55 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index 4a4f6f75fd..d1d8fc7263 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -125,27 +125,43 @@ function responseWithBufferedBody( pending?: Uint8Array, status = response.status, ): Response { + let bufferedBody = buffered; + let pendingBody = pending; + let readerReleased = false; + const releaseReader = () => { + if (readerReleased) return; + readerReleased = true; + reader.releaseLock(); + }; const body = new ReadableStream({ - start(controller) { - void (async () => { - try { - if (buffered.byteLength > 0) controller.enqueue(buffered); - if (pending?.byteLength) controller.enqueue(pending); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (value?.byteLength) controller.enqueue(value); + async pull(controller) { + try { + if (bufferedBody.byteLength > 0 || pendingBody?.byteLength) { + if (bufferedBody.byteLength > 0) { + controller.enqueue(bufferedBody); + bufferedBody = new Uint8Array(); + } + if (pendingBody?.byteLength) { + controller.enqueue(pendingBody); + pendingBody = undefined; } + return; + } + + const { done, value } = await reader.read(); + if (done) { controller.close(); - } catch (error) { - controller.error(error); - } finally { - reader.releaseLock(); + releaseReader(); + return; } - })(); + if (value?.byteLength) controller.enqueue(value); + } catch (error) { + controller.error(error); + releaseReader(); + } }, cancel(reason) { - void reader.cancel(reason).catch(() => {}); + void reader.cancel(reason).catch(() => {}).finally(releaseReader); }, }); return new Response(body, { diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 86d6f44463..a1dede7912 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -332,6 +332,61 @@ describe("google provider hardening", () => { } }); + test("CCA passthrough does not consume an unread upstream tail", async () => { + const realFetch = globalThis.fetch; + const encoder = new TextEncoder(); + const candidate = encoder.encode( + 'data: {"response":{"candidates":[{"content":{"parts":[{"text":"first"}]}}]}}\n\n', + ); + const tail = encoder.encode("data:\n\n"); + let candidateSent = false; + let tailChunksProduced = 0; + let releaseTail!: () => void; + const tailGate = new Promise(resolve => { + releaseTail = resolve; + }); + + globalThis.fetch = (async () => new Response(new ReadableStream({ + pull(streamController) { + if (!candidateSent) { + candidateSent = true; + streamController.enqueue(candidate); + return; + } + if (tailChunksProduced === 0) { + return tailGate.then(() => { + streamController.enqueue(tail); + tailChunksProduced += 1; + }); + } + if (tailChunksProduced >= 1_000) { + streamController.close(); + return; + } + streamController.enqueue(tail); + tailChunksProduced += 1; + }, + }), { status: 200, headers: { "content-type": "text/event-stream" } })) as typeof fetch; + + try { + const response = await fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }, { timeoutMs: 5_000, stream: true }); + + releaseTail(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(tailChunksProduced).toBeLessThan(10); + await response.body?.cancel(); + } finally { + releaseTail(); + globalThis.fetch = realFetch; + } + }); + test("CCA inline UNAVAILABLE fails over to the production host", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; From 9f074f57f872a248bfc7479472c488fae97f2a2f Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:16:35 -0600 Subject: [PATCH 017/102] fix(antigravity): harden peer failover request replay Preserve repaired request bodies when CCA failover invokes the peer and lock in transport-failure coverage. Clarify the separate SSE chat and unary image endpoints across provider docs. --- .../src/content/docs/fr/guides/providers.md | 2 +- .../src/content/docs/guides/providers.md | 2 +- .../src/content/docs/ja/guides/providers.md | 2 +- .../src/content/docs/ko/guides/providers.md | 2 +- .../src/content/docs/ru/guides/providers.md | 2 +- .../src/content/docs/tr/guides/providers.md | 2 +- .../content/docs/zh-cn/guides/providers.md | 2 +- .../content/docs/zh-tw/guides/providers.md | 2 +- src/adapters/google-http.ts | 6 +- tests/google-hardening.test.ts | 83 +++++++++++++++++++ 10 files changed, 94 insertions(+), 11 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/providers.md b/docs-site/src/content/docs/fr/guides/providers.md index 82f04b6986..4287198fc8 100644 --- a/docs-site/src/content/docs/fr/guides/providers.md +++ b/docs-site/src/content/docs/fr/guides/providers.md @@ -120,7 +120,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Modèles de programmation Kimi K2.7/K2.6/K2.5. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Passerelle d'abonnement Nous Research (le même service en amont que celui utilisé par Hermes Agent). Connexion par autorisation d'appareil auprès de `portal.nousresearch.com` ; le jeton d'accès est le JWT d'inférence envoyé avec chaque requête. Le catalogue mixte de modèles payants et `:free` (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) est découvert en direct pour le compte connecté. Les jetons d'actualisation sont à usage unique et renouvelés à chaque actualisation. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | La connexion initiale importe la session de l'installation locale de `kiro-cli`, déjà authentifiée (sous Unix, installez avec `curl -fsSL https://cli.kiro.dev/install` | `bash`; sous Windows PowerShell, utilisez `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; puis exécutez `kiro-cli login`). **Ajouter un compte** déconnecte `kiro-cli`, lance une nouvelle connexion dans le navigateur qui change le compte utilisé par `kiro-cli`, puis enregistre les métadonnées propres au profil. Les comptes OpenCodex existants sont préservés ; une annulation ou un échec restaure la session `kiro-cli` précédente. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. Le quota est sondé via `retrieveUserQuota` et `retrieveUserQuotaSummary` (délai de 8 secondes). CCA utilise toujours SSE, met en mémoire tampon SSE pour les appels unitaires et réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth avec le protocole Cloud Code Assist. La découverte en direct utilise le point de terminaison CCA authentifié `v1internal:fetchAvailableModels` et publie les modèles d'agent accessibles au compte connecté ; le catalogue maintenu reste la solution de repli. Le quota est sondé via `retrieveUserQuota` et `retrieveUserQuotaSummary` (délai de 8 secondes). Les requêtes de chat et d'adaptateur CCA utilisent SSE (`v1internal:streamGenerateContent?alt=sse`) et mettent ce flux en mémoire tampon pour les appels unitaires. La génération d'images intégrée utilise le point de terminaison unitaire distinct `v1internal:generateContent`. L'adaptateur réessaie le pair daily/production en cas d'échec de transport, de 404 ou d'indisponibilité sur le premier hôte. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Connexion PKCE expérimentale, transport HTTP/2 en direct et découverte de modèles filtrés par compte. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Expérimental. Flux d'appareil GitHub et échange `copilot_internal` (client OAuth de VS Code). Nécessite un abonnement Copilot actif ; il ne s'agit pas d'une API tierce officielle. | diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 72f86cda79..8e01067a8c 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA always uses SSE, buffers SSE for unary callers, and retries the daily/production peer on first-host transport, 404, or unavailable failures. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries the daily/production peer on first-host transport, 404, or unavailable failures. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/ja/guides/providers.md b/docs-site/src/content/docs/ja/guides/providers.md index 61115f6efa..105ae0daa2 100644 --- a/docs-site/src/content/docs/ja/guides/providers.md +++ b/docs-site/src/content/docs/ja/guides/providers.md @@ -110,7 +110,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 コーディングモデル。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research サブスクリプションゲートウェイ(Hermes Agent と同じバックエンド)。`portal.nousresearch.com` へのデバイスグラントログイン; access トークンはリクエストごとの inference JWT。有料 + `:free` モデルの混在カタログ(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` など)はサインイン中のアカウントからライブ探索されます。Refresh トークンは単回使用で、更新のたびにローテーションされます。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初回ログインは、インストール済みでサインインした `kiro-cli` セッションを取り込みます(Unix では `curl -fsSL https://cli.kiro.dev/install` | `bash`、Windows PowerShell では `irm 'https://cli.kiro.dev/install.ps1'` | `iex` でインストールしてから `kiro-cli login` を実行)。**アカウントを追加**は `kiro-cli` をログアウトして新しいブラウザログインを開始し、`kiro-cli` 自体のアカウントを切り替えてアカウント別プロファイルメタデータを保存します。既存の OpenCodex アカウントは保持され、キャンセルまたは失敗時には以前の `kiro-cli` セッションが復元されます。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA は常に SSE を使用し、単項呼び出しでは SSE をバッファリングします。最初のホストでの transport、404、unavailable の失敗時は daily/production peer に再試行し。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth を Cloud Code Assist wire で使用。ライブ探索は認証済みの CCA `v1internal:fetchAvailableModels` エンドポイントを使用し、ログイン中のアカウントで利用可能な agent モデルのみを公開します。管理されたカタログはフォールバックとして残ります。Quota は `retrieveUserQuota` と `retrieveUserQuotaSummary`(8 秒タイムアウト)で取得します。CCA のチャット/アダプター リクエストは SSE(`v1internal:streamGenerateContent?alt=sse`)を使用し、単項呼び出しではそのストリームをバッファリングします。組み込み画像生成は別の単項 `v1internal:generateContent` エンドポイントを使用します。アダプターは最初のホストでの transport、404、unavailable の失敗時に daily/production peer を再試行します。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 実験的 PKCE ログイン、HTTP/2 トランスポート、アカウント別モデル探索をサポート。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 実験的。GitHub デバイスフロー + `copilot_internal` 交換(VS Code OAuth クライアント)。有効な Copilot サブスクリプションが必要で、公式のサードパーティ API ではありません。 | diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 13768c80eb..ea49a737e0 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -109,7 +109,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 코딩 모델. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 구독 게이트웨이(Hermes Agent와 동일한 백엔드). `portal.nousresearch.com`에 대한 디바이스 그랜트 로그인; access 토큰은 요청별 inference JWT. 유료 + `:free` 모델 혼합 카탈로그(`tencent/hy3:free`, `stepfun/step-3.7-flash:free` 등)는 로그인한 계정에서 실시간으로 발견됩니다. Refresh 토큰은 단회 사용이며, 갱신할 때마다 회전됩니다. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 최초 로그인은 설치하고 로그인한 `kiro-cli` 세션을 가져옵니다(Unix에서는 `curl -fsSL https://cli.kiro.dev/install` | `bash`, Windows PowerShell에서는 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`로 설치한 뒤 `kiro-cli login` 실행). **계정 추가**는 `kiro-cli`에서 로그아웃한 뒤 새 브라우저 로그인을 시작하여 `kiro-cli` 자체의 계정을 전환하고, 계정별 프로필 메타데이터를 저장합니다. 기존 OpenCodex 계정은 유지되며, 취소되거나 실패하면 이전 `kiro-cli` 세션을 복원합니다. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA는 항상 SSE를 사용하고 단항 호출에는 SSE 이벤트를 버퍼링하며, 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth를 Cloud Code Assist wire로 사용합니다. 실시간 탐색은 인증된 CCA `v1internal:fetchAvailableModels` 엔드포인트를 사용하며 로그인한 계정에서 사용할 수 있는 agent 모델만 게시합니다. 유지 관리되는 카탈로그는 폴백으로 남습니다. Quota는 `retrieveUserQuota` 및 `retrieveUserQuotaSummary` RPC(8초 시간 제한)로 조회합니다. CCA 채팅/어댑터 요청은 SSE(`v1internal:streamGenerateContent?alt=sse`)를 사용하고 단항 호출에는 해당 스트림을 버퍼링합니다. 내장 이미지 생성은 별도의 단항 `v1internal:generateContent` 엔드포인트를 사용합니다. 어댑터는 첫 호스트의 transport/404/unavailable 실패 시 daily/production peer로 한 번 재시도합니다. | | `cursor` | `cursor` | `https://api2.cursor.sh` | 실험적 PKCE 로그인, HTTP/2 전송, 계정별 모델 탐색을 지원합니다. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 실험적. GitHub 디바이스 플로우 + `copilot_internal` 교환(VS Code OAuth 클라이언트). 활성 Copilot 구독 필요; 공식 서드파티 API가 아닙니다. | diff --git a/docs-site/src/content/docs/ru/guides/providers.md b/docs-site/src/content/docs/ru/guides/providers.md index dd0f088253..6a4604b35e 100644 --- a/docs-site/src/content/docs/ru/guides/providers.md +++ b/docs-site/src/content/docs/ru/guides/providers.md @@ -119,7 +119,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Модели Kimi K2.7/K2.6/K2.5 для кодинга. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Шлюз подписки Nous Research (тот же бэкенд, что использует Hermes Agent). Вход по device grant против `portal.nousresearch.com`; access-токен — это JWT для каждого запроса к inference. Смешанный каталог платных + `:free` моделей (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, …) обнаруживается вживую по авторизованному аккаунту. Refresh-токены одноразовые и ротируются при каждом обновлении. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Первый вход импортирует существующую сессию после установки Kiro CLI (в Unix: `curl -fsSL https://cli.kiro.dev/install` | `bash`; в Windows PowerShell: `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; затем выполните `kiro-cli login`). **Добавить аккаунт** выполняет выход из `kiro-cli`, запускает новый вход через браузер, переключает аккаунт самого `kiro-cli` и сохраняет метаданные профиля отдельно для каждого аккаунта. Существующие аккаунты OpenCodex сохраняются; при отмене или сбое восстанавливается предыдущая сессия `kiro-cli`. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). CCA всегда использует SSE и буферизует SSE для унарных вызовов; при сбое транспорта, 404 или unavailable на первом хосте выполняется одна попытка на daily/production peer. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth поверх протокола Cloud Code Assist. Живое обнаружение использует аутентифицированный CCA-эндпоинт `v1internal:fetchAvailableModels` и публикует только agent-модели, доступные текущему аккаунту; поддерживаемый каталог остаётся резервным вариантом. Квота запрашивается через `retrieveUserQuota` и `retrieveUserQuotaSummary` (тайм-аут 8 секунд). Запросы чата и адаптера CCA используют SSE (`v1internal:streamGenerateContent?alt=sse`) и буферизуют этот поток для унарных вызовов. Встроенная генерация изображений использует отдельный унарный эндпоинт `v1internal:generateContent`. Адаптер повторяет запрос к daily/production peer при сбое транспорта, 404 или unavailable на первом хосте. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Экспериментальный PKCE-вход, живой транспорт HTTP/2 и обнаружение моделей с фильтрацией по аккаунту. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Экспериментально. Device flow GitHub + обмен `copilot_internal` (OAuth-клиент VS Code). Требуется активная подписка Copilot; это не официальный сторонний API. | diff --git a/docs-site/src/content/docs/tr/guides/providers.md b/docs-site/src/content/docs/tr/guides/providers.md index 9f83498f50..23fce9593c 100644 --- a/docs-site/src/content/docs/tr/guides/providers.md +++ b/docs-site/src/content/docs/tr/guides/providers.md @@ -134,7 +134,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 kodlama modelleri. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research abonelik ağ geçidi (Hermes Agent'ın kullandığı aynı arka uç). `portal.nousresearch.com`'a karşı cihaz yetkilendirmesi girişi; erişim belirteci istek başına çıkarım JWT'sidir. Oturum açmış hesaptan canlı olarak keşfedilen karışık ücretli + `:free` model kataloğu (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...). Yenileme belirteçleri tek kullanımlıktır ve her yenilemede döndürülür. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | İlk oturum açma, kurulu ve oturum açılmış `kiro-cli` oturumunu içe aktarır (Unix'te `curl -fsSL https://cli.kiro.dev/install` | `bash` ile kurun; Windows PowerShell'de `irm 'https://cli.kiro.dev/install.ps1'` | `iex` kullanın; ardından `kiro-cli login` çalıştırın). **Hesap ekle**, `kiro-cli` oturumunu kapatır, `kiro-cli` tarafından kullanılan hesabı değiştiren yeni bir tarayıcı girişi başlatır ve hesap kapsamlı profil meta verilerini saklar. Mevcut OpenCodex hesapları korunur ve iptal veya başarısızlık önceki `kiro-cli` oturumunu geri yükler. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. Kota `retrieveUserQuota` ve `retrieveUserQuotaSummary` RPC'leriyle (8 saniyelik zaman aşımı) sorgulanır. CCA her zaman SSE kullanır ve tekli çağrılar için SSE'yi arabelleğe alır; ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer denenir. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Cloud Code Assist hattı üzerinden Google OAuth. Canlı keşif CCA'nın kimlik doğrulamalı `v1internal:fetchAvailableModels` uç noktasını kullanır ve oturum açmış hesap için kullanılabilir olan ajan modellerini yayınlar; sürdürülen katalog geri dönüş olarak kalır. Kota `retrieveUserQuota` ve `retrieveUserQuotaSummary` RPC'leriyle (8 saniyelik zaman aşımı) sorgulanır. CCA sohbet/adaptör istekleri SSE (`v1internal:streamGenerateContent?alt=sse`) kullanır ve tekli çağrılar için bu akışı arabelleğe alır. Yerleşik görüntü oluşturma ayrı tekli `v1internal:generateContent` uç noktasını kullanır. Adaptör, ilk ana bilgisayardaki aktarım/404/unavailable hatasında daily/production peer'ı yeniden dener. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Deneysel PKCE girişi, canlı HTTP/2 aktarımı ve hesap filtreli model keşfi. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Deneysel. GitHub cihaz akışı + `copilot_internal` değişimi (VS Code OAuth istemcisi). Aktif bir Copilot aboneliği gerektirir; resmi bir üçüncü taraf API değildir. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 03d20514a1..68b4566784 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -100,7 +100,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 始终使用 SSE,单请求调用会缓冲 SSE 事件;首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 聊天/adapter 请求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),并为单次调用缓冲该流。内置图像生成使用单独的 unary `v1internal:generateContent` 端点。Adapter 在首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/docs-site/src/content/docs/zh-tw/guides/providers.md b/docs-site/src/content/docs/zh-tw/guides/providers.md index 5bc1b9e812..2419bea41b 100644 --- a/docs-site/src/content/docs/zh-tw/guides/providers.md +++ b/docs-site/src/content/docs/zh-tw/guides/providers.md @@ -108,7 +108,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding 模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 訂閱 gateway(Hermes Agent 使用相同 backend)。透過 `portal.nousresearch.com` 做 device-grant 登入;access token 是每次請求使用的 inference JWT。混合付費與 `:free` 模型 catalog(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)會從已登入帳號即時探索。Refresh token 為單次使用,每次 refresh 都會輪換。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 初次登入會匯入已安裝且已登入的 `kiro-cli` session。Unix 可用 `curl -fsSL https://cli.kiro.dev/install` | `bash` 安裝;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`,再執行 `kiro-cli login`。**Add account** 會先登出 `kiro-cli`、啟動新的 browser login,切換 `kiro-cli` 所使用的帳號並保存 account-scoped profile metadata。既有 OpenCodex 帳號會保留;取消或失敗時會恢復先前的 `kiro-cli` session。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 一律使用 SSE,單次呼叫會緩衝 SSE 事件;第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 透過 Cloud Code Assist wire 使用 Google OAuth。即時探索使用 CCA 經認證的 `v1internal:fetchAvailableModels` 端點,發布目前登入帳號可用的 agent 模型;維護中的 catalog 作為 fallback。Quota 會透過 `retrieveUserQuota` 與 `retrieveUserQuotaSummary` RPC 即時查詢(8 秒逾時)。CCA 聊天/adapter 請求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),並為單次呼叫緩衝該串流。內建圖片生成使用獨立的 unary `v1internal:generateContent` 端點。Adapter 會在第一個主機發生 transport、404 或 unavailable 失敗時重試 daily/production peer。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 實驗性 PKCE 登入、即時 HTTP/2 transport 與按帳號篩選的模型探索。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 實驗性。GitHub device flow + `copilot_internal` exchange(VS Code OAuth client)。需要有效 Copilot 訂閱;不是官方第三方 API。 | diff --git a/src/adapters/google-http.ts b/src/adapters/google-http.ts index d1d8fc7263..10fe4d2a4f 100644 --- a/src/adapters/google-http.ts +++ b/src/adapters/google-http.ts @@ -308,7 +308,7 @@ async function fetchGoogleWithRetryInternal( if (shouldTryPeer) { cancelResponseBodyBestEffort(res); antigravityHostIndex = 1; - activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + activeRequest = requestForHost(activeRequest, antigravityHosts[antigravityHostIndex]!); continue; } } @@ -316,7 +316,7 @@ async function fetchGoogleWithRetryInternal( const fetchPeer = antigravityHosts.length > 1 && antigravityHostIndex === 0 ? () => fetchGoogleWithRetryInternal( label, - requestForHost(request, antigravityHosts[1]!), + requestForHost(activeRequest, antigravityHosts[1]!), ctx, false, opts, @@ -366,7 +366,7 @@ async function fetchGoogleWithRetryInternal( lastError = err; if (antigravityHosts.length > 1 && antigravityHostIndex === 0) { antigravityHostIndex = 1; - activeRequest = requestForHost(request, antigravityHosts[antigravityHostIndex]!); + activeRequest = requestForHost(activeRequest, antigravityHosts[antigravityHostIndex]!); continue; } if (attempt === GOOGLE_RETRY_ATTEMPTS - 1) throw err; diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index a1dede7912..3ae88dc487 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -266,6 +266,89 @@ describe("google provider hardening", () => { } }); + test("CCA throwing peer leg does not replay the first host", async () => { + const calls: string[] = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async (input: string | URL | Request) => { + calls.push(String(input)); + if (calls.length === 1) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + throw new Error("peer transport down"); + }) as typeof fetch; + try { + await expect(fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: "{}", + }, { timeoutMs: 5_000 })).rejects.toThrow("peer transport down"); + expect(calls).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + } finally { + globalThis.fetch = realFetch; + } + }); + + test("CCA host failover preserves a body repaired after the first host 400", async () => { + const calls: Array<{ url: string; body: string }> = []; + const realFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit, + ) => { + calls.push({ url: String(input), body: String(init?.body ?? "") }); + if (calls.length === 1) { + return new Response(JSON.stringify({ + error: { status: "INVALID_ARGUMENT", message: "input schema is invalid" }, + }), { status: 400 }); + } + if (calls.length === 2) { + return sseResponse([{ error: { status: "UNAVAILABLE", message: "try another host" } }]); + } + return sseResponse([ + { response: { candidates: [{ content: { parts: [{ text: "repaired" }] } }] } }, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ]); + }) as typeof fetch; + const originalBody = JSON.stringify({ + model: "gemini-3.7-flash-tiered", + request: { + contents: [{ role: "user", parts: [{ text: "hi" }] }], + tools: [{ + functionDeclarations: [{ + name: "lookup", + parameters: { type: "object", properties: { city: { type: "string" } } }, + }], + }], + }, + }); + try { + const response = await fetchAntigravityWithRetry({ + url: "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + method: "POST", + headers: {}, + body: originalBody, + }, { timeoutMs: 5_000 }); + + expect(await response.text()).toContain("repaired"); + expect(calls.map(call => call.url)).toEqual([ + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://daily-cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + "https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse", + ]); + expect(calls[0]?.body).toBe(originalBody); + expect(calls[1]?.body).not.toBe(originalBody); + expect(calls[2]?.body).toBe(calls[1]?.body); + } finally { + globalThis.fetch = realFetch; + } + }); + test("CCA peer failures use retry and final error normalization", async () => { const calls: string[] = []; const realFetch = globalThis.fetch; From 7507812012092f8e16dec11aee7462e226463add Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 17:17:28 -0600 Subject: [PATCH 018/102] docs(antigravity): scope CCA transport invariant --- structure/04_transports-and-sidecars.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 6e6b044c0a..1d94629ed9 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -852,9 +852,10 @@ combo whose remaining eligible targets use other providers. ## Antigravity transport failover -Cloud Code Assist requests always use `streamGenerateContent?alt=sse`, including unary callers; -the adapter buffers those SSE events for the unary contract. The configured daily or production -host is tried first, then only its maintained peer (`daily-cloudcode-pa.googleapis.com` or +CCA chat/adapter requests use `streamGenerateContent?alt=sse`, including unary callers; the adapter +buffers those SSE events for the unary contract. Built-in image generation uses the separate unary +`v1internal:generateContent` endpoint outside the adapter. The configured daily or production host +is tried first, then only its maintained peer (`daily-cloudcode-pa.googleapis.com` or `cloudcode-pa.googleapis.com`) is eligible for one first-host transport, 404, `UNAVAILABLE`, or empty-stream retry. Authentication, geoblock, invalid-request, and exhausted-quota responses do not trigger host failover. From 35e54219f020708f3967253024124cedaee570c7 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:26 -0600 Subject: [PATCH 019/102] test(google): cover exact SSE cap and terminal CCA Co-authored-by: Cursor --- src/adapters/google.ts | 5 ++++- tests/google-antigravity-wire.test.ts | 6 +++++- tests/google-sse-frame-cap.test.ts | 20 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 563e9cffcf..2e220409c8 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -324,9 +324,12 @@ function scanSseLineBytes(incompleteLineBytes: number, incoming: Uint8Array): { let lineBytes = incompleteLineBytes; let maximum = lineBytes; for (const byte of incoming) { + if (byte === 0x0a) { + lineBytes = 0; + continue; + } lineBytes += 1; maximum = Math.max(maximum, lineBytes); - if (byte === 0x0a) lineBytes = 0; } return { maximum, residual: lineBytes }; } diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index a811748715..82c5b8b8df 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -788,7 +788,11 @@ describe("antigravity parseResponse unwraps response (non-streaming)", () => { // buildRequest first to set the per-adapter model/session, then parseResponse to observe. await adapter.buildRequest(parsed("hello world")); const body = { response: { candidates: [{ content: { parts: [{ functionCall: { name: "do_x", args: { a: 1 } }, thoughtSignature: "sig-nonstream0000000" } ] } }] } }; - await adapter.parseResponse!(sseResponse([body])); + const events = await adapter.parseResponse!(sseResponse([ + body, + { response: { candidates: [{ finishReason: "STOP" }] } }, + ])); + expect(events.at(-1)?.type).toBe("done"); // A follow-up request's history should now get the signature re-injected. const followup = parsed("hello world"); const contents = [{ role: "model", parts: [{ functionCall: { name: "do_x", args: { a: 1 } } }] }]; diff --git a/tests/google-sse-frame-cap.test.ts b/tests/google-sse-frame-cap.test.ts index 80cd498b4f..d06d9b55f8 100644 --- a/tests/google-sse-frame-cap.test.ts +++ b/tests/google-sse-frame-cap.test.ts @@ -160,6 +160,26 @@ describe("google SSE frame byte cap", () => { }); }); + test("accepts a data line exactly at the cap before its LF delimiter", async () => { + const cap = 128; + const envelope = (text: string) => ({ + response: { candidates: [{ content: { parts: [{ text }] } }] }, + }); + const encoder = new TextEncoder(); + const emptyLine = `data: ${JSON.stringify(envelope(""))}`; + const line = `data: ${JSON.stringify(envelope("a".repeat(cap - encoder.encode(emptyLine).byteLength)))}`; + expect(encoder.encode(line).byteLength).toBe(cap); + + setGoogleSseFrameMaxBytesForTests(cap); + const events = await collect( + createGoogleAdapter(googleProvider()).parseStream( + byteStreamResponse([encoder.encode(`${line}\n\n`)]), + ), + ); + + expect(events.some(event => event.type === "error" && event.message.includes("exceeds"))).toBe(false); + }); + test("accepts multiple sub-cap data frames delivered in one oversized chunk", async () => { const cap = 96; const body = [ From aeba15dfbc17b104b317b21478185306e91095f4 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:26 -0600 Subject: [PATCH 020/102] test(antigravity): assert quota RPC redirect policy Co-authored-by: Cursor --- tests/antigravity-quota.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/antigravity-quota.test.ts b/tests/antigravity-quota.test.ts index aef37eb3fc..b41cd2bd7b 100644 --- a/tests/antigravity-quota.test.ts +++ b/tests/antigravity-quota.test.ts @@ -3,13 +3,15 @@ import { fetchAntigravityLiveQuota } from "../src/providers/antigravity-quota"; import { QUOTA_RESPONSE_MAX_BYTES } from "../src/providers/quota"; test("does not classify an unlabelled daily summary window as weekly", async () => { + const requestOptions: Array<{ url: string; init?: RequestInit }> = []; const result = await fetchAntigravityLiveQuota({ accessToken: "agy-access-secret", projectId: "agy-project-secret", baseUrl: "https://daily-cloudcode-pa.googleapis.com", timeoutMs: 1_000, - fetchImpl: async (input: RequestInfo | URL) => { + fetchImpl: async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); + requestOptions.push({ url, init }); if (url.endsWith(":retrieveUserQuota")) { return new Response(JSON.stringify({ models: { @@ -28,6 +30,11 @@ test("does not classify an unlabelled daily summary window as weekly", async () expect(result?.customWindows?.[0]?.label).toBe("Gem"); expect(result?.weeklyPercent).toBeUndefined(); + expect( + requestOptions + .filter(({ url }) => url.includes(":retrieveUserQuota")) + .map(({ init }) => init?.redirect), + ).toEqual(["error", "error"]); }); test("keeps the daily quota when the summary RPC fails", async () => { From c077a6ca9da3e696451c65f475b6917cae5c3356 Mon Sep 17 00:00:00 2001 From: SB Yoon <44089734+yansigit@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:08:39 -0600 Subject: [PATCH 021/102] docs(antigravity): clarify peer failover contract Co-authored-by: Cursor --- docs-site/src/content/docs/guides/providers.md | 2 +- docs-site/src/content/docs/zh-cn/guides/providers.md | 2 +- structure/04_transports-and-sidecars.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 8e01067a8c..341fbe7c29 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -115,7 +115,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install` | `bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1'` | `iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries the daily/production peer on first-host transport, 404, or unavailable failures. | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | Google OAuth over the Cloud Code Assist wire. Live discovery uses CCA's authenticated `v1internal:fetchAvailableModels` endpoint and publishes the agent models available to the signed-in account; the maintained catalog remains the fallback. Quota is probed live via `retrieveUserQuota` and `retrieveUserQuotaSummary` (8-second timeout). CCA chat/adapter requests use SSE (`v1internal:streamGenerateContent?alt=sse`) and buffer that stream for unary callers. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint. The adapter retries its maintained daily/production peer at most once after a first-host transport failure, empty stream, 404, or `UNAVAILABLE`; authentication, geoblock, invalid-request, and exhausted-quota responses do not trigger host failover. | | `cursor` | `cursor` | `https://api2.cursor.sh` | Experimental PKCE login, live HTTP/2 transport with an opt-in HTTP/1.1 compatibility path, and account-filtered model discovery. | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | Experimental. GitHub device flow + `copilot_internal` exchange (VS Code OAuth client). Requires an active Copilot subscription; not an official third-party API. | diff --git a/docs-site/src/content/docs/zh-cn/guides/providers.md b/docs-site/src/content/docs/zh-cn/guides/providers.md index 68b4566784..ef93c6ad8c 100644 --- a/docs-site/src/content/docs/zh-cn/guides/providers.md +++ b/docs-site/src/content/docs/zh-cn/guides/providers.md @@ -100,7 +100,7 @@ ocx logout | `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 编程模型。 | | `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research 订阅网关(与 Hermes Agent 使用同一后端)。通过设备授权登录 `portal.nousresearch.com`;access 令牌是每个请求的 inference JWT。付费 + `:free` 模型混合目录(`tencent/hy3:free`、`stepfun/step-3.7-flash:free` 等)会从已登录账户实时发现。Refresh 令牌是单次使用,每次刷新都会轮换。 | | `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | 首次登录会导入已安装并已登录的 Kiro CLI 会话(Unix 使用 `curl -fsSL https://cli.kiro.dev/install` | `bash`;Windows PowerShell 使用 `irm 'https://cli.kiro.dev/install.ps1'` | `iex`;然后运行 `kiro-cli login`)。**添加账户**会先退出 `kiro-cli`,再启动新的浏览器登录,从而切换 `kiro-cli` 自身使用的账户,并保存账户范围的配置文件元数据。现有 OpenCodex 账户会保留;如果取消或失败,则恢复之前的 `kiro-cli` 会话。 | -| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 聊天/adapter 请求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),并为单次调用缓冲该流。内置图像生成使用单独的 unary `v1internal:generateContent` 端点。Adapter 在首个主机发生传输、404 或 unavailable 失败时重试 daily/production peer。 | +| `google-antigravity` | `google` | `https://daily-cloudcode-pa.googleapis.com` | 通过 Cloud Code Assist 协议使用 Google OAuth。实时发现调用已认证的 CCA `v1internal:fetchAvailableModels` 端点,并仅发布当前登录账户可用的 agent 模型;维护中的目录仍作为回退。Quota 通过 `retrieveUserQuota` 和 `retrieveUserQuotaSummary` RPC 实时查询(8 秒超时)。CCA 聊天/adapter 请求使用 SSE(`v1internal:streamGenerateContent?alt=sse`),并为单次调用缓冲该流。内置图像生成使用单独的 unary `v1internal:generateContent` 端点。Adapter 在首个主机发生传输失败、空流、404 或 `UNAVAILABLE` 后,至多重试一次其维护的 daily/production peer;认证、地理封锁、无效请求和配额耗尽不会触发主机故障转移。 | | `cursor` | `cursor` | `https://api2.cursor.sh` | 实验性 PKCE 登录、带可选 HTTP/1.1 兼容路径的 HTTP/2 传输,以及按账号筛选的模型发现。 | | `github-copilot` | `openai-chat` | `https://api.githubcopilot.com` | 实验性。GitHub 设备流 + `copilot_internal` 交换(VS Code OAuth 客户端)。需要有效的 Copilot 订阅;不是官方第三方 API。 | diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1d94629ed9..063a98f8ae 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -856,9 +856,9 @@ CCA chat/adapter requests use `streamGenerateContent?alt=sse`, including unary c buffers those SSE events for the unary contract. Built-in image generation uses the separate unary `v1internal:generateContent` endpoint outside the adapter. The configured daily or production host is tried first, then only its maintained peer (`daily-cloudcode-pa.googleapis.com` or -`cloudcode-pa.googleapis.com`) is eligible for one first-host transport, 404, `UNAVAILABLE`, or -empty-stream retry. Authentication, geoblock, invalid-request, and exhausted-quota responses do -not trigger host failover. +`cloudcode-pa.googleapis.com`) is eligible for a single retry after a first-host transport +failure, empty stream, 404, or `UNAVAILABLE`. Authentication, geoblock, invalid-request, and +exhausted-quota responses do not trigger host failover. ## Transport inventory From cc58ff5f0c07d35f748ff277d98ce3008062d46f Mon Sep 17 00:00:00 2001 From: zhou-zhichao Date: Wed, 19 Aug 2026 03:45:31 +0200 Subject: [PATCH 022/102] feat(images): relay Codex image_gen to xAI Imagine with Grok OAuth When images.bridgeEnabled is on and an xAI login exists, POST /v1/images/{generations,edits} goes to api.x.ai instead of ChatGPT. Routed chat turns keep hosted image_generation visible as image_gen so Grok can call Codex's client tool. The Responses image-bridge loop stays API-key-only and still defers to web search. --- .../content/docs/guides/codex-integration.md | 6 + .../src/content/docs/guides/image-bridge.md | 10 +- src/images/index.ts | 2 +- src/images/plan.ts | 14 +++ src/images/synthetic-tool.ts | 5 + src/images/xai-client.ts | 15 ++- src/responses/parser.ts | 11 +- src/server/images.ts | 117 ++++++++++++++++-- tests/images/synthetic-tool.test.ts | 7 ++ tests/images/xai-client.test.ts | 18 +++ tests/responses-parser.test.ts | 1 + tests/server-images.test.ts | 40 ++++++ 12 files changed, 229 insertions(+), 17 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-integration.md b/docs-site/src/content/docs/guides/codex-integration.md index a9c7a140ba..2a4578fab0 100644 --- a/docs-site/src/content/docs/guides/codex-integration.md +++ b/docs-site/src/content/docs/guides/codex-integration.md @@ -55,6 +55,12 @@ Standalone `/images/generations` calls never enter that bridge. `openai-responses` provider whose endpoint implements the OpenAI Images API. Explicit selection fails closed and never falls back to a different paid upstream. Registry-managed provider ids are not accepted here; omit `images.provider` to use the built-in OpenAI tiers. +- **xAI Imagine (Grok OAuth) relay:** when `images.bridgeEnabled` is `true` and an `xai` provider + has a usable Grok CLI OAuth token (`ocx login xai`) or API key, `/v1/images/generations` and + `/v1/images/edits` are sent to `https://api.x.ai/v1` with that token. ChatGPT credentials are + not forwarded. The relay maps Codex `size` / `aspect_ratio` onto xAI's Imagine body and returns + the same `{created, data:[{b64_json}]}` shape. This is independent of the Responses Image + Bridge loop (which remains API-key-only). - **Google Antigravity (CCA) fallback:** when neither an OpenAI forward candidate nor a keyed provider is configured, `/v1/images/generations` (not `/images/edits`) falls back to the Antigravity **Cloud Code Assist** endpoint using the `gemini-3.1-flash-image` model. The fallback diff --git a/docs-site/src/content/docs/guides/image-bridge.md b/docs-site/src/content/docs/guides/image-bridge.md index 6606fee55b..ca513a0d2a 100644 --- a/docs-site/src/content/docs/guides/image-bridge.md +++ b/docs-site/src/content/docs/guides/image-bridge.md @@ -14,10 +14,12 @@ xAI Grok Imagine, so the model you're actually chatting with can still generate - **Enable the bridge** by setting `images.bridgeEnabled: true` in your config (it is off by default to avoid unexpected xAI charges — see [Configuration](#configuration) below). -- An `xai` provider entry with an **API key**. The bridge pins fulfillment to the registry xAI - Images endpoint (`https://api.x.ai/v1`); any configured `baseUrl` override is ignored for image - calls. OAuth / `ocx login xai` alone does **not** arm the bridge (the Grok CLI OAuth transport is - chat-oriented and is not used for `/images/*`). +- An `xai` provider entry with an **API key**. The Responses Image Bridge pins fulfillment to the + registry xAI Images endpoint (`https://api.x.ai/v1`); any configured `baseUrl` override is + ignored for image calls. OAuth / `ocx login xai` alone does **not** arm this sidecar loop. + The same `bridgeEnabled` flag does arm the separate Codex `/v1/images` relay so the built-in + `image_gen` client can call Imagine with the Grok CLI grant — see + [Built-in image generation](/guides/codex-integration/#built-in-image-generation-image_gen). ```json { diff --git a/src/images/index.ts b/src/images/index.ts index f9f0fd5b99..d3562f398b 100644 --- a/src/images/index.ts +++ b/src/images/index.ts @@ -1,4 +1,4 @@ -export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey } from "./plan"; +export { planImageBridge, planVideoBridge, findXaiProvider, resolveXaiImageApiKey, resolveXaiImageAuthToken } from "./plan"; export { runWithImageBridge, clampImageMaxRounds, DEFAULT_MAX_ROUNDS, MAX_ROUNDS_HARD_LIMIT } from "./loop"; export type { ImageBridgePlan, ImageCallResult, VideoBridgePlan, VideoCallResult } from "./types"; export { buildImageTool, buildVideoTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isImageGenName, isVideoGenName } from "./synthetic-tool"; diff --git a/src/images/plan.ts b/src/images/plan.ts index b8780d0fb4..6ff898ef76 100644 --- a/src/images/plan.ts +++ b/src/images/plan.ts @@ -2,6 +2,7 @@ import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; import { toolChoiceToolPredicate } from "../types"; import type { ImageBridgePlan, VideoBridgePlan } from "./types"; import { resolveEnvValue } from "../config"; +import { getValidAccessToken } from "../oauth/index"; import { getProviderRegistryEntry } from "../providers/registry"; import { IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME, isVideoGenName } from "./synthetic-tool"; @@ -40,6 +41,19 @@ export function resolveXaiImageApiKey(provider: OcxProviderConfig): string | und return apiKey || undefined; } +/** Token for the /v1/images → Imagine relay. OAuth reuses the Grok CLI grant. */ +export async function resolveXaiImageAuthToken(provider: OcxProviderConfig): Promise { + if (provider.authMode === "oauth") { + try { + const token = (await getValidAccessToken("xai"))?.trim(); + return token || undefined; + } catch { + return undefined; + } + } + return resolveXaiImageApiKey(provider); +} + export async function planImageBridge( config: OcxConfig, parsed: OcxParsedRequest, diff --git a/src/images/synthetic-tool.ts b/src/images/synthetic-tool.ts index 84ac536ab3..96979bbb50 100644 --- a/src/images/synthetic-tool.ts +++ b/src/images/synthetic-tool.ts @@ -80,6 +80,11 @@ export function buildImageTool(): OcxTool { properties: { prompt: { type: "string", description: "Detailed image generation prompt. Required." }, n: { type: "integer", minimum: 1, maximum: 4 }, + aspect_ratio: { + type: "string", + enum: ["1:1", "16:9", "9:16", "4:3", "3:4", "auto"], + description: "Image aspect ratio. Default auto.", + }, }, required: ["prompt"], }, diff --git a/src/images/xai-client.ts b/src/images/xai-client.ts index 3695bcde50..4889c6692d 100644 --- a/src/images/xai-client.ts +++ b/src/images/xai-client.ts @@ -14,6 +14,8 @@ export interface XaiImageRequest { n?: number; // 1-4 size?: string; quality?: string; + /** Literal xAI aspect_ratio. Wins over `size` when both are present. */ + aspectRatio?: string; imageUrl?: string; // if set → /images/edits } @@ -28,6 +30,7 @@ const XAI_DEFAULT_MODEL = "grok-imagine-image-quality"; // OpenAI's "WxH" size to the closest standard ratio (log-space distance) and // OpenAI quality buckets onto xAI's 1k/2k resolution. Unknown values are // dropped rather than forwarded, to avoid sending parameters xAI rejects. +const XAI_ASPECT_RATIO_LITERALS = new Set(["1:1", "16:9", "9:16", "4:3", "3:4"]); const XAI_ASPECT_RATIOS: ReadonlyArray = [ ["1:1", 1], ["3:4", 0.75], @@ -36,6 +39,16 @@ const XAI_ASPECT_RATIOS: ReadonlyArray = [ ["16:9", 16 / 9], ]; +function resolveAspectRatio(req: XaiImageRequest): string | undefined { + const literal = req.aspectRatio?.trim(); + if (literal) { + if (literal === "auto") return undefined; + if (XAI_ASPECT_RATIO_LITERALS.has(literal)) return literal; + return undefined; + } + return mapSizeToAspectRatio(req.size); +} + function mapSizeToAspectRatio(size?: string): string | undefined { if (!size) return undefined; const m = /^(\d+)x(\d+)$/.exec(size); @@ -75,7 +88,7 @@ export async function callXaiImages( prompt: req.prompt, n: req.n ?? 1, }; - const aspectRatio = mapSizeToAspectRatio(req.size); + const aspectRatio = resolveAspectRatio(req); const resolution = mapQualityToResolution(req.quality); if (aspectRatio) body.aspect_ratio = aspectRatio; if (resolution) body.resolution = resolution; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 7c2393bd2e..73f8aa93da 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -19,7 +19,7 @@ import { compactionItemToText } from "./compaction"; import { previousResponseReplayPrefixLength } from "./state"; import { decodeReasoningEnvelope } from "./reasoning-envelope"; import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool"; -import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; +import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool"; function isObj(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); @@ -226,6 +226,12 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { toolSearch: true, }); } + else if (t.type === "image_generation" || t.type === "image_gen") { + // Keep Codex's image_gen visible to routed chat models. The hosted OpenAI tool + // cannot execute on Grok; the model still has to see a callable image_gen so + // Codex's client-side /v1/images request can fire and be relayed to xAI. + if (!out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME)) out.push(buildImageTool()); + } else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") { // Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly // model) is client-executed — pass it through as a function so the routed model can read and @@ -233,8 +239,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined { // silently dropped, so the model never saw them. pushFn(t); } - // Only the OpenAI-hosted server-side tools (web_search, image_generation) are intentionally - // dropped — they're executed by OpenAI and can't be relayed to a routed chat model. + // Hosted web_search is still dropped here — the web-search sidecar re-injects it. } return out.length > 0 ? out : undefined; } diff --git a/src/server/images.ts b/src/server/images.ts index 5e65a1a170..ac76a60f39 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -38,6 +38,8 @@ import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; import { sanitizeUpstreamErrorText } from "../adapters/upstream-http-error"; import { ANTIGRAVITY_REQUEST_UA } from "../adapters/google-antigravity-wire"; import { decodeValidatedImageBase64, MAX_ENCODED_BYTES_PER_IMAGE } from "../images/artifacts"; +import { findXaiProvider, resolveXaiImageAuthToken } from "../images/plan"; +import { callXaiImages } from "../images/xai-client"; import type { AdmissionLease } from "../lib/admission"; import { codexAccountSelectionForTurn } from "./lifecycle"; @@ -372,6 +374,101 @@ async function tryCcaImageGeneration( } } +/** + * Codex's client-side image_gen POSTs here. When the Grok Imagine bridge is + * opted in, send that request to api.x.ai instead of ChatGPT. + */ +async function tryXaiImageRelay( + body: unknown, + config: OcxConfig, + logCtx: RequestLogContext, + signal: AbortSignal | undefined, + endpoint: ImagesEndpoint, +): Promise { + if (config.images?.bridgeEnabled !== true) return undefined; + const found = findXaiProvider(config); + if (!found) return undefined; + const token = await resolveXaiImageAuthToken(found.provider); + if (!token) return undefined; + const obj = body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : {}; + const prompt = typeof obj.prompt === "string" ? obj.prompt + : typeof obj.input === "string" ? obj.input + : ""; + if (!prompt.trim()) { + return formatErrorResponse(400, "invalid_request_error", "image generation requires a prompt"); + } + const n = typeof obj.n === "number" && Number.isFinite(obj.n) ? Math.max(1, Math.min(4, Math.floor(obj.n))) : 1; + const size = typeof obj.size === "string" ? obj.size : undefined; + const quality = typeof obj.quality === "string" ? obj.quality : undefined; + const aspectRatio = typeof obj.aspect_ratio === "string" ? obj.aspect_ratio : undefined; + let imageUrl: string | undefined; + if (endpoint === "edits") { + const images = obj.images; + const first = Array.isArray(images) ? images[0] : undefined; + if (typeof obj.image === "string") imageUrl = obj.image; + else if (typeof obj.image_url === "string") imageUrl = obj.image_url; + else if (first && typeof first === "object" && first !== null) { + const rec = first as Record; + if (typeof rec.image_url === "string") imageUrl = rec.image_url; + else if (typeof rec.url === "string") imageUrl = rec.url; + } + } + logCtx.provider = "xai"; + logCtx.model = config.images?.bridgeModel ?? "grok-imagine-image-quality"; + const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; + try { + const result = await callXaiImages( + { + prompt, + model: logCtx.model, + n, + size, + quality, + aspectRatio, + imageUrl, + }, + { baseUrl: "https://api.x.ai/v1", token }, + signal, + timeoutMs, + ); + const data: Array<{ b64_json: string }> = []; + for (const img of result.images) { + if (typeof img.b64_json === "string" && img.b64_json) { + data.push({ b64_json: img.b64_json }); + continue; + } + if (typeof img.url !== "string" || !img.url) continue; + const fetched = await fetch(img.url, { signal, redirect: "follow" }); + if (!fetched.ok) continue; + const bytes = Buffer.from(await fetched.arrayBuffer()); + if (bytes.byteLength === 0) continue; + data.push({ b64_json: bytes.toString("base64") }); + } + if (data.length === 0) { + return formatErrorResponse(502, "upstream_error", "xAI image generation returned no usable images"); + } + return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (err) { + if (signal?.aborted) { + return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`); + } + const status = typeof err === "object" && err && "status" in err && typeof (err as { status: unknown }).status === "number" + ? (err as { status: number }).status + : 502; + const message = err instanceof Error ? err.message : String(err); + return formatErrorResponse( + status >= 400 && status < 600 ? status : 502, + "upstream_error", + `xAI image ${endpoint} failed: ${message}`, + ); + } +} + export async function handleImages( req: Request, config: OcxConfig, @@ -379,6 +476,18 @@ export async function handleImages( logCtx: RequestLogContext, turnAdmissionLease?: AdmissionLease, ): Promise { + let body: unknown; + try { + body = await readJsonRequestBody(req); + } catch (err) { + return decodeRequestErrorResponse(err, "images"); + } + const model = (body as { model?: unknown } | null)?.model; + if (typeof model === "string" && model) logCtx.model = model; + + const xaiRelay = await tryXaiImageRelay(body, config, logCtx, req.signal, endpoint); + if (xaiRelay) return xaiRelay; + const candidates = selectImagesProvider(config); if (candidates.error) { return formatErrorResponse(400, "invalid_request_error", candidates.error); @@ -398,14 +507,6 @@ export async function handleImages( } } } - let body: unknown; - try { - body = await readJsonRequestBody(req); - } catch (err) { - return decodeRequestErrorResponse(err, "images"); - } - const model = (body as { model?: unknown } | null)?.model; - if (typeof model === "string" && model) logCtx.model = model; const canUseOpenAiForward = !skipOpenAiForwardForAdmissionBearer && candidates.forwardCandidates.length > 0; diff --git a/tests/images/synthetic-tool.test.ts b/tests/images/synthetic-tool.test.ts index 24e1fc9311..e882462010 100644 --- a/tests/images/synthetic-tool.test.ts +++ b/tests/images/synthetic-tool.test.ts @@ -83,4 +83,11 @@ describe("buildImageTool", () => { expect(tool.name).toBe("image_gen"); expect(tool.imageGeneration).toBe(true); }); + + test("exposes aspect_ratio literals including auto", () => { + const tool = buildImageTool(); + const aspect = (tool.parameters as { properties?: { aspect_ratio?: { enum?: string[] } } }) + .properties?.aspect_ratio; + expect(aspect?.enum).toEqual(["1:1", "16:9", "9:16", "4:3", "3:4", "auto"]); + }); }); diff --git a/tests/images/xai-client.test.ts b/tests/images/xai-client.test.ts index cec8f055e4..86e02b7a66 100644 --- a/tests/images/xai-client.test.ts +++ b/tests/images/xai-client.test.ts @@ -158,4 +158,22 @@ describe("callXaiImages", () => { expect(body).not.toHaveProperty("size"); expect(body).not.toHaveProperty("quality"); }); + + test("explicit aspect_ratio is forwarded and wins over size", async () => { + const calls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", size: "1024x1024", aspectRatio: "16:9" }, AUTH); + const body = JSON.parse((calls[0]!.init?.body as string) ?? "{}"); + expect(body.aspect_ratio).toBe("16:9"); + expect(body).not.toHaveProperty("size"); + }); + + test("aspect_ratio auto and illegal values are dropped", async () => { + const autoCalls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", aspectRatio: "auto" }, AUTH); + expect(JSON.parse((autoCalls[0]!.init?.body as string) ?? "{}")).not.toHaveProperty("aspect_ratio"); + + const badCalls = stubFetch(200, { data: [{ b64_json: "dGVzdA==" }] }); + await callXaiImages({ prompt: "x", aspectRatio: "2:1" }, AUTH); + expect(JSON.parse((badCalls[0]!.init?.body as string) ?? "{}")).not.toHaveProperty("aspect_ratio"); + }); }); diff --git a/tests/responses-parser.test.ts b/tests/responses-parser.test.ts index be6258f6a1..d80166eba1 100644 --- a/tests/responses-parser.test.ts +++ b/tests/responses-parser.test.ts @@ -216,6 +216,7 @@ describe("Responses parser", () => { expect(parsed._imageGeneration?.toolNames.has("image_generation")).toBe(true); expect(parsed.options.toolChoice).toEqual({ name: "image_gen" }); + expect(parsed.context.tools?.some(tool => tool.name === "image_gen")).toBe(true); }); test("preserves requested service_tier for request logging", () => { diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 9499de4d85..26d97db25b 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -155,6 +155,46 @@ test("image response byte reader enforces the stream cap when Content-Length is expect(tailPulled).toBe(false); }); +test("POST /v1/images/generations relays to xAI Imagine when the image bridge is enabled", async () => { + const captured: CapturedRequest[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.x.ai")) { + captured.push({ + path: new URL(url).pathname, + headers: new Headers(init?.headers), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return Response.json({ data: [{ b64_json: "dHJhaW4=" }] }); + } + return originalFetch(input, init); + }) as typeof fetch; + saveConfig({ + ...forwardConfig(), + images: { bridgeEnabled: true }, + providers: { + ...forwardConfig().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-test-token" }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/generations", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "a train", model: "gpt-image-2", size: "1024x1024" }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { data?: Array<{ b64_json?: string }> }; + expect(json.data?.[0]?.b64_json).toBe("dHJhaW4="); + expect(captured.some(call => call.path === "/v1/images/generations" && JSON.stringify(call.body).includes("grok-imagine-image-quality"))).toBe(true); + expect(captured.every(call => !call.path.includes("/backend-api/codex"))).toBe(true); + } finally { + await server.stop(true); + } +}); + test("POST /v1/images/generations relays to the ChatGPT forward provider with forwarded auth", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); From 26315c383317233c66b5b4568319eb020990e147 Mon Sep 17 00:00:00 2001 From: zhou-zhichao Date: Wed, 19 Aug 2026 03:54:15 +0200 Subject: [PATCH 023/102] fix(images): bound xAI relay timeout and reject empty edits --- src/server/images.ts | 92 +++++++++++++++++++++++-------------- tests/server-images.test.ts | 39 ++++++++++++++++ 2 files changed, 97 insertions(+), 34 deletions(-) diff --git a/src/server/images.ts b/src/server/images.ts index ac76a60f39..7be4df82c9 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -388,37 +388,53 @@ async function tryXaiImageRelay( if (config.images?.bridgeEnabled !== true) return undefined; const found = findXaiProvider(config); if (!found) return undefined; - const token = await resolveXaiImageAuthToken(found.provider); - if (!token) return undefined; - const obj = body && typeof body === "object" && !Array.isArray(body) - ? body as Record - : {}; - const prompt = typeof obj.prompt === "string" ? obj.prompt - : typeof obj.input === "string" ? obj.input - : ""; - if (!prompt.trim()) { - return formatErrorResponse(400, "invalid_request_error", "image generation requires a prompt"); - } - const n = typeof obj.n === "number" && Number.isFinite(obj.n) ? Math.max(1, Math.min(4, Math.floor(obj.n))) : 1; - const size = typeof obj.size === "string" ? obj.size : undefined; - const quality = typeof obj.quality === "string" ? obj.quality : undefined; - const aspectRatio = typeof obj.aspect_ratio === "string" ? obj.aspect_ratio : undefined; - let imageUrl: string | undefined; - if (endpoint === "edits") { - const images = obj.images; - const first = Array.isArray(images) ? images[0] : undefined; - if (typeof obj.image === "string") imageUrl = obj.image; - else if (typeof obj.image_url === "string") imageUrl = obj.image_url; - else if (first && typeof first === "object" && first !== null) { - const rec = first as Record; - if (typeof rec.image_url === "string") imageUrl = rec.image_url; - else if (typeof rec.url === "string") imageUrl = rec.url; - } - } - logCtx.provider = "xai"; - logCtx.model = config.images?.bridgeModel ?? "grok-imagine-image-quality"; const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; + const linkedSignal = signalWithTimeout(timeoutMs, signal); try { + let token: string | undefined; + try { + token = await abortableRace(resolveXaiImageAuthToken(found.provider), linkedSignal.signal); + } catch { + if (signal?.aborted) { + return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`); + } + if (linkedSignal.signal.aborted) { + return formatErrorResponse(504, "upstream_error", `xAI image ${endpoint} timed out during authentication`); + } + return undefined; + } + if (!token) return undefined; + const obj = body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : {}; + const prompt = typeof obj.prompt === "string" ? obj.prompt + : typeof obj.input === "string" ? obj.input + : ""; + if (!prompt.trim()) { + return formatErrorResponse(400, "invalid_request_error", "image generation requires a prompt"); + } + const n = typeof obj.n === "number" && Number.isFinite(obj.n) ? Math.max(1, Math.min(4, Math.floor(obj.n))) : 1; + const size = typeof obj.size === "string" ? obj.size : undefined; + const quality = typeof obj.quality === "string" ? obj.quality : undefined; + const aspectRatio = typeof obj.aspect_ratio === "string" ? obj.aspect_ratio : undefined; + let imageUrl: string | undefined; + if (endpoint === "edits") { + const images = obj.images; + const first = Array.isArray(images) ? images[0] : undefined; + if (typeof obj.image === "string") imageUrl = obj.image; + else if (typeof obj.image_url === "string") imageUrl = obj.image_url; + else if (first && typeof first === "object" && first !== null) { + const rec = first as Record; + if (typeof rec.image_url === "string") imageUrl = rec.image_url; + else if (typeof rec.url === "string") imageUrl = rec.url; + } + if (!imageUrl?.trim()) { + return formatErrorResponse(400, "invalid_request_error", "image edits require an image URL"); + } + imageUrl = imageUrl.trim(); + } + logCtx.provider = "xai"; + logCtx.model = config.images?.bridgeModel ?? "grok-imagine-image-quality"; const result = await callXaiImages( { prompt, @@ -430,7 +446,7 @@ async function tryXaiImageRelay( imageUrl, }, { baseUrl: "https://api.x.ai/v1", token }, - signal, + linkedSignal.signal, timeoutMs, ); const data: Array<{ b64_json: string }> = []; @@ -440,11 +456,14 @@ async function tryXaiImageRelay( continue; } if (typeof img.url !== "string" || !img.url) continue; - const fetched = await fetch(img.url, { signal, redirect: "follow" }); + const fetched = await fetch(img.url, { signal: linkedSignal.signal, redirect: "follow" }); if (!fetched.ok) continue; - const bytes = Buffer.from(await fetched.arrayBuffer()); - if (bytes.byteLength === 0) continue; - data.push({ b64_json: bytes.toString("base64") }); + const observed = await readImageResponseBytes(fetched, { + maxBytes: IMAGES_RESPONSE_MAX_BYTES, + signal: linkedSignal.signal, + }); + if (observed.oversized || observed.bytes.byteLength === 0) continue; + data.push({ b64_json: Buffer.from(observed.bytes).toString("base64") }); } if (data.length === 0) { return formatErrorResponse(502, "upstream_error", "xAI image generation returned no usable images"); @@ -457,6 +476,9 @@ async function tryXaiImageRelay( if (signal?.aborted) { return formatErrorResponse(499, "client_closed_request", `image ${endpoint} request canceled by client`); } + if (linkedSignal.signal.aborted || (err instanceof Error && err.name === "TimeoutError")) { + return formatErrorResponse(504, "upstream_error", `xAI image ${endpoint} timed out`); + } const status = typeof err === "object" && err && "status" in err && typeof (err as { status: unknown }).status === "number" ? (err as { status: number }).status : 502; @@ -466,6 +488,8 @@ async function tryXaiImageRelay( "upstream_error", `xAI image ${endpoint} failed: ${message}`, ); + } finally { + linkedSignal.cleanup(); } } diff --git a/tests/server-images.test.ts b/tests/server-images.test.ts index 26d97db25b..174d5344f5 100644 --- a/tests/server-images.test.ts +++ b/tests/server-images.test.ts @@ -195,6 +195,45 @@ test("POST /v1/images/generations relays to xAI Imagine when the image bridge is } }); +test("POST /v1/images/edits with no image URL does not call xAI generation", async () => { + const captured: CapturedRequest[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("api.x.ai")) { + captured.push({ + path: new URL(url).pathname, + headers: new Headers(init?.headers), + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }); + return Response.json({ data: [{ b64_json: "dHJhaW4=" }] }); + } + return originalFetch(input, init); + }) as typeof fetch; + saveConfig({ + ...forwardConfig(), + images: { bridgeEnabled: true }, + providers: { + ...forwardConfig().providers, + xai: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", apiKey: "xai-test-token" }, + }, + } as OcxConfig); + + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/images/edits", server.url), { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${DIRECT_CHATGPT_TOKEN}` }, + body: JSON.stringify({ prompt: "make it blue", model: "gpt-image-2" }), + }); + expect(response.status).toBe(400); + const json = await response.json() as { error?: { message?: string } }; + expect(json.error?.message).toContain("image edits require an image URL"); + expect(captured).toHaveLength(0); + } finally { + await server.stop(true); + } +}); + test("POST /v1/images/generations relays to the ChatGPT forward provider with forwarded auth", async () => { const captured: CapturedRequest[] = []; const upstream = fakeImagesUpstream(captured); From 63944a1ca1bd81fe4f16dc315033fcf393d160a3 Mon Sep 17 00:00:00 2001 From: User Date: Sat, 15 Aug 2026 00:09:10 -0600 Subject: [PATCH 024/102] feat(cursor): native SelectedImage vision for verified models (data: only) --- .../docs/reference/configuration/providers.md | 7 + src/adapters/cursor/discovery.ts | 20 + src/adapters/cursor/images.ts | 685 ++++++++++++++++++ src/adapters/cursor/live-transport.ts | 26 +- src/adapters/cursor/protobuf-request.ts | 16 +- src/adapters/cursor/request-builder.ts | 43 +- src/adapters/cursor/types.ts | 9 +- src/providers/registry.ts | 10 +- .../catalog-vision-sidecar-modalities.test.ts | 21 + tests/cursor-blob.test.ts | 184 +++++ tests/cursor-discovery.test.ts | 17 + tests/cursor-images.test.ts | 574 +++++++++++++++ tests/cursor-request-builder.test.ts | 61 +- tests/cursor-static-catalog.test.ts | 7 + tests/cursor-vision-wire-harness.test.ts | 103 +++ tests/helpers/cursor-grumpy-fixture.png | Bin 0 -> 440734 bytes tests/oauth-provider-reconcile.test.ts | 29 + tests/provider-registry-parity.test.ts | 5 + 18 files changed, 1782 insertions(+), 35 deletions(-) create mode 100644 src/adapters/cursor/images.ts create mode 100644 tests/cursor-images.test.ts create mode 100644 tests/cursor-vision-wire-harness.test.ts create mode 100644 tests/helpers/cursor-grumpy-fixture.png diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index d467b5cb5f..5a01d07fcb 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -315,6 +315,13 @@ Cursor-specific model parameters: Explicit variants send Cursor's `default` model with its `optimization` parameter, preserving the selection on every request. They remain available when live discovery omits `default`. +### Vision + +Native Cursor vision uses `SelectedImage` (JPEG soft-cap + `blobIdWithData`) for models that can +see images natively — Claude, Gemini, GPT, Kimi, and Grok among them — using active-turn `data:` +images only. Auto, the Composer family, and GLM (`glm-5.2`) stay on the curated `noVisionModels` +list and use the vision describe sidecar instead. + Cursor server-driven local tools are disabled by default. Codex continues using its own tools such as `apply_patch` and `exec_command` with its own approval and sandbox policy: diff --git a/src/adapters/cursor/discovery.ts b/src/adapters/cursor/discovery.ts index 888f1c1f79..05d42ccc64 100644 --- a/src/adapters/cursor/discovery.ts +++ b/src/adapters/cursor/discovery.ts @@ -103,6 +103,26 @@ export const CURSOR_ROUTER_MODEL_IDS = [ ...CURSOR_ROUTING_LEVELS.map(level => `${CURSOR_AUTO_MODEL_ID}-${level}`), ] as const; +/** + * Cursor models that cannot see images natively. OpenCodex routes them through the vision + * sidecar (the catalog still advertises image so Codex can attach). Evidence: + * - Composer family: Cursor staff — text-only; "Model does not support images" + * - Auto / router modes: Cursor docs omit Images for Auto Cost; staff — pick Claude/GPT for images + * - glm-5.2: Cursor docs omit Images; Z.ai GLM-5.2 is text-only (vision is GLM-5V) + * + * Composer ids are enumerated explicitly — prefix wildcard matching is deliberately out of + * scope here; a live-discovered new Composer slug stays native-path until curated. Everyone + * else in the static seed (Claude, Gemini, GPT, Kimi, Grok) takes SelectedImage. Other + * live-discovered ids stay unclassified (native path) until curated. + */ +export const CURSOR_NO_VISION_MODELS = [ + ...CURSOR_ROUTER_MODEL_IDS, + "composer-1", + "composer-2.5", + "composer-2.5-fast", + "glm-5.2", +] as const; + /** Wire id Cursor Connect expects for the auto-router (GetUsableModels returns `default`, not `auto`). */ export const CURSOR_AUTO_WIRE_MODEL_ID = "default"; diff --git a/src/adapters/cursor/images.ts b/src/adapters/cursor/images.ts new file mode 100644 index 0000000000..9997d0c67c --- /dev/null +++ b/src/adapters/cursor/images.ts @@ -0,0 +1,685 @@ +import { randomUUID } from "node:crypto"; +import { create } from "@bufbuild/protobuf"; +import type { OcxContentPart, OcxImageContent, OcxMessage } from "../../types"; +import { + SelectedContextSchema, + SelectedImageSchema, + SelectedImage_BlobIdWithDataSchema, + SelectedImage_DimensionSchema, + type SelectedContext, + type SelectedImage, +} from "./gen/agent_pb"; +import { + storeCursorBlob, + type CursorBlobRequestScopeToken, +} from "./native-exec"; + +/** Final per-image byte cap after prep (OmniRoute / composer-api style). */ +export const MAX_CURSOR_IMAGE_BYTES = 1024 * 1024; + +/** + * Inbound decode/fetch bomb ceiling before JPEG prep. Large clipboard PNGs may exceed + * {@link MAX_CURSOR_IMAGE_BYTES} raw but shrink under the wire cap after re-encode. + */ +export const MAX_CURSOR_IMAGE_DECODE_BYTES = 16 * 1024 * 1024; + +/** + * Soft target for Cursor vision hydration. Live A/B: ~430 KiB PNG failed ("gray"/wrong UI) + * while the same visual as ~75 KiB JPEG succeeded. Prefer JPEG at or under this size. + */ +export const CURSOR_VISION_SOFT_MAX_BYTES = 100 * 1024; + +/** Soft target when the client requests `detail: original` or `high`. */ +export const CURSOR_VISION_SOFT_MAX_BYTES_HIGH = 256 * 1024; + +/** Longest edge after Cursor vision prep (Cursor staff guidance: ≤ 2000 px). */ +export const CURSOR_VISION_MAX_EDGE = 2000; + +/** + * Decode bomb: reject images whose sniffed longest edge exceeds this before Bun.Image. + * Separate from {@link CURSOR_VISION_MAX_EDGE} (output resize target). + */ +export const MAX_CURSOR_IMAGE_DECODE_EDGE = 8192; + +/** Decode bomb: reject images whose sniffed pixel count exceeds this before Bun.Image. */ +export const MAX_CURSOR_IMAGE_PIXELS = 25_000_000; + +const CURSOR_VISION_JPEG_QUALITIES_DEFAULT = [85, 70, 55, 40] as const; +const CURSOR_VISION_JPEG_QUALITIES_HIGH = [90, 80, 65, 50] as const; +/** Stop shrinking below this longest edge when chasing the soft byte cap. */ +const CURSOR_VISION_SOFT_MIN_EDGE = 256; +const CURSOR_VISION_SOFT_SHRINK = 0.85; + +const CURSOR_VISION_PASSTHROUGH_MIME = new Set([ + "image/jpeg", + "image/jpg", + "image/png", + "image/gif", + "image/webp", +]); + +/** Upper bound on images attached to one Cursor turn. */ +export const MAX_CURSOR_IMAGES = 12; + +/** Marker when an image cannot be prepared for the Cursor vision wire. */ +export const CURSOR_VISION_IMAGE_OMITTED = + "[image omitted: undecodable or unsupported type]"; + +export class CursorImageError extends Error { + readonly status: number; + + constructor(message: string, status = 400) { + super(message); + this.name = "CursorImageError"; + this.status = status; + } +} + +export interface ResolvedCursorImage { + data: Uint8Array; + mimeType: string; + uuid: string; + /** Codex/OpenAI image detail hint; affects JPEG soft-cap tier. */ + detail?: string; +} + +export type PrepareCursorImageOutcome = + | { status: "ready"; image: ResolvedCursorImage } + | { status: "omitted"; reason: string }; + +function isImagePart(part: OcxContentPart): part is OcxImageContent { + return part.type === "image"; +} + +function estimatedBase64DecodedBytes(payload: string): number { + return Math.floor((payload.length * 3) / 4); +} + +function isHighDetail(detail: string | undefined): boolean { + const normalized = (detail ?? "").trim().toLowerCase(); + return normalized === "original" || normalized === "high"; +} + +function softMaxBytesForDetail(detail: string | undefined): number { + return isHighDetail(detail) ? CURSOR_VISION_SOFT_MAX_BYTES_HIGH : CURSOR_VISION_SOFT_MAX_BYTES; +} + +function jpegQualitiesForDetail(detail: string | undefined): readonly number[] { + return isHighDetail(detail) ? CURSOR_VISION_JPEG_QUALITIES_HIGH : CURSOR_VISION_JPEG_QUALITIES_DEFAULT; +} + +export function decodeCursorImageDataUrl(url: string): { data: Uint8Array; mimeType: string } { + const comma = url.indexOf(","); + if (comma < 0) throw new CursorImageError("Image data URL is malformed."); + const header = url.slice(5, comma); + const payload = url.slice(comma + 1); + const isBase64 = /;base64/i.test(header); + const mimeType = (header.split(";")[0] || "").trim().toLowerCase() || "application/octet-stream"; + + if (!mimeType.startsWith("image/")) { + throw new CursorImageError("Image data URL must have an image/* media type."); + } + if (!isBase64) { + throw new CursorImageError("Image data URL must be base64-encoded."); + } + if (payload.length > MAX_CURSOR_IMAGE_DECODE_BYTES * 2) { + throw new CursorImageError("Image input is too large to process safely."); + } + + const normalized = payload.replace(/\s/g, ""); + if (normalized.length === 0) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + // Reject lenient Buffer.from acceptances (wrong alphabet, bad padding, truncated groups). + if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(normalized)) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (estimatedBase64DecodedBytes(normalized) > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); + } + + let data: Uint8Array; + try { + data = Buffer.from(normalized, "base64"); + } catch { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (data.byteLength === 0) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + // Round-trip guard: Node/Bun can silently drop trailing garbage. + if (Buffer.from(data).toString("base64").replace(/=+$/, "") !== normalized.replace(/=+$/, "")) { + throw new CursorImageError("Image data URL contains invalid base64 data."); + } + if (data.byteLength > MAX_CURSOR_IMAGE_DECODE_BYTES) { + throw new CursorImageError("Image input is too large to process safely."); + } + return { data, mimeType }; +} + +function throwIfImagePhaseAborted(signal?: AbortSignal): void { + if (!signal?.aborted) return; + if (signal.reason instanceof Error) throw signal.reason; + const err = new Error("Cursor image phase aborted"); + err.name = "AbortError"; + throw err; +} + +/** Magic-byte format sniff (independent of declared MIME). */ +export function sniffCursorImageFormat( + data: Uint8Array, +): "png" | "jpeg" | "gif" | "webp" | undefined { + if ( + data.byteLength >= 8 + && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 + && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a + ) { + return "png"; + } + if ( + data.byteLength >= 6 + && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38 + ) { + return "gif"; + } + if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) return "jpeg"; + if ( + data.byteLength >= 12 + && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 + && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50 + ) { + return "webp"; + } + return undefined; +} + +/** Collect image URLs from one message's content parts, preserving order. */ +export function extractCursorImageUrls(content: string | readonly OcxContentPart[]): string[] { + return extractCursorImageParts(content).map(part => part.imageUrl); +} + +export interface CursorImagePartRef { + imageUrl: string; + detail?: string; +} + +/** Collect image parts (URL + optional detail) from one message's content. */ +export function extractCursorImageParts( + content: string | readonly OcxContentPart[], +): CursorImagePartRef[] { + if (typeof content === "string" || !Array.isArray(content)) return []; + const parts: CursorImagePartRef[] = []; + for (const part of content) { + if (isImagePart(part) && typeof part.imageUrl === "string" && part.imageUrl.length > 0) { + parts.push({ + imageUrl: part.imageUrl, + ...(typeof part.detail === "string" && part.detail.length > 0 ? { detail: part.detail } : {}), + }); + } + } + return parts; +} + +/** + * Resolve OpenCodex image parts (data: URLs only) into bytes for SelectedImage. + * Prep (JPEG soft-cap) runs before the 1 MiB wire cap so large clipboard PNGs can shrink. + * Unsupported / undecodable images are omitted (fail-closed). + */ +export async function resolveCursorImages( + imageUrls: readonly string[], + signal?: AbortSignal, + options?: { details?: readonly (string | undefined)[] }, +): Promise { + if (imageUrls.length > MAX_CURSOR_IMAGES) { + throw new CursorImageError(`Too many images in one request (max ${MAX_CURSOR_IMAGES}).`); + } + + const out: ResolvedCursorImage[] = []; + for (let i = 0; i < imageUrls.length; i++) { + throwIfImagePhaseAborted(signal); + const url = imageUrls[i]; + if (typeof url !== "string" || url.length === 0) { + // Soft-omit missing URLs rather than aborting a mixed turn. + continue; + } + // Remote URL fetching is deliberately out of scope here; https:// images are omitted. + if (!url.toLowerCase().startsWith("data:")) continue; + try { + const resolved = decodeCursorImageDataUrl(url); + if (resolved.data.byteLength === 0) continue; + const outcome = await prepareCursorImageForWire({ + data: resolved.data, + mimeType: resolved.mimeType, + uuid: randomUUID(), + ...(options?.details?.[i] ? { detail: options.details[i] } : {}), + }, signal); + if (outcome.status === "omitted") continue; + if (outcome.image.data.byteLength > MAX_CURSOR_IMAGE_BYTES) continue; + out.push(outcome.image); + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + if (err instanceof CursorImageError) continue; + continue; + } + } + return out; +} + +export async function resolveCursorImageParts( + parts: readonly CursorImagePartRef[], + signal?: AbortSignal, +): Promise { + return resolveCursorImages( + parts.map(part => part.imageUrl), + signal, + { details: parts.map(part => part.detail) }, + ); +} + +/** Filename Cursor clients typically put on SelectedImage.path (shunt / agent parity). */ +export function cursorImageAttachmentPath(uuid: string, mimeType: string): string { + const normalized = mimeType.toLowerCase(); + const ext = normalized === "image/jpeg" || normalized === "image/jpg" ? "jpg" + : normalized === "image/gif" ? "gif" + : normalized === "image/webp" ? "webp" + : "png"; + return `attachment-${uuid}.${ext}`; +} + +/** + * Re-encode toward a JPEG under the soft vision cap when Bun can decode the payload. + * Unsupported MIME, oversize dimensions/pixels, or undecodable bytes are omitted (fail-closed). + * After the quality ladder, edges shrink iteratively until the soft byte cap is met + * (or the min edge floor is hit) so large clipboard PNGs do not leave >softMax JPEGs + * that Cursor vision hallucinates on. + */ +export async function prepareCursorImageForWire( + image: ResolvedCursorImage, + signal?: AbortSignal, +): Promise { + throwIfImagePhaseAborted(signal); + const mime = image.mimeType.toLowerCase(); + const softMax = softMaxBytesForDetail(image.detail); + const qualities = jpegQualitiesForDetail(image.detail); + const lowestQuality = qualities[qualities.length - 1]!; + + if (!CURSOR_VISION_PASSTHROUGH_MIME.has(mime)) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + + const format = sniffCursorImageFormat(image.data); + // Peek headers before Bun.Image so huge compressed bombs fail closed cheaply. + const sniffed = sniffCursorImageDimensions(image.data); + if (sniffed) { + const edge = Math.max(sniffed.width, sniffed.height); + const pixels = sniffed.width * sniffed.height; + if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || pixels > MAX_CURSOR_IMAGE_PIXELS) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + } + + // Second-pass skip: already soft-capped JPEG that has a real SOF (not SOI-only junk). + const declaredJpeg = mime === "image/jpeg" || mime === "image/jpg"; + const alreadySmallJpeg = declaredJpeg + && format === "jpeg" + && sniffed !== undefined + && image.data.byteLength <= softMax; + if (alreadySmallJpeg) { + return { status: "ready", image }; + } + + try { + throwIfImagePhaseAborted(signal); + // Force a full decode before accepting passthrough / encode (Anthropic-style validate). + await new Bun.Image(image.data).resize(1, 1).jpeg({ quality: 1 }).toBuffer(); + + // Passthrough only when declared MIME matches actual JPEG magic (never PNG-as-JPEG). + if (declaredJpeg && format === "jpeg" && image.data.byteLength <= softMax) { + return { status: "ready", image }; + } + + throwIfImagePhaseAborted(signal); + const meta = await new Bun.Image(image.data).metadata(); + const width = typeof meta.width === "number" ? meta.width : 0; + const height = typeof meta.height === "number" ? meta.height : 0; + if (width > 0 && height > 0) { + const edge = Math.max(width, height); + if (edge > MAX_CURSOR_IMAGE_DECODE_EDGE || width * height > MAX_CURSOR_IMAGE_PIXELS) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + } + let targetW = width; + let targetH = height; + if (width > 0 && height > 0 && Math.max(width, height) > CURSOR_VISION_MAX_EDGE) { + const scale = CURSOR_VISION_MAX_EDGE / Math.max(width, height); + targetW = Math.max(1, Math.round(width * scale)); + targetH = Math.max(1, Math.round(height * scale)); + } + + const encodeAt = async (w: number, h: number, quality: number): Promise => { + throwIfImagePhaseAborted(signal); + let pipeline = new Bun.Image(image.data); + if (w > 0 && h > 0 && (w !== width || h !== height)) { + pipeline = pipeline.resize(w, h); + } + return new Uint8Array(await pipeline.jpeg({ quality }).bytes()); + }; + + let best: Uint8Array | undefined; + for (const quality of qualities) { + const encoded = await encodeAt(targetW, targetH, quality); + if (!best || encoded.byteLength < best.byteLength) best = encoded; + if (encoded.byteLength <= softMax) { + return { + status: "ready", + image: { ...image, data: encoded, mimeType: "image/jpeg" }, + }; + } + } + + // Quality ladder missed the soft cap — shrink edges until it fits or we hit the floor. + while ( + best + && best.byteLength > softMax + && targetW > 0 + && targetH > 0 + && Math.max(targetW, targetH) > CURSOR_VISION_SOFT_MIN_EDGE + ) { + throwIfImagePhaseAborted(signal); + const nextW = Math.max(1, Math.round(targetW * CURSOR_VISION_SOFT_SHRINK)); + const nextH = Math.max(1, Math.round(targetH * CURSOR_VISION_SOFT_SHRINK)); + if (Math.max(nextW, nextH) < CURSOR_VISION_SOFT_MIN_EDGE) { + const scale = CURSOR_VISION_SOFT_MIN_EDGE / Math.max(targetW, targetH); + targetW = Math.max(1, Math.round(targetW * scale)); + targetH = Math.max(1, Math.round(targetH * scale)); + } else { + targetW = nextW; + targetH = nextH; + } + const encoded = await encodeAt(targetW, targetH, lowestQuality); + if (!best || encoded.byteLength < best.byteLength) best = encoded; + if (encoded.byteLength <= softMax) { + return { + status: "ready", + image: { ...image, data: encoded, mimeType: "image/jpeg" }, + }; + } + if (Math.max(targetW, targetH) <= CURSOR_VISION_SOFT_MIN_EDGE) break; + } + + if (best) { + return { + status: "ready", + image: { ...image, data: best, mimeType: "image/jpeg" }, + }; + } + // Undeclared/mismatched magic with no encode result — omit rather than lie about MIME. + if (declaredJpeg && format !== "jpeg") { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + return { status: "ready", image }; + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } +} + +/** + * Sniff PNG/JPEG/GIF/WebP dimensions from raw bytes when the header is present. + * Best-effort only — unknown formats return undefined (dimension is optional). + */ +export function sniffCursorImageDimensions( + data: Uint8Array, +): { width: number; height: number } | undefined { + // PNG: signature + IHDR chunk (width/height at bytes 16..23) + if ( + data.byteLength >= 24 + && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47 + && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a + ) { + const width = ((data[16]! << 24) | (data[17]! << 16) | (data[18]! << 8) | data[19]!) >>> 0; + const height = ((data[20]! << 24) | (data[21]! << 16) | (data[22]! << 8) | data[23]!) >>> 0; + if (width > 0 && height > 0) return { width, height }; + } + // GIF: "GIF8" + width/height as little-endian u16 at bytes 6..9 + if ( + data.byteLength >= 10 + && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x38 + ) { + const width = data[6]! | (data[7]! << 8); + const height = data[8]! | (data[9]! << 8); + if (width > 0 && height > 0) return { width, height }; + } + // WebP: RIFF....WEBP + VP8X / VP8 / VP8L (same layout as anthropic-image-guard). + if ( + data.byteLength >= 30 + && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46 + && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50 + ) { + const fourcc = String.fromCharCode(data[12]!, data[13]!, data[14]!, data[15]!); + if (fourcc === "VP8X") { + const width = 1 + (data[24]! | (data[25]! << 8) | (data[26]! << 16)); + const height = 1 + (data[27]! | (data[28]! << 8) | (data[29]! << 16)); + if (width > 0 && height > 0) return { width, height }; + } else if (fourcc === "VP8 ") { + if (data[23] === 0x9d && data[24] === 0x01 && data[25] === 0x2a) { + const width = (data[26]! | (data[27]! << 8)) & 0x3fff; + const height = (data[28]! | (data[29]! << 8)) & 0x3fff; + if (width > 0 && height > 0) return { width, height }; + } + } else if (fourcc === "VP8L" && data[20] === 0x2f) { + const raw = data[21]! | (data[22]! << 8) | (data[23]! << 16) | (data[24]! << 24); + const width = (raw & 0x3fff) + 1; + const height = ((raw >> 14) & 0x3fff) + 1; + if (width > 0 && height > 0) return { width, height }; + } + } + // JPEG: scan for SOF0/SOF2 marker with dimensions + if (data.byteLength >= 4 && data[0] === 0xff && data[1] === 0xd8) { + let offset = 2; + while (offset + 8 < data.byteLength) { + if (data[offset] !== 0xff) break; + const marker = data[offset + 1]!; + // Standalone markers (TEM, RSTn, SOI, EOI) carry no length payload. + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd9)) { + offset += 2; + continue; + } + const length = (data[offset + 2]! << 8) | data[offset + 3]!; + if (marker === 0xc0 || marker === 0xc2) { + const height = (data[offset + 5]! << 8) | data[offset + 6]!; + const width = (data[offset + 7]! << 8) | data[offset + 8]!; + if (width > 0 && height > 0) return { width, height }; + break; + } + if (length < 2) break; + offset += 2 + length; + } + } + return undefined; +} + +/** + * Build SelectedImage messages for the AgentService vision path: + * store bytes in the local KV map under sha256(blobId), and encode + * `blobIdWithData` so the server can populate its cache without relying solely + * on getBlobArgs timing. Also set `path` like native/shunt clients. + */ +export function buildSelectedImages( + images: readonly ResolvedCursorImage[], + requestScope?: CursorBlobRequestScopeToken, +): SelectedImage[] { + return images.map(image => { + const blobId = storeCursorBlob(image.data, requestScope); + const dims = sniffCursorImageDimensions(image.data); + return create(SelectedImageSchema, { + uuid: image.uuid, + path: cursorImageAttachmentPath(image.uuid, image.mimeType), + mimeType: image.mimeType, + ...(dims + ? { dimension: create(SelectedImage_DimensionSchema, dims) } + : {}), + dataOrBlobId: { + case: "blobIdWithData", + value: create(SelectedImage_BlobIdWithDataSchema, { + blobId, + data: image.data, + }), + }, + }); + }); +} + +/** + * Always send `UserMessage.selected_context`, even when empty — matches cursor-agent. + * When images are present, they are blobIdWithData refs backed by the request-scoped KV store. + */ +export function buildSelectedContext( + images: readonly ResolvedCursorImage[] = [], + requestScope?: CursorBlobRequestScopeToken, +): SelectedContext { + return create(SelectedContextSchema, { + selectedImages: buildSelectedImages(images, requestScope), + }); +} + +/** + * Resolve data: images for the active user/developer turn onto SelectedImage. + * Tool-result image promotion is intentionally out of scope in this slice. + */ +export async function resolveActiveCursorImages( + messages: readonly OcxMessage[] | undefined, + signal?: AbortSignal, +): Promise { + if (!messages?.length) return []; + // Tool-result continuations are out of scope in this slice: no trailing toolResult promotion. + if (messages.at(-1)?.role === "toolResult") return []; + + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (!message) continue; + if (message.role === "user" || message.role === "developer") { + return resolveCursorImageParts(extractCursorImageParts(message.content), signal); + } + } + return []; +} + +function imageDataUrlFromPrepared(image: ResolvedCursorImage): string { + return `data:${image.mimeType};base64,${Buffer.from(image.data).toString("base64")}`; +} + +/** + * Re-encode a single image URL through {@link prepareCursorImageForWire}. + * data: URLs only. Omitted images become text (caller replaces the part). + */ +export async function prepareCursorImageDataUrl( + imageUrl: string, + detail?: string, + signal?: AbortSignal, +): Promise<{ status: "ready"; imageUrl: string } | { status: "omitted"; reason: string }> { + try { + const resolved = imageUrl.toLowerCase().startsWith("data:") + ? decodeCursorImageDataUrl(imageUrl) + : null; + if (!resolved) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + if (resolved.data.byteLength === 0) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + const outcome = await prepareCursorImageForWire({ + data: resolved.data, + mimeType: resolved.mimeType, + uuid: randomUUID(), + ...(detail ? { detail } : {}), + }, signal); + if (outcome.status === "omitted") return outcome; + if (outcome.image.data.byteLength > MAX_CURSOR_IMAGE_BYTES) { + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } + if ( + imageUrl.toLowerCase().startsWith("data:") + && outcome.image.data === resolved.data + && outcome.image.mimeType === resolved.mimeType + ) { + return { status: "ready", imageUrl }; + } + return { status: "ready", imageUrl: imageDataUrlFromPrepared(outcome.image) }; + } catch (err) { + if (signal?.aborted || (err instanceof Error && err.name === "AbortError")) throw err; + return { status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }; + } +} + +async function prepareCursorContentParts( + content: string | readonly OcxContentPart[], + signal?: AbortSignal, +): Promise { + if (typeof content === "string" || !Array.isArray(content)) return content; + let changed = false; + const next: OcxContentPart[] = []; + for (const part of content) { + if (part.type === "image" && typeof part.imageUrl === "string" && part.imageUrl.length > 0) { + throwIfImagePhaseAborted(signal); + const prepared = await prepareCursorImageDataUrl(part.imageUrl, part.detail, signal); + if (prepared.status === "omitted") { + changed = true; + next.push({ type: "text", text: prepared.reason }); + continue; + } + if (prepared.imageUrl !== part.imageUrl) changed = true; + next.push({ ...part, imageUrl: prepared.imageUrl }); + } else { + next.push(part); + } + } + return changed ? next : content; +} + +/** + * First original-message index that still needs image prep for the active vision window. + * Historical messages before this index are left untouched (no decode). + */ +export function cursorVisionPrepareStartIndex(messages: readonly OcxMessage[]): number { + // Tool-result image preparation is out of scope in this slice. + if (messages.at(-1)?.role === "toolResult") return messages.length; + for (let i = messages.length - 1; i >= 0; i--) { + const role = messages[i]?.role; + if (role === "user" || role === "developer") return i; + } + return messages.length; +} + +/** + * Rewrite image data URLs in the active vision window (last user/developer turn) through + * the JPEG soft-cap path before protobuf encode. Historical messages are left by + * reference. Undecodable images become {@link CURSOR_VISION_IMAGE_OMITTED} text so + * image-only turns stay userMessageAction. + */ +export async function prepareCursorRawMessages( + messages: readonly OcxMessage[] | undefined, + signal?: AbortSignal, +): Promise { + if (!messages?.length) return messages; + const prepareFrom = cursorVisionPrepareStartIndex(messages); + let changed = false; + const out: OcxMessage[] = []; + for (let i = 0; i < messages.length; i++) { + throwIfImagePhaseAborted(signal); + const message = messages[i]!; + if ( + i >= prepareFrom + && (message.role === "user" || message.role === "developer") + ) { + const content = await prepareCursorContentParts(message.content, signal); + if (content !== message.content) { + changed = true; + out.push({ ...message, content } as OcxMessage); + continue; + } + } + out.push(message); + } + return changed ? out : messages; +} diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index 3f8ce957a9..4dcca22557 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -13,6 +13,8 @@ import { type TranslatorBudget, } from "../../lib/translator-budget"; import { activePromptText, prepareCursorRunRequest } from "./protobuf-request"; +import { prepareCursorRawMessages, resolveActiveCursorImages } from "./images"; +import { cursorRequestMessagesFromRaw } from "./request-builder"; import { createCursorContextUsageTracker, createCursorProtobufEventState, @@ -567,10 +569,24 @@ class LiveCursorTransport implements CursorTransport { // Advertise MCP tools before the stream opens — the server only calls tools it was told about. await this.prepareMcp(); - const activeText = activePromptText(request); - this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(request, this.clientToolFinalizeGraceMs); - const cursorVisibleTools = cursorToolsForActivePrompt(request.tools, activeText, request.toolChoice); - const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, request.toolChoice); + // JPEG soft-cap rewrite for active-turn data: images before encode. Rebuild text + // messages from the prepared raw channel so omission markers replace stale + // pre-rewrite content that activePromptText and the tool filter would otherwise see. + const preparedRawMessages = await prepareCursorRawMessages(request.rawMessages, signal); + const selectedImages = await resolveActiveCursorImages(preparedRawMessages, signal); + const preparedMessages = preparedRawMessages === request.rawMessages + ? request.messages + : cursorRequestMessagesFromRaw(preparedRawMessages); + const activeRequest: CursorRunRequest = { + ...request, + messages: preparedMessages, + rawMessages: preparedRawMessages, + selectedImages, + }; + const activeText = activePromptText(activeRequest); + this.activeClientToolFinalizeGraceMs = clientToolFinalizeGraceMsForRequest(activeRequest, this.clientToolFinalizeGraceMs); + const cursorVisibleTools = cursorToolsForActivePrompt(activeRequest.tools, activeText, activeRequest.toolChoice); + const clientToolDefs = buildCursorToolDefinitions(cursorVisibleTools, activeRequest.toolChoice); // `request.tools` is the catalog already filtered and budgeted by request-builder. Derive // conversion provenance only from tagged synthetic tools that also survive this final prompt // filter; a client tool with the same wire name can never opt into conversion by collision. @@ -606,7 +622,7 @@ class LiveCursorTransport implements CursorTransport { }); // Build the payload once. The estimate is only worth deriving when there is no // carry-forward to fall back on — with a carry present it would never be used (#373). - const prepared = prepareCursorRunRequest(request, { + const prepared = prepareCursorRunRequest(activeRequest, { estimateInputTokens: contextUsage.carryForwardTokens === undefined, }); this.blobRequestScope = prepared.blobRequestScope; diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index e03dc4e71f..fcfe7e21c2 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -15,6 +15,7 @@ import { storeCursorBlob, type CursorBlobRequestScopeToken, } from "./native-exec"; +import { buildSelectedContext } from "./images"; import { estimateTokens } from "../../lib/token-estimate"; import { parseDataUrl } from "../image"; import { @@ -335,7 +336,7 @@ function contentText(message: OcxMessage): string { .map(part => { if (part.type === "text") return part.text; if (part.type === "thinking") return part.thinking; - if (part.type === "image") return `[image produced by this tool, omitted from Cursor text replay: ${part.detail ?? "auto"}]`; + if (part.type === "image") return undefined; return undefined; }) .filter((value): value is string => typeof value === "string" && value.length > 0) @@ -722,6 +723,8 @@ function conversationTurns( userMessage: storeCursorBlob(toBinary(UserMessageSchema, create(UserMessageSchema, { text: contentText(message), messageId: crypto.randomUUID(), + selectedContext: buildSelectedContext([], requestScope), + mode: 1, })), requestScope), steps: [], }; @@ -791,16 +794,22 @@ function buildPreparedCursorRunRequest( ? appendCursorGenericToolUseHint(request.tools, rawText) : rawText; const lastRawIsToolResult = request.rawMessages?.at(-1)?.role === "toolResult"; + const selectedImages = request.selectedImages ?? []; // Native models resume the remembered Cursor conversation. External wire // models continue as userMessageAction so history-blob tool results stay // visible without a ResumeAction. const externalToolContinuation = lastRawIsToolResult && isCursorExternalWireModel(request.modelId); - const actionCase = (externalToolContinuation || (!lastRawIsToolResult && text.trim().length > 0)) + // Image-only active turns (including soft-omitted images) stay userMessageAction. + const actionCase = ( + externalToolContinuation + || (!lastRawIsToolResult && (text.trim().length > 0 || selectedImages.length > 0)) + ) ? "userMessageAction" : "resumeAction"; const actionText = externalToolContinuation ? CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT : text; + const selectedContext = buildSelectedContext(selectedImages, requestScope); const action = create(ConversationActionSchema, { action: actionCase === "userMessageAction" ? { @@ -809,6 +818,9 @@ function buildPreparedCursorRunRequest( userMessage: create(UserMessageSchema, { text: actionText, messageId: crypto.randomUUID(), + selectedContext, + // OmniRoute / cursor-agent always send mode=1 on UserMessage. + mode: 1, }), requestContext: buildRequestContext(), }), diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index da003f85a0..80860bdd37 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -25,6 +25,7 @@ import { isCursorWaitTool, } from "./tool-definitions"; import { lookupCursorThreadConversation } from "./thread-continuity"; +import { extractCursorImageUrls } from "./images"; /** Probe-verified Cursor Connect boundaries, with byte headroom for the enclosing field. */ export const CURSOR_TOOL_COUNT_LIMIT = 330; @@ -205,15 +206,8 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri case "thinking": return part.thinking; case "image": - // User-message images are still flattened here: this path builds the plain-text prompt, and - // the schema slot that could carry them (UserMessage.selectedContext.selectedImages) is not - // populated by this adapter. The tool-result ENCODER does build real McpImageContent - // (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no - // longer true of the encoder — but note that nothing reaches Cursor today either way: - // every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar - // describes or strips images before this adapter runs. Kept the same length to avoid - // shifting any byte-budgeted prompt path. - return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`; + // Images ride UserMessage.selected_context (SelectedImage) instead of text. + return undefined; case "toolCall": // Cursor does not accept OpenAI Responses assistant tool-call parts as native history here. // Rendering them as visible "[tool_call]" text leaks synthetic protocol markers back into @@ -246,9 +240,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined { switch (message.role) { case "user": case "developer": - return { role: message.role, content: contentToText(message.content) }; + { + const content = contentToText(message.content); + // Image-only turns survive as empty content; the encoder keeps them userMessageAction. + if (content.length === 0 && extractCursorImageUrls(message.content).length === 0) { + return undefined; + } + return { role: message.role, content }; + } case "assistant": - return { role: "assistant", content: contentToText(message.content) }; + { + const content = contentToText(message.content); + return content.length > 0 ? { role: "assistant", content } : undefined; + } case "toolResult": return { role: "tool", @@ -257,6 +261,19 @@ function requestMessage(message: OcxMessage): CursorRequestMessage | undefined { } } +/** + * Rebuild the text `messages` channel from prepared `rawMessages` so omission markers + * and JPEG-rewritten parts stay visible to activePromptText after image preparation. + */ +export function cursorRequestMessagesFromRaw( + messages: readonly OcxMessage[] | undefined, +): CursorRequestMessage[] { + if (!messages?.length) return []; + return messages + .map(requestMessage) + .filter((message): message is CursorRequestMessage => !!message); +} + export function generatedCursorConversationId(): string { return `cursor_${crypto.randomUUID().replace(/-/g, "")}`; } @@ -307,9 +324,7 @@ export function createCursorRequest( parsed: OcxParsedRequest, options: CreateCursorRequestOptions = {}, ): CursorRunRequest { - const messages = parsed.context.messages - .map(requestMessage) - .filter((message): message is CursorRequestMessage => !!message && message.content.length > 0); + const messages = cursorRequestMessagesFromRaw(parsed.context.messages); const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? ""; const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice); const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); diff --git a/src/adapters/cursor/types.ts b/src/adapters/cursor/types.ts index b32026a07d..ece91f4fee 100644 --- a/src/adapters/cursor/types.ts +++ b/src/adapters/cursor/types.ts @@ -1,6 +1,7 @@ import type { OcxUsage } from "../../types"; import type { OcxMessage, OcxRequestOptions, OcxTool } from "../../types"; import type { CursorRoutingLevel } from "./discovery"; +import type { ResolvedCursorImage } from "./images"; export interface CursorRequestedModelParameter { id: string; @@ -16,7 +17,13 @@ export interface CursorRunRequest { conversationId: string; system: string[]; messages: CursorRequestMessage[]; - rawMessages?: OcxMessage[]; + rawMessages?: readonly OcxMessage[]; + /** + * Images for the active user/developer turn. Encoded as SelectedImage blobIdWithData refs under + * UserMessage.selected_context (bytes live in the request-scoped KV store for getBlobArgs + * hydration). History stays text-only. data: URLs only in this slice. + */ + selectedImages?: readonly ResolvedCursorImage[]; tools?: OcxTool[]; toolChoice?: OcxRequestOptions["toolChoice"]; parallelToolCalls?: boolean; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index c81735ba15..a8b5ccbafa 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -10,6 +10,7 @@ import { MOONSHOT_BASE_URL_CHOICES, MOONSHOT_INTL_BASE_URL, } from "./base-url-choices"; import { + CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS, cursorModelContextWindows, cursorModelIds, @@ -978,11 +979,10 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // no-effort fallback to `kimi-k3-max` would never be reached. Mirrors the other K3 // routes (kimi, kimi-code, opencode-go). modelDefaultReasoningEfforts: { "kimi-k3": "max" }, - // Cursor's wire protocol never forwards image parts (request-builder emits an unsupported- - // content marker), so the vision sidecar covers ALL cursor models regardless of what the - // upstream model could natively do. Live-discovered models outside the static list fall back - // to the same marker until they appear here. - noVisionModels: cursorModelIds(CURSOR_STATIC_MODELS), + // Blind Cursor models (Auto routers, Composer, GLM-5.2) go through the vision sidecar; + // multimodal hosts (Claude/Gemini/GPT/Kimi/Grok) take native SelectedImage. The catalog + // still advertises image for noVision members so Codex can attach (sidecar option B). + noVisionModels: [...CURSOR_NO_VISION_MODELS], }, { id: "xai", diff --git a/tests/catalog-vision-sidecar-modalities.test.ts b/tests/catalog-vision-sidecar-modalities.test.ts index c49347025e..4608700b4c 100644 --- a/tests/catalog-vision-sidecar-modalities.test.ts +++ b/tests/catalog-vision-sidecar-modalities.test.ts @@ -5,6 +5,8 @@ import type { OcxProviderConfig } from "../src/types"; import { deriveComboCatalogModel } from "../src/codex/catalog"; import { PROVIDER_REGISTRY } from "../src/providers/registry"; import { enrichProviderFromRegistry } from "../src/providers/derive"; +import { CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS } from "../src/adapters/cursor/discovery"; +import { modelInList } from "../src/types"; import type { CatalogModel } from "../src/types"; const base: OcxProviderConfig = { @@ -231,3 +233,22 @@ describe("vision-capable provider models feed combo modalities", () => { expect(hinted.inputModalities).toEqual(["text", "image"]); }); }); + +describe("Cursor native vs sidecar vision registry", () => { + test("curates noVisionModels for Auto/Composer/GLM while advertising image for all static ids", () => { + const cursor = PROVIDER_REGISTRY.find(entry => entry.id === "cursor"); + expect(cursor?.noVisionModels).toEqual([...CURSOR_NO_VISION_MODELS]); + for (const model of ["auto", "composer-1", "composer-2.5", "composer-2.5-fast", "glm-5.2"]) { + expect(modelInList(cursor?.noVisionModels, model), `${model} should match noVision`).toBe(true); + } + for (const model of ["auto", "composer-2.5", "glm-5.2", "gpt-5.5", "gemini-3-pro", "grok-4.5", "kimi-k3"]) { + expect(cursor?.modelInputModalities?.[model]).toEqual(["text", "image"]); + } + for (const model of ["gpt-5.5", "gemini-3-pro", "grok-4.5", "kimi-k3"]) { + expect(modelInList(cursor?.noVisionModels, model)).toBe(false); + } + for (const model of CURSOR_STATIC_MODELS) { + expect(cursor?.modelInputModalities?.[model.id]).toEqual(["text", "image"]); + } + }); +}); diff --git a/tests/cursor-blob.test.ts b/tests/cursor-blob.test.ts index e91111f782..e33a1bd086 100644 --- a/tests/cursor-blob.test.ts +++ b/tests/cursor-blob.test.ts @@ -37,6 +37,7 @@ import { GetBlobArgsSchema, KvServerMessageSchema, SetBlobArgsSchema, + UserMessageSchema, } from "../src/adapters/cursor/gen/agent_pb"; beforeEach(() => { @@ -115,6 +116,35 @@ function actionText(bytes: Uint8Array): string | undefined { return action?.case === "userMessageAction" ? action.value.userMessage?.text : undefined; } +/** Minimal valid 1×1 PNG for SelectedImage fixtures (not signature-only). */ +const PNG_1X1 = Uint8Array.from( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), +); + +function activeUserMessage(bytes: Uint8Array) { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const action = run?.action?.action; + return action?.case === "userMessageAction" ? action.value.userMessage : undefined; +} + +function activeSelectedImages(bytes: Uint8Array) { + return activeUserMessage(bytes)?.selectedContext?.selectedImages; +} + +function nativeTurnUserMessages(bytes: Uint8Array) { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + return (run?.conversationState?.turns ?? []).map(turnId => { + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnId)); + if (turn.turn.case !== "agentConversationTurn") return undefined; + return fromBinary(UserMessageSchema, blobData(turn.turn.value.userMessage)); + }).filter((message): message is NonNullable => message !== undefined); +} + /** The `toolName`s advertised in the top-level AgentRunRequest.mcp_tools channel (undefined when unset). */ function mcpToolNames(bytes: Uint8Array): string[] | undefined { const msg = fromBinary(AgentClientMessageSchema, bytes); @@ -130,6 +160,160 @@ describe("Cursor blob handshake", () => { expect(Array.from(id)).toEqual(Array.from(sha256(data))); }); + test("encodeCursorRunRequest attaches selectedContext with blobIdWithData image refs on the active user turn", () => { + const imageBytes = PNG_1X1; + const expectedBlobId = sha256(imageBytes); + const bytes = encodeCursorRunRequest({ + modelId: "claude-4.6-opus-high", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "see this" }], + selectedImages: [{ + data: imageBytes, + mimeType: "image/png", + uuid: "img-uuid-1", + }], + }); + + expect(actionText(bytes)).toBe("see this"); + const userMessage = activeUserMessage(bytes); + expect(userMessage?.mode).toBe(1); + expect(userMessage?.selectedContext).toBeDefined(); + const images = activeSelectedImages(bytes); + expect(images?.length).toBe(1); + expect(images?.[0]?.uuid).toBe("img-uuid-1"); + expect(images?.[0]?.mimeType).toBe("image/png"); + expect(images?.[0]?.path).toBe("attachment-img-uuid-1.png"); + expect(images?.[0]?.dataOrBlobId.case).toBe("blobIdWithData"); + const withData = images?.[0]?.dataOrBlobId.value as { blobId: Uint8Array; data: Uint8Array }; + expect(Array.from(withData.blobId)).toEqual(Array.from(expectedBlobId)); + expect(Array.from(withData.data)).toEqual(Array.from(imageBytes)); + expect(Array.from(blobData(expectedBlobId))).toEqual(Array.from(imageBytes)); + }); + + test("encodeCursorRunRequest always sends empty selectedContext and mode=1 on text-only turns", () => { + const bytes = encodeCursorRunRequest({ + modelId: "claude-4.6-opus-high", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "hi" }], + }); + + const userMessage = activeUserMessage(bytes); + expect(userMessage?.text).toBe("hi"); + expect(userMessage?.mode).toBe(1); + expect(userMessage?.selectedContext).toBeDefined(); + expect(userMessage?.selectedContext?.selectedImages.length).toBe(0); + }); + + test("encodeCursorRunRequest keeps selectedContext only on the active user turn", () => { + const activeImageBytes = PNG_1X1; + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "user", content: "active turn" }], + rawMessages: [ + { + role: "user", + content: [ + { type: "text", text: "old turn" }, + { type: "image", imageUrl: "data:image/png;base64,old", detail: "auto" }, + ], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { role: "user", content: "active turn", timestamp: 3 }, + ], + selectedImages: [{ + data: activeImageBytes, + mimeType: "image/png", + uuid: "active-img", + }], + }); + + const roots = decodeRootMessages(bytes) as Array<{ role?: string; selectedContext?: unknown }>; + expect(roots.some(root => root.selectedContext !== undefined)).toBe(false); + + const historicalUser = nativeTurnUserMessages(bytes)[0]; + expect(historicalUser?.text).toBe("old turn"); + expect(historicalUser?.mode).toBe(1); + expect(historicalUser?.selectedContext).toBeDefined(); + expect(historicalUser?.selectedContext?.selectedImages.length).toBe(0); + + const activeMessage = activeUserMessage(bytes); + expect(activeMessage?.mode).toBe(1); + const images = activeSelectedImages(bytes); + expect(images?.length).toBe(1); + expect(images?.[0]?.uuid).toBe("active-img"); + }); + + test("encodeCursorRunRequest uses userMessageAction for image-only turns with selectedImages", () => { + const imageBytes = PNG_1X1; + const bytes = encodeCursorRunRequest({ + modelId: "claude-4.6-opus-high", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "" }], + rawMessages: [{ + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "auto" }], + timestamp: 1, + }], + selectedImages: [{ + data: imageBytes, + mimeType: "image/png", + uuid: "image-only", + }], + }); + + expect(activeUserMessage(bytes)).toBeDefined(); + expect(actionText(bytes)).toBe(""); + expect(activeSelectedImages(bytes)?.length).toBe(1); + }); + + test("encodeCursorRunRequest uses userMessageAction for image-only turns after assistant reply", () => { + const imageBytes = PNG_1X1; + const bytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c1", + system: [], + messages: [ + { role: "user", content: "first" }, + { role: "assistant", content: "ack" }, + { role: "user", content: "" }, + ], + rawMessages: [ + { role: "user", content: "first", timestamp: 1 }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "auto" }], + timestamp: 3, + }, + ], + selectedImages: [{ + data: imageBytes, + mimeType: "image/png", + uuid: "follow-up-image", + }], + }); + + expect(activeUserMessage(bytes)).toBeDefined(); + expect(actionText(bytes)).toBe(""); + expect(activeSelectedImages(bytes)?.length).toBe(1); + }); + test("encodeCursorRunRequest sends rootPromptMessagesJson as blob IDs, not inline JSON", () => { const bytes = encodeCursorRunRequest({ modelId: "claude-4.6-opus-high", diff --git a/tests/cursor-discovery.test.ts b/tests/cursor-discovery.test.ts index 7e9595aca2..bbcc72b8ed 100644 --- a/tests/cursor-discovery.test.ts +++ b/tests/cursor-discovery.test.ts @@ -4,6 +4,7 @@ import { CURSOR_DEFAULT_CONTEXT_WINDOW, CURSOR_ROUTER_MODEL_IDS, CURSOR_ROUTING_LEVELS, + CURSOR_NO_VISION_MODELS, CURSOR_STATIC_MODELS, cursorCodexToWireModelId, filterCursorConfiguredModelsByLiveDiscovery, @@ -20,6 +21,22 @@ import { } from "../src/adapters/cursor/discovery"; describe("Cursor discovery metadata", () => { + test("no-vision list is a curated explicit subset of the static seed", () => { + const ids = new Set(cursorModelIds(CURSOR_STATIC_MODELS)); + expect([...CURSOR_NO_VISION_MODELS]).toEqual([ + ...CURSOR_ROUTER_MODEL_IDS, + "composer-1", + "composer-2.5", + "composer-2.5-fast", + "glm-5.2", + ]); + for (const id of CURSOR_NO_VISION_MODELS) { + expect(ids.has(id), `${id} must be in the static Cursor seed`).toBe(true); + } + for (const id of ["grok-4.5", "grok-4.5-fast", "gpt-5.5", "claude-sonnet-5", "kimi-k3", "gemini-3-pro"]) { + expect(CURSOR_NO_VISION_MODELS as readonly string[]).not.toContain(id); + } + }); test("static seed includes Cursor's public model families plus the safe auto model", () => { const ids = cursorModelIds(CURSOR_STATIC_MODELS); diff --git a/tests/cursor-images.test.ts b/tests/cursor-images.test.ts new file mode 100644 index 0000000000..5f734157f3 --- /dev/null +++ b/tests/cursor-images.test.ts @@ -0,0 +1,574 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { + CursorImageError, + CURSOR_VISION_IMAGE_OMITTED, + CURSOR_VISION_SOFT_MAX_BYTES, + CURSOR_VISION_SOFT_MAX_BYTES_HIGH, + MAX_CURSOR_IMAGE_BYTES, + MAX_CURSOR_IMAGE_DECODE_BYTES, + MAX_CURSOR_IMAGE_DECODE_EDGE, + MAX_CURSOR_IMAGE_PIXELS, + MAX_CURSOR_IMAGES, + buildSelectedImages, + cursorVisionPrepareStartIndex, + decodeCursorImageDataUrl, + prepareCursorImageForWire, + prepareCursorRawMessages, + resolveActiveCursorImages, + resolveCursorImages, + sniffCursorImageDimensions, + sniffCursorImageFormat, +} from "../src/adapters/cursor/images"; +import { + handleCursorNativeKv, + resetCursorBlobStateForTests, +} from "../src/adapters/cursor/native-exec"; +import { cursorRequestMessagesFromRaw } from "../src/adapters/cursor/request-builder"; +import { activePromptText, encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { + AgentClientMessageSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../src/adapters/cursor/gen/agent_pb"; + +/** Minimal valid 1×1 PNG (real IHDR; not a signature-only stub). */ +const PNG_BYTES = Uint8Array.from( + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==", + "base64", + ), +); +const PNG_DATA_URL = `data:image/png;base64,${Buffer.from(PNG_BYTES).toString("base64")}`; + +async function oversizedDecodablePng(): Promise { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const src = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + return new Uint8Array(await new Bun.Image(src).resize(2400, 2400).png().bytes()); +} + +describe("Cursor image resolver", () => { + test("rejects more than MAX_CURSOR_IMAGES in one request", async () => { + const urls = Array.from({ length: MAX_CURSOR_IMAGES + 1 }, () => PNG_DATA_URL); + await expect(resolveCursorImages(urls)).rejects.toMatchObject({ + name: "CursorImageError", + message: `Too many images in one request (max ${MAX_CURSOR_IMAGES}).`, + }); + }); + + test("omits data URLs above the inbound decode bomb ceiling", async () => { + const oversized = "A".repeat(Math.ceil((MAX_CURSOR_IMAGE_DECODE_BYTES + 1) * 4 / 3)); + // Soft-omit: one bad URL must not abort a mixed turn. + const resolved = await resolveCursorImages([`data:image/png;base64,${oversized}`]); + expect(resolved).toEqual([]); + }); + + test("prep-before-cap accepts PNG over 1 MiB that JPEG-encodes under the soft and wire caps", async () => { + const png = await oversizedDecodablePng(); + expect(png.byteLength).toBeGreaterThan(MAX_CURSOR_IMAGE_BYTES); + const url = `data:image/png;base64,${Buffer.from(png).toString("base64")}`; + const resolved = await resolveCursorImages([url]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.mimeType).toBe("image/jpeg"); + expect(resolved[0]!.data.byteLength).toBeLessThan(png.byteLength); + expect(resolved[0]!.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(resolved[0]!.data.byteLength).toBeLessThanOrEqual(MAX_CURSOR_IMAGE_BYTES); + }); + + test("omits undecodable payloads under the decode ceiling instead of sending them", async () => { + const junk = "A".repeat(Math.ceil((MAX_CURSOR_IMAGE_BYTES + 1) * 4 / 3)); + // Pad to valid base64 length so alphabet/padding checks pass and Bun decode fails. + const padded = junk + "=".repeat((4 - (junk.length % 4)) % 4); + const resolved = await resolveCursorImages([`data:image/png;base64,${padded}`]); + expect(resolved).toEqual([]); + }); + + test("decodes valid base64 data URLs through JPEG prep", async () => { + const resolved = await resolveCursorImages([PNG_DATA_URL]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.mimeType).toBe("image/jpeg"); + expect(resolved[0]!.data.byteLength).toBeGreaterThan(0); + expect(resolved[0]!.data[0]).toBe(0xff); + expect(resolved[0]!.data[1]).toBe(0xd8); + expect(resolved[0]?.uuid.length).toBeGreaterThan(0); + }); + + test("soft-omits malformed and non-image data URLs", async () => { + expect(await resolveCursorImages(["data:image/png,not-base64"])).toEqual([]); + expect(await resolveCursorImages(["data:text/plain;base64,YQ=="])).toEqual([]); + expect(await resolveCursorImages(["data:image/png;base64"])).toEqual([]); + expect(await resolveCursorImages(["data:image/png;base64,"])).toEqual([]); + }); + + test("omits remote URLs — this slice is data: only", async () => { + expect(await resolveCursorImages(["http://example.com/image.png"])).toEqual([]); + expect(await resolveCursorImages(["https://example.com/image.png"])).toEqual([]); + }); + + test("resolveActiveCursorImages selects the last user turn and ignores earlier images", async () => { + const resolved = await resolveActiveCursorImages([ + { + role: "user", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/auto", + content: [{ type: "text", text: "seen" }], + timestamp: 2, + }, + { + role: "user", + content: [ + { type: "text", text: "active" }, + { type: "image", imageUrl: PNG_DATA_URL }, + ], + timestamp: 3, + }, + ]); + expect(resolved).toHaveLength(1); + expect(resolved[0]?.mimeType).toBe("image/jpeg"); + }); + + test("resolveActiveCursorImages supports developer turns", async () => { + const resolved = await resolveActiveCursorImages([ + { + role: "developer", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + timestamp: 1, + }, + ]); + expect(resolved).toHaveLength(1); + }); + + test("resolveActiveCursorImages returns empty for text-only trailing toolResult", async () => { + const resolved = await resolveActiveCursorImages([ + { + role: "user", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + timestamp: 1, + }, + { + role: "toolResult", + toolCallId: "call-1", + toolName: "read_file", + content: "done", + isError: false, + timestamp: 2, + }, + ]); + expect(resolved).toEqual([]); + }); + + test("user message after toolResult does not promote stale tool images", async () => { + const resolved = await resolveActiveCursorImages([ + { role: "user", content: "first", timestamp: 1 }, + { + role: "toolResult", + toolCallId: "call_view", + toolName: "view_image", + content: [{ type: "image", imageUrl: PNG_DATA_URL }], + isError: false, + timestamp: 2, + }, + { role: "user", content: "new question without an image", timestamp: 3 }, + ]); + expect(resolved).toEqual([]); + }); + + test("CursorImageError carries HTTP status for callers", () => { + const error = new CursorImageError("blocked", 403); + expect(error.status).toBe(403); + expect(error.name).toBe("CursorImageError"); + }); + + test("buildSelectedImages uses blobIdWithData + attachment path and keeps KV hydrated", () => { + resetCursorBlobStateForTests(); + // Minimal PNG signature + IHDR claiming 2x3 (not Bun-decodable — stays PNG) + const png = Uint8Array.from([ + 137, 80, 78, 71, 13, 10, 26, 10, + 0, 0, 0, 13, 73, 72, 68, 82, + 0, 0, 0, 2, 0, 0, 0, 3, + 8, 2, 0, 0, 0, 0, 0, 0, 0, + ]); + expect(sniffCursorImageDimensions(png)).toEqual({ width: 2, height: 3 }); + + // Standalone RST0 before SOF0 must not be parsed as a length-bearing segment. + const jpegWithRst = Uint8Array.from([ + 0xff, 0xd8, // SOI + 0xff, 0xd0, // RST0 (no length) + 0xff, 0xc0, 0x00, 0x0b, 0x08, 0x00, 0x03, 0x00, 0x02, 0x03, 0x01, 0x11, 0x00, // SOF0 2x3 + ]); + expect(sniffCursorImageDimensions(jpegWithRst)).toEqual({ width: 2, height: 3 }); + + const [selected] = buildSelectedImages([{ + data: png, + mimeType: "image/png", + uuid: "u-dim", + }]); + expect(selected?.dataOrBlobId.case).toBe("blobIdWithData"); + expect(selected?.path).toBe("attachment-u-dim.png"); + expect(selected?.dimension?.width).toBe(2); + expect(selected?.dimension?.height).toBe(3); + const withData = selected!.dataOrBlobId.value as { blobId: Uint8Array; data: Uint8Array }; + expect(Array.from(withData.blobId)).toEqual(Array.from(createHash("sha256").update(png).digest())); + expect(Array.from(withData.data)).toEqual(Array.from(png)); + + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId: withData.blobId }) }, + }))); + const kv = reply.message.case === "kvClientMessage" ? reply.message.value : undefined; + const data = kv?.message.case === "getBlobResult" ? kv.message.value.blobData : undefined; + expect(Array.from(data ?? [])).toEqual(Array.from(png)); + }); + + test("prepareCursorImageForWire re-encodes large PNG as JPEG under the soft cap", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + expect(png.byteLength).toBeGreaterThan(CURSOR_VISION_SOFT_MAX_BYTES); + + const prepared = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "big-png", + }); + expect(prepared.status).toBe("ready"); + if (prepared.status !== "ready") throw new Error("expected ready"); + expect(prepared.image.mimeType).toBe("image/jpeg"); + expect(prepared.image.data.byteLength).toBeLessThan(png.byteLength); + expect(prepared.image.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(prepared.image.data[0]).toBe(0xff); + expect(prepared.image.data[1]).toBe(0xd8); + }); + + test("detail original/high uses a higher soft tier than auto", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + const auto = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "auto", + detail: "auto", + }); + const original = await prepareCursorImageForWire({ + data: png, + mimeType: "image/png", + uuid: "original", + detail: "original", + }); + expect(auto.status).toBe("ready"); + expect(original.status).toBe("ready"); + if (auto.status !== "ready" || original.status !== "ready") throw new Error("expected ready"); + expect(original.image.data.byteLength).toBeGreaterThan(auto.image.data.byteLength); + expect(auto.image.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(original.image.data.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES_HIGH); + expect(original.image.data.byteLength).toBeLessThanOrEqual(MAX_CURSOR_IMAGE_BYTES); + }); + + test("exotic MIME and corrupt PNG fail closed", async () => { + const bmp = await prepareCursorImageForWire({ + data: new Uint8Array([0x42, 0x4d, 0, 0, 0, 0]), + mimeType: "image/bmp", + uuid: "bmp", + }); + expect(bmp).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + + const corrupt = await prepareCursorImageForWire({ + data: new Uint8Array(128).fill(0x41), + mimeType: "image/png", + uuid: "corrupt", + }); + expect(corrupt).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + + // Soft-cap-sized labeled JPEG must still decode; junk under the soft max is omitted. + const fakeJpeg = await prepareCursorImageForWire({ + data: new Uint8Array(128).fill(0xff), + mimeType: "image/jpeg", + uuid: "fake-jpeg", + }); + expect(fakeJpeg).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("prepareCursorRawMessages JPEG-preps active-turn user data URLs", async () => { + const pngPath = new URL("./helpers/cursor-grumpy-fixture.png", import.meta.url); + const png = new Uint8Array(await Bun.file(pngPath).arrayBuffer()); + const imageUrl = `data:image/png;base64,${Buffer.from(png).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image", imageUrl, detail: "auto" }, + ], + timestamp: 1, + }, + ]); + const user = prepared?.[0]; + expect(user?.role).toBe("user"); + if (user?.role !== "user" || typeof user.content === "string") throw new Error("expected image parts"); + const part = user.content.find(item => item.type === "image"); + expect(part?.type).toBe("image"); + if (part?.type !== "image") throw new Error("expected image"); + expect(part.imageUrl.startsWith("data:image/jpeg;base64,")).toBe(true); + const payload = part.imageUrl.slice(part.imageUrl.indexOf(",") + 1); + const bytes = Buffer.from(payload, "base64"); + expect(bytes.byteLength).toBeLessThanOrEqual(CURSOR_VISION_SOFT_MAX_BYTES); + expect(bytes[0]).toBe(0xff); + expect(bytes[1]).toBe(0xd8); + }); + + test("prepareCursorRawMessages replaces exotic images with omission text", async () => { + const bmpUrl = `data:image/bmp;base64,${Buffer.from([0x42, 0x4d, 0, 0]).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: bmpUrl }], + timestamp: 1, + }, + ]); + const user = prepared?.[0]; + expect(user?.role).toBe("user"); + if (user?.role !== "user" || typeof user.content === "string") throw new Error("expected parts"); + expect(user.content).toEqual([{ type: "text", text: CURSOR_VISION_IMAGE_OMITTED }]); + }); + + test("cursorRequestMessagesFromRaw surfaces omission text after prepare", async () => { + const bmpUrl = `data:image/bmp;base64,${Buffer.from([0x42, 0x4d, 0, 0]).toString("base64")}`; + const raw = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: bmpUrl }], + timestamp: 1, + }, + ]); + const messages = cursorRequestMessagesFromRaw(raw); + expect(messages).toEqual([{ role: "user", content: CURSOR_VISION_IMAGE_OMITTED }]); + expect(activePromptText({ + modelId: "grok-4.5", + conversationId: "cursor_test", + system: [], + messages, + rawMessages: raw, + })).toBe(CURSOR_VISION_IMAGE_OMITTED); + }); + + test("live-transport image phase: prepare rawMessages then resolve SelectedImage", async () => { + // Mirrors live-transport.ts: prepareCursorRawMessages → resolveActiveCursorImages. + const rawIn = [ + { + role: "user" as const, + content: [ + { type: "text" as const, text: "What is in this image?" }, + { type: "image" as const, imageUrl: PNG_DATA_URL, detail: "high" }, + ], + timestamp: 1, + }, + ]; + const rawMessages = await prepareCursorRawMessages(rawIn); + const messages = cursorRequestMessagesFromRaw(rawMessages); + const selectedImages = await resolveActiveCursorImages(rawMessages); + expect(selectedImages).toHaveLength(1); + + resetCursorBlobStateForTests(); + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c-wire", + system: ["You are helpful."], + messages, + rawMessages, + selectedImages, + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("userMessageAction"); + if (run?.action?.action.case !== "userMessageAction") throw new Error("expected userMessageAction"); + expect(run.action.action.value.userMessage?.text).toContain("What is in this image?"); + expect(run.action.action.value.userMessage?.selectedContext?.selectedImages.length).toBe(1); + }); + + test("image-only remote soft-omit yields userMessageAction with omission text", async () => { + const raw = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: "https://example.com/missing.png" }], + timestamp: 1, + }, + ]); + const messages = cursorRequestMessagesFromRaw(raw); + expect(messages).toEqual([{ role: "user", content: CURSOR_VISION_IMAGE_OMITTED }]); + const selectedImages = await resolveActiveCursorImages(raw); + expect(selectedImages).toEqual([]); + resetCursorBlobStateForTests(); + const bytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c-https-omit", + system: [], + messages, + rawMessages: raw, + selectedImages, + }); + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + expect(run?.action?.action.case).toBe("userMessageAction"); + expect(actionTextFrom(bytes)).toBe(CURSOR_VISION_IMAGE_OMITTED); + }); + + test("remote image with valid text continues text-only", async () => { + const raw = await prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "text", text: "what color is the sky?" }, + { type: "image", imageUrl: "https://example.com/missing.png" }, + ], + timestamp: 1, + }, + ]); + const messages = cursorRequestMessagesFromRaw(raw); + expect(typeof messages[0]?.content).toBe("string"); + expect(messages[0]?.content).toContain("what color is the sky?"); + expect(messages[0]?.content).toContain(CURSOR_VISION_IMAGE_OMITTED); + expect(await resolveActiveCursorImages(raw)).toEqual([]); + }); + + test("strict base64 rejects truncated and wrong-alphabet payloads", () => { + expect(() => decodeCursorImageDataUrl("data:image/png;base64,iVBOR")).toThrow(CursorImageError); + expect(() => decodeCursorImageDataUrl("data:image/png;base64,!!!!")).toThrow(CursorImageError); + // Signature-only 8-byte stub is valid base64 but must not bypass prepare (no ≤64 passthrough). + const stubUrl = `data:image/png;base64,${Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).toString("base64")}`; + const decoded = decodeCursorImageDataUrl(stubUrl); + expect(decoded.data.byteLength).toBe(8); + }); + + test("signature-only PNG stub is omitted by prepare (no ≤64 bypass)", async () => { + const outcome = await prepareCursorImageForWire({ + data: new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]), + mimeType: "image/png", + uuid: "stub", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("oversize sniffed dimensions omit without Bun decode bomb", async () => { + // PNG IHDR with absurd width/height; sniff rejects before Bun.Image. + const ihdr = new Uint8Array([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x01, 0x00, 0x00, // width 65536 + 0x00, 0x01, 0x00, 0x00, // height 65536 + ]); + expect(sniffCursorImageDimensions(ihdr)).toEqual({ width: 65536, height: 65536 }); + expect(65536).toBeGreaterThan(MAX_CURSOR_IMAGE_DECODE_EDGE); + expect(65536 * 65536).toBeGreaterThan(MAX_CURSOR_IMAGE_PIXELS); + const outcome = await prepareCursorImageForWire({ + data: ihdr, + mimeType: "image/png", + uuid: "huge", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("truncated FF D8 JPEG under soft cap is omitted (no SOI-only fast path)", async () => { + const truncated = new Uint8Array([0xff, 0xd8, 0x00, 0x00]); + expect(sniffCursorImageFormat(truncated)).toBe("jpeg"); + expect(sniffCursorImageDimensions(truncated)).toBeUndefined(); + const outcome = await prepareCursorImageForWire({ + data: truncated, + mimeType: "image/jpeg", + uuid: "soi-only", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("PNG bytes labeled image/jpeg are re-encoded as JPEG, not passthrough", async () => { + const outcome = await prepareCursorImageForWire({ + data: PNG_BYTES, + mimeType: "image/jpeg", + uuid: "mislabeled", + }); + expect(outcome.status).toBe("ready"); + if (outcome.status !== "ready") throw new Error("expected ready"); + expect(outcome.image.mimeType).toBe("image/jpeg"); + expect(outcome.image.data[0]).toBe(0xff); + expect(outcome.image.data[1]).toBe(0xd8); + expect(sniffCursorImageFormat(outcome.image.data)).toBe("jpeg"); + }); + + test("oversized WebP VP8X header omits before Bun decode", async () => { + // RIFF....WEBP + VP8X with canvas size 65536x65536 (stored as size-1). + const webp = new Uint8Array(30); + webp.set([0x52, 0x49, 0x46, 0x46], 0); // RIFF + webp.set([0x57, 0x45, 0x42, 0x50], 8); // WEBP + webp.set([0x56, 0x50, 0x38, 0x58], 12); // VP8X + // width-1 / height-1 as 24-bit LE at 24..29 → 65535 → displayed 65536 + webp[24] = 0xff; + webp[25] = 0xff; + webp[26] = 0x00; + webp[27] = 0xff; + webp[28] = 0xff; + webp[29] = 0x00; + expect(sniffCursorImageFormat(webp)).toBe("webp"); + expect(sniffCursorImageDimensions(webp)).toEqual({ width: 65536, height: 65536 }); + const outcome = await prepareCursorImageForWire({ + data: webp, + mimeType: "image/webp", + uuid: "huge-webp", + }); + expect(outcome).toEqual({ status: "omitted", reason: CURSOR_VISION_IMAGE_OMITTED }); + }); + + test("prepareCursorRawMessages leaves historical images untouched on a later user turn", async () => { + const oldUrl = `data:image/png;base64,${Buffer.from([...PNG_BYTES, 1]).toString("base64")}`; + const prepared = await prepareCursorRawMessages([ + { + role: "user", + content: [{ type: "image", imageUrl: oldUrl }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/grok-4.5", + content: [{ type: "text", text: "seen" }], + timestamp: 2, + }, + { role: "user", content: "thanks, no image", timestamp: 3 }, + ]); + expect(prepared?.[0]).toEqual({ + role: "user", + content: [{ type: "image", imageUrl: oldUrl }], + timestamp: 1, + }); + expect(cursorVisionPrepareStartIndex(prepared ?? [])).toBe(2); + }); + + test("aborted image-phase signal stops further local prepare work", async () => { + const controller = new AbortController(); + controller.abort(); + await expect(prepareCursorImageForWire({ + data: PNG_BYTES, + mimeType: "image/png", + uuid: "aborted", + }, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + + await expect(prepareCursorRawMessages([ + { + role: "user", + content: [ + { type: "image", imageUrl: PNG_DATA_URL }, + { type: "image", imageUrl: PNG_DATA_URL }, + ], + timestamp: 1, + }, + ], controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +function actionTextFrom(bytes: Uint8Array): string | undefined { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const action = run?.action?.action; + return action?.case === "userMessageAction" ? action.value.userMessage?.text : undefined; +} diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index f3aaafd656..a16ed9efd5 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -180,7 +180,7 @@ describe("Cursor request builder", () => { ]); }); - test("uses an explicit image placeholder for unsupported image parts", () => { + test("omits image parts from text — they ride SelectedImage, not markers", () => { const request = createCursorRequest({ ...base, context: { @@ -198,13 +198,58 @@ describe("Cursor request builder", () => { }); expect(request.messages[0]?.content).toContain("see"); - // A USER-message image is still flattened here (this path builds the plain-text prompt). - // The tool-result ENCODER does build real McpImageContent, so the placeholder no longer - // claims the encoder as a whole is unable to send images. (Neither kind reaches Cursor in - // production today: every Cursor model is in noVisionModels, so the vision sidecar runs - // first — see devlog/_plan/260817_cursor_toolcall_decode/020_*.md.) - expect(request.messages[0]?.content).toContain("image omitted from this Cursor text prompt"); - expect(request.messages[0]?.content).toContain("high"); + expect(request.messages[0]?.content).not.toContain("image input unsupported"); + expect(request.messages[0]?.content).not.toContain("data:image/png"); + }); + + test("preserves image-only user turns as empty-string active messages", () => { + const request = createCursorRequest({ + ...base, + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "high" }], + timestamp: 1, + }, + ], + }, + }); + + expect(request.messages).toEqual([{ role: "user", content: "" }]); + expect(request.rawMessages?.length).toBe(1); + }); + + test("preserves image-only active user turn after assistant reply", () => { + const request = createCursorRequest({ + ...base, + context: { + messages: [ + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,abc", detail: "high" }], + timestamp: 1, + }, + { + role: "assistant", + model: "cursor/composer-2.5", + content: [{ type: "text", text: "ack" }], + timestamp: 2, + }, + { + role: "user", + content: [{ type: "image", imageUrl: "data:image/png;base64,def", detail: "high" }], + timestamp: 3, + }, + ], + }, + }); + + expect(request.messages).toEqual([ + { role: "user", content: "" }, + { role: "assistant", content: "ack" }, + { role: "user", content: "" }, + ]); }); test("preserves Responses tools and tool choice for Cursor request context", () => { diff --git a/tests/cursor-static-catalog.test.ts b/tests/cursor-static-catalog.test.ts index 4dbd772e86..fa8be304f3 100644 --- a/tests/cursor-static-catalog.test.ts +++ b/tests/cursor-static-catalog.test.ts @@ -111,5 +111,12 @@ describe("Cursor static Codex catalog", () => { ]); expect(entries.find(item => item.slug === "cursor/glm-5.2")?.supported_reasoning_levels) .toMatchObject([{ effort: "high" }, { effort: "max" }, { effort: "ultra" }]); + + for (const modelId of ["auto", "composer-2.5", "gpt-5.5", "gemini-3-pro"]) { + expect( + entries.find(item => item.slug === `cursor/${modelId}`)?.input_modalities, + `cursor/${modelId} should advertise image input`, + ).toEqual(["text", "image"]); + } }); }); diff --git a/tests/cursor-vision-wire-harness.test.ts b/tests/cursor-vision-wire-harness.test.ts new file mode 100644 index 0000000000..a01ae1cdde --- /dev/null +++ b/tests/cursor-vision-wire-harness.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { fromBinary } from "@bufbuild/protobuf"; +import { encodeCursorRunRequest } from "../src/adapters/cursor/protobuf-request"; +import { + AgentClientMessageSchema, + ConversationStepSchema, + ConversationTurnStructureSchema, +} from "../src/adapters/cursor/gen/agent_pb"; +import { handleCursorNativeKv, resetCursorBlobStateForTests } from "../src/adapters/cursor/native-exec"; +import { GetBlobArgsSchema, KvServerMessageSchema } from "../src/adapters/cursor/gen/agent_pb"; +import { create } from "@bufbuild/protobuf"; + +function blobData(blobId: Uint8Array): Uint8Array { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }))); + const kv = reply.message.case === "kvClientMessage" ? reply.message.value : undefined; + const result = kv?.message.case === "getBlobResult" ? kv.message.value.blobData : undefined; + if (!result) throw new Error("missing blob data"); + return result; +} + +function activeSelectedImageBytes(bytes: Uint8Array): Uint8Array | undefined { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + const action = run?.action?.action; + const image = action?.case === "userMessageAction" + ? action.value.userMessage?.selectedContext?.selectedImages[0] + : undefined; + if (!image) return undefined; + if (image.dataOrBlobId.case === "data") return image.dataOrBlobId.value; + if (image.dataOrBlobId.case === "blobId") return blobData(image.dataOrBlobId.value); + if (image.dataOrBlobId.case === "blobIdWithData") return image.dataOrBlobId.value.data; + return undefined; +} + +function anyMcpImageContent(bytes: Uint8Array): boolean { + const msg = fromBinary(AgentClientMessageSchema, bytes); + const run = msg.message.case === "runRequest" ? msg.message.value : undefined; + for (const turnId of run?.conversationState?.turns ?? []) { + const turn = fromBinary(ConversationTurnStructureSchema, blobData(turnId)); + if (turn.turn.case !== "agentConversationTurn") continue; + for (const stepId of turn.turn.value.steps) { + const step = fromBinary(ConversationStepSchema, blobData(stepId)); + if (step.message.case !== "toolCall") continue; + const tool = step.message.value.tool; + if (tool.case !== "mcpToolCall") continue; + const result = tool.value.result?.result; + if (result?.case !== "success") continue; + for (const item of result.value.content) { + if (item.content.case === "image") return true; + } + } + } + return false; +} + +describe("Cursor vision wire harness", () => { + test("grok attach keeps non-empty PNG bytes on the wire; tool-result images stay text-only", () => { + resetCursorBlobStateForTests(); + const imageBytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13]); + const imageUrl = `data:image/png;base64,${Buffer.from(imageBytes).toString("base64")}`; + + const attachBytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c1", + system: [], + messages: [{ role: "user", content: "see this" }], + selectedImages: [{ uuid: "img-uuid-1", mimeType: "image/png", data: imageBytes }], + }); + const attachWireBytes = activeSelectedImageBytes(attachBytes); + expect(attachWireBytes).toBeDefined(); + expect(attachWireBytes!.byteLength).toBeGreaterThan(0); + expect(Array.from(attachWireBytes!.slice(0, 4))).toEqual([137, 80, 78, 71]); + + const viewBytes = encodeCursorRunRequest({ + modelId: "grok-4.5", + conversationId: "c1", + system: ["You are helpful."], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_view\nname: view_image\nis_error: false\noutput:" }], + rawMessages: [ + { role: "user", content: "describe the image", timestamp: 1 }, + { + role: "assistant", + model: "cursor/grok-4.5", + timestamp: 2, + content: [{ type: "toolCall", id: "call_view", name: "view_image", arguments: { path: "/tmp/x.png" } }], + }, + { + role: "toolResult", + toolCallId: "call_view", + toolName: "view_image", + content: [{ type: "image", imageUrl, detail: "auto" }], + isError: false, + timestamp: 3, + }, + ], + }); + // Tool-result image promotion is out of scope in this slice: no McpImageContent on the wire. + expect(anyMcpImageContent(viewBytes)).toBe(false); + }); +}); diff --git a/tests/helpers/cursor-grumpy-fixture.png b/tests/helpers/cursor-grumpy-fixture.png new file mode 100644 index 0000000000000000000000000000000000000000..a06cdc84a2491b7289866dd884f5f6fa1034f8c7 GIT binary patch literal 440734 zcmV)IK)k<+P)00Lx!FgY@T5XY6cQj}2vB^5;#AloDk zMI0L|F>ygKF)0_6Qcw!WfRGU~R0K8#sU?GG4XxMRuitcsGwrjd)c38m_WsYl=f3xZ zTIDUBzW1Gb&K~|@4d43KTG2oF{;zGCrV+gSh~rqID3K_N_vZ*CW zhmxgZ$&#_8$v`kOtJOk|PFAv5tYp2exn_#Gkhb?epp9o7E2mou0)D5=QuPz zFG;HNmsdO&*O;vpjGwQEa1QZYc(2H4pvC^s_7um%eLjq80=*|jo$Z<80g&+1yg zFQU&Gsa(LhcweG>w#LQ_`y;w?`Nq1g557s5eCe8Ty zs!=(Dtrg)J%39)dAW;JuSxSsGhR@eJmvPka{x?M-74{|W5z7Hp9Ob$Yp<#{fn(TIO9N|htoXwhdNt)pEjf}HYCexwJMq|Nk z?agK!U#<~6yXdvzG?6ArWmPp&K*uz8A5ALjs*vSkEvtMjaXOUwd@ie{$_1%(j+#n8 zUsa{aWYrB(rSsl!-&8V8QkhOiGMf%1%_12MQW=d>?(MoRWROKNo(yC>#JVdbf{ctC zNot+@EX5wgaA6Io%#2c#mt`TtbS#mLo30PjA&KsR^?KduiXHl`taUy4XZ*m*#sL}V zGJtbr^WyV0NHVSij4$pj4HdZ``kr3UZvsNk|E-Iv!6&4yRRBn!(8CQ)VG`Ux`*-_^ z8^OT!ALwp}FShVIG(0#++X~kQx)q|qGwQyA5X9Yd$~PQvzh{4s(%mNr^4K<6_*q|2 zSa@mXg2k?hHQO_uwl-4mOk10)_gw8gsYS)}#<(ti$F{}&;rGNwI)cJWq>1#LwHay< zHtWqw$|C1|WEqU4S~mGc25Bba@tDfIh*WSa*DIN%6W!>wettkf-$+p;GDt<%33iwY zX((*$wY*q!;}4RQ!Yzv`8BIpgpB4GpvX{h8!dOk;68#zu~L7VLZ|XuPTh8?uo>s~n_NCK$WXKow(IY^XS! zO6L;-lLi+wehgs`<)AZHL$XkzyMd>I-_;WgS3C>nD#iv+c)u{}M%h5->y@sl;ebD> zM6n{$L^4Q-B$lXxOl$aWC~p{W`KFM`bVh-Wd5bArRls8WRq-}zd{;G0%GOALFu?PL z`$fO2>m-G7uwL`08U`Zi0BfYefN@;7IQcwk{IvT6V^Hb77^azQs**no7)CIpvRL9I zm87w~rBZ|f8XKA4%LW`hzE#&U%yj&rkno%sWBhwO9P7N;KEpk(7E3xNQ0{ah(p0iI zlV!ec*Fg#+Cf~?(HkQq1jjKzXBvNcPYCOVm!E&Z;5KGh~_HSL2q45S~Nk=1!rH*Pz zA{c8C=Y?{DXV(LJips&Rl@5m-6FfszVh%;BV#9NuEKX!Pn({n)4V?>haC8pSIF;FK zMk9iT0{3!8!=iCvi!p^>hhYJk1N90S63A6I+B*5iKWxd~IEjTjB~B!ULWe_vofSfW%(;$7N#m!GvW8OuSy9n>$jeIdm zlA^}i#dE|od?ItqRG~K#WsRgcM!uw?UL{bHnPdZ8zm)yGDdiLHJke z#2Tw$sL}_-4d6a&D)cPD7@Lt|4yTS^Wsa~qY`#YNIt*jEOYgNd81_n_gZy0Gb#xAW z?$TC?nU#jsstQIoxhrs3S^i~JY&8XXs360e4KL{53vZxRR#Z4U6r#QS`n%4pw;QQd zWC{?A1m`q)#DT@D{k|>i_Bt>vT4ACCh3Bf9&JfsI%y@J^PtRxwClzxXem8u!@WAIW z>6kcr>Ft@jap337VCqjQ6gtKL%7XW-??St`MvI<91-u9h3m`NFAX7__=*w^_06x^i1qxD0y2hr4u*KMJ=uPAAMOn!J z-p*jid#)4sR;8qah(^V1Hj*D3c^AWi}mg{x`*1#^XKNJGda5a-|y{hG*PJ8fV-sWu;?Y8T3K#&(_8uQ&efW zn9KfbkM{uuaxk09as`inNY5l+=Q12aSizLgG3EM%)};lebK>*p>!cN&ks5nY0GoBr zaj%L}^3_@%282)>t!ct>EjB15jA>$lciF zMv@HA2{ZsocQWSl!JD0oC-lJJ5jD7&8TC4jl&5@NDDA2N(l_GY7xQDTztM0=1vdl& zSg3-;m{6IC$a=Yw@nA?~0E)FLD=Dj*YlccrYDK4D9_Ba_LL>uER~N!-MeuUBf6r@DTDN>Wz1e5J9k z<4iOUVgicBoVQS}go>nTqc-aH+>qQW4fOemFZ+To|ldcP+xOq zL#Hov(zuq0kOuNTFo;!_Y^e*5cBjsT#-KlM4TZk_@lcVN*V}s7ffDI?nEZJLtmqHZ z_tI5S!=u7y>(fwAUn&%h|XZ-tOzLN1|D64f&s2bN?tyVI~ z0HG(cTrOoTwIof-H8{wI>b+K2fCr=|!fP8#l1vz?z+{akd+G(J14)1~5ZaH4 zOkv$uxKSE4SmU~*;BC=YjT?zdbu5M{+`pK{2aFz~EI@$9BaYu>JfsJTdC!soJyon5 zdg>+@>S`mKJeM@xmn0i+(qMgsO5s~b%>9PX>Gc(bQT^lIIrerKD(Ne9|8A>K z0>{veG3)_ zUTr+RQ3W5WV)v9Cx!1?2&K$264KZc3%gCW2#d}vs%MlmC?=@oIfZt#>oAQTp*VRek zmeb>$DtuZLl4JwMk2m#(ULqjfVpGUyd`{wFA)9IlO!@S@3lY- zKGK*Jyoted#yDG9R+6QNjgKT7j-&uwJ|4?pl1jN;s1mAc$=3xHKMay+Fr;Fk0vN#y zRZAb$(aocEGVz(hNq*?c=pX(NH#Pd=hgolBUQd z8>EsKxuh}BG~M(u0>yT|3f@ON%-+JcudOHN!8fwQ^2Y>q?SzD(h7t*5& ziIWlImOzQ3cpya$V-?;wP(Ml#3Vs@OP&k{;DIgSX@Iur;v_hF8Y*R`T4b)gS1O8+M zg)Nen2tkxG9D!33OJ1&JwW%c?Or%;AGMda}K3~e@-~tsO&V~3bkuJ^`_8Alt)*=j? z4UM%C_6!WRGFRCVDh*wQ3WfLNZ=b8vcM(H}ER9{+z8ib)Tf2y@!T{x9+EtAkV=pzH|wJ{_ngq%d+-}OOogn^cL;4P~{MQjr9<9?0k z!u!TP)blumcV-xY-?K#&kO&>dYA1f{+B-M}PBuJ+V%TQ`_&-$W2}n?|EcuLJ0;Cp$ z3^%2Q9hBUm-VcnO#S)4sl^fUZ5cZ-XHwZ_)RoK&ri)Zu^K_I!XKO@Y!hV@A@o_gT; z4+LRV1D(iZU9M%ZSxMR?a5yIl$YJB*NM@6fj0OW4j|P%M7{D9aY-Cf_a`MC-dP3{6q7cxvrB=Da z>ET-U&9)16gCU5W15Y!JQdt*kLcA-)T%$;q>$MECOuY<)OaWoz+fXiuA7Vap7)*nK zg+t(dm2&a?o*W&W$oc)T495s_AZ}ffED4l-xybpwlT|K71C%Bw1gsvm8kr!jDtwCF zb0nkjklyvl;hGTq>c+9;n~E_{eI$K;6=G`O!njDHBgS{JK7g#yTa6;Q{p1s-9Klh5 z0Egib5mABn0|kNat&8J!EHRECN%=UE(_r(vzrQa}-n_1{a*P{1mNb>)<71KP7SG}G zfcyj_Qk^@$PcrJkg#*FxZK{PdWi7|^IUO3V4S0!WxPcg_5VXdD-4Id|pzpHobCeK_Cb~1=j0p++SQXRW0LeAd8cg6nP;h>q-`jB@u`xZyriI91$6U zM;k>Ojyb-28=uKi*&C0g$}8EQPRMyEXngCOaJ+zoLS8Wh)yYPeD@y9xR^0d;G$Fii zon-YIF?NvMF#-hS=fpJrAUwIgX62Sq=f+rlt#?FvwdYLHv%p&L?+(GTRKDo~(}E}tXLZ3+lKN#yW&DdQo?rV~js zDA-uysN(Y?yyRrdRu*e)*PK9n4j%q#-R4NG>!H3L^fjo_rfuD#aeJUk%9RLpTc{Iu z=_Ksg9wh9ZvH!#abvL5Ko`A^;ScQM+1X_4uY~DU}-7OfzTvVC%6kHw4jpq~G4Wft4&Dth=Ll>OJ5%FFa!?#IlJ!PLf^|p#i8Ny-gNk4YlvhBm@eorp! z2~pLjC;zPPoi$dpx&a%3#Y=-l|3+(%P$A&ST%NvpDA%qZ$#PT4?OTW3SS1K4AoHbA z=ztj$*_(_7!29|AnH)@R%OlUZDtog`60pw+3ByK6WH103XdoNF{l}%O*0nr+=ZG-j z&D*y*_F1Mz_5hwIt}{qvGKKe&$===`6(^z2D3v>hw-`^;*njuTFh)kBOfH>2FOx|k z4_-Nt3kM^4)FJ!(hNR)9x=g##R7^GXMk{h)2oJu)3 z*yDJ>`iB99u*vbAL#`nx8YM9){0wD*_njOEGy$H_rdZMlosCCw?qDi=lT4odz$F=v zW+Vn?r0pUmIFQYHBiC=t<@)V~T)TNl;{-~QggS#2p}+v@fItMpX@75zUhZTv;dmV% z-j-Ef$l*Mfe2v&A*1f@rmbHQxz5{P@I-SbZt5+pUA}Y1pw{H__K0!vr2F3(3Fn}BY zS*r-!!gzpUPKV0&$ARx+eUPY^rm|S%GTR@?{CF*Udt({^{0@vm%RIpE*PpoFhO%0D z(1;9%Fj!)_aBxn>v$0$_w@;65HDAchJGaR=hv9>L0WXs>ga&fJ4u_JlOdt5Ym?Ml6 z?qz>AB`l2nhI@f`di%~DSuS(QKnjFG9>p|}E?>GN8R6WRe2R6I%Bsoa&Yig|*QLyF zA5+m$t=h87qLmDjNJeQQcMgx`;@+M_D`b_x0FFuQQrW1X7@~Mah~v5n8PtZ#`tL1t z9m;ge#v!B*4;E)O1A72QY@9Mv1*R%i8_DHd>Upx~W9fmxNI|0K%;z~BRj%05( zqP)ZXO%)psw3T zb9w64vHaa1`VpB<_T{4=|Ae6}YM9`;aE|GCCRZO;zlp>*)yB)&u^3_Wee(!qM zyBMp+wSVfTeo~%#>M43xRj3$^><8ul&;ys{7k=)a$+hb@B?C;F$8zgvBiC-u<@(`T ze)PxQEuVej8q?`O9)Twf+K76#4de5)>}iDqB`&CVp8K4K0|l9cfCuFPhcpVP-wwZ?jtVYzx&8k_!*LP28lfIzyosa@z2RW{+WM5L+l^@ z@%FdBU0(k3m&<2A^SLeW2iXnS=lK3Z4_u`}`iKAUf0U!8dfzZ` ze*E1(A)op5=VZ0X)f0@IAEOH01vVjP&q9Hak7GHvw=d_;os%E`i65h3f*Jg#w|$Ge z^3|`Bzxa#4(ECR?Ni^KEH=D?_AACUG^G|+8p1O8TE}S1Qgs?ct8S44jfBw(p+LPDi z_+%-YqOvr)QU+|B)Pn*AO)_n;yt&e$+wWMu#H|9HvJF%xkXA zAfAr@kxR+{z|c@Rigf|S1cM$56&@;#2h1OdoLg3?_T1yRBIa;b=5 zRm*x~4iML|hR2XXs&+EMQ?z1(P;Tc05k*P{Qb&~{J2gDserOl})qT#CW`x_2RkIQq z4uCvX%!0`Gkc!qY1cVt@2WwlIL-x=+w-jGRd362tb&@-`PWTSdyB4{w8$~({!Rs6U z5UWH9TC}$#|M|aNZ!l#Iuia!cReIF;@ET3Y`Hh7hB$lNA9vTK3Q`n9Vhn~B0TCq8? z7)q2ssEMcz!LZ1n@E8Lya@TlK?pf3LNB?Y>4<-CWC>d zULk!8RujA+c#RFLO-`bvsSxJIGZ?#b)WvX+6WKa!<|PA?(R=Q4Z9^g7hrK-JkADN* z@H}QHY=>_-C-z;CNVq_JyqK@8OSw1xo2;P&B>T)6U(eD8nrcLXNs zx6?cGaE50F#P%EssKj#ZbDxzDeel<1zS_va!FdWWq;EjT0oNLU(6y{(zRv0SfB!pv zNDi((*rqqC(8GDLu5ixy!D&DMQRz+ zunBQ1UqSk9g%w}&$}g2Szwu2n8kOWHe9O204SDsKeK`mRdh>TVUhtAv$shjF|0n-n&HdmT&rYdHCUn<-rFZlpp{5KcytY-k9q;gf~=5oGBCkkN)IQ z`SoA_b>83m-~U_k;+MYEl!1+PkBO{gN@YbGl`PCr;eQ``svYmzkUf{#0}@QZ1mAd!mhZDR z2vbNccFtM>Kv$5)mZ=hN5JMlye6^C}`I0hWQz(NS2$1IRXKJ8<82Jpfij-&fgHS#rP@%Ta~d9r7_xg77}=I{*{JDa1gP9 z4y&}5fFmMY``F!1EU?w5hzpQAq-X}pa(ls_CV};p@|G9g^Q=!P@c!rfLapc7gy-mc z?cwh%I>8NXP>Td8L6pjJwUiVK42y*VSQEn#j=^Hd;JJV^Q78yiBYwkyRgzo__t^D1 z1IXE*yPw;ChNK4n`<7K4q>SkIwlf7n1b0@K001BWNklQWDx zt_3LsuO!MyhJqIa*sF=wjDdn3jAcgBEAI;6lP(!76cS2`S`Oz+S*~*~Hf%sZL?nvP zl8B^Xni1k*8*bp7dCEA4F+_>{U>sE`AvfLm28Sn0Dx8x=u4=^40*{dOf8zUxM+;eP zDq8->^EILHqs2xpUw#&eNNVhL%zn;2<5re$>mU{&Z~YL9w}6BNgkg39C_9z;dLs*r z7Yq>+DWg+@{4520*PkHp;rn$XCyTZ68sPD8ZRw}N=XMh!qyk}T88wPCVR04%{lGh=0)t;&>TbLvO?{%;f>(pNKblLr(7f&| z4?J6{I^n$HK7B=ZV`2)zNT2-vd?~}cprQKgho8?PHXsFdd*Sr>Kz4oX(La-;!v)7) z+3)~Wx;$+1A*kVWEC6b(^GcR06s{qNx-D#)HVcBb?N+o?*^PMWS{GaK zxVIL?&{vS@wFMD}kU$8x_aS%v>^5A!>pXopKJ@m;^lq(u^+JgTCmN3-$%o#alCQwX zhA@GpI2;-C*q#l7f(&(|hPH8R5O!Mh0Z@q-b?KfXe5Nk9zC%E^|LzO7mvZ5LQ7><~ ze>ei7BqoKd2u}qB7{%UwQCo2!dSb9xK~Bk~ZkDt(hr@|cN7dVE5Ss82^L^ADCJW;2|We7kU__7HiS`aUE|ciw7=0 zpv7H)3>dQ17M1B%sjQYO#*-0}!*@}TNAu~yM2FFU1?o0uIg)hyXs?ThDDl7nL#J{LSAi@A;=c!?4o2$R*F$N<0ByB^YDFNr{re$DgBC z1jZINGz7fW<$C?dUVG;ZS!sd;Tr4OTjnU}Iy zK@Nc63NC`m0w{cyZJM6Md0<>z(Ll{eF|?A1nLRT@r9h>Wz0RLJ2mVNUIJpe^9EujH zcdV8kU~5}c#KKwouTIe*NSGZh;49LCLv z!*M6Gsd!^#R4B}@YFqV?-mU$#bVJkLY7m5cHyCsvy9N$m?67#7^VqW%LM0nQG2v_1 z`~Drr_(Q0vX+Y^CDvkmv66;ddc(FPeJk*k@5{T!x z)7-y~-*;!2^sL#yAlx-J{qux~MgMujXY=>_bJ3rV{=so%9(0#a%Boft_tW!5QlWT* zmDKg;>MU7R3YtUVEK)c#84V$i&+Gge0kJM<7$Apd| zSvIyQ)%0#W+A|8I=!qC|K?vr zhVU8i6P|kFnygoIX=+p(OUTL&i94Q0dvI_dZ+XjGh{!CLP)rEzzz|TgOa*AD)bw6R zqRVr+a^(v59?p5`(j}hnlpEHZ{t1Qnd%yc34U1*S)84dv72g|bpzTKf!hN{&unJU~ z)q!}hu0d9vqc*T-2~Gh_fEDF}dFdUdqwQQWnd#tg*HW5W!rQsZ8x>5p8+m?X^Lkjueuo0|AAo1*`fp zH4bWl^pS>eYSe31WC6rm7#w(E3^(pI@5sI)A&zpYi+6`H>@e?qTNicpMO;^TH9vp( zXZ*Q|#`X1a5JL~c+J2vDf5CT*Vuc*wsVnKqEC;jr{34(k`63D@e;1tNfA2#JZe8zE zV&S<@PfS2Q2s~xAIQ0$WcVd#(c`j>|cB}vq!Ar$`=%$%Y#}db55|k(=@i*2Bcks|) zD3-D`?*U9>3rj!zuj#`cucTN!wK01d4*%wAJc9T=)GayxX=N)n}rKV zmtqKtII2kes9T1A5Hc`9BZ&d8NW>=HQxA_{`5eNV9S*kBM8c=_umr1OnFXq~`3yGW zH7q3t8-(v^0f^4C@_WNBuTTBt8VJ^c*9|}lQ%gK;Yb)R%_6&4b;4Nyb7_lx7Vesd0 zC}02eUoU^~`yXaSt8T4&)`Kh7(~9K24;o#n9Mp?|0cGB_ClBu=h#E)3!U$IDOFfbn z;Q@Mv6dmOKgT#b&joRUOMkS}Ac0QhIIv#1BIy_rT4db(dx3R7`7S*PbJ2!7}J|BAU zs$98z)nc(>+_-<#(i9~t`Jv?NbfodZH@x8uOxZ#jU4*K9Uexw0U+jXR$=}98}u0zRgAhbnTO!=A@smAU0d~|_^a8C8P+x>6z z$@T3XCBJMQUF%f>HdF0>@yccS=C^&T{PHjVGE=Nr^PtVQwsRU^K<684AR!7uBbBFd zpHERh-+yknlS733Yoz=cB#gt^+UnSLvcWy~zO3KiwkUbZZ(o^lkF1Rmc`UM zv8@qfJJ-@v@#6W4E;i5X7N+8LnF^udWAk*{TH6xPJ3LMw(7yE$e%Ax}yN>W$XXVxP z|F-M;fM*bc@lt~=Hlr<4 z+Z0>#M95l=160Ribuw%UG)6F}pH=AM&dTKX4DM$Ybz@f(J~z*_y-ZNKN~;Fyd%8ix zo2hsHWRC`vq6LdKM_xS`!w2dCdK`Aky)psadh06OQd-tf5-DSwfQrvuxWpJH*sm~t z*l5V-&NdX~(--j4xTqPxCOmk*tmfuHfgMF3+mMqgtPUUqI*WZVpbp|3GzqYila} z*~Y|e?~x9o(Rj`;oB3?-2Yx=tIo_HNf4^Pa{%>t4!#zij*5?!$Yc%@qDS?QhD?;v!jLb>QNrY~3eN8ibSQSOLjyZ(_|ULyY7 zkjH`;IpXE;bOEz7Uha{p`p;KvjPx-$Gio31tgX*qoA$1%<_e1^;}(HZ_J8tQ^bWN2D638tmAvM)e?{T}*w;b9AzsQg zsWFw!kYU3T)|XbfoTlg$HI|pX^3{@Kjib0lHju$&#?&v`)vkoU00+HgvSV$5QiQUe zNZUh4Fe5*)Akx;)?dNBGL|g8ve*o!6vNt`D(O|-+9;mKXg0qmq8=uKK*ZBf*>Ew9M z>*K7f#9ISvE5p$Q%CiH_ETP_y3Wvf5V&P(&ejKOC3!`?lM1k9vl}hU6D7w z>CN&xzw$LhdE~X#~@$QwiRKjr=^gSSh{`9ZfBsp0L4TVk@y;e*TAiBv2V*`b~yo~g^ch( zaZoNta!21AIKBW`ZWoSr$^@aRye>6IFs(Y_NN>qdEH zK2cjf6@{jdT~tYp<}^XN962I*E^uBr-j=lk1j3EdNS_U9y39XkY`>vl&h;c^ymd#^0s@`<#{0+|A`go8TBqmSxWP}P zJhrp0!Fn}Hwf3y0C3=THDs(N2tE3pC(-V;3BrC+AB?1*TV0r_K;)3W@%d$Y(N$s{1 zDqw{XngAk2^M)<$W`kp$+l)x2M_3%-G;ALZphJZ1z!SYxG6Dn@NkY~RGD=Em3b0MF zC;_q7vO;B|8nIu6)U!Cet$LYhfj1y)s+}t8YV9+p!)c zOK9BNe97drb=@wA9q5L~v*>G93h(HqgcS=RsO>nhV8M7r+6k1G&Xc=|t&J6Zl*l}1=NZioU`&jlMnvM9rZ_63MK-cS$99l%=dh#;2$D?Hh(bdLH^{ogt)1aj z1smc0q5d^uVJKx=*76l!`IQ>GYL`uyj@Lh^CIJ_y$8_P=x$Ib5rPUWF)>Bg)}A9OxdREA!LO?}c34Tf$+ zz~;-=BCGMWic3)=ZGvN&13R88JhtY8|557@#5VAFK-#2{>!Ocj!ioiJw8EmT6=d8r zwLJUT56c_=+8gD!|MkCCqj@W{!RuleuB1k&LB5KhFcXrmP4gj-uBe} z-$aRMWN5({t}H^p+VqFUInfVzf(eTfEsBap@8#ZEsakw$h6S7E2j4B_11ZnE8 zrtC4*m}EGFNr3Wc`Y6T_sF*89%6o&pwJ;Psqt|&7wDmcrhB{dBDWvskbvavS+D?Im zt=x!eW9z{|gV|e{SIUvlGUFOr)kb6r$!qpOgZLU0gVn7Cc>@=xl-NU;%3iH_?eG=m!D|~qkVxEW zWIPs|KGGb=s@BDY%4aO!h%%E_x4Noe9pOGFw01UCEVm8|$v4Mx?qDo?lc6N&DV~Ci z0fnwP?iepeHDEbs!&GVvFqNEEGom80@Ei(j_<{=w8*YH0i3S!OOGJ8%7IR>W$a`WF zelm1K$4R$1Hh;=L8(9N2qlXb|oI@F-Eho=ODAJnT}ZxJkgC zuS>af>4N3@=dwReO!Hpuld?Hu+IGuBuv(zR^k~w03gNo z(O8~7I+ox1tq;gMe(;^zXrtUvu+^Jf;sKD4m`W60Hc4%X z$djLYTpsX_M31C?8wcNgSgS?4XzUt*>^mZ}(vkoo(5wixlHlj)B`QUd5Z!gd4 zMyv~E-yh4x3BQBSGqe!$80;GOEPH=jPA|qDWpD}AQjnRvFwe)ykQ!&!Rpc4vSSRc? z4z~Y2Lkdcq`qZaBC6E2ZpUZSSW^Hinwf*Tno51|XKY3I}$w*cOTA$izp}cIZ{ms$O znr|7$12rA6=V4g>?Z5SX^4q`peyPz;)i$Ua9j2^;ETei5v9~!C9+e#oZZ!89VC*%6 z1J_Q2w!7@Xv19%u+W4;UYim8i$RK>6U?bIFGzmZ&{B(f;K~ zhwb2fM+_k!HKmLFX?!l=6v4=(B1e8W+N0L8L3cxRo*NA%8(@u~dZezCffb%&K^v+e zpb#AkylIg)jYC5|s?TDm8f9xoCPN(#sk6_KpRh0kWi~uZA0U&fNhnPo(IA(Jha=C#i=4~ zo#z>6EnS}m0M4PBqL4rG=Y~ z>20Ii6>=s4xsZ8TfuW1R(vE-Vx2>jL$^3Or4Vq+l!2m2sb8*8k!&%$||J{XYABhMF zSRu3<#>cT>09g@MvJLBVrBNo!MTZeWdk3T+BjEUT$hzK6HBuASAb8e^SE3P=IbeK} z00WKyo*ntbk?{{8ENFbS9#WT|sRLtU(Qdx&YJ{FTb?-wLaBBj|-x(@!`lNGbXk?&MRZO_+E5Gt9<*6qgmq}8~zx+RcR{r_V{4cD3etUy$6G^_< z$bb1azC&L7<*#D{6JNkemWH50?eaoml#vvXTeoh>w|?`tu%GlEy2Qcgn(fIFMK)My zY00rd|9Iq#b3V{T$BG%LSAeQ;YkBnJACtFy<6C8rsCN$uhZ4B6Q6|GEk{3V!K^bI# zH!(h1#6lZR#4^m_9f{m{`g%(w)Fvg_*1Bsea;34n{`IftozAVs zJPP@!IncHG#4}63NYHvrvao*zVOikT@;a7P@pql zNkXiqDH6lh_y%+v!lWcM>sa?W3MyZGO}V^>pKg`$h?+p|c>}4=I}aT{pKB)=Yrm18 zgQ=Az_m?VkUTiz{Y?qSR{yl)!cd*+10lgAhM+VrQ6UN{zO+mlA9h1;{;`%+0L90Sn zYo%3~XOxT$yKZRB>Yb8zpAXAc^ccLUITMayVZgri3x1T?bNnCVNAU6Uvw3+RuOf^Hq_c%96!! z!!SjtFnSSz*3g6i8jptZ(1Q=iNB{jtL7R=J7sp|ITibfhh zBR=qf-(nRk5D9&ccy&Vj-WiWt5e!oA;`s}F7S>$9hf)s(Rj7#YyDP618h1o9!2uJM;=6nVA-2aB#JT;aMwj7w~y~g z6rr=55~-jhv1hzcj^Cd{uXv{CJ@0w)n%BH$r~Ejzj)S8O>%pr=DxS6M)&BP0I0wqv zs?N3N4CId$)SA)d+iub)aMuDJU@e8~qA; z)P2%g`xNbbc}O)(#q%`W)MD|@02gn8>UzTGy(Ks*^TJu=;h}|LyhrUh^4y z3()H}XI6a?&5H3RKcP$v=b^m^JNz?k-;W&*ARIA`+q?cdorcG?;-|GNtkw;>+s*V( z;sdUq31Noqo9~~?8G`+V>2X(u(K>;?eZA8uZ=F>{lgG#_+ZA=%-z}vHN@BS}-CisX zbh3gsi2PiQuOQY4Mm$OtqUJB4Npy3tOic8waH(^7*RS?dzi<+pC< zv3Dqg#?8+juW$}d8K(iErT<4NeddM(bXNv>KWXQF74E%`@IE!QZ?{o%x5LRd&fN5c zdoN|qkU$)>(FJp+)1e$6-)3A2V*=*7!}`09PB3-}NQ@V9Eg=jQ%Uj<34f4U?{C}k^ zHgfgSfjo3^N?38(r1EEqTPJfk8Dorq?1A1}~PLdCqtxfO7oOjST>6oEeZ>Z%>kh;uO%1!{& z7GhS8iZynjF`$OpGGEJlxsW&+Gd-7)halw-PwLr2et+TEpXFeN&M!hyo|7NW8OEWX z${eD$oG;ifDAW{;OM2GDL(hBnVsRnfZS3t0*1-mOc?(K8-*tj+W1 z4`hFDO0Ta$@suhzOH{Lg^*Y{C(NPOyn_6`thtIjN=>6O6-^Ei2E!S{z@q+i(RupRI zAwXCxElZ2b7~FV21vkR^4ie4!>%?IpueJtGLzwDuKZmD(pi&o<2D~i%B#eBILj|Cg zH(9Ob^3s>Sh>$Q2#zBM9qM?TfA?Zegk{SK^zzS}IT5{#mCH9GLinSz7DYFTRVKg#M z1~N)W8MMu)`Q5xO+ms>=O>tdxBb$y!yb8Q^7>cNSfSS>l&d=mzc_LN0kg8lu6obhP zkF%gx0AeQFM|$53I7wVTXo4YB6xF66thrn*WH>~8v6fr6o|elO_SGX|4R96aYAh(+ zJ{TtQ$5$lSU+w6^byBr`Y1QJPjxDAZ8V;d25oOO#Iaf&N!`F{ zz`UV<`bN^xKn5xHBzTro001BWNkl7zxR8;*N%~U z@9j99v4tIpV=oITTFZ6D`r&|~R=JKnLF{1NGcy1RhbgHD_-6FX(Yu`KhntE> zm4*zAscnuGNo+8v(F=pbI&DItsc#kw<~=F_uU(@YM2jO60ak>IdV@7UJmboW!kD4Q zuwRMzwgxdsyfeqbS?z5_PD^W4*{I00{#`3Mw0eu-;*v4$s`B(oh26J|Q1ZNXx3>1r z_|ZB&?HpNdxslX3{#MnKCAF=^GrPp$Z$VW*N2#@6iE`Rv82Nn#eJ{A6|F^ye?Vh8S zhE+km{g9!e1%jgklQv?IKfD&J{AC`M&Y2n=YS4J;!S4S1 z;}?!&U)k#Kr^6tpQ59vObIj;0W21SeB`fCQX6j!8f(-99oKHTjhXQux^4;Zd&&|J& z4GN)8rC~&;yB$udS}Yb6LQtOCMNR0T`*Zfk8O8(Rx=>=iNm^DskRA(HJaFZz6iEB3 zHY{k8PDWO3Z6)KZt7^qHiA`yQBBl(YXbt0JHeV>MTITf5h9kh&U^y?ODwZ-DXe z&k<__9J^R9*aeRU0zxY>a(*p43!YM*%kdEg@lZCKKanJ@%YCc9EhC|=`dUYy8{Bo^K%@4Vzmu`x*YKly0w{Se&NCexqSI@yU*`+gjN6W`R6|O zx$S$*>-Qnw;iz!!P|*>gqw$;Un|%f%h3 z%9WfukG8dFJB*xp7)2_;QKScz^9IP6IK`9M?#zW3VE2!Fvc=W|WcM zI%f%jL1AMmKPQrTc_R0UuqUDLk>MZ-Q%X@dW|`+E5B0MoS_Y-Fxh`nrT8Ps2w<`3m zZLp?&YDkuingZk{tiOTvy6wSt4Z9T4?S(J}qFBkixCZ39mL|66ZJip0|BG<*~)xF zUi`Xk44w7Rdq*F(4iC3v!VT*e4h4PqT|M7+?O9r0UU)(6wH5;DFYaAlr4tgkm>^I+ zR%~O#p4TbJCa~C*>hFuEii#kYUM)vH57U1@OaW6oA>1nSwZzE@^HovK66}63!VzOf z9y5%I&eGJn=^h7al^^fbtvRnmXH}4bg9S1AV~XV){m~zlAO7JVZmXDiahSUuP&~+iP0tTyd3?-v znam7;A!$_z{Rr;|bp(f;d6-6o+W)K7R(VTTsCSL@|CZACHIfV*3T|%g34VB z`49i$-^+Tfad8iAwA~g1;pm7C&WUFUi#dg`{4=f4G56PRUS|dZw!&vVb0p)D$l>8_ zu3yyb#{8fRbpZ-G9gnV*D`bUxnW6FF#x2^%zTy?HXs;I%19|-&d$@HREI(Nrm?%UG zubk_YjCyr^;I(?KcCFWr3>8?7+UpzXpd0j9*c7r{!#gk3^S9EiT^gDdH=!;yZybiG zdCW|0CS2`zs?HbIF!vv;Tk5~D=CnXgSFDO9o-Kr?`z@%pMtRzFDW5&A0Y}QxqubZ1 zHK)X4&1~7Q>NQGTw>%KWTQ$pxRq8Bb1}Tq9Z#49^12i?8$E(!25ykPZ+xDwoZaCBx zN)E)jJ==lf2(mF3E0odOXZut>bWldFFSxh8!0iPD3oZOxmzLEHlK(AK=$*H^Z9M{J zbdPPP0`H*%9&_okOpU#2sboxZLI2*YCJe^=;{a>V-W+i@sOcD0j}SW{j!)$e<~9Ut zSwyl1Jhs?KEptNlu)Yz$iH6#pf*Xtd*1G~G_=$zd#mUfwLP&n^1e`pk)tj^s#_=K! zeZxjuaVWnpfmxs{0X|p~a2E zy)g*G`|;5683=lZ>Cr;I};yLN-u8Zno7C8Gf#{)K$!+ukPEZ#*pzUf$RBK|*Jj z;eg@RwW-1q${cGGBr2rwxgyqS)m(IV=s-3w=0G=G>EF-%;`Pu&56PeZ`JdDC!S{C= zh@q#7`P8`hHqsIjCvo6+D06%sYat}tg{=Ku*ZD#ZD8{e*y04SRpSUKYEY$)`RV9<* zh~tF5?;risN2REcTdkT4Et_5L`uYvKW`*7;{{6C-y^Pli5sLQS1M-(XSKy%|rIlWu zhQu_#UX2DQA$7*QSA`Y6VbKySw3Nyi+5%}~s?0a+X}+=UZzS8beeaQN<2Rutj8Wu< z>&s9=q^7wes4s9%G&B)%H2U-8udsEtL*|rxfmhrBQn#_gGlIi{50W}!=WJ))3j3(+ zwvZMo5XK12BF0{`vLUNQ!mzb+vp^q^$>2tNh#H4ZQ>dW(y3CDDRJ~)3kRH#fb7r?o ztA}U~y9Ao7#nI;aI~?sF_7k%~LC>lUA%(40Z~?i&Z)R7iQ$KtWu#iA9Afbsx90+${ zv2{J!{X9B2Iuo=6&0BDI0>V|hN2wA;9caeHA-u9!X0s_fX)%2Od9aagc-Z`h(^Hym2vvi)?=RWs2!t_CBajPb$ zBtm+!NhZ_TIeF7tzL70WkLGJajVYD0mV~7z1y9DlPcTJsJKI;?*dbRHHo7WjBZ?7| zvXnd2+Dpe=gV+;zHuc`RxT9Dc9tRZfg$tMD;+0D>8tuvc!4(O?VN=({ES|4XHDu(S)8d#`{Ab`Zv;)NDVv9eb53h3ft}<9LU?=_BIwv!5#|H zu0CYgAvc}kg|~@LVxcnc3p(!%hvSJSo@hU#hJ|{zefZhskk@SJGDEtt4`I-=P=U|? zP;+`P%H-+;56S-d3o@RamviSa59ehvyC^Sx*=uEyLs<@3Zvg8?%_%3wD;g zltj&2=S_=Csd1``9QotwD8?*D%0?~>Aq_f2iv?Lw-ZkQ@Sm%7N$X9%US1?BQ1w3=g zXKk;_MQCNd$AckjYqTBj%xOWIUmBo6o&p%k6_&>(YGGlqU@hqQSg{s{+;serWuc|P z7j3gAZw7^H!Uzeoj{KfN+2`jE*PXwIR>r7hf_f)ND<+zP&@qra)SqDGau{a~L&MCW zx6oK$KT{Tk7GX#*={J%OZ$!V`l?Lo@9*6cq3#I$}_GUcoiEk_Zwpc{qx$1Qt1yJu% zzs_EiRtE~dqOrt4vO1PJO{n0hc)(HRs!QFz;>rQMS=C1Eud4H?MXyjH-aX>y4U z7gC}<=xivHQ7X+?MS5DNg36~%(V)OHGK(?!m8Gnk0v767g3ZUM6-3^DJ+$&-SEPnY z@VwT$m(Uh+Shd?z|JdanopprZR0j!J^(HEtfaPwLlJ55!A=DUpCH}X27M3v!*_ps$ zOy2L&*H1{u0;C_F+nbwc_Z@#&VQ%@s`Ac%LT*)FYBpXeoi8h2J?}Z+mb!Z(7B?G~T zWmUgwJM7fn7ix8zLdEifq3exDSYZzex)e#k{UeOZ2)R7=>BnWgK|O82lqjyDpdiHz z_$>l~H?nZlKq43po18*iud5z%hO(=600n_yQ%_ih*&t}n6TZ5=&)Fvqw7x7yrJ_wJtj#c93t4m#&ZP zd?Rv4G-Rt#yS`BW16R(VTdW2-GaQ?A(? zjp30_28Q8agfhg?Z9Oo&07ro5*8QM{y_TB>vH{jS(9@IoQZAgEk(f%O0bV(bv4DK( z_ZFg2exG6ZvHv$+r|#69MiP0iK^^&i7dISLzj|mWOv6SZ91Kfm*|wlh=OKp!9)32w z&}mD&Yvmcf!tb_Te88IS6rd3(m@{mz_RHSBQG1~SUS{Ko0pi~yne8D~s0&;9V@4L^ z=k-3=+@Xr31mwg<5coG10%Gc@V3o_!;X)1%PvrP$CG#c7BBd0YhJW|Z(h#@)zutnA zN{eOs0)?_8HWC_3p)wCY+s+T;U){L+Ia*Z1yPE}_tv(;6e%8@P7(&nDevVLihX&&P zdg%Bph;YLR3IvHf{O}_!jHqLyo+M%A@tDd48}U#7^iSnuAN!b^a8_rz9gnsIsIV&B z>%Zn}r2)%V5-N6hTVs&b+9c4fUFhlY+1{u=AKo&RdEpSaTysI8AB%FpVDf_~LTfl# z;T-TlqMY!eW+)a~l`26x-;#<$y8&9b%UHUemIe>%|NfW1B#Yx??KQpu`A@wLEz+V! zgdeYa-Rrh^5B^Ny+6?n+JwM!wzu-IWp|?VbkiJ2|2IJ#C|LEU+L|bNt9Ra(MY(F0= zfM;@T-|xXu1(6om2Z*%wM$49tBDr&NLst1QjldBMf2kzjC?^251fH>@-?7gA?FC+_ z_*$}@vfoR{%;8X0MM=gwn&mL!XoR?<#ZnflrL1zSFHfTaA_b4wDvY8#8oKw98v?{t z32+$o8vILHP2LuZlhxy-+W>9jgdo-S9@c*l3rc;}6`oKKy~UA>Qpg@s`!@ z48&f{p$|iM-!sxhTcuei(~Ac%Vi;kA>37(zCQD>C8OUrhmg8(G^VM7=={yoWhd;09 zzyYpAJV?(As}O%;stU#l+Zgdxkn4&{s%fpP;DK6=WG^$%$kIqaM9K8r%5CceVTgj9 zHiS+y9QCu#2-Uht)s)KZ=jZ*l>#mQ|pcSJ+aKv9)2;cSx$eS5TNNz;Z0X@X3Ky}7|fC^7A z2CH6MWn#@@z7Pjw6}AM>Xe#T)LPpb>j;j^Pfd}XJ1W*38uYIkhnri*`@Hy}bE5NxN z@Ypu;&z<1q^TSLRE8px+1fRvLaNQ%_%$dMALUg8IPWFp+P*y^~TWlJRgLW@OM#Dy? zdkCQ*Y{yPTj7PKX1^hf(aL?@hcnn=%S|w87x8_G!6IK-ss3A+MZc0MgfP2=!1x{C*xaP%TZ%j(#|0*AXg5C~V&` zsS)woaX^(fyk~8%OE;$7F!qF>mS}9w1&m%cQc+`tJp?Lcy`y$RJW8~*u%%p|<*bGt zdmkFtyO(z`((M2-;R%kF^H`=1lN>4l$Qea%t8nSKpG?+pxh;TKTl)XKgi|I;at*_ z1q;Mzb+zz*brSU&US3j*9uWFQ4AfYTNO9AA#ZuO*jTCt))7eNy1JuK=B{`T$##~M} zf`VoqFrpZ@w`X?T%cs={QZCnu;R)E|?*@7QVy2JbE|o=ccdXhA$Kz_79o@p&e?yWL6J80}>qN4okLLwKi6LL+M(RWtNVnjC892 zN5oUTM=kJTrFAWosoqf?DB*1-9y*LJ+eqpR^@Wk%w5_c3UmEuEP|lKvgoCo=)?&lw ztG!l#eNkzubBJ>QMRUnJ8R8C(6?(}!UWCd?q`8?sYoFC2Ih!2) zG~aj9x0B;eg(Nr^YFscR&{Fb^1mMuHf5|P~@TD8KBVvUEjYEa9-d6a171Ukv8PC59 zUw#jL2+bd+OG5~h0T+}O-Y1lTdvr?;L>jUywXGz`aj5tZr5w>DQb*aV(xgM8`_edt5--T&41nx(vh$Pe#Wl%2g=p>{zji<7y? zg?&PdXrOZTpjnqe9=@(luP;Eg?8Af@cUx;eM2j>Xhmbjih{AzdOb5!uD%)9@0lfy& zK`d9UT#`?Gv?A=u5E9B_F&jf8sxVvzL+u78PafNR)zXw`m8xh4#ui9aSTN@~&v}ks zr$sbQkFBR+`FFgw074z^Mf=<{Z8IPaI(vRotckjW`~9pn6(Whwj1Q~deen($j%JLQ zK}gw$5MQL+1Q>&!MWHlb?HwhlvH9R~`E7RDRfbb4D=s5~k;@Q1X+W;z~` z(T-R-7~ShlPV%QaGOb5$5)y#}EmY;99OEod@oFM2V%Kb9WocJFN07g67`SZmV_Tu? z_qNH<{>-LNb{5ErXe6-Cy6<~4O2XqTXQN$fbee%=DC`bjZa5stZ~%xNDQ(DlsoJWP z;WvHa?ZN1kyvEJ4;D6(V-1SYmcPD0N+gv|Y

hvddan4-@|<@; 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%0DWZrohfWhFLCA2Z6rPx0H~v(h6!B} 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