diff --git a/src/api/providers/__tests__/qwen-code-portal-mapping.spec.ts b/src/api/providers/__tests__/qwen-code-portal-mapping.spec.ts new file mode 100644 index 0000000000..f46d76b13f --- /dev/null +++ b/src/api/providers/__tests__/qwen-code-portal-mapping.spec.ts @@ -0,0 +1,227 @@ +// npx vitest run api/providers/__tests__/qwen-code-portal-mapping.spec.ts + +// Mock filesystem - must come before other imports +vi.mock("node:fs", () => ({ + promises: { + readFile: vi.fn(), + writeFile: vi.fn(), + }, +})) + +// Track the OpenAI client configuration +let capturedApiKey: string | undefined +let capturedBaseURL: string | undefined + +const mockCreate = vi.fn() +vi.mock("openai", () => { + return { + __esModule: true, + default: vi.fn().mockImplementation(() => { + const instance = { + _apiKey: "dummy-key-will-be-replaced", + _baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + get apiKey() { + return this._apiKey + }, + set apiKey(val: string) { + this._apiKey = val + capturedApiKey = val + }, + get baseURL() { + return this._baseURL + }, + set baseURL(val: string) { + this._baseURL = val + capturedBaseURL = val + }, + chat: { + completions: { + create: mockCreate, + }, + }, + } + return instance + }), + } +}) + +// Mock global fetch for token refresh +const mockFetch = vi.fn() +vi.stubGlobal("fetch", mockFetch) + +import { promises as fs } from "node:fs" +import { QwenCodeHandler } from "../qwen-code" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("QwenCodeHandler Portal URL Mapping", () => { + let handler: QwenCodeHandler + + beforeEach(() => { + vi.clearAllMocks() + capturedApiKey = undefined + capturedBaseURL = undefined + ;(fs.writeFile as any).mockResolvedValue(undefined) + }) + + function createHandlerWithCredentials(resourceUrl?: string) { + const mockCredentials: Record = { + access_token: "test-access-token", + refresh_token: "test-refresh-token", + token_type: "Bearer", + expiry_date: Date.now() + 3600000, // 1 hour from now (valid token) + } + if (resourceUrl !== undefined) { + mockCredentials.resource_url = resourceUrl + } + ;(fs.readFile as any).mockResolvedValue(JSON.stringify(mockCredentials)) + + const options: ApiHandlerOptions & { qwenCodeOauthPath?: string } = { + apiModelId: "qwen3-coder-plus", + } + handler = new QwenCodeHandler(options) + return handler + } + + function setupStreamMock() { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) + } + + describe("getBaseUrl mapping via createMessage", () => { + it("should map portal.qwen.ai to dashscope-intl API endpoint", async () => { + createHandlerWithCredentials("portal.qwen.ai") + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(capturedBaseURL).toBe("https://dashscope-intl.aliyuncs.com/compatible-mode/v1") + }) + + it("should map chat.qwen.ai to dashscope (China) API endpoint", async () => { + createHandlerWithCredentials("chat.qwen.ai") + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(capturedBaseURL).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1") + }) + + it("should use default dashscope URL when resource_url is absent", async () => { + createHandlerWithCredentials(undefined) + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(capturedBaseURL).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1") + }) + + it("should preserve existing dashscope URL in resource_url", async () => { + createHandlerWithCredentials("https://dashscope.aliyuncs.com/compatible-mode/v1") + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(capturedBaseURL).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1") + }) + + it("should handle portal.qwen.ai with https:// prefix", async () => { + createHandlerWithCredentials("https://portal.qwen.ai") + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(capturedBaseURL).toBe("https://dashscope-intl.aliyuncs.com/compatible-mode/v1") + }) + }) + + describe("OAuth token endpoint mapping via token refresh", () => { + function createHandlerWithExpiredCredentials(resourceUrl?: string) { + const mockCredentials: Record = { + access_token: "expired-access-token", + refresh_token: "test-refresh-token", + token_type: "Bearer", + expiry_date: Date.now() - 60000, // Expired 1 minute ago + } + if (resourceUrl !== undefined) { + mockCredentials.resource_url = resourceUrl + } + ;(fs.readFile as any).mockResolvedValue(JSON.stringify(mockCredentials)) + + const options: ApiHandlerOptions & { qwenCodeOauthPath?: string } = { + apiModelId: "qwen3-coder-plus", + } + handler = new QwenCodeHandler(options) + return handler + } + + function setupTokenRefreshMock() { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + access_token: "new-access-token", + refresh_token: "new-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }), + }) + } + + it("should use portal.qwen.ai token endpoint for portal.qwen.ai resource_url", async () => { + createHandlerWithExpiredCredentials("portal.qwen.ai") + setupTokenRefreshMock() + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(mockFetch).toHaveBeenCalledWith( + "https://portal.qwen.ai/api/v1/oauth2/token", + expect.objectContaining({ + method: "POST", + }), + ) + }) + + it("should use chat.qwen.ai token endpoint for chat.qwen.ai resource_url", async () => { + createHandlerWithExpiredCredentials("chat.qwen.ai") + setupTokenRefreshMock() + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(mockFetch).toHaveBeenCalledWith( + "https://chat.qwen.ai/api/v1/oauth2/token", + expect.objectContaining({ + method: "POST", + }), + ) + }) + + it("should use default chat.qwen.ai token endpoint when resource_url is absent", async () => { + createHandlerWithExpiredCredentials(undefined) + setupTokenRefreshMock() + setupStreamMock() + + const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" }) + await stream.next() + + expect(mockFetch).toHaveBeenCalledWith( + "https://chat.qwen.ai/api/v1/oauth2/token", + expect.objectContaining({ + method: "POST", + }), + ) + }) + }) +}) diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts index f2a207051e..bf25853df8 100644 --- a/src/api/providers/qwen-code.ts +++ b/src/api/providers/qwen-code.ts @@ -16,12 +16,27 @@ import { ApiStream } from "../transform/stream" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai" -const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token` const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56" const QWEN_DIR = ".qwen" const QWEN_CREDENTIAL_FILENAME = "oauth_creds.json" +// Mapping of known portal/resource_url domains to their corresponding API configurations. +// The Qwen Code CLI sets resource_url to a portal domain (e.g. "portal.qwen.ai"), +// which must be mapped to the correct Dashscope API base URL and OAuth token endpoint. +const QWEN_PORTAL_CONFIG: Record = { + "portal.qwen.ai": { + apiBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", + oauthBaseUrl: "https://portal.qwen.ai", + }, + "chat.qwen.ai": { + apiBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", + oauthBaseUrl: "https://chat.qwen.ai", + }, +} + +const DEFAULT_API_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" +const DEFAULT_OAUTH_BASE_URL = "https://chat.qwen.ai" + interface QwenOAuthCredentials { access_token: string refresh_token: string @@ -122,7 +137,8 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan client_id: QWEN_OAUTH_CLIENT_ID, } - const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + const tokenEndpoint = this.getOAuthTokenEndpoint(credentials) + const response = await fetch(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -184,12 +200,48 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan client.baseURL = this.getBaseUrl(this.credentials) } + /** + * Resolve the resource_url from credentials to the correct Dashscope API base URL. + * The Qwen Code CLI (v0.14.2+) sets resource_url to a portal domain like "portal.qwen.ai" + * which is NOT an API endpoint. We map known portal domains to their corresponding + * Dashscope API URLs. If resource_url is already a dashscope URL, we use it directly. + */ private getBaseUrl(creds: QwenOAuthCredentials): string { - let baseUrl = creds.resource_url || "https://dashscope.aliyuncs.com/compatible-mode/v1" + const resourceUrl = creds.resource_url + if (!resourceUrl) { + return DEFAULT_API_BASE_URL + } + + // Strip protocol for portal config lookup + const domain = resourceUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "") + const portalConfig = QWEN_PORTAL_CONFIG[domain] + if (portalConfig) { + return portalConfig.apiBaseUrl + } + + // If it's already a full dashscope-style URL, normalize and use it + let baseUrl = resourceUrl if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { baseUrl = `https://${baseUrl}` } - return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1` + return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/compatible-mode/v1` + } + + /** + * Resolve the OAuth token endpoint based on the resource_url from credentials. + * International users (portal.qwen.ai) need a different token endpoint than + * Chinese users (chat.qwen.ai). + */ + private getOAuthTokenEndpoint(creds: QwenOAuthCredentials | null): string { + const resourceUrl = creds?.resource_url + if (!resourceUrl) { + return `${DEFAULT_OAUTH_BASE_URL}/api/v1/oauth2/token` + } + + const domain = resourceUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "") + const portalConfig = QWEN_PORTAL_CONFIG[domain] + const oauthBase = portalConfig?.oauthBaseUrl || DEFAULT_OAUTH_BASE_URL + return `${oauthBase}/api/v1/oauth2/token` } private async callApiWithRetry(apiCall: () => Promise): Promise {