mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
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:
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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user