-

+
{t('empty.title')}
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts
new file mode 100644
index 00000000..02a57ada
--- /dev/null
+++ b/desktop/src/stores/settingsStore.test.ts
@@ -0,0 +1,22 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+describe('settingsStore locale defaults', () => {
+ beforeEach(() => {
+ vi.resetModules()
+ window.localStorage.clear()
+ })
+
+ it('defaults to Chinese when no locale is stored', async () => {
+ const { useSettingsStore } = await import('./settingsStore')
+
+ expect(useSettingsStore.getState().locale).toBe('zh')
+ })
+
+ it('keeps a stored locale override', async () => {
+ window.localStorage.setItem('cc-haha-locale', 'en')
+
+ const { useSettingsStore } = await import('./settingsStore')
+
+ expect(useSettingsStore.getState().locale).toBe('en')
+ })
+})
diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts
index 2fa8f56e..3613287d 100644
--- a/desktop/src/stores/settingsStore.ts
+++ b/desktop/src/stores/settingsStore.ts
@@ -12,7 +12,7 @@ function getStoredLocale(): Locale {
const stored = localStorage.getItem(LOCALE_STORAGE_KEY)
if (stored === 'en' || stored === 'zh') return stored
} catch { /* localStorage unavailable */ }
- return 'en'
+ return 'zh'
}
type SettingsStore = {
diff --git a/desktop/src/theme/globals.css b/desktop/src/theme/globals.css
index 2ca0d01e..a9c4c085 100644
--- a/desktop/src/theme/globals.css
+++ b/desktop/src/theme/globals.css
@@ -636,6 +636,28 @@ button, input, textarea, select, a, [role="button"] {
::-webkit-scrollbar-thumb { background: var(--color-outline); border-radius: 9999px; opacity: 0.3; }
::-webkit-scrollbar-thumb:hover { opacity: 0.6; }
+.sidebar-scroll-area {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--color-outline) 72%, transparent) transparent;
+}
+
+.sidebar-scroll-area::-webkit-scrollbar {
+ width: 4px;
+}
+
+.sidebar-scroll-area::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.sidebar-scroll-area::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--color-outline) 78%, transparent);
+ border-radius: 9999px;
+}
+
+.sidebar-scroll-area::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in srgb, var(--color-outline) 92%, transparent);
+}
+
/* Animations */
@keyframes shimmer {
0%, 100% { opacity: 0.4; }
diff --git a/docs/en/index.md b/docs/en/index.md
index 155fd246..05acb4e1 100644
--- a/docs/en/index.md
+++ b/docs/en/index.md
@@ -6,7 +6,7 @@ hero:
text: Locally Runnable Claude Code
tagline: Repaired from leaked source, supports any Anthropic-compatible API endpoint (MiniMax, OpenRouter, etc.)
image:
- src: /images/banner.jpg
+ src: /images/logo-horizontal.png
alt: Claude Code Haha
actions:
- theme: brand
diff --git a/docs/images/app-icon-dark.png b/docs/images/app-icon-dark.png
new file mode 100644
index 00000000..19dc2785
Binary files /dev/null and b/docs/images/app-icon-dark.png differ
diff --git a/docs/images/app-icon-dark.svg b/docs/images/app-icon-dark.svg
new file mode 100644
index 00000000..e77d3d7a
--- /dev/null
+++ b/docs/images/app-icon-dark.svg
@@ -0,0 +1,26 @@
+
diff --git a/docs/images/app-icon-light.png b/docs/images/app-icon-light.png
new file mode 100644
index 00000000..02f20c4c
Binary files /dev/null and b/docs/images/app-icon-light.png differ
diff --git a/docs/images/app-icon-light.svg b/docs/images/app-icon-light.svg
new file mode 100644
index 00000000..582d3c54
--- /dev/null
+++ b/docs/images/app-icon-light.svg
@@ -0,0 +1,17 @@
+
diff --git a/docs/images/app-icon.jpg b/docs/images/app-icon.jpg
deleted file mode 100644
index 03098747..00000000
Binary files a/docs/images/app-icon.jpg and /dev/null differ
diff --git a/docs/images/app-icon.png b/docs/images/app-icon.png
index 289f77e2..02f20c4c 100644
Binary files a/docs/images/app-icon.png and b/docs/images/app-icon.png differ
diff --git a/docs/images/app-icon.svg b/docs/images/app-icon.svg
new file mode 100644
index 00000000..582d3c54
--- /dev/null
+++ b/docs/images/app-icon.svg
@@ -0,0 +1,17 @@
+
diff --git a/docs/images/banner.jpg b/docs/images/banner.jpg
deleted file mode 100644
index f2bc6f6e..00000000
Binary files a/docs/images/banner.jpg and /dev/null differ
diff --git a/docs/images/banner.png b/docs/images/banner.png
new file mode 100644
index 00000000..8a188b96
Binary files /dev/null and b/docs/images/banner.png differ
diff --git a/docs/images/banner.svg b/docs/images/banner.svg
new file mode 100644
index 00000000..7805ed12
--- /dev/null
+++ b/docs/images/banner.svg
@@ -0,0 +1,83 @@
+
diff --git a/docs/images/logo-horizontal.jpg b/docs/images/logo-horizontal.jpg
deleted file mode 100644
index 859b0eac..00000000
Binary files a/docs/images/logo-horizontal.jpg and /dev/null differ
diff --git a/docs/images/logo-horizontal.png b/docs/images/logo-horizontal.png
new file mode 100644
index 00000000..e1cc11e1
Binary files /dev/null and b/docs/images/logo-horizontal.png differ
diff --git a/docs/images/logo-horizontal.svg b/docs/images/logo-horizontal.svg
new file mode 100644
index 00000000..11cfd35e
--- /dev/null
+++ b/docs/images/logo-horizontal.svg
@@ -0,0 +1,68 @@
+
diff --git a/docs/index.md b/docs/index.md
index 4612c970..db49f622 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -6,7 +6,7 @@ hero:
text: 本地可运行的 Claude Code
tagline: 基于泄露源码修复,支持接入任意 Anthropic 兼容 API(MiniMax、OpenRouter 等)
image:
- src: /images/app-icon.png
+ src: /images/logo-horizontal.png
alt: Claude Code Haha
actions:
- theme: brand
diff --git a/docs/public/images/banner.jpg b/docs/public/images/banner.jpg
deleted file mode 100644
index f2bc6f6e..00000000
Binary files a/docs/public/images/banner.jpg and /dev/null differ
diff --git a/docs/public/images/banner.png b/docs/public/images/banner.png
new file mode 100644
index 00000000..8a188b96
Binary files /dev/null and b/docs/public/images/banner.png differ
diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts
index c47943e8..5a5bea6a 100644
--- a/src/server/__tests__/conversation-service.test.ts
+++ b/src/server/__tests__/conversation-service.test.ts
@@ -13,6 +13,7 @@ describe('ConversationService', () => {
let originalModel: string | undefined
let originalEntrypoint: string | undefined
let originalOAuthToken: string | undefined
+ let originalProviderManagedByHost: string | undefined
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'cc-haha-conversation-service-'))
@@ -22,6 +23,7 @@ describe('ConversationService', () => {
originalModel = process.env.ANTHROPIC_MODEL
originalEntrypoint = process.env.CLAUDE_CODE_ENTRYPOINT
originalOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
+ originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.ANTHROPIC_AUTH_TOKEN = 'test-token'
@@ -31,6 +33,7 @@ describe('ConversationService', () => {
// Clear inherited CLAUDE_CODE_ENTRYPOINT so tests can assert whether
// buildChildEnv injects it or not without interference from the shell env.
delete process.env.CLAUDE_CODE_ENTRYPOINT
+ delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
})
afterEach(async () => {
@@ -52,6 +55,9 @@ describe('ConversationService', () => {
if (originalOAuthToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN
else process.env.CLAUDE_CODE_OAUTH_TOKEN = originalOAuthToken
+ if (originalProviderManagedByHost === undefined) delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
+ else process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = originalProviderManagedByHost
+
await fs.rm(tmpDir, { recursive: true, force: true })
})
@@ -153,8 +159,9 @@ describe('ConversationService', () => {
})) as Record
expect(env.ANTHROPIC_BASE_URL).toBe(`http://127.0.0.1:3456/proxy/providers/${provider.id}`)
- expect(env.ANTHROPIC_AUTH_TOKEN).toBe('proxy-managed')
+ expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_MODEL).toBe('kimi-k2.6')
+ expect(env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST).toBe('1')
expect(env.CLAUDE_CODE_ENTRYPOINT).toBeUndefined()
})
@@ -181,7 +188,7 @@ describe('ConversationService', () => {
providerId: null,
})) as Record
- expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.CLAUDE_CODE_ENTRYPOINT).toBe('claude-desktop')
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('forced-official-token')
})
diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts
index cc818870..2dae22ce 100644
--- a/src/server/__tests__/conversations.test.ts
+++ b/src/server/__tests__/conversations.test.ts
@@ -718,4 +718,139 @@ describe('WebSocket Chat Integration', () => {
conversationService.stopSession(sessionId)
}
}, 20_000)
+
+ it('should wait for an in-flight runtime restart before sending the next user turn', async () => {
+ const providerService = new ProviderService()
+ const providerA = await providerService.addProvider({
+ presetId: 'custom',
+ name: 'Provider Restart A',
+ apiKey: 'key-a',
+ baseUrl: 'http://127.0.0.1:1/anthropic',
+ apiFormat: 'anthropic',
+ models: {
+ main: 'restart-a-main',
+ haiku: 'restart-a-haiku',
+ sonnet: 'restart-a-sonnet',
+ opus: 'restart-a-opus',
+ },
+ })
+ const providerB = await providerService.addProvider({
+ presetId: 'custom',
+ name: 'Provider Restart B',
+ apiKey: 'key-b',
+ baseUrl: 'http://127.0.0.1:1/anthropic',
+ apiFormat: 'anthropic',
+ models: {
+ main: 'restart-b-main',
+ haiku: 'restart-b-haiku',
+ sonnet: 'restart-b-sonnet',
+ opus: 'restart-b-opus',
+ },
+ })
+
+ const createRes = await fetch(`${baseUrl}/api/sessions`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ workDir: process.cwd() }),
+ })
+ expect(createRes.status).toBe(201)
+ const { sessionId } = await createRes.json() as { sessionId: string }
+
+ const originalStartSession = conversationService.startSession.bind(conversationService)
+ const startCalls: Array<{
+ sessionId: string
+ options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined
+ }> = []
+
+ conversationService.startSession = (async function patchedStartSession(
+ sid: string,
+ workDir: string,
+ sdkUrl: string,
+ options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null },
+ ) {
+ startCalls.push({ sessionId: sid, options })
+ return originalStartSession(sid, workDir, sdkUrl, options)
+ }) as typeof conversationService.startSession
+
+ try {
+ const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
+ let phase: 'boot' | 'turn1' | 'turn2' | 'turn3' | 'done' = 'boot'
+
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ ws.close()
+ reject(new Error(`Timed out waiting for runtime restart synchronization flow for session ${sessionId}`))
+ }, 15_000)
+
+ ws.onmessage = (event) => {
+ const msg = JSON.parse(event.data as string)
+
+ if (msg.type === 'connected' && phase === 'boot') {
+ ws.send(JSON.stringify({
+ type: 'set_runtime_config',
+ providerId: providerA.id,
+ modelId: 'restart-a-sonnet',
+ }))
+ ws.send(JSON.stringify({ type: 'user_message', content: 'first turn' }))
+ phase = 'turn1'
+ return
+ }
+
+ if (msg.type === 'error') {
+ clearTimeout(timeout)
+ ws.close()
+ reject(new Error(msg.message))
+ return
+ }
+
+ if (msg.type === 'message_complete' && phase === 'turn1') {
+ ws.send(JSON.stringify({
+ type: 'set_runtime_config',
+ providerId: providerB.id,
+ modelId: 'restart-b-opus',
+ }))
+ ws.send(JSON.stringify({ type: 'user_message', content: 'second turn immediately after switch' }))
+ phase = 'turn2'
+ return
+ }
+
+ if (msg.type === 'message_complete' && phase === 'turn2') {
+ ws.send(JSON.stringify({ type: 'user_message', content: 'third turn should reuse restarted runtime' }))
+ phase = 'turn3'
+ return
+ }
+
+ if (msg.type === 'message_complete' && phase === 'turn3') {
+ clearTimeout(timeout)
+ phase = 'done'
+ ws.close()
+ resolve()
+ }
+ }
+
+ ws.onerror = () => {
+ reject(new Error(`WebSocket error for runtime restart synchronization session ${sessionId}`))
+ }
+ })
+
+ expect(startCalls).toHaveLength(2)
+ expect(startCalls[0]).toMatchObject({
+ sessionId,
+ options: {
+ providerId: providerA.id,
+ model: 'restart-a-sonnet',
+ },
+ })
+ expect(startCalls[1]).toMatchObject({
+ sessionId,
+ options: {
+ providerId: providerB.id,
+ model: 'restart-b-opus',
+ },
+ })
+ } finally {
+ conversationService.startSession = originalStartSession
+ conversationService.stopSession(sessionId)
+ }
+ }, 20_000)
})
diff --git a/src/server/__tests__/providers-real.test.ts b/src/server/__tests__/providers-real.test.ts
index a5f48783..955867d4 100644
--- a/src/server/__tests__/providers-real.test.ts
+++ b/src/server/__tests__/providers-real.test.ts
@@ -66,7 +66,7 @@ describe('Real Provider Configs', () => {
// 验证写入 cc-haha/settings.json
const settings = await readCcHahaSettings()
expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
- expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
+ expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record).ANTHROPIC_MODEL).toBe('MiniMax-M2.7-highspeed')
// 验证原版 settings.json 没有被创建
@@ -106,7 +106,7 @@ describe('Real Provider Configs', () => {
await service.activateProvider(jiekou.id)
settings = await readCcHahaSettings()
expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
- expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-fake-test-key-for-testing-only')
+ expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('sk-fake-test-key-for-testing-only')
expect((settings.env as Record).ANTHROPIC_MODEL).toBe('claude-opus-4-7')
// 验证 activeId 正确
@@ -151,7 +151,7 @@ describe('Real Provider Configs', () => {
// 验证新字段写入
expect((settings.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.jiekou.ai/anthropic')
- expect((settings.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk_test')
+ expect((settings.env as Record).ANTHROPIC_API_KEY).toBe('sk_test')
// 验证已有字段保留
expect(settings.customField).toBe('should_be_preserved')
@@ -181,7 +181,7 @@ describe('Real Provider Configs', () => {
settings = await readCcHahaSettings()
const env = settings.env as Record | undefined
expect(env?.ANTHROPIC_BASE_URL).toBeUndefined()
- expect(env?.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
+ expect(env?.ANTHROPIC_API_KEY).toBeUndefined()
expect(env?.ANTHROPIC_MODEL).toBeUndefined()
console.log('✅ activateOfficial 正确清除了 provider env')
@@ -213,7 +213,7 @@ describe('Real Provider Configs', () => {
effortLevel: 'high',
env: {
ANTHROPIC_BASE_URL: 'https://original-claude-code.api.com',
- ANTHROPIC_AUTH_TOKEN: 'original-key',
+ ANTHROPIC_API_KEY: 'original-key',
},
}, null, 2),
)
@@ -231,13 +231,13 @@ describe('Real Provider Configs', () => {
// 验证原版 settings.json 没被修改
const original = JSON.parse(await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8'))
expect((original.env as Record).ANTHROPIC_BASE_URL).toBe('https://original-claude-code.api.com')
- expect((original.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('original-key')
+ expect((original.env as Record).ANTHROPIC_API_KEY).toBe('original-key')
expect(original.effortLevel).toBe('high')
// 验证 cc-haha/settings.json 是 Haha 自己的
const haha = await readCcHahaSettings()
expect((haha.env as Record).ANTHROPIC_BASE_URL).toBe('https://api.minimaxi.com/anthropic')
- expect((haha.env as Record).ANTHROPIC_AUTH_TOKEN).toBe('sk-haha-key')
+ expect((haha.env as Record).ANTHROPIC_API_KEY).toBe('sk-haha-key')
console.log('✅ 原版 settings.json 完好无损,Haha 配置独立存储')
})
diff --git a/src/server/__tests__/providers.test.ts b/src/server/__tests__/providers.test.ts
index fef8bf8e..426f8a3c 100644
--- a/src/server/__tests__/providers.test.ts
+++ b/src/server/__tests__/providers.test.ts
@@ -50,26 +50,30 @@ function makeRequest(
/** A sample provider input for reuse across tests */
function sampleInput(overrides?: Partial): CreateProviderInput {
return {
+ presetId: 'custom',
name: 'Test Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test-key-123',
- models: [
- { id: 'model-a', name: 'Model A' },
- { id: 'model-b', name: 'Model B' },
- ],
+ apiFormat: 'anthropic',
+ models: {
+ main: 'model-main',
+ haiku: 'model-haiku',
+ sonnet: 'model-sonnet',
+ opus: 'model-opus',
+ },
...overrides,
}
}
/** Read the settings.json written to the temp config dir */
async function readSettings(): Promise> {
- const raw = await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf-8')
+ const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')
return JSON.parse(raw) as Record
}
/** Read the providers.json written to the temp config dir */
async function readProvidersConfig(): Promise> {
- const raw = await fs.readFile(path.join(tmpDir, 'providers.json'), 'utf-8')
+ const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'providers.json'), 'utf-8')
return JSON.parse(raw) as Record
}
@@ -86,8 +90,8 @@ describe('ProviderService', () => {
describe('listProviders', () => {
test('should return empty array when no providers exist', async () => {
const svc = new ProviderService()
- const providers = await svc.listProviders()
- expect(providers).toEqual([])
+ const result = await svc.listProviders()
+ expect(result).toEqual({ providers: [], activeId: null })
})
test('should return all added providers', async () => {
@@ -95,10 +99,11 @@ describe('ProviderService', () => {
await svc.addProvider(sampleInput({ name: 'Provider A' }))
await svc.addProvider(sampleInput({ name: 'Provider B' }))
- const providers = await svc.listProviders()
+ const { providers, activeId } = await svc.listProviders()
expect(providers).toHaveLength(2)
expect(providers[0].name).toBe('Provider A')
expect(providers[1].name).toBe('Provider B')
+ expect(activeId).toBeNull()
})
})
@@ -113,35 +118,33 @@ describe('ProviderService', () => {
expect(provider.name).toBe('Test Provider')
expect(provider.baseUrl).toBe('https://api.example.com')
expect(provider.apiKey).toBe('sk-test-key-123')
- expect(provider.models).toHaveLength(2)
- expect(provider.createdAt).toBeGreaterThan(0)
- expect(provider.updatedAt).toBeGreaterThan(0)
+ expect(provider.models.main).toBe('model-main')
})
- test('first provider should be auto-activated', async () => {
+ test('new providers should not be auto-activated', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
- expect(provider.isActive).toBe(true)
+ expect(provider.id).toBeDefined()
+ const { activeId } = await svc.listProviders()
+ expect(activeId).toBeNull()
})
- test('first provider auto-activation should sync to settings.json', async () => {
+ test('adding a provider should not sync settings until activated', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput())
- const settings = await readSettings()
- const env = settings.env as Record
- expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
- expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-key-123')
- expect(settings.model).toBe('model-a')
+ await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')).rejects.toThrow()
})
- test('second provider should not be auto-activated', async () => {
+ test('adding additional providers should keep activeId unchanged', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(sampleInput({ name: 'Second' }))
- expect(second.isActive).toBe(false)
+ expect(second.id).toBeDefined()
+ const { activeId } = await svc.listProviders()
+ expect(activeId).toBeNull()
})
test('should preserve optional notes field', async () => {
@@ -193,7 +196,6 @@ describe('ProviderService', () => {
expect(updated.baseUrl).toBe('https://new-api.example.com')
// unchanged fields preserved
expect(updated.apiKey).toBe('sk-test-key-123')
- expect(updated.updatedAt).toBeGreaterThanOrEqual(added.updatedAt)
})
test('should throw 404 for non-existent provider', async () => {
@@ -211,8 +213,8 @@ describe('ProviderService', () => {
test('updating active provider should re-sync settings.json', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
+ await svc.activateProvider(added.id)
- // First provider is auto-activated, so updating it should re-sync
await svc.updateProvider(added.id, {
baseUrl: 'https://new-api.example.com',
apiKey: 'sk-new-key',
@@ -221,7 +223,8 @@ describe('ProviderService', () => {
const settings = await readSettings()
const env = settings.env as Record
expect(env.ANTHROPIC_BASE_URL).toBe('https://new-api.example.com')
- expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-new-key')
+ expect(env.ANTHROPIC_API_KEY).toBe('sk-new-key')
+ expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
})
@@ -236,7 +239,7 @@ describe('ProviderService', () => {
// Second is inactive, so deletion should succeed
await svc.deleteProvider(second.id)
- const providers = await svc.listProviders()
+ const { providers } = await svc.listProviders()
expect(providers).toHaveLength(1)
expect(providers[0].name).toBe('First')
})
@@ -244,6 +247,7 @@ describe('ProviderService', () => {
test('should throw 409 when deleting an active provider', async () => {
const svc = new ProviderService()
const active = await svc.addProvider(sampleInput())
+ await svc.activateProvider(active.id)
try {
await svc.deleteProvider(active.id)
@@ -281,14 +285,13 @@ describe('ProviderService', () => {
}),
)
- await svc.activateProvider(second.id, 'model-a')
+ await svc.activateProvider(second.id)
// Second should now be active
- const providers = await svc.listProviders()
- const activeFirst = providers.find((p) => p.id === first.id)
- const activeSecond = providers.find((p) => p.id === second.id)
- expect(activeFirst!.isActive).toBe(false)
- expect(activeSecond!.isActive).toBe(true)
+ const { activeId, providers } = await svc.listProviders()
+ expect(activeId).toBe(second.id)
+ expect(providers.find((p) => p.id === first.id)).toBeDefined()
+ expect(providers.find((p) => p.id === second.id)).toBeDefined()
})
test('should write correct settings.json on activation', async () => {
@@ -302,19 +305,23 @@ describe('ProviderService', () => {
}),
)
- await svc.activateProvider(second.id, 'model-b')
+ await svc.activateProvider(second.id)
const settings = await readSettings()
const env = settings.env as Record
expect(env.ANTHROPIC_BASE_URL).toBe('https://second-api.example.com')
- expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second-key')
- expect(settings.model).toBe('model-b')
+ expect(env.ANTHROPIC_API_KEY).toBe('sk-second-key')
+ expect(env.ANTHROPIC_MODEL).toBe('model-main')
+ expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku')
+ expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet')
+ expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus')
})
test('should preserve existing settings.json fields on activation', async () => {
// Pre-seed settings with an extra field
+ await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
- path.join(tmpDir, 'settings.json'),
+ path.join(tmpDir, 'cc-haha', 'settings.json'),
JSON.stringify({ theme: 'dark', env: { CUSTOM_VAR: 'keep-me' } }),
)
@@ -322,7 +329,7 @@ describe('ProviderService', () => {
const provider = await svc.addProvider(sampleInput())
// Re-activate to verify merge behavior
- await svc.activateProvider(provider.id, 'model-a')
+ await svc.activateProvider(provider.id)
const settings = await readSettings()
expect(settings.theme).toBe('dark')
@@ -331,24 +338,11 @@ describe('ProviderService', () => {
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
})
- test('should throw 400 for non-existent model id', async () => {
- const svc = new ProviderService()
- const provider = await svc.addProvider(sampleInput())
-
- try {
- await svc.activateProvider(provider.id, 'non-existent-model')
- expect(true).toBe(false)
- } catch (err: unknown) {
- const apiErr = err as { statusCode: number }
- expect(apiErr.statusCode).toBe(400)
- }
- })
-
test('should throw 404 for non-existent provider id', async () => {
const svc = new ProviderService()
try {
- await svc.activateProvider('non-existent-id', 'model-a')
+ await svc.activateProvider('non-existent-id')
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
@@ -356,33 +350,36 @@ describe('ProviderService', () => {
}
})
- test('activeModel should be persisted in providers.json', async () => {
+ test('activeId should be persisted in providers.json', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
- await svc.activateProvider(provider.id, 'model-b')
+ await svc.activateProvider(provider.id)
const config = await readProvidersConfig()
- expect(config.activeModel).toBe('model-b')
+ expect(config.activeId).toBe(provider.id)
})
})
- // ─── getActiveProvider ───────────────────────────────────────────────────
+ // ─── getProviderForProxy ─────────────────────────────────────────────────
- describe('getActiveProvider', () => {
- test('should return null when no providers exist', async () => {
+ describe('getProviderForProxy', () => {
+ test('should return null when no provider is active', async () => {
const svc = new ProviderService()
- const active = await svc.getActiveProvider()
+ const active = await svc.getProviderForProxy()
expect(active).toBeNull()
})
- test('should return the active provider', async () => {
+ test('should return the active provider proxy config', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
+ await svc.activateProvider(provider.id)
- const active = await svc.getActiveProvider()
+ const active = await svc.getProviderForProxy()
expect(active).not.toBeNull()
- expect(active!.id).toBe(provider.id)
+ expect(active!.baseUrl).toBe(provider.baseUrl)
+ expect(active!.apiKey).toBe(provider.apiKey)
+ expect(active!.apiFormat).toBe('anthropic')
})
})
})
@@ -424,17 +421,24 @@ describe('Providers API', () => {
test('POST /api/providers should create a provider', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
+ presetId: 'custom',
name: 'New Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
- models: [{ id: 'gpt-4', name: 'GPT-4' }],
+ apiFormat: 'anthropic',
+ models: {
+ main: 'gpt-4',
+ haiku: 'gpt-4-haiku',
+ sonnet: 'gpt-4-sonnet',
+ opus: 'gpt-4-opus',
+ },
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(201)
- const body = (await res.json()) as { provider: { name: string; isActive: boolean } }
+ const body = (await res.json()) as { provider: { name: string; models: { main: string } } }
expect(body.provider.name).toBe('New Provider')
- expect(body.provider.isActive).toBe(true) // first provider auto-activated
+ expect(body.provider.models.main).toBe('gpt-4')
})
test('POST /api/providers should return 400 for invalid input', async () => {
@@ -501,6 +505,7 @@ describe('Providers API', () => {
test('DELETE /api/providers/:id should return 409 for active provider', async () => {
const svc = new ProviderService()
const active = await svc.addProvider(sampleInput())
+ await svc.activateProvider(active.id)
const { req, url, segments } = makeRequest('DELETE', `/api/providers/${active.id}`)
const res = await handleProvidersApi(req, url, segments)
@@ -524,7 +529,6 @@ describe('Providers API', () => {
const { req, url, segments } = makeRequest(
'POST',
`/api/providers/${second.id}/activate`,
- { modelId: 'model-a' },
)
const res = await handleProvidersApi(req, url, segments)
@@ -536,11 +540,11 @@ describe('Providers API', () => {
const settings = await readSettings()
const env = settings.env as Record
expect(env.ANTHROPIC_BASE_URL).toBe('https://second.example.com')
- expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second')
- expect(settings.model).toBe('model-a')
+ expect(env.ANTHROPIC_API_KEY).toBe('sk-second')
+ expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
- test('POST /api/providers/:id/activate should return 400 for missing modelId', async () => {
+ test('POST /api/providers/:id/activate should not require modelId', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
@@ -551,10 +555,10 @@ describe('Providers API', () => {
)
const res = await handleProvidersApi(req, url, segments)
- expect(res.status).toBe(400)
+ expect(res.status).toBe(200)
})
- test('POST /api/providers/:id/activate should return 400 for invalid model', async () => {
+ test('POST /api/providers/:id/activate should ignore modelId because session runtime selects the model', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
@@ -565,7 +569,7 @@ describe('Providers API', () => {
)
const res = await handleProvidersApi(req, url, segments)
- expect(res.status).toBe(400)
+ expect(res.status).toBe(200)
})
// ─── Method not allowed ──────────────────────────────────────────────────
diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts
index cbce0b43..2577139f 100644
--- a/src/server/services/conversationService.ts
+++ b/src/server/services/conversationService.ts
@@ -569,6 +569,9 @@ export class ConversationService {
// should come from Desktop-managed config or inherited launch env, not
// be reintroduced from the repo's .env file.
CC_HAHA_SKIP_DOTENV: '1',
+ ...(explicitProviderEnv
+ ? { CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST: '1' }
+ : {}),
// "官方" 模式 (cc-haha/settings.json 没 provider env) 下,把 CLI 标记为
// managed-OAuth,让它忽略外部 ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN
// 残留、只走用户 /login 的 OAuth token。自定义 provider 模式绝不能设,
diff --git a/src/server/services/providerService.ts b/src/server/services/providerService.ts
index f5f92532..c06d5815 100644
--- a/src/server/services/providerService.ts
+++ b/src/server/services/providerService.ts
@@ -28,6 +28,7 @@ import type {
const MANAGED_ENV_KEYS = [
'ANTHROPIC_BASE_URL',
+ 'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
@@ -234,7 +235,7 @@ export class ProviderService {
return {
ANTHROPIC_BASE_URL: baseUrl,
- ANTHROPIC_AUTH_TOKEN: needsProxy ? 'proxy-managed' : provider.apiKey,
+ ANTHROPIC_API_KEY: needsProxy ? 'proxy-managed' : provider.apiKey,
ANTHROPIC_MODEL: provider.models.main,
ANTHROPIC_DEFAULT_HAIKU_MODEL: provider.models.haiku,
ANTHROPIC_DEFAULT_SONNET_MODEL: provider.models.sonnet,
@@ -252,9 +253,14 @@ export class ProviderService {
private async syncToSettings(provider: SavedProvider): Promise {
const settings = await this.readSettings()
const existingEnv = (settings.env as Record) || {}
+ const cleanedEnv = { ...existingEnv }
+
+ for (const key of MANAGED_ENV_KEYS) {
+ delete cleanedEnv[key]
+ }
settings.env = {
- ...existingEnv,
+ ...cleanedEnv,
...this.buildManagedEnv(provider),
}
diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts
index 4fe4c894..5d34193a 100644
--- a/src/server/ws/handler.ts
+++ b/src/server/ws/handler.ts
@@ -54,6 +54,8 @@ const runtimeOverrides = new Map()
+const runtimeTransitionPromises = new Map>()
+
export function getSlashCommands(sessionId: string): Array<{ name: string; description: string }> {
return sessionSlashCommands.get(sessionId) || []
}
@@ -202,6 +204,23 @@ async function handleUserMessage(
// Send thinking status
sendMessage(ws, { type: 'status', state: 'thinking', verb: 'Thinking' })
+ const pendingRuntimeTransition = runtimeTransitionPromises.get(sessionId)
+ if (pendingRuntimeTransition) {
+ try {
+ await pendingRuntimeTransition
+ } catch (err) {
+ const errMsg = err instanceof Error ? err.message : String(err)
+ console.error(`[WS] Runtime transition failed before handling user message for ${sessionId}: ${errMsg}`)
+ sendMessage(ws, {
+ type: 'error',
+ message: `Failed to switch provider/model: ${errMsg}`,
+ code: 'CLI_RESTART_FAILED',
+ })
+ sendMessage(ws, { type: 'status', state: 'idle' })
+ return
+ }
+ }
+
// 启动 CLI 子进程(如果还没有)
if (!conversationService.hasSession(sessionId)) {
try {
@@ -374,7 +393,9 @@ async function handleSetRuntimeConfig(
return
}
- await restartSessionWithRuntimeConfig(ws, sessionId)
+ await enqueueRuntimeTransition(sessionId, () =>
+ restartSessionWithRuntimeConfig(ws, sessionId),
+ )
}
async function restartSessionWithPermissionMode(
@@ -553,6 +574,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
sessionSlashCommands.delete(sessionId)
sessionTitleState.delete(sessionId)
runtimeOverrides.delete(sessionId)
+ runtimeTransitionPromises.delete(sessionId)
}
function translateCliMessage(cliMsg: any, sessionId: string): ServerMessage[] {
@@ -993,6 +1015,23 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
}
}
+function enqueueRuntimeTransition(
+ sessionId: string,
+ transition: () => Promise,
+): Promise {
+ const previous = runtimeTransitionPromises.get(sessionId) ?? Promise.resolve()
+ const next = previous
+ .catch(() => {})
+ .then(transition)
+ .finally(() => {
+ if (runtimeTransitionPromises.get(sessionId) === next) {
+ runtimeTransitionPromises.delete(sessionId)
+ }
+ })
+ runtimeTransitionPromises.set(sessionId, next)
+ return next
+}
+
/**
* Send a message to a specific session's WebSocket (for use by services)
*/
diff --git a/src/utils/betas.ts b/src/utils/betas.ts
index 7a1747ca..da429117 100644
--- a/src/utils/betas.ts
+++ b/src/utils/betas.ts
@@ -27,7 +27,7 @@ import { has1mContext } from './context.js'
import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js'
import { getCanonicalName } from './model/model.js'
import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js'
-import { getAPIProvider } from './model/providers.js'
+import { getAPIProvider, isFirstPartyAnthropicBaseUrl } from './model/providers.js'
import { getInitialSettings } from './settings/settings.js'
/**
@@ -215,6 +215,7 @@ export function getToolSearchBetaHeader(): string {
export function shouldIncludeFirstPartyOnlyBetas(): boolean {
return (
(getAPIProvider() === 'firstParty' || getAPIProvider() === 'foundry') &&
+ (getAPIProvider() !== 'firstParty' || isFirstPartyAnthropicBaseUrl()) &&
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)
)
}
@@ -398,6 +399,10 @@ export function getMergedBetas(
model: string,
options?: { isAgenticQuery?: boolean },
): string[] {
+ if (getAPIProvider() === 'firstParty' && !isFirstPartyAnthropicBaseUrl()) {
+ return []
+ }
+
const baseBetas = [...getModelBetas(model)]
// Agentic queries always need claude-code and cli-internal beta headers.