Stop provider model selection from inheriting global Claude settings

The desktop provider flow was still mixing provider-managed state with
`~/.claude/settings.json`, which let unrelated tools leak fields like
`ANTHROPIC_REASONING_MODEL` and `model` back into the active provider path.
This change moves the provider JSON editor onto `~/.claude/cc-haha/settings.json`,
routes provider settings through dedicated `/api/providers/settings` endpoints,
and makes model reads/writes under an active provider use the managed cc-haha
settings instead of the global user settings file.

Constraint: Active provider model selection must be isolated from legacy ~/.claude/settings.json
Rejected: Keep merging provider JSON with settingsApi.getUser() | external tools can reintroduce unrelated model fields into the provider flow
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Any future provider-model UI or runtime change should read/write cc-haha managed settings first, not the global user settings file
Tested: bun test src/server/__tests__/provider-presets.test.ts src/server/__tests__/settings.test.ts; cd desktop && bun run lint
Not-tested: Manual desktop provider modal interaction after the cc-haha settings API switch
This commit is contained in:
程序员阿江(Relakkes) 2026-04-22 17:19:12 +08:00
parent e0142b286b
commit d8a44c0a54
10 changed files with 200 additions and 35 deletions

View File

@ -33,6 +33,14 @@ export const providersApi = {
return api.get<AuthStatusResponse>('/api/providers/auth-status')
},
getSettings() {
return api.get<Record<string, unknown>>('/api/providers/settings')
},
updateSettings(settings: Record<string, unknown>) {
return api.put<{ ok: true }>('/api/providers/settings', settings)
},
create(input: CreateProviderInput) {
return api.post<ProviderResponse>('/api/providers', input)
},

View File

@ -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)',

View File

@ -104,7 +104,7 @@ export const zh: Record<TranslationKey, string> = {
'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 (原生)',

View File

@ -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
}

View File

@ -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<string, unknown>,
): { 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)
})
})

View File

@ -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)

View File

@ -100,28 +100,28 @@ async function handleModelsList(): Promise<Response> {
async function handleCurrentModel(req: Request): Promise<Response> {
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<string, string>) || {}
// 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<string, string>) || {}
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<Response> {
// 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 })
}

View File

@ -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()

View File

@ -115,6 +115,15 @@ export class ProviderService {
}
}
async getManagedSettings(): Promise<Record<string, unknown>> {
return this.readSettings()
}
async updateManagedSettings(settings: Record<string, unknown>): Promise<void> {
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 }
}

View File

@ -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}`
}