/** * Provider Service — preset-based provider configuration * * Storage: ~/.claude/cc-haha/providers.json (lightweight index) * Active provider env vars written to ~/.claude/settings.json */ import * as fs from 'fs/promises' import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' import type { SavedProvider, ProvidersIndex, CreateProviderInput, UpdateProviderInput, TestProviderInput, ProviderTestResult, } from '../types/provider.js' 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 { private getConfigDir(): string { return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') } private getCcHahaDir(): string { return path.join(this.getConfigDir(), 'cc-haha') } 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.getIndexPath(), 'utf-8') return JSON.parse(raw) as ProvidersIndex } catch (err: unknown) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return { ...DEFAULT_INDEX, providers: [] } } throw ApiError.internal(`Failed to read providers index: ${err}`) } } 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(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 index: ${err}`) } } private async readSettings(): Promise> { try { 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') return {} throw ApiError.internal(`Failed to read settings.json: ${err}`) } } private async writeSettings(settings: Record): Promise { const filePath = this.getSettingsPath() const dir = path.dirname(filePath) await fs.mkdir(dir, { recursive: true }) const tmpFile = `${filePath}.tmp.${Date.now()}` try { await fs.writeFile(tmpFile, JSON.stringify(settings, null, 2) + '\n', 'utf-8') await fs.rename(tmpFile, filePath) } catch (err) { await fs.unlink(tmpFile).catch(() => {}) throw ApiError.internal(`Failed to write settings.json: ${err}`) } } // --- CRUD --- async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { const index = await this.readIndex() return { providers: index.providers, activeId: index.activeId } } 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.main, }) } async testProviderConfig(input: TestProviderInput): Promise { const url = `${input.baseUrl.replace(/\/+$/, '')}/v1/messages` const start = Date.now() try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': input.apiKey, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: input.modelId, max_tokens: 1, messages: [{ role: 'user', content: 'Hi' }], }), signal: AbortSignal.timeout(15000), }) const latencyMs = Date.now() - start if (response.ok) { return { success: true, latencyMs, modelUsed: input.modelId, httpStatus: response.status } } let errorMessage = `HTTP ${response.status}` try { const body = (await response.json()) as Record if (body.error && typeof body.error === 'object') { errorMessage = ((body.error as Record).message as string) || errorMessage } else if (typeof body.message === 'string') { errorMessage = body.message } } catch { errorMessage = `HTTP ${response.status} ${response.statusText}` } 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 } } } }