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
This commit is contained in:
Roo Code 2026-04-10 09:37:49 +00:00
parent 7adbfec2a4
commit 3dad9e289a
2 changed files with 284 additions and 5 deletions

View file

@ -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<string, unknown> = {
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<string, unknown> = {
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",
}),
)
})
})
})

View file

@ -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<string, { apiBaseUrl: string; oauthBaseUrl: string }> = {
"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<T>(apiCall: () => Promise<T>): Promise<T> {