From 747b45b14fa89b16501cc9255dc8f020d1cf1430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Tue, 7 Apr 2026 11:23:59 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20rewrite=20providerService=20?= =?UTF-8?q?=E2=80=94=20cc-haha=20storage,=20full=20env=20sync,=20official?= =?UTF-8?q?=20clear?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/services/providerService.ts | 454 ++++++++++--------------- 1 file changed, 183 insertions(+), 271 deletions(-) diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index f0d1b313..9f1d08aa 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -1,11 +1,8 @@ /** - * Provider Service — AI provider configuration management + * Provider Service — preset-based provider configuration * - * Manages custom API providers (base URL, API key, models). - * When a provider is activated, its configuration is synced to - * ~/.claude/settings.json so that Claude Code uses it. - * - * Storage: ~/.claude/providers.json (atomic write via .tmp + rename) + * Storage: ~/.claude/cc-haha/providers.json (lightweight index) + * Active provider env vars written to ~/.claude/settings.json */ import * as fs from 'fs/promises' @@ -13,296 +10,237 @@ import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' import type { - Provider, + SavedProvider, + ProvidersIndex, CreateProviderInput, UpdateProviderInput, - ProvidersConfig, TestProviderInput, ProviderTestResult, } from '../types/provider.js' -const DEFAULT_CONFIG: ProvidersConfig = { - providers: [], - version: 1, -} +const MANAGED_ENV_KEYS = [ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_AUTH_TOKEN', + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', +] as const + +const DEFAULT_INDEX: ProvidersIndex = { activeId: null, providers: [] } export class ProviderService { - // --------------------------------------------------------------------------- - // Internal helpers - // --------------------------------------------------------------------------- - - /** Config directory, overridable via CLAUDE_CONFIG_DIR (for testing) */ private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } - /** Path to the providers configuration file */ - private getProvidersPath(): string { - return path.join(this.getConfigDir(), 'providers.json') + private getCcHahaDir(): string { + return path.join(this.getConfigDir(), 'cc-haha') } - /** Read providers config; returns empty defaults when the file does not exist */ - private async readProvidersConfig(): Promise { + private getIndexPath(): string { + return path.join(this.getCcHahaDir(), 'providers.json') + } + + private getSettingsPath(): string { + return path.join(this.getConfigDir(), 'settings.json') + } + + private async readIndex(): Promise { try { - const raw = await fs.readFile(this.getProvidersPath(), 'utf-8') - return JSON.parse(raw) as ProvidersConfig + const raw = await fs.readFile(this.getIndexPath(), 'utf-8') + return JSON.parse(raw) as ProvidersIndex } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { - return { ...DEFAULT_CONFIG, providers: [] } + return { ...DEFAULT_INDEX, providers: [] } } - throw ApiError.internal( - `Failed to read providers config: ${err}`, - ) + throw ApiError.internal(`Failed to read providers index: ${err}`) } } - /** Atomic write: write to .tmp first, then rename */ - private async writeProvidersConfig(config: ProvidersConfig): Promise { - const filePath = this.getProvidersPath() + private async writeIndex(index: ProvidersIndex): Promise { + const filePath = this.getIndexPath() const dir = path.dirname(filePath) await fs.mkdir(dir, { recursive: true }) const tmpFile = `${filePath}.tmp.${Date.now()}` try { - await fs.writeFile(tmpFile, JSON.stringify(config, null, 2) + '\n', 'utf-8') + await fs.writeFile(tmpFile, JSON.stringify(index, null, 2) + '\n', 'utf-8') await fs.rename(tmpFile, filePath) } catch (err) { await fs.unlink(tmpFile).catch(() => {}) - throw ApiError.internal(`Failed to write providers config: ${err}`) + throw ApiError.internal(`Failed to write providers index: ${err}`) } } - // --------------------------------------------------------------------------- - // CRUD - // --------------------------------------------------------------------------- - - /** List all providers */ - async listProviders(): Promise { - const config = await this.readProvidersConfig() - return config.providers - } - - /** Get a single provider by id; throws 404 if not found */ - async getProvider(id: string): Promise { - const config = await this.readProvidersConfig() - const provider = config.providers.find((p) => p.id === id) - if (!provider) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - return provider - } - - /** Get the currently active provider, or null if none */ - async getActiveProvider(): Promise { - const config = await this.readProvidersConfig() - return config.providers.find((p) => p.isActive) ?? null - } - - /** Add a new provider. If it is the first one, activate it automatically. */ - async addProvider(input: CreateProviderInput): Promise { - const config = await this.readProvidersConfig() - const now = Date.now() - const isFirst = config.providers.length === 0 - - const provider: Provider = { - id: crypto.randomUUID(), - name: input.name, - baseUrl: input.baseUrl, - apiKey: input.apiKey, - models: input.models, - isActive: isFirst, - createdAt: now, - updatedAt: now, - ...(input.notes !== undefined && { notes: input.notes }), - } - - config.providers.push(provider) - - // Auto-activate the first provider with its first model - if (isFirst && provider.models.length > 0) { - config.activeModel = provider.models[0].id - } - - await this.writeProvidersConfig(config) - - // Sync to settings if this provider was auto-activated - if (isFirst && provider.models.length > 0) { - await this.syncToSettings(provider, provider.models[0].id) - } - - return provider - } - - /** Update an existing provider */ - async updateProvider(id: string, input: UpdateProviderInput): Promise { - const config = await this.readProvidersConfig() - const index = config.providers.findIndex((p) => p.id === id) - if (index === -1) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - - const existing = config.providers[index] - const updated: Provider = { - ...existing, - ...(input.name !== undefined && { name: input.name }), - ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), - ...(input.apiKey !== undefined && { apiKey: input.apiKey }), - ...(input.models !== undefined && { models: input.models }), - ...(input.notes !== undefined && { notes: input.notes }), - updatedAt: Date.now(), - } - - config.providers[index] = updated - - // If the updated provider is active, validate activeModel still exists - if (updated.isActive && config.activeModel) { - const modelStillExists = updated.models.some((m) => m.id === config.activeModel) - if (!modelStillExists) { - config.activeModel = updated.models[0]?.id - } - } - - await this.writeProvidersConfig(config) - - // Re-sync settings if active - if (updated.isActive && config.activeModel) { - await this.syncToSettings(updated, config.activeModel) - } - - return updated - } - - /** Delete a provider; refuses to delete an active provider */ - async deleteProvider(id: string): Promise { - const config = await this.readProvidersConfig() - const index = config.providers.findIndex((p) => p.id === id) - if (index === -1) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - - if (config.providers[index].isActive) { - throw ApiError.conflict( - 'Cannot delete an active provider. Deactivate it first by activating another provider.', - ) - } - - config.providers.splice(index, 1) - await this.writeProvidersConfig(config) - } - - // --------------------------------------------------------------------------- - // Activation - // --------------------------------------------------------------------------- - - /** - * Activate a provider with a specific model. - * - * 1. Validate provider exists and modelId belongs to it - * 2. Deactivate all providers - * 3. Activate the target provider - * 4. Set config.activeModel - * 5. Persist providers.json - * 6. Sync env to settings.json - */ - async activateProvider(id: string, modelId: string): Promise { - const config = await this.readProvidersConfig() - const provider = config.providers.find((p) => p.id === id) - if (!provider) { - throw ApiError.notFound(`Provider not found: ${id}`) - } - - const model = provider.models.find((m) => m.id === modelId) - if (!model) { - throw ApiError.badRequest( - `Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}`, - ) - } - - // Deactivate all, then activate target - for (const p of config.providers) { - p.isActive = false - } - provider.isActive = true - config.activeModel = modelId - - await this.writeProvidersConfig(config) - await this.syncToSettings(provider, modelId) - } - - // --------------------------------------------------------------------------- - // Settings sync - // --------------------------------------------------------------------------- - - /** - * Sync the active provider's configuration to settings.json. - * - * Preserves all existing fields; only updates `env` (merging into existing - * env vars) and `model`. - */ - private async syncToSettings(provider: Provider, modelId: string): Promise { - const settingsPath = path.join(this.getConfigDir(), 'settings.json') - - // Read existing settings - let settings: Record = {} + private async readSettings(): Promise> { try { - const raw = await fs.readFile(settingsPath, 'utf-8') - settings = JSON.parse(raw) as Record + const raw = await fs.readFile(this.getSettingsPath(), 'utf-8') + return JSON.parse(raw) as Record } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - throw ApiError.internal(`Failed to read settings.json: ${err}`) - } - // File doesn't exist yet — start with empty object + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {} + throw ApiError.internal(`Failed to read settings.json: ${err}`) } + } - // Merge env: preserve existing env vars, override only ours - const existingEnv = (settings.env as Record) || {} - settings.env = { - ...existingEnv, - ANTHROPIC_BASE_URL: provider.baseUrl, - ANTHROPIC_AUTH_TOKEN: provider.apiKey, - } - - // Set model - settings.model = modelId - - // Atomic write - const dir = path.dirname(settingsPath) + private async writeSettings(settings: Record): Promise { + const filePath = this.getSettingsPath() + const dir = path.dirname(filePath) await fs.mkdir(dir, { recursive: true }) - const tmpFile = `${settingsPath}.tmp.${Date.now()}` + const tmpFile = `${filePath}.tmp.${Date.now()}` try { await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') - await fs.rename(tmpFile, settingsPath) + await fs.rename(tmpFile, filePath) } catch (err) { await fs.unlink(tmpFile).catch(() => {}) throw ApiError.internal(`Failed to write settings.json: ${err}`) } } - // --------------------------------------------------------------------------- - // Connectivity testing - // --------------------------------------------------------------------------- + // --- CRUD --- - /** Test connectivity of a saved provider */ - async testProvider(id: string): Promise { - const provider = await this.getProvider(id) + async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { + const index = await this.readIndex() + return { providers: index.providers, activeId: index.activeId } + } - if (provider.models.length === 0) { - return { - success: false, - latencyMs: 0, - error: 'Provider has no models configured', - } + async getProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + return provider + } + + async addProvider(input: CreateProviderInput): Promise { + const index = await this.readIndex() + + const provider: SavedProvider = { + id: crypto.randomUUID(), + presetId: input.presetId, + name: input.name, + apiKey: input.apiKey, + baseUrl: input.baseUrl, + models: input.models, + ...(input.notes !== undefined && { notes: input.notes }), } + index.providers.push(provider) + await this.writeIndex(index) + return provider + } + + async updateProvider(id: string, input: UpdateProviderInput): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + const existing = index.providers[idx] + const updated: SavedProvider = { + ...existing, + ...(input.name !== undefined && { name: input.name }), + ...(input.apiKey !== undefined && { apiKey: input.apiKey }), + ...(input.baseUrl !== undefined && { baseUrl: input.baseUrl }), + ...(input.models !== undefined && { models: input.models }), + ...(input.notes !== undefined && { notes: input.notes }), + } + + index.providers[idx] = updated + await this.writeIndex(index) + + if (index.activeId === id) { + await this.syncToSettings(updated) + } + + return updated + } + + async deleteProvider(id: string): Promise { + const index = await this.readIndex() + const idx = index.providers.findIndex((p) => p.id === id) + if (idx === -1) throw ApiError.notFound(`Provider not found: ${id}`) + + if (index.activeId === id) { + throw ApiError.conflict('Cannot delete the active provider. Switch to another provider first.') + } + + index.providers.splice(idx, 1) + await this.writeIndex(index) + } + + // --- Activation --- + + async activateProvider(id: string): Promise { + const index = await this.readIndex() + const provider = index.providers.find((p) => p.id === id) + if (!provider) throw ApiError.notFound(`Provider not found: ${id}`) + + index.activeId = id + await this.writeIndex(index) + + if (provider.presetId === 'official') { + await this.clearProviderFromSettings() + } else { + await this.syncToSettings(provider) + } + } + + async activateOfficial(): Promise { + const index = await this.readIndex() + index.activeId = null + await this.writeIndex(index) + await this.clearProviderFromSettings() + } + + // --- Settings sync --- + + private async syncToSettings(provider: SavedProvider): Promise { + const settings = await this.readSettings() + const existingEnv = (settings.env as Record) || {} + + settings.env = { + ...existingEnv, + ANTHROPIC_BASE_URL: provider.baseUrl, + ANTHROPIC_AUTH_TOKEN: provider.apiKey, + ANTHROPIC_MODEL: provider.models.main, + ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku, + ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet, + ANTHROPIC_DEFAULT_OPUS_MODEL: provider.models.opus, + } + + await this.writeSettings(settings) + } + + private async clearProviderFromSettings(): Promise { + const settings = await this.readSettings() + const env = (settings.env as Record) || {} + + for (const key of MANAGED_ENV_KEYS) { + delete env[key] + } + + settings.env = env + if (Object.keys(env).length === 0) { + delete settings.env + } + + await this.writeSettings(settings) + } + + // --- Test --- + + async testProvider(id: string): Promise { + const provider = await this.getProvider(id) + if (!provider.baseUrl || !provider.apiKey) { + return { success: false, latencyMs: 0, error: 'Missing baseUrl or apiKey' } + } return this.testProviderConfig({ baseUrl: provider.baseUrl, apiKey: provider.apiKey, - modelId: provider.models[0].id, + modelId: provider.models.main, }) } - /** Test connectivity with an arbitrary configuration */ async testProviderConfig(input: TestProviderInput): Promise { const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` const start = Date.now() @@ -326,54 +264,28 @@ export class ProviderService { const latencyMs = Date.now() - start if (response.ok) { - return { - success: true, - latencyMs, - modelUsed: input.modelId, - httpStatus: response.status, - } + return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status } } - // Non-OK response — try to extract error message let errorMessage = `HTTP ${response.status}` try { const body = (await response.json()) as Record if (body.error && typeof body.error === 'object') { - const errObj = body.error as Record - errorMessage = (errObj.message as string) || errorMessage + errorMessage = ((body.error as Record).message as string) || errorMessage } else if (typeof body.message === 'string') { errorMessage = body.message } } catch { - // Could not parse body — use status text errorMessage = `HTTP ${response.status} ${response.statusText}` } - return { - success: false, - latencyMs, - error: errorMessage, - modelUsed: input.modelId, - httpStatus: response.status, - } + return { success: false, latencyMs, error: errorMessage, modelUsed: input.modelId, httpStatus: response.status } } catch (err: unknown) { const latencyMs = Date.now() - start - if (err instanceof DOMException && err.name === 'TimeoutError') { - return { - success: false, - latencyMs, - error: 'Request timed out after 15 seconds', - modelUsed: input.modelId, - } - } - - return { - success: false, - latencyMs, - error: err instanceof Error ? err.message : String(err), - modelUsed: input.modelId, + return { success: false, latencyMs, error: 'Request timed out after 15 seconds', modelUsed: input.modelId } } + return { success: false, latencyMs, error: err instanceof Error ? err.message : String(err), modelUsed: input.modelId } } } }