diff --git a/src/api/providers/__tests__/qwen-code.spec.ts b/src/api/providers/__tests__/qwen-code.spec.ts new file mode 100644 index 0000000000..6cc05a91c6 --- /dev/null +++ b/src/api/providers/__tests__/qwen-code.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { QwenCodeHandler } from "../qwen-code" +import { promises as fs } from "node:fs" +import OpenAI from "openai" + +// Mock fs module +vi.mock("node:fs", () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})) + +// Mock OpenAI +vi.mock("openai", () => { + const mockCreate = vi.fn() + return { + default: vi.fn().mockImplementation(() => ({ + chat: { + completions: { + create: mockCreate, + }, + }, + })), + } +}) + +// Mock fetch for OAuth token refresh +global.fetch = vi.fn() + +describe("QwenCodeHandler", () => { + let handler: QwenCodeHandler + const mockCredentials = { + access_token: "test-access-token", + refresh_token: "test-refresh-token", + token_type: "Bearer", + expiry_date: Date.now() + 3600000, // 1 hour from now + resource_url: "https://dashscope.aliyuncs.com/compatible-mode/v1", + } + + beforeEach(() => { + vi.clearAllMocks() + // Mock reading credentials file + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockCredentials)) + vi.mocked(fs.writeFile).mockResolvedValue(undefined) + + handler = new QwenCodeHandler({ + apiModelId: "qwen-max", + }) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("Error Handling", () => { + it("should handle 400 errors with user-friendly message", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = { + status: 400, + message: "Invalid request format", + } + + vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError) + + // Override the ensureClient method to return our mock + handler["client"] = mockClient + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "Qwen API Error (400): Invalid request format. This may be due to invalid input format, unsupported file type, or request size limits.", + ) + }) + + it("should handle 401 errors and attempt token refresh", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = { + status: 401, + message: "Unauthorized", + } + + // First call fails with 401, second succeeds after refresh + vi.mocked(mockClient.chat.completions.create) + .mockRejectedValueOnce(mockError) + .mockResolvedValueOnce({ + choices: [{ message: { content: "Success after refresh" } }], + } as any) + + // Mock successful token refresh + vi.mocked(global.fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + token_type: "Bearer", + expires_in: 3600, + refresh_token: "new-refresh-token", + }), + } as any) + + handler["client"] = mockClient + + const result = await handler.completePrompt("test prompt") + expect(result).toBe("Success after refresh") + expect(global.fetch).toHaveBeenCalledTimes(1) + }) + + it("should handle 403 errors with permission message", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = { + status: 403, + message: "Access denied", + } + + vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError) + handler["client"] = mockClient + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "Qwen API Error (403): Access denied. Please check your API permissions.", + ) + }) + + it("should handle 429 rate limit errors", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = { + status: 429, + message: "Too many requests", + } + + vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError) + handler["client"] = mockClient + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "Qwen API Error (429): Too many requests. Please wait before making more requests.", + ) + }) + + it("should handle 500+ server errors", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = { + status: 503, + message: "Service unavailable", + } + + vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError) + handler["client"] = mockClient + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "Qwen API Error (503): Service unavailable. The Qwen service may be temporarily unavailable.", + ) + }) + + it("should handle generic errors with context", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = new Error("Network timeout") + + vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError) + handler["client"] = mockClient + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "Failed to complete prompt with Qwen model: Network timeout", + ) + }) + + it("should preserve already formatted Qwen API errors", async () => { + const mockClient = new OpenAI({ apiKey: "test" }) + const mockError = new Error("Qwen API Error (400): Already formatted error") + + vi.mocked(mockClient.chat.completions.create).mockRejectedValue(mockError) + handler["client"] = mockClient + + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + "Qwen API Error (400): Already formatted error", + ) + }) + }) + + describe("Model Configuration", () => { + it("should return correct model info", () => { + const model = handler.getModel() + expect(model.id).toBe("qwen-max") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBeGreaterThan(0) + }) + + it("should use default model when not specified", () => { + const defaultHandler = new QwenCodeHandler({}) + const model = defaultHandler.getModel() + expect(model.id).toBeDefined() + expect(model.info).toBeDefined() + }) + }) +}) diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index d930d9dfc7..d7bade7716 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -195,92 +195,128 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan client.apiKey = this.credentials.access_token client.baseURL = this.getBaseUrl(this.credentials) return await apiCall() + } else if (error.status === 400) { + // Bad request error - provide more context + const errorMessage = error?.message || error?.error?.message || "Bad request" + const detailedMessage = `Qwen API Error (400): ${errorMessage}. This may be due to invalid input format, unsupported file type, or request size limits.` + console.error(detailedMessage) + throw new Error(detailedMessage) + } else if (error.status === 403) { + // Forbidden error + const errorMessage = error?.message || error?.error?.message || "Forbidden" + throw new Error(`Qwen API Error (403): ${errorMessage}. Please check your API permissions.`) + } else if (error.status === 429) { + // Rate limit error + const errorMessage = error?.message || error?.error?.message || "Rate limit exceeded" + throw new Error(`Qwen API Error (429): ${errorMessage}. Please wait before making more requests.`) + } else if (error.status >= 500) { + // Server error + const errorMessage = error?.message || error?.error?.message || "Server error" + throw new Error( + `Qwen API Error (${error.status}): ${errorMessage}. The Qwen service may be temporarily unavailable.`, + ) } else { + // Other errors - preserve original error but add context + if (error.status) { + const errorMessage = error?.message || error?.error?.message || "Unknown error" + throw new Error(`Qwen API Error (${error.status}): ${errorMessage}`) + } throw error } } } override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - await this.ensureAuthenticated() - const client = this.ensureClient() - const model = this.getModel() + try { + await this.ensureAuthenticated() + const client = this.ensureClient() + const model = this.getModel() - const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { - role: "system", - content: systemPrompt, - } + const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { + role: "system", + content: systemPrompt, + } - const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model: model.id, - temperature: 0, - messages: convertedMessages, - stream: true, - stream_options: { include_usage: true }, - max_completion_tokens: model.info.maxTokens, - } + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: model.id, + temperature: 0, + messages: convertedMessages, + stream: true, + stream_options: { include_usage: true }, + max_completion_tokens: model.info.maxTokens, + } - const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) + const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) - let fullContent = "" + let fullContent = "" - for await (const apiChunk of stream) { - const delta = apiChunk.choices[0]?.delta ?? {} + for await (const apiChunk of stream) { + const delta = apiChunk.choices[0]?.delta ?? {} - if (delta.content) { - let newText = delta.content - if (newText.startsWith(fullContent)) { - newText = newText.substring(fullContent.length) - } - fullContent = delta.content + if (delta.content) { + let newText = delta.content + if (newText.startsWith(fullContent)) { + newText = newText.substring(fullContent.length) + } + fullContent = delta.content - if (newText) { - // Check for thinking blocks - if (newText.includes("") || newText.includes("")) { - // Simple parsing for thinking blocks - const parts = newText.split(/<\/?think>/g) - for (let i = 0; i < parts.length; i++) { - if (parts[i]) { - if (i % 2 === 0) { - // Outside thinking block - yield { - type: "text", - text: parts[i], - } - } else { - // Inside thinking block - yield { - type: "reasoning", - text: parts[i], + if (newText) { + // Check for thinking blocks + if (newText.includes("") || newText.includes("")) { + // Simple parsing for thinking blocks + const parts = newText.split(/<\/?think>/g) + for (let i = 0; i < parts.length; i++) { + if (parts[i]) { + if (i % 2 === 0) { + // Outside thinking block + yield { + type: "text", + text: parts[i], + } + } else { + // Inside thinking block + yield { + type: "reasoning", + text: parts[i], + } } } } - } - } else { - yield { - type: "text", - text: newText, + } else { + yield { + type: "text", + text: newText, + } } } } - } - if ("reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - text: (delta.reasoning_content as string | undefined) || "", + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + text: (delta.reasoning_content as string | undefined) || "", + } + } + + if (apiChunk.usage) { + yield { + type: "usage", + inputTokens: apiChunk.usage.prompt_tokens || 0, + outputTokens: apiChunk.usage.completion_tokens || 0, + } } } + } catch (error: any) { + // Log the error for debugging + console.error("Error in QwenCodeHandler.createMessage:", error) - if (apiChunk.usage) { - yield { - type: "usage", - inputTokens: apiChunk.usage.prompt_tokens || 0, - outputTokens: apiChunk.usage.completion_tokens || 0, - } + // Re-throw with a more user-friendly message if it's not already formatted + if (error.message && !error.message.startsWith("Qwen API Error")) { + throw new Error(`Failed to process request with Qwen model: ${error.message}`) } + throw error } } @@ -291,18 +327,29 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan } async completePrompt(prompt: string): Promise { - await this.ensureAuthenticated() - const client = this.ensureClient() - const model = this.getModel() + try { + await this.ensureAuthenticated() + const client = this.ensureClient() + const model = this.getModel() - const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: model.id, - messages: [{ role: "user", content: prompt }], - max_completion_tokens: model.info.maxTokens, + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: model.id, + messages: [{ role: "user", content: prompt }], + max_completion_tokens: model.info.maxTokens, + } + + const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) + + return response.choices[0]?.message.content || "" + } catch (error: any) { + // Log the error for debugging + console.error("Error in QwenCodeHandler.completePrompt:", error) + + // Re-throw with a more user-friendly message if it's not already formatted + if (error.message && !error.message.startsWith("Qwen API Error")) { + throw new Error(`Failed to complete prompt with Qwen model: ${error.message}`) + } + throw error } - - const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) - - return response.choices[0]?.message.content || "" } }