feat: add qwen-code provider support with complete translations

- Add QwenCode provider type definition and models in packages/types/src/providers/qwen-code.ts
- Implement OAuth-based provider in src/api/providers/qwen-code.ts with proper error handling
- Add comprehensive test coverage in src/api/providers/__tests__/qwen-code.spec.ts
- Create QwenCode settings UI component in webview-ui/src/components/settings/providers/QwenCode.tsx
- Add qwenCodeOAuthPath field to provider settings schema
- Add qwenCode error translations to all backend common.json files (17 languages)
- Add qwenCode provider settings to all frontend settings.json files (17 languages)
- Include OAuth path configuration and setup instructions for all locales

Supported languages: ca, de, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW
This commit is contained in:
Shariq Riaz 2025-08-24 04:13:33 +05:00
parent fc70012f54
commit 7f52b77fb7
50 changed files with 666 additions and 5 deletions

View file

@ -50,6 +50,7 @@ export const providerNames = [
"doubao",
"unbound",
"requesty",
"qwen-code",
"human-relay",
"fake-ai",
"xai",
@ -311,6 +312,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 +357,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 +396,7 @@ export const providerSettingsSchema = z.object({
...fireworksSchema.shape,
...featherlessSchema.shape,
...ioIntelligenceSchema.shape,
...qwenCodeSchema.shape,
...rooSchema.shape,
...codebaseIndexProviderSchema.shape,
})
@ -440,7 +447,7 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str
}
export const MODELS_BY_PROVIDER: Record<
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama">,
Exclude<ProviderName, "fake-ai" | "human-relay" | "gemini-cli" | "lmstudio" | "openai" | "ollama" | "qwen-code">,
{ id: ProviderName; label: string; models: string[] }
> = {
anthropic: {

View file

@ -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"

View file

@ -0,0 +1,44 @@
import type { ModelInfo } from "../model.js"
import type { ProviderName } from "../provider-settings.js"
export const qwenCodeModels = {
"qwen3-coder-plus": {
id: "qwen3-coder-plus",
name: "Qwen3 Coder Plus",
provider: "qwen-code" as ProviderName,
contextWindow: 1000000,
maxTokens: 65536,
supportsPromptCache: false,
},
"qwen3-coder-flash": {
id: "qwen3-coder-flash",
name: "Qwen3 Coder Flash",
provider: "qwen-code" as ProviderName,
contextWindow: 1000000,
maxTokens: 65536,
supportsPromptCache: false,
},
} as const
export type QwenCodeModelId = keyof typeof qwenCodeModels
export const qwenCodeDefaultModelId: QwenCodeModelId = "qwen3-coder-plus"
export const isQwenCodeModel = (modelId: string): modelId is QwenCodeModelId => {
return modelId in qwenCodeModels
}
export const getQwenCodeModelInfo = (modelId: string): ModelInfo => {
if (isQwenCodeModel(modelId)) {
return qwenCodeModels[modelId]
}
// Fallback to a default or throw an error
return qwenCodeModels[qwenCodeDefaultModelId]
}
export type QwenCodeProvider = {
id: "qwen-code"
apiKey?: string
baseUrl?: string
model: QwenCodeModelId
}

View file

@ -15,6 +15,7 @@ import {
OpenAiHandler,
LmStudioHandler,
GeminiHandler,
QwenCodeHandler,
OpenAiNativeHandler,
DeepSeekHandler,
MoonshotHandler,
@ -148,6 +149,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
return new RooHandler(options)
case "featherless":
return new FeatherlessHandler(options)
case "qwen-code":
return new QwenCodeHandler(options)
default:
apiProvider satisfies "gemini-cli" | undefined
return new AnthropicHandler(options)

View file

@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest"
import { QwenCodeHandler } from "../qwen-code"
import { ApiHandlerOptions } from "../../../shared/api"
// Mock fs
vi.mock("fs", () => ({
existsSync: vi.fn(),
readFileSync: vi.fn(),
}))
// Mock os
vi.mock("os", () => ({
homedir: () => "/home/user",
}))
// Mock path
vi.mock("path", () => ({
resolve: vi.fn((...args) => args.join("/")),
join: vi.fn((...args) => args.join("/")),
}))
describe("QwenCodeHandler", () => {
it("should initialize with correct model configuration", () => {
const options: ApiHandlerOptions = {
apiModelId: "qwen3-coder-plus",
}
const handler = new QwenCodeHandler(options)
const model = handler.getModel()
expect(model.id).toBe("qwen3-coder-plus")
expect(model.info).toBeDefined()
expect(model.info?.supportsPromptCache).toBe(false)
})
it("should use default model when none specified", () => {
const options: ApiHandlerOptions = {}
const handler = new QwenCodeHandler(options)
const model = handler.getModel()
expect(model.id).toBe("qwen3-coder-plus") // default model
expect(model.info).toBeDefined()
})
it("should use custom oauth path when provided", () => {
const customPath = "/custom/path/oauth.json"
const options: ApiHandlerOptions = {
qwenCodeOAuthPath: customPath,
}
const handler = new QwenCodeHandler(options)
// Handler should initialize without throwing
expect(handler).toBeDefined()
})
})

View file

@ -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"

View file

@ -0,0 +1,289 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { promises as fs } from "node:fs"
import * as os from "os"
import * as path from "path"
import OpenAI from "openai"
import { XmlMatcher } from "../../utils/xml-matcher"
import type { ModelInfo, QwenCodeModelId } from "@roo-code/types"
import { qwenCodeDefaultModelId, qwenCodeModels } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { t } from "../../i18n"
import { convertToOpenAiMessages } from "../transform/openai-format"
import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { BaseProvider } from "./base-provider"
import { DEFAULT_HEADERS } from "./constants"
// --- Constants from qwenOAuth2.js ---
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
}
function getQwenCachedCredentialPath(): string {
return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME)
}
function objectToUrlEncoded(data: Record<string, string>): string {
return Object.keys(data)
.map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`)
.join("&")
}
export class QwenCodeHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
private credentials: QwenOAuthCredentials | null = null
private client: OpenAI
constructor(options: ApiHandlerOptions) {
super()
this.options = options
// Create the client instance once in the constructor.
// 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", // A default base URL
defaultHeaders: DEFAULT_HEADERS,
})
}
private async loadCachedQwenCredentials(): Promise<QwenOAuthCredentials> {
try {
const keyFile = getQwenCachedCredentialPath()
const credsStr = await fs.readFile(keyFile, "utf-8")
return JSON.parse(credsStr)
} catch (error) {
console.error(`Error reading or parsing credentials file at ${getQwenCachedCredentialPath()}`)
throw new Error(t("common:errors.qwenCode.oauthLoadFailed", { error }))
}
}
private async refreshAccessToken(credentials: QwenOAuthCredentials): Promise<QwenOAuthCredentials> {
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()
await fs.writeFile(filePath, JSON.stringify(newCredentials, null, 2))
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<void> {
if (!this.credentials) {
this.credentials = await this.loadCachedQwenCredentials()
}
if (!this.isTokenValid(this.credentials)) {
this.credentials = await this.refreshAccessToken(this.credentials)
}
// After authentication, just update the apiKey and baseURL on the existing client.
this.client.apiKey = this.credentials.access_token
this.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<T>(apiCall: () => Promise<T>): Promise<T> {
try {
return await apiCall()
} catch (error: any) {
if (error.status === 401) {
this.credentials = await this.refreshAccessToken(this.credentials!)
// Just update the key, don't re-create the client
this.client.apiKey = this.credentials.access_token
this.client.baseURL = this.getBaseUrl(this.credentials)
return await apiCall()
} else {
throw error
}
}
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
await this.ensureAuthenticated()
const { id: modelId, info: modelInfo } = this.getModel()
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
}
const convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
temperature: this.options.modelTemperature ?? 0,
messages: convertedMessages,
stream: true,
stream_options: { include_usage: true },
}
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
const stream = await this.callApiWithRetry(() => this.client!.chat.completions.create(requestOptions))
const matcher = new XmlMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
let lastUsage: any
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) {
for (const processedChunk of matcher.update(newText)) {
yield processedChunk
}
}
}
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
text: (delta.reasoning_content as string | undefined) || "",
}
}
if (apiChunk.usage) {
lastUsage = apiChunk.usage
}
}
for (const chunk of matcher.final()) {
yield chunk
}
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, modelInfo)
}
}
async completePrompt(prompt: string): Promise<string> {
await this.ensureAuthenticated()
const { id: modelId, info: modelInfo } = this.getModel()
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: [{ role: "user", content: prompt }],
}
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
const response = await this.callApiWithRetry(() => this.client!.chat.completions.create(requestOptions))
return response.choices[0]?.message.content || ""
}
override getModel() {
const modelId = this.options.apiModelId
const id = modelId && modelId in qwenCodeModels ? (modelId as QwenCodeModelId) : qwenCodeDefaultModelId
const info: ModelInfo = qwenCodeModels[id]
const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options })
return { id, info, ...params }
}
protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk {
return {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,
outputTokens: usage?.completion_tokens || 0,
}
}
/**
* Adds max_completion_tokens to the request body if needed based on provider configuration
*/
private addMaxTokensIfNeeded(
requestOptions:
| OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
| OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming,
modelInfo: ModelInfo,
): void {
if (this.options.includeMaxTokens === true) {
requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens
}
}
}

View file

@ -107,6 +107,9 @@
"roo": {
"authenticationRequired": "El proveïdor Roo requereix autenticació al núvol. Si us plau, inicieu sessió a Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Error en carregar les credencials OAuth. Si us plau, autentica't primer: {{error}}"
},
"mode_import_failed": "Ha fallat la importació del mode: {{error}}"
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Roo-Anbieter erfordert Cloud-Authentifizierung. Bitte melde dich bei Roo Code Cloud an."
},
"qwenCode": {
"oauthLoadFailed": "Fehler beim Laden der OAuth-Anmeldedaten. Bitte authentifiziere dich zuerst: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Roo provider requires cloud authentication. Please sign in to Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Failed to load OAuth credentials. Please authenticate first: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "El proveedor Roo requiere autenticación en la nube. Por favor, inicia sesión en Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Error al cargar las credenciales OAuth. Por favor, autentícate primero: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Le fournisseur Roo nécessite une authentification cloud. Veuillez vous connecter à Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Échec du chargement des identifiants OAuth. Veuillez d'abord vous authentifier : {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Roo प्रदाता को क्लाउड प्रमाणीकरण की आवश्यकता है। कृपया Roo Code Cloud में साइन इन करें।"
},
"qwenCode": {
"oauthLoadFailed": "OAuth क्रेडेंशियल्स लोड करने में विफल। कृपया पहले प्रमाणीकरण करें: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Penyedia Roo memerlukan autentikasi cloud. Silakan masuk ke Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Gagal memuat kredensial OAuth. Silakan autentikasi terlebih dahulu: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Il provider Roo richiede l'autenticazione cloud. Accedi a Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Impossibile caricare le credenziali OAuth. Autenticati prima: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Rooプロバイダーはクラウド認証が必要です。Roo Code Cloudにサインインしてください。"
},
"qwenCode": {
"oauthLoadFailed": "OAuth認証情報の読み込みに失敗しました。最初に認証してください: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Roo 제공업체는 클라우드 인증이 필요합니다. Roo Code Cloud에 로그인하세요."
},
"qwenCode": {
"oauthLoadFailed": "OAuth 자격 증명을 로드하지 못했습니다. 먼저 인증하세요: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Roo provider vereist cloud authenticatie. Log in bij Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Fout bij laden van OAuth-inloggegevens. Authenticeer eerst: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Dostawca Roo wymaga uwierzytelnienia w chmurze. Zaloguj się do Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Nie udało się załadować poświadczeń OAuth. Najpierw się uwierzytelnij: {{error}}"
}
},
"warnings": {

View file

@ -107,6 +107,9 @@
},
"roo": {
"authenticationRequired": "O provedor Roo requer autenticação na nuvem. Faça login no Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Falha ao carregar credenciais OAuth. Por favor, autentique primeiro: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Провайдер Roo требует облачной аутентификации. Войдите в Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Не удалось загрузить учетные данные OAuth. Сначала пройдите аутентификацию: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Roo sağlayıcısı bulut kimlik doğrulaması gerektirir. Lütfen Roo Code Cloud'a giriş yapın."
},
"qwenCode": {
"oauthLoadFailed": "OAuth kimlik bilgileri yüklenemedi. Lütfen önce kimlik doğrulaması yapın: {{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
},
"roo": {
"authenticationRequired": "Nhà cung cấp Roo yêu cầu xác thực đám mây. Vui lòng đăng nhập vào Roo Code Cloud."
},
"qwenCode": {
"oauthLoadFailed": "Không thể tải thông tin xác thực OAuth. Vui lòng xác thực trước: {{error}}"
}
},
"warnings": {

View file

@ -108,6 +108,9 @@
},
"roo": {
"authenticationRequired": "Roo 提供商需要云认证。请登录 Roo Code Cloud。"
},
"qwenCode": {
"oauthLoadFailed": "加载 OAuth 凭据失败。请先进行身份验证:{{error}}"
}
},
"warnings": {

View file

@ -103,6 +103,9 @@
"roo": {
"authenticationRequired": "Roo 提供者需要雲端認證。請登入 Roo Code Cloud。"
},
"qwenCode": {
"oauthLoadFailed": "載入 OAuth 憑證失敗。請先進行驗證:{{error}}"
},
"mode_import_failed": "匯入模式失敗:{{error}}"
},
"warnings": {

View file

@ -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, roo, and qwen-code providers which don't need any configuration.
if (
config.apiProvider &&
["human-relay", "fake-ai", "claude-code", "roo", "qwen-code"].includes(config.apiProvider)
) {
return true
}

View file

@ -33,6 +33,7 @@ import {
fireworksDefaultModelId,
featherlessDefaultModelId,
ioIntelligenceDefaultModelId,
qwenCodeDefaultModelId,
rooDefaultModelId,
} from "@roo-code/types"
@ -80,6 +81,7 @@ import {
OpenAI,
OpenAICompatible,
OpenRouter,
QwenCode,
Requesty,
SambaNova,
Unbound,
@ -331,6 +333,7 @@ const ApiOptions = ({
fireworks: { field: "apiModelId", default: fireworksDefaultModelId },
featherless: { field: "apiModelId", default: featherlessDefaultModelId },
"io-intelligence": { field: "ioIntelligenceModelId", default: ioIntelligenceDefaultModelId },
"qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId },
roo: { field: "apiModelId", default: rooDefaultModelId },
openai: { field: "openAiModelId" },
ollama: { field: "ollamaModelId" },
@ -589,6 +592,10 @@ const ApiOptions = ({
<Fireworks apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProvider === "qwen-code" && (
<QwenCode apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProvider === "roo" && (
<div className="flex flex-col gap-3">
{cloudIsAuthenticated ? (

View file

@ -10,6 +10,7 @@ import {
geminiModels,
mistralModels,
openAiNativeModels,
qwenCodeModels,
vertexModels,
xaiModels,
groqModels,
@ -33,6 +34,7 @@ export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, Mod
gemini: geminiModels,
mistral: mistralModels,
"openai-native": openAiNativeModels,
"qwen-code": qwenCodeModels,
vertex: vertexModels,
xai: xaiModels,
groq: groqModels,
@ -75,5 +77,6 @@ export const PROVIDERS = [
{ value: "fireworks", label: "Fireworks AI" },
{ value: "featherless", label: "Featherless AI" },
{ value: "io-intelligence", label: "IO Intelligence" },
{ value: "qwen-code", label: "Qwen Code" },
{ value: "roo", label: "Roo Code Cloud" },
].sort((a, b) => a.label.localeCompare(b.label))

View file

@ -0,0 +1,58 @@
import { useCallback } from "react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import type { ProviderSettings } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import { inputEventTransform } from "../transforms"
type QwenCodeProps = {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
}
export const QwenCode = ({ apiConfiguration, setApiConfigurationField }: QwenCodeProps) => {
const { t } = useAppTranslation()
const handleInputChange = useCallback(
<K extends keyof ProviderSettings, E>(
field: K,
transform: (event: E) => ProviderSettings[K] = inputEventTransform,
) =>
(event: E | Event) => {
setApiConfigurationField(field, transform(event as E))
},
[setApiConfigurationField],
)
return (
<>
<VSCodeTextField
value={apiConfiguration?.qwenCodeOAuthPath || ""}
onInput={handleInputChange("qwenCodeOAuthPath")}
placeholder="~/.qwen/oauth_creds.json"
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.qwenCode.oauthPath")}</label>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground -mt-2">
{t("settings:providers.qwenCode.oauthPathDescription")}
</div>
<div className="text-sm text-vscode-descriptionForeground mt-3">
{t("settings:providers.qwenCode.description")}
</div>
<div className="text-sm text-vscode-descriptionForeground mt-2">
{t("settings:providers.qwenCode.instructions")}
</div>
<VSCodeLink
href="https://github.com/QwenLM/qwen-code/blob/main/README.md"
className="text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground mt-2 inline-block">
{t("settings:providers.qwenCode.setupLink")}
</VSCodeLink>
</>
)
}

View file

@ -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"

View file

@ -50,6 +50,8 @@ import {
featherlessDefaultModelId,
ioIntelligenceDefaultModelId,
ioIntelligenceModels,
qwenCodeDefaultModelId,
qwenCodeModels,
rooDefaultModelId,
rooModels,
BEDROCK_CLAUDE_SONNET_4_MODEL_ID,
@ -305,6 +307,11 @@ function getSelectedModel({
routerModels["io-intelligence"]?.[id] ?? ioIntelligenceModels[id as keyof typeof ioIntelligenceModels]
return { id, info }
}
case "qwen-code": {
const id = apiConfiguration.apiModelId ?? qwenCodeDefaultModelId
const info = qwenCodeModels[id as keyof typeof qwenCodeModels]
return { id, info }
}
case "roo": {
const id = apiConfiguration.apiModelId ?? rooDefaultModelId
const info = rooModels[id as keyof typeof rooModels]
@ -314,7 +321,7 @@ function getSelectedModel({
// case "human-relay":
// case "fake-ai":
default: {
provider satisfies "anthropic" | "gemini-cli" | "human-relay" | "fake-ai"
provider satisfies "anthropic" | "gemini-cli" | "human-relay" | "fake-ai" | "qwen-code"
const id = apiConfiguration.apiModelId ?? anthropicDefaultModelId
const baseInfo = anthropicModels[id as keyof typeof anthropicModels]

View file

@ -463,6 +463,13 @@
"placeholder": "Per defecte: claude",
"maxTokensLabel": "Tokens màxims de sortida",
"maxTokensDescription": "Nombre màxim de tokens de sortida per a les respostes de Claude Code. El valor per defecte és 8000."
},
"qwenCode": {
"oauthPath": "Ruta de credencials OAuth (opcional)",
"oauthPathDescription": "Ruta del fitxer de credencials OAuth. Deixa-ho buit per utilitzar la ubicació per defecte (~/.qwen/oauth_creds.json).",
"description": "Aquest proveïdor utilitza autenticació OAuth del servei Qwen i no requereix claus API.",
"instructions": "Si us plau, seguiu la documentació oficial per obtenir el fitxer d'autorització i col·locar-lo en la ruta especificada.",
"setupLink": "Documentació oficial de Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Standard: claude",
"maxTokensLabel": "Maximale Ausgabe-Tokens",
"maxTokensDescription": "Maximale Anzahl an Ausgabe-Tokens für Claude Code-Antworten. Standard ist 8000."
},
"qwenCode": {
"oauthPath": "OAuth-Anmeldedaten Pfad (optional)",
"oauthPathDescription": "Pfad zur OAuth-Anmeldedatei. Leer lassen, um den Standardort zu verwenden (~/.qwen/oauth_creds.json).",
"description": "Dieser Anbieter verwendet OAuth-Authentifizierung vom Qwen-Service und benötigt keine API-Schlüssel.",
"instructions": "Bitte folge der offiziellen Dokumentation, um die Autorisierungsdatei zu erhalten und sie im angegebenen Pfad zu platzieren.",
"setupLink": "Qwen Offizielle Dokumentation"
}
},
"browser": {

View file

@ -462,6 +462,13 @@
"placeholder": "Default: claude",
"maxTokensLabel": "Max Output Tokens",
"maxTokensDescription": "Maximum number of output tokens for Claude Code responses. Default is 8000."
},
"qwenCode": {
"oauthPath": "OAuth Credentials Path (optional)",
"oauthPathDescription": "Path to the OAuth credentials file. Leave empty to use the default location (~/.qwen/oauth_creds.json).",
"description": "This provider uses OAuth authentication from the Qwen service and does not require API keys.",
"instructions": "Please follow the official documentation to obtain the authorization file and place it in the specified path.",
"setupLink": "Qwen Official Documentation"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Por defecto: claude",
"maxTokensLabel": "Tokens máximos de salida",
"maxTokensDescription": "Número máximo de tokens de salida para las respuestas de Claude Code. El valor predeterminado es 8000."
},
"qwenCode": {
"oauthPath": "Ruta de credenciales OAuth (opcional)",
"oauthPathDescription": "Ruta del archivo de credenciales OAuth. Déjalo vacío para usar la ubicación predeterminada (~/.qwen/oauth_creds.json).",
"description": "Este proveedor utiliza autenticación OAuth del servicio Qwen y no requiere claves API.",
"instructions": "Por favor, sigue la documentación oficial para obtener el archivo de autorización y colocarlo en la ruta especificada.",
"setupLink": "Documentación oficial de Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Défaut : claude",
"maxTokensLabel": "Jetons de sortie max",
"maxTokensDescription": "Nombre maximum de jetons de sortie pour les réponses de Claude Code. La valeur par défaut est 8000."
},
"qwenCode": {
"oauthPath": "Chemin des identifiants OAuth (optionnel)",
"oauthPathDescription": "Chemin du fichier d'identifiants OAuth. Laissez vide pour utiliser l'emplacement par défaut (~/.qwen/oauth_creds.json).",
"description": "Ce fournisseur utilise l'authentification OAuth du service Qwen et ne nécessite pas de clés API.",
"instructions": "Veuillez suivre la documentation officielle pour obtenir le fichier d'autorisation et le placer dans le chemin spécifié.",
"setupLink": "Documentation officielle Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "डिफ़ॉल्ट: claude",
"maxTokensLabel": "अधिकतम आउटपुट टोकन",
"maxTokensDescription": "Claude Code प्रतिक्रियाओं के लिए आउटपुट टोकन की अधिकतम संख्या। डिफ़ॉल्ट 8000 है।"
},
"qwenCode": {
"oauthPath": "OAuth क्रेडेंशियल्स पथ (वैकल्पिक)",
"oauthPathDescription": "OAuth क्रेडेंशियल्स फ़ाइल का पथ। डिफ़ॉल्ट स्थान (~/.qwen/oauth_creds.json) का उपयोग करने के लिए खाली छोड़ें।",
"description": "यह प्रदाता Qwen सेवा से OAuth प्रमाणीकरण का उपयोग करता है और API कुंजियों की आवश्यकता नहीं है।",
"instructions": "कृपया प्राधिकरण फ़ाइल प्राप्त करने और इसे निर्दिष्ट पथ में रखने के लिए आधिकारिक दस्तावेज़ीकरण का पालन करें।",
"setupLink": "Qwen आधिकारिक दस्तावेज़ीकरण"
}
},
"browser": {

View file

@ -467,6 +467,13 @@
"placeholder": "Default: claude",
"maxTokensLabel": "Token Output Maks",
"maxTokensDescription": "Jumlah maksimum token output untuk respons Claude Code. Default adalah 8000."
},
"qwenCode": {
"oauthPath": "Jalur Kredensial OAuth (opsional)",
"oauthPathDescription": "Jalur ke file kredensial OAuth. Biarkan kosong untuk menggunakan lokasi default (~/.qwen/oauth_creds.json).",
"description": "Provider ini menggunakan autentikasi OAuth dari layanan Qwen dan tidak memerlukan API key.",
"instructions": "Silakan ikuti dokumentasi resmi untuk memperoleh file otorisasi dan menempatkannya di jalur yang ditentukan.",
"setupLink": "Dokumentasi Resmi Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Predefinito: claude",
"maxTokensLabel": "Token di output massimi",
"maxTokensDescription": "Numero massimo di token di output per le risposte di Claude Code. Il valore predefinito è 8000."
},
"qwenCode": {
"oauthPath": "Percorso credenziali OAuth (opzionale)",
"oauthPathDescription": "Percorso del file delle credenziali OAuth. Lascia vuoto per utilizzare la posizione predefinita (~/.qwen/oauth_creds.json).",
"description": "Questo provider utilizza l'autenticazione OAuth dal servizio Qwen e non richiede chiavi API.",
"instructions": "Segui la documentazione ufficiale per ottenere il file di autorizzazione e posizionarlo nel percorso specificato.",
"setupLink": "Documentazione ufficiale Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "デフォルトclaude",
"maxTokensLabel": "最大出力トークン",
"maxTokensDescription": "Claude Codeレスポンスの最大出力トークン数。デフォルトは8000です。"
},
"qwenCode": {
"oauthPath": "OAuth認証情報パスオプション",
"oauthPathDescription": "OAuth認証情報ファイルのパス。デフォルトの場所~/.qwen/oauth_creds.jsonを使用する場合は空にしてください。",
"description": "このプロバイダーはQwenサービスからのOAuth認証を使用し、APIキーは必要ありません。",
"instructions": "認証ファイルを取得し、指定されたパスに配置するには、公式ドキュメントに従ってください。",
"setupLink": "Qwen公式ドキュメント"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "기본값: claude",
"maxTokensLabel": "최대 출력 토큰",
"maxTokensDescription": "Claude Code 응답의 최대 출력 토큰 수. 기본값은 8000입니다."
},
"qwenCode": {
"oauthPath": "OAuth 자격 증명 경로 (선택사항)",
"oauthPathDescription": "OAuth 자격 증명 파일의 경로입니다. 기본 위치(~/.qwen/oauth_creds.json)를 사용하려면 비워두세요.",
"description": "이 공급자는 Qwen 서비스의 OAuth 인증을 사용하며 API 키가 필요하지 않습니다.",
"instructions": "인증 파일을 획득하고 지정된 경로에 배치하려면 공식 문서를 따르세요.",
"setupLink": "Qwen 공식 문서"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Standaard: claude",
"maxTokensLabel": "Max Output Tokens",
"maxTokensDescription": "Maximaal aantal output-tokens voor Claude Code-reacties. Standaard is 8000."
},
"qwenCode": {
"oauthPath": "OAuth-inloggegevens pad (optioneel)",
"oauthPathDescription": "Pad naar het OAuth-inloggegevens bestand. Laat leeg om de standaardlocatie (~/.qwen/oauth_creds.json) te gebruiken.",
"description": "Deze provider gebruikt OAuth-authenticatie van de Qwen-service en vereist geen API-sleutels.",
"instructions": "Volg de officiële documentatie om het autorisatiebestand te verkrijgen en het in het opgegeven pad te plaatsen.",
"setupLink": "Qwen officiële documentatie"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Domyślnie: claude",
"maxTokensLabel": "Maksymalna liczba tokenów wyjściowych",
"maxTokensDescription": "Maksymalna liczba tokenów wyjściowych dla odpowiedzi Claude Code. Domyślnie 8000."
},
"qwenCode": {
"oauthPath": "Ścieżka poświadczeń OAuth (opcjonalnie)",
"oauthPathDescription": "Ścieżka do pliku poświadczeń OAuth. Pozostaw puste, aby użyć domyślnej lokalizacji (~/.qwen/oauth_creds.json).",
"description": "Ten dostawca używa uwierzytelniania OAuth z usługi Qwen i nie wymaga kluczy API.",
"instructions": "Postępuj zgodnie z oficjalną dokumentacją, aby uzyskać plik autoryzacji i umieścić go w określonej ścieżce.",
"setupLink": "Oficjalna dokumentacja Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Padrão: claude",
"maxTokensLabel": "Tokens de saída máximos",
"maxTokensDescription": "Número máximo de tokens de saída para respostas do Claude Code. O padrão é 8000."
},
"qwenCode": {
"oauthPath": "Caminho das credenciais OAuth (opcional)",
"oauthPathDescription": "Caminho para o arquivo de credenciais OAuth. Deixe vazio para usar a localização padrão (~/.qwen/oauth_creds.json).",
"description": "Este provedor usa autenticação OAuth do serviço Qwen e não requer chaves de API.",
"instructions": "Siga a documentação oficial para obter o arquivo de autorização e colocá-lo no caminho especificado.",
"setupLink": "Documentação oficial do Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "По умолчанию: claude",
"maxTokensLabel": "Макс. выходных токенов",
"maxTokensDescription": "Максимальное количество выходных токенов для ответов Claude Code. По умолчанию 8000."
},
"qwenCode": {
"oauthPath": "Путь OAuth-данных (необязательно)",
"oauthPathDescription": "Путь к файлу OAuth-данных. Оставьте пустым для использования местоположения по умолчанию (~/.qwen/oauth_creds.json).",
"description": "Этот провайдер использует OAuth-аутентификацию из сервиса Qwen и не требует API-ключей.",
"instructions": "Следуйте официальной документации для получения файла авторизации и размещения его по указанному пути.",
"setupLink": "Официальная документация Qwen"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Varsayılan: claude",
"maxTokensLabel": "Maksimum Çıktı Token sayısı",
"maxTokensDescription": "Claude Code yanıtları için maksimum çıktı token sayısı. Varsayılan 8000'dir."
},
"qwenCode": {
"oauthPath": "OAuth kimlik bilgileri yolu (isteğe bağlı)",
"oauthPathDescription": "OAuth kimlik bilgileri dosyasının yolu. Varsayılan konumu (~/.qwen/oauth_creds.json) kullanmak için boş bırakın.",
"description": "Bu sağlayıcı Qwen hizmetinden OAuth kimlik doğrulaması kullanır ve API anahtarı gerektirmez.",
"instructions": "Yetkilendirme dosyasını almak ve belirtilen yola yerleştirmek için resmi belgeleri takip edin.",
"setupLink": "Qwen resmi belgeleri"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "Mặc định: claude",
"maxTokensLabel": "Số token đầu ra tối đa",
"maxTokensDescription": "Số lượng token đầu ra tối đa cho các phản hồi của Claude Code. Mặc định là 8000."
},
"qwenCode": {
"oauthPath": "Đường dẫn thông tin xác thực OAuth (tùy chọn)",
"oauthPathDescription": "Đường dẫn đến tệp thông tin xác thực OAuth. Để trống để sử dụng vị trí mặc định (~/.qwen/oauth_creds.json).",
"description": "Nhà cung cấp này sử dụng xác thực OAuth từ dịch vụ Qwen và không yêu cầu khóa API.",
"instructions": "Vui lòng theo dõi tài liệu chính thức để lấy tệp ủy quyền và đặt nó trong đường dẫn đã chỉ định.",
"setupLink": "Tài liệu chính thức Qwen"
}
},
"browser": {

View file

@ -459,10 +459,17 @@
"setReasoningLevel": "启用推理工作量",
"claudeCode": {
"pathLabel": "Claude Code 路径",
"description": "您的 Claude Code CLI 的可选路径。如果未设置,则默认为 “claude”。",
"description": "您的 Claude Code CLI 的可选路径。如果未设置,则默认为 'claude'。",
"placeholder": "默认claude",
"maxTokensLabel": "最大输出 Token",
"maxTokensDescription": "Claude Code 响应的最大输出 Token 数量。默认为 8000。"
},
"qwenCode": {
"oauthPath": "OAuth 凭据路径(可选)",
"oauthPathDescription": "OAuth 凭据文件的路径。留空以使用默认位置 (~/.qwen/oauth_creds.json)。",
"description": "此提供商使用来自 Qwen 服务的 OAuth 身份验证,不需要 API 密钥。",
"instructions": "请参照官方文档获取授权文件并将其放置在指定路径。",
"setupLink": "Qwen 官方文档"
}
},
"browser": {

View file

@ -463,6 +463,13 @@
"placeholder": "預設claude",
"maxTokensLabel": "最大輸出 Token",
"maxTokensDescription": "Claude Code 回應的最大輸出 Token 數量。預設為 8000。"
},
"qwenCode": {
"oauthPath": "OAuth 憑證路徑(選用)",
"oauthPathDescription": "OAuth 憑證檔案的路徑。留空以使用預設位置 (~/.qwen/oauth_creds.json)。",
"description": "此供應商使用來自 Qwen 服務的 OAuth 身分驗證,不需要 API 金鑰。",
"instructions": "請遵循官方說明文件取得授權檔案,並將其放置在指定路徑。",
"setupLink": "Qwen 官方說明文件"
}
},
"browser": {

View file

@ -73,6 +73,9 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri
return i18next.t("settings:validation.apiKey")
}
break
case "qwen-code":
// OAuth-based provider, no API key validation needed
break
case "openai-native":
if (!apiConfiguration.openAiNativeApiKey) {
return i18next.t("settings:validation.apiKey")