pi subagents and workflow skill plus HA

This commit is contained in:
2026-03-28 22:00:48 +01:00
parent 6514c8a319
commit b15689b73c
20 changed files with 1243 additions and 25 deletions

View File

@@ -0,0 +1,378 @@
/**
* Qwen Provider Extension
*
* Registers Qwen 3.5 models via the qwen.ai OAuth flow (chat.qwen.ai).
* Based on the upstream custom-provider-qwen-cli example.
*
* Models:
* - qwen3.5-max (frontier, best reasoning)
* - qwen3.5-397b-a17b (large MoE, strong parallel workhorse)
* - qwen3.5-122b-a10b (mid MoE, efficient scout/light coder)
* - qwen3.5-35b-a3b (small MoE, fast throwaway tasks)
* - qwen3.5-27b (dense, sustained reasoning)
*
* Usage:
* /login qwen-cli (browser OAuth)
* 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();
const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri;
const instructions = deviceCode.verification_uri_complete
? undefined
: `Enter code: ${deviceCode.user_code}`;
callbacks.onAuth({ url: authUrl, instructions });
const tokenResponse = await pollForToken(
deviceCode.device_code,
verifier,
deviceCode.interval,
deviceCode.expires_in,
callbacks.signal,
);
const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: tokenResponse.refresh_token || "",
access: tokenResponse.access_token,
expires: expiresAt,
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.5-max",
name: "Qwen 3.5 Max (Frontier)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3.5-397b-a17b",
name: "Qwen 3.5 397B-A17B (Large MoE)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3.5-122b-a10b",
name: "Qwen 3.5 122B-A10B (Mid MoE)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3.5-35b-a3b",
name: "Qwen 3.5 35B-A3B (Small MoE)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3.5-27b",
name: "Qwen 3.5 27B (Dense)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
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));
},
},
});
}