mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
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:
parent
7adbfec2a4
commit
3dad9e289a
2 changed files with 284 additions and 5 deletions
227
src/api/providers/__tests__/qwen-code-portal-mapping.spec.ts
Normal file
227
src/api/providers/__tests__/qwen-code-portal-mapping.spec.ts
Normal 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",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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> {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue