fix(api): 自定义渠道 - 移除浏览器 CORS 依赖

This commit is contained in:
Hao OUYANG
2026-07-22 21:44:44 +08:00
parent 180baf4be8
commit a5bc71c194
9 changed files with 820 additions and 28 deletions

View File

@@ -0,0 +1,28 @@
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig } from "@/stores/use-config-store";
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">;
export type ChannelRequest = {
url: string;
headers: Record<string, string>;
credentials: RequestCredentials;
};
/** 自定义渠道统一经登录态后端中转,避免依赖第三方服务的浏览器 CORS。 */
export function channelRequest(config: RelayConfig, upstreamUrl: string, headers: HeadersInit = {}): ChannelRequest {
const normalizedHeaders = new Headers(headers);
if (isSystemProxyBaseUrl(config.baseUrl)) {
return { url: upstreamUrl, headers: Object.fromEntries(normalizedHeaders.entries()), credentials: "include" };
}
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
normalizedHeaders.delete("x-goog-api-key");
normalizedHeaders.set("Authorization", `Bearer ${config.apiKey}`);
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
normalizedHeaders.set("X-Canvas-Upstream-Format", config.apiFormat === "gemini" ? "gemini" : "openai");
return {
url: resolveBackendApiUrl("/api/ai/custom"),
headers: Object.fromEntries(normalizedHeaders.entries()),
credentials: "include",
};
}

View File

@@ -4,6 +4,7 @@ import { buildApiUrl, isSystemProxyBaseUrl, resolveBackendApiUrl, resolveModelRe
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { channelRequest } from "@/services/api/custom-channel-relay";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
@@ -249,10 +250,6 @@ function aiHeaders(config: AiConfig, contentType?: string) {
};
}
function aiFetchCredentials(config: Pick<AiConfig, "baseUrl">): RequestCredentials {
return isSystemProxyBaseUrl(config.baseUrl) ? "include" : "same-origin";
}
function geminiBaseUrl(config: Pick<AiConfig, "baseUrl">) {
const normalizedBaseUrl = resolveBackendApiUrl(config.baseUrl).replace(/\/+$/, "");
const lowerBaseUrl = normalizedBaseUrl.toLowerCase();
@@ -454,12 +451,13 @@ function consumeResponseStreamText(state: ResponseStreamState, text: string, onD
}
async function requestStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const response = await fetch(aiApiUrl(config, "/responses"), {
const request = channelRequest(config, aiApiUrl(config, "/responses"), { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" });
const response = await fetch(request.url, {
method: "POST",
headers: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: aiFetchCredentials(config),
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
@@ -534,12 +532,13 @@ function consumeChatCompletionStreamText(state: ChatCompletionStreamState, text:
}
async function requestStreamingChatCompletion(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const response = await fetch(aiApiUrl(config, "/chat/completions"), {
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: { ...aiHeaders(config, "application/json"), Accept: "text/event-stream" },
headers: request.headers,
body: JSON.stringify({ ...body, stream: true }),
signal: options?.signal,
credentials: aiFetchCredentials(config),
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
const contentType = response.headers.get("content-type") || "";
@@ -640,12 +639,13 @@ function toGeminiToolOptions(tools: ResponseFunctionTool[], toolChoice: ToolChoi
}
async function requestGeminiStreamingResponse(config: AiConfig, body: Record<string, unknown>, onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const response = await fetch(`${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, {
const request = channelRequest(config, `${geminiApiUrl(config, "streamGenerateContent")}?alt=sse`, geminiHeaders(config));
const response = await fetch(request.url, {
method: "POST",
headers: geminiHeaders(config),
headers: request.headers,
body: JSON.stringify(body),
signal: options?.signal,
credentials: aiFetchCredentials(config),
credentials: request.credentials,
});
if (!response.ok) throw new Error(await readFetchError(response, "请求失败"));
if (!response.body) {
@@ -887,18 +887,17 @@ export async function requestToolResponse(config: AiConfig, messages: ResponseIn
export async function fetchImageModels(config: Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">) {
try {
if (config.apiFormat === "gemini") {
const response = await axios.get<GeminiPayload>(geminiApiUrl({ ...defaultGeminiConfig, ...config }), { headers: geminiHeaders({ ...defaultGeminiConfig, ...config }) });
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 response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
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))