fix(ai): adapt huanke and volcano providers to sdk v2
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-02-19 00:42:24 +08:00
parent 801d61f9a6
commit c4da49e126
2 changed files with 357 additions and 123 deletions

View File

@ -567,129 +567,253 @@ async function sendHuankeGeminiRequest(
* 使用 Gemini 原生 API 格式
*/
export function createHuankeGemini({ apiKey, baseURL }: HuankeGeminiConfig) {
const getMessagesFromOptions = (options: any): any[] => {
if (Array.isArray(options?.messages)) {
return options.messages;
}
if (!Array.isArray(options?.prompt)) {
return [];
}
return options.prompt
.map((message: any) => {
if (!message || typeof message !== "object") return null;
if (typeof message.content === "string") {
return {
role: message.role,
content: message.content,
};
}
if (!Array.isArray(message.content)) {
return null;
}
const content = message.content
.map((part: any) => {
if (!part || typeof part !== "object") return null;
if (part.type === "text") {
return { type: "text", text: part.text || "" };
}
if (part.type === "file") {
const data =
typeof part.data === "string"
? part.data
: part.data instanceof URL
? part.data.toString()
: null;
if (!data) return null;
return {
type: "file",
data,
mediaType: part.mediaType,
filename: part.filename,
};
}
return null;
})
.filter(Boolean);
return {
role: message.role,
content,
};
})
.filter(Boolean);
};
const getImageParamsFromOptions = (options: any) => {
const providerOptions =
options?.providerOptions?.["huanke-gemini"] ||
options?.providerOptions?.huankeGemini ||
{};
const imageParams = options?.imageParams || {};
return {
aspectRatio: imageParams.aspectRatio ?? providerOptions.aspectRatio ?? "auto",
imageSize: imageParams.imageSize ?? providerOptions.imageSize ?? "2K",
imageCount: imageParams.imageCount ?? providerOptions.imageCount ?? 1,
};
};
const toV2Usage = (text: string) => {
const outputTokens = text.length;
return {
inputTokens: 0,
outputTokens,
totalTokens: outputTokens,
};
};
const createV2TextStream = (run: Promise<{ text: string; finishReason: "stop" | "error" }>) =>
new ReadableStream({
start(controller) {
controller.enqueue({ type: "stream-start", warnings: [] });
void run
.then((result) => {
const usage = toV2Usage(result.text);
if (result.text) {
const textId = `txt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
controller.enqueue({ type: "text-start", id: textId });
controller.enqueue({
type: "text-delta",
id: textId,
delta: result.text,
});
controller.enqueue({ type: "text-end", id: textId });
}
controller.enqueue({
type: "finish",
finishReason: result.finishReason,
usage,
});
})
.catch((error) => {
controller.enqueue({ type: "error", error });
controller.enqueue({
type: "finish",
finishReason: "error",
usage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
});
})
.finally(() => {
controller.close();
});
},
});
return {
chat: (modelId: string) => ({
doGenerate: async (options: any) => {
console.log("[Huanke Gemini] doGenerate called!");
console.log("[Huanke Gemini] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "...");
const effectiveModelId = options.overrideModelId || modelId;
const messages = getMessagesFromOptions(options);
console.log(
"[Huanke Gemini] Messages received:",
JSON.stringify(messages).substring(0, 500) + "..."
);
const effectiveModelId = options?.overrideModelId || modelId;
const imageParams = getImageParamsFromOptions(options);
const imageCount = Math.min(Math.max(Number(imageParams.imageCount || 1), 1), 2);
console.log("[Huanke Gemini] Using modelId:", effectiveModelId);
console.log("[Huanke Gemini] Image params from options:", options.imageParams);
console.log("[Huanke Gemini] Final imageParams:", imageParams);
console.log(`[Huanke Gemini] Image count: ${imageCount}`);
try {
const imageParams = {
aspectRatio: options.imageParams?.aspectRatio || "auto",
imageSize: options.imageParams?.imageSize || "2K"
};
const imageCount = Math.min(Math.max(options.imageParams?.imageCount || 1, 1), 2);
console.log("[Huanke Gemini] Final imageParams:", imageParams);
console.log(`[Huanke Gemini] Image count: ${imageCount}`);
console.log(`[Huanke Gemini] Starting ${imageCount} concurrent request(s)...`);
const startTime = Date.now();
const requestPromises = Array.from({ length: imageCount }, () =>
sendHuankeGeminiRequest(baseURL, apiKey, options.messages, effectiveModelId, imageParams)
);
const responses = await Promise.all(requestPromises);
console.log(`[Huanke Gemini] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`);
const extractPromises = responses.map(response => extractAndUploadImages(response));
const imageUrlsArrays = await Promise.all(extractPromises);
const allImageUrls = imageUrlsArrays.flat();
const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize);
console.log(`[Huanke Gemini] Starting ${imageCount} concurrent request(s)...`);
const startTime = Date.now();
console.log(
`[Huanke Gemini] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s)`
);
const requestPromises = Array.from({ length: imageCount }, () =>
sendHuankeGeminiRequest(baseURL, apiKey, messages, effectiveModelId, imageParams)
);
const responses = await Promise.all(requestPromises);
console.log(`[Huanke Gemini] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`);
const extractPromises = responses.map((response) => extractAndUploadImages(response));
const imageUrlsArrays = await Promise.all(extractPromises);
const allImageUrls = imageUrlsArrays.flat();
const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize);
console.log(
`[Huanke Gemini] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s)`
);
const isV2Call = Array.isArray(options?.prompt);
if (isV2Call) {
return {
text: markdownText,
content: [{ type: "text", text: markdownText }],
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: markdownText.length,
},
};
} catch (error) {
console.error("[Huanke Gemini] Generation failed:", error);
throw error;
}
},
doStream: async function* (options: any) {
console.log("[Huanke Gemini] ===== doStream ENTERED! =====");
console.log("[Huanke Gemini] Messages received:", JSON.stringify(options.messages).substring(0, 500) + "...");
const effectiveModelId = options.overrideModelId || modelId;
console.log("[Huanke Gemini] Using modelId:", effectiveModelId);
console.log("[Huanke Gemini] Image params from options:", options.imageParams);
try {
const imageParams = {
aspectRatio: options.imageParams?.aspectRatio || "auto",
imageSize: options.imageParams?.imageSize || "2K"
};
const imageCount = Math.min(Math.max(options.imageParams?.imageCount || 1, 1), 2);
console.log("[Huanke Gemini] Final imageParams:", imageParams);
console.log(`[Huanke Gemini Stream] Image count: ${imageCount}`);
console.log(`[Huanke Gemini Stream] Starting ${imageCount} concurrent request(s)...`);
const startTime = Date.now();
const requestPromises = Array.from({ length: imageCount }, () =>
sendHuankeGeminiRequest(baseURL, apiKey, options.messages, effectiveModelId, imageParams)
);
const responses = await Promise.all(requestPromises);
console.log(`[Huanke Gemini Stream] All ${imageCount} request(s) completed in ${Date.now() - startTime}ms`);
const extractPromises = responses.map(response => extractAndUploadImages(response));
const imageUrlsArrays = await Promise.all(extractPromises);
const allImageUrls = imageUrlsArrays.flat();
const markdownText = formatImageResponse(allImageUrls, imageParams.imageSize);
console.log(
`[Huanke Gemini Stream] Generated ${allImageUrls.length} images from ${imageCount} concurrent request(s)`
);
yield { type: "text-delta" as const, textDelta: markdownText };
console.log("[Huanke Gemini Stream] Sent complete markdown");
yield {
type: "finish" as const,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: markdownText.length,
},
};
} catch (error) {
console.error("[Huanke Gemini Stream] Generation failed:", error);
const errorMessage =
error instanceof Error
? `图片生成失败: ${error.message}`
: "图片生成失败,请稍后重试";
yield { type: "text-delta" as const, textDelta: errorMessage };
yield {
type: "finish" as const,
finishReason: "error" as const,
usage: { promptTokens: 0, completionTokens: 0 },
usage: toV2Usage(markdownText),
warnings: [],
};
}
return {
text: markdownText,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: markdownText.length,
},
};
},
doStream: function (this: any, options: any) {
const isV2Call = Array.isArray(options?.prompt);
if (!isV2Call) {
const legacyStream = async function* (this: any) {
try {
const result = await (this as any).doGenerate(options);
yield { type: "text-delta" as const, textDelta: result.text };
yield {
type: "finish" as const,
finishReason: "stop" as const,
usage: {
promptTokens: 0,
completionTokens: result.text?.length || 0,
},
};
} catch (error) {
const errorMessage =
error instanceof Error
? `图片生成失败: ${error.message}`
: "图片生成失败,请稍后重试";
yield { type: "text-delta" as const, textDelta: errorMessage };
yield {
type: "finish" as const,
finishReason: "error" as const,
usage: { promptTokens: 0, completionTokens: 0 },
};
}
};
return legacyStream.call(this);
}
const run = (async () => {
try {
const result = await this.doGenerate(options);
const content = Array.isArray(result?.content) ? result.content : [];
const text = content
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("");
return { text, finishReason: "stop" as const };
} catch (error) {
console.error("[Huanke Gemini Stream] Generation failed:", error);
const text =
error instanceof Error
? `图片生成失败: ${error.message}`
: "图片生成失败,请稍后重试";
return { text, finishReason: "error" as const };
}
})();
return Promise.resolve({
stream: createV2TextStream(run),
});
},
supportedUrls: {},
modelId,
provider: "huanke-gemini",
specificationVersion: "v1" as const,
specificationVersion: "v2" as const,
defaultObjectGenerationMode: "json" as const,
}),
};

View File

@ -22,8 +22,14 @@ function parseMessageParts(content: unknown) {
const imageUrls: string[] = [];
const pushImageUrl = (value: unknown) => {
if (typeof value !== "string") return;
const url = value.trim();
const raw =
typeof value === "string"
? value
: value instanceof URL
? value.toString()
: "";
if (!raw) return;
const url = raw.trim();
if (!url) return;
imageUrls.push(url);
};
@ -73,7 +79,11 @@ function extractPromptAndImages(options: any) {
return { text: options.prompt.trim(), imageUrls: [] as string[] };
}
const messages = Array.isArray(options?.messages) ? options.messages : [];
const messages = Array.isArray(options?.messages)
? options.messages
: Array.isArray(options?.prompt)
? options.prompt
: [];
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message?.role !== "user") continue;
@ -181,13 +191,65 @@ async function callResponsesApi(params: {
export function createVolcanoMultimodal(config: VolcanoMultimodalConfig) {
const endpoint = normalizeResponsesEndpoint(config.baseURL);
const timeout = config.timeout ?? 60000;
const toV2Usage = (usage: { promptTokens?: number; completionTokens?: number }) => {
const inputTokens = usage.promptTokens ?? 0;
const outputTokens = usage.completionTokens ?? 0;
return {
inputTokens,
outputTokens,
totalTokens: inputTokens + outputTokens,
};
};
const createV2TextStream = (
run: Promise<{ text: string; finishReason: "stop" | "error"; usage: { promptTokens?: number; completionTokens?: number } }>
) =>
new ReadableStream({
start(controller) {
controller.enqueue({ type: "stream-start", warnings: [] });
void run
.then((result) => {
if (result.text) {
const textId = `txt-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
controller.enqueue({ type: "text-start", id: textId });
controller.enqueue({
type: "text-delta",
id: textId,
delta: result.text,
});
controller.enqueue({ type: "text-end", id: textId });
}
controller.enqueue({
type: "finish",
finishReason: result.finishReason,
usage: toV2Usage(result.usage),
});
})
.catch((error) => {
controller.enqueue({ type: "error", error });
controller.enqueue({
type: "finish",
finishReason: "error",
usage: {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
},
});
})
.finally(() => controller.close());
},
});
return {
provider: "volcano-multimodal",
chat: (modelId: string) => ({
provider: "volcano-multimodal",
modelId,
specificationVersion: "v1" as const,
specificationVersion: "v2" as const,
supportedUrls: {},
defaultObjectGenerationMode: "json" as const,
doGenerate: async (options: any) => {
const { text, imageUrls } = extractPromptAndImages(options);
@ -199,23 +261,71 @@ export function createVolcanoMultimodal(config: VolcanoMultimodalConfig) {
imageUrls,
timeout,
});
return {
const legacyResult = {
text: result.text,
finishReason: "stop" as const,
usage: result.usage,
};
const isV2Call = Array.isArray(options?.prompt);
if (isV2Call) {
return {
content: [{ type: "text", text: result.text }],
finishReason: "stop" as const,
usage: toV2Usage(result.usage),
warnings: [],
};
}
return legacyResult;
},
doStream: async function* (options: any) {
const result = await this.doGenerate(options);
yield {
type: "text-delta" as const,
textDelta: result.text,
};
yield {
type: "finish" as const,
finishReason: "stop" as const,
usage: result.usage,
};
doStream: function (this: any, options: any) {
const isV2Call = Array.isArray(options?.prompt);
if (!isV2Call) {
const legacyStream = async function* (this: any) {
const result = await this.doGenerate(options);
yield {
type: "text-delta" as const,
textDelta: result.text,
};
yield {
type: "finish" as const,
finishReason: "stop" as const,
usage: result.usage,
};
};
return legacyStream.call(this);
}
const run = (async () => {
try {
const result = await this.doGenerate(options);
const content = Array.isArray(result?.content) ? result.content : [];
const text = content
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
.map((part: any) => part.text)
.join("");
return {
text,
finishReason: "stop" as const,
usage: {
promptTokens: result?.usage?.inputTokens ?? 0,
completionTokens: result?.usage?.outputTokens ?? 0,
},
};
} catch (error) {
return {
text: error instanceof Error ? error.message : "请求失败",
finishReason: "error" as const,
usage: { promptTokens: 0, completionTokens: 0 },
};
}
})();
return Promise.resolve({
stream: createV2TextStream(run),
});
},
}),
};