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 <cursoragent@cursor.com>
This commit is contained in:
diguaandCursor committed 2026-05-18 00:07:28 +08:00
1 parent 640d16c6ec
commit 7feab45447
4 files changed
+416

No files matched your search

@@ -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<string, unknown> } {
const data = new Map<string, unknown>()
return {
data,
readJson<T>(key: string): T | null {
return (data.get(key) as T) ?? null
},
writeJson<T>(key: string, value: T): void {
data.set(key, JSON.parse(JSON.stringify(value)))
},
}
}
describe('LLMConfigStore', () => {
let storage: ReturnType<typeof createMemoryStorage>
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')
})
})
+4
View File
@@ -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'
@@ -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<T>(key: string): T | null
writeJson<T>(key: string, data: T): void
}
// ==================== Dependencies ====================
export interface LLMConfigStoreDeps {
t?: (key: string, options?: Record<string, unknown>) => 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, unknown>): 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, unknown>) => 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<AIConfigStore>('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<string, unknown>).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<AIConfigStore>('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<AIServiceConfig, 'id' | 'createdAt' | 'updatedAt'>): {
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<Omit<AIServiceConfig, 'id' | 'createdAt' | 'updatedAt'>>
): { 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
}
}
+4
View File
@@ -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'