fix(api): 渠道请求头 - 补充自定义 Header 配置并应用到上游请求

Refs #39
This commit is contained in:
ddCat
2026-07-29 20:57:24 +08:00
parent 412399a473
commit 6de81049bf
21 changed files with 535 additions and 75 deletions

View File

@@ -0,0 +1,109 @@
import { Button, Input, Tooltip } from "antd";
import { Plus, Trash2 } from "lucide-react";
import type { ChangeEvent } from "react";
import type { ChannelHeader } from "@/stores/use-config-store";
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
const MAX_HEADER_COUNT = 32;
const BLOCKED_HEADERS = new Set([
"authorization",
"proxy-authorization",
"cookie",
"set-cookie",
"host",
"content-length",
"content-type",
"accept",
"connection",
"proxy-connection",
"keep-alive",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"forwarded",
"x-goog-api-key",
]);
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
type Props = {
value?: ChannelHeader[];
onChange?: (headers: ChannelHeader[]) => void;
disabled?: boolean;
};
export function ChannelHeadersEditor({ value = [], onChange, disabled }: Props) {
const headers = Array.isArray(value) ? value : [];
const validation = validateChannelHeaders(headers);
const hasUserAgent = headers.some((header) => header.name.trim().toLowerCase() === "user-agent");
const updateHeader = (index: number, patch: Partial<ChannelHeader>) => {
onChange?.(headers.map((header, itemIndex) => (itemIndex === index ? { ...header, ...patch } : header)));
};
return (
<div className="space-y-2 rounded-md border border-border/70 bg-muted/[.1] p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-sm font-medium text-foreground">自定义请求头</div>
<div className="mt-0.5 break-all text-xs leading-5 text-foreground/50">默认发送 {DEFAULT_USER_AGENT};添加 User-Agent 后会覆盖默认值。</div>
</div>
{!hasUserAgent ? (
<Button size="small" disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "User-Agent", value: DEFAULT_USER_AGENT }])}>
添加 User-Agent
</Button>
) : null}
</div>
{headers.length ? (
<div className="space-y-2">
{headers.map((header, index) => {
const inputProps = {
"aria-label": `请求头 ${index + 1} 值`,
disabled,
value: header.value,
placeholder: "Header 值",
onChange: (event: ChangeEvent<HTMLInputElement>) => updateHeader(index, { value: event.target.value }),
};
return (
<div key={index} className="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_32px] gap-2">
<Input aria-label={`请求头 ${index + 1} 名称`} disabled={disabled} value={header.name} placeholder="Header 名称" onChange={(event) => updateHeader(index, { name: event.target.value })} />
{/token|key|secret/i.test(header.name) ? <Input.Password {...inputProps} /> : <Input {...inputProps} />}
<Tooltip title="删除请求头">
<Button aria-label={`删除请求头 ${index + 1}`} className="size-8 p-0" disabled={disabled} icon={<Trash2 className="size-3.5" />} onClick={() => onChange?.(headers.filter((_, itemIndex) => itemIndex !== index))} />
</Tooltip>
</div>
);
})}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button size="small" type="dashed" icon={<Plus className="size-3.5" />} disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "", value: "" }])}>
添加请求头
</Button>
<span className={`text-xs ${validation ? "text-destructive" : "text-foreground/45"}`}>{validation || `${headers.length}/${MAX_HEADER_COUNT}`}</span>
</div>
</div>
);
}
export function validateChannelHeaders(headers?: ChannelHeader[]) {
if (!headers?.length) return "";
if (headers.length > MAX_HEADER_COUNT) return `自定义请求头最多支持 ${MAX_HEADER_COUNT} 项`;
const seen = new Set<string>();
let totalBytes = 0;
for (const header of headers) {
const name = header.name.trim();
const value = header.value.trim();
if (!name && !value) continue;
if (!name || !HEADER_NAME_PATTERN.test(name)) return "请求头名称无效,请使用标准 HTTP Header 名称";
const normalizedName = name.toLowerCase();
if (BLOCKED_HEADERS.has(normalizedName) || normalizedName.startsWith("x-canvas-") || normalizedName.startsWith("x-forwarded-")) return `${name} 由系统管理,不允许自定义`;
if (seen.has(normalizedName)) return `请求头不能重复:${name}`;
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) return `${name} 的值包含非法控制字符`;
if (name.length > 128 || value.length > 4096) return "单个请求头名称或值过长";
totalBytes += new TextEncoder().encode(name + value).length;
if (totalBytes > 16 * 1024) return "自定义请求头总大小不能超过 16KB";
seen.add(normalizedName);
}
return "";
}

View File

