diff --git a/package.json b/package.json index a28356b5b..0199ab89b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "packages/web-ui/example", "packages/coding-agent/examples/extensions/with-deps", "packages/coding-agent/examples/extensions/custom-provider-anthropic", - "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo" + "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", + "packages/coding-agent/examples/extensions/custom-provider-qwen-cli" ], "scripts": { "clean": "npm run clean --workspaces", diff --git a/packages/ai/README.md b/packages/ai/README.md index 8f5c1b797..6aadcbcaa 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -732,7 +732,7 @@ interface OpenAICompletionsCompat { supportsDeveloperRole?: boolean; // Whether provider supports `developer` role vs `system` (default: true) supportsReasoningEffort?: boolean; // Whether provider supports `reasoning_effort` (default: true) maxTokensField?: 'max_completion_tokens' | 'max_tokens'; // Which field name to use (default: max_completion_tokens) - thinkingFormat?: 'openai' | 'zai'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" } (default: openai) + thinkingFormat?: 'openai' | 'zai' | 'qwen'; // Format for reasoning param: 'openai' uses reasoning_effort, 'zai' uses thinking: { type: "enabled" }, 'qwen' uses enable_thinking: boolean (default: openai) } interface OpenAIResponsesCompat { diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index 2fcf86fd5..1c3538af0 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -442,6 +442,9 @@ function buildParams(model: Model<"openai-completions">, context: Context, optio // Z.ai uses binary thinking: { type: "enabled" | "disabled" } // Must explicitly disable since z.ai defaults to thinking enabled (params as any).thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" }; + } else if (compat.thinkingFormat === "qwen" && model.reasoning) { + // Qwen uses enable_thinking: boolean + (params as any).enable_thinking = !!options?.reasoningEffort; } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { // OpenAI-style reasoning_effort params.reasoning_effort = options.reasoningEffort; diff --git a/packages/ai/src/types.ts b/packages/ai/src/types.ts index 8cdf4dc10..f8c8921f5 100644 --- a/packages/ai/src/types.ts +++ b/packages/ai/src/types.ts @@ -222,8 +222,8 @@ export interface OpenAICompletionsCompat { requiresThinkingAsText?: boolean; /** Whether tool call IDs must be normalized to Mistral format (exactly 9 alphanumeric chars). Default: auto-detected from URL. */ requiresMistralToolIds?: boolean; - /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }. Default: "openai". */ - thinkingFormat?: "openai" | "zai"; + /** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "zai" uses thinking: { type: "enabled" }, "qwen" uses enable_thinking: boolean. Default: "openai". */ + thinkingFormat?: "openai" | "zai" | "qwen"; /** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */ openRouterRouting?: OpenRouterRouting; /** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */ diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 38921014d..86ccf7db9 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -108,6 +108,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/ |-----------|-------------| | `custom-provider-anthropic/` | Custom Anthropic provider with OAuth support and custom streaming implementation | | `custom-provider-gitlab-duo/` | GitLab Duo provider using pi-ai's built-in Anthropic/OpenAI streaming via proxy | +| `custom-provider-qwen-cli/` | Qwen CLI provider with OAuth device flow and OpenAI-compatible models | ### External Dependencies diff --git a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore new file mode 100644 index 000000000..c2658d7d1 --- /dev/null +++ b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts new file mode 100644 index 000000000..57deb8af7 --- /dev/null +++ b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts @@ -0,0 +1,345 @@ +/** + * Qwen CLI Provider Extension + * + * Provides access to Qwen models via OAuth authentication with chat.qwen.ai. + * Uses device code flow with PKCE for secure browser-based authentication. + * + * Usage: + * pi -e ./packages/coding-agent/examples/extensions/custom-provider-qwen-cli + * # Then /login qwen-cli, or set QWEN_CLI_API_KEY=... + */ + +import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; + +// ============================================================================= +// Constants +// ============================================================================= + +const QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code"; +const QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token"; +const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56"; +const QWEN_SCOPE = "openid profile email model.completion"; +const QWEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; +const QWEN_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"; +const QWEN_POLL_INTERVAL_MS = 2000; + +// ============================================================================= +// PKCE Helpers +// ============================================================================= + +async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + const verifier = btoa(String.fromCharCode(...array)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + const challenge = btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + + return { verifier, challenge }; +} + +// ============================================================================= +// OAuth Implementation +// ============================================================================= + +interface DeviceCodeResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval?: number; +} + +interface TokenResponse { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in: number; + resource_url?: string; +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new Error("Login cancelled")); + return; + } + const timeout = setTimeout(resolve, ms); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(new Error("Login cancelled")); + }, + { once: true }, + ); + }); +} + +async function startDeviceFlow(): Promise<{ deviceCode: DeviceCodeResponse; verifier: string }> { + const { verifier, challenge } = await generatePKCE(); + + const body = new URLSearchParams({ + client_id: QWEN_CLIENT_ID, + scope: QWEN_SCOPE, + code_challenge: challenge, + code_challenge_method: "S256", + }); + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + const requestId = globalThis.crypto?.randomUUID?.(); + if (requestId) headers["x-request-id"] = requestId; + + const response = await fetch(QWEN_DEVICE_CODE_ENDPOINT, { + method: "POST", + headers, + body: body.toString(), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Device code request failed: ${response.status} ${text}`); + } + + const data = (await response.json()) as DeviceCodeResponse; + + if (!data.device_code || !data.user_code || !data.verification_uri) { + throw new Error("Invalid device code response: missing required fields"); + } + + return { deviceCode: data, verifier }; +} + +async function pollForToken( + deviceCode: string, + verifier: string, + intervalSeconds: number | undefined, + expiresIn: number, + signal?: AbortSignal, +): Promise { + const deadline = Date.now() + expiresIn * 1000; + const resolvedIntervalSeconds = + typeof intervalSeconds === "number" && Number.isFinite(intervalSeconds) && intervalSeconds > 0 + ? intervalSeconds + : QWEN_POLL_INTERVAL_MS / 1000; + let intervalMs = Math.max(1000, Math.floor(resolvedIntervalSeconds * 1000)); + + const handleTokenError = async (error: string, description?: string): Promise => { + switch (error) { + case "authorization_pending": + await abortableSleep(intervalMs, signal); + return true; + case "slow_down": + intervalMs = Math.min(intervalMs + 5000, 10000); + await abortableSleep(intervalMs, signal); + return true; + case "expired_token": + throw new Error("Device code expired. Please restart authentication."); + case "access_denied": + throw new Error("Authorization denied by user."); + default: + throw new Error(`Token request failed: ${error} - ${description || ""}`); + } + }; + + while (Date.now() < deadline) { + if (signal?.aborted) { + throw new Error("Login cancelled"); + } + + const body = new URLSearchParams({ + grant_type: QWEN_GRANT_TYPE, + client_id: QWEN_CLIENT_ID, + device_code: deviceCode, + code_verifier: verifier, + }); + + const response = await fetch(QWEN_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: body.toString(), + }); + + const responseText = await response.text(); + let data: (TokenResponse & { error?: string; error_description?: string }) | null = null; + if (responseText) { + try { + data = JSON.parse(responseText) as TokenResponse & { error?: string; error_description?: string }; + } catch { + data = null; + } + } + + const error = data?.error; + const errorDescription = data?.error_description; + + if (!response.ok) { + if (error && (await handleTokenError(error, errorDescription))) { + continue; + } + throw new Error(`Token request failed: ${response.status} ${response.statusText}. Response: ${responseText}`); + } + + if (data?.access_token) { + return data; + } + + if (error && (await handleTokenError(error, errorDescription))) { + continue; + } + + throw new Error("Token request failed: missing access token in response"); + } + + throw new Error("Authentication timed out. Please try again."); +} + +async function loginQwen(callbacks: OAuthLoginCallbacks): Promise { + const { deviceCode, verifier } = await startDeviceFlow(); + + // Show verification URL and user code to user + const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri; + const instructions = deviceCode.verification_uri_complete + ? undefined // Code is already embedded in the URL + : `Enter code: ${deviceCode.user_code}`; + callbacks.onAuth({ url: authUrl, instructions }); + + // Poll for token + const tokenResponse = await pollForToken( + deviceCode.device_code, + verifier, + deviceCode.interval, + deviceCode.expires_in, + callbacks.signal, + ); + + // Calculate expiry with 5-minute buffer + const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: tokenResponse.refresh_token || "", + access: tokenResponse.access_token, + expires: expiresAt, + // Store resource_url for API base URL if provided + enterpriseUrl: tokenResponse.resource_url, + }; +} + +async function refreshQwenToken(credentials: OAuthCredentials): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: QWEN_CLIENT_ID, + }); + + const response = await fetch(QWEN_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: body.toString(), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`Token refresh failed: ${response.status} ${text}`); + } + + const data = (await response.json()) as TokenResponse; + + if (!data.access_token) { + throw new Error("Token refresh failed: no access token in response"); + } + + const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000; + + return { + refresh: data.refresh_token || credentials.refresh, + access: data.access_token, + expires: expiresAt, + enterpriseUrl: data.resource_url ?? credentials.enterpriseUrl, + }; +} + +function getQwenBaseUrl(resourceUrl?: string): string { + if (!resourceUrl) { + return QWEN_DEFAULT_BASE_URL; + } + + let url = resourceUrl.startsWith("http") ? resourceUrl : `https://${resourceUrl}`; + if (!url.endsWith("/v1")) { + url = `${url}/v1`; + } + return url; +} + +// ============================================================================= +// Extension Entry Point +// ============================================================================= + +export default function (pi: ExtensionAPI) { + pi.registerProvider("qwen-cli", { + baseUrl: QWEN_DEFAULT_BASE_URL, + apiKey: "QWEN_CLI_API_KEY", + api: "openai-completions", + + models: [ + { + id: "qwen3-coder-plus", + name: "Qwen3 Coder Plus", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 65536, + }, + { + id: "qwen3-coder-flash", + name: "Qwen3 Coder Flash", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000000, + maxTokens: 65536, + }, + { + id: "vision-model", + name: "Qwen3 VL Plus", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 262144, + maxTokens: 32768, + compat: { supportsDeveloperRole: false, thinkingFormat: "qwen" }, + }, + ], + + oauth: { + name: "Qwen CLI", + login: loginQwen, + refreshToken: refreshQwenToken, + getApiKey: (cred) => cred.access, + modifyModels: (models, cred) => { + const baseUrl = getQwenBaseUrl(cred.enterpriseUrl as string | undefined); + return models.map((m) => (m.provider === "qwen-cli" ? { ...m, baseUrl } : m)); + }, + }, + }); +} diff --git a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json new file mode 100644 index 000000000..ad94d97c7 --- /dev/null +++ b/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json @@ -0,0 +1,16 @@ +{ + "name": "pi-extension-custom-provider-qwen-cli", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "clean": "echo 'nothing to clean'", + "build": "echo 'nothing to build'", + "check": "echo 'nothing to check'" + }, + "pi": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 50c4c6b06..38aeb1889 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -48,7 +48,7 @@ const OpenAICompletionsCompatSchema = Type.Object({ requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), requiresThinkingAsText: Type.Optional(Type.Boolean()), requiresMistralToolIds: Type.Optional(Type.Boolean()), - thinkingFormat: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai")])), + thinkingFormat: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai"), Type.Literal("qwen")])), openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), }); @@ -544,6 +544,14 @@ export class ModelRegistry { compat: modelDef.compat, } as Model); } + + // Apply OAuth modifyModels if credentials exist (e.g., to update baseUrl) + if (config.oauth?.modifyModels) { + const cred = this.authStorage.get(providerName); + if (cred?.type === "oauth") { + this.models = config.oauth.modifyModels(this.models, cred); + } + } } else if (config.baseUrl) { // Override-only: update baseUrl/headers for existing models const resolvedHeaders = resolveHeaders(config.headers);