diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 70ab13bb4e..2b1aa701e2 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.59.0", + "version": "1.60.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 3fa7094d87..4f5ca9c3ca 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -18,6 +18,7 @@ import { mistralModels, moonshotModels, openAiNativeModels, + qwenCodeModels, rooModels, sambaNovaModels, vertexModels, @@ -48,6 +49,7 @@ export const providerNames = [ "moonshot", "deepseek", "doubao", + "qwen-code", "unbound", "requesty", "human-relay", @@ -311,6 +313,10 @@ const ioIntelligenceSchema = apiModelIdProviderModelSchema.extend({ ioIntelligenceApiKey: z.string().optional(), }) +const qwenCodeSchema = apiModelIdProviderModelSchema.extend({ + qwenCodeOauthPath: z.string().optional(), +}) + const rooSchema = apiModelIdProviderModelSchema.extend({ // No additional fields needed - uses cloud authentication }) @@ -352,6 +358,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })), featherlessSchema.merge(z.object({ apiProvider: z.literal("featherless") })), ioIntelligenceSchema.merge(z.object({ apiProvider: z.literal("io-intelligence") })), + qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), rooSchema.merge(z.object({ apiProvider: z.literal("roo") })), defaultSchema, ]) @@ -390,6 +397,7 @@ export const providerSettingsSchema = z.object({ ...fireworksSchema.shape, ...featherlessSchema.shape, ...ioIntelligenceSchema.shape, + ...qwenCodeSchema.shape, ...rooSchema.shape, ...codebaseIndexProviderSchema.shape, }) @@ -506,6 +514,7 @@ export const MODELS_BY_PROVIDER: Record< label: "OpenAI", models: Object.keys(openAiNativeModels), }, + "qwen-code": { id: "qwen-code", label: "Qwen Code", models: Object.keys(qwenCodeModels) }, roo: { id: "roo", label: "Roo", models: Object.keys(rooModels) }, sambanova: { id: "sambanova", diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 8ca9c2c9b2..27951f0f1a 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -19,6 +19,7 @@ export * from "./moonshot.js" export * from "./ollama.js" export * from "./openai.js" export * from "./openrouter.js" +export * from "./qwen-code.js" export * from "./requesty.js" export * from "./roo.js" export * from "./sambanova.js" diff --git a/packages/types/src/providers/qwen-code.ts b/packages/types/src/providers/qwen-code.ts new file mode 100644 index 0000000000..0f51e4eacb --- /dev/null +++ b/packages/types/src/providers/qwen-code.ts @@ -0,0 +1,30 @@ +import type { ModelInfo } from "../model.js" + +export type QwenCodeModelId = "qwen3-coder-plus" | "qwen3-coder-flash" + +export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus" + +export const qwenCodeModels = { + "qwen3-coder-plus": { + maxTokens: 65_536, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + description: "Qwen3 Coder Plus - High-performance coding model with 1M context window for large codebases", + }, + "qwen3-coder-flash": { + maxTokens: 65_536, + contextWindow: 1_000_000, + supportsImages: false, + supportsPromptCache: false, + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + description: "Qwen3 Coder Flash - Fast coding model with 1M context window optimized for speed", + }, +} as const satisfies Record diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68e0120391..b683e48b64 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,8 +584,8 @@ importers: specifier: ^1.14.0 version: 1.14.0(typescript@5.8.3) '@roo-code/cloud': - specifier: ^0.19.0 - version: 0.19.0 + specifier: ^0.21.0 + version: 0.21.0 '@roo-code/ipc': specifier: workspace:^ version: link:../packages/ipc @@ -3262,11 +3262,11 @@ packages: cpu: [x64] os: [win32] - '@roo-code/cloud@0.19.0': - resolution: {integrity: sha512-alZ3X4+TPqRr0xSs9v/UDo3eTlcHaI8ZW8AbWPDtgqf86P8govnyM2hVUMhGXete3AlbYIPRE/9w3/7MrcIjsA==} + '@roo-code/cloud@0.21.0': + resolution: {integrity: sha512-yNVybIjaS7Hy8GwDtGJc76N1WpCXGaCSlAEsW7VGjnojpxaIzV2GcJP1j1hg5q8HqLQnU4ixV0qXxOkxwhkEiA==} - '@roo-code/types@1.55.0': - resolution: {integrity: sha512-+T5MP8IQcDp7htnGDnk3M4n7S5eYk6jNkw3VBSUBZRhS4EE2GuPDI+CcdmhnDiMb6NMV6yseL+CT4G4QV5ktUw==} + '@roo-code/types@1.60.0': + resolution: {integrity: sha512-tQO6njPr/ZDNBoSHQg1/dpxfVEYeUzpKcernUxgJzmttn1zJbS0sc3CfUyPYOfYKB331z6O3KFUpaiqYFje1wA==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -4072,9 +4072,6 @@ packages: '@types/node@20.17.57': resolution: {integrity: sha512-f3T4y6VU4fVQDKVqJV4Uppy8c1p/sVvS3peyqxyWnzkqXFJLRU7Y1Bl7rMS1Qe9z0v4M6McY0Fp9yBsgHJUsWQ==} - '@types/node@20.19.11': - resolution: {integrity: sha512-uug3FEEGv0r+jrecvUUpbY8lLisvIjg6AAic6a2bSP5OEOLeJsDSnvhCDov7ipFFMXS3orMpzlmi0ZcuGkBbow==} - '@types/node@24.2.1': resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==} @@ -9490,9 +9487,6 @@ packages: undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} @@ -12569,9 +12563,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true - '@roo-code/cloud@0.19.0': + '@roo-code/cloud@0.21.0': dependencies: - '@roo-code/types': 1.55.0 + '@roo-code/types': 1.60.0 ioredis: 5.6.1 p-wait-for: 5.0.2 socket.io-client: 4.8.1 @@ -12581,7 +12575,7 @@ snapshots: - supports-color - utf-8-validate - '@roo-code/types@1.55.0': + '@roo-code/types@1.60.0': dependencies: zod: 3.25.76 @@ -13574,11 +13568,6 @@ snapshots: dependencies: undici-types: 6.19.8 - '@types/node@20.19.11': - dependencies: - undici-types: 6.21.0 - optional: true - '@types/node@24.2.1': dependencies: undici-types: 7.10.0 @@ -13642,7 +13631,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 20.19.11 + '@types/node': 24.2.1 optional: true '@types/yargs-parser@21.0.3': {} @@ -13815,7 +13804,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -19934,9 +19923,6 @@ snapshots: undici-types@6.19.8: {} - undici-types@6.21.0: - optional: true - undici-types@7.10.0: {} undici@6.21.3: {} diff --git a/src/api/index.ts b/src/api/index.ts index 6c70a1485d..188cb3930f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -30,6 +30,7 @@ import { ChutesHandler, LiteLLMHandler, ClaudeCodeHandler, + QwenCodeHandler, SambaNovaHandler, IOIntelligenceHandler, DoubaoHandler, @@ -108,6 +109,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new DeepSeekHandler(options) case "doubao": return new DoubaoHandler(options) + case "qwen-code": + return new QwenCodeHandler(options) case "moonshot": return new MoonshotHandler(options) case "vscode-lm": diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index d256fbbe55..e2b9047dfc 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -21,6 +21,7 @@ export { OllamaHandler } from "./ollama" export { OpenAiNativeHandler } from "./openai-native" export { OpenAiHandler } from "./openai" export { OpenRouterHandler } from "./openrouter" +export { QwenCodeHandler } from "./qwen-code" export { RequestyHandler } from "./requesty" export { SambaNovaHandler } from "./sambanova" export { UnboundHandler } from "./unbound" diff --git a/src/api/providers/qwen-code.ts b/src/api/providers/qwen-code.ts new file mode 100644 index 0000000000..3d8ca246d0 --- /dev/null +++ b/src/api/providers/qwen-code.ts @@ -0,0 +1,315 @@ +import { promises as fs } from "node:fs" +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" +import * as os from "os" +import * as path from "path" + +import type { ModelInfo } from "@roo-code/types" +import type { ApiHandlerOptions } from "../../shared/api" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream } from "../transform/stream" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler } from "../index" + +// --- Constants for Qwen OAuth2 --- +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" + +interface QwenOAuthCredentials { + access_token: string + refresh_token: string + token_type: string + expiry_date: number + resource_url?: string +} + +interface QwenCodeHandlerOptions extends ApiHandlerOptions { + qwenCodeOauthPath?: string +} + +function getQwenCachedCredentialPath(customPath?: string): string { + if (customPath) { + // Support custom path that starts with ~/ or is absolute + if (customPath.startsWith("~/")) { + return path.join(os.homedir(), customPath.slice(2)) + } + return path.resolve(customPath) + } + return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME) +} + +function objectToUrlEncoded(data: Record): string { + return Object.keys(data) + .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) + .join("&") +} + +export class QwenCodeHandler extends BaseProvider implements SingleCompletionHandler { + protected options: QwenCodeHandlerOptions + private credentials: QwenOAuthCredentials | null = null + private client: OpenAI | undefined + private refreshPromise: Promise | null = null + + constructor(options: QwenCodeHandlerOptions) { + super() + this.options = options + } + + private ensureClient(): OpenAI { + if (!this.client) { + // Create the client instance with dummy key initially + // The API key will be updated dynamically via ensureAuthenticated + this.client = new OpenAI({ + apiKey: "dummy-key-will-be-replaced", + baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", + }) + } + return this.client + } + + private async loadCachedQwenCredentials(): Promise { + try { + const keyFile = getQwenCachedCredentialPath(this.options.qwenCodeOauthPath) + const credsStr = await fs.readFile(keyFile, "utf-8") + return JSON.parse(credsStr) + } catch (error) { + console.error( + `Error reading or parsing credentials file at ${getQwenCachedCredentialPath(this.options.qwenCodeOauthPath)}`, + ) + throw new Error(`Failed to load Qwen OAuth credentials: ${error}`) + } + } + + private async refreshAccessToken(credentials: QwenOAuthCredentials): Promise { + // If a refresh is already in progress, return the existing promise + if (this.refreshPromise) { + return this.refreshPromise + } + + // Create a new refresh promise + this.refreshPromise = this.doRefreshAccessToken(credentials) + + try { + const result = await this.refreshPromise + return result + } finally { + // Clear the promise after completion (success or failure) + this.refreshPromise = null + } + } + + private async doRefreshAccessToken(credentials: QwenOAuthCredentials): Promise { + if (!credentials.refresh_token) { + throw new Error("No refresh token available in credentials.") + } + + const bodyData = { + grant_type: "refresh_token", + refresh_token: credentials.refresh_token, + client_id: QWEN_OAUTH_CLIENT_ID, + } + + const response = await fetch(QWEN_OAUTH_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: objectToUrlEncoded(bodyData), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token refresh failed: ${response.status} ${response.statusText}. Response: ${errorText}`) + } + + const tokenData = await response.json() + + if (tokenData.error) { + throw new Error(`Token refresh failed: ${tokenData.error} - ${tokenData.error_description}`) + } + + const newCredentials = { + ...credentials, + access_token: tokenData.access_token, + token_type: tokenData.token_type, + refresh_token: tokenData.refresh_token || credentials.refresh_token, + expiry_date: Date.now() + tokenData.expires_in * 1000, + } + + const filePath = getQwenCachedCredentialPath(this.options.qwenCodeOauthPath) + try { + await fs.writeFile(filePath, JSON.stringify(newCredentials, null, 2)) + } catch (error) { + console.error("Failed to save refreshed credentials:", error) + // Continue with the refreshed token in memory even if file write fails + } + + return newCredentials + } + + private isTokenValid(credentials: QwenOAuthCredentials): boolean { + const TOKEN_REFRESH_BUFFER_MS = 30 * 1000 // 30s buffer + if (!credentials.expiry_date) { + return false + } + return Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS + } + + private async ensureAuthenticated(): Promise { + if (!this.credentials) { + this.credentials = await this.loadCachedQwenCredentials() + } + + if (!this.isTokenValid(this.credentials)) { + this.credentials = await this.refreshAccessToken(this.credentials) + } + + // After authentication, update the apiKey and baseURL on the existing client + const client = this.ensureClient() + client.apiKey = this.credentials.access_token + client.baseURL = this.getBaseUrl(this.credentials) + } + + private getBaseUrl(creds: QwenOAuthCredentials): string { + let baseUrl = creds.resource_url || "https://dashscope.aliyuncs.com/compatible-mode/v1" + if (!baseUrl.startsWith("http://") && !baseUrl.startsWith("https://")) { + baseUrl = `https://${baseUrl}` + } + return baseUrl.endsWith("/v1") ? baseUrl : `${baseUrl}/v1` + } + + private async callApiWithRetry(apiCall: () => Promise): Promise { + try { + return await apiCall() + } catch (error: any) { + if (error.status === 401) { + // Token expired, refresh and retry + this.credentials = await this.refreshAccessToken(this.credentials!) + const client = this.ensureClient() + client.apiKey = this.credentials.access_token + client.baseURL = this.getBaseUrl(this.credentials) + return await apiCall() + } else { + throw error + } + } + } + + override async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + await this.ensureAuthenticated() + const client = this.ensureClient() + const model = this.getModel() + + const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = { + role: "system", + content: systemPrompt, + } + + const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)] + + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { + model: model.id, + temperature: 0, + messages: convertedMessages, + stream: true, + stream_options: { include_usage: true }, + max_completion_tokens: model.info.maxTokens, + } + + const stream = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) + + let fullContent = "" + + for await (const apiChunk of stream) { + const delta = apiChunk.choices[0]?.delta ?? {} + + if (delta.content) { + let newText = delta.content + if (newText.startsWith(fullContent)) { + newText = newText.substring(fullContent.length) + } + fullContent = delta.content + + if (newText) { + // Check for thinking blocks + if (newText.includes("") || newText.includes("")) { + // Simple parsing for thinking blocks + const parts = newText.split(/<\/?think>/g) + for (let i = 0; i < parts.length; i++) { + if (parts[i]) { + if (i % 2 === 0) { + // Outside thinking block + yield { + type: "text", + text: parts[i], + } + } else { + // Inside thinking block + yield { + type: "reasoning", + text: parts[i], + } + } + } + } + } else { + yield { + type: "text", + text: newText, + } + } + } + } + + // Handle reasoning content (o1-style) + if ("reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + text: (delta.reasoning_content as string | undefined) || "", + } + } + + if (apiChunk.usage) { + yield { + type: "usage", + inputTokens: apiChunk.usage.prompt_tokens || 0, + outputTokens: apiChunk.usage.completion_tokens || 0, + } + } + } + } + + override getModel(): { id: string; info: ModelInfo } { + const modelId = this.options.apiModelId + const { qwenCodeModels, qwenCodeDefaultModelId } = require("@roo-code/types") + if (modelId && modelId in qwenCodeModels) { + const id = modelId + return { id, info: qwenCodeModels[id] } + } + return { + id: qwenCodeDefaultModelId, + info: qwenCodeModels[qwenCodeDefaultModelId], + } + } + + async completePrompt(prompt: string): Promise { + await this.ensureAuthenticated() + const client = this.ensureClient() + const model = this.getModel() + + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: model.id, + messages: [{ role: "user", content: prompt }], + max_completion_tokens: model.info.maxTokens, + } + + const response = await this.callApiWithRetry(() => client.chat.completions.create(requestOptions)) + + return response.choices[0]?.message.content || "" + } +} diff --git a/src/package.json b/src/package.json index 52949a006a..b23d06137a 100644 --- a/src/package.json +++ b/src/package.json @@ -432,7 +432,7 @@ "@mistralai/mistralai": "^1.9.18", "@modelcontextprotocol/sdk": "^1.9.0", "@qdrant/js-client-rest": "^1.14.0", - "@roo-code/cloud": "^0.19.0", + "@roo-code/cloud": "^0.21.0", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 8acb88ed3f..3fc9a5ffdb 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -5,8 +5,11 @@ export function checkExistKey(config: ProviderSettings | undefined) { return false } - // Special case for human-relay, fake-ai, claude-code, and roo providers which don't need any configuration. - if (config.apiProvider && ["human-relay", "fake-ai", "claude-code", "roo"].includes(config.apiProvider)) { + // Special case for human-relay, fake-ai, claude-code, qwen-code, and roo providers which don't need any configuration. + if ( + config.apiProvider && + ["human-relay", "fake-ai", "claude-code", "qwen-code", "roo"].includes(config.apiProvider) + ) { return true } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 13a8ab4848..1acc243950 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -17,6 +17,7 @@ import { anthropicDefaultModelId, doubaoDefaultModelId, claudeCodeDefaultModelId, + qwenCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, moonshotDefaultModelId, @@ -80,6 +81,7 @@ import { OpenAI, OpenAICompatible, OpenRouter, + QwenCode, Requesty, SambaNova, Unbound, @@ -309,6 +311,7 @@ const ApiOptions = ({ anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, cerebras: { field: "apiModelId", default: cerebrasDefaultModelId }, "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, + "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, @@ -516,6 +519,10 @@ const ApiOptions = ({ )} + {selectedProvider === "qwen-code" && ( + + )} + {selectedProvider === "moonshot" && ( )} diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index cdeb71814d..1a94c5c599 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -10,6 +10,7 @@ import { geminiModels, mistralModels, openAiNativeModels, + qwenCodeModels, vertexModels, xaiModels, groqModels, @@ -33,6 +34,7 @@ export const MODELS_BY_PROVIDER: Partial void +} + +export const QwenCode: React.FC = ({ apiConfiguration, setApiConfigurationField }) => { + const defaultPath = "~/.qwen/oauth_creds.json" + + const handleInputChange = (e: Event | React.FormEvent) => { + const element = e.target as HTMLInputElement + setApiConfigurationField("qwenCodeOauthPath", element.value) + } + + const handleBlur = (e: Event | React.FormEvent) => { + const element = e.target as HTMLInputElement + // If the field is empty on blur, set it to the default value + if (!element.value || element.value.trim() === "") { + setApiConfigurationField("qwenCodeOauthPath", defaultPath) + } + } + + return ( +
+
+ + OAuth Credentials Path + + +

+ Path to your Qwen OAuth credentials file. Defaults to ~/.qwen/oauth_creds.json if left empty. +

+ +
+ Qwen Code is an OAuth-based API that requires authentication through the official Qwen client. + You'll need to set up OAuth credentials first. +
+ +
+ To get started: +
+ 1. Install the official Qwen client +
+ 2. Authenticate using your account +
+ 3. OAuth credentials will be stored automatically +
+ + + Setup Instructions + +
+
+ ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index eff33e1298..46fea622c9 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -17,6 +17,7 @@ export { Ollama } from "./Ollama" export { OpenAI } from "./OpenAI" export { OpenAICompatible } from "./OpenAICompatible" export { OpenRouter } from "./OpenRouter" +export { QwenCode } from "./QwenCode" export { Requesty } from "./Requesty" export { SambaNova } from "./SambaNova" export { Unbound } from "./Unbound" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 75a4a968ad..aeb90c3491 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -52,6 +52,8 @@ import { ioIntelligenceModels, rooDefaultModelId, rooModels, + qwenCodeDefaultModelId, + qwenCodeModels, BEDROCK_CLAUDE_SONNET_4_MODEL_ID, } from "@roo-code/types" @@ -310,11 +312,16 @@ function getSelectedModel({ const info = rooModels[id as keyof typeof rooModels] return { id, info } } + case "qwen-code": { + const id = apiConfiguration.apiModelId ?? qwenCodeDefaultModelId + const info = qwenCodeModels[id as keyof typeof qwenCodeModels] + return { id, info } + } // case "anthropic": // case "human-relay": // case "fake-ai": default: { - provider satisfies "anthropic" | "gemini-cli" | "human-relay" | "fake-ai" + provider satisfies "anthropic" | "gemini-cli" | "qwen-code" | "human-relay" | "fake-ai" const id = apiConfiguration.apiModelId ?? anthropicDefaultModelId const baseInfo = anthropicModels[id as keyof typeof anthropicModels] diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 54d66c17c7..977ccaeec9 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -795,7 +795,8 @@ "modelAvailability": "L'ID de model ({{modelId}}) que heu proporcionat no està disponible. Si us plau, trieu un altre model.", "providerNotAllowed": "El proveïdor '{{provider}}' no està permès per la vostra organització", "modelNotAllowed": "El model '{{model}}' no està permès per al proveïdor '{{provider}}' per la vostra organització", - "profileInvalid": "Aquest perfil conté un proveïdor o model que no està permès per la vostra organització" + "profileInvalid": "Aquest perfil conté un proveïdor o model que no està permès per la vostra organització", + "qwenCodeOauthPath": "Has de proporcionar una ruta vàlida de credencials OAuth" }, "placeholders": { "apiKey": "Introduïu la clau API...", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 402e57c8dc..71006a9d4c 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -795,7 +795,8 @@ "modelAvailability": "Die von dir angegebene Modell-ID ({{modelId}}) ist nicht verfügbar. Bitte wähle ein anderes Modell.", "providerNotAllowed": "Anbieter '{{provider}}' ist von deiner Organisation nicht erlaubt", "modelNotAllowed": "Modell '{{model}}' ist für Anbieter '{{provider}}' von deiner Organisation nicht erlaubt", - "profileInvalid": "Dieses Profil enthält einen Anbieter oder ein Modell, das von deiner Organisation nicht erlaubt ist" + "profileInvalid": "Dieses Profil enthält einen Anbieter oder ein Modell, das von deiner Organisation nicht erlaubt ist", + "qwenCodeOauthPath": "Du musst einen gültigen OAuth-Anmeldedaten-Pfad angeben" }, "placeholders": { "apiKey": "API-Schlüssel eingeben...", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 63a9445924..6d761e85ff 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -794,7 +794,8 @@ "modelAvailability": "The model ID ({{modelId}}) you provided is not available. Please choose a different model.", "providerNotAllowed": "Provider '{{provider}}' is not allowed by your organization", "modelNotAllowed": "Model '{{model}}' is not allowed for provider '{{provider}}' by your organization", - "profileInvalid": "This profile contains a provider or model that is not allowed by your organization" + "profileInvalid": "This profile contains a provider or model that is not allowed by your organization", + "qwenCodeOauthPath": "You must provide a valid OAuth credentials path." }, "placeholders": { "apiKey": "Enter API Key...", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index a67e2520d0..a3dbfa410c 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -795,7 +795,8 @@ "modelAvailability": "El ID de modelo ({{modelId}}) que proporcionó no está disponible. Por favor, elija un modelo diferente.", "providerNotAllowed": "El proveedor '{{provider}}' no está permitido por su organización", "modelNotAllowed": "El modelo '{{model}}' no está permitido para el proveedor '{{provider}}' por su organización", - "profileInvalid": "Este perfil contiene un proveedor o modelo que no está permitido por su organización" + "profileInvalid": "Este perfil contiene un proveedor o modelo que no está permitido por su organización", + "qwenCodeOauthPath": "Debes proporcionar una ruta válida de credenciales OAuth" }, "placeholders": { "apiKey": "Ingrese clave API...", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index 36e4629c43..a2c2d0ca06 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -795,7 +795,8 @@ "modelAvailability": "L'ID de modèle ({{modelId}}) que vous avez fourni n'est pas disponible. Veuillez choisir un modèle différent.", "providerNotAllowed": "Le fournisseur '{{provider}}' n'est pas autorisé par votre organisation", "modelNotAllowed": "Le modèle '{{model}}' n'est pas autorisé pour le fournisseur '{{provider}}' par votre organisation", - "profileInvalid": "Ce profil contient un fournisseur ou un modèle qui n'est pas autorisé par votre organisation" + "profileInvalid": "Ce profil contient un fournisseur ou un modèle qui n'est pas autorisé par votre organisation", + "qwenCodeOauthPath": "Tu dois fournir un chemin valide pour les identifiants OAuth" }, "placeholders": { "apiKey": "Saisissez la clé API...", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index a419e7a688..000b5f89b3 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "आपके द्वारा प्रदान की गई मॉडल ID ({{modelId}}) उपलब्ध नहीं है। कृपया कोई अन्य मॉडल चुनें।", "providerNotAllowed": "प्रदाता '{{provider}}' आपके संगठन द्वारा अनुमत नहीं है", "modelNotAllowed": "मॉडल '{{model}}' प्रदाता '{{provider}}' के लिए आपके संगठन द्वारा अनुमत नहीं है", - "profileInvalid": "इस प्रोफ़ाइल में एक प्रदाता या मॉडल शामिल है जो आपके संगठन द्वारा अनुमत नहीं है" + "profileInvalid": "इस प्रोफ़ाइल में एक प्रदाता या मॉडल शामिल है जो आपके संगठन द्वारा अनुमत नहीं है", + "qwenCodeOauthPath": "आपको एक वैध OAuth क्रेडेंशियल पथ प्रदान करना होगा" }, "placeholders": { "apiKey": "API कुंजी दर्ज करें...", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index fe16c7724a..0af73ecda9 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -825,7 +825,8 @@ "modelAvailability": "Model ID ({{modelId}}) yang kamu berikan tidak tersedia. Silakan pilih model yang berbeda.", "providerNotAllowed": "Provider '{{provider}}' tidak diizinkan oleh organisasi kamu", "modelNotAllowed": "Model '{{model}}' tidak diizinkan untuk provider '{{provider}}' oleh organisasi kamu", - "profileInvalid": "Profil ini berisi provider atau model yang tidak diizinkan oleh organisasi kamu" + "profileInvalid": "Profil ini berisi provider atau model yang tidak diizinkan oleh organisasi kamu", + "qwenCodeOauthPath": "Kamu harus memberikan jalur kredensial OAuth yang valid" }, "placeholders": { "apiKey": "Masukkan API Key...", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 89b900de98..0cc2a59019 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "L'ID modello ({{modelId}}) fornito non è disponibile. Seleziona un modello diverso.", "providerNotAllowed": "Il fornitore '{{provider}}' non è consentito dalla tua organizzazione", "modelNotAllowed": "Il modello '{{model}}' non è consentito per il fornitore '{{provider}}' dalla tua organizzazione.", - "profileInvalid": "Questo profilo contiene un fornitore o un modello non consentito dalla tua organizzazione." + "profileInvalid": "Questo profilo contiene un fornitore o un modello non consentito dalla tua organizzazione.", + "qwenCodeOauthPath": "Devi fornire un percorso valido per le credenziali OAuth" }, "placeholders": { "apiKey": "Inserisci chiave API...", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index d597f259d2..27b51854dd 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "指定されたモデルID({{modelId}})は利用できません。別のモデルを選択してください。", "providerNotAllowed": "プロバイダー「{{provider}}」は組織によって許可されていません", "modelNotAllowed": "モデル「{{model}}」はプロバイダー「{{provider}}」に対して組織によって許可されていません", - "profileInvalid": "このプロファイルには、組織によって許可されていないプロバイダーまたはモデルが含まれています" + "profileInvalid": "このプロファイルには、組織によって許可されていないプロバイダーまたはモデルが含まれています", + "qwenCodeOauthPath": "有効なOAuth認証情報のパスを提供する必要があります" }, "placeholders": { "apiKey": "API キーを入力...", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 381622ad74..e6125f0ca8 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "제공한 모델 ID({{modelId}})를 사용할 수 없습니다. 다른 모델을 선택하세요.", "providerNotAllowed": "제공자 '{{provider}}'는 조직에서 허용되지 않습니다", "modelNotAllowed": "모델 '{{model}}'은 제공자 '{{provider}}'에 대해 조직에서 허용되지 않습니다", - "profileInvalid": "이 프로필에는 조직에서 허용되지 않는 제공자 또는 모델이 포함되어 있습니다" + "profileInvalid": "이 프로필에는 조직에서 허용되지 않는 제공자 또는 모델이 포함되어 있습니다", + "qwenCodeOauthPath": "유효한 OAuth 자격 증명 경로를 제공해야 합니다" }, "placeholders": { "apiKey": "API 키 입력...", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 27811c0bca..ca2024522e 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "Het opgegeven model-ID ({{modelId}}) is niet beschikbaar. Kies een ander model.", "providerNotAllowed": "Provider '{{provider}}' is niet toegestaan door je organisatie", "modelNotAllowed": "Model '{{model}}' is niet toegestaan voor provider '{{provider}}' door je organisatie", - "profileInvalid": "Dit profiel bevat een provider of model dat niet is toegestaan door je organisatie" + "profileInvalid": "Dit profiel bevat een provider of model dat niet is toegestaan door je organisatie", + "qwenCodeOauthPath": "Je moet een geldig OAuth-referentiepad opgeven" }, "placeholders": { "apiKey": "Voer API-sleutel in...", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index b101a98b78..25b04078bb 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "Podane ID modelu ({{modelId}}) jest niedostępne. Wybierz inny model.", "providerNotAllowed": "Dostawca '{{provider}}' nie jest dozwolony przez Twoją organizację", "modelNotAllowed": "Model '{{model}}' nie jest dozwolony dla dostawcy '{{provider}}' przez Twoją organizację", - "profileInvalid": "Ten profil zawiera dostawcę lub model, który nie jest dozwolony przez Twoją organizację" + "profileInvalid": "Ten profil zawiera dostawcę lub model, który nie jest dozwolony przez Twoją organizację", + "qwenCodeOauthPath": "Musisz podać prawidłową ścieżkę do poświadczeń OAuth" }, "placeholders": { "apiKey": "Wprowadź klucz API...", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 20064675c8..7039497b94 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "O ID do modelo ({{modelId}}) que você forneceu não está disponível. Por favor, escolha outro modelo.", "providerNotAllowed": "O provedor '{{provider}}' não é permitido pela sua organização", "modelNotAllowed": "O modelo '{{model}}' não é permitido para o provedor '{{provider}}' pela sua organização", - "profileInvalid": "Este perfil contém um provedor ou modelo que não é permitido pela sua organização" + "profileInvalid": "Este perfil contém um provedor ou modelo que não é permitido pela sua organização", + "qwenCodeOauthPath": "Você deve fornecer um caminho válido de credenciais OAuth" }, "placeholders": { "apiKey": "Digite a chave API...", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 4143a331fc..05d73cd46f 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "ID модели ({{modelId}}), который вы указали, недоступен. Пожалуйста, выберите другую модель.", "providerNotAllowed": "Провайдер '{{provider}}' не разрешен вашей организацией", "modelNotAllowed": "Модель '{{model}}' не разрешена для провайдера '{{provider}}' вашей организацией", - "profileInvalid": "Этот профиль содержит провайдера или модель, которые не разрешены вашей организацией" + "profileInvalid": "Этот профиль содержит провайдера или модель, которые не разрешены вашей организацией", + "qwenCodeOauthPath": "Вы должны указать допустимый путь к учетным данным OAuth" }, "placeholders": { "apiKey": "Введите API-ключ...", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 5de8363038..1bcaa7def5 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "Sağladığınız model kimliği ({{modelId}}) kullanılamıyor. Lütfen başka bir model seçin.", "providerNotAllowed": "Sağlayıcı '{{provider}}' kuruluşunuz tarafından izin verilmiyor", "modelNotAllowed": "Model '{{model}}' sağlayıcı '{{provider}}' için kuruluşunuz tarafından izin verilmiyor", - "profileInvalid": "Bu profil, kuruluşunuz tarafından izin verilmeyen bir sağlayıcı veya model içeriyor" + "profileInvalid": "Bu profil, kuruluşunuz tarafından izin verilmeyen bir sağlayıcı veya model içeriyor", + "qwenCodeOauthPath": "Geçerli bir OAuth kimlik bilgileri yolu sağlamalısın" }, "placeholders": { "apiKey": "API anahtarını girin...", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 6fce8db6f4..b8cbb8c571 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "ID mô hình ({{modelId}}) bạn đã cung cấp không khả dụng. Vui lòng chọn một mô hình khác.", "providerNotAllowed": "Nhà cung cấp '{{provider}}' không được phép bởi tổ chức của bạn", "modelNotAllowed": "Mô hình '{{model}}' không được phép cho nhà cung cấp '{{provider}}' bởi tổ chức của bạn", - "profileInvalid": "Hồ sơ này chứa một nhà cung cấp hoặc mô hình không được phép bởi tổ chức của bạn" + "profileInvalid": "Hồ sơ này chứa một nhà cung cấp hoặc mô hình không được phép bởi tổ chức của bạn", + "qwenCodeOauthPath": "Bạn phải cung cấp đường dẫn thông tin xác thực OAuth hợp lệ" }, "placeholders": { "apiKey": "Nhập khóa API...", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 948693768c..a7d3077684 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "模型ID {{modelId}} 不可用,请重新选择", "providerNotAllowed": "提供商 '{{provider}}' 不允许用于您的组织", "modelNotAllowed": "模型 '{{model}}' 不允许用于提供商 '{{provider}}',您的组织不允许", - "profileInvalid": "此配置文件包含您的组织不允许的提供商或模型" + "profileInvalid": "此配置文件包含您的组织不允许的提供商或模型", + "qwenCodeOauthPath": "您必须提供有效的 OAuth 凭证路径" }, "placeholders": { "apiKey": "请输入 API 密钥...", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 8dd76aee3f..d8b71601dc 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -796,7 +796,8 @@ "modelAvailability": "您指定的模型 ID ({{modelId}}) 目前無法使用,請選擇其他模型。", "providerNotAllowed": "供應商 '{{provider}}' 不允許用於您的組織。", "modelNotAllowed": "模型 '{{model}}' 不允許用於供應商 '{{provider}}',您的組織不允許", - "profileInvalid": "此設定檔包含您的組織不允許的供應商或模型" + "profileInvalid": "此設定檔包含您的組織不允許的供應商或模型", + "qwenCodeOauthPath": "您必須提供有效的 OAuth 憑證路徑" }, "placeholders": { "apiKey": "請輸入 API 金鑰...", diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 348a373059..8f18e4411d 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -131,6 +131,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.apiKey") } break + case "qwen-code": + if (!apiConfiguration.qwenCodeOauthPath) { + return i18next.t("settings:validation.qwenCodeOauthPath") + } + break } return undefined