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:
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
open-sse/handlers/imageProviders/antigravity.js
Normal file
73
open-sse/handlers/imageProviders/antigravity.js
Normal 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 }],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user