diff --git a/package-lock.json b/package-lock.json index 65f0f54b..b194e7b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "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-qwen-cli", "packages/coding-agent/examples/extensions/sandbox" ], "dependencies": { @@ -6592,10 +6591,6 @@ "resolved": "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo", "link": true }, - "node_modules/pi-extension-custom-provider-qwen-cli": { - "resolved": "packages/coding-agent/examples/extensions/custom-provider-qwen-cli", - "link": true - }, "node_modules/pi-extension-sandbox": { "resolved": "packages/coding-agent/examples/extensions/sandbox", "link": true @@ -8282,10 +8277,6 @@ "name": "pi-extension-custom-provider-gitlab-duo", "version": "0.70.6" }, - "packages/coding-agent/examples/extensions/custom-provider-qwen-cli": { - "name": "pi-extension-custom-provider-qwen-cli", - "version": "0.70.6" - }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", "version": "1.0.0", diff --git a/package.json b/package.json index 1fe982a5..6ba7e9db 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,6 @@ "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-qwen-cli", "packages/coding-agent/examples/extensions/sandbox" ], "scripts": { diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index fa4d9afb..d3575927 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -53,6 +53,7 @@ ### Removed +- Removed the discontinued Qwen CLI OAuth custom provider extension example ([#3832](https://github.com/badlogic/pi-mono/pull/3832) by [@4h9fbZ](https://github.com/4h9fbZ)). - Removed Google Gemini CLI and Google Antigravity built-in login, default model, documentation, and example extension support. ## [0.70.6] - 2026-04-28 diff --git a/packages/coding-agent/docs/custom-provider.md b/packages/coding-agent/docs/custom-provider.md index a1e68682..d4562a39 100644 --- a/packages/coding-agent/docs/custom-provider.md +++ b/packages/coding-agent/docs/custom-provider.md @@ -13,7 +13,6 @@ See these complete provider examples: - [`examples/extensions/custom-provider-anthropic/`](../examples/extensions/custom-provider-anthropic/) - [`examples/extensions/custom-provider-gitlab-duo/`](../examples/extensions/custom-provider-gitlab-duo/) -- [`examples/extensions/custom-provider-qwen-cli/`](../examples/extensions/custom-provider-qwen-cli/) ## Table of Contents diff --git a/packages/coding-agent/examples/extensions/README.md b/packages/coding-agent/examples/extensions/README.md index 48937dfd..63070867 100644 --- a/packages/coding-agent/examples/extensions/README.md +++ b/packages/coding-agent/examples/extensions/README.md @@ -124,7 +124,6 @@ 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 deleted file mode 100644 index c2658d7d..00000000 --- a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/.gitignore +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 57deb8af..00000000 --- a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/index.ts +++ /dev/null @@ -1,345 +0,0 @@ -/** - * 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 deleted file mode 100644 index fea109d0..00000000 --- a/packages/coding-agent/examples/extensions/custom-provider-qwen-cli/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "pi-extension-custom-provider-qwen-cli", - "private": true, - "version": "0.70.6", - "type": "module", - "scripts": { - "clean": "echo 'nothing to clean'", - "build": "echo 'nothing to build'", - "check": "echo 'nothing to check'" - }, - "pi": { - "extensions": [ - "./index.ts" - ] - } -}