pi-bansos 0.1.3

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.
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # pi-bansos
2
+
3
+ Free model provider for pi. Automatically detects and verifies free models using a local proxy. Models are updated on each pi startup — when a free promo ends, the model is removed.
@@ -0,0 +1,507 @@
1
+ /**
2
+ * bansos — pi extension
3
+ *
4
+ * Dynamically fetches free models from OpenCode Zen API,
5
+ * verifies each is actually free, then registers via pi.registerProvider().
6
+ *
7
+ * Security hardening applied per OWASP Top 10 (2021):
8
+ * - A03/A10: Input validation, path/method whitelisting, header sanitization
9
+ * - A01: Origin validation on local proxy
10
+ * - A04: Dangerous header stripping
11
+ * - A05: Configurable port, better error handling
12
+ * - A07: Per-IP rate limiting
13
+ * - A08: Model cache with TTL for integrity
14
+ * - A09: Structured logging with audit trail
15
+ */
16
+ import http from "node:http";
17
+ import https from "node:https";
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ // ── Configuration ──────────────────────────────────────────────────
21
+ const UPSTREAM = "https://opencode.ai/zen";
22
+ const PORT = Number(process.env.BANSOS_PORT) || 18080;
23
+ const HOST = "127.0.0.1";
24
+ const API = `${UPSTREAM}/v1`;
25
+
26
+ // Security: model cache TTL (default 1 hour)
27
+ const MODEL_CACHE_TTL_MS = Number(process.env.BANSOS_CACHE_TTL) || 3_600_000;
28
+
29
+ // ── Known context windows (update when new models appear) ──────────
30
+ const CONTEXT: Record<string, number> = {
31
+ "mimo-v2.5-free": 1_000_000,
32
+ "deepseek-v4-flash-free": 128_000,
33
+ "nemotron-3-ultra-free": 128_000,
34
+ "north-mini-code-free": 128_000,
35
+ "big-pickle": 128_000,
36
+ };
37
+
38
+ const DEFAULT_CONTEXT = 128_000;
39
+ const DEFAULT_MAX_TOKENS = 16_384;
40
+
41
+ // Known free models that don't have -free suffix
42
+ const KNOWN_FREE = new Set(["big-pickle"]);
43
+
44
+ // Models to exclude (promos ended, known broken)
45
+ const EXCLUDE = new Set(["minimax-m3-free", "qwen3.6-plus-free"]);
46
+
47
+ // ── Security: Whitelists & Constants ───────────────────────────────
48
+
49
+ /** Only allow paths starting with /v1/ (A03/A10: prevents path traversal + SSRF)
50
+ * Rejects ".." segments to prevent directory traversal */
51
+ const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
52
+ const PATH_TRAVERSAL_PATTERN = /\.\./;
53
+
54
+ /** Only allow safe HTTP methods (A03: prevents CONNECT tunneling) */
55
+ const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
56
+
57
+ /** Headers that must never be forwarded upstream (A04: header injection) */
58
+ const STRIP_HEADERS = new Set([
59
+ "authorization",
60
+ "host",
61
+ "x-forwarded-for",
62
+ "x-forwarded-host",
63
+ "x-forwarded-proto",
64
+ "x-real-ip",
65
+ "x-client-ip",
66
+ "x-originate-ip",
67
+ "cookie",
68
+ "set-cookie",
69
+ "proxy-connection",
70
+ "proxy-authorization",
71
+ ]);
72
+
73
+ // ── Security: Structured Logger (A09) ──────────────────────────────
74
+
75
+ type LogLevel = "info" | "warn" | "error" | "audit";
76
+
77
+ function log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
78
+ const timestamp = new Date().toISOString();
79
+ const prefix = `[bansos] [${timestamp}] [${level.toUpperCase()}]`;
80
+ const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
81
+ if (level === "error") {
82
+ console.error(`${prefix} ${message}${metaStr}`);
83
+ } else {
84
+ console.log(`${prefix} ${message}${metaStr}`);
85
+ }
86
+ }
87
+
88
+ // ── Security: Rate Limiter (A07) ───────────────────────────────────
89
+
90
+ interface RateLimitEntry {
91
+ count: number;
92
+ resetAt: number;
93
+ }
94
+
95
+ const rateLimitMap = new Map<string, RateLimitEntry>();
96
+ const RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
97
+ const RATE_LIMIT_MAX_REQUESTS = 120; // per window
98
+
99
+ // Cleanup stale entries every 5 minutes
100
+ let cleanupInterval: ReturnType<typeof setInterval> | null = null;
101
+
102
+ function startCleanupInterval() {
103
+ if (cleanupInterval) return;
104
+ cleanupInterval = setInterval(() => {
105
+ const now = Date.now();
106
+ for (const [key, entry] of rateLimitMap) {
107
+ if (entry.resetAt <= now) {
108
+ rateLimitMap.delete(key);
109
+ }
110
+ }
111
+ }, 300_000);
112
+ }
113
+
114
+ function checkRateLimit(ip: string): boolean {
115
+ const now = Date.now();
116
+ const entry = rateLimitMap.get(ip);
117
+
118
+ if (!entry || entry.resetAt <= now) {
119
+ rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
120
+ return true;
121
+ }
122
+
123
+ if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
124
+ return false;
125
+ }
126
+
127
+ entry.count++;
128
+ return true;
129
+ }
130
+
131
+ // ── Security: Model Cache with TTL (A08) ───────────────────────────
132
+
133
+ interface ModelCache {
134
+ models: ZenModel[];
135
+ fetchedAt: number;
136
+ }
137
+
138
+ let modelCache: ModelCache | null = null;
139
+
140
+ interface ZenModel {
141
+ id: string;
142
+ name: string;
143
+ reasoning: boolean;
144
+ contextWindow: number;
145
+ maxTokens: number;
146
+ }
147
+
148
+ // ── Fetch & verify free models ─────────────────────────────────────
149
+ async function fetchModels(): Promise<ZenModel[]> {
150
+ // Check cache first (A08: integrity - serve cached data within TTL)
151
+ if (modelCache && Date.now() - modelCache.fetchedAt < MODEL_CACHE_TTL_MS) {
152
+ return modelCache.models;
153
+ }
154
+
155
+ let res: Response;
156
+ try {
157
+ res = await fetch(`${API}/models`);
158
+ } catch (err) {
159
+ log("error", "failed to fetch models (network error)", {
160
+ error: err instanceof Error ? err.message : String(err),
161
+ });
162
+ // Return cached data if available, even if stale
163
+ if (modelCache) {
164
+ return modelCache.models;
165
+ }
166
+ throw err;
167
+ }
168
+
169
+ if (!res.ok) {
170
+ log("error", `models fetch failed`, { status: res.status });
171
+ if (modelCache) {
172
+ return modelCache.models;
173
+ }
174
+ throw new Error(`models fetch failed: ${res.status}`);
175
+ }
176
+
177
+ const data = (await res.json()) as { data: Array<{ id: string }> };
178
+
179
+ // Validate response structure (A08: data integrity)
180
+ if (!Array.isArray(data?.data)) {
181
+ log("error", "invalid response structure from upstream");
182
+ if (modelCache) return modelCache.models;
183
+ throw new Error("invalid models response");
184
+ }
185
+
186
+ const candidates = data.data
187
+ .map((m) => m.id)
188
+ .filter(
189
+ (id) =>
190
+ typeof id === "string" &&
191
+ id.length > 0 &&
192
+ id.length < 128 && // sanity check
193
+ !EXCLUDE.has(id) &&
194
+ (id.endsWith("-free") || KNOWN_FREE.has(id)),
195
+ );
196
+
197
+ const verified: ZenModel[] = [];
198
+ for (const id of candidates) {
199
+ try {
200
+ const ok = await testModel(id);
201
+ if (ok) {
202
+ verified.push({
203
+ id,
204
+ name: formatName(id),
205
+ reasoning: id.includes("nemotron") || id.includes("mimo"),
206
+ contextWindow: CONTEXT[id] ?? DEFAULT_CONTEXT,
207
+ maxTokens: DEFAULT_MAX_TOKENS,
208
+ });
209
+ }
210
+ } catch {
211
+ // Model test error — skip silently
212
+ }
213
+ }
214
+
215
+ // Update cache (A08: integrity)
216
+ modelCache = { models: verified, fetchedAt: Date.now() };
217
+
218
+ return verified;
219
+ }
220
+
221
+ // ── Test if model is free ──────────────────────────────────────────
222
+ async function testModel(id: string): Promise<boolean> {
223
+ // Validate model ID format (A03: injection prevention)
224
+ if (!/^[a-zA-Z0-9._-]+$/.test(id) || id.length > 128) {
225
+ log("warn", `invalid model ID format: ${id}`);
226
+ return false;
227
+ }
228
+
229
+ const res = await fetch(`${API}/chat/completions`, {
230
+ method: "POST",
231
+ headers: { "content-type": "application/json" },
232
+ body: JSON.stringify({
233
+ model: id,
234
+ messages: [{ role: "user", content: "1" }],
235
+ max_tokens: 1,
236
+ stream: false,
237
+ }),
238
+ });
239
+ if (!res.ok) return false;
240
+ const data = (await res.json()) as { cost?: string };
241
+ return !data.cost || data.cost === "0" || data.cost === "0.0";
242
+ }
243
+
244
+ // ── Pretty name ────────────────────────────────────────────────────
245
+ function formatName(id: string): string {
246
+ return id
247
+ .replace(/-free$/, "")
248
+ .replace(/-/g, " ")
249
+ .replace(/\b\w/g, (c) => c.toUpperCase());
250
+ }
251
+
252
+ // ── Security: Extract client IP from socket ────────────────────────
253
+ function getClientIP(req: http.IncomingMessage): string {
254
+ const addr = req.socket.remoteAddress;
255
+ if (!addr) return "unknown";
256
+ // Normalize IPv6 mapped IPv4
257
+ if (addr.startsWith("::ffff:")) return addr.slice(7);
258
+ return addr;
259
+ }
260
+
261
+ // ── Security: Validate and sanitize path (A03/A10) ─────────────────
262
+ function validatePath(rawUrl: string): URL | null {
263
+ // Remove leading slashes and normalize
264
+ const cleaned = rawUrl.replace(/^\/+/, "");
265
+
266
+ // Check against whitelist pattern
267
+ if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) {
268
+ return null;
269
+ }
270
+
271
+ // Reject path traversal (..) — A03: prevents directory traversal
272
+ if (PATH_TRAVERSAL_PATTERN.test(cleaned)) {
273
+ return null;
274
+ }
275
+
276
+ // Prevent double-encoding tricks
277
+ try {
278
+ const decoded = decodeURIComponent(cleaned);
279
+ if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
280
+ return null;
281
+ }
282
+ } catch {
283
+ return null;
284
+ }
285
+
286
+ try {
287
+ return new URL(cleaned, `${UPSTREAM}/`);
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ // ── Security: Sanitize headers for upstream (A04) ──────────────────
294
+ function sanitizeHeaders(
295
+ incoming: http.IncomingHttpHeaders,
296
+ targetHost: string,
297
+ ): Record<string, string> {
298
+ const sanitized: Record<string, string> = {};
299
+
300
+ for (const [key, value] of Object.entries(incoming)) {
301
+ const lower = key.toLowerCase();
302
+
303
+ // Skip stripped headers
304
+ if (STRIP_HEADERS.has(lower)) continue;
305
+
306
+ // Skip pseudo-headers
307
+ if (lower.startsWith(":")) continue;
308
+
309
+ // Only forward safe headers
310
+ if (typeof value === "string") {
311
+ sanitized[lower] = value;
312
+ } else if (Array.isArray(value)) {
313
+ sanitized[lower] = value.join(", ");
314
+ }
315
+ }
316
+
317
+ // Set required headers explicitly
318
+ sanitized.host = targetHost;
319
+ sanitized["accept-encoding"] = "identity";
320
+ sanitized.connection = "close";
321
+
322
+ return sanitized;
323
+ }
324
+
325
+ // ── Start local proxy ──────────────────────────────────────────────
326
+ function startProxy(overridePort?: number): http.Server {
327
+ const effectivePort = overridePort ?? PORT;
328
+ const server = http.createServer((req, res) => {
329
+ const clientIP = getClientIP(req);
330
+ const startTime = Date.now();
331
+
332
+ // A07: Rate limiting
333
+ if (!checkRateLimit(clientIP)) {
334
+ log("warn", "rate limit exceeded", { ip: clientIP });
335
+ res.writeHead(429, { "content-type": "application/json" });
336
+ res.end(JSON.stringify({ error: "rate limit exceeded" }));
337
+ return;
338
+ }
339
+
340
+ // A03: Method whitelist
341
+ if (!ALLOWED_METHODS.has(req.method ?? "")) {
342
+ log("audit", "method not allowed", {
343
+ ip: clientIP,
344
+ method: req.method,
345
+ path: req.url,
346
+ });
347
+ res.writeHead(405, { "content-type": "application/json" });
348
+ res.end(JSON.stringify({ error: "method not allowed" }));
349
+ return;
350
+ }
351
+
352
+ // Handle CORS preflight (safe: no credentials)
353
+ if (req.method === "OPTIONS") {
354
+ res.writeHead(204, {
355
+ "access-control-allow-origin": "http://localhost",
356
+ "access-control-allow-methods": "GET, POST, OPTIONS",
357
+ "access-control-max-age": "86400",
358
+ });
359
+ res.end();
360
+ return;
361
+ }
362
+
363
+ // A03/A10: Validate path
364
+ const target = validatePath(req.url ?? "/");
365
+ if (!target) {
366
+ log("audit", "invalid path rejected", {
367
+ ip: clientIP,
368
+ method: req.method,
369
+ path: req.url,
370
+ });
371
+ res.writeHead(403, { "content-type": "application/json" });
372
+ res.end(JSON.stringify({ error: "forbidden" }));
373
+ return;
374
+ }
375
+
376
+ // A04: Sanitize headers
377
+ const fwd = sanitizeHeaders(req.headers, target.hostname);
378
+
379
+ const proxy = https.request(
380
+ {
381
+ method: req.method,
382
+ hostname: target.hostname,
383
+ port: 443,
384
+ path: target.pathname + target.search,
385
+ headers: fwd,
386
+ },
387
+ (upstream) => {
388
+ const outHeaders: Record<string, string> = {};
389
+
390
+ // Forward safe response headers only
391
+ const safeResponseHeaders = [
392
+ "content-type",
393
+ "cache-control",
394
+ "x-request-id",
395
+ ];
396
+ for (const h of safeResponseHeaders) {
397
+ const val = upstream.headers[h];
398
+ if (typeof val === "string") {
399
+ outHeaders[h] = val;
400
+ }
401
+ }
402
+
403
+ // Add security headers
404
+ outHeaders["x-content-type-options"] = "nosniff";
405
+ outHeaders["x-frame-options"] = "DENY";
406
+
407
+ res.writeHead(upstream.statusCode ?? 502, outHeaders);
408
+ upstream.pipe(res);
409
+ },
410
+ );
411
+
412
+ proxy.on("error", (err) => {
413
+ const duration = Date.now() - startTime;
414
+ log("error", "upstream proxy error", {
415
+ ip: clientIP,
416
+ path: target.pathname,
417
+ error: err.message,
418
+ duration: `${duration}ms`,
419
+ });
420
+ if (!res.headersSent) {
421
+ res.writeHead(502, { "content-type": "application/json" });
422
+ }
423
+ res.end(JSON.stringify({ error: "upstream error" }));
424
+ });
425
+
426
+ // A07: Per-request timeout (30s, shorter than global 120s)
427
+ proxy.setTimeout(30_000, () => {
428
+ proxy.destroy(new Error("request timeout"));
429
+ });
430
+
431
+ // Handle client abort — only fires when client disconnects prematurely,
432
+ // NOT when the readable stream ends normally (fixes 502 on every request)
433
+ req.on("aborted", () => {
434
+ if (!proxy.destroyed) {
435
+ proxy.destroy();
436
+ }
437
+ });
438
+
439
+ req.pipe(proxy);
440
+ });
441
+
442
+ server.on("error", (err: NodeJS.ErrnoException) => {
443
+ if (err.code === "EADDRINUSE") {
444
+ log(
445
+ "warn",
446
+ `port ${effectivePort} in use — proxy may already be running`,
447
+ );
448
+ return;
449
+ }
450
+ log("error", "server error", { code: err.code, message: err.message });
451
+ });
452
+
453
+ // A09: Log server start
454
+ server.listen(effectivePort, HOST);
455
+ log("info", `proxy listening on http://${HOST}:${effectivePort}`);
456
+
457
+ startCleanupInterval();
458
+
459
+ return server;
460
+ }
461
+
462
+ // ── Main extension ─────────────────────────────────────────────────
463
+ export default async function (pi: ExtensionAPI) {
464
+ log("info", "extension loading...");
465
+
466
+ const server = startProxy();
467
+
468
+ log("info", "fetching free models...");
469
+ const models = await fetchModels();
470
+
471
+ if (models.length === 0) {
472
+ log("warn", "no free models found — extension inactive");
473
+ return;
474
+ }
475
+
476
+ log(
477
+ "info",
478
+ `${models.length} free model(s) registered: ${models.map((m) => `${m.id} (${m.contextWindow / 1000}K)`).join(", ")}`,
479
+ );
480
+
481
+ pi.registerProvider("bansos", {
482
+ baseUrl: `http://${HOST}:${PORT}/v1`,
483
+ apiKey: "placeholder",
484
+ api: "openai-completions",
485
+ compat: { supportsDeveloperRole: true, supportsReasoningEffort: true },
486
+ models: models.map((m) => ({
487
+ id: m.id,
488
+ name: m.name,
489
+ reasoning: m.reasoning,
490
+ input: ["text"] as ("text" | "image")[],
491
+ contextWindow: m.contextWindow,
492
+ maxTokens: m.maxTokens,
493
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
494
+ })),
495
+ });
496
+
497
+ pi.on("session_shutdown", () => {
498
+ log("info", "shutting down proxy...");
499
+ server.close();
500
+ rateLimitMap.clear();
501
+ if (cleanupInterval) {
502
+ clearInterval(cleanupInterval);
503
+ cleanupInterval = null;
504
+ }
505
+ log("info", "shutdown complete");
506
+ });
507
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "pi-bansos",
3
+ "version": "0.1.3",
4
+ "description": "Free model provider for pi via OpenCode Zen",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "free-models"
9
+ ],
10
+ "author": "mannrachman",
11
+ "license": "MIT",
12
+ "pi": {
13
+ "extensions": [
14
+ "./extensions"
15
+ ]
16
+ },
17
+ "files": [
18
+ "extensions",
19
+ "README.md"
20
+ ]
21
+ }