Skip to content

Commit 6c5d5d2

Browse files
committed
feat: extend Claude Code support for alternative providers (Z.ai, Qwen, DeepSeek)
- Add configuration file reading from multiple locations (~/.claude/settings.json, etc.) - Implement provider detection based on ANTHROPIC_BASE_URL environment variable - Support dynamic model selection for Z.ai, Qwen (Alibaba Cloud), and DeepSeek providers - Pass environment variables from Claude Code config to subprocess - Update frontend to display alternative provider models dynamically - Add comprehensive test coverage for new functionality - Update existing tests to handle async initialization Fixes #8452
1 parent 13534cc commit 6c5d5d2

File tree

6 files changed

+652
-32
lines changed

6 files changed

+652
-32
lines changed
Lines changed: 356 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,356 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from "vitest"
2+
import { ClaudeCodeHandler } from "../claude-code"
3+
import * as os from "os"
4+
import * as path from "path"
5+
import type { ApiHandlerOptions } from "../../../shared/api"
6+
7+
// Mock the fs module - matching the actual import style in claude-code.ts
8+
vi.mock("fs", () => ({
9+
promises: {
10+
readFile: vi.fn(),
11+
},
12+
}))
13+
14+
// Mock os module
15+
vi.mock("os", () => ({
16+
homedir: vi.fn(() => "/home/user"),
17+
platform: vi.fn(() => "linux"),
18+
}))
19+
20+
// Mock process.cwd
21+
const originalCwd = process.cwd
22+
beforeEach(() => {
23+
process.cwd = vi.fn(() => "/workspace")
24+
vi.clearAllMocks()
25+
})
26+
27+
afterEach(() => {
28+
process.cwd = originalCwd
29+
vi.restoreAllMocks()
30+
})
31+
32+
describe("ClaudeCodeHandler - Alternative Providers", () => {
33+
describe("readClaudeCodeConfig", () => {
34+
it("should read config from ~/.claude/settings.json first", async () => {
35+
const { promises: fs } = await import("fs")
36+
const mockConfig = {
37+
env: {
38+
ANTHROPIC_BASE_URL: "https://api.z.ai/v1",
39+
ANTHROPIC_MODEL: "glm-4.5",
40+
},
41+
}
42+
43+
;(fs.readFile as Mock).mockImplementation(async (filePath: string) => {
44+
if (filePath === path.join("/home/user", ".claude", "settings.json")) {
45+
return JSON.stringify(mockConfig)
46+
}
47+
throw new Error("File not found")
48+
})
49+
50+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
51+
// Access private method through any type assertion for testing
52+
const config = await (handler as any).readClaudeCodeConfig()
53+
54+
expect(config).toEqual(mockConfig)
55+
expect(fs.readFile).toHaveBeenCalledWith(path.join("/home/user", ".claude", "settings.json"), "utf8")
56+
})
57+
58+
it("should try multiple config locations in order", async () => {
59+
const { promises: fs } = await import("fs")
60+
;(fs.readFile as Mock).mockRejectedValue(new Error("File not found"))
61+
62+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
63+
// Clear cached config to force fresh read
64+
;(handler as any).cachedConfig = null
65+
const config = await (handler as any).readClaudeCodeConfig()
66+
67+
expect(config).toBeNull()
68+
// The constructor calls initializeModelDetection which also reads config,
69+
// so we expect 10 calls total (5 from constructor + 5 from our test)
70+
expect(fs.readFile).toHaveBeenCalledTimes(10)
71+
})
72+
73+
it("should cache config after first read", async () => {
74+
const { promises: fs } = await import("fs")
75+
const mockConfig = { env: { ANTHROPIC_BASE_URL: "https://api.z.ai/v1" } }
76+
;(fs.readFile as Mock).mockImplementation(async (filePath: string) => {
77+
if (filePath === path.join("/home/user", ".claude", "settings.json")) {
78+
return JSON.stringify(mockConfig)
79+
}
80+
throw new Error("File not found")
81+
})
82+
83+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
84+
// Clear any cached config first
85+
;(handler as any).cachedConfig = null
86+
const config1 = await (handler as any).readClaudeCodeConfig()
87+
const config2 = await (handler as any).readClaudeCodeConfig()
88+
89+
expect(config1).toBe(config2) // Same reference, cached
90+
expect(config1).toEqual(mockConfig)
91+
// Called once from constructor's initializeModelDetection and once from our test
92+
expect(fs.readFile).toHaveBeenCalledTimes(2)
93+
})
94+
})
95+
96+
describe("detectProviderFromConfig", () => {
97+
it("should detect Z.ai provider", async () => {
98+
const { promises: fs } = await import("fs")
99+
const mockConfig = {
100+
env: {
101+
ANTHROPIC_BASE_URL: "https://api.z.ai/v1",
102+
},
103+
}
104+
105+
;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig))
106+
107+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
108+
// Clear cached config to force fresh read
109+
;(handler as any).cachedConfig = null
110+
const provider = await (handler as any).detectProviderFromConfig()
111+
112+
expect(provider).toBeTruthy()
113+
expect(provider?.provider).toBe("zai")
114+
expect(provider?.models).toHaveProperty("glm-4.5")
115+
expect(provider?.models).toHaveProperty("glm-4.5-air")
116+
expect(provider?.models).toHaveProperty("glm-4.6")
117+
})
118+
119+
it("should detect Qwen provider from Dashscope URL", async () => {
120+
const { promises: fs } = await import("fs")
121+
const mockConfig = {
122+
env: {
123+
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/api/v1",
124+
},
125+
}
126+
127+
;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig))
128+
129+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
130+
// Clear cached config to force fresh read
131+
;(handler as any).cachedConfig = null
132+
const provider = await (handler as any).detectProviderFromConfig()
133+
134+
expect(provider).toBeTruthy()
135+
expect(provider?.provider).toBe("qwen-code")
136+
expect(provider?.models).toHaveProperty("qwen3-coder-plus")
137+
expect(provider?.models).toHaveProperty("qwen3-coder-flash")
138+
})
139+
140+
it("should detect DeepSeek provider", async () => {
141+
const { promises: fs } = await import("fs")
142+
const mockConfig = {
143+
env: {
144+
ANTHROPIC_BASE_URL: "https://api.deepseek.com/v1",
145+
},
146+
}
147+
148+
;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig))
149+
150+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
151+
// Clear cached config to force fresh read
152+
;(handler as any).cachedConfig = null
153+
const provider = await (handler as any).detectProviderFromConfig()
154+
155+
expect(provider).toBeTruthy()
156+
expect(provider?.provider).toBe("deepseek")
157+
expect(provider?.models).toHaveProperty("deepseek-chat")
158+
expect(provider?.models).toHaveProperty("deepseek-reasoner")
159+
})
160+
161+
it("should return null for standard Claude API", async () => {
162+
const { promises: fs } = await import("fs")
163+
const mockConfig = {
164+
env: {
165+
ANTHROPIC_BASE_URL: "https://api.anthropic.com/v1",
166+
},
167+
}
168+
169+
;(fs.readFile as Mock).mockImplementation(async () => JSON.stringify(mockConfig))
170+
171+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
172+
// Clear cached config to force fresh read
173+
;(handler as any).cachedConfig = null
174+
const provider = await (handler as any).detectProviderFromConfig()
175+
176+
expect(provider).toBeNull()
177+
})
178+
179+
it("should return null when no config exists", async () => {
180+
const { promises: fs } = await import("fs")
181+
;(fs.readFile as Mock).mockImplementation(async () => {
182+
throw new Error("File not found")
183+
})
184+
185+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
186+
// Clear cached config to force fresh read
187+
;(handler as any).cachedConfig = null
188+
const provider = await (handler as any).detectProviderFromConfig()
189+
190+
expect(provider).toBeNull()
191+
})
192+
})
193+
194+
describe("getAvailableModels", () => {
195+
it("should return Z.ai models when Z.ai is configured", async () => {
196+
const { promises: fs } = await import("fs")
197+
const mockConfig = {
198+
env: {
199+
ANTHROPIC_BASE_URL: "https://api.z.ai/v1",
200+
ANTHROPIC_MODEL: "glm-4.5",
201+
},
202+
}
203+
204+
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockConfig))
205+
206+
const result = await ClaudeCodeHandler.getAvailableModels()
207+
208+
expect(result).toBeTruthy()
209+
expect(result?.provider).toBe("zai")
210+
expect(result?.models).toHaveProperty("glm-4.5")
211+
expect(Object.keys(result?.models || {})).toContain("glm-4.5")
212+
expect(Object.keys(result?.models || {})).toContain("glm-4.5-air")
213+
expect(Object.keys(result?.models || {})).toContain("glm-4.6")
214+
})
215+
216+
it("should return default Claude models when no alternative provider", async () => {
217+
const { promises: fs } = await import("fs")
218+
;(fs.readFile as Mock).mockRejectedValue(new Error("File not found"))
219+
220+
const result = await ClaudeCodeHandler.getAvailableModels()
221+
222+
expect(result).toBeTruthy()
223+
expect(result?.provider).toBe("claude-code")
224+
expect(result?.models).toHaveProperty("claude-sonnet-4-5")
225+
expect(result?.models).toHaveProperty("claude-opus-4-1-20250805")
226+
})
227+
228+
it("should handle errors gracefully", async () => {
229+
const { promises: fs } = await import("fs")
230+
;(fs.readFile as Mock).mockRejectedValue(new Error("Permission denied"))
231+
232+
const result = await ClaudeCodeHandler.getAvailableModels()
233+
234+
expect(result).toBeTruthy()
235+
expect(result?.provider).toBe("claude-code")
236+
expect(result?.models).toBeDefined()
237+
})
238+
})
239+
240+
describe("getModel", () => {
241+
it("should return cached model info for alternative provider", async () => {
242+
const { promises: fs } = await import("fs")
243+
const mockConfig = {
244+
env: {
245+
ANTHROPIC_BASE_URL: "https://api.z.ai/v1",
246+
ANTHROPIC_MODEL: "glm-4.5",
247+
},
248+
}
249+
250+
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockConfig))
251+
252+
const handler = new ClaudeCodeHandler({ apiModelId: "glm-4.5" } as ApiHandlerOptions)
253+
254+
// Wait for initialization
255+
await new Promise((resolve) => setTimeout(resolve, 100))
256+
257+
const model = handler.getModel()
258+
259+
expect(model.id).toBe("glm-4.5")
260+
expect(model.info).toBeDefined()
261+
expect(model.info.maxTokens).toBeDefined()
262+
})
263+
264+
it("should use default model when cache not ready", () => {
265+
const handler = new ClaudeCodeHandler({} as ApiHandlerOptions)
266+
const model = handler.getModel()
267+
268+
expect(model.id).toBe("claude-sonnet-4-20250514")
269+
expect(model.info).toBeDefined()
270+
})
271+
272+
it("should override maxTokens with configured value", async () => {
273+
const handler = new ClaudeCodeHandler({
274+
claudeCodeMaxOutputTokens: 32000,
275+
} as ApiHandlerOptions)
276+
277+
// Wait for initialization
278+
await new Promise((resolve) => setTimeout(resolve, 100))
279+
280+
const model = handler.getModel()
281+
282+
expect(model.info.maxTokens).toBe(32000)
283+
})
284+
})
285+
286+
describe("createMessage with alternative providers", () => {
287+
it("should pass environment variables to runClaudeCode for Z.ai", async () => {
288+
const { promises: fs } = await import("fs")
289+
const mockConfig = {
290+
env: {
291+
ANTHROPIC_BASE_URL: "https://api.z.ai/v1",
292+
ANTHROPIC_MODEL: "glm-4.5",
293+
ANTHROPIC_API_KEY: "test-key",
294+
},
295+
}
296+
297+
;(fs.readFile as Mock).mockResolvedValue(JSON.stringify(mockConfig))
298+
299+
// Mock runClaudeCode
300+
const runClaudeCodeModule = await import("../../../integrations/claude-code/run")
301+
vi.spyOn(runClaudeCodeModule, "runClaudeCode").mockImplementation(async function* () {
302+
yield { type: "usage", inputTokens: 100, outputTokens: 50, totalCost: 0.001 }
303+
} as any)
304+
305+
const handler = new ClaudeCodeHandler({ apiModelId: "glm-4.5" } as ApiHandlerOptions)
306+
// Wait for initialization
307+
await new Promise((resolve) => setTimeout(resolve, 100))
308+
309+
const messages = [{ role: "user" as const, content: "test" }]
310+
311+
const generator = handler.createMessage("system prompt", messages)
312+
const results = []
313+
for await (const chunk of generator) {
314+
results.push(chunk)
315+
}
316+
317+
expect(runClaudeCodeModule.runClaudeCode).toHaveBeenCalledWith(
318+
expect.objectContaining({
319+
envVars: mockConfig.env,
320+
modelId: "glm-4.5",
321+
}),
322+
)
323+
})
324+
325+
it("should use standard Claude model ID when no alternative provider", async () => {
326+
const { promises: fs } = await import("fs")
327+
;(fs.readFile as Mock).mockRejectedValue(new Error("File not found"))
328+
329+
// Mock runClaudeCode
330+
const runClaudeCodeModule = await import("../../../integrations/claude-code/run")
331+
vi.spyOn(runClaudeCodeModule, "runClaudeCode").mockImplementation(async function* () {
332+
yield { type: "usage", inputTokens: 100, outputTokens: 50, totalCost: 0.001 }
333+
} as any)
334+
335+
const handler = new ClaudeCodeHandler({ apiModelId: "claude-sonnet-4-5" } as ApiHandlerOptions)
336+
// Wait for initialization to complete
337+
await new Promise((resolve) => setTimeout(resolve, 100))
338+
339+
const messages = [{ role: "user" as const, content: "test" }]
340+
341+
const generator = handler.createMessage("system prompt", messages)
342+
const results = []
343+
for await (const chunk of generator) {
344+
results.push(chunk)
345+
}
346+
347+
// claude-sonnet-4-5 is a valid ClaudeCodeModelId, so it should be used as-is
348+
expect(runClaudeCodeModule.runClaudeCode).toHaveBeenCalledWith(
349+
expect.objectContaining({
350+
modelId: "claude-sonnet-4-5",
351+
envVars: {},
352+
}),
353+
)
354+
})
355+
})
356+
})

0 commit comments

Comments
 (0)