pi-free 2.2.2 → 2.2.4

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,48 @@
1
+ /**
2
+ * Qoder WAF bypass body encoding.
3
+ *
4
+ * Qoder's API uses a custom base64 variant with a scrambled alphabet and
5
+ * rearranged segments to evade WAF detection. This is the same encoding
6
+ * used by the official qodercli binary.
7
+ *
8
+ * The algorithm:
9
+ * 1. Take the standard base64 of the input
10
+ * 2. Rearrange segments: last N/3 chars + middle N-2N/3 chars + first N/3 chars
11
+ * 3. Substitute each character through a custom alphabet
12
+ * 4. Replace '=' padding with '$'
13
+ */
14
+
15
+ const QODER_CUSTOM_ALPHABET =
16
+ "_doRTgHZBKcGVjlvpC,@aFSx#DPuNJme&i*MzLOEn)sUrthbf%Y^w.(kIQyXqWA!";
17
+ const QODER_STD_ALPHABET =
18
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
19
+
20
+ /**
21
+ * Encode a body string or buffer using Qoder's custom WAF-bypass encoding.
22
+ *
23
+ * @param plaintext - String or Buffer to encode
24
+ * @returns Encoded string ready for transmission
25
+ */
26
+ export function qoderEncodeBody(plaintext: string | Buffer): string {
27
+ const std = Buffer.isBuffer(plaintext)
28
+ ? plaintext.toString("base64")
29
+ : Buffer.from(plaintext).toString("base64");
30
+ const n = std.length;
31
+ const a = Math.floor(n / 3);
32
+ const rearranged = std.slice(n - a) + std.slice(a, n - a) + std.slice(0, a);
33
+ let out = "";
34
+ for (let i = 0; i < n; i++) {
35
+ const c = rearranged[i];
36
+ if (c === "=") {
37
+ out += "$";
38
+ } else {
39
+ const idx = QODER_STD_ALPHABET.indexOf(c);
40
+ if (idx >= 0) {
41
+ out += QODER_CUSTOM_ALPHABET[idx];
42
+ } else {
43
+ out += c;
44
+ }
45
+ }
46
+ }
47
+ return out;
48
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Qoder model definitions and cache management.
3
+ *
4
+ * Qoder provides a static set of models (all at zero cost) with the option
5
+ * to dynamically discover more from the `/algo/api/v2/model/list` endpoint.
6
+ * The dynamic list is cached at `~/.pi/agent/qoder-models-cache.json` with
7
+ * a 1-hour TTL and falls back to the static models on cache miss or API error.
8
+ *
9
+ * ALL Qoder models are free — no pricing data needed.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
16
+ import { buildAuthHeaders } from "./cosy.ts";
17
+
18
+ // ─── Cache ───────────────────────────────────────────────────────────────────
19
+
20
+ const CACHE_PATH = join(homedir(), ".pi", "agent", "qoder-models-cache.json");
21
+
22
+ const ZERO_COST = Object.freeze({
23
+ input: 0,
24
+ output: 0,
25
+ cacheRead: 0,
26
+ cacheWrite: 0,
27
+ });
28
+
29
+ // ─── Static model list ───────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Static model definitions for Qoder.
33
+ * All models are free (zero cost) — no paid tier exists.
34
+ * These serve as the fallback when the dynamic API is unreachable.
35
+ */
36
+ export const staticModels: ProviderModelConfig[] = [
37
+ {
38
+ id: "auto",
39
+ name: "Qoder Auto",
40
+ reasoning: true,
41
+ input: ["text", "image"] as ("text" | "image")[],
42
+ cost: ZERO_COST,
43
+ contextWindow: 180_000,
44
+ maxTokens: 32_768,
45
+ },
46
+ {
47
+ id: "ultimate",
48
+ name: "Qoder Ultimate",
49
+ reasoning: true,
50
+ input: ["text", "image"] as ("text" | "image")[],
51
+ cost: ZERO_COST,
52
+ contextWindow: 1_000_000,
53
+ maxTokens: 32_768,
54
+ },
55
+ {
56
+ id: "performance",
57
+ name: "Qoder Performance",
58
+ reasoning: true,
59
+ input: ["text", "image"] as ("text" | "image")[],
60
+ cost: ZERO_COST,
61
+ contextWindow: 1_000_000,
62
+ maxTokens: 32_768,
63
+ },
64
+ {
65
+ id: "efficient",
66
+ name: "Qoder Efficient",
67
+ reasoning: false,
68
+ input: ["text", "image"] as ("text" | "image")[],
69
+ cost: ZERO_COST,
70
+ contextWindow: 180_000,
71
+ maxTokens: 32_768,
72
+ },
73
+ {
74
+ id: "lite",
75
+ name: "Qoder Lite",
76
+ reasoning: false,
77
+ input: ["text"] as ("text" | "image")[],
78
+ cost: ZERO_COST,
79
+ contextWindow: 180_000,
80
+ maxTokens: 32_768,
81
+ },
82
+ {
83
+ id: "qmodel",
84
+ name: "Qwen3.7 Plus (Qoder)",
85
+ reasoning: false,
86
+ input: ["text", "image"] as ("text" | "image")[],
87
+ cost: ZERO_COST,
88
+ contextWindow: 1_000_000,
89
+ maxTokens: 32_768,
90
+ },
91
+ {
92
+ id: "qmodel_latest",
93
+ name: "Qwen3.7 Max (Qoder)",
94
+ reasoning: false,
95
+ input: ["text", "image"] as ("text" | "image")[],
96
+ cost: ZERO_COST,
97
+ contextWindow: 1_000_000,
98
+ maxTokens: 32_768,
99
+ },
100
+ {
101
+ id: "dmodel",
102
+ name: "DeepSeek V4 Pro (Qoder)",
103
+ reasoning: true,
104
+ input: ["text", "image"] as ("text" | "image")[],
105
+ cost: ZERO_COST,
106
+ contextWindow: 1_000_000,
107
+ maxTokens: 32_768,
108
+ },
109
+ {
110
+ id: "dfmodel",
111
+ name: "DeepSeek V4 Flash (Qoder)",
112
+ reasoning: true,
113
+ input: ["text", "image"] as ("text" | "image")[],
114
+ cost: ZERO_COST,
115
+ contextWindow: 1_000_000,
116
+ maxTokens: 32_768,
117
+ },
118
+ {
119
+ id: "gm51model",
120
+ name: "GLM 5.1 (Qoder)",
121
+ reasoning: true,
122
+ input: ["text", "image"] as ("text" | "image")[],
123
+ cost: ZERO_COST,
124
+ contextWindow: 180_000,
125
+ maxTokens: 32_768,
126
+ },
127
+ {
128
+ id: "kmodel",
129
+ name: "Kimi K2.6 (Qoder)",
130
+ reasoning: false,
131
+ input: ["text", "image"] as ("text" | "image")[],
132
+ cost: ZERO_COST,
133
+ contextWindow: 256_000,
134
+ maxTokens: 32_768,
135
+ },
136
+ {
137
+ id: "mmodel",
138
+ name: "MiniMax M3 (Qoder)",
139
+ reasoning: false,
140
+ input: ["text", "image"] as ("text" | "image")[],
141
+ cost: ZERO_COST,
142
+ contextWindow: 1_000_000,
143
+ maxTokens: 32_768,
144
+ },
145
+ ];
146
+
147
+ // ─── Dynamic model API ───────────────────────────────────────────────────────
148
+
149
+ interface QoderModelEntry {
150
+ key?: string;
151
+ enable?: boolean;
152
+ display_name?: string;
153
+ max_input_tokens?: number;
154
+ max_output_tokens?: number;
155
+ context_config?: Record<string, { token_count?: number }>;
156
+ is_vl?: boolean;
157
+ is_reasoning?: boolean;
158
+ thinking_config?: { enabled?: { efforts?: unknown } };
159
+ source?: string;
160
+ [key: string]: unknown;
161
+ }
162
+
163
+ // ─── Cache management ────────────────────────────────────────────────────────
164
+
165
+ function modelEntryToConfig(
166
+ entry: QoderModelEntry,
167
+ ): ProviderModelConfig | null {
168
+ const key = entry.key;
169
+ if (!key || !entry.enable) return null;
170
+
171
+ const display = entry.display_name || key;
172
+ const ctxLen = resolveContextLength(entry);
173
+ const isVL = Boolean(entry.is_vl);
174
+ const isReasoning = Boolean(entry.is_reasoning) || Boolean(entry.thinking_config);
175
+ const input: ("text" | "image")[] = isVL ? ["text", "image"] : ["text"];
176
+
177
+ return {
178
+ id: key,
179
+ name: display,
180
+ reasoning: isReasoning,
181
+ input,
182
+ cost: ZERO_COST,
183
+ contextWindow: ctxLen,
184
+ maxTokens: entry.max_output_tokens || 32_768,
185
+ };
186
+ }
187
+
188
+ function resolveContextLength(entry: QoderModelEntry): number {
189
+ let ctxLen = entry.max_input_tokens || 180_000;
190
+ if (entry.context_config && typeof entry.context_config === "object") {
191
+ for (const val of Object.values(entry.context_config)) {
192
+ if (
193
+ val &&
194
+ typeof val === "object" &&
195
+ typeof (val as Record<string, unknown>).token_count === "number"
196
+ ) {
197
+ const tc = (val as Record<string, number>).token_count;
198
+ if (tc > ctxLen) ctxLen = tc;
199
+ }
200
+ }
201
+ }
202
+ return ctxLen;
203
+ }
204
+
205
+ /** Get models from cache, falling back to static models. */
206
+ export function getCachedModels(): ProviderModelConfig[] {
207
+ if (existsSync(CACHE_PATH)) {
208
+ try {
209
+ const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
210
+ if (data && Array.isArray(data.models)) {
211
+ return data.models as ProviderModelConfig[];
212
+ }
213
+ } catch {
214
+ // Fall through to static
215
+ }
216
+ }
217
+ return staticModels;
218
+ }
219
+
220
+ /** Check if the local model cache is stale (>1 hour old). */
221
+ export function isCacheStale(): boolean {
222
+ if (!existsSync(CACHE_PATH)) return true;
223
+ try {
224
+ const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
225
+ if (!data || typeof data.updatedAt !== "number") return true;
226
+ return Date.now() - data.updatedAt > 3_600_000; // 1 hour
227
+ } catch {
228
+ return true;
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Fetch available models from Qoder's dynamic model list API and cache them.
234
+ * Falls back silently if the API is unreachable.
235
+ */
236
+ export async function updateQoderModelsCache(
237
+ authToken: string,
238
+ userID: string,
239
+ name: string,
240
+ email: string,
241
+ ): Promise<void> {
242
+ const modelListURL = "https://api3.qoder.sh/algo/api/v2/model/list";
243
+ try {
244
+ const headers = buildAuthHeaders(null, modelListURL, {
245
+ userID,
246
+ authToken,
247
+ name,
248
+ email,
249
+ });
250
+
251
+ const response = await fetch(modelListURL, {
252
+ method: "GET",
253
+ headers: {
254
+ Accept: "application/json",
255
+ ...headers,
256
+ },
257
+ });
258
+
259
+ if (!response.ok) return;
260
+
261
+ const resData = (await response.json()) as {
262
+ chat?: QoderModelEntry[];
263
+ };
264
+ const chatModels = resData.chat || [];
265
+ if (chatModels.length === 0) return;
266
+
267
+ const newModels: ProviderModelConfig[] = [];
268
+ const configs: Record<string, QoderModelEntry> = {};
269
+
270
+ for (const entry of chatModels) {
271
+ const model = modelEntryToConfig(entry);
272
+ if (!model) continue;
273
+ configs[model.id] = entry;
274
+ newModels.push(model);
275
+ }
276
+
277
+ if (newModels.length === 0) return;
278
+
279
+ // Ensure the auto router model is present
280
+ if (!newModels.some((m) => m.id === "auto")) {
281
+ newModels.unshift({
282
+ id: "auto",
283
+ name: "Qoder Auto",
284
+ reasoning: true,
285
+ input: ["text", "image"] as ("text" | "image")[],
286
+ cost: ZERO_COST,
287
+ contextWindow: 180_000,
288
+ maxTokens: 32_768,
289
+ });
290
+ }
291
+
292
+ const cacheData = {
293
+ updatedAt: Date.now(),
294
+ models: newModels,
295
+ configs,
296
+ };
297
+
298
+ mkdirSync(dirname(CACHE_PATH), { recursive: true });
299
+ writeFileSync(CACHE_PATH, JSON.stringify(cacheData, null, 2), "utf-8");
300
+ } catch {
301
+ // Best-effort
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Get the cached model config for a specific model key.
307
+ * Used to determine per-model settings (reasoning, max tokens, etc.) at stream time.
308
+ */
309
+ export function getCachedModelConfig(modelKey: string): QoderModelEntry | null {
310
+ if (existsSync(CACHE_PATH)) {
311
+ try {
312
+ const data = JSON.parse(readFileSync(CACHE_PATH, "utf8"));
313
+ if (data?.configs?.[modelKey]) {
314
+ return data.configs[modelKey] as QoderModelEntry;
315
+ }
316
+ } catch {
317
+ // Fall through
318
+ }
319
+ }
320
+ return null;
321
+ }