Skip to content

Commit 4bce661

Browse files
committed
feat: add Qwen Code CLI API Support with OAuth authentication
- Add QwenCodeHandler with OAuth2 authentication flow - Support automatic token refresh with 30-second buffer - Add configurable OAuth credential paths (supports ~/ expansion) - Integrate Qwen Code models (qwen3-coder-plus, qwen3-coder-flash) - Add UI components for OAuth path configuration - Include setup instructions and documentation links - Add validation for required OAuth credentials Implements comprehensive support for Qwen advanced coding models with enterprise-grade OAuth2 authentication, based on PR #5766 from Cline repository.
1 parent 8367b1a commit 4bce661

File tree

11 files changed

+420
-2
lines changed

11 files changed

+420
-2
lines changed

packages/types/src/provider-settings.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const providerNames = [
4848
"moonshot",
4949
"deepseek",
5050
"doubao",
51+
"qwen-code",
5152
"unbound",
5253
"requesty",
5354
"human-relay",
@@ -311,6 +312,10 @@ const ioIntelligenceSchema = apiModelIdProviderModelSchema.extend({
311312
ioIntelligenceApiKey: z.string().optional(),
312313
})
313314

315+
const qwenCodeSchema = apiModelIdProviderModelSchema.extend({
316+
qwenCodeOauthPath: z.string().optional(),
317+
})
318+
314319
const rooSchema = apiModelIdProviderModelSchema.extend({
315320
// No additional fields needed - uses cloud authentication
316321
})
@@ -352,6 +357,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
352357
fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })),
353358
featherlessSchema.merge(z.object({ apiProvider: z.literal("featherless") })),
354359
ioIntelligenceSchema.merge(z.object({ apiProvider: z.literal("io-intelligence") })),
360+
qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })),
355361
rooSchema.merge(z.object({ apiProvider: z.literal("roo") })),
356362
defaultSchema,
357363
])
@@ -390,6 +396,7 @@ export const providerSettingsSchema = z.object({
390396
...fireworksSchema.shape,
391397
...featherlessSchema.shape,
392398
...ioIntelligenceSchema.shape,
399+
...qwenCodeSchema.shape,
393400
...rooSchema.shape,
394401
...codebaseIndexProviderSchema.shape,
395402
})
@@ -440,7 +447,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
440447
}
441448

