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

View File

@@ -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", {

View File

@@ -4,31 +4,37 @@ import api from "@/lib/api-client";
export type AiDriver = "openai" | "gemini" | "ollama";
// Masked AI provider settings returned by the server.
// The API key is NEVER returned; only `hasApiKey` indicates whether one is stored.
// No API key is ever returned; only `hasApiKey` / `hasEmbeddingApiKey` indicate
// whether one is stored. `embeddingBaseUrl` is the RAW stored value (empty means
// "uses the chat base URL").
export interface IAiSettings {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
hasApiKey: boolean;
hasEmbeddingApiKey: boolean;
// RAG indexing coverage (pages indexed for semantic search).
indexedPages: number;
totalPages: number;
}
// Update payload. Key semantics:
// - omit `apiKey` -> key unchanged
// - `apiKey: ''` -> clear the stored key
// - `apiKey: 'non-empty'`-> set the key
// Update payload. Key semantics (same for `apiKey` and `embeddingApiKey`):
// - omit the key -> key unchanged
// - `key: ''` -> clear the stored key
// - `key: 'non-empty'` -> set the key
// Non-secret fields are saved as given.
export interface IAiSettingsUpdate {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
apiKey?: string;
embeddingApiKey?: string;
}
// Result of a connection test against the configured provider.

View File

@@ -0,0 +1,18 @@
import { type Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Encrypted, embedding-specific provider key. Separate from `api_key_enc`
// (the chat key) so the chat model and the embedding model can use different
// tokens. When NULL, the embedding model falls back to `api_key_enc`.
await db.schema
.alterTable('ai_provider_credentials')
.addColumn('embedding_api_key_enc', 'text', (col) => col)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.alterTable('ai_provider_credentials')
.dropColumn('embedding_api_key_enc')
.execute();
}

View File

@@ -60,4 +60,42 @@ export class AiProviderCredentialsRepo {
.where('driver', '=', driver)
.execute();
}
// Upsert the embedding-specific encrypted key. If no row exists yet this
// inserts one with `apiKeyEnc` left null (the column is nullable). On conflict
// only `embeddingApiKeyEnc` / `updatedAt` are touched, so the chat key is kept.
async upsertEmbeddingKey(
workspaceId: string,
driver: string,
embeddingApiKeyEnc: string,
trx?: KyselyTransaction,
): Promise<AiProviderCredentials> {
const db = dbOrTx(this.db, trx);
return db
.insertInto('aiProviderCredentials')
.values({ workspaceId, driver, embeddingApiKeyEnc })
.onConflict((oc) =>
oc.columns(['workspaceId', 'driver']).doUpdateSet({
embeddingApiKeyEnc,
updatedAt: new Date(),
}),
)
.returningAll()
.executeTakeFirst();
}
// Clear only the embedding-specific key; the chat key (`apiKeyEnc`) is kept.
async clearEmbeddingKey(
workspaceId: string,
driver: string,
trx?: KyselyTransaction,
): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.updateTable('aiProviderCredentials')
.set({ embeddingApiKeyEnc: null, updatedAt: new Date() })
.where('workspaceId', '=', workspaceId)
.where('driver', '=', driver)
.execute();
}
}

View File

@@ -239,7 +239,7 @@ export class WorkspaceRepo {
// is a real jsonb object, never a double-encoded string. The CASE self-heals
// workspaces whose settings.ai.provider was previously corrupted into an
// array/string.
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'systemPrompt'];
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'embeddingBaseUrl', 'systemPrompt'];
const entries = Object.entries(provider).filter(
([k, v]) => v !== undefined && ALLOWED.includes(k),
);

View File

@@ -12,6 +12,8 @@ export interface AiProviderCredentials {
workspaceId: string;
driver: string;
apiKeyEnc: string | null;
// Encrypted, embedding-specific provider key. Falls back to apiKeyEnc when null.
embeddingApiKeyEnc: string | null;
createdAt: Generated<Timestamp>;
updatedAt: Generated<Timestamp>;
}

View File

