mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
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:
committed by
decolua
co-authored by
Cursor
parent
b4d2754d32
commit
5306bd904e
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user