mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
292 lines
9.1 KiB
TypeScript
292 lines
9.1 KiB
TypeScript
/**
|
|
* 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<ProvidersIndex> {
|
|
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<void> {
|
|
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<Record<string, unknown>> {
|
|
try {
|
|
const raw = await fs.readFile(this.getSettingsPath(), 'utf-8')
|
|
return JSON.parse(raw) as Record<string, unknown>
|
|
} 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<string, unknown>): Promise<void> {
|
|
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<SavedProvider> {
|
|
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<SavedProvider> {
|
|
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<SavedProvider> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
const index = await this.readIndex()
|
|
index.activeId = null
|
|
await this.writeIndex(index)
|
|
await this.clearProviderFromSettings()
|
|
}
|
|
|
|
// --- Settings sync ---
|
|
|
|
private async syncToSettings(provider: SavedProvider): Promise<void> {
|
|
const settings = await this.readSettings()
|
|
const existingEnv = (settings.env as Record<string, string>) || {}
|
|
|
|
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<void> {
|
|
const settings = await this.readSettings()
|
|
const env = (settings.env as Record<string, string>) || {}
|
|
|
|
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<ProviderTestResult> {
|
|
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<ProviderTestResult> {
|
|
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<string, unknown>
|
|
if (body.error && typeof body.error === 'object') {
|
|
errorMessage = ((body.error as Record<string, unknown>).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 }
|
|
}
|
|
}
|
|
}
|