Files
ttstd-infinite-canvas/web/src/services/api/image.ts

1223 lines
59 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from "axios";
import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
import { sanitizeChannelModelCatalogItem, type ChannelModelCatalogItem } from "@/lib/channel-model-catalog";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { createClientId } from "@/lib/client-id";
import { projectDesktopLocalChannelRuntime } from "@/lib/desktop-local-channel";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
import { withOpenAIPromptCacheKey } from "@/lib/openai-prompt-cache";
import { imageSizeRequest, modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
import { buildGeminiImageGenerationConfig, parseGeminiImageDataUrl, type GeminiImageGenerationConfig } from "@/lib/gemini-image";
export type AiTextMessage = {
role: "system" | "user" | "assistant";
content: string | AiTextContentPart[];
};
export type AiTextContentPart = { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } } | { type: "file_url"; file_url: { url: string; name: string; mimeType: string } };
export type ResponseToolCall = {
id: string;
type: "function";
function: { name: string; arguments: string };
thoughtSignature?: string;
};
export type ResponseInputMessage = AiTextMessage | { type: "function_call"; call_id: string; name: string; arguments: string; thoughtSignature?: string } | { role: "tool"; tool_call_id: string; content: string };
export type ResponseFunctionTool = {
type: "function";
function: {
name: string;
description?: string;
parameters: Record<string, unknown>;
strict?: boolean;
};
};
export type ToolResponseResult = {
content: string;
toolCalls: ResponseToolCall[];
reasoning?: string;
};
type ToolChoice = "auto" | "required" | { type: "function"; name: string };
type ResponseMessageContent = AiTextMessage["content"] | string;
type ResponseInputContent = { type: "input_text"; text: string } | { type: "input_image"; image_url: string } | { type: "input_file"; filename: string; file_data?: string; file_url?: string };
type ResponseInputItem = { role: "system" | "user" | "assistant"; content: string | ResponseInputContent[] } | { type: "function_call"; call_id: string; name: string; arguments: string } | { type: "function_call_output"; call_id: string; output: string };
type ResponseApiToolDefinition = {
type: "function";
name: string;
description?: string;
parameters: Record<string, unknown>;
strict?: boolean;
};
type ResponseApiOutputItem = { type?: "message"; content?: Array<{ type?: string; text?: string }> } | { type?: "function_call"; id?: string; call_id?: string; name?: string; arguments?: string };
type ResponseApiPayload = {
id?: string;
output?: ResponseApiOutputItem[];
output_text?: string;
error?: { message?: string };
code?: number;
msg?: string;
};
type ResponseStreamState = { buffer: string; text: string; reasoning: string; payload?: ResponseApiPayload; error?: string };
type ChatCompletionToolCall = { id?: string; type?: "function"; function?: { name?: string; arguments?: string } };
type ChatCompletionPayload = {
choices?: Array<{ message?: { content?: string | null; reasoning_content?: string | null; tool_calls?: ChatCompletionToolCall[] } }>;
error?: { message?: string };
code?: number;
msg?: string;
};
type ChatCompletionStreamToolCall = { id: string; name: string; arguments: string };
type ChatCompletionStreamState = { buffer: string; text: string; reasoning: string; toolCalls: Map<number, ChatCompletionStreamToolCall>; error?: string };
type ImageApiResponse = {
data?: Array<Record<string, unknown>>;
error?: { message?: string };
code?: number;
msg?: string;
};
type GeminiPart = {
text?: string;
thought?: boolean;
inlineData?: { mimeType?: string; data?: string };
inline_data?: { mime_type?: string; mimeType?: string; data?: string };
fileData?: { mimeType?: string; fileUri?: string };
functionCall?: { id?: string; name?: string; args?: Record<string, unknown> };
functionResponse?: { id?: string; name?: string; response?: Record<string, unknown> };
thoughtSignature?: string;
thought_signature?: string;
};
type GeminiContent = { role?: "user" | "model"; parts: GeminiPart[] };
type GeminiPayload = {
candidates?: Array<{ content?: { parts?: GeminiPart[] }; finishReason?: string }>;
models?: Array<{ name?: string }>;
error?: { message?: string };
promptFeedback?: { blockReason?: string };
};
type GeminiStreamState = { buffer: string; text: string; reasoning: string; toolCalls: ResponseToolCall[]; error?: string };
type RequestOptions = { signal?: AbortSignal; promptCacheKey?: string; onReasoning?: (text: string) => void };
const QUALITY_BASE: Record<string, number> = {
low: 1024,
medium: 2048,
high: 2880,
standard: 1024,
hd: 2048,
};
const QUALITY_ALIASES: Record<string, string> = {
"1k": "low",
"2k": "medium",
"4k": "high",
};
const DEFAULT_IMAGE_SHORT_SIDE = 1024;
const IMAGE_SIZE_STEP = 16;
const IMAGE_MIN_PIXELS = 655360;
const IMAGE_MAX_PIXELS = 8294400;
const IMAGE_MAX_EDGE = 3840;
const IMAGE_MAX_RATIO = 3;
const IMAGE_OUTPUT_FORMAT = "png";
const VOLCENGINE_ARK_IMAGE_MAX_PIXELS = 4624220;
function normalizeQuality(quality: string) {
const value = quality.trim().toLowerCase();
const normalized = QUALITY_ALIASES[value] || value;
return QUALITY_BASE[normalized] ? normalized : undefined;
}
/** grok2api / xAI Imagine:画布 quality 映射为 resolution(1k/2k)。 */
function normalizeGrokImageResolution(quality: string | undefined) {
const value = (quality || "").trim().toLowerCase();
if (!value || value === "auto") return undefined;
if (value === "1k" || value === "low" || value === "standard") return "1k";
if (value === "2k" || value === "medium" || value === "hd" || value === "high" || value === "4k") return "2k";
return undefined;
}
/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */
function resolveSize(quality: string | undefined, ratio: string): string {
const parsedRatio = parseImageRatio(ratio);
const basePixels = quality ? QUALITY_BASE[quality] : undefined;
const isLandscape = parsedRatio.width >= parsedRatio.height;
const longRatio = isLandscape ? parsedRatio.width / parsedRatio.height : parsedRatio.height / parsedRatio.width;
let longSide: number;
let shortSide: number;
if (basePixels) {
const targetPixels = basePixels * basePixels;
const longSideRaw = Math.sqrt(targetPixels * longRatio);
longSide = Math.floor(longSideRaw / IMAGE_SIZE_STEP) * IMAGE_SIZE_STEP;
shortSide = Math.round(longSide / longRatio / IMAGE_SIZE_STEP) * IMAGE_SIZE_STEP;
} else {
shortSide = DEFAULT_IMAGE_SHORT_SIDE;
longSide = Math.round((shortSide * longRatio) / IMAGE_SIZE_STEP) * IMAGE_SIZE_STEP;
}
const width = isLandscape ? longSide : shortSide;
const height = isLandscape ? shortSide : longSide;
validateImageSize(width, height);
return `${width}x${height}`;
}
function parseImageRatio(value: string) {
const parts = value.split(":");
if (parts.length !== 2) throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024");
const w = Number(parts[0]);
const h = Number(parts[1]);
if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) throw new Error("图像比例必须是正数,例如 9:16");
if (Math.max(w, h) / Math.min(w, h) > IMAGE_MAX_RATIO) throw new Error("图像宽高比不能超过 3:1,请调整尺寸");
return { width: w, height: h };
}
function parseImageDimensions(value: string) {
const match = value.match(/^(\d+)x(\d+)$/i);
if (!match) return null;
return { width: Number(match[1]), height: Number(match[2]) };
}
function validateImageSize(width: number, height: number) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) throw new Error("图像尺寸必须是正整数,例如 1024x1024");
if (width % IMAGE_SIZE_STEP !== 0 || height % IMAGE_SIZE_STEP !== 0) throw new Error("图像尺寸的宽高必须是 16 的倍数,请调整尺寸");
if (Math.max(width, height) > IMAGE_MAX_EDGE) throw new Error("图像尺寸最长边不能超过 3840px,请调整尺寸");
if (Math.max(width, height) / Math.min(width, height) > IMAGE_MAX_RATIO) throw new Error("图像宽高比不能超过 3:1,请调整尺寸");
const pixels = width * height;
if (pixels < IMAGE_MIN_PIXELS || pixels > IMAGE_MAX_PIXELS) throw new Error("图像总像素需在 655360 到 8294400 之间,请调整尺寸");
}
function resolveRequestSize(quality: string | undefined, size: string) {
const value = size.trim();
if (!value || value.toLowerCase() === "auto") return undefined;
const dimensions = parseImageDimensions(value);
if (dimensions) {
validateImageSize(dimensions.width, dimensions.height);
return `${dimensions.width}x${dimensions.height}`;
}
if (value.includes(":")) return resolveSize(quality, value);
throw new Error("图像尺寸格式不支持,请使用 auto、9:16 或 1024x1024");
}
function resolveAspectRatio(value: string) {
const normalized = value.trim().toLowerCase().replace("×", "x");
if (normalized.includes(":")) return normalized;
const dimensions = parseImageDimensions(normalized);
if (!dimensions) throw new Error("图像比例格式不支持,请使用 3:4 或 1024x1360");
const divisor = dimensionGCD(dimensions.width, dimensions.height);
return `${dimensions.width / divisor}:${dimensions.height / divisor}`;
}
function dimensionGCD(left: number, right: number) {
while (right) [left, right] = [right, left % right];
return Math.max(1, left);
}
function resolveImageRequestSize(profile: ImageCapabilityConfig, quality: string | undefined, size: string) {
const request = imageSizeRequest(profile, size);
if (!request) return undefined;
const value = request.parameter === "size" ? resolveRequestSize(quality, request.value) : resolveAspectRatio(request.value);
return value ? { parameter: request.parameter, value } : undefined;
}
function validateImageCapability(profile: ImageCapabilityConfig, references: ReferenceImage[], mask?: ReferenceImage) {
if (references.length > profile.references.maxImages) throw new Error(`当前图片模型最多支持 ${profile.references.maxImages} 张参考图`);
if (mask && !profile.references.maskSupported) throw new Error("当前图片模型不支持蒙版编辑");
if (profile.references.maxImageBytes > 0 && references.some((image) => (image.bytes || 0) > profile.references.maxImageBytes)) throw new Error("参考图片文件超过当前模型大小限制");
}
function normalizeVolcengineArkImageSize(size: string | undefined) {
if (!size) return undefined;
const dimensions = parseImageDimensions(size);
if (!dimensions || dimensions.width * dimensions.height <= VOLCENGINE_ARK_IMAGE_MAX_PIXELS) return size;
const scale = Math.sqrt(VOLCENGINE_ARK_IMAGE_MAX_PIXELS / (dimensions.width * dimensions.height));
let width = Math.floor((dimensions.width * scale) / 2) * 2;
let height = Math.floor((dimensions.height * scale) / 2) * 2;
while (width > 2 && height > 2 && width * height > VOLCENGINE_ARK_IMAGE_MAX_PIXELS) {
if (width >= height) width -= 2;
else height -= 2;
}
return `${width}x${height}`;
}
function resolveImageDataUrl(item: Record<string, unknown>) {
if (typeof item.b64_json === "string" && item.b64_json) {
return `data:image/png;base64,${item.b64_json}`;
}
if (typeof item.url === "string" && item.url) {
return item.url;
}
return null;
}
function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
const images =
payload.data
?.map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
if (images.length === 0) {
throw new Error("接口没有返回图片");
}
return images;
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isCancel(error)) return "请求已取消";
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || readStatusError(error.response?.status, fallback);
}
if (error instanceof DOMException && error.name === "AbortError") return "请求已取消";
return error instanceof Error ? error.message : fallback;
}
function readStatusError(status: number | undefined, fallback: string) {
if (status === 401 || status === 403) return "鉴权失败,请检查 API Key、套餐权限或模型权限";
if (status === 429) return "请求被限流或额度不足,请稍后重试";
return status ? `${fallback}:${status}` : fallback;
}
function withSystemPrompt(config: AiConfig, prompt: string) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
}
function aiApiUrl(config: AiConfig, path: string) {
return buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
return {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
...(isSystemProxyBaseUrl(config.baseUrl) ? { "X-Canvas-Scene": "image", "X-Idempotency-Key": createClientId() } : {}),
};
}
async function postVolcengineArkImage(config: ReturnType<typeof resolveModelRequestConfig>, payload: Record<string, unknown>, options?: RequestOptions) {
const upstreamUrl = aiApiUrl(config, "/images/generations");
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
return (
await axios.post<ImageApiResponse>(request.url, payload, {
headers: request.headers,
withCredentials: request.credentials === "include",
signal: options?.signal,
})
).data;
}
function geminiBaseUrl(config: Pick<AiConfig, "baseUrl">) {
const normalizedBaseUrl = resolveBackendApiUrl(config.baseUrl).replace(/\/+$/, "");
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
return isSystemProxyBaseUrl(normalizedBaseUrl) || lowerBaseUrl.endsWith("/v1") || lowerBaseUrl.endsWith("/v1beta") ? normalizedBaseUrl : `${normalizedBaseUrl}/v1beta`;
}
function geminiModelName(model: string) {
return model.trim().replace(/^models\//, "");
}
function geminiApiUrl(config: Pick<AiConfig, "baseUrl" | "model">, action?: "generateContent" | "streamGenerateContent") {
const baseUrl = geminiBaseUrl(config);
if (!action) return `${baseUrl}/models`;
return `${baseUrl}/models/${encodeURIComponent(geminiModelName(config.model))}:${action}`;
}
function geminiHeaders(config: Pick<AiConfig, "apiKey">) {
return {
"x-goog-api-key": config.apiKey,
"Content-Type": "application/json",
};
}
function withSystemMessage<T extends ResponseInputMessage>(config: AiConfig, messages: T[]): ResponseInputMessage[] {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
}
function toResponseInput(messages: ResponseInputMessage[]): ResponseInputItem[] {
return messages.flatMap((message): ResponseInputItem[] => {
if ("type" in message) return [message];
if (message.role === "tool") return [{ type: "function_call_output", call_id: message.tool_call_id, output: message.content }];
return [{ role: message.role, content: toResponseContent(message.content || "") }];
});
}
function toResponseContent(content: ResponseMessageContent): string | ResponseInputContent[] {
if (!Array.isArray(content)) return String(content || "");
return content.map((item) => {
if (item.type === "text") return { type: "input_text" as const, text: item.text };
if (item.type === "image_url") return { type: "input_image" as const, image_url: item.image_url.url };
return item.file_url.url.startsWith("data:") ? { type: "input_file" as const, filename: item.file_url.name, file_data: item.file_url.url } : { type: "input_file" as const, filename: item.file_url.name, file_url: item.file_url.url };
});
}
function toResponseTool(tool: ResponseFunctionTool): ResponseApiToolDefinition {
return {
type: "function",
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict,
};
}
function toChatCompletionMessages(messages: ResponseInputMessage[]) {
const result: Array<Record<string, unknown>> = [];
for (let index = 0; index < messages.length;) {
const message = messages[index];
if ("type" in message) {
const toolCalls: Array<Record<string, unknown>> = [];
while (index < messages.length && "type" in messages[index]) {
const call = messages[index] as Extract<ResponseInputMessage, { type: "function_call" }>;
toolCalls.push({ id: call.call_id, type: "function", function: { name: call.name, arguments: call.arguments } });
index += 1;
}
result.push({ role: "assistant", content: null, tool_calls: toolCalls });
continue;
}
if (message.role === "tool") {
result.push({ role: "tool", tool_call_id: message.tool_call_id, content: message.content });
} else {
result.push({ role: message.role, content: toChatCompletionContent(message.content) });
}
index += 1;
}
return result;
}
function toChatCompletionContent(content: ResponseMessageContent) {
if (!Array.isArray(content)) return content;
return content.map((item) => {
if (item.type !== "file_url") return item;
const file = item.file_url.url.startsWith("data:") ? { filename: item.file_url.name, file_data: item.file_url.url } : { filename: item.file_url.name, file_url: item.file_url.url };
return { type: "file", file };
});
}
function toChatCompletionToolChoice(toolChoice: ToolChoice) {
return typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
}
function isToolChoiceCompatibilityError(error: unknown) {
const message = error instanceof Error ? error.message : "";
return /tool[_\s-]?choice|thinking\s+mode/i.test(message);
}
function parseChatCompletionPayload(payload: ChatCompletionPayload): ToolResponseResult {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (payload.error?.message) throw new Error(payload.error.message);
const message = payload.choices?.[0]?.message;
const toolCalls = (message?.tool_calls || [])
.map((call) => ({
id: call.id || "",
type: "function" as const,
function: { name: call.function?.name || "", arguments: call.function?.arguments || "{}" },
}))
.filter((call) => call.id && call.function.name);
return { content: message?.content || "", toolCalls, ...(message?.reasoning_content ? { reasoning: message.reasoning_content } : {}) };
}
function parseToolResponse(payload: ResponseApiPayload): ToolResponseResult {
const output = payload.output || [];
const content =
payload.output_text ||
output
.flatMap((item) => (item.type === "message" ? item.content || [] : []))
.map((item) => item.text || "")
.join("");
const toolCalls = output
.filter((item): item is Extract<ResponseApiOutputItem, { type?: "function_call" }> => item.type === "function_call")
.map((item) => ({
id: item.call_id || item.id || "",
type: "function" as const,
function: { name: item.name || "", arguments: item.arguments || "{}" },
}))
.filter((item) => item.id && item.function.name);
return { content, toolCalls };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function responseErrorMessage(value: unknown) {
if (!isRecord(value)) return "";
const error = isRecord(value.error) ? value.error : undefined;
const response = isRecord(value.response) ? value.response : undefined;
const responseError = response && isRecord(response.error) ? response.error : undefined;
return stringValue(value.msg) || stringValue(error?.message) || stringValue(responseError?.message);
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : "";
}
function validateResponsePayload(payload: ResponseApiPayload) {
if (typeof payload.code === "number" && payload.code !== 0) throw new Error(payload.msg || "请求失败");
if (payload.error?.message) throw new Error(payload.error.message);
}
function validateGeminiPayload(payload: GeminiPayload) {
if (payload.error?.message) throw new Error(payload.error.message);
if (payload.promptFeedback?.blockReason) throw new Error(`Gemini 拒绝了本次请求:${payload.promptFeedback.blockReason}`);
}
async function readFetchError(response: Response, fallback: string) {
const text = await response.text();
if (!text) return readStatusError(response.status, fallback);
try {
return responseErrorMessage(JSON.parse(text)) || readStatusError(response.status, fallback);
} catch {
return text.slice(0, 300) || readStatusError(response.status, fallback);
}
}
async function readJsonPayload<T>(response: Response, fallback: string): Promise<T> {
const text = await response.text();
try {
return JSON.parse(text) as T;
} catch {
if (/^\s*(?:<!doctype|<html)/i.test(text)) throw new Error("后端代理返回了前端网页,请检查 VITE_CANVAS_BACKEND_URL 和反向代理配置");
throw new Error(`${fallback}:接口没有返回有效 JSON`);
}
}
function consumeResponseStreamBlock(block: string, state: ResponseStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n")
.trim();
if (!data || data === "[DONE]") return;
const event = JSON.parse(data) as Record<string, unknown>;
const type = stringValue(event.type);
const errorMessage = responseErrorMessage(event);
if (errorMessage) state.error = errorMessage;
if (type === "response.output_text.delta" && typeof event.delta === "string") {
state.text += event.delta;
onDelta?.(state.text);
}
if (type === "response.output_text.done" && !state.text && typeof event.text === "string") {
state.text = event.text;
onDelta?.(state.text);
}
if (["response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta"].includes(type) && typeof event.delta === "string") {
state.reasoning += event.delta;
onReasoning?.(state.reasoning);
}
if (["response.reasoning.done", "response.reasoning_text.done", "response.reasoning_summary_text.done"].includes(type) && !state.reasoning && typeof event.text === "string") {
state.reasoning = event.text;
onReasoning?.(state.reasoning);
}
if (type === "response.completed" && isRecord(event.response)) {
state.payload = event.response as ResponseApiPayload;
} else if (Array.isArray(event.output)) {
state.payload = event as ResponseApiPayload;
}
}
function consumeResponseStreamText(state: ResponseStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
const index = match.index ?? 0;
consumeResponseStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
state.buffer = state.buffer.slice(index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeResponseStreamBlock(state.buffer, state, onDelta, onReasoning);
state.buffer = "";
}
}
async function requestStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const request = channelRequest(config, aiApiUrl(config, "/responses"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
const response = await fetch(request.url, {
method: "POST",
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
const payload = (await response.json()) as ResponseApiPayload;
validateResponsePayload(payload);
return parseToolResponse(payload);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const state: ResponseStreamState = { buffer: "", text: "", reasoning: "" };
for (;;) {
const { done, value } = await reader.read();
if (done) break;
consumeResponseStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning);
if (state.error) throw new Error(state.error);
}
consumeResponseStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true);
if (state.error) throw new Error(state.error);
if (!state.payload) return { content: state.text, toolCalls: [], ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
validateResponsePayload(state.payload);
const result = parseToolResponse(state.payload);
return { ...result, content: state.text || result.content, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
}
function consumeChatCompletionStreamBlock(block: string, state: ChatCompletionStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n")
.trim();
if (!data || data === "[DONE]") return;
const event = JSON.parse(data) as Record<string, unknown>;
const errorMessage = responseErrorMessage(event);
if (errorMessage) state.error = errorMessage;
const choices = Array.isArray(event.choices) ? event.choices : [];
const choice = isRecord(choices[0]) ? choices[0] : undefined;
const delta = choice && isRecord(choice.delta) ? choice.delta : undefined;
if (!delta) return;
if (typeof delta.content === "string") {
state.text += delta.content;
onDelta?.(state.text);
}
const reasoningDelta = stringValue(delta.reasoning_content) || stringValue(delta.reasoning) || stringValue(delta.reasoning_text);
if (reasoningDelta) {
state.reasoning += reasoningDelta;
onReasoning?.(state.reasoning);
}
const chunks = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
chunks.forEach((value, fallbackIndex) => {
if (!isRecord(value)) return;
const callIndex = typeof value.index === "number" ? value.index : fallbackIndex;
const current = state.toolCalls.get(callIndex) || { id: "", name: "", arguments: "" };
const fn = isRecord(value.function) ? value.function : undefined;
state.toolCalls.set(callIndex, {
id: stringValue(value.id) || current.id,
name: stringValue(fn?.name) || current.name,
arguments: current.arguments + stringValue(fn?.arguments),
});
});
}
function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
const index = match.index ?? 0;
consumeChatCompletionStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
state.buffer = state.buffer.slice(index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeChatCompletionStreamBlock(state.buffer, state, onDelta, onReasoning);
state.buffer = "";
}
}
async function requestStreamingChatCompletion(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const request = channelRequest(config, aiApiUrl(config, "/chat/completions"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
const response = await fetch(request.url, {
method: "POST",
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
const contentType = response.headers.get("content-type") || "";
if (!response.body || !contentType.includes("text/event-stream")) {
const result = parseChatCompletionPayload(await readJsonPayload<ChatCompletionPayload>(response, "请求失败"));
if (result.reasoning) options?.onReasoning?.(result.reasoning);
return result;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const state: ChatCompletionStreamState = { buffer: "", text: "", reasoning: "", toolCalls: new Map() };
for (;;) {
const { done, value } = await reader.read();
if (done) break;
consumeChatCompletionStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning);
if (state.error) throw new Error(state.error);
}
consumeChatCompletionStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true);
if (state.error) throw new Error(state.error);
const toolCalls = Array.from(state.toolCalls.entries())
.sort(([left], [right]) => left - right)
.map(([, call]) => ({ id: call.id, type: "function" as const, function: { name: call.name, arguments: call.arguments || "{}" } }))
.filter((call) => call.id && call.function.name);
return { content: state.text, toolCalls, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
}
function toGeminiBody(config: AiConfig, messages: ResponseInputMessage[], extra?: Record<string, unknown>) {
const systemText = [config.systemPrompt.trim(), ...messages.flatMap((message) => (!("type" in message) && message.role === "system" ? [geminiTextContent(message.content)] : []))].filter(Boolean).join("\n\n");
const contents = toGeminiContents(messages.filter((message) => ("type" in message ? true : message.role !== "system")));
return {
contents,
...(systemText ? { systemInstruction: { parts: [{ text: systemText }] } } : {}),
...extra,
};
}
function toGeminiContents(messages: ResponseInputMessage[]): GeminiContent[] {
const callNameById = new Map<string, string>();
return messages.flatMap((message): GeminiContent[] => {
if ("type" in message) {
callNameById.set(message.call_id, message.name);
return [{ role: "model", parts: [{ functionCall: { id: message.call_id, name: message.name, args: jsonObject(message.arguments) }, ...(message.thoughtSignature ? { thoughtSignature: message.thoughtSignature } : {}) }] }];
}
if (message.role === "tool") {
const name = callNameById.get(message.tool_call_id) || "tool_result";
return [{ role: "user", parts: [{ functionResponse: { id: message.tool_call_id, name, response: { result: jsonValue(message.content) } } }] }];
}
return [{ role: message.role === "assistant" ? "model" : "user", parts: toGeminiParts(message.content) }];
});
}
function toGeminiParts(content: ResponseMessageContent): GeminiPart[] {
if (!Array.isArray(content)) return [{ text: String(content || "") }];
return content.map((item) => {
if (item.type === "text") return { text: item.text };
if (item.type === "image_url") return toGeminiFilePart(item.image_url.url, "image/png");
return toGeminiFilePart(item.file_url.url, item.file_url.mimeType);
});
}
function toGeminiFilePart(url: string, mimeType: string): GeminiPart {
const match = url.match(/^data:([^;,]+);base64,(.+)$/);
if (match) return { inlineData: { mimeType: match[1], data: match[2] } };
return { fileData: { fileUri: url, mimeType } };
}
function geminiTextContent(content: ResponseMessageContent) {
if (!Array.isArray(content)) return String(content || "");
return content.map((item) => (item.type === "text" ? item.text : item.type === "image_url" ? item.image_url.url : `${item.file_url.name}: ${item.file_url.url}`)).join("\n");
}
function jsonObject(value: string): Record<string, unknown> {
const parsed = jsonValue(value);
return isRecord(parsed) ? parsed : {};
}
function jsonValue(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
function toGeminiToolOptions(tools: ResponseFunctionTool[], toolChoice: ToolChoice) {
if (!tools.length) return {};
const functionDeclarations = tools.map((tool) => ({
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
}));
const functionCallingConfig = typeof toolChoice === "object" ? { mode: "ANY", allowedFunctionNames: [toolChoice.name] } : { mode: toolChoice === "required" ? "ANY" : "AUTO" };
return {
tools: [{ functionDeclarations }],
toolConfig: { functionCallingConfig },
};
}
async function requestGeminiStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const request = channelRequest(config, `${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, geminiHeaders(config));
const response = await fetch(request.url, {
method: "POST",
headers: request.headers,
body: JSON.stringify(body),
signal: options?.signal,
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
const payload = (await response.json()) as GeminiPayload;
const result = parseGeminiToolResponse(payload);
if (result.reasoning) options?.onReasoning?.(result.reasoning);
return result;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
const state: GeminiStreamState = { buffer: "", text: "", reasoning: "", toolCalls: [] };
for (;;) {
const { done, value } = await reader.read();
if (done) break;
consumeGeminiStreamText(state, decoder.decode(value, { stream: true }), onDelta, options?.onReasoning);
if (state.error) throw new Error(state.error);
}
consumeGeminiStreamText(state, decoder.decode(), onDelta, options?.onReasoning, true);
if (state.error) throw new Error(state.error);
return { content: state.text, toolCalls: state.toolCalls, ...(state.reasoning ? { reasoning: state.reasoning } : {}) };
}
function consumeGeminiStreamText(state: GeminiStreamState, text: string, onDelta?: (text: string) => void, onReasoning?: (text: string) => void, flush = false) {
state.buffer += text;
for (;;) {
const match = state.buffer.match(/\r?\n\r?\n/);
if (!match) break;
const index = match.index ?? 0;
consumeGeminiStreamBlock(state.buffer.slice(0, index), state, onDelta, onReasoning);
state.buffer = state.buffer.slice(index + match[0].length);
}
if (flush && state.buffer.trim()) {
consumeGeminiStreamBlock(state.buffer, state, onDelta, onReasoning);
state.buffer = "";
}
}
function consumeGeminiStreamBlock(block: string, state: GeminiStreamState, onDelta?: (text: string) => void, onReasoning?: (text: string) => void) {
const data = block
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).replace(/^ /, ""))
.join("\n")
.trim();
if (!data || data === "[DONE]") return;
const result = parseGeminiToolResponse(JSON.parse(data) as GeminiPayload);
if (result.reasoning) {
state.reasoning += result.reasoning;
onReasoning?.(state.reasoning);
}
if (result.content) {
state.text += result.content;
onDelta?.(state.text);
}
state.toolCalls.push(...result.toolCalls);
}
function parseGeminiToolResponse(payload: GeminiPayload): ToolResponseResult {
validateGeminiPayload(payload);
const parts = payload.candidates?.flatMap((candidate) => candidate.content?.parts || []) || [];
const content = parts
.filter((part) => !part.thought)
.map((part) => part.text || "")
.join("");
const reasoning = parts
.filter((part) => part.thought)
.map((part) => part.text || "")
.join("");
const toolCalls = parts
.map((part) => part.functionCall)
.filter((call): call is NonNullable<GeminiPart["functionCall"]> => Boolean(call?.name))
.map((call) => {
const part = parts.find((item) => item.functionCall === call);
const thoughtSignature = part?.thoughtSignature || part?.thought_signature;
return {
id: call.id || nanoid(),
type: "function" as const,
function: { name: call.name || "", arguments: JSON.stringify(call.args || {}) },
...(thoughtSignature ? { thoughtSignature } : {}),
};
});
return { content, toolCalls, ...(reasoning ? { reasoning } : {}) };
}
async function requestGeminiImages(config: AiConfig, prompt: string, references: ReferenceImage[], count: number, generationConfig: GeminiImageGenerationConfig, options?: RequestOptions) {
// 参考图先完整读取并校验一次,再复用已解析的 inlineData;不能让每个输出重新读取 storageKey,
// 否则资源缓存瞬时未命中时会出现“偶发缺参考图”或把 storageKey 当 URL 请求的问题。
const referenceParts = await Promise.all(
references.map(async (image) => {
const dataUrl = await imageToDataUrl(image);
const inlineData = parseGeminiImageDataUrl(dataUrl);
return { inlineData };
}),
);
const requests = Array.from({ length: count }, () => requestGeminiImagesOnce(config, prompt, referenceParts, generationConfig, options));
return (await Promise.all(requests)).flat();
}
async function requestGeminiImagesOnce(config: AiConfig, prompt: string, referenceParts: GeminiPart[], generationConfig: GeminiImageGenerationConfig, options?: RequestOptions) {
const parts: GeminiPart[] = [{ text: prompt }, ...referenceParts];
const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config));
const response = await axios.post<GeminiPayload>(
request.url,
{
...toGeminiBody(config, [{ role: "user", content: prompt }], { generationConfig }),
contents: [{ role: "user", parts }],
},
{ headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal },
);
return parseGeminiImagePayload(response.data);
}
function parseGeminiImagePayload(payload: GeminiPayload) {
validateGeminiPayload(payload);
const images =
payload.candidates
?.flatMap((candidate) => candidate.content?.parts || [])
.map((part) => {
const inlineData = part.inlineData || (part.inline_data ? { mimeType: part.inline_data.mimeType || part.inline_data.mime_type, data: part.inline_data.data } : undefined);
if (inlineData?.data) return `data:${inlineData.mimeType || "image/png"};base64,${inlineData.data}`;
return part.fileData?.fileUri || null;
})
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
if (!images.length) throw new Error("Gemini 接口没有返回图片");
return images;
}
export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) {
const selectedModel = config.model || config.imageModel;
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!;
validateImageCapability(imageProfile, []);
const normalizedImage = normalizeImageValue(imageProfile, config);
const n = Number(normalizedImage.count);
if (requestConfig.interfaceType === "gemini-image") {
try {
return await requestGeminiImages(requestConfig, prompt, [], n, buildGeminiImageGenerationConfig(normalizedImage.size, normalizedImage.quality), options);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
if (requestConfig.interfaceType === "grok-image") {
try {
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
const aspectRatio = size?.includes(":") ? size : undefined;
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
const responseData = await postChannelJSON<ImageApiResponse>(
requestConfig,
aiApiUrl(requestConfig, "/images/generations"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
response_format: "url",
...(size ? { size } : {}),
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
...(resolution ? { resolution } : {}),
},
options,
);
return parseImagePayload(responseData);
} catch (error) {
throw new Error(readAxiosError(error, "Grok 图片生成失败"));
}
}
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
const isVolcengineArk = requestConfig.interfaceType === "volcengine-ark-image";
const normalizedRequestSize = requestSize?.parameter === "size" && isVolcengineArk ? { ...requestSize, value: normalizeVolcengineArkImageSize(requestSize.value)! } : requestSize;
try {
const payload = isVolcengineArk
? {
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
watermark: false,
...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}),
}
: {
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
...(quality ? { quality } : {}),
...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}),
...(imageProfile.responseFormat.supported ? { response_format: "b64_json" } : {}),
...(imageProfile.outputFormat.supported ? { output_format: IMAGE_OUTPUT_FORMAT } : {}),
...(imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true" ? { background: "transparent" } : {}),
};
const responseData = isVolcengineArk ? await postVolcengineArkImage(requestConfig, payload, options) : await postChannelJSON<ImageApiResponse>(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options);
const images = parseImagePayload(responseData);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
async function postChannelJSON<T>(config: ReturnType<typeof resolveModelRequestConfig>, upstreamUrl: string, body: unknown, options?: RequestOptions) {
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
return (
await axios.post<T>(request.url, body, {
headers: request.headers,
withCredentials: request.credentials === "include",
signal: options?.signal,
})
).data;
}
async function grokImageInputURL(image: ReferenceImage) {
const candidate = image.url?.trim() || "";
if (/^https?:\/\//i.test(candidate)) return candidate;
return imageToDataUrl(image);
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage, options?: RequestOptions) {
const selectedModel = config.model || config.imageModel;
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!;
validateImageCapability(imageProfile, references, mask);
const normalizedImage = normalizeImageValue(imageProfile, config);
const n = Number(normalizedImage.count);
const requestPrompt = buildImageReferencePromptText(prompt, references);
if (requestConfig.interfaceType === "gemini-image") {
if (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑");
try {
return await requestGeminiImages(requestConfig, requestPrompt, references, n, buildGeminiImageGenerationConfig(normalizedImage.size, normalizedImage.quality), options);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
if (requestConfig.interfaceType === "grok-image") {
if (mask) throw new Error("Grok 图片协议不支持蒙版编辑,请移除蒙版后重试");
if (references.length !== 1) throw new Error("Grok 图片编辑必须提供且仅支持 1 张参考图");
try {
const imageUrl = await grokImageInputURL(references[0]);
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
const aspectRatio = size?.includes(":") ? size : undefined;
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
const response = await postChannelJSON<ImageApiResponse>(
requestConfig,
aiApiUrl(requestConfig, "/images/edits"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
image: { url: imageUrl },
n,
response_format: "url",
...(size ? { size } : {}),
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
...(resolution ? { resolution } : {}),
},
options,
);
return parseImagePayload(response);
} catch (error) {
throw new Error(readAxiosError(error, "Grok 图片编辑失败"));
}
}
if (requestConfig.interfaceType === "volcengine-ark-image") {
if (mask) throw new Error("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试");
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
const sizeRequest = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
const requestSize = sizeRequest?.parameter === "size" ? { ...sizeRequest, value: normalizeVolcengineArkImageSize(sizeRequest.value)! } : sizeRequest;
try {
const images = await Promise.all(references.map((image) => imageToDataUrl(image)));
const response = await postVolcengineArkImage(
requestConfig,
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
n,
watermark: false,
...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}),
...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}),
},
options,
);
return parseImagePayload(response);
} catch (error) {
throw new Error(readAxiosError(error, "火山方舟图片生成失败"));
}
}
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
const formData = new FormData();
formData.set("model", requestConfig.model);
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
formData.set("n", String(n));
if (imageProfile.responseFormat.supported) formData.set("response_format", "b64_json");
if (imageProfile.outputFormat.supported) formData.set("output_format", IMAGE_OUTPUT_FORMAT);
if (imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true") {
formData.set("background", "transparent");
}
if (quality) {
formData.set("quality", quality);
}
if (requestSize) {
formData.set(requestSize.parameter, requestSize.value);
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file));
if (mask) formData.set("mask", dataUrlToFile(mask));
try {
const request = channelRequest(requestConfig, aiApiUrl(requestConfig, "/images/edits"), aiHeaders(requestConfig));
const response = await axios.post<ImageApiResponse>(request.url, formData, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal });
const images = parseImagePayload(response.data);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestImageQuestion(config: AiConfig, messages: AiTextMessage[], onDelta: (text: string) => void, options?: RequestOptions) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
try {
if (requestConfig.apiFormat === "gemini") {
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const answer =
(
await requestStreamingChatCompletion(
requestConfig,
{
model: requestConfig.model,
messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)),
},
onDelta,
options,
)
).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
const answer =
(
await requestStreamingResponse(
requestConfig,
{
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
},
onDelta,
options,
)
).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestToolResponse(config: AiConfig, messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice = "auto", onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
try {
if (requestConfig.apiFormat === "gemini") {
return await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages, toGeminiToolOptions(tools, toolChoice)), onDelta, options);
}
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const chatPayload: Record<string, unknown> = {
model: requestConfig.model,
messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)),
tools,
tool_choice: toChatCompletionToolChoice(toolChoice),
parallel_tool_calls: false,
};
try {
return await requestStreamingChatCompletion(requestConfig, chatPayload, onDelta, options);
} catch (error) {
if (!isToolChoiceCompatibilityError(error)) throw error;
// 部分 OpenAI 兼容上游仅支持 auto,思考模式则可能要求完全省略该字段。
if (toolChoice !== "auto") {
try {
return await requestStreamingChatCompletion(requestConfig, { ...chatPayload, tool_choice: toChatCompletionToolChoice("auto") }, onDelta, options);
} catch (autoError) {
if (!isToolChoiceCompatibilityError(autoError)) throw autoError;
}
}
const { tool_choice: _ignored, ...withoutToolChoice } = chatPayload;
void _ignored;
return await requestStreamingChatCompletion(requestConfig, withoutToolChoice, onDelta, options);
}
}
return await requestStreamingResponse(
requestConfig,
withOpenAIPromptCacheKey(
{
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
tools: tools.map(toResponseTool),
tool_choice: toolChoice,
parallel_tool_calls: false,
},
options?.promptCacheKey,
),
onDelta,
options,
);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { allowLocalChannel?: boolean }) {
try {
if (config.apiFormat === "gemini") {
const requestConfig = { ...defaultGeminiConfig, ...config };
const request = channelRequest(requestConfig, geminiApiUrl(requestConfig), geminiHeaders(requestConfig));
const response = await axios.get<GeminiPayload>(request.url, { headers: request.headers, withCredentials: request.credentials === "include" });
validateGeminiPayload(response.data);
return (response.data.models || [])
.map((model) => model.name?.replace(/^models\//, ""))
.filter((id): id is string => Boolean(id))
.sort((a, b) => a.localeCompare(b));
}
const request = channelRequest(config, buildApiUrl(config.baseUrl, "/models"), { Authorization: `Bearer ${config.apiKey}` });
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(request.url, { headers: request.headers, withCredentials: request.credentials === "include" });
return (response.data.data || [])
.map((model) => model.id)
.filter((id): id is string => Boolean(id))
.sort((a, b) => a.localeCompare(b));
} catch (error) {
throw new Error(readAxiosError(error, "读取模型失败"));
}
}
export type ChannelModelFetchResult = { models: string[]; catalog: ChannelModelCatalogItem[] };
export async function fetchChannelModels(channel: ModelChannel, viaBackend = false): Promise<ChannelModelFetchResult> {
const runtimeChannel = projectDesktopLocalChannelRuntime(channel);
if (!viaBackend) {
const models = await fetchImageModels({ baseUrl: runtimeChannel.baseUrl, allowLocalChannel: runtimeChannel.allowLocalChannel === true, apiKey: runtimeChannel.apiKey, apiFormat: runtimeChannel.apiFormat });
return { models, catalog: models.map((id) => ({ id })) };
}
try {
// 登录态由同源后端代取模型目录,避免每个 OpenAI 兼容服务分别维护浏览器 CORS 白名单。
const response = await axios.post<{ code?: number; data?: { models?: Array<string | ChannelModelCatalogItem> }; msg?: string }>(
resolveBackendApiUrl("/api/ai/models"),
{
baseUrl: runtimeChannel.baseUrl,
allowLocalChannel: runtimeChannel.allowLocalChannel === true,
apiKey: runtimeChannel.apiKey,
apiFormat: runtimeChannel.apiFormat,
headers: runtimeChannel.headers,
},
{ withCredentials: true },
);
if (typeof response.data.code === "number" && response.data.code !== 0) {
throw new Error(response.data.msg || "读取模型失败");
}
const catalog = new Map<string, ChannelModelCatalogItem>();
for (const item of response.data.data?.models || []) {
const entry = typeof item === "string" ? sanitizeChannelModelCatalogItem({ id: item }) : sanitizeChannelModelCatalogItem(item);
if (!entry) continue;
const existing = catalog.get(entry.id);
catalog.set(entry.id, existing || entry);
}
const models = Array.from(catalog.keys()).sort((a, b) => a.localeCompare(b));
const sortedCatalog = Array.from(catalog.values()).sort((a, b) => a.id.localeCompare(b.id));
return { models, catalog: sortedCatalog };
} catch (error) {
throw new Error(readAxiosError(error, "读取模型失败"));
}
}
const defaultGeminiConfig: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat" | "model" | "systemPrompt"> = {
baseUrl: "https://generativelanguage.googleapis.com",
apiKey: "",
apiFormat: "gemini",
model: "",
systemPrompt: "",
};