feat(ai): support custom headers for api services

This commit is contained in:
二丫讲梵 authored and GitHub committed 2026-08-31 23:47:14 +08:00
1 parent ba11e4be8d
commit 42c0dc7f89
27 files changed
+299 -10

No files matched your search

@@ -62,6 +62,7 @@ import {
DUCKDB_WORKER_MAX_PROCESSES_MIN,
normalizeDuckDbWorkerMaxProcesses,
normalizeAiEnv,
normalizeAiHeaders,
type AiProvider,
type AiApiStyle,
type AiAuthMethod,
@@ -325,6 +326,12 @@ interface AiEnvRow {
value: string;
}
interface AiHeaderRow {
id: string;
name: string;
value: string;
}
function tableColumnTemplateRowsFromSettings(lines: readonly string[]): TableColumnTemplateGridRow[] {
return parseTableColumnTemplateFields([...lines]).map((field) => ({
id: uuid(),
@@ -3341,6 +3348,7 @@ const aiEditEndpoint = ref("");
const aiEditModel = ref("");
const aiEditLegacyModels = ref<AiConfiguredModel[]>([]);
const aiEditApiStyle = ref<AiApiStyle>("completions");
const aiEditCustomHeaderRows = ref<AiHeaderRow[]>([]);
const aiEditProxyEnabled = ref(false);
const aiEditProxyUrl = ref("");
const aiEditEnableThinking = ref(true);
@@ -3505,6 +3513,7 @@ const aiCliMcpActionLabel = computed(() => {
return t("settings.mcpUpToDate");
});
const aiCliEnvError = computed(() => cliEnvValidationError());
const aiHeadersValidationError = computed(() => customHeadersValidationError());
const aiCliPathError = computed(() => {
const path = aiEditCliPath.value.trim();
const firstToken = path.split(/\s+/)[0] || "";
@@ -3520,6 +3529,49 @@ function aiEnvRowsFromConfig(env: unknown): AiEnvRow[] {
}));
}
function aiHeaderRowsFromConfig(headers: unknown): AiHeaderRow[] {
return Object.entries(normalizeAiHeaders(headers)).map(([name, value]) => ({
id: uuid(),
name,
value,
}));
}
const AI_HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
const RESERVED_AI_HEADERS = new Set(["host", "content-length", "content-type", "connection", "transfer-encoding", "proxy-authorization"]);
function customHeadersFromRows(): Record<string, string> {
const result: Record<string, string> = {};
for (const row of aiEditCustomHeaderRows.value) {
const name = row.name.trim();
if (name) result[name] = row.value;
}
return result;
}
function customHeadersValidationError(): string {
const names = new Set<string>();
for (const row of aiEditCustomHeaderRows.value) {
const name = row.name.trim();
if (!name && !row.value) continue;
if (!AI_HEADER_NAME_RE.test(name)) return t("ai.customHeadersInvalidName", { name: row.name || "—" });
const normalizedName = name.toLowerCase();
if (RESERVED_AI_HEADERS.has(normalizedName)) return t("ai.customHeadersReserved", { name });
if (names.has(normalizedName)) return t("ai.customHeadersDuplicate", { name });
if (/\r|\n/.test(row.value)) return t("ai.customHeadersInvalidValue", { name });
names.add(normalizedName);
}
return "";
}
function addAiCustomHeaderRow() {
aiEditCustomHeaderRows.value.push({ id: uuid(), name: "", value: "" });
}
function removeAiCustomHeaderRow(id: string) {
aiEditCustomHeaderRows.value = aiEditCustomHeaderRows.value.filter((row) => row.id !== id);
}
function cliEnvFromRows(rows = aiEditCliEnvRows.value): Record<string, string> {
const result: Record<string, string> = {};
for (const row of rows) {
@@ -3578,6 +3630,7 @@ function currentAiEditConfig() {
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
})),
apiStyle: aiEditApiStyle.value,
customHeaders: customHeadersFromRows(),
proxyEnabled: aiEditProxyEnabled.value,
proxyUrl: aiEditProxyUrl.value,
enableThinking: aiEditEnableThinking.value,
@@ -3624,6 +3677,7 @@ function aiSelectProvider(provider: AiProvider) {
aiEditModel.value = "";
aiEditLegacyModels.value = [];
aiEditApiStyle.value = preset.apiStyle;
aiEditCustomHeaderRows.value = [];
aiEditEnableThinking.value = true;
aiEditReasoningLevel.value = "default";
if (CLI_AI_PROVIDERS.has(provider)) void ensureCliMcpStatus();
@@ -3659,6 +3713,7 @@ function aiEnterEditMode(configId?: string) {
supportedEffortLevels: model.supportedEffortLevels ? [...model.supportedEffortLevels] : undefined,
}));
aiEditApiStyle.value = config.apiStyle;
aiEditCustomHeaderRows.value = aiHeaderRowsFromConfig(config.customHeaders);
aiEditProxyEnabled.value = config.proxyEnabled ?? false;
aiEditProxyUrl.value = config.proxyUrl ?? "";
aiEditEnableThinking.value = config.enableThinking ?? true;
@@ -3690,6 +3745,7 @@ function aiEnterEditMode(configId?: string) {
aiEditModel.value = "";
aiEditLegacyModels.value = [];
aiEditApiStyle.value = AI_PROVIDER_PRESETS["claude"].apiStyle;
aiEditCustomHeaderRows.value = [];
aiEditProxyEnabled.value = false;
aiEditProxyUrl.value = "";
aiEditEnableThinking.value = true;
@@ -3730,6 +3786,7 @@ async function applyPendingAiConfigDeepLinkDraft() {
aiEditModel.value = draft.model;
aiEditLegacyModels.value = [];
aiEditApiStyle.value = draft.apiStyle;
aiEditCustomHeaderRows.value = [];
if (!draft.promptForClipboardApiKey) return;
@@ -3764,6 +3821,10 @@ async function aiSaveConfig() {
toast(t("ai.configNameExists", { name: aiEditConfigName.value }), 3000);
return;
}
if (aiHeadersValidationError.value) {
toast(aiHeadersValidationError.value, 3000);
return;
}
const editConfig = currentAiEditConfig();
const config: AiConfigItem = {
@@ -3817,6 +3878,11 @@ async function aiSetDefaultConfig(id: string) {
async function aiTestConn() {
if ((aiRequiresApiKey.value && !aiEditApiKey.value.trim()) || (!aiIsCliProvider.value && !aiEditEndpoint.value.trim())) return;
if (aiHeadersValidationError.value) {
aiTestResult.value = "error";
aiTestError.value = aiHeadersValidationError.value;
return;
}
if (aiCliValidationError.value) {
aiTestResult.value = "error";
aiTestError.value = aiCliValidationError.value;
@@ -7206,6 +7272,28 @@ onUnmounted(() => {
</div>
</div>
<!-- Custom HTTP headers for API gateways and tenant routing. -->
<div v-if="!aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.customHeaders") }}</Label>
<div class="col-span-2 space-y-2">
<div class="space-y-1.5">
<div v-for="row in aiEditCustomHeaderRows" :key="row.id" class="grid grid-cols-[minmax(0,0.9fr)_minmax(0,1.3fr)_2rem] gap-2">
<Input v-model="row.name" autocomplete="off" class="h-8 font-mono text-xs" :placeholder="t('ai.customHeadersNamePlaceholder')" />
<PasswordInput v-model="row.value" autocomplete="off" class="min-w-0" inputClass="h-8 font-mono text-xs" :placeholder="t('ai.customHeadersValuePlaceholder')" />
<Button type="button" variant="ghost" size="icon" class="h-8 w-8" :title="t('common.remove')" :aria-label="t('common.remove')" @click="removeAiCustomHeaderRow(row.id)">
<X class="h-3.5 w-3.5" />
</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" class="h-7 px-2 text-xs" @click="addAiCustomHeaderRow">
<Plus class="mr-1 h-3.5 w-3.5" />
{{ t("ai.customHeadersAdd") }}
</Button>
<p v-if="aiHeadersValidationError" class="text-[11px] text-destructive">{{ aiHeadersValidationError }}</p>
<p v-else class="text-[11px] text-muted-foreground">{{ t("ai.customHeadersHint") }}</p>
</div>
</div>
<!-- CLI Path -->
<div v-if="aiIsCliProvider" class="grid grid-cols-3 items-start gap-3">
<Label class="pt-2 text-right text-xs">{{ t("ai.cliPath", { provider: aiCliProviderLabel }) }}</Label>
@@ -8191,7 +8279,7 @@ onUnmounted(() => {
</template>
<template v-else>
<div class="flex min-w-0 flex-1 items-center gap-2">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim())" @click="aiTestConn">
<Button size="sm" variant="outline" :disabled="aiTesting || !!aiCliValidationError || !!aiHeadersValidationError || (aiRequiresApiKey && !aiEditApiKey?.trim()) || (!aiIsCliProvider && !aiEditEndpoint?.trim())" @click="aiTestConn">
<Loader2 v-if="aiTesting" class="h-3 w-3 animate-spin mr-1" />
{{ t("connection.test") }}
</Button>
+9
View File
@@ -2714,6 +2714,15 @@ export default {
proxy: "Proxy",
proxyEnable: "Send AI requests through proxy",
proxyUrl: "Proxy URL",
customHeaders: "Custom Headers",
customHeadersAdd: "Add header",
customHeadersHint: "Extra headers are sent with model discovery, connection tests, chat, and Agent requests. Header values may be sensitive.",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "Invalid header name: {name}",
customHeadersInvalidValue: "Invalid header value for: {name}",
customHeadersDuplicate: "Duplicate header name: {name}",
customHeadersReserved: "This header is managed by DBX: {name}",
enableThinking: "Thinking",
enableThinkingOn: "Enabled",
enableThinkingOff: "Disabled",
+9
View File
@@ -2790,6 +2790,15 @@ export default withEnglishFallback({
proxy: "Proxy",
proxyEnable: "Enviar solicitudes de IA mediante proxy",
proxyUrl: "URL del proxy",
customHeaders: "Cabeceras personalizadas",
customHeadersAdd: "Añadir cabecera",
customHeadersHint: "Estas cabeceras se envían al descubrir modelos, probar la conexión, chatear y usar Agent. Sus valores pueden ser sensibles.",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "Nombre de cabecera no válido: {name}",
customHeadersInvalidValue: "Valor de cabecera no válido para: {name}",
customHeadersDuplicate: "Nombre de cabecera duplicado: {name}",
customHeadersReserved: "DBX administra esta cabecera: {name}",
enableThinking: "Razonamiento",
enableThinkingOn: "Activado",
enableThinkingOff: "Desactivado",
+9
View File
@@ -2684,6 +2684,15 @@ export default withEnglishFallback({
proxy: "Proxy",
proxyEnable: "Invia richieste AI tramite proxy",
proxyUrl: "URL Proxy",
customHeaders: "Intestazioni personalizzate",
customHeadersAdd: "Aggiungi intestazione",
customHeadersHint: "Queste intestazioni vengono inviate durante la ricerca dei modelli, il test della connessione, la chat e le richieste Agent. I valori possono essere sensibili.",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "Nome intestazione non valido: {name}",
customHeadersInvalidValue: "Valore intestazione non valido per: {name}",
customHeadersDuplicate: "Nome intestazione duplicato: {name}",
customHeadersReserved: "Questa intestazione è gestita da DBX: {name}",
enableThinking: "Pensiero (Thinking)",
enableThinkingOn: "Abilitato",
enableThinkingOff: "Disabilitato",
+9
View File
@@ -2823,6 +2823,15 @@ export default withEnglishFallback({
proxy: "プロキシ",
proxyEnable: "プロキシ経由でAIリクエストを送信",
proxyUrl: "プロキシURL",
customHeaders: "カスタムヘッダー",
customHeadersAdd: "ヘッダーを追加",
customHeadersHint: "モデル検出、接続テスト、チャット、Agent リクエストでこれらのヘッダーが送信されます。値には機密情報が含まれる場合があります。",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "無効なヘッダー名: {name}",
customHeadersInvalidValue: "無効なヘッダー値: {name}",
customHeadersDuplicate: "重複するヘッダー名: {name}",
customHeadersReserved: "このヘッダーは DBX により管理されています: {name}",
enableThinking: "思考",
enableThinkingOn: "有効",
enableThinkingOff: "無効",
+9
View File
@@ -2539,6 +2539,15 @@ export default withEnglishFallback({
proxy: "프록시",
proxyEnable: "프록시를 통해 AI 요청 전송",
proxyUrl: "프록시 URL",
customHeaders: "사용자 지정 헤더",
customHeadersAdd: "헤더 추가",
customHeadersHint: "모델 검색, 연결 테스트, 채팅 및 Agent 요청에 이 헤더가 전송됩니다. 값에는 민감한 정보가 포함될 수 있습니다.",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "잘못된 헤더 이름: {name}",
customHeadersInvalidValue: "잘못된 헤더 값: {name}",
customHeadersDuplicate: "중복된 헤더 이름: {name}",
customHeadersReserved: "이 헤더는 DBX에서 관리합니다: {name}",
enableThinking: "사고",
enableThinkingOn: "활성화됨",
enableThinkingOff: "비활성화됨",
+9
View File
@@ -2792,6 +2792,15 @@ export default withEnglishFallback({
proxy: "Proxy",
proxyEnable: "Enviar requisições de AI através do proxy",
proxyUrl: "URL do Proxy",
customHeaders: "Cabeçalhos personalizados",
customHeadersAdd: "Adicionar cabeçalho",
customHeadersHint: "Estes cabeçalhos são enviados na descoberta de modelos, teste de conexão, chat e solicitações do Agent. Os valores podem ser sensíveis.",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "Nome de cabeçalho inválido: {name}",
customHeadersInvalidValue: "Valor de cabeçalho inválido para: {name}",
customHeadersDuplicate: "Nome de cabeçalho duplicado: {name}",
customHeadersReserved: "Este cabeçalho é gerenciado pelo DBX: {name}",
anthropicMessagesHint: "APIs compatíveis com Anthropic Messages geralmente usam /v1/messages.",
openAiCompatibleEndpointHint: "A maioria das APIs compatíveis com OpenAI requer o prefixo de caminho /v1.",
contextWindow: "Janela de Contexto",
+9
View File
@@ -2637,6 +2637,15 @@ export default withEnglishFallback({
proxy: "代理",
proxyEnable: "AI 请求通过代理发送",
proxyUrl: "代理地址",
customHeaders: "自定义请求头",
customHeadersAdd: "添加请求头",
customHeadersHint: "模型发现、连接测试、对话和 Agent 请求都会携带这些请求头;值可能包含敏感信息。",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "请求头名称无效:{name}",
customHeadersInvalidValue: "请求头值无效:{name}",
customHeadersDuplicate: "请求头名称重复:{name}",
customHeadersReserved: "该请求头由 DBX 管理:{name}",
enableThinking: "Thinking",
enableThinkingOn: "已启用",
enableThinkingOff: "已禁用",
+9
View File
@@ -2683,6 +2683,15 @@ export default withEnglishFallback({
proxy: "代理伺服器",
proxyEnable: "經由代理伺服器發送 AI 請求",
proxyUrl: "代理伺服器地址",
customHeaders: "自訂請求標頭",
customHeadersAdd: "新增請求標頭",
customHeadersHint: "模型探索、連線測試、對話和 Agent 請求都會攜帶這些標頭;值可能包含敏感資訊。",
customHeadersNamePlaceholder: "X-Tenant-ID",
customHeadersValuePlaceholder: "tenant-a",
customHeadersInvalidName: "請求標頭名稱無效:{name}",
customHeadersInvalidValue: "請求標頭值無效:{name}",
customHeadersDuplicate: "請求標頭名稱重複:{name}",
customHeadersReserved: "此請求標頭由 DBX 管理:{name}",
enableThinking: "Thinking",
enableThinkingOn: "已啟用",
enableThinkingOff: "已停用",
+12
View File
@@ -383,6 +383,17 @@ export function normalizeAiEnv(value: unknown): Record<string, string> {
return result;
}
export function normalizeAiHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const result: Record<string, string> = {};
for (const [rawName, rawValue] of Object.entries(value as Record<string, unknown>)) {
const name = rawName.trim();
if (!name) continue;
result[name] = rawValue == null ? "" : String(rawValue);
}
return result;
}
export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined): AiConfig {
const provider = config?.provider && config.provider in AI_PROVIDER_PRESETS ? config.provider : inferAiProviderFromConfig(config);
return {
@@ -391,6 +402,7 @@ export function normalizeAiConfig(config: Partial<AiConfig> | null | undefined):
provider,
apiKey: (config?.apiKey ?? "").trim(),
apiStyle: config?.apiStyle ?? defaultConfigs[provider].apiStyle,
customHeaders: normalizeAiHeaders(config?.customHeaders),
authMethod: config?.authMethod ?? defaultConfigs[provider].authMethod,
proxyEnabled: !!config?.proxyEnabled,
proxyUrl: config?.proxyUrl ?? "",
+2
View File
@@ -36,6 +36,8 @@ export interface AiConfig {
model: string;
models?: AiConfiguredModel[];
apiStyle: AiApiStyle;
/** Additional HTTP headers sent to API-based AI providers. */
customHeaders?: Record<string, string>;
proxyEnabled?: boolean;
proxyUrl?: string;
enableThinking?: boolean;