442449
export const MODELS_BY_PROVIDER: Record<
443-
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama">,
450+
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama" | "qwen-code">,
444451
{ id: ProviderName; label: string; models: string[] }
445452
> = {
446453
anthropic: {

packages/types/src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from "./moonshot.js"
1919
export * from "./ollama.js"
2020
export * from "./openai.js"
2121
export * from "./openrouter.js"
22+
export * from "./qwen-code.js"
2223
export * from "./requesty.js"
2324
export * from "./roo.js"
2425
export * from "./sambanova.js"
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
export type QwenCodeModelId = "qwen3-coder-plus" | "qwen3-coder-flash"
4+
5+
export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus"
6+
7+
export const qwenCodeModels = {
8+
"qwen3-coder-plus": {
9+
maxTokens: 65_536,
10+
contextWindow: 1_000_000,
11+
supportsImages: false,
12+
supportsPromptCache: false,
13+
inputPrice: 0,
14+
outputPrice: 0,
15+
cacheWritesPrice: 0,
16+
cacheReadsPrice: 0,
17+
description: "Qwen3 Coder Plus - High-performance coding model with 1M context window for large codebases",
18+
},
19+
"qwen3-coder-flash": {
20+
maxTokens: 65_536,
21+
contextWindow: 1_000_000,
22+
supportsImages: false,
23+
supportsPromptCache: false,
24+
inputPrice: 0,
25+
outputPrice: 0,
26+
cacheWritesPrice: 0,
27+
cacheReadsPrice: 0,
28+
description: "Qwen3 Coder Flash - Fast coding model with 1M context window optimized for speed",
29+
},
30+
} as const satisfies Record<QwenCodeModelId, ModelInfo>

src/api/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
ChutesHandler,
3131
LiteLLMHandler,
3232
ClaudeCodeHandler,
33+
QwenCodeHandler,
3334
SambaNovaHandler,
3435
IOIntelligenceHandler,
3536
DoubaoHandler,
@@ -108,6 +109,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
108109
return new DeepSeekHandler(options)
109110
case "doubao":
110111
return new DoubaoHandler(options)
112+
case "qwen-code":
113+
return new QwenCodeHandler(options)
111114
case "moonshot":
112115
return new MoonshotHandler(options)
113116
case "vscode-lm":

src/api/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export { OllamaHandler } from "./ollama"
2121
export { OpenAiNativeHandler } from "./openai-native"
2222
export { OpenAiHandler } from "./openai"
2323
export { OpenRouterHandler } from "./openrouter"
24+
export { QwenCodeHandler } from "./qwen-code"
2425
export { RequestyHandler } from "./requesty"
2526
export { SambaNovaHandler } from "./sambanova"
2627
export { UnboundHandler } from "./unbound"

src/api/providers/qwen-code.ts

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
import { promises as fs } from "node:fs"
2+
import { Anthropic } from "@anthropic-ai/sdk"
3+
import OpenAI from "openai"
4+
import * as os from "os"
5+
import * as path from "path"
6+
7+
import type { ModelInfo } from "@roo-code/types"
8+
import type { ApiHandlerOptions } from "../../shared/api"
9+
10+
import { convertToOpenAiMessages } from "../transform/openai-format"
11+
import { ApiStream } from "../transform/stream"
12+
import { BaseProvider } from "./base-provider"
13+
import type { SingleCompletionHandler } from "../index"
14+
15+
// --- Constants for Qwen OAuth2 ---
16+
const QWEN_OAUTH_BASE_URL = "https://chat.qwen.ai"
17+
const QWEN_OAUTH_TOKEN_ENDPOINT = `${QWEN_OAUTH_BASE_URL}/api/v1/oauth2/token`
18+
const QWEN_OAUTH_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"
19+
const QWEN_DIR = ".qwen"
20+
const QWEN_CREDENTIAL_FILENAME = "oauth_creds.json"
21+
22+
interface QwenOAuthCredentials {
23+
access_token: string
24+
refresh_token: string
25+
token_type: string
26+
expiry_date: number
27+
resource_url?: string
28+
}
29+
30+
interface QwenCodeHandlerOptions extends ApiHandlerOptions {
31+
qwenCodeOauthPath?: string
32+
}
33+
34+
function getQwenCachedCredentialPath(customPath?: string): string {
35+
if (customPath) {
36+
// Support custom path that starts with ~/ or is absolute
37+
if (customPath.startsWith("~/")) {
38+
return path.join(os.homedir(), customPath.slice(2))
39+
}
40+
return path.resolve(customPath)
41+
}
42+
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME)
43+
}
44+
45+
function objectToUrlEncoded(data: Record<string, string>): string {
46+
return Object.keys(data)
47+
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
48+
.join("&")
49+
}
50+
51+
export class QwenCodeHandler extends BaseProvider implements SingleCompletionHandler {
52+
protected options: QwenCodeHandlerOptions
53+
private credentials: QwenOAuthCredentials | null = null
54+
private client: OpenAI | undefined
55+
56+
constructor(options: QwenCodeHandlerOptions) {
57+
super()
58+
this.options = options
59+
}
60+
61+
private ensureClient(): OpenAI {
62+
if (!this.client) {
63+
// Create the client instance with dummy key initially
64+
// The API key will be updated dynamically via ensureAuthenticated
65+
this.client = new OpenAI({
66+
apiKey: "dummy-key-will-be-replaced",
67+
baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
68+
})
69+
}
70+
return this.client
71+
}
72+
73+
private async loadCachedQwenCredentials(): Promise<QwenOAuthCredentials> {
74+
try {
75+
const keyFile = getQwenCachedCredentialPath(this.options.qwenCodeOauthPath)
76+
const credsStr = await fs.readFile(keyFile, "utf-8")
77+
return JSON.parse(credsStr)
78+
} catch (error) {
79+
console.error(
80+
`Error reading or parsing credentials file at ${getQwenCachedCredentialPath(this.options.qwenCodeOauthPath)}`,
81+
)
82+
throw new Error(`Failed to load Qwen OAuth credentials: ${error}`)
83+
}
84+
}
85+
86+
private async refreshAccessToken(credentials: QwenOAuthCredentials): Promise<QwenOAuthCredentials> {
87+
if (!credentials.refresh_token) {
88+
throw new Error("No refresh token available in credentials.")
89+
}
90+
91+
const bodyData = {
92+
grant_type: "refresh_token",
93+
refresh_token: credentials.refresh_token,
94+
client_id: QWEN_OAUTH_CLIENT_ID,
95+
}
96+
97+
const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, {
98+
method: "POST",
99+
headers: {
100+
"Content-Type": "application/x-www-form-urlencoded",
101+
Accept: "application/json",
102+
},
103+
body: objectToUrlEncoded(bodyData),
104+
})
105+
106+
if (!response.ok) {
107+
const errorText = await response.text()
108+
throw new Error(`Token refresh failed: ${response.status} ${response.statusText}. Response: ${errorText}`)
109+
}
110+
111+
const tokenData = await response.json()
112+
113+
if (tokenData.error) {
114+
throw new Error(`Token refresh failed: ${tokenData.error} - ${tokenData.error_description}`)
115+
}
116+
117+
const newCredentials = {
118+
...credentials,
119+
access_token: tokenData.access_token,
120+
token_type: tokenData.token_type,
121+
refresh_token: tokenData.refresh_token || credentials.refresh_token,
122+
expiry_date: Date.now() + tokenData.expires_in * 1000,
123+
}
124+
125+
const filePath = getQwenCachedCredentialPath(this.options.qwenCodeOauthPath)
126+
await fs.writeFile(filePath, JSON.stringify(newCredentials, null, 2))
127+
128+
return newCredentials
129+
}
130+
131+
private isTokenValid(credentials: QwenOAuthCredentials): boolean {
132+
const TOKEN_REFRESH_BUFFER_MS = 30 * 1000 // 30s buffer
133+
if (!credentials.expiry_date) {
134+
return false
135+
}
136+
return Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS
137+
}
138+
139+
private async ensureAuthenticated(): Promise<void> {
140+
if (!this.credentials) {
141+
this.credentials = await this.loadCachedQwenCredentials()
142+
}
143+
144+
if (!this.isTokenValid(this.credentials)) {
145+
this.credentials = await this.refreshAccessToken(this.credentials)
146+
}
147+
148+
// After authentication, update the apiKey and baseURL on the existing client
149+
const client = this.ensureClient()
150+
client.apiKey = this.credentials.access_token
151+
client.baseURL = this.getBaseUrl(this.credentials)
152+
}
153+
154+
private getBaseUrl(creds: QwenOAuthCredentials): string {
155+
let baseUrl = creds.resource_url || "https://dashscope.aliyuncs.com/compatible-mode/v1"
156+
if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) {
157+
baseUrl = `https://${baseUrl}`
158+
}
159+
return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1`
160+
}
161+
162+
private async callApiWithRetry<T>(apiCall: () => Promise<T>): Promise<T> {
163+
try {
164+
return await apiCall()
165+
} catch (error: any) {
166+
if (error.status === 401) {
167+
// Token expired, refresh and retry
168+
this.credentials = await this.refreshAccessToken(this.credentials!)
169+
const client = this.ensureClient()
170+
client.apiKey = this.credentials.access_token
171+
client.baseURL = this.getBaseUrl(this.credentials)
172+
return await apiCall()
173+
} else {
174+
throw error
175+
}
176+
}
177+
}
178+
179+
override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
180+
await this.ensureAuthenticated()
181+
const client = this.ensureClient()
182+
const model = this.getModel()
183+
184+
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
185+
role: "system",
186+
content: systemPrompt,
187+
}
188+
189+
const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
190+
191+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
192+
model: model.id,
193+
temperature: 0,
194+
messages: convertedMessages,
195+
stream: true,
196+
stream_options: { include_usage: true },
197+
max_completion_tokens: model.info.maxTokens,
198+
}
199+
200+
const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
201+
202+
let fullContent = ""
203+
204+
for await (const apiChunk of stream) {
205+
const delta = apiChunk.choices[0]?.delta ?? {}
206+
207+
if (delta.content) {
208+
let newText = delta.content
209+
if (newText.startsWith(fullContent)) {
210+
newText = newText.substring(fullContent.length)
211+
}
212+
fullContent = delta.content
213+
214+
if (newText) {
215+
// Check for thinking blocks
216+
if (newText.includes("<think>") || newText.includes("</think>")) {
217+
// Simple parsing for thinking blocks
218+
const parts = newText.split(/<\/?think>/g)
219+
for (let i = 0; i < parts.length; i++) {
220+
if (parts[i]) {
221+
if (i % 2 === 0) {
222+
// Outside thinking block
223+
yield {
224+
type: "text",
225+
text: parts[i],
226+
}
227+
} else {
228+
// Inside thinking block
229+
yield {
230+
type: "reasoning",
231+
text: parts[i],
232+
}
233+
}
234+
}
235+
}
236+
} else {
237+
yield {
238+
type: "text",
239+
text: newText,
240+
}
241+
}
242+
}
243+
}
244+
245+
// Handle reasoning content (o1-style)
246+
if ("reasoning_content" in delta && delta.reasoning_content) {
247+
yield {
248+
type: "reasoning",
249+
text: (delta.reasoning_content as string | undefined) || "",
250+
}
251+
}
252+
253+
if (apiChunk.usage) {
254+
yield {
255+
type: "usage",
256+
inputTokens: apiChunk.usage.prompt_tokens || 0,
257+
outputTokens: apiChunk.usage.completion_tokens || 0,
258+
}
259+
}
260+
}
261+
}
262+
263+
override getModel(): { id: string; info: ModelInfo } {
264+
const modelId = this.options.apiModelId
265+
const { qwenCodeModels, qwenCodeDefaultModelId } = require("@roo-code/types")
266+
if (modelId && modelId in qwenCodeModels) {
267+
const id = modelId
268+
return { id, info: qwenCodeModels[id] }
269+
}
270+
return {
271+
id: qwenCodeDefaultModelId,
272+
info: qwenCodeModels[qwenCodeDefaultModelId],
273+
}
274+
}
275+
276+
async completePrompt(prompt: string): Promise<string> {
277+
await this.ensureAuthenticated()
278+
const client = this.ensureClient()
279+
const model = this.getModel()
280+
281+
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
282+
model: model.id,
283+
messages: [{ role: "user", content: prompt }],
284+
max_completion_tokens: model.info.maxTokens,
285+
}
286+
287+
const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions))
288+
289+
return response.choices[0]?.message.content || ""
290+
}
291+
}

0 commit comments

Comments
 (0)