From b10670168a2554461ccc8e7572261817dfcbb560 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 24 Jul 2026 17:00:35 +0800 Subject: [PATCH] feat(google): enable Gemini inline image output via responseModalities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt on current dev (terminal-truth parser rework). - Parse inlineData parts in both streaming and non-streaming Google adapter paths, materializing images to OPENCODEX_HOME/artifacts with async writes, per-image (50 MiB) and per-response (100 MiB) decoded byte budgets, 0o700/0o600 permissions, and full-UUID filenames. - Restrict responseModalities=["TEXT","IMAGE"] to explicit image-capable models (gemini-3.1-flash-image class) — non-image Gemini, Vertex, Claude/GPT-on-Antigravity, and thinking models are unaffected. - Whitelist responseModalities in compileGenerationConfig so the setting survives CCA/Vertex wire compilation. - Add gemini-3.1-flash-image to the Antigravity model catalog (wire IDs, picker, context windows). - CCA /v1/images/generations fallback: gated to generations only, uses signalWithTimeout, caps response via arrayBuffer (IMAGES_RESPONSE_MAX_BYTES), redacts errors via safeAntigravityHttpErrorMessage. - Markdown paths escape spaces/parentheses; tests cover special-char dirs. - Update model-count pins in provider-registry-parity and antigravity-wire. --- src/adapters/google-wire-compiler.ts | 4 + src/adapters/google.ts | 29 ++++ src/images/artifacts.ts | 59 +++++++ src/providers/antigravity-models.ts | 3 + src/server/images.ts | 107 ++++++++++++- tests/google-antigravity-wire.test.ts | 1 + tests/images/gemini-inline.test.ts | 206 +++++++++++++++++++++++++ tests/provider-registry-parity.test.ts | 3 +- 8 files changed, 408 insertions(+), 4 deletions(-) create mode 100644 src/images/artifacts.ts create mode 100644 tests/images/gemini-inline.test.ts diff --git a/src/adapters/google-wire-compiler.ts b/src/adapters/google-wire-compiler.ts index 87100b0b0..88c482ba7 100644 --- a/src/adapters/google-wire-compiler.ts +++ b/src/adapters/google-wire-compiler.ts @@ -137,6 +137,10 @@ function compileGenerationConfig(value: unknown): JsonObject | undefined { : (["xhigh", "max", "ultra"].includes(raw) ? "high" : undefined); if (thinkingLevel) out.thinkingConfig = { thinkingLevel }; } + if (Array.isArray(value.responseModalities)) { + const valid = value.responseModalities.filter((m): m is string => typeof m === "string" && ["TEXT", "IMAGE", "AUDIO"].includes(m)); + if (valid.length > 0) out.responseModalities = valid; + } return Object.keys(out).length > 0 ? out : undefined; } diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 2a6f9b77c..ea30790a8 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,6 +1,7 @@ import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame } from "../lib/debug"; import { createHash } from "node:crypto"; +import { createImageBudget, materializeInlineImage } from "../images/artifacts"; import type { AdapterEvent, OcxAssistantMessage, @@ -194,6 +195,17 @@ function usageFromGemini(usage: Record | undefined): OcxUsage | }; } +const IMAGE_CAPABLE_MODELS = new Set([ + "gemini-3.1-flash-image", + "gemini-2.0-flash-preview-image-generation", + "imagen-4.0-generate-001", +]); + +function isImageCapableModel(modelId: string): boolean { + if (IMAGE_CAPABLE_MODELS.has(modelId)) return true; + return /image/.test(modelId) && /gemini/.test(modelId); +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -233,6 +245,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ? mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning) : undefined; if (directFlashThinking) generationConfig.thinkingConfig = { thinkingLevel: directFlashThinking }; + if (!generationConfig.thinkingConfig && isImageCapableModel(parsed.modelId)) { + generationConfig.responseModalities = ["TEXT", "IMAGE"]; + } if (Object.keys(generationConfig).length > 0) body.generationConfig = generationConfig; const method = parsed.stream ? "streamGenerateContent" : "generateContent"; @@ -345,6 +360,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte let pendingUsage: OcxUsage | undefined; let toolCallsStarted = 0; let lastFinishReason: string | undefined; + const imageBudget = createImageBudget(); try { while (true) { @@ -409,6 +425,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.text) { yield { type: "text_delta", text: part.text }; } + const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; + if (inline?.data && inline.mimeType) { + const filePath = await materializeInlineImage(inline.mimeType, inline.data, imageBudget); + const escapedPath = filePath.replace(/([() ])/g, "\\$1"); + yield { type: "text_delta", text: `\n![image](${escapedPath})\n` }; + } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; @@ -455,6 +477,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return [{ type: "error", message: "google response contained no candidates" }]; } let toolCallsStarted = 0; + const imageBudget = createImageBudget(); if (candidates?.[0]?.content?.parts) { // Non-streaming CCA: observe thoughtSignatures for the next turn, same as the stream path. if (provider.googleMode === "cloud-code-assist" && antigravityModel && antigravitySession) { @@ -462,6 +485,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } for (const part of candidates[0].content.parts) { if (part.text) events.push({ type: "text_delta", text: part.text }); + const inline = (part as { inlineData?: { mimeType?: string; data?: string } }).inlineData; + if (inline?.data && inline.mimeType) { + const filePath = await materializeInlineImage(inline.mimeType, inline.data, imageBudget); + const escapedPath = filePath.replace(/([() ])/g, "\\$1"); + events.push({ type: "text_delta", text: `\n![image](${escapedPath})\n` }); + } if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; diff --git a/src/images/artifacts.ts b/src/images/artifacts.ts new file mode 100644 index 000000000..f27270468 --- /dev/null +++ b/src/images/artifacts.ts @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { getConfigDir } from "../config"; + +const EXT_MAP: Record = { + "image/png": "png", + "image/jpeg": "jpg", + "image/webp": "webp", + "image/gif": "gif", +}; + +const MAX_DECODED_BYTES_PER_IMAGE = 50 * 1024 * 1024; +const MAX_DECODED_BYTES_PER_RESPONSE = 100 * 1024 * 1024; + +export interface ImageBudget { + spent: number; +} + +export function createImageBudget(): ImageBudget { + return { spent: 0 }; +} + +export async function materializeInlineImage( + mimeType: string, + base64Data: string, + budget?: ImageBudget, +): Promise { + const ext = EXT_MAP[mimeType] ?? "png"; + const dir = join(getConfigDir(), "artifacts"); + await mkdir(dir, { recursive: true, mode: 0o700 }); + + const buf = Buffer.from(base64Data, "base64"); + if (buf.length === 0) throw new Error("inline image data is empty after base64 decode"); + if (buf.length > MAX_DECODED_BYTES_PER_IMAGE) throw new Error(`inline image exceeds ${MAX_DECODED_BYTES_PER_IMAGE} byte per-image cap`); + if (budget) { + if (budget.spent + buf.length > MAX_DECODED_BYTES_PER_RESPONSE) { + throw new Error(`inline image response exceeds ${MAX_DECODED_BYTES_PER_RESPONSE} byte per-response cap`); + } + budget.spent += buf.length; + } + + const now = new Date(); + const ts = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + "-", + String(now.getHours()).padStart(2, "0"), + String(now.getMinutes()).padStart(2, "0"), + String(now.getSeconds()).padStart(2, "0"), + "-", + String(now.getMilliseconds()).padStart(3, "0"), + ].join(""); + const suffix = crypto.randomUUID(); + const filePath = join(dir, `img-${ts}-${suffix}.${ext}`); + + await writeFile(filePath, buf, { mode: 0o600 }); + return filePath; +} diff --git a/src/providers/antigravity-models.ts b/src/providers/antigravity-models.ts index e241e1e33..18b0b3961 100644 --- a/src/providers/antigravity-models.ts +++ b/src/providers/antigravity-models.ts @@ -13,6 +13,7 @@ const ANTIGRAVITY_WIRE_MODELS = [ "gemini-3.6-flash-high", "gemini-3.1-pro-low", "gemini-pro-agent", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", @@ -85,6 +86,7 @@ export const ANTIGRAVITY_MODEL_ALIASES: Record = { export const ANTIGRAVITY_MODELS = [ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", @@ -97,6 +99,7 @@ const ANTIGRAVITY_WIRE_MODEL_CONTEXT_WINDOWS: Record = { "gemini-3.6-flash-high": 1_048_576, "gemini-3.1-pro-low": 1_048_576, "gemini-pro-agent": 1_048_576, + "gemini-3.1-flash-image": 1_048_576, "claude-sonnet-4-6": 200_000, "claude-opus-4-6-thinking": 1_000_000, "gpt-oss-120b-medium": 131_072, diff --git a/src/server/images.ts b/src/server/images.ts index 6dc2c0aa0..73d3f2f8d 100644 --- a/src/server/images.ts +++ b/src/server/images.ts @@ -27,6 +27,8 @@ import { readJsonRequestBody } from "./request-decompress"; import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors"; import type { RequestLogContext } from "./request-log"; import { codexLogAccountId, decodeRequestErrorResponse } from "./responses"; +import { getValidAccessToken, getOAuthCredentialProjectId } from "../oauth/index"; +import { safeAntigravityHttpErrorMessage } from "../adapters/google-errors"; export type ImagesEndpoint = "generations" | "edits"; @@ -40,6 +42,103 @@ const IMAGES_UPSTREAM_TIMEOUT_MS = 300_000; */ const IMAGES_RESPONSE_MAX_BYTES = 100 * 1024 * 1024; +const CCA_IMAGE_MODEL = "gemini-3.1-flash-image"; + +async function tryCcaImageGeneration( + body: unknown, + config: OcxConfig, + logCtx: RequestLogContext, + signal: AbortSignal, + endpoint: ImagesEndpoint, +): Promise { + if (endpoint !== "generations") return undefined; + const provider = config.providers?.["google-antigravity"]; + if (!provider || provider.disabled) return undefined; + + let token: string; + try { + token = await getValidAccessToken("google-antigravity"); + } catch { + return undefined; + } + const project = getOAuthCredentialProjectId("google-antigravity"); + if (!project) return undefined; + + const prompt = (body as { prompt?: unknown })?.prompt; + if (typeof prompt !== "string" || !prompt) return undefined; + + logCtx.provider = "google-antigravity"; + logCtx.model = CCA_IMAGE_MODEL; + + const baseUrl = provider.baseUrl?.trim() || "https://daily-cloudcode-pa.googleapis.com"; + const envelope = { + model: CCA_IMAGE_MODEL, + userAgent: "antigravity", + requestType: "agent", + project, + requestId: `agent-${crypto.randomUUID()}`, + request: { + contents: [{ role: "user", parts: [{ text: prompt }] }], + generationConfig: { responseModalities: ["TEXT", "IMAGE"] }, + sessionId: `ocx-img-${crypto.randomUUID().slice(0, 8)}`, + }, + }; + + const timeoutMs = config.images?.timeoutMs ?? IMAGES_UPSTREAM_TIMEOUT_MS; + const linkedSignal = signalWithTimeout(timeoutMs, signal); + let upstream: Response; + try { + upstream = await fetch(`${baseUrl}/v1internal:generateContent`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}`, + "User-Agent": "opencodex-images/1.0", + }, + body: JSON.stringify(envelope), + signal: linkedSignal.signal, + }); + } catch (err) { + if (signal.aborted) return formatErrorResponse(499, "client_closed_request", "CCA image request canceled by client"); + if (err instanceof Error && err.name === "TimeoutError") { + return formatErrorResponse(504, "upstream_error", "CCA image generation timed out"); + } + return formatErrorResponse(502, "upstream_error", `CCA image generation failed: ${err instanceof Error ? err.message : String(err)}`); + } finally { + linkedSignal.cleanup(); + } + + const payload = await upstream.arrayBuffer(); + if (payload.byteLength > IMAGES_RESPONSE_MAX_BYTES) { + return formatErrorResponse(502, "upstream_error", `CCA image response too large (${payload.byteLength} bytes)`); + } + + if (!upstream.ok) { + const text = new TextDecoder().decode(payload); + return formatErrorResponse(502, "upstream_error", safeAntigravityHttpErrorMessage(upstream.status, text)); + } + + let json: Record; + try { + json = JSON.parse(new TextDecoder().decode(payload)) as Record; + } catch { + return formatErrorResponse(502, "upstream_error", "CCA image response was not valid JSON"); + } + const resp = (json.response ?? json) as { candidates?: { content?: { parts?: { inlineData?: { mimeType?: string; data?: string }; text?: string }[] } }[] }; + const parts = resp.candidates?.[0]?.content?.parts ?? []; + const images: { b64_json: string }[] = []; + for (const part of parts) { + if (part.inlineData?.data) images.push({ b64_json: part.inlineData.data }); + } + if (images.length === 0) { + return formatErrorResponse(502, "upstream_error", "CCA image model returned no image data"); + } + return new Response(JSON.stringify({ created: Math.floor(Date.now() / 1000), data: images }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + export async function handleImages( req: Request, config: OcxConfig, @@ -62,14 +161,16 @@ export async function handleImages( const candidates = selectOpenAiImagesProvider(config); if (candidates.forwardCandidates.length === 0 && !candidates.keyed) { + const ccaResponse = await tryCcaImageGeneration(body, config, logCtx, req.signal, endpoint); + if (ccaResponse) return ccaResponse; // 400, not 5xx: codex retries every 5xx up to 5 total attempts, and this is a permanent // configuration state that must surface on the first attempt. return formatErrorResponse( 400, "invalid_request_error", - "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider), " - + "but none is configured in opencodex. Routed providers cannot serve /v1/images/* — " - + "add an OpenAI provider or disable the tool with `codex features disable image_generation`.", + "Built-in image generation needs an OpenAI upstream (ChatGPT login or an OpenAI API-key provider) " + + "or a logged-in Google Antigravity (Cloud Code Assist) provider, " + + "but none is configured in opencodex. Add a provider or disable the tool with `codex features disable image_generation`.", ); } diff --git a/tests/google-antigravity-wire.test.ts b/tests/google-antigravity-wire.test.ts index 65ea30564..148e40650 100644 --- a/tests/google-antigravity-wire.test.ts +++ b/tests/google-antigravity-wire.test.ts @@ -72,6 +72,7 @@ describe("antigravity CCA envelope", () => { expect(ANTIGRAVITY_MODELS).toEqual([ "gemini-3.6-flash", "gemini-3.1-pro", + "gemini-3.1-flash-image", "claude-sonnet-4-6", "claude-opus-4-6-thinking", "gpt-oss-120b-medium", diff --git a/tests/images/gemini-inline.test.ts b/tests/images/gemini-inline.test.ts new file mode 100644 index 000000000..0236e2b59 --- /dev/null +++ b/tests/images/gemini-inline.test.ts @@ -0,0 +1,206 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { isAbsolute, join } from "node:path"; +import { createImageBudget, materializeInlineImage } from "../../src/images/artifacts"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import type { AdapterEvent, OcxProviderConfig } from "../../src/types"; + +let tempHome: string; +let artifactsDir: string; + +beforeAll(() => { + tempHome = mkdtempSync(join(tmpdir(), "ocx-test-")); + process.env.OPENCODEX_HOME = tempHome; + artifactsDir = join(tempHome, "artifacts"); +}); + +afterAll(() => { + delete process.env.OPENCODEX_HOME; + rmSync(tempHome, { recursive: true, force: true }); +}); + +// 1x1 red PNG pixel in base64 +const TINY_PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + +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" } }); +} + +function jsonResponse(obj: unknown): Response { + return new Response(JSON.stringify(obj), { status: 200, headers: { "content-type": "application/json" } }); +} + +async function collectStream(provider: OcxProviderConfig, chunks: unknown[]): Promise { + const adapter = createGoogleAdapter(provider); + const events: AdapterEvent[] = []; + for await (const ev of adapter.parseStream(sseResponse(chunks))) events.push(ev); + return events; +} + +const aiStudioProvider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" } as OcxProviderConfig; + +describe("materializeInlineImage", () => { + test("writes a file and returns an absolute path", async () => { + const result = await materializeInlineImage("image/png", TINY_PNG); + expect(isAbsolute(result)).toBe(true); + expect(existsSync(result)).toBe(true); + const buf = readFileSync(result); + expect(buf.length).toBeGreaterThan(0); + }); + + test("maps mimeType to correct extension", async () => { + expect((await materializeInlineImage("image/png", TINY_PNG)).endsWith(".png")).toBe(true); + expect((await materializeInlineImage("image/jpeg", TINY_PNG)).endsWith(".jpg")).toBe(true); + expect((await materializeInlineImage("image/webp", TINY_PNG)).endsWith(".webp")).toBe(true); + expect((await materializeInlineImage("image/gif", TINY_PNG)).endsWith(".gif")).toBe(true); + expect((await materializeInlineImage("image/bmp", TINY_PNG)).endsWith(".png")).toBe(true); + }); + + test("creates the artifacts directory if missing", async () => { + rmSync(artifactsDir, { recursive: true, force: true }); + expect(existsSync(artifactsDir)).toBe(false); + const result = await materializeInlineImage("image/png", TINY_PNG); + expect(existsSync(artifactsDir)).toBe(true); + expect(existsSync(result)).toBe(true); + }); + + test("produces unique filenames for same-millisecond calls", async () => { + const a = await materializeInlineImage("image/png", TINY_PNG); + const b = await materializeInlineImage("image/png", TINY_PNG); + expect(a).not.toBe(b); + expect(existsSync(a)).toBe(true); + expect(existsSync(b)).toBe(true); + }); + + test("throws on empty base64 data", async () => { + await expect(materializeInlineImage("image/png", "")).rejects.toThrow("empty"); + }); + + test("enforces per-response budget", async () => { + const budget = createImageBudget(); + budget.spent = 100 * 1024 * 1024; // already at cap + await expect(materializeInlineImage("image/png", TINY_PNG, budget)).rejects.toThrow("per-response"); + }); +}); + +describe("google adapter — inline image streaming", () => { + test("yields markdown text_delta when a chunk contains inlineData", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + expect(textEvents[0].text).toMatch(/^\n!\[image\]\(.+\.png\)\n$/); + expect(events.some(e => e.type === "done")).toBe(true); + }); + + test("behaves unchanged when no inlineData is present (regression)", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ text: "hello world" }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 3, candidatesTokenCount: 2 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + expect(textEvents[0].text).toBe("hello world"); + const done = events.find(e => e.type === "done") as Extract; + expect(done.usage?.inputTokens).toBe(3); + expect(done.usage?.outputTokens).toBe(2); + }); +}); + +describe("google adapter — inline image non-streaming", () => { + test("parseResponse returns markdown text for inlineData parts", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ text: "Here is a cat:" }, { inlineData: { mimeType: "image/jpeg", data: TINY_PNG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 10 }, + })); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(2); + expect(textEvents[0].text).toBe("Here is a cat:"); + expect(textEvents[1].text).toMatch(/^\n!\[image\]\(.+\.jpg\)\n$/); + }); +}); + +describe("responseModalities gating", () => { + test("image-capable model gets responseModalities in compiled wire body", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "gemini-3.1-flash-image", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig.responseModalities).toEqual(["TEXT", "IMAGE"]); + }); + + test("non-image model does NOT get responseModalities", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + const req = await adapter.buildRequest({ + context: { messages: [], tools: [] }, + options: {}, + modelId: "gemini-3.6-flash", + stream: false, + } as never); + const body = JSON.parse(req.body); + expect(body.generationConfig).toBeUndefined(); + }); +}); + +describe("markdown path escaping with special characters", () => { + let specialHome: string; + let savedHome: string | undefined; + + beforeAll(() => { + savedHome = process.env.OPENCODEX_HOME; + specialHome = mkdtempSync(join(tmpdir(), "ocx test (dir) ")); + process.env.OPENCODEX_HOME = specialHome; + }); + + afterAll(() => { + if (savedHome !== undefined) process.env.OPENCODEX_HOME = savedHome; + else delete process.env.OPENCODEX_HOME; + rmSync(specialHome, { recursive: true, force: true }); + }); + + test("streaming: escapes spaces and parentheses in emitted markdown path", async () => { + const events = await collectStream(aiStudioProvider, [ + { candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: TINY_PNG } }] } }] }, + { candidates: [{ finishReason: "STOP" }], usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 } }, + ]); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const mdPath = match![1]; + expect(mdPath).toContain("ocx\\ test\\ \\(dir\\)\\ "); + expect(mdPath).not.toMatch(/(? { + const adapter = createGoogleAdapter(aiStudioProvider); + const events = await adapter.parseResponse(jsonResponse({ + candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/webp", data: TINY_PNG } }] }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 3 }, + })); + + const textEvents = events.filter(e => e.type === "text_delta") as Extract[]; + expect(textEvents.length).toBe(1); + const match = textEvents[0].text.match(/^\n!\[image\]\((.+)\)\n$/); + expect(match).not.toBeNull(); + const mdPath = match![1]; + expect(mdPath).toContain("ocx\\ test\\ \\(dir\\)\\ "); + const unescaped = mdPath.replace(/\\([() ])/g, "$1"); + expect(existsSync(unescaped)).toBe(true); + }); +}); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index 14d840e8f..163f4f0f2 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -601,7 +601,8 @@ describe("provider registry parity", () => { expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-sonnet-4-6"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("claude-opus-4-6-thinking"); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gpt-oss-120b-medium"); - expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(5); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toContain("gemini-3.1-flash-image"); + expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.models).toHaveLength(6); // Effort ladders on collapsed base models. expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.6-flash"]).toEqual(["low", "medium", "high"]); expect(OAUTH_PROVIDERS["google-antigravity"].providerConfig.modelReasoningEfforts?.["gemini-3.1-pro"]).toEqual(["low", "high"]);