fix(api): 自定义渠道 - 移除浏览器 CORS 依赖
This commit is contained in:
28
web/src/services/api/custom-channel-relay.ts
Normal file
28
web/src/services/api/custom-channel-relay.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user