mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 09:01:14 +00:00
feat(coding-agent): add Qwen CLI OAuth provider
This commit is contained in:
parent
c27168840c
commit
993c45a059
9 changed files with 380 additions and 5 deletions
|
|
@ -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<void> {
|
||||
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<string, string> = {
|
||||
"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<TokenResponse> {
|
||||
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<boolean> => {
|
||||
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<OAuthCredentials> {
|
||||
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<OAuthCredentials> {
|
||||
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));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue