feat(ai): separate base URL and API key for chat vs embedding model

Per-workspace AI provider config previously shared a single base URL and
a single API key between the chat model and the embedding model. Add
dedicated, optional embedding endpoint/token that fall back to the chat
values when empty, preserving backward compatibility.

- db: new migration adds nullable `embedding_api_key_enc` to
  `ai_provider_credentials`; chat key stays in `api_key_enc`
- repo: add `upsertEmbeddingKey` / `clearEmbeddingKey` (on-conflict
  touches only its own column, so chat/embedding keys never overwrite)
- ai-settings.service: store non-secret `embeddingBaseUrl`; resolve()
  applies fallback (embeddingBaseUrl || baseUrl; embedding key || chat
  key); getMasked() exposes raw `embeddingBaseUrl` + `hasEmbeddingApiKey`,
  never the key; update() handles the embedding key write-only
- ai.service: getEmbeddingModel() builds openai/gemini/ollama with the
  embedding-specific URL/key; chat path unchanged
- client: new "Embedding base URL" and "Embedding API key" fields with
  fallback hints and a clear-key action

Requires running the DB migration on deploy.
This commit is contained in:
vvzvlad
2026-06-18 01:33:45 +03:00
parent 334a50f003
commit a7f244053b
10 changed files with 245 additions and 47 deletions
@@ -31,9 +31,13 @@ const formSchema = z.object({
chatModel: z.string(),
embeddingModel: z.string(),
baseUrl: z.string(),
// Embedding-specific base URL. Empty means "use the chat base URL".
embeddingBaseUrl: z.string(),
systemPrompt: z.string(),
// Write-only key buffer. Empty string means "do not change" (unless explicitly cleared).
apiKey: z.string(),
// Write-only embedding key buffer. Same semantics as `apiKey`.
embeddingApiKey: z.string(),
});
type FormValues = z.infer<typeof formSchema>;
@@ -51,6 +55,9 @@ export default function AiProviderSettings() {
const [hasApiKey, setHasApiKey] = useState(false);
// Tracks whether the user explicitly cleared the stored key.
const [keyCleared, setKeyCleared] = useState(false);
// Same, for the embedding-specific key.
const [hasEmbeddingApiKey, setHasEmbeddingApiKey] = useState(false);
const [embeddingKeyCleared, setEmbeddingKeyCleared] = useState(false);
const form = useForm<FormValues>({
validate: zod4Resolver(formSchema),
@@ -59,8 +66,10 @@ export default function AiProviderSettings() {
chatModel: "",
embeddingModel: "",
baseUrl: "",
embeddingBaseUrl: "",
systemPrompt: "",
apiKey: "",
embeddingApiKey: "",
},
});
@@ -72,12 +81,16 @@ export default function AiProviderSettings() {
chatModel: settings.chatModel ?? "",
embeddingModel: settings.embeddingModel ?? "",
baseUrl: settings.baseUrl ?? "",
embeddingBaseUrl: settings.embeddingBaseUrl ?? "",
systemPrompt: settings.systemPrompt ?? "",
apiKey: "",
embeddingApiKey: "",
});
form.resetDirty();
setHasApiKey(settings.hasApiKey);
setKeyCleared(false);
setHasEmbeddingApiKey(settings.hasEmbeddingApiKey);
setEmbeddingKeyCleared(false);
}, [settings]);
const driver = form.values.driver as AiDriver;
@@ -91,21 +104,30 @@ export default function AiProviderSettings() {
driver: values.driver,
chatModel: values.chatModel,
embeddingModel: values.embeddingModel,
// Send the base URL only for providers that use it.
// Send the base URLs only for providers that use them. The embedding base
// URL is optional; empty falls back to the chat base URL server-side.
baseUrl: showBaseUrl ? values.baseUrl : "",
embeddingBaseUrl: showBaseUrl ? values.embeddingBaseUrl : "",
systemPrompt: values.systemPrompt,
};
// Key semantics (never send the stored key back):
// - typed a value -> set it
// - explicitly cleared -> send '' to clear
// - untouched -> omit `apiKey` entirely (leave unchanged)
// - untouched -> omit the key entirely (leave unchanged)
if (showApiKey) {
if (values.apiKey.length > 0) {
payload.apiKey = values.apiKey;
} else if (keyCleared) {
payload.apiKey = "";
}
// Same write-only semantics for the embedding-specific key.
if (values.embeddingApiKey.length > 0) {
payload.embeddingApiKey = values.embeddingApiKey;
} else if (embeddingKeyCleared) {
payload.embeddingApiKey = "";
}
}
return payload;
@@ -113,10 +135,13 @@ export default function AiProviderSettings() {
async function handleSubmit(values: FormValues) {
const updated = await updateMutation.mutateAsync(buildPayload(values));
// Reflect the new key state and reset the write-only buffer.
// Reflect the new key state and reset the write-only buffers.
setHasApiKey(updated.hasApiKey);
setKeyCleared(false);
form.setFieldValue("apiKey", "");
setHasEmbeddingApiKey(updated.hasEmbeddingApiKey);
setEmbeddingKeyCleared(false);
form.setFieldValue("embeddingApiKey", "");
form.resetDirty();
}
@@ -126,6 +151,12 @@ export default function AiProviderSettings() {
form.setFieldValue("apiKey", "");
}
function handleClearEmbeddingKey() {
setEmbeddingKeyCleared(true);
setHasEmbeddingApiKey(false);
form.setFieldValue("embeddingApiKey", "");
}
const driverOptions = [
{ value: "openai", label: "OpenAI" },
{ value: "gemini", label: "Gemini" },
@@ -188,6 +219,44 @@ export default function AiProviderSettings() {
{...form.getInputProps("embeddingModel")}
/>
{showBaseUrl && (
<TextInput
label={t("Embedding base URL")}
placeholder={t("Leave empty to use the chat base URL")}
readOnly={!isAdmin}
{...form.getInputProps("embeddingBaseUrl")}
/>
)}
{showApiKey && (
<PasswordInput
label={t("Embedding API key")}
// Placeholder hints whether a dedicated key is stored and the fallback;
// the value is never shown.
placeholder={
hasEmbeddingApiKey
? t("•••• set")
: t("Leave empty to use the chat API key")
}
readOnly={!isAdmin}
autoComplete="off"
{...form.getInputProps("embeddingApiKey")}
/>
)}
{showApiKey && isAdmin && hasEmbeddingApiKey && (
<Group justify="flex-start" mt={-8}>
<Button
variant="subtle"
size="compact-sm"
color="red"
onClick={handleClearEmbeddingKey}
>
{t("Clear key")}
</Button>
</Group>
)}
{settings && (
<Text size="sm" c="dimmed" mt={-8}>
{t("Indexed {{indexed}} of {{total}} pages", {