mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
e0142b286b
commit
d8a44c0a54
@ -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)
|
||||
},
|
||||
|
||||
@ -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)',
|
||||
|
||||
@ -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 (原生)',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@ -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}`
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user