@@ -5,16 +5,17 @@ import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { refreshSystemChannels } from "@/lib/user-session";
import { createAdminChannel, deleteAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth";
import { type ModelChannel } from "@/stores/use-config-store";
import { type ChannelHeader, type ModelChannel } from "@/stores/use-config-store";
import { useAdminContext } from "../admin-context";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton, configuredSecretText } from "../components/admin-ui";
import { ChannelModelManager } from "../components/channel-model-manager";
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; secretKey?: string; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; secretKey?: string; headers?: ChannelHeader[]; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
export default function ChannelsPage() {
const { message, modal } = App.useApp();
@@ -79,7 +80,7 @@ export default function ChannelsPage() {
const openDrawer = (channel?: ModelChannel) => {
setEditingChannel(channel || null);
form.resetFields();
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", secretKey: "", useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", secretKey: "", useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", secretKey: "", headers: channel.headers || [], useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
setDrawerOpen(true);
};
@@ -94,13 +95,18 @@ export default function ChannelsPage() {
const save = async () => {
const values = await form.validateFields();
const headerError = validateChannelHeaders(values.headers);
if (headerError) {
message.error(headerError);
return;
}
if (!editingChannel && !values.apiKey?.trim()) {
message.error("请填写 API Key 或 Access Key");
return;
}
setSaving(true);
try {
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", headers: values.headers || [], useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
await syncChannels();
setDrawerOpen(false);
@@ -164,6 +170,7 @@ export default function ChannelsPage() {
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
<Form.Item name="apiKey" label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]} extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} /></Form.Item>
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} /></Form.Item>
<div className="mb-6"><Form.Item name="headers" noStyle><ChannelHeadersEditor /></Form.Item></div>
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>

View File

@@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from "react-router";
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
import { refreshSystemChannels } from "@/lib/user-session";
import { MODEL_PROTOCOL_OPTIONS, modelProtocolLabel } from "@/lib/model-protocols";
import { fetchChannelModels } from "@/services/api/image";
@@ -391,6 +392,9 @@ export default function SettingsPage() {
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
/>
</Form.Item>
<div className="lg:col-span-12">
<ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} />
</div>
</div>
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
</section>
@@ -572,11 +576,11 @@ function channelConnectionError(channel: ModelChannel) {
}
function channelConnectionSignature(channel: ModelChannel) {
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto"].join("\n");
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
}
function channelValidationError(channel: ModelChannel) {
return channelConnectionError(channel) || (!channel.models.length ? "请添加至少一个模型" : "");
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
}
function isChannelReady(channel: ModelChannel) {

View File

@@ -1,6 +1,6 @@
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig } from "@/stores/use-config-store";
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig, type ChannelHeader } from "@/stores/use-config-store";
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">;
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { headers?: ChannelHeader[] };
export type ChannelRequest = {
url: string;
@@ -16,13 +16,22 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers
}
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
normalizedHeaders.delete("X-Canvas-Upstream-Headers");
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");
if (config.headers?.length) normalizedHeaders.set("X-Canvas-Upstream-Headers", encodeChannelHeaders(config.headers));
return {
url: resolveBackendApiUrl("/api/ai/custom"),
headers: Object.fromEntries(normalizedHeaders.entries()),
credentials: "include",
};
}
function encodeChannelHeaders(headers: ChannelHeader[]) {
const bytes = new TextEncoder().encode(JSON.stringify(headers));
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}

View File

@@ -755,13 +755,14 @@ async function requestGeminiImagesOnce(config: AiConfig, prompt: string, referen
for (const image of references) {
parts.push(toGeminiImagePart(await imageToDataUrl(image)));
}
const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config));
const response = await axios.post<GeminiPayload>(
geminiApiUrl(config, "generateContent"),
request.url,
{
...toGeminiBody(config, [{ role: "user", content: prompt }], { generationConfig: { responseModalities: ["TEXT", "IMAGE"] } }),
contents: [{ role: "user", parts }],
},
{ headers: geminiHeaders(config), signal: options?.signal },
{ headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal },
);
return parseGeminiImagePayload(response.data);
}
@@ -893,7 +894,8 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
if (mask) formData.set("mask", dataUrlToFile(mask));
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/edits"), formData, { headers: aiHeaders(requestConfig), signal: options?.signal });
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) {
@@ -990,6 +992,7 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
apiFormat: channel.apiFormat,
headers: channel.headers,
},
{ withCredentials: true },
);

View File

