diff --git a/desktop/src/api/providers.ts b/desktop/src/api/providers.ts index ce36f1dc..e19ac1b7 100644 --- a/desktop/src/api/providers.ts +++ b/desktop/src/api/providers.ts @@ -33,6 +33,14 @@ export const providersApi = { return api.get('/api/providers/auth-status') }, + getSettings() { + return api.get>('/api/providers/settings') + }, + + updateSettings(settings: Record) { + return api.put<{ ok: true }>('/api/providers/settings', settings) + }, + create(input: CreateProviderInput) { return api.post('/api/providers', input) }, diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 1cb6335f..3a129ac9 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -102,7 +102,7 @@ export const en = { 'settings.providers.sameAsMain': 'Same as main', 'settings.providers.testConnection': 'Test Connection', 'settings.providers.settingsJson': 'Settings JSON', - 'settings.providers.settingsJsonDesc': '~/.claude/settings.json — edit directly, will be written on save.', + 'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — edit directly, will be written on save.', 'settings.providers.jsonError': 'JSON syntax error: {error}', 'settings.providers.apiFormat': 'API Format', 'settings.providers.apiFormatAnthropic': 'Anthropic Messages (native)', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index bba0f4da..30b7b962 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -104,7 +104,7 @@ export const zh: Record = { 'settings.providers.sameAsMain': '与主模型相同', 'settings.providers.testConnection': '测试连接', 'settings.providers.settingsJson': '设置 JSON', - 'settings.providers.settingsJsonDesc': '~/.claude/settings.json — 直接编辑,保存时写入。', + 'settings.providers.settingsJsonDesc': '~/.claude/cc-haha/settings.json — 直接编辑,保存时写入。', 'settings.providers.jsonError': 'JSON 语法错误: {error}', 'settings.providers.apiFormat': 'API 格式', 'settings.providers.apiFormatAnthropic': 'Anthropic Messages (原生)', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index ea851904..dfc85afc 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -350,8 +350,8 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF jsonPastedRef.current = false return } - import('../api/settings').then(({ settingsApi }) => { - settingsApi.getUser().then((settings) => { + import('../api/providers').then(({ providersApi }) => { + providersApi.getSettings().then((settings) => { const needsProxy = apiFormat !== 'anthropic' const merged = { ...settings, @@ -390,12 +390,13 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF if (!canSubmit) return setIsSubmitting(true) try { - // Write the edited settings.json first (for all presets including official) + // Write the edited cc-haha settings.json first so provider-specific model + // settings never conflict with the user's global ~/.claude/settings.json. if (settingsJson.trim()) { try { const parsed = JSON.parse(settingsJson) - const { settingsApi } = await import('../api/settings') - await settingsApi.updateUser(parsed) + const { providersApi } = await import('../api/providers') + await providersApi.updateSettings(parsed) } catch { // JSON validation already prevents this } diff --git a/src/server/__tests__/provider-presets.test.ts b/src/server/__tests__/provider-presets.test.ts index 2eb34ea1..c698905a 100644 --- a/src/server/__tests__/provider-presets.test.ts +++ b/src/server/__tests__/provider-presets.test.ts @@ -1,11 +1,41 @@ -import { describe, test, expect } from 'bun:test' +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import * as fs from 'fs/promises' +import * as os from 'os' +import * as path from 'path' import { handleProvidersApi } from '../api/providers.js' import { PROVIDER_PRESETS } from '../config/providerPresets.js' -function makeRequest(method: string, urlStr: string): { req: Request; url: URL; segments: string[] } { +let tmpDir: string +let originalConfigDir: string | undefined + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-presets-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + if (originalConfigDir !== undefined) { + process.env.CLAUDE_CONFIG_DIR = originalConfigDir + } else { + delete process.env.CLAUDE_CONFIG_DIR + } + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +function makeRequest( + method: string, + urlStr: string, + body?: Record, +): { req: Request; url: URL; segments: string[] } { const url = new URL(urlStr, 'http://localhost:3456') - const req = new Request(url.toString(), { method }) + const init: RequestInit = { method } + if (body) { + init.headers = { 'Content-Type': 'application/json' } + init.body = JSON.stringify(body) + } + const req = new Request(url.toString(), init) const segments = url.pathname.split('/').filter(Boolean) return { req, url, segments } } @@ -39,4 +69,37 @@ describe('provider presets API', () => { expect(kimi?.defaultModels.main).toBe('kimi-k2.6') expect(minimax?.defaultModels.main).toBe('MiniMax-M2.7') }) + + test('GET and PUT /api/providers/settings read and write cc-haha settings.json', async () => { + const initial = { + env: { + ANTHROPIC_MODEL: 'glm-5.1', + }, + model: 'glm-5.1', + } + await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true }) + await fs.writeFile( + path.join(tmpDir, 'cc-haha', 'settings.json'), + JSON.stringify(initial, null, 2), + 'utf-8', + ) + + const getReq = makeRequest('GET', '/api/providers/settings') + const getRes = await handleProvidersApi(getReq.req, getReq.url, getReq.segments) + expect(getRes.status).toBe(200) + expect(await getRes.json()).toEqual(initial) + + const updateBody = { + model: 'kimi-k2.6', + env: { + ANTHROPIC_MODEL: 'kimi-k2.6', + }, + } + const putReq = makeRequest('PUT', '/api/providers/settings', updateBody) + const putRes = await handleProvidersApi(putReq.req, putReq.url, putReq.segments) + expect(putRes.status).toBe(200) + + const updatedRaw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8') + expect(JSON.parse(updatedRaw)).toEqual(updateBody) + }) }) diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 018d06dc..f1a521c3 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -10,6 +10,7 @@ import { SettingsService } from '../services/settingsService.js' import { handleSettingsApi } from '../api/settings.js' import { handleModelsApi } from '../api/models.js' import { handleStatusApi, resetUsage, addUsage } from '../api/status.js' +import { ProviderService } from '../services/providerService.js' // ─── Test helpers ───────────────────────────────────────────────────────────── @@ -254,7 +255,7 @@ describe('Models API', () => { expect(res.status).toBe(200) const body = await res.json() expect(body.models).toBeArray() - expect(body.models.length).toBe(4) + expect(body.models.length).toBe(3) expect(body.models[0].id).toContain('claude') }) @@ -264,7 +265,7 @@ describe('Models API', () => { expect(res.status).toBe(200) const body = await res.json() - expect(body.model.id).toBe('claude-sonnet-4-6') + expect(body.model.id).toBe('claude-opus-4-7') }) it('PUT /api/models/current should switch model', async () => { @@ -291,6 +292,66 @@ describe('Models API', () => { expect(res.status).toBe(400) }) + it('GET /api/models/current should prefer cc-haha managed model over global user model when provider is active', async () => { + const settingsSvc = new SettingsService() + await settingsSvc.updateUserSettings({ model: 'kimi-k2.6' }) + + const providerSvc = new ProviderService() + const provider = await providerSvc.addProvider({ + presetId: 'zhipuglm', + name: 'Zhipu GLM', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + apiKey: 'test-key', + apiFormat: 'anthropic', + models: { + main: 'glm-5.1', + haiku: 'glm-4.5-air', + sonnet: 'glm-5-turbo', + opus: 'glm-5.1', + }, + }) + await providerSvc.activateProvider(provider.id) + await providerSvc.updateManagedSettings({ model: 'glm-5-turbo' }) + + const { req, url, segments } = makeRequest('GET', '/api/models/current') + const res = await handleModelsApi(req, url, segments) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.model.id).toBe('glm-5-turbo') + }) + + it('PUT /api/models/current should persist to cc-haha managed settings when provider is active', async () => { + const settingsSvc = new SettingsService() + const providerSvc = new ProviderService() + const provider = await providerSvc.addProvider({ + presetId: 'zhipuglm', + name: 'Zhipu GLM', + baseUrl: 'https://open.bigmodel.cn/api/anthropic', + apiKey: 'test-key', + apiFormat: 'anthropic', + models: { + main: 'glm-5.1', + haiku: 'glm-4.5-air', + sonnet: 'glm-5-turbo', + opus: 'glm-5.1', + }, + }) + await providerSvc.activateProvider(provider.id) + + const putReq = makeRequest('PUT', '/api/models/current', { + modelId: 'glm-5-turbo', + }) + const putRes = await handleModelsApi(putReq.req, putReq.url, putReq.segments) + expect(putRes.status).toBe(200) + + const managedSettings = await providerSvc.getManagedSettings() + expect(managedSettings.model).toBe('glm-5-turbo') + + const globalSettings = await settingsSvc.getUserSettings() + expect(globalSettings.model).toBeUndefined() + }) + it('GET /api/effort should return default effort level', async () => { const { req, url, segments } = makeRequest('GET', '/api/effort') const res = await handleModelsApi(req, url, segments) diff --git a/src/server/api/models.ts b/src/server/api/models.ts index f90d530b..5811aabd 100644 --- a/src/server/api/models.ts +++ b/src/server/api/models.ts @@ -100,28 +100,28 @@ async function handleModelsList(): Promise { async function handleCurrentModel(req: Request): Promise { if (req.method === 'GET') { - const settings = await settingsService.getUserSettings() - const explicitModel = (settings.model as string) || '' - const contextTier = (settings.modelContext as string) || undefined - const env = (settings.env as Record) || {} - // Build the full model list: prefer active provider's models, fall back to defaults const { providers, activeId } = await providerService.listProviders() const activeProvider = activeId ? providers.find((p) => p.id === activeId) : null + const settings = activeProvider + ? await providerService.getManagedSettings() + : await settingsService.getUserSettings() + const explicitModel = (settings.model as string) || '' + const contextTier = (settings.modelContext as string) || undefined + const env = (settings.env as Record) || {} let currentModelId: string let currentModelName: string if (activeProvider) { - // Provider is active — use the model from env (set by syncToSettings when provider was activated) - // unless user explicitly set a different model ID in settings + // Provider is active — only use the provider-managed cc-haha settings. + // This avoids leaking global ~/.claude/settings.json model choices into + // the active provider flow. const providerEnvModel = env.ANTHROPIC_MODEL - if (providerEnvModel && (!explicitModel || explicitModel === DEFAULT_MODEL)) { - // No explicit model override — use the provider's configured model + if (providerEnvModel && !explicitModel) { currentModelId = providerEnvModel currentModelName = providerEnvModel } else { - // User explicitly set a model (possibly from the provider's model list) currentModelId = explicitModel || providerEnvModel || activeProvider.models.main currentModelName = currentModelId } @@ -175,7 +175,12 @@ async function handleCurrentModel(req: Request): Promise { // Clear context tier when switching to a non-composite model updates.modelContext = undefined } - await settingsService.updateUserSettings(updates) + const { activeId } = await providerService.listProviders() + if (activeId) { + await providerService.updateManagedSettings(updates) + } else { + await settingsService.updateUserSettings(updates) + } return Response.json({ ok: true, model: modelId }) } diff --git a/src/server/api/providers.ts b/src/server/api/providers.ts index 8a2eaef0..1747828f 100644 --- a/src/server/api/providers.ts +++ b/src/server/api/providers.ts @@ -4,7 +4,9 @@ * GET /api/providers — list all saved providers + activeId * GET /api/providers/presets — list available presets * GET /api/providers/auth-status — check whether any usable auth exists + * GET /api/providers/settings — read cc-haha managed settings.json * POST /api/providers — add a provider + * PUT /api/providers/settings — update cc-haha managed settings.json * PUT /api/providers/:id — update a provider * DELETE /api/providers/:id — delete a provider * POST /api/providers/:id/activate — activate a saved provider @@ -62,6 +64,19 @@ export async function handleProvidersApi( return Response.json(status) } + // /api/providers/settings + if (id === 'settings') { + if (req.method === 'GET') { + return Response.json(await providerService.getManagedSettings()) + } + if (req.method === 'PUT') { + const body = await parseJsonBody(req) + await providerService.updateManagedSettings(body) + return Response.json({ ok: true }) + } + throw methodNotAllowed(req.method) + } + // POST /api/providers/official if (id === 'official' && req.method === 'POST') { await providerService.activateOfficial() diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts index b4683001..dc28eda3 100644 --- a/src/server/services/providerService.ts +++ b/src/server/services/providerService.ts @@ -115,6 +115,15 @@ export class ProviderService { } } + async getManagedSettings(): Promise> { + return this.readSettings() + } + + async updateManagedSettings(settings: Record): Promise { + const current = await this.readSettings() + await this.writeSettings(Object.assign({}, current, settings)) + } + // --- CRUD --- async listProviders(): Promise<{ providers: SavedProvider[]; activeId: string | null }> { @@ -530,4 +539,3 @@ function validateResponseBody( } return { ok: true, model: (body.model as string) || undefined } } - diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 86ae831a..c8695b78 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -850,27 +850,31 @@ async function getRuntimeSettings(): Promise<{ model?: string effort?: string }> { + // Check if a custom provider is active + const { activeId } = await providerService.listProviders() const userSettings = await settingsService.getUserSettings() + const providerSettings = activeId + ? await providerService.getManagedSettings() + : undefined + const modelSettings = providerSettings ?? userSettings const modelContext = - typeof userSettings.modelContext === 'string' && userSettings.modelContext.trim() - ? userSettings.modelContext + typeof modelSettings.modelContext === 'string' && modelSettings.modelContext.trim() + ? modelSettings.modelContext : undefined const effort = typeof userSettings.effort === 'string' && userSettings.effort.trim() ? userSettings.effort : undefined - // Check if a custom provider is active - const { activeId } = await providerService.listProviders() - let model: string | undefined if (activeId) { - // Provider is active — only pass --model if user explicitly selected a non-default model. - // Otherwise the CLI should use ANTHROPIC_MODEL from env (set by syncToSettings). - // Default Anthropic model should be overridden by the provider's model. - const baseModel = (userSettings.model as string) || '' - if (baseModel && baseModel !== 'claude-sonnet-4-6') { - // User explicitly selected a different model — pass it through + // Provider is active — only consult provider-managed cc-haha settings. + // Global ~/.claude/settings.json model values must not bleed into provider mode. + const baseModel = + typeof modelSettings.model === 'string' && modelSettings.model.trim() + ? modelSettings.model + : '' + if (baseModel) { model = baseModel if (modelContext) model += `:${modelContext}` }