@@ -13,16 +13,18 @@ import {
/**
* Shape of the partial update accepted by `update`. Mirrors the validated
* controller DTO. `apiKey` is write-only: undefined = leave, '' = clear,
* non-empty = encrypt + store (§6.4/§8).
* controller DTO. `apiKey` / `embeddingApiKey` are write-only: undefined =
* leave, '' = clear, non-empty = encrypt + store (§6.4/§8).
*/
export interface UpdateAiSettingsInput {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
apiKey?: string;
embeddingApiKey?: string;
}
/**
@@ -71,6 +73,11 @@ export class AiSettingsService {
systemPrompt: provider.systemPrompt,
};
// Effective embedding base URL: the embedding-specific value, else the chat
// base URL. URL is non-secret and relevant for ollama too, so set it
// unconditionally.
config.embeddingBaseUrl = provider.embeddingBaseUrl || provider.baseUrl;
if (provider.driver !== 'ollama') {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
@@ -79,26 +86,34 @@ export class AiSettingsService {
if (creds?.apiKeyEnc) {
config.apiKey = this.secretBox.decryptSecret(creds.apiKeyEnc);
}
// Effective embedding key: the embedding-specific key, else the chat key.
config.embeddingApiKey = creds?.embeddingApiKeyEnc
? this.secretBox.decryptSecret(creds.embeddingApiKeyEnc)
: config.apiKey;
}
return config;
}
/**
* Masked settings safe for admin clients. NEVER includes the key (even
* encrypted); only `hasApiKey` for the current driver. Also reports RAG
* indexing coverage (`indexedPages`/`totalPages`) for the settings UI.
* Masked settings safe for admin clients. NEVER includes any key (even
* encrypted); only `hasApiKey` / `hasEmbeddingApiKey` for the current driver.
* Returns the RAW stored `embeddingBaseUrl` (empty means "uses chat value");
* the fallback is applied only by `resolve`. Also reports RAG indexing
* coverage (`indexedPages`/`totalPages`) for the settings UI.
*/
async getMasked(workspaceId: string): Promise<MaskedAiSettings> {
const provider = await this.readProvider(workspaceId);
let hasApiKey = false;
let hasEmbeddingApiKey = false;
if (provider.driver) {
const creds = await this.aiProviderCredentialsRepo.find(
workspaceId,
provider.driver,
);
hasApiKey = !!creds?.apiKeyEnc;
hasEmbeddingApiKey = !!creds?.embeddingApiKeyEnc;
}
const [indexedPages, totalPages] = await Promise.all([
@@ -111,8 +126,10 @@ export class AiSettingsService {
chatModel: provider.chatModel,
embeddingModel: provider.embeddingModel,
baseUrl: provider.baseUrl,
embeddingBaseUrl: provider.embeddingBaseUrl,
systemPrompt: provider.systemPrompt,
hasApiKey,
hasEmbeddingApiKey,
indexedPages,
totalPages,
};
@@ -120,19 +137,20 @@ export class AiSettingsService {
/**
* Apply a partial update. Non-secret fields are persisted via
* `updateAiProviderSettings`; the API key is handled separately:
* - apiKey === undefined → leave existing key untouched
* - apiKey === '' clear the key for the target driver
* - apiKey non-empty → encrypt + upsert for the target driver
* `updateAiProviderSettings`; the chat / embedding API keys are handled
* separately, each write-only:
* - key === undefined → leave existing key untouched
* - key === '' → clear the key for the target driver
* - key non-empty → encrypt + upsert for the target driver
*
* Target driver for the key = incoming dto.driver, else the stored driver.
* If a key is supplied but no driver can be determined → BadRequest.
* Target driver for the keys = incoming dto.driver, else the stored driver.
* If any key is supplied but no driver can be determined → BadRequest.
*/
async update(
workspaceId: string,
dto: UpdateAiSettingsInput,
): Promise<MaskedAiSettings> {
const { apiKey, ...nonSecret } = dto;
const { apiKey, embeddingApiKey, ...nonSecret } = dto;
// Persist non-secret provider fields (only those present in the partial).
const providerPatch: Partial<AiProviderSettings> = {};
@@ -141,6 +159,7 @@ export class AiSettingsService {
'chatModel',
'embeddingModel',
'baseUrl',
'embeddingBaseUrl',
'systemPrompt',
] as const) {
if (nonSecret[key] !== undefined) {
@@ -154,8 +173,9 @@ export class AiSettingsService {
);
}
// Key handling (write-only).
if (apiKey !== undefined) {
// Key handling (write-only). Both keys share the same target driver and the
// same "driver required" guard, resolved once.
if (apiKey !== undefined || embeddingApiKey !== undefined) {
const stored = await this.readProvider(workspaceId);
const targetDriver = dto.driver ?? stored.driver;
if (!targetDriver) {
@@ -164,15 +184,38 @@ export class AiSettingsService {
);
}
if (apiKey === '') {
await this.aiProviderCredentialsRepo.clearKey(workspaceId, targetDriver);
} else {
const enc = this.secretBox.encryptSecret(apiKey);
await this.aiProviderCredentialsRepo.upsert(
workspaceId,
targetDriver,
enc,
);
// Chat key.
if (apiKey !== undefined) {
if (apiKey === '') {
await this.aiProviderCredentialsRepo.clearKey(
workspaceId,
targetDriver,
);
} else {
const enc = this.secretBox.encryptSecret(apiKey);
await this.aiProviderCredentialsRepo.upsert(
workspaceId,
targetDriver,
enc,
);
}
}
// Embedding key.
if (embeddingApiKey !== undefined) {
if (embeddingApiKey === '') {
await this.aiProviderCredentialsRepo.clearEmbeddingKey(
workspaceId,
targetDriver,
);
} else {
const enc = this.secretBox.encryptSecret(embeddingApiKey);
await this.aiProviderCredentialsRepo.upsertEmbeddingKey(
workspaceId,
targetDriver,
enc,
);
}
}
}

View File

@@ -66,34 +66,38 @@ export class AiService {
* RAG indexer / semanticSearch (§6.7 stage D). Built PER WORKSPACE on demand,
* same as getChatModel; the decrypted key is never logged.
*
* Uses the embedding-specific endpoint/key (`embeddingBaseUrl` /
* `embeddingApiKey`), which fall back to the chat values when unset (resolved
* by AiSettingsService.resolve).
*
* Throws AiEmbeddingNotConfiguredException (→ 503) when the driver,
* embeddingModel or (for non-ollama) the API key is missing, so RAG callers
* can 503 or skip independently of chat being configured.
* embeddingModel or (for non-ollama) the embedding API key is missing, so RAG
* callers can 503 or skip independently of chat being configured.
*/
async getEmbeddingModel(workspaceId: string): Promise<EmbeddingModel> {
const cfg = await this.aiSettings.resolve(workspaceId);
if (
!cfg?.driver ||
!cfg?.embeddingModel ||
(cfg.driver !== 'ollama' && !cfg.apiKey)
(cfg.driver !== 'ollama' && !cfg.embeddingApiKey)
) {
throw new AiEmbeddingNotConfiguredException();
}
switch (cfg.driver) {
case 'openai':
// baseURL (when set) covers openai-compatible endpoints.
// embeddingBaseUrl (when set) covers openai-compatible endpoints.
return createOpenAI({
apiKey: cfg.apiKey,
baseURL: cfg.baseUrl,
apiKey: cfg.embeddingApiKey,
baseURL: cfg.embeddingBaseUrl,
}).textEmbeddingModel(cfg.embeddingModel);
case 'gemini':
return createGoogleGenerativeAI({
apiKey: cfg.apiKey,
apiKey: cfg.embeddingApiKey,
}).textEmbeddingModel(cfg.embeddingModel);
case 'ollama':
// Ollama needs no API key (e.g. nomic-embed-text).
return createOllama({ baseURL: cfg.baseUrl }).textEmbeddingModel(
return createOllama({ baseURL: cfg.embeddingBaseUrl }).textEmbeddingModel(
cfg.embeddingModel,
);
default:

View File

@@ -19,31 +19,40 @@ export interface AiProviderSettings {
chatModel: string;
embeddingModel?: string;
baseUrl?: string;
// Embedding-specific base URL. Falls back to `baseUrl` when empty/unset.
embeddingBaseUrl?: string;
systemPrompt?: string;
}
/**
* Fully resolved provider config, including the decrypted API key for the
* stored driver. Returned by `AiSettingsService.resolve`. The key is held in
* memory only while building the provider and is never logged.
* stored driver. Returned by `AiSettingsService.resolve`. The keys are held in
* memory only while building the provider and are never logged.
*
* `embeddingBaseUrl` / `embeddingApiKey` are the embedding-specific endpoint and
* key, already resolved with the chat-value fallback applied by `resolve`.
*/
export interface ResolvedAiConfig extends Partial<AiProviderSettings> {
driver?: AiDriver;
chatModel?: string;
apiKey?: string;
embeddingApiKey?: string;
}
/**
* Masked provider settings safe to return to admin clients. NEVER includes the
* API key (not even encrypted); only a `hasApiKey` boolean.
* Masked provider settings safe to return to admin clients. NEVER includes any
* API key (not even encrypted); only `hasApiKey` / `hasEmbeddingApiKey` booleans.
* `embeddingBaseUrl` reflects the RAW stored value (empty means "uses chat value").
*/
export interface MaskedAiSettings {
driver?: AiDriver;
chatModel?: string;
embeddingModel?: string;
baseUrl?: string;
embeddingBaseUrl?: string;
systemPrompt?: string;
hasApiKey: boolean;
hasEmbeddingApiKey: boolean;
// RAG indexing coverage for the settings UI.
indexedPages: number;
totalPages: number;

View File

@@ -4,9 +4,10 @@ import { AI_DRIVERS, AiDriver } from '../ai.types';
/**
* Admin update payload for the workspace AI provider settings.
*
* `apiKey` is write-only (§8.2): provided → stored encrypted, '' → cleared,
* absent → left untouched. It is NEVER returned by any endpoint. The global
* ValidationPipe runs with `whitelist: true`, so unknown fields are stripped.
* `apiKey` / `embeddingApiKey` are write-only (§8.2): provided → stored
* encrypted, '' → cleared, absent → left untouched. They are NEVER returned by
* any endpoint. The global ValidationPipe runs with `whitelist: true`, so
* unknown fields are stripped.
*/
export class UpdateAiSettingsDto {
@IsOptional()
@@ -25,6 +26,10 @@ export class UpdateAiSettingsDto {
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
embeddingBaseUrl?: string;
@IsOptional()
@IsString()
systemPrompt?: string;
@@ -32,4 +37,8 @@ export class UpdateAiSettingsDto {
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsString()
embeddingApiKey?: string;
}