From 5306bd904e34fedf0ead77dc337c75415b7d2d82 Mon Sep 17 00:00:00 2001 From: Nautilaceae Date: Sun, 21 Jun 2026 17:50:21 +0700 Subject: [PATCH] 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 --- open-sse/executors/antigravity.js | 85 ++++++++++++++++++- open-sse/handlers/chatCore.js | 7 ++ .../handlers/chatCore/nonStreamingHandler.js | 6 ++ open-sse/handlers/imageGenerationCore.js | 41 +++++++++ .../handlers/imageProviders/antigravity.js | 73 ++++++++++++++++ open-sse/handlers/imageProviders/index.js | 2 + open-sse/providers/registry/antigravity.js | 4 + open-sse/services/usage/google.js | 3 + 8 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 open-sse/handlers/imageProviders/antigravity.js diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index 2387a523..d409f2aa 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -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; diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index b537fcaf..d93eb652 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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. diff --git a/open-sse/handlers/chatCore/nonStreamingHandler.js b/open-sse/handlers/chatCore/nonStreamingHandler.js index 00040658..d1610058 100644 --- a/open-sse/handlers/chatCore/nonStreamingHandler.js +++ b/open-sse/handlers/chatCore/nonStreamingHandler.js @@ -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`; + } } } diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js index 280b3158..d2d0eba2 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.js @@ -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; diff --git a/open-sse/handlers/imageProviders/antigravity.js b/open-sse/handlers/imageProviders/antigravity.js new file mode 100644 index 00000000..a1f90519 --- /dev/null +++ b/open-sse/handlers/imageProviders/antigravity.js @@ -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 }], + }; + }, +}; \ No newline at end of file diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 95d8e005..520c3d60 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -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, diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 17abd64d..cec4dae3 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -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", diff --git a/open-sse/services/usage/google.js b/open-sse/services/usage/google.js index e3226725..71c53e89 100644 --- a/open-sse/services/usage/google.js +++ b/open-sse/services/usage/google.js @@ -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)) {