fix: route grsai gpt image provider
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-23 14:33:20 +08:00
parent 85c0f2c096
commit e58d37015e
11 changed files with 77 additions and 36 deletions

View File

@ -455,6 +455,7 @@ type AiProvider = {
| "custom"
| "local"
| "gemini"
| "grsai-gpt-image"
| "huanke-gemini"
| "qianwen-video"
| "doubao-video"
@ -1186,6 +1187,7 @@ const PROVIDER_TYPES = [
{ value: "ruxa-image", label: "RUXA生图" },
{ value: "fal-image", label: "FAL 生图模型" },
{ value: "openai-image", label: "OpenAI 图片兼容" },
{ value: "grsai-gpt-image", label: "Grsai GPT Image" },
{ value: "midjourney", label: "Midjourney" },
{ value: "tripo-3d", label: "Tripo3D 模型生成" },
{ value: "worldlabs-3d", label: "World Labs 3D 世界生成" },
@ -1215,6 +1217,7 @@ const PROVIDER_DEFAULT_BASE_URL: Record<string, string> = {
"ruxa-image": "https://api.ruxa.ai",
"fal-image": "https://fal.run",
"openai-image": "https://api.openai.com/v1",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
midjourney: "https://your-midjourney-api.example.com",
"fal-video": "https://fal.run",
"tripo-3d": "https://api.tripo3d.ai/v2/openapi",
@ -3008,6 +3011,7 @@ export function AdminSettingsClient() {
| "custom"
| "local"
| "gemini"
| "grsai-gpt-image"
| "huanke-gemini"
| "qianwen-video"
| "doubao-video"

View File

@ -15,6 +15,7 @@ const providerTypes = [
"custom",
"local",
"gemini",
"grsai-gpt-image",
"huanke-gemini",
"qianwen-video",
"doubao-video",
@ -69,6 +70,7 @@ const WORLDLABS_3D_BASE_URL = "https://api.worldlabs.ai";
const KITTYCAD_CAD_BASE_URL = "https://api.zoo.dev";
const RUNWAY_BASE_URL = "https://api.dev.runwayml.com";
const KREA_BASE_URL = "https://api.krea.ai";
const GRSAI_GPT_IMAGE_BASE_URL = "https://grsai.dakka.com.cn";
const RUNWAY_API_KEY_PATTERN = /^key_[0-9a-f]{128}$/;
function normalizeProviderTypeForStorage(
@ -222,6 +224,8 @@ export async function POST(request: Request) {
? data.baseUrl || RUNWAY_BASE_URL
: data.type === "krea"
? data.baseUrl || KREA_BASE_URL
: data.type === "grsai-gpt-image"
? data.baseUrl || GRSAI_GPT_IMAGE_BASE_URL
: data.baseUrl,
updatedBy: session.user.id,
});

View File

@ -1132,7 +1132,12 @@ export function Chat({
let modelType: PendingGeneration["modelType"] = "other";
// 🆕 gemini-image 和 huanke-gemini 都视为 gemini 类型
if (model?.providerType === "gemini" || model?.providerType === "gemini-image" || model?.providerType === "huanke-gemini") modelType = "gemini";
if (
model?.providerType === "gemini" ||
model?.providerType === "gemini-image" ||
model?.providerType === "grsai-gpt-image" ||
model?.providerType === "huanke-gemini"
) modelType = "gemini";
else if (model?.providerType === "volcano-vision") modelType = "volcano-vision";
else if (
isVideoModelDescriptor({

View File

@ -108,9 +108,14 @@ function PureMessages({
);
// 判断当前选中的模型类型
// 🆕 包含 gemini 和 huanke-gemini 两种 Gemini 图片生成 provider
// 🆕 包含 Gemini 和 Grsai GPT Image 图片生成 provider
const isGeminiModel = modelsData?.models?.some(
(m: DynamicModel) => m.id === selectedModelId && (m.providerType === "gemini" || m.providerType === "gemini-image" || m.providerType === "huanke-gemini")
(m: DynamicModel) => m.id === selectedModelId && (
m.providerType === "gemini" ||
m.providerType === "gemini-image" ||
m.providerType === "grsai-gpt-image" ||
m.providerType === "huanke-gemini"
)
) ?? false;
const isQianwenVideoModel = modelsData?.models?.some(

View File

@ -128,6 +128,15 @@ function getOrCreateProvider(provider: Pick<AiProvider, "id" | "type" | "baseUrl
return geminiProvider as any;
}
case "grsai-gpt-image": {
const grsaiGptImageProvider = createGeminiImage({
baseURL: resolvedBaseUrl,
apiKey: provider.apiKey,
});
console.log(`[Provider] Created Grsai GPT Image provider: ${resolvedBaseUrl}`);
return grsaiGptImageProvider as any;
}
case "huanke-gemini": {
// 环科 Gemini 原生格式 API
// 使用 /v1beta/models/{model}:generateContent 端点
@ -340,13 +349,14 @@ export async function getDynamicModel(
// customProvider (gemini, local) 使用 .languageModel()
// OpenAI-compatible 使用 .chat()
let baseModel;
if (provider.type === "gemini") {
// Gemini 图片生成使用 chat() 方法(与 local-qwen 相同的调用方式)
console.log("[getDynamicModel] Getting Gemini chat model..."); // 调试日志
if (provider.type === "gemini" || provider.type === "grsai-gpt-image") {
const imageProviderLabel = provider.type === "grsai-gpt-image" ? "Grsai GPT Image" : "Gemini image";
// 图片生成 provider 使用 chat() 方法(与 local-qwen 相同的调用方式)
console.log(`[getDynamicModel] Getting ${imageProviderLabel} chat model...`); // 调试日志
baseModel = providerInstance.chat(resolvedModelId);
console.log("[getDynamicModel] Gemini model retrieved:", baseModel ? "success" : "failed"); // 调试日志
console.log("[getDynamicModel] Gemini model type:", typeof baseModel); // 调试
console.log("[getDynamicModel] Gemini model keys:", baseModel ? Object.keys(baseModel).join(', ') : 'N/A'); // 调试
console.log(`[getDynamicModel] ${imageProviderLabel} model retrieved:`, baseModel ? "success" : "failed"); // 调试日志
console.log(`[getDynamicModel] ${imageProviderLabel} model type:`, typeof baseModel); // 调试
console.log(`[getDynamicModel] ${imageProviderLabel} model keys:`, baseModel ? Object.keys(baseModel).join(', ') : 'N/A'); // 调试
} else if (provider.type === "local") {
// 本地模型使用 chat() 方法
console.log("[getDynamicModel] Getting local chat model..."); // 调试日志

View File

@ -3,6 +3,7 @@ import type { AiProvider } from "@/lib/db/schema";
const PROVIDER_DEFAULT_BASE_URL: Partial<Record<AiProvider["type"], string>> = {
alibaba: "https://dashscope.aliyuncs.com/compatible-mode/v1",
openai: "https://api.openai.com/v1",
"grsai-gpt-image": "https://grsai.dakka.com.cn",
"huanke-gemini": "https://api.huandutech.com/v1",
"qianwen-video": "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation",
"doubao-video": "https://ark.cn-beijing.volces.com/api/v3",

View File

@ -418,8 +418,12 @@ async function sendGeminiRequest(
imageSize?: string;
}
): Promise<GeminiResponse> {
// 使用 Nano Banana 原生端点(支持完整的图片生成参数)
const url = `${baseURL}/v1/draw/nano-banana`;
const isGptImageModel = /^gpt-image/i.test(modelId);
const apiLogPrefix = isGptImageModel ? "[Grsai GPT Image]" : "[Gemini Image]";
// GPT Image 和 Nano Banana 在 Grsai 上是两套接口,不能混用同一个 endpoint。
const url = isGptImageModel
? `${baseURL}/v1/draw/completions`
: `${baseURL}/v1/draw/nano-banana`;
// 提取最后一条消息的文本作为prompt
const lastMessage = messages[messages.length - 1];
@ -453,20 +457,20 @@ async function sendGeminiRequest(
// 🆕 处理参考图:上传 Base64 到 S3
const processedUrls: string[] = [];
if (urls.length > 0) {
console.log(`[Gemini Image] Processing ${urls.length} reference images...`);
console.log(`${apiLogPrefix} Processing ${urls.length} reference images...`);
for (const url of urls) {
const s3Url = await ensureImageUrl(url);
processedUrls.push(s3Url);
}
}
console.log("[Gemini Image] Sending request to:", url);
console.log("[Gemini Image] Using model:", modelId);
console.log("[Gemini Image] Prompt:", prompt.substring(0, 100));
console.log("[Gemini Image] Reference image count:", urls.length);
console.log("[Gemini Image] Image params:", imageParams);
console.log(`${apiLogPrefix} Sending request to:`, url);
console.log(`${apiLogPrefix} Using model:`, modelId);
console.log(`${apiLogPrefix} Prompt:`, prompt.substring(0, 100));
console.log(`${apiLogPrefix} Reference image count:`, urls.length);
console.log(`${apiLogPrefix} Image params:`, imageParams);
// 使用 Nano Banana 原生格式
// 使用对应模型的原生格式
const requestBody: any = {
model: modelId,
prompt: prompt,
@ -477,19 +481,24 @@ async function sendGeminiRequest(
requestBody.urls = processedUrls;
}
// 添加 Nano Banana 特定参数
if (imageParams?.aspectRatio) {
requestBody.aspectRatio = imageParams.aspectRatio;
}
if (imageParams?.imageSize) {
requestBody.imageSize = imageParams.imageSize;
if (isGptImageModel) {
requestBody.size = imageParams?.aspectRatio || "auto";
requestBody.shutProgress = false;
} else {
// 添加 Nano Banana 特定参数
if (imageParams?.aspectRatio) {
requestBody.aspectRatio = imageParams.aspectRatio;
}
if (imageParams?.imageSize) {
requestBody.imageSize = imageParams.imageSize;
}
}
console.log("[Gemini Image] Request body (truncated):", JSON.stringify(requestBody).substring(0, 500) + "...");
console.log(`${apiLogPrefix} Request body (truncated):`, JSON.stringify(requestBody).substring(0, 500) + "...");
// 根据分辨率动态设置超时时间
const timeout = imageParams?.imageSize === "4K" ? 300000 : 120000; // 4K: 5分钟, 其他: 2分钟
console.log(`[Gemini Image] Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);
console.log(`${apiLogPrefix} Timeout set to ${timeout/1000} seconds for ${imageParams?.imageSize || "default"}`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
@ -509,14 +518,14 @@ async function sendGeminiRequest(
if (!response.ok) {
const errorText = await response.text();
console.error("[Gemini Image] Request failed:", response.status, errorText);
console.error(`${apiLogPrefix} Request failed:`, response.status, errorText);
throw new Error(
`Gemini API error: ${response.status} ${response.statusText}`
`${isGptImageModel ? "Grsai GPT Image" : "Gemini"} API error: ${response.status} ${response.statusText}`
);
}
// 处理流式响应(SSE格式)
console.log("[Gemini Image] Reading stream response...");
console.log(`${apiLogPrefix} Reading stream response...`);
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = "";

View File

@ -41,6 +41,7 @@ export interface CanvasGenerateImageResult {
const SUPPORTED_CANVAS_PROVIDER_TYPES = new Set([
"gemini",
"grsai-gpt-image",
"alibaba-image",
"bfl-image",
"ruxa-image",
@ -92,6 +93,7 @@ function getProviderBatchLimit(providerType: string) {
case "krea":
return 4;
case "gemini":
case "grsai-gpt-image":
return 2;
case "alibaba-image":
case "fal-image":
@ -173,7 +175,7 @@ export async function generateCanvasImage(
}
if (!SUPPORTED_CANVAS_PROVIDER_TYPES.has(fallbackProvider.type)) {
throw new Error(
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/阿里/BFL/RUXA/FAL/OpenAI/Runway/Krea 图片模型。"
"当前图片模型类型暂不支持画布生成,请在后台选择 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/Runway/Krea 图片模型。"
);
}
if (fallbackModel.model.id !== selectedModel.model.id) {
@ -252,7 +254,7 @@ export async function generateCanvasImage(
| ((batchImageCount: number) => Promise<string>)
| null = null;
if (provider.type === "gemini") {
if (provider.type === "gemini" || provider.type === "grsai-gpt-image") {
const geminiProvider = createGeminiImage({
apiKey: provider.apiKey,
baseURL: provider.baseUrl,
@ -392,7 +394,7 @@ export async function generateCanvasImage(
};
} else {
throw new Error(
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/阿里/BFL/RUXA/FAL/OpenAI/Midjourney/Runway/Krea 图片模型`
`不支持的图片生成模型类型: ${provider.type || "unknown"},请改用 Gemini/Grsai GPT Image/阿里/BFL/RUXA/FAL/OpenAI/Midjourney/Runway/Krea 图片模型`
);
}

View File

@ -13,6 +13,7 @@ config({
const providerBaseUrlDefaults = [
{ type: "alibaba", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" },
{ type: "openai", baseUrl: "https://api.openai.com/v1" },
{ type: "grsai-gpt-image", baseUrl: "https://grsai.dakka.com.cn" },
{ type: "huanke-gemini", baseUrl: "https://api.huandutech.com/v1" },
{
type: "qianwen-video",
@ -96,7 +97,7 @@ const runMigrate = async () => {
ADD CONSTRAINT "AiProvider_type_check"
CHECK (type IN (
'alibaba', 'openai', 'custom', 'local', 'gemini',
'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
'grsai-gpt-image', 'huanke-gemini', 'qianwen-video', 'doubao-video', 'ruxa-video', 'volcano-vision', 'volcano-video', 'alibaba-image', 'bfl-image', 'ruxa-image', 'fal-image', 'openai-image', 'midjourney', 'fal-video', 'zhipu', 'tripo-3d', 'worldlabs-3d', 'kittycad-cad', 'runway', 'krea'
));
`);
await db.execute(sql`

View File

@ -3750,7 +3750,7 @@ export async function createAiProvider({
updatedBy,
}: {
name: string;
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
type: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
baseUrl: string;
apiKey: string;
isEnabled?: boolean;
@ -3788,7 +3788,7 @@ export async function updateAiProvider({
}: {
id: string;
name?: string;
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
type?: "alibaba" | "openai" | "custom" | "local" | "gemini" | "grsai-gpt-image" | "huanke-gemini" | "qianwen-video" | "doubao-video" | "ruxa-video" | "volcano-vision" | "volcano-video" | "alibaba-image" | "bfl-image" | "ruxa-image" | "fal-image" | "openai-image" | "midjourney" | "fal-video" | "zhipu" | "tripo-3d" | "worldlabs-3d" | "kittycad-cad" | "runway" | "krea";
baseUrl?: string;
apiKey?: string;
isEnabled?: boolean;

View File

@ -276,7 +276,7 @@ export type ToolConfig = InferSelectModel<typeof toolConfig>;
export const aiProvider = pgTable("AiProvider", {
id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 提供商名称,如 "阿里云通义千问"
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
type: varchar("type", { enum: ["alibaba", "openai", "custom", "local", "gemini", "grsai-gpt-image", "huanke-gemini", "qianwen-video", "doubao-video", "ruxa-video", "volcano-vision", "volcano-video", "alibaba-image", "ruxa-image", "fal-image", "openai-image", "midjourney", "fal-video", "zhipu", "tripo-3d", "worldlabs-3d", "kittycad-cad", "runway", "krea", "bfl-image"] })
.notNull()
.default("custom"), // 提供商类型
baseUrl: text("baseUrl").notNull(), // API 基础 URL