@@ -204,7 +204,12 @@ function geminiVeoHeaders(config: ResolvedAiConfig, contentType?: string) {
}
async function channelPost<T>(config: ResolvedAiConfig, upstreamUrl: string, body: unknown, options?: RequestOptions) {
const request = channelRequest(config, upstreamUrl, { "Content-Type": "application/json" });
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 channelPostForm<T>(config: ResolvedAiConfig, upstreamUrl: string, body: FormData, options?: RequestOptions) {
const request = channelRequest(config, upstreamUrl, aiHeaders(config));
return (await axios.post<T>(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
}
@@ -213,6 +218,11 @@ async function channelGet<T>(config: ResolvedAiConfig, upstreamUrl: string, opti
return (await axios.get<T>(request.url, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
}
async function channelGetBlob(config: ResolvedAiConfig, upstreamUrl: string, options?: RequestOptions) {
const request = channelRequest(config, upstreamUrl);
return (await axios.get<Blob>(request.url, { headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal })).data;
}
function findGeminiVideoURL(value: unknown): string {
if (!value || typeof value !== "object") return "";
if (Array.isArray(value)) {
@@ -259,7 +269,7 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
};
try {
const createPath = config.interfaceType === "xai-video" ? "/videos/generations" : "/videos";
const created = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelPost<ApiVideoResponse>(config, aiApiUrl(config, createPath), payload, options) : (await axios.post<ApiVideoResponse>(aiApiUrl(config, createPath), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data);
const created = unwrapVideoResponse(await channelPost<ApiVideoResponse>(config, aiApiUrl(config, createPath), payload, options));
const id = videoTaskId(created);
if (!id) throw new Error("视频接口没有返回任务 ID");
return { id, provider: "openai", model };
@@ -277,7 +287,7 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => body.append("input_reference[]", file));
try {
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config), signal: options?.signal })).data);
const created = unwrapVideoResponse(await channelPostForm<ApiVideoResponse>(config, aiApiUrl(config, "/videos"), body, options));
if (!created.id) throw new Error("视频接口没有返回任务 ID");
return { id: created.id, provider: "openai", model };
} catch (error) {
@@ -287,13 +297,13 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
async function pollOpenAIVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const video = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelGet<ApiVideoResponse>(config, aiApiUrl(config, `/videos/${task.id}`), options) : (await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), signal: options?.signal })).data);
const video = unwrapVideoResponse(await channelGet<ApiVideoResponse>(config, aiApiUrl(config, `/videos/${task.id}`), options));
if (video.status === "completed" || video.status === "succeeded" || video.status === "success" || video.status === "done") {
const resultUrl = video.video?.url || video.video_url || video.result_url;
if (resultUrl) return { status: "completed", result: await videoResultFromUrl(resultUrl, options) };
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
await assertVideoBlob(content.data);
return { status: "completed", result: { blob: content.data } };
const content = await channelGetBlob(config, aiApiUrl(config, `/videos/${task.id}/content`), options);
await assertVideoBlob(content);
return { status: "completed", result: { blob: content } };
}
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
return { status: "pending" };
@@ -312,9 +322,7 @@ async function createSeedanceTask(config: ResolvedAiConfig, model: string, promp
: await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences);
try {
const raw = isVolcengineArk
? await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options)
: (await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data;
const raw = await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options);
const created = unwrapSeedanceTask(raw);
const id = created.id || created.task_id;
if (!id) throw new Error("Seedance 接口没有返回任务 ID");
@@ -326,18 +334,15 @@ async function createSeedanceTask(config: ResolvedAiConfig, model: string, promp
async function pollSeedanceTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const raw =
config.interfaceType === "volcengine-ark-video"
? await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options)
: (await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data;
const raw = await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options);
const state = unwrapSeedanceTask(raw);
if (state.status === "succeeded" || state.status === "completed") {
const url = state.video_url || state.content?.video_url;
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
if (isArkPlanBaseUrl(config.baseUrl)) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
await assertVideoBlob(content.data);
return { status: "completed", result: { blob: content.data } };
const content = await channelGetBlob(config, aiApiUrl(config, `/videos/${task.id}/content`), options);
await assertVideoBlob(content);
return { status: "completed", result: { blob: content } };
}
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: seedanceErrorMessage(state) || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
return { status: "pending" };

View File

@@ -9,6 +9,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge
export type ApiCallFormat = "openai" | "gemini";
export type ChannelInterfaceType = ModelProtocol;
export type ChannelHeader = { name: string; value: string };
export type ModelChannel = {
id: string;
@@ -16,6 +17,7 @@ export type ModelChannel = {
baseUrl: string;
apiKey: string;
secretKey?: string;
headers?: ChannelHeader[];
apiFormat: ApiCallFormat;
interfaceType?: ChannelInterfaceType;
models: string[];
@@ -298,6 +300,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
apiKey: channel?.apiKey || "",
secretKey: channel?.secretKey || "",
headers: Array.isArray(channel?.headers) ? channel.headers.map((header) => ({ name: String(header.name || ""), value: String(header.value || "") })) : [],
apiFormat,
interfaceType,
models: uniqueRawModels(channel?.models || []),
@@ -388,6 +391,7 @@ export function resolveModelRequestConfig(config: AiConfig, value: string) {
baseUrl: channel.baseUrl,
apiKey: channel.apiKey,
secretKey: channel.secretKey,
headers: channel.headers,
apiFormat: interfaceType ? (interfaceType === "gemini-veo" ? "gemini" as const : "openai" as const) : channel.apiFormat,
interfaceType,
channelId: channel.scope === "system" ? channel.id : "",