feat(antigravity): native image generation support

Add image generation for Antigravity provider via gemini-3.1-flash-image
and gemini-3-pro-image, exposed through Text to Image UI and
/v1/images/generations.

- registry: serviceKinds ['llm','image'] + image model entries
- executor: image model detection + image_gen request envelope
- chatCore: force stream=false for image models (generateContent)
- nonStreamingHandler: parse inlineData -> markdown image
- imageGenerationCore: useExecutor fast-path for executor delegation
- imageProviders/antigravity: image adapter with image input support
- usage/google: image models in quota whitelist

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nautilaceae
2026-06-21 17:50:21 +07:00
committed by decolua
parent b4d2754d32
commit 5306bd904e
8 changed files with 220 additions and 1 deletions

View File

@@ -29,6 +29,40 @@ const ANTIGRAVITY_REQUEST_BLACKLIST = [
"thinkingConfig",
];
// Image generation model name patterns
const IMAGE_MODEL_PATTERNS = [
/image/i,
/imagen/i,
/image-generation/i,
];
// Detect if a model is an image generation model
function isImageModel(model) {
if (!model) return false;
return IMAGE_MODEL_PATTERNS.some(p => p.test(model));
}
// Parse aspect ratio / resolution from model name suffixes
// e.g. "gemini-3.1-flash-image-16x9" -> { aspectRatio: "16:9" }
// e.g. "gemini-3.1-flash-image-1024x768" -> { aspectRatio: "4:3" }
function parseImageConfig(model) {
const config = { aspectRatio: "1:1" };
const resMatch = model.match(/(\d+)x(\d+)$/);
if (resMatch) {
const w = parseInt(resMatch[1]);
const h = parseInt(resMatch[2]);
if (w <= 16 && h <= 16) {
config.aspectRatio = `${w}:${h}`;
} else {
// Resolution like 1024x768 — derive aspect ratio
const gcd = (a, b) => b ? gcd(b, a % b) : a;
const d = gcd(w, h);
config.aspectRatio = `${w/d}:${h/d}`;
}
}
return config;
}
export class AntigravityExecutor extends BaseExecutor {
constructor() {
super("antigravity", PROVIDERS.antigravity);
@@ -37,7 +71,9 @@ export class AntigravityExecutor extends BaseExecutor {
buildUrl(model, stream, urlIndex = 0) {
const baseUrls = this.getBaseUrls();
const baseUrl = baseUrls[urlIndex] || baseUrls[0];
const action = stream ? "streamGenerateContent?alt=sse" : "generateContent";
// Image generation MUST use non-streaming generateContent
const forceNonStream = isImageModel(model);
const action = (stream && !forceNonStream) ? "streamGenerateContent?alt=sse" : "generateContent";
return `${baseUrl}/v1internal:${action}`;
}
@@ -58,6 +94,53 @@ export class AntigravityExecutor extends BaseExecutor {
transformRequest(model, body, stream, credentials) {
const projectId = credentials?.projectId || this.generateProjectId();
// ─── Image generation: completely different request structure ───
if (isImageModel(model)) {
const imageConfig = parseImageConfig(model);
// Strip model name suffixes for the actual API model name
const cleanModel = model.replace(/-(\d+)x(\d+)$/, "");
// Build simplified contents — text-only, merge all user messages
const contents = [];
const srcContents = body.request?.contents || body.contents || [];
for (const c of srcContents) {
const textParts = (c.parts || []).filter(p => p.text !== undefined).map(p => ({ text: p.text }));
if (textParts.length > 0) {
contents.push({ role: c.role || "user", parts: textParts });
}
}
const sessionId = resolveSessionId({
headers: credentials?.rawHeaders,
body,
connectionId: credentials?.email || credentials?.connectionId,
scope: "antigravity",
});
this._lastSessionId = sessionId;
return {
project: projectId,
model: cleanModel,
userAgent: "antigravity",
requestType: "image_gen",
requestId: `agent-${crypto.randomUUID()}`,
request: {
contents,
generationConfig: {
temperature: 1.0,
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
imageConfig,
},
sessionId,
// No tools, no systemInstruction, no safetySettings for image gen
},
};
}
// ─── Standard (non-image) request ───
// Fix contents for Claude models via Antigravity
const contents = body.request?.contents?.map(c => {
let role = c.role;

View File

@@ -71,6 +71,13 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
let stream = providerRequiresStreaming ? true : (body.stream !== false);
// Image generation models require non-streaming (Google v1internal:generateContent)
const modelType = getModelType(alias, model);
const isImageGenModel = modelType === "imageGen" || /image|imagen|image-generation/i.test(model);
if (isImageGenModel && (provider === "antigravity" || provider === "gemini-cli")) {
stream = false;
}
// DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE.
// Non-interactive mode (-p flag) sends without stream and can't parse SSE.
// Only force non-streaming when client didn't explicitly request it.

View File

@@ -37,6 +37,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) }
});
}
// Handle inline image data (from image generation models)
const inlineData = part.inlineData || part.inline_data;
if (inlineData?.data) {
const mimeType = inlineData.mimeType || inlineData.mime_type || "image/png";
textContent += `\n![image](data:${mimeType};base64,${inlineData.data})\n`;
}
}
}

View File

