diff --git a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx index 6458f2e4..4fe6d671 100644 --- a/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx +++ b/apps/client/src/features/workspace/components/settings/components/ai-provider-settings.tsx @@ -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; @@ -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({ 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 && ( + + )} + + {showApiKey && ( + + )} + + {showApiKey && isAdmin && hasEmbeddingApiKey && ( + + + + )} + {settings && ( {t("Indexed {{indexed}} of {{total}} pages", { diff --git a/apps/client/src/features/workspace/services/ai-settings-service.ts b/apps/client/src/features/workspace/services/ai-settings-service.ts index 6f8000b2..b0f92111 100644 --- a/apps/client/src/features/workspace/services/ai-settings-service.ts +++ b/apps/client/src/features/workspace/services/ai-settings-service.ts @@ -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. diff --git a/apps/server/src/database/migrations/20260618T120000-ai-embedding-credentials.ts b/apps/server/src/database/migrations/20260618T120000-ai-embedding-credentials.ts new file mode 100644 index 00000000..b2ef242b --- /dev/null +++ b/apps/server/src/database/migrations/20260618T120000-ai-embedding-credentials.ts @@ -0,0 +1,18 @@ +import { type Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + // 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): Promise { + await db.schema + .alterTable('ai_provider_credentials') + .dropColumn('embedding_api_key_enc') + .execute(); +} diff --git a/apps/server/src/database/repos/ai-chat/ai-provider-credentials.repo.ts b/apps/server/src/database/repos/ai-chat/ai-provider-credentials.repo.ts index e180889a..4709ba96 100644 --- a/apps/server/src/database/repos/ai-chat/ai-provider-credentials.repo.ts +++ b/apps/server/src/database/repos/ai-chat/ai-provider-credentials.repo.ts @@ -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 { + 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 { + const db = dbOrTx(this.db, trx); + await db + .updateTable('aiProviderCredentials') + .set({ embeddingApiKeyEnc: null, updatedAt: new Date() }) + .where('workspaceId', '=', workspaceId) + .where('driver', '=', driver) + .execute(); + } } diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts index da534f08..f61ce9db 100644 --- a/apps/server/src/database/repos/workspace/workspace.repo.ts +++ b/apps/server/src/database/repos/workspace/workspace.repo.ts @@ -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), ); diff --git a/apps/server/src/database/types/ai-provider-credentials.types.ts b/apps/server/src/database/types/ai-provider-credentials.types.ts index 7200d917..5bd7db33 100644 --- a/apps/server/src/database/types/ai-provider-credentials.types.ts +++ b/apps/server/src/database/types/ai-provider-credentials.types.ts @@ -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; updatedAt: Generated; } diff --git a/apps/server/src/integrations/ai/ai-settings.service.ts b/apps/server/src/integrations/ai/ai-settings.service.ts index e064efb3..2ac71cd8 100644 --- a/apps/server/src/integrations/ai/ai-settings.service.ts +++ b/apps/server/src/integrations/ai/ai-settings.service.ts @@ -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 { 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 { - const { apiKey, ...nonSecret } = dto; + const { apiKey, embeddingApiKey, ...nonSecret } = dto; // Persist non-secret provider fields (only those present in the partial). const providerPatch: Partial = {}; @@ -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, + ); + } } } diff --git a/apps/server/src/integrations/ai/ai.service.ts b/apps/server/src/integrations/ai/ai.service.ts index b68b676b..268079d2 100644 --- a/apps/server/src/integrations/ai/ai.service.ts +++ b/apps/server/src/integrations/ai/ai.service.ts @@ -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 { 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: diff --git a/apps/server/src/integrations/ai/ai.types.ts b/apps/server/src/integrations/ai/ai.types.ts index 0b4a81cd..3e52ec05 100644 --- a/apps/server/src/integrations/ai/ai.types.ts +++ b/apps/server/src/integrations/ai/ai.types.ts @@ -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 { 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; diff --git a/apps/server/src/integrations/ai/dto/update-ai-settings.dto.ts b/apps/server/src/integrations/ai/dto/update-ai-settings.dto.ts index 7f0a9d43..9bdd3762 100644 --- a/apps/server/src/integrations/ai/dto/update-ai-settings.dto.ts +++ b/apps/server/src/integrations/ai/dto/update-ai-settings.dto.ts @@ -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; }