Skip to content

Commit 3dad9e2

Browse files
committed
fix: map Qwen Code portal resource_url to correct Dashscope API endpoints
The Qwen Code CLI v0.14.2+ sets resource_url to portal domains like "portal.qwen.ai" instead of Dashscope API URLs. The handler was using this value directly as the API base URL, causing 400 errors. Changes: - Add QWEN_PORTAL_CONFIG mapping for known portal domains to their corresponding Dashscope API and OAuth token endpoints - portal.qwen.ai -> dashscope-intl.aliyuncs.com (international) - chat.qwen.ai -> dashscope.aliyuncs.com (China) - Derive OAuth token endpoint from resource_url instead of hardcoding chat.qwen.ai, so international users hit the correct token server - Add comprehensive tests for URL mapping behavior Closes #12061
1 parent 7adbfec commit 3dad9e2

File tree

2 files changed

+284
-5
lines changed

2 files changed

+284
-5
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
// npx vitest run api/providers/__tests__/qwen-code-portal-mapping.spec.ts
2+
3+
// Mock filesystem - must come before other imports
4+
vi.mock("node:fs", () => ({
5+
promises: {
6+
readFile: vi.fn(),
7+
writeFile: vi.fn(),
8+
},
9+
}))
10+
11+
// Track the OpenAI client configuration
12+
let capturedApiKey: string | undefined
13+
let capturedBaseURL: string | undefined
14+
15+
const mockCreate = vi.fn()
16+
vi.mock("openai", () => {
17+
return {
18+
__esModule: true,
19+
default: vi.fn().mockImplementation(() => {
20+
const instance = {
21+
_apiKey: "dummy-key-will-be-replaced",
22+
_baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
23+
get apiKey() {
24+
return this._apiKey
25+
},
26+
set apiKey(val: string) {
27+
this._apiKey = val
28+
capturedApiKey = val
29+
},
30+
get baseURL() {
31+
return this._baseURL
32+
},
33+
set baseURL(val: string) {
34+
this._baseURL = val
35+
capturedBaseURL = val
36+
},
37+
chat: {
38+
completions: {
39+
create: mockCreate,
40+
},
41+
},
42+
}
43+
return instance
44+
}),
45+
}
46+
})
47+
48+
// Mock global fetch for token refresh
49+
const mockFetch = vi.fn()
50+
vi.stubGlobal("fetch", mockFetch)
51+
52+
import { promises as fs } from "node:fs"
53+
import { QwenCodeHandler } from "../qwen-code"
54+
import type { ApiHandlerOptions } from "../../../shared/api"
55+
56+
describe("QwenCodeHandler Portal URL Mapping", () => {
57+
let handler: QwenCodeHandler
58+
59+
beforeEach(() => {
60+
vi.clearAllMocks()
61+
capturedApiKey = undefined
62+
capturedBaseURL = undefined
63+
;(fs.writeFile as any).mockResolvedValue(undefined)
64+
})
65+
66+
function createHandlerWithCredentials(resourceUrl?: string) {
67+
const mockCredentials: Record<string, unknown> = {
68+
access_token: "test-access-token",
69+
refresh_token: "test-refresh-token",
70+
token_type: "Bearer",
71+
expiry_date: Date.now() + 3600000, // 1 hour from now (valid token)
72+
}
73+
if (resourceUrl !== undefined) {
74+
mockCredentials.resource_url = resourceUrl
75+
}
76+
;(fs.readFile as any).mockResolvedValue(JSON.stringify(mockCredentials))
77+
78+
const options: ApiHandlerOptions & { qwenCodeOauthPath?: string } = {
79+
apiModelId: "qwen3-coder-plus",
80+
}
81+
handler = new QwenCodeHandler(options)
82+
return handler
83+
}
84+
85+
function setupStreamMock() {
86+
mockCreate.mockImplementationOnce(() => ({
87+
[Symbol.asyncIterator]: async function* () {
88+
yield {
89+
choices: [{ delta: { content: "Test response" } }],
90+
}
91+
},
92+
}))
93+
}
94+
95+
describe("getBaseUrl mapping via createMessage", () => {
96+
it("should map portal.qwen.ai to dashscope-intl API endpoint", async () => {
97+
createHandlerWithCredentials("portal.qwen.ai")
98+
setupStreamMock()
99+
100+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
101+
await stream.next()
102+
103+
expect(capturedBaseURL).toBe("https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
104+
})
105+
106+
it("should map chat.qwen.ai to dashscope (China) API endpoint", async () => {
107+
createHandlerWithCredentials("chat.qwen.ai")
108+
setupStreamMock()
109+
110+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
111+
await stream.next()
112+
113+
expect(capturedBaseURL).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1")
114+
})
115+
116+
it("should use default dashscope URL when resource_url is absent", async () => {
117+
createHandlerWithCredentials(undefined)
118+
setupStreamMock()
119+
120+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
121+
await stream.next()
122+
123+
expect(capturedBaseURL).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1")
124+
})
125+
126+
it("should preserve existing dashscope URL in resource_url", async () => {
127+
createHandlerWithCredentials("https://dashscope.aliyuncs.com/compatible-mode/v1")
128+
setupStreamMock()
129+
130+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
131+
await stream.next()
132+
133+
expect(capturedBaseURL).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1")
134+
})
135+
136+
it("should handle portal.qwen.ai with https:// prefix", async () => {
137+
createHandlerWithCredentials("https://portal.qwen.ai")
138+
setupStreamMock()
139+
140+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
141+
await stream.next()
142+
143+
expect(capturedBaseURL).toBe("https://dashscope-intl.aliyuncs.com/compatible-mode/v1")
144+
})
145+
})
146+
147+
describe("OAuth token endpoint mapping via token refresh", () => {
148+
function createHandlerWithExpiredCredentials(resourceUrl?: string) {
149+
const mockCredentials: Record<string, unknown> = {
150+
access_token: "expired-access-token",
151+
refresh_token: "test-refresh-token",
152+
token_type: "Bearer",
153+
expiry_date: Date.now() - 60000, // Expired 1 minute ago
154+
}
155+
if (resourceUrl !== undefined) {
156+
mockCredentials.resource_url = resourceUrl
157+
}
158+
;(fs.readFile as any).mockResolvedValue(JSON.stringify(mockCredentials))
159+
160+
const options: ApiHandlerOptions & { qwenCodeOauthPath?: string } = {
161+
apiModelId: "qwen3-coder-plus",
162+
}
163+
handler = new QwenCodeHandler(options)
164+
return handler
165+
}
166+
167+
function setupTokenRefreshMock() {
168+
mockFetch.mockResolvedValueOnce({
169+
ok: true,
170+
json: async () => ({
171+
access_token: "new-access-token",
172+
refresh_token: "new-refresh-token",
173+
token_type: "Bearer",
174+
expires_in: 3600,
175+
}),
176+
})
177+
}
178+
179+
it("should use portal.qwen.ai token endpoint for portal.qwen.ai resource_url", async () => {
180+
createHandlerWithExpiredCredentials("portal.qwen.ai")
181+
setupTokenRefreshMock()
182+
setupStreamMock()
183+
184+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
185+
await stream.next()
186+
187+
expect(mockFetch).toHaveBeenCalledWith(
188+
"https://portal.qwen.ai/api/v1/oauth2/token",
189+
expect.objectContaining({
190+
method: "POST",
191+
}),
192+
)
193+
})
194+
195+
it("should use chat.qwen.ai token endpoint for chat.qwen.ai resource_url", async () => {
196+
createHandlerWithExpiredCredentials("chat.qwen.ai")
197+
setupTokenRefreshMock()
198+
setupStreamMock()
199+
200+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
201+
await stream.next()
202+
203+
expect(mockFetch).toHaveBeenCalledWith(
204+
"https://chat.qwen.ai/api/v1/oauth2/token",
205+
expect.objectContaining({
206+
method: "POST",
207+
}),
208+
)
209+
})
210+
211+
it("should use default chat.qwen.ai token endpoint when resource_url is absent", async () => {
212+
createHandlerWithExpiredCredentials(undefined)
213+
setupTokenRefreshMock()
214+
setupStreamMock()
215+
216+
const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id" })
217+
await stream.next()
218+
219+
expect(mockFetch).toHaveBeenCalledWith(
220+
"https://chat.qwen.ai/api/v1/oauth2/token",
221+
expect.objectContaining({
222+
method: "POST",
223+
}),
224+
)
225+
})
226+
})
227+
})

src/api/providers/qwen-code.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,27 @@ import { ApiStream } from "../transform/stream"
1616
import { BaseProvider } from "./base-provider"
1717
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
1818

19-
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
20-
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`
2119
const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
2220
const QWEN_DIR = ".qwen"
2321
const QWEN_CREDENTIAL_FILENAME = "oauth_creds.json"
2422

23+
// Mapping of known portal/resource_url domains to their corresponding API configurations.
24+
// The Qwen Code CLI sets resource_url to a portal domain (e.g. "portal.qwen.ai"),
25+
// which must be mapped to the correct Dashscope API base URL and OAuth token endpoint.
26+
const QWEN_PORTAL_CONFIG: Record<string, { apiBaseUrl: string; oauthBaseUrl: string }> = {
27+
"portal.qwen.ai": {
28+
apiBaseUrl: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
29+
oauthBaseUrl: "https://portal.qwen.ai",
30+
},
31+
"chat.qwen.ai": {
32+
apiBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
33+
oauthBaseUrl: "https://chat.qwen.ai",
34+
},
35+
}
36+
37+
const DEFAULT_API_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
38+
const DEFAULT_OAUTH_BASE_URL = "https://chat.qwen.ai"
39+
2540
interface QwenOAuthCredentials {
2641
access_token: string
2742
refresh_token: string
@@ -122,7 +137,8 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
122137
client_id: QWEN_OAUTH_CLIENT_ID,
123138
}
124139

125-
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
140+
const tokenEndpoint = this.getOAuthTokenEndpoint(credentials)
141+
const response = await fetch(tokenEndpoint, {
126142
method: "POST",
127143
headers: {
128144
"Content-Type": "application/x-www-form-urlencoded",
@@ -184,12 +200,48 @@ export class QwenCodeHandler extends BaseProvider implements SingleCompletionHan
184200
client.baseURL = this.getBaseUrl(this.credentials)
185201
}
186202

203+
/**
204+
* Resolve the resource_url from credentials to the correct Dashscope API base URL.
205+
* The Qwen Code CLI (v0.14.2+) sets resource_url to a portal domain like "portal.qwen.ai"
206+
* which is NOT an API endpoint. We map known portal domains to their corresponding
207+
* Dashscope API URLs. If resource_url is already a dashscope URL, we use it directly.
208+
*/
187209
private getBaseUrl(creds: QwenOAuthCredentials): string {
188-
let baseUrl = creds.resource_url || "https://dashscope.aliyuncs.com/compatible-mode/v1"
210+
const resourceUrl = creds.resource_url
211+
if (!resourceUrl) {
212+
return DEFAULT_API_BASE_URL
213+
}
214+
215+
// Strip protocol for portal config lookup
216+
const domain = resourceUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
217+
const portalConfig = QWEN_PORTAL_CONFIG[domain]
218+
if (portalConfig) {
219+
return portalConfig.apiBaseUrl
220+
}
221+
222+
// If it's already a full dashscope-style URL, normalize and use it
223+
let baseUrl = resourceUrl
189224
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
190225
baseUrl = `https://${baseUrl}`
191226
}
192-
return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`
227+
return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/compatible-mode/v1`
228+
}
229+
230+
/**
231+
* Resolve the OAuth token endpoint based on the resource_url from credentials.
232+
* International users (portal.qwen.ai) need a different token endpoint than
233+
* Chinese users (chat.qwen.ai).
234+
*/
235+
private getOAuthTokenEndpoint(creds: QwenOAuthCredentials | null): string {
236+
const resourceUrl = creds?.resource_url
237+
if (!resourceUrl) {
238+
return `${DEFAULT_OAUTH_BASE_URL}/api/v1/oauth2/token`
239+
}
240+
241+
const domain = resourceUrl.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
242+
const portalConfig = QWEN_PORTAL_CONFIG[domain]
243+
const oauthBase = portalConfig?.oauthBaseUrl || DEFAULT_OAUTH_BASE_URL
244+
return `${oauthBase}/api/v1/oauth2/token`
193245
}
194246

195247
private async callApiWithRetry<T>(apiCall: () => Promise<T>): Promise<T> {

0 commit comments

Comments
 (0)