feat(ai): support custom headers for api services
This commit is contained in:
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "無効",
|
||||
|
||||
@@ -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: "비활성화됨",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "已禁用",
|
||||
|
||||
@@ -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: "已停用",
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user