@@ -50,6 +50,47 @@ export async function handleImageGenerationCore({
);
}
// Executor-delegating adapters: skip manual URL/headers/body, use the proven executor flow
if (adapter.useExecutor && adapter.executeViaExecutor) {
try {
log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..." (executor)`);
const responseBody = await adapter.executeViaExecutor(model, body, credentials, log);
if (onRequestSuccess) await onRequestSuccess();
const normalized = adapter.normalize(responseBody, body.prompt);
const finalBody = (normalized.created && Array.isArray(normalized.data)) ? normalized : responseBody;
if (binaryOutput) {
const first = finalBody.data?.[0];
let b64 = first?.b64_json;
if (!b64 && first?.url) {
try { b64 = await urlToBase64(first.url); } catch {}
}
if (b64) {
const buf = Buffer.from(b64, "base64");
const fmt = (body.output_format || "png").toLowerCase();
const mime = fmt === "jpeg" || fmt === "jpg" ? "image/jpeg" : fmt === "webp" ? "image/webp" : "image/png";
return {
success: true,
response: new Response(buf, {
headers: { "Content-Type": mime, "Content-Disposition": `inline; filename="image.${fmt === "jpeg" ? "jpg" : fmt}"`, "Access-Control-Allow-Origin": "*" },
}),
};
}
}
return {
success: true,
response: new Response(JSON.stringify(finalBody), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
}),
};
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
log?.debug?.("IMAGE", `Executor error: ${errMsg}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
}
let url;
let headers;
let requestBody;

View File

@@ -0,0 +1,73 @@
// Antigravity image adapter - delegates to the executor for correct request
// envelope (project, model, requestType, sessionId) and auth headers.
import { nowSec } from "./_base.js";
import { getExecutor } from "../../executors/index.js";
// Convert image input (data URI or raw base64) to Gemini inlineData part
function resolveImageInput(input) {
if (!input || typeof input !== "string") return null;
// data:image/png;base64,... format
const dataUriMatch = input.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (dataUriMatch) {
return { inlineData: { mimeType: dataUriMatch[1], data: dataUriMatch[2] } };
}
// Raw base64 string (assume PNG)
if (/^[A-Za-z0-9+/]/.test(input) && input.length > 100 && !input.startsWith("http")) {
return { inlineData: { mimeType: "image/png", data: input } };
}
return null;
}
export default {
// Delegate to executor instead of building URL/headers/body manually
useExecutor: true,
// Stubs - required by imageGenerationCore interface but unused with useExecutor
buildUrl: () => "",
buildHeaders: () => ({}),
buildBody: () => ({}),
async executeViaExecutor(model, body, credentials, log) {
const executor = getExecutor("antigravity");
if (!executor) throw new Error("Antigravity executor not found");
// Build parts: text prompt + optional input image for editing
const parts = [{ text: body.prompt }];
const imageInput = body.image || (Array.isArray(body.images) && body.images[0]);
if (imageInput) {
const inlineData = resolveImageInput(imageInput);
if (inlineData) parts.unshift(inlineData);
}
const chatBody = {
contents: [{ role: "user", parts }],
};
const result = await executor.execute({
model,
body: chatBody,
stream: false,
credentials,
log,
});
if (!result.response.ok) {
const text = await result.response.text();
throw new Error(text || `HTTP ${result.response.status}`);
}
return result.response.json();
},
normalize: (responseBody, prompt) => {
const candidates = responseBody.candidates || responseBody.response?.candidates || [];
const parts = candidates[0]?.content?.parts || [];
const images = parts.filter((p) => p.inlineData?.data).map((p) => ({
b64_json: p.inlineData.data,
}));
return {
created: nowSec(),
data: images.length > 0 ? images : [{ b64_json: "", revised_prompt: prompt }],
};
},
};

View File

@@ -11,6 +11,7 @@ import stabilityAi from "./stabilityAi.js";
import blackForestLabs from "./blackForestLabs.js";
import runwayml from "./runwayml.js";
import cloudflareAi from "./cloudflareAi.js";
import antigravity from "./antigravity.js";
const ADAPTERS = {
openai: createOpenAIAdapter("openai"),
@@ -25,6 +26,7 @@ const ADAPTERS = {
comfyui,
huggingface,
nanobanana,
antigravity,
"fal-ai": falAi,
"stability-ai": stabilityAi,
"black-forest-labs": blackForestLabs,

View File

@@ -18,6 +18,7 @@ export default {
deprecationNotice: "RISK_NOTICE",
},
category: "oauth",
serviceKinds: ["llm", "image"],
transport: {
baseUrls: [
"https://daily-cloudcode-pa.googleapis.com",
@@ -53,6 +54,9 @@ export default {
{ id: "claude-opus-4-6-thinking", name: "Claude Opus 4.6 (Thinking)" },
{ id: "gpt-oss-120b-medium", name: "GPT-OSS 120B (Medium)" },
{ id: "gemini-3-flash", name: "Gemini 3 Flash", thinking: false },
// Image generation models
{ id: "gemini-3.1-flash-image", name: "Gemini 3.1 Flash (Image)", imageGen: true, capabilities: ["textToImage"] },
{ id: "gemini-3-pro-image", name: "Gemini 3 Pro (Image)", imageGen: true, capabilities: ["textToImage"] },
],
oauth: {
authorizeUrl: "https://accounts.google.com/o/oauth2/v2/auth",

View File

@@ -171,6 +171,9 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
'claude-opus-4-6-thinking',
'gpt-oss-120b-medium',
'gemini-3-flash',
// Image generation models
'gemini-3.1-flash-image',
'gemini-3-pro-image',
];
for (const [modelKey, info] of Object.entries(data.models)) {