pi-free 2.2.3 → 2.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * COSY cryptographic signing for Qoder API authentication.
3
+ *
4
+ * Qoder uses a proprietary signing scheme (COSY) that combines RSA-encrypted
5
+ * AES keys, AES-CBC-encrypted user info, and MD5 payload signing. This module
6
+ * reimplements the same algorithm used by the official qodercli binary.
7
+ *
8
+ * The auth flow:
9
+ * 1. Generate a random 16-byte AES key
10
+ * 2. AES-CBC-encrypt user info (uid, auth token, name, email) with it
11
+ * 3. RSA-encrypt the AES key with Qoder's public key
12
+ * 4. Build a COSY payload: { version, requestId, info, cosyVersion, ideVersion }
13
+ * 5. MD5-hash: payloadB64 + "\n" + cosyKey + "\n" + timestamp + "\n" + body + "\n" + sigPath
14
+ * 6. Send as Authorization: Bearer COSY.{payloadB64}.{sig} + 15 Cosy-* headers
15
+ */
16
+
17
+ import crypto from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join } from "node:path";
21
+
22
+ const QODER_RSA_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
23
+ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
24
+ 4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
25
+ 6HRkPJ7S236FZz73In/KVuLnwI8JJ2CbuJap8kvheCCZpmAWpb/cPx/3Vr/J6I17
26
+ XcW+ML9FoCI6AOvOzwIDAQAB
27
+ -----END PUBLIC KEY-----`;
28
+
29
+ const QODER_IDE_VERSION = "1.0.0";
30
+ const QODER_CLIENT_TYPE = "5";
31
+ const QODER_DATA_POLICY = "disagree";
32
+ const QODER_LOGIN_VERSION = "v2";
33
+ const QODER_MACHINE_OS = "x86_64_windows";
34
+ const QODER_MACHINE_TYPE_MAGIC = "5";
35
+
36
+ interface UserInfo {
37
+ uid: string;
38
+ security_oauth_token: string;
39
+ name: string;
40
+ aid: string;
41
+ email: string;
42
+ }
43
+
44
+ interface CosyPayload {
45
+ version: string;
46
+ requestId: string;
47
+ info: string;
48
+ cosyVersion: string;
49
+ ideVersion: string;
50
+ }
51
+
52
+ export interface CosyCredentials {
53
+ userID: string;
54
+ authToken: string;
55
+ name: string;
56
+ email: string;
57
+ machineID?: string;
58
+ }
59
+
60
+ type BodyInput = Buffer | string | null;
61
+
62
+ function bodyToUtf8(body: BodyInput): string {
63
+ if (!body) return "";
64
+ if (Buffer.isBuffer(body)) return body.toString("utf8");
65
+ return body;
66
+ }
67
+
68
+ function bodyToLengthString(body: BodyInput): string {
69
+ if (!body) return "0";
70
+ if (Buffer.isBuffer(body)) return String(body.length);
71
+ return String(Buffer.from(body).length);
72
+ }
73
+
74
+ function rsaEncryptBase64(data: Buffer | string): string {
75
+ const key = {
76
+ key: QODER_RSA_PUBLIC_KEY,
77
+ padding: crypto.constants.RSA_PKCS1_PADDING,
78
+ };
79
+ const encrypted = crypto.publicEncrypt(
80
+ key,
81
+ typeof data === "string" ? Buffer.from(data) : data,
82
+ );
83
+ return encrypted.toString("base64");
84
+ }
85
+
86
+ /**
87
+ * Encrypt plaintext with AES-128-CBC using the same key for both key and IV.
88
+ *
89
+ * NOTE: Using key as IV is insecure in general and would be flagged by static
90
+ * analysis tools. However, this is a strict requirement of Qoder's COSY protocol
91
+ * (reverse-engineered from the official CLI). Changing the mode or IV derivation
92
+ * will cause authentication failures.
93
+ *
94
+ * @param plaintext - Data to encrypt
95
+ * @param keyStr - 16-byte hex key (also used as IV per protocol spec)
96
+ * @returns Base64-encoded ciphertext
97
+ */
98
+ function aesEncryptCBCBase64(plaintext: string, keyStr: string): string {
99
+ // sonar-security: AES-CBC with key-as-IV is protocol-mandatory for Qoder COSY auth
100
+ const cipher = crypto.createCipheriv(
101
+ "aes-128-cbc",
102
+ Buffer.from(keyStr),
103
+ Buffer.from(keyStr),
104
+ );
105
+ let encrypted = cipher.update(plaintext, "utf8", "base64");
106
+ encrypted += cipher.final("base64");
107
+ return encrypted;
108
+ }
109
+
110
+ function computeSigPath(urlStr: string): string {
111
+ try {
112
+ const parsed = new URL(urlStr);
113
+ let sigPath = parsed.pathname;
114
+ if (sigPath.startsWith("/algo")) {
115
+ sigPath = sigPath.slice("/algo".length);
116
+ }
117
+ return sigPath;
118
+ } catch {
119
+ return "";
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get or create a persistent machine ID.
125
+ * Checks ~/.qoder/.auth/machine_id first, then falls back to ~/.pi/agent/qoder-machine-id.
126
+ */
127
+ export function getMachineId(): string {
128
+ const paths = [
129
+ join(homedir(), ".qoder", ".auth", "machine_id"),
130
+ join(homedir(), ".pi", "agent", "qoder-machine-id"),
131
+ ];
132
+ for (const p of paths) {
133
+ if (existsSync(p)) {
134
+ try {
135
+ const val = readFileSync(p, "utf8").trim();
136
+ if (val) return val;
137
+ } catch {
138
+ // Ignore read errors
139
+ }
140
+ }
141
+ }
142
+ const newId = crypto.randomUUID();
143
+ try {
144
+ const savePath = paths[1];
145
+ mkdirSync(dirname(savePath), { recursive: true });
146
+ writeFileSync(savePath, newId, "utf8");
147
+ } catch {
148
+ // Best-effort
149
+ }
150
+ return newId;
151
+ }
152
+
153
+ /**
154
+ * Build all COSY authentication headers for a Qoder API request.
155
+ *
156
+ * @param body - Request body (Buffer or string), or null for GET requests
157
+ * @param requestURL - Full URL being requested
158
+ * @param creds - COSY credentials (userID, authToken, name, email, machineID)
159
+ * @returns Record of headers to include in the request
160
+ */
161
+ export function buildAuthHeaders(
162
+ body: Buffer | string | null,
163
+ requestURL: string,
164
+ creds: CosyCredentials,
165
+ ): Record<string, string> {
166
+ if (!creds.userID) {
167
+ throw new Error("cosy: user id is empty");
168
+ }
169
+ if (!creds.authToken) {
170
+ throw new Error("cosy: auth token is empty");
171
+ }
172
+
173
+ const aesKey = crypto.randomUUID().replaceAll(/-/g, "").slice(0, 16);
174
+ const userInfo: UserInfo = {
175
+ uid: creds.userID,
176
+ security_oauth_token: creds.authToken,
177
+ name: creds.name || "",
178
+ aid: "",
179
+ email: creds.email || "",
180
+ };
181
+
182
+ const infoB64 = aesEncryptCBCBase64(JSON.stringify(userInfo), aesKey);
183
+ const cosyKey = rsaEncryptBase64(aesKey);
184
+
185
+ const timestamp = Math.floor(Date.now() / 1000).toString();
186
+ const requestId = crypto.randomUUID();
187
+
188
+ const cosyPayload: CosyPayload = {
189
+ version: "v1",
190
+ requestId,
191
+ info: infoB64,
192
+ cosyVersion: QODER_IDE_VERSION,
193
+ ideVersion: "",
194
+ };
195
+
196
+ const payloadB64 = Buffer.from(JSON.stringify(cosyPayload)).toString(
197
+ "base64",
198
+ );
199
+ const sigPath = computeSigPath(requestURL);
200
+
201
+ const bodyStr = bodyToUtf8(body);
202
+ const sigInput = `${payloadB64}\n${cosyKey}\n${timestamp}\n${bodyStr}\n${sigPath}`;
203
+ // sonar-security: MD5 is protocol-mandatory for COSY signature (reverse-engineered from Qoder CLI)
204
+ const sig = crypto.createHash("md5").update(sigInput).digest("hex");
205
+
206
+ // sonar-security: MD5 is protocol-mandatory for COSY body hash (reverse-engineered from Qoder CLI)
207
+ const bodyHash = crypto
208
+ .createHash("md5")
209
+ .update(body || "")
210
+ .digest("hex");
211
+ const bodyLen = bodyToLengthString(body);
212
+
213
+ const machineID = creds.machineID || getMachineId();
214
+
215
+ return {
216
+ Authorization: `Bearer COSY.${payloadB64}.${sig}`,
217
+ "Cosy-Key": cosyKey,
218
+ "Cosy-User": creds.userID,
219
+ "Cosy-Date": timestamp,
220
+ "Cosy-Version": QODER_IDE_VERSION,
221
+ "Cosy-Machineid": machineID,
222
+ "Cosy-Machinetoken": machineID,
223
+ "Cosy-Machinetype": QODER_MACHINE_TYPE_MAGIC,
224
+ "Cosy-Machineos": QODER_MACHINE_OS,
225
+ "Cosy-Clienttype": QODER_CLIENT_TYPE,
226
+ "Cosy-Clientip": "127.0.0.1",
227
+ "Cosy-Bodyhash": bodyHash,
228
+ "Cosy-Bodylength": bodyLen,
229
+ "Cosy-Sigpath": sigPath,
230
+ "Cosy-Data-Policy": QODER_DATA_POLICY,
231
+ "Cosy-Organization-Id": "",
232
+ "Cosy-Organization-Tags": "",
233
+ "Login-Version": QODER_LOGIN_VERSION,
234
+ "X-Request-Id": crypto.randomUUID(),
235
+ };
236
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Qoder model definitions and cache management.
3
+ *
4
+ * Qoder operates on a credits-based pricing model:
5
+ * - Community Edition (free): basic models with daily message limits
6
+ * - Pro / Pro+ / Ultra (paid): premium models via monthly credits
7
+ *
8
+ * The dynamic model list API is currently unavailable (legacy api3 endpoint
9
+ * is decommissioned). We keep a static curated list and classify models as
10
+ * basic (free tier) or premium (paid credits) by model ID.
11
+ *
12
+ * Dynamic model discovery is disabled until Qoder publishes a model-list
13
+ * endpoint on api2-v2. Stale legacy cache entries are ignored and static
14
+ * models in `staticModels` remain the source of truth.
15
+ */
16
+
17
+ import { existsSync, readFileSync } from "node:fs";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
21
+ import { createLogger } from "../../lib/logger.ts";
22
+
23
+ const _logger = createLogger("qoder");
24
+
25
+ export type QoderModelConfig = ProviderModelConfig;
26
+
27
+ // ─── Cache ───────────────────────────────────────────────────────────────────
28
+
29
+ const CACHE_PATH = join(homedir(), ".pi", "agent", "qoder-models-cache.json");
30
+
31
+ const ZERO_COST = Object.freeze({
32
+ input: 0,
33
+ output: 0,
34
+ cacheRead: 0,
35
+ cacheWrite: 0,
36
+ });
37
+
38
+ // ─── Basic (free-tier) model IDs ─────────────────────────────────────────────
39
+ // These are the Qoder-branded router models available on Community Edition.
40
+ // Named models (DeepSeek, Qwen, GLM, Kimi, MiniMax) are premium and cost credits.
41
+ // This set is the single source of truth for basic-model classification.
42
+ const BASIC_MODEL_IDS = new Set([
43
+ "auto",
44
+ "ultimate",
45
+ "performance",
46
+ "efficient",
47
+ "lite",
48
+ ]);
49
+
50
+ // ─── Static model list ───────────────────────────────────────────────────────
51
+
52
+ /**
53
+ * Static model definitions for Qoder.
54
+ * Basic models (free tier) are identified by membership in BASIC_MODEL_IDS.
55
+ * Premium models consume credits and require a paid plan.
56
+ *
57
+ * Model IDs are validated against the live api2-v2 endpoint; invalid IDs
58
+ * (dfmodel, gm51model, qmodel_latest) are excluded here.
59
+ */
60
+ export const staticModels: QoderModelConfig[] = [
61
+ {
62
+ id: "auto",
63
+ name: "Qoder Auto",
64
+ reasoning: true,
65
+ input: ["text", "image"] as ("text" | "image")[],
66
+ cost: ZERO_COST,
67
+ contextWindow: 180_000,
68
+ maxTokens: 32_768,
69
+ },
70
+ {
71
+ id: "ultimate",
72
+ name: "Qoder Ultimate",
73
+ reasoning: true,
74
+ input: ["text", "image"] as ("text" | "image")[],
75
+ cost: ZERO_COST,
76
+ contextWindow: 1_000_000,
77
+ maxTokens: 32_768,
78
+ },
79
+ {
80
+ id: "performance",
81
+ name: "Qoder Performance",
82
+ reasoning: true,
83
+ input: ["text", "image"] as ("text" | "image")[],
84
+ cost: ZERO_COST,
85
+ contextWindow: 1_000_000,
86
+ maxTokens: 32_768,
87
+ },
88
+ {
89
+ id: "efficient",
90
+ name: "Qoder Efficient",
91
+ reasoning: false,
92
+ input: ["text", "image"] as ("text" | "image")[],
93
+ cost: ZERO_COST,
94
+ contextWindow: 180_000,
95
+ maxTokens: 32_768,
96
+ },
97
+ {
98
+ id: "lite",
99
+ name: "Qoder Lite",
100
+ reasoning: false,
101
+ input: ["text"] as ("text" | "image")[],
102
+ cost: ZERO_COST,
103
+ contextWindow: 180_000,
104
+ maxTokens: 32_768,
105
+ },
106
+ {
107
+ id: "qmodel",
108
+ name: "Qwen3.7 Plus (Qoder)",
109
+ reasoning: false,
110
+ input: ["text", "image"] as ("text" | "image")[],
111
+ cost: ZERO_COST,
112
+ contextWindow: 1_000_000,
113
+ maxTokens: 32_768,
114
+ },
115
+ {
116
+ id: "dmodel",
117
+ name: "DeepSeek V4 Pro (Qoder)",
118
+ reasoning: true,
119
+ input: ["text", "image"] as ("text" | "image")[],
120
+ cost: ZERO_COST,
121
+ contextWindow: 1_000_000,
122
+ maxTokens: 32_768,
123
+ },
124
+ {
125
+ id: "kmodel",
126
+ name: "Kimi K2.6 (Qoder)",
127
+ reasoning: false,
128
+ input: ["text", "image"] as ("text" | "image")[],
129
+ cost: ZERO_COST,
130
+ contextWindow: 256_000,
131
+ maxTokens: 32_768,
132
+ },
133
+ {
134
+ id: "mmodel",
135
+ name: "MiniMax M3 (Qoder)",
136
+ reasoning: false,
137
+ input: ["text", "image"] as ("text" | "image")[],
138
+ cost: ZERO_COST,
139
+ contextWindow: 1_000_000,
140
+ maxTokens: 32_768,
141
+ },
142
+ ];
143
+
144
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
145
+
146
+ /** Check if a model is a basic (free-tier) model. */
147
+ export function isBasicModel(model: ProviderModelConfig): boolean {
148
+ return BASIC_MODEL_IDS.has(model.id);
149
+ }
150
+
151
+ // ─── Cache management ────────────────────────────────────────────────────────
152
+
153
+ /** Get models from cache, falling back to static models. */
154
+ export function getCachedModels(): QoderModelConfig[] {
155
+ if (existsSync(CACHE_PATH) && !isCacheStale()) {
156
+ try {
157
+ const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
158
+ if (data && Array.isArray(data.models)) {
159
+ return data.models as QoderModelConfig[];
160
+ }
161
+ } catch (err) {
162
+ _logger.warn(
163
+ "Failed to read Qoder model cache; falling back to static models",
164
+ {
165
+ error: err instanceof Error ? err.message : String(err),
166
+ },
167
+ );
168
+ }
169
+ }
170
+ return staticModels;
171
+ }
172
+
173
+ /** Check if the local model cache is stale (>1 hour old). */
174
+ export function isCacheStale(): boolean {
175
+ if (!existsSync(CACHE_PATH)) return true;
176
+ try {
177
+ const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
178
+ if (!data || typeof data.updatedAt !== "number") return true;
179
+ return Date.now() - data.updatedAt > 3_600_000; // 1 hour
180
+ } catch (err) {
181
+ _logger.warn("Failed to check Qoder cache staleness; treating as stale", {
182
+ error: err instanceof Error ? err.message : String(err),
183
+ });
184
+ return true;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Get the cached model config for a specific model key.
190
+ * Used to determine per-model settings (reasoning, max tokens, etc.) at stream time.
191
+ */
192
+ export function getCachedModelConfig(
193
+ modelKey: string,
194
+ ): Record<string, unknown> | null {
195
+ if (existsSync(CACHE_PATH) && !isCacheStale()) {
196
+ try {
197
+ const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
198
+ if (data?.configs?.[modelKey]) {
199
+ return data.configs[modelKey] as Record<string, unknown>;
200
+ }
201
+ } catch (err) {
202
+ _logger.warn("Failed to read Qoder model config cache", {
203
+ modelKey,
204
+ error: err instanceof Error ? err.message : String(err),
205
+ });
206
+ }
207
+ }
208
+ return null;
209
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Qoder Provider Extension
3
+ *
4
+ * Registers the Qoder provider with Pi, providing free access to top-tier
5
+ * LLM models (DeepSeek V4 Pro/Flash, Qwen3.7 Plus/Max, GLM 5.1, Kimi K2.6,
6
+ * MiniMax M3) through Qoder's OpenAI-compatible API.
7
+ *
8
+ * Qoder uses a custom authentication protocol (PAT exchange + COSY signing)
9
+ * and a credits-based pricing model:
10
+ * - Community Edition (free): basic models with daily message limits
11
+ * - Pro / Pro+ / Ultra (paid): premium models via monthly credits
12
+ *
13
+ * Usage:
14
+ * Install pi-free, then run /login qoder to authenticate
15
+ * (PAT paste or browser OAuth)
16
+ * /toggle-qoder switches between basic (free-tier) and all models
17
+ *
18
+ * Environment variables:
19
+ * QODER_PERSONAL_ACCESS_TOKEN — PAT for headless auth (optional)
20
+ * QODER_PAT — Alias for above
21
+ */
22
+
23
+ import type { Api, OAuthCredentials } from "@earendil-works/pi-ai";
24
+ import type {
25
+ ExtensionAPI,
26
+ ProviderModelConfig,
27
+ } from "@earendil-works/pi-coding-agent";
28
+ import { BASE_URL_QODER, PROVIDER_QODER } from "../../constants.ts";
29
+ import { getProviderShowPaid } from "../../config.ts";
30
+ import {
31
+ getCachedModels,
32
+ isBasicModel,
33
+ type QoderModelConfig,
34
+ } from "./models.ts";
35
+ import { getCachedCredentials, loginQoder, refreshQoderToken } from "./auth.ts";
36
+ import { streamQoder } from "./stream.ts";
37
+ import { enhanceWithCI } from "../../provider-helper.ts";
38
+ import { registerWithGlobalToggle } from "../../lib/registry.ts";
39
+ import { createToggleState } from "../../lib/toggle-state.ts";
40
+
41
+ // =============================================================================
42
+ // Extension Entry Point
43
+ // =============================================================================
44
+
45
+ export default async function qoderProvider(pi: ExtensionAPI) {
46
+ const logger = (await import("../../lib/logger.ts")).createLogger("qoder");
47
+
48
+ // Initial model fetch
49
+ const allModels: QoderModelConfig[] = getCachedModels();
50
+ const basicModels: QoderModelConfig[] = allModels.filter(isBasicModel);
51
+ const stored = { free: basicModels, all: allModels };
52
+
53
+ const toggleState = createToggleState({
54
+ providerId: PROVIDER_QODER,
55
+ initialShowPaid: getProviderShowPaid(PROVIDER_QODER),
56
+ initialModels: stored,
57
+ });
58
+
59
+ // ── OAuth config (defined before reRegister so it's always available) ──
60
+ const oauthConfig = {
61
+ name: "Qoder (Browser OAuth / PAT)",
62
+ login: async (callbacks: any): Promise<OAuthCredentials> => {
63
+ return loginQoder(callbacks);
64
+ },
65
+ refreshToken: refreshQoderToken,
66
+ getApiKey: (cred: OAuthCredentials) => cred.access,
67
+ };
68
+
69
+ // Re-register function — called by toggle, session refresh, login
70
+ const reRegister = (models: ProviderModelConfig[]) => {
71
+ const enhanced = enhanceWithCI(models, PROVIDER_QODER);
72
+ pi.registerProvider(PROVIDER_QODER, {
73
+ baseUrl: BASE_URL_QODER,
74
+ api: "qoder-api" as Api,
75
+ models: enhanced,
76
+ oauth: oauthConfig,
77
+ streamSimple: streamQoder,
78
+ });
79
+ };
80
+
81
+ // Register with global toggle system so it participates in /toggle-free
82
+ registerWithGlobalToggle(PROVIDER_QODER, stored, (m) => reRegister(m), false);
83
+
84
+ // Per-provider toggle: /toggle-qoder (basic free-tier ↔ all models)
85
+ pi.registerCommand("toggle-qoder", {
86
+ description: "Toggle between basic (free-tier) and all Qoder models",
87
+ handler: async (_args, ctx) => {
88
+ try {
89
+ const applied = toggleState.toggle(reRegister);
90
+ const basicCount = stored.free.length;
91
+ const premiumCount = stored.all.length - basicCount;
92
+ if (applied.mode === "all") {
93
+ ctx.ui.notify(
94
+ `qoder: showing all ${stored.all.length} models (${basicCount} basic, ${premiumCount} premium)`,
95
+ "info",
96
+ );
97
+ } else {
98
+ ctx.ui.notify(
99
+ `qoder: showing ${stored.free.length} basic (free-tier) models`,
100
+ "info",
101
+ );
102
+ }
103
+ } catch (err) {
104
+ logger.error("[qoder] toggle failed", {
105
+ error: err instanceof Error ? err.message : String(err),
106
+ });
107
+ ctx.ui.notify("qoder: toggle failed", "error");
108
+ }
109
+ },
110
+ });
111
+
112
+ // Initial registration respects the configured show-paid mode.
113
+ toggleState.applyCurrent(reRegister);
114
+
115
+ logger.info(`[qoder] Provider registered with ${allModels.length} models`);
116
+ }
117
+
118
+ // Re-export key symbols for testing
119
+ export { loginQoder, refreshQoderToken, getCachedCredentials, streamQoder };