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:
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user