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>
|
||||
|
||||
Reference in new issue
Block a user