From 7feab45447fe2576ee3ca2c45d3101f9df3a1737 Mon Sep 17 00:00:00 2001 From: digua Date: Mon, 18 May 2026 00:07:28 +0800 Subject: [PATCH] feat(node-runtime): extract LLM config CRUD to shared LLMConfigStore class Introduce ConfigStorage abstraction and LLMConfigStore class with dependency injection for i18n, UUID generation, and auth profile hooks. All CRUD operations (add/update/delete/slots) are platform-agnostic. Electron can implement ConfigStorage with fs + getAiDataDir. Co-authored-by: Cursor --- .../src/ai/__tests__/llm-config-store.test.ts | 132 +++++++++ packages/node-runtime/src/ai/index.ts | 4 + .../node-runtime/src/ai/llm-config-store.ts | 276 ++++++++++++++++++ packages/node-runtime/src/index.ts | 4 + 4 files changed, 416 insertions(+) create mode 100644 packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts create mode 100644 packages/node-runtime/src/ai/llm-config-store.ts diff --git a/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts new file mode 100644 index 00000000..29c05ab9 --- /dev/null +++ b/packages/node-runtime/src/ai/__tests__/llm-config-store.test.ts @@ -0,0 +1,132 @@ +import { describe, it, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { LLMConfigStore, type ConfigStorage, type AIConfigStore } from '../llm-config-store' + +function createMemoryStorage(): ConfigStorage & { data: Map } { + const data = new Map() + return { + data, + readJson(key: string): T | null { + return (data.get(key) as T) ?? null + }, + writeJson(key: string, value: T): void { + data.set(key, JSON.parse(JSON.stringify(value))) + }, + } +} + +describe('LLMConfigStore', () => { + let storage: ReturnType + let store: LLMConfigStore + let idCounter: number + + beforeEach(() => { + storage = createMemoryStorage() + idCounter = 0 + store = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + }) + }) + + it('returns empty store when no data', () => { + const all = store.getAllConfigs() + assert.equal(all.length, 0) + assert.equal(store.hasActiveConfig(), false) + }) + + it('adds a config', () => { + const result = store.addConfig({ + name: 'Test', + provider: 'openai', + apiKey: 'sk-test', + model: 'gpt-4', + }) + assert.ok(result.success) + assert.equal(result.config!.id, 'id-1') + assert.equal(result.config!.name, 'Test') + assert.equal(store.getAllConfigs().length, 1) + }) + + it('sets first config as default assistant', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + const slot = store.getDefaultAssistantSlot() + assert.ok(slot) + assert.equal(slot!.configId, 'id-1') + assert.equal(slot!.modelId, 'gpt-4') + }) + + it('updates a config', () => { + store.addConfig({ name: 'Old', provider: 'openai', apiKey: 'k' }) + const result = store.updateConfig('id-1', { name: 'New' }) + assert.ok(result.success) + const config = store.getConfigById('id-1') + assert.equal(config!.name, 'New') + }) + + it('returns error when updating non-existent config', () => { + const result = store.updateConfig('nope', { name: 'X' }) + assert.equal(result.success, false) + assert.ok(result.error) + }) + + it('deletes a config', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k' }) + store.addConfig({ name: 'B', provider: 'openai', apiKey: 'k' }) + const result = store.deleteConfig('id-1') + assert.ok(result.success) + assert.equal(store.getAllConfigs().length, 1) + }) + + it('reassigns default assistant after deleting current default', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + store.addConfig({ name: 'B', provider: 'openai', apiKey: 'k', model: 'gpt-3' }) + store.deleteConfig('id-1') + const slot = store.getDefaultAssistantSlot() + assert.equal(slot!.configId, 'id-2') + }) + + it('sets and retrieves fast model', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + store.setFastModel({ configId: 'id-1', modelId: 'gpt-3.5' }) + const config = store.getFastModelConfig() + assert.ok(config) + assert.equal(config!.model, 'gpt-3.5') + }) + + it('fast model falls back to default when null', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'k', model: 'gpt-4' }) + store.setFastModel(null) + const config = store.getFastModelConfig() + assert.ok(config) + assert.equal(config!.model, 'gpt-4') + }) + + it('strips apiKey when saving', () => { + store.addConfig({ name: 'A', provider: 'openai', apiKey: 'secret' }) + const raw = storage.data.get('llm-config') as AIConfigStore + assert.equal(raw.configs[0].apiKey, '') + }) + + it('respects MAX_CONFIG_COUNT', () => { + for (let i = 0; i < 99; i++) { + store.addConfig({ name: `C${i}`, provider: 'openai', apiKey: 'k' }) + } + const result = store.addConfig({ name: 'Overflow', provider: 'openai', apiKey: 'k' }) + assert.equal(result.success, false) + assert.ok(result.error) + }) + + it('calls onApiKeyCreated when adding config with key', () => { + const captured: Array<{ name: string; key: string }> = [] + const storeWithHook = new LLMConfigStore(storage, { + generateId: () => `id-${++idCounter}`, + onApiKeyCreated: (config, apiKey) => { + captured.push({ name: config.name, key: apiKey }) + }, + }) + storeWithHook.addConfig({ name: 'Hooked', provider: 'openai', apiKey: 'my-key' }) + assert.equal(captured.length, 1) + assert.equal(captured[0].name, 'Hooked') + assert.equal(captured[0].key, 'my-key') + }) +}) diff --git a/packages/node-runtime/src/ai/index.ts b/packages/node-runtime/src/ai/index.ts index da2788e3..9c0c5ede 100644 --- a/packages/node-runtime/src/ai/index.ts +++ b/packages/node-runtime/src/ai/index.ts @@ -93,6 +93,10 @@ export { } from './summary' export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult } from './summary' +// LLM Config Store +export { LLMConfigStore, MAX_CONFIG_COUNT } from './llm-config-store' +export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './llm-config-store' + // LLM Model Builder export { buildPiModel, normalizeAnthropicBaseUrl, normalizeOpenAICompatibleBaseUrl } from './llm-builder' export type { PiModelConfig, BuildPiModelOptions } from './llm-builder' diff --git a/packages/node-runtime/src/ai/llm-config-store.ts b/packages/node-runtime/src/ai/llm-config-store.ts new file mode 100644 index 00000000..8116785b --- /dev/null +++ b/packages/node-runtime/src/ai/llm-config-store.ts @@ -0,0 +1,276 @@ +/** + * LLM configuration CRUD store — platform-agnostic. + * Uses ConfigStorage abstraction for persistence and dependency injection + * for i18n, UUID generation, and auth profile management. + */ + +import type { ModelSlot } from '@openchatlab/core' + +// ==================== Types ==================== + +export interface AIServiceConfig { + id: string + name: string + provider: string + apiKey: string + model?: string + baseUrl?: string + maxTokens?: number + apiFormat?: string + disableThinking?: boolean + isReasoningModel?: boolean + customModels?: Array<{ id: string; name: string }> + createdAt: number + updatedAt: number +} + +export interface AIConfigStore { + configs: AIServiceConfig[] + defaultAssistant: ModelSlot | null + fastModel: ModelSlot | null +} + +export const MAX_CONFIG_COUNT = 99 + +// ==================== Storage abstraction ==================== + +export interface ConfigStorage { + readJson(key: string): T | null + writeJson(key: string, data: T): void +} + +// ==================== Dependencies ==================== + +export interface LLMConfigStoreDeps { + t?: (key: string, options?: Record) => string + generateId?: () => string + onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => void + resolveApiKey?: (provider: string, authProfile?: string) => string | undefined + onStoreLoaded?: (configs: AIServiceConfig[]) => void +} + +// ==================== Config Store ==================== + +function defaultT(key: string, options?: Record): string { + if (key === 'llm.maxConfigs' && options?.count) return `Maximum ${options.count} configurations allowed` + if (key === 'llm.configNotFound') return 'Configuration not found' + return key +} + +function defaultGenerateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function resolveSlot(slot: ModelSlot | null | undefined, configs: AIServiceConfig[]): ModelSlot | null { + if (slot && configs.some((c) => c.id === slot.configId)) return slot + const fallback = configs[0] + return fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null +} + +export class LLMConfigStore { + private storage: ConfigStorage + private t: (key: string, options?: Record) => string + private generateId: () => string + private onApiKeyCreated?: (config: AIServiceConfig, apiKey: string) => void + private resolveApiKey?: (provider: string, authProfile?: string) => string | undefined + private onStoreLoaded?: (configs: AIServiceConfig[]) => void + + constructor(storage: ConfigStorage, deps: LLMConfigStoreDeps = {}) { + this.storage = storage + this.t = deps.t || defaultT + this.generateId = deps.generateId || defaultGenerateId + this.onApiKeyCreated = deps.onApiKeyCreated + this.resolveApiKey = deps.resolveApiKey + this.onStoreLoaded = deps.onStoreLoaded + } + + loadStore(): AIConfigStore { + const store = this.storage.readJson('llm-config') + if (!store) { + return { configs: [], defaultAssistant: null, fastModel: null } + } + + this.onStoreLoaded?.(store.configs) + + const resolvedConfigs = this.resolveApiKey + ? store.configs.map((config) => { + const profileKey = this.resolveApiKey!( + config.provider, + (config as unknown as Record).authProfile as string | undefined + ) + return { ...config, apiKey: profileKey || config.apiKey || '' } + }) + : store.configs + + return { + ...store, + configs: resolvedConfigs, + defaultAssistant: resolveSlot(store.defaultAssistant, resolvedConfigs), + fastModel: resolveSlot(store.fastModel, resolvedConfigs), + } + } + + saveStore(store: AIConfigStore): void { + this.storage.writeJson('llm-config', { + ...store, + configs: store.configs.map((config) => ({ + ...config, + apiKey: '', + })), + }) + } + + getAllConfigs(): AIServiceConfig[] { + return this.loadStore().configs + } + + getDefaultAssistantSlot(): ModelSlot | null { + const store = this.loadStore() + return resolveSlot(store.defaultAssistant, store.configs) + } + + getDefaultAssistantConfig(): AIServiceConfig | null { + const store = this.loadStore() + const slot = resolveSlot(store.defaultAssistant, store.configs) + if (!slot) return null + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) return null + return { ...config, model: slot.modelId || config.model } + } + + getFastModelSlot(): ModelSlot | null { + const store = this.loadStore() + if (store.fastModel === null) return null + return resolveSlot(store.fastModel, store.configs) + } + + getFastModelConfig(): AIServiceConfig | null { + const store = this.loadStore() + if (store.fastModel === null) return this.getDefaultAssistantConfig() + + const slot = resolveSlot(store.fastModel, store.configs) + if (slot) { + const config = store.configs.find((c) => c.id === slot.configId) + if (config) return { ...config, model: slot.modelId || config.model } + } + return this.getDefaultAssistantConfig() + } + + getConfigById(id: string): AIServiceConfig | null { + const store = this.loadStore() + return store.configs.find((c) => c.id === id) || null + } + + addConfig(config: Omit): { + success: boolean + config?: AIServiceConfig + error?: string + } { + const store = this.loadStore() + + if (store.configs.length >= MAX_CONFIG_COUNT) { + return { success: false, error: this.t('llm.maxConfigs', { count: MAX_CONFIG_COUNT }) } + } + + const now = Date.now() + const newConfig: AIServiceConfig = { + ...config, + id: this.generateId(), + createdAt: now, + updatedAt: now, + } + + store.configs.push(newConfig) + + if (store.configs.length === 1) { + store.defaultAssistant = { configId: newConfig.id, modelId: newConfig.model || '' } + } + + if (newConfig.apiKey && this.onApiKeyCreated) { + this.onApiKeyCreated(newConfig, newConfig.apiKey) + } + + this.saveStore(store) + return { success: true, config: newConfig } + } + + updateConfig( + id: string, + updates: Partial> + ): { success: boolean; error?: string } { + const store = this.loadStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: this.t('llm.configNotFound') } + } + + const updated = { + ...store.configs[index], + ...updates, + updatedAt: Date.now(), + } + store.configs[index] = updated + + if (updates.apiKey && this.onApiKeyCreated) { + this.onApiKeyCreated(updated, updates.apiKey) + } + + this.saveStore(store) + return { success: true } + } + + deleteConfig(id: string): { success: boolean; error?: string } { + const store = this.loadStore() + const index = store.configs.findIndex((c) => c.id === id) + + if (index === -1) { + return { success: false, error: this.t('llm.configNotFound') } + } + + store.configs.splice(index, 1) + + const fallback = store.configs[0] + if (store.defaultAssistant?.configId === id) { + store.defaultAssistant = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + if (store.fastModel?.configId === id) { + store.fastModel = fallback ? { configId: fallback.id, modelId: fallback.model || '' } : null + } + + this.saveStore(store) + return { success: true } + } + + setDefaultAssistantModel(configId: string, modelId: string): { success: boolean; error?: string } { + const store = this.loadStore() + const config = store.configs.find((c) => c.id === configId) + + if (!config) { + return { success: false, error: this.t('llm.configNotFound') } + } + + store.defaultAssistant = { configId, modelId } + this.saveStore(store) + return { success: true } + } + + setFastModel(slot: ModelSlot | null): { success: boolean; error?: string } { + const store = this.loadStore() + + if (slot !== null) { + const config = store.configs.find((c) => c.id === slot.configId) + if (!config) { + return { success: false, error: this.t('llm.configNotFound') } + } + } + + store.fastModel = slot + this.saveStore(store) + return { success: true } + } + + hasActiveConfig(): boolean { + return this.getDefaultAssistantConfig() !== null + } +} diff --git a/packages/node-runtime/src/index.ts b/packages/node-runtime/src/index.ts index 45689df8..40518b98 100644 --- a/packages/node-runtime/src/index.ts +++ b/packages/node-runtime/src/index.ts @@ -99,6 +99,10 @@ export { } from './ai' export type { SummaryDeps, SummaryMessage, SummaryOptions, SummaryResult } from './ai' +// LLM Config Store +export { LLMConfigStore, MAX_CONFIG_COUNT } from './ai' +export type { AIServiceConfig, AIConfigStore, ConfigStorage, LLMConfigStoreDeps } from './ai' + // Agent Event Handler export { AgentEventHandler, estimateTokensFromText } from './ai' export type { TokenUsage, AgentRuntimeStatus, AgentStreamChunk, EventHandlerConfig, EventHandlerContext } from './ai'