pi-bansos 0.1.3 → 0.1.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.
Files changed (2) hide show
  1. package/extensions/index.ts +107 -217
  2. package/package.json +1 -1
@@ -23,38 +23,59 @@ const PORT = Number(process.env.BANSOS_PORT) || 18080;
23
23
  const HOST = "127.0.0.1";
24
24
  const API = `${UPSTREAM}/v1`;
25
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 ───────────────────────────────
26
+ // ── Hardcoded Free Models ──────────────────────────────────────────
27
+ // Update this list manually when models change.
28
+ // On startup, each model is tested for availability.
29
+ interface ModelDef {
30
+ id: string;
31
+ name: string;
32
+ reasoning: boolean;
33
+ contextWindow: number;
34
+ maxTokens: number;
35
+ }
48
36
 
49
- /** Only allow paths starting with /v1/ (A03/A10: prevents path traversal + SSRF)
50
- * Rejects ".." segments to prevent directory traversal */
37
+ const KNOWN_MODELS: ModelDef[] = [
38
+ {
39
+ id: "deepseek-v4-flash-free",
40
+ name: "DeepSeek V4 Flash",
41
+ reasoning: true,
42
+ contextWindow: 128_000,
43
+ maxTokens: 16_384,
44
+ },
45
+ {
46
+ id: "mimo-v2.5-free",
47
+ name: "Mimo V2.5",
48
+ reasoning: false,
49
+ contextWindow: 128_000,
50
+ maxTokens: 16_384,
51
+ },
52
+ {
53
+ id: "nemotron-3-ultra-free",
54
+ name: "Nemotron 3 Ultra",
55
+ reasoning: true,
56
+ contextWindow: 128_000,
57
+ maxTokens: 16_384,
58
+ },
59
+ {
60
+ id: "north-mini-code-free",
61
+ name: "North Mini Code",
62
+ reasoning: true,
63
+ contextWindow: 128_000,
64
+ maxTokens: 16_384,
65
+ },
66
+ {
67
+ id: "big-pickle",
68
+ name: "Big Pickle",
69
+ reasoning: true,
70
+ contextWindow: 128_000,
71
+ maxTokens: 16_384,
72
+ },
73
+ ];
74
+
75
+ // ── Whitelists ─────────────────────────────────────────────────────
51
76
  const ALLOWED_PATH_PATTERN = /^\/v1\/[a-zA-Z0-9/_.,\-?&=]*$/;
52
77
  const PATH_TRAVERSAL_PATTERN = /\.\./;
53
-
54
- /** Only allow safe HTTP methods (A03: prevents CONNECT tunneling) */
55
78
  const ALLOWED_METHODS = new Set(["GET", "POST", "OPTIONS", "HEAD"]);
56
-
57
- /** Headers that must never be forwarded upstream (A04: header injection) */
58
79
  const STRIP_HEADERS = new Set([
59
80
  "authorization",
60
81
  "host",
@@ -70,33 +91,28 @@ const STRIP_HEADERS = new Set([
70
91
  "proxy-authorization",
71
92
  ]);
72
93
 
73
- // ── Security: Structured Logger (A09) ──────────────────────────────
74
-
94
+ // ── Logger ─────────────────────────────────────────────────────────
75
95
  type LogLevel = "info" | "warn" | "error" | "audit";
76
96
 
77
97
  function log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
78
- const timestamp = new Date().toISOString();
79
- const prefix = `[bansos] [${timestamp}] [${level.toUpperCase()}]`;
98
+ const ts = new Date().toISOString();
80
99
  const metaStr = meta ? ` ${JSON.stringify(meta)}` : "";
100
+ const line = `[bansos] [${ts}] [${level.toUpperCase()}] ${message}${metaStr}`;
81
101
  if (level === "error") {
82
- console.error(`${prefix} ${message}${metaStr}`);
102
+ console.error(line);
83
103
  } else {
84
- console.log(`${prefix} ${message}${metaStr}`);
104
+ console.log(line);
85
105
  }
86
106
  }
87
107
 
88
- // ── Security: Rate Limiter (A07) ───────────────────────────────────
89
-
108
+ // ── Rate Limiter ───────────────────────────────────────────────────
90
109
  interface RateLimitEntry {
91
110
  count: number;
92
111
  resetAt: number;
93
112
  }
94
-
95
113
  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
114
+ const RATE_LIMIT_WINDOW_MS = 60_000;
115
+ const RATE_LIMIT_MAX = 120;
100
116
  let cleanupInterval: ReturnType<typeof setInterval> | null = null;
101
117
 
102
118
  function startCleanupInterval() {
@@ -104,9 +120,7 @@ function startCleanupInterval() {
104
120
  cleanupInterval = setInterval(() => {
105
121
  const now = Date.now();
106
122
  for (const [key, entry] of rateLimitMap) {
107
- if (entry.resetAt <= now) {
108
- rateLimitMap.delete(key);
109
- }
123
+ if (entry.resetAt <= now) rateLimitMap.delete(key);
110
124
  }
111
125
  }, 300_000);
112
126
  }
@@ -114,166 +128,46 @@ function startCleanupInterval() {
114
128
  function checkRateLimit(ip: string): boolean {
115
129
  const now = Date.now();
116
130
  const entry = rateLimitMap.get(ip);
117
-
118
131
  if (!entry || entry.resetAt <= now) {
119
132
  rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS });
120
133
  return true;
121
134
  }
122
-
123
- if (entry.count >= RATE_LIMIT_MAX_REQUESTS) {
124
- return false;
125
- }
126
-
135
+ if (entry.count >= RATE_LIMIT_MAX) return false;
127
136
  entry.count++;
128
137
  return true;
129
138
  }
130
139
 
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;
140
+ // ── Health Check ───────────────────────────────────────────────────
141
+ async function checkModelAlive(id: string): Promise<boolean> {
156
142
  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),
143
+ const res = await fetch(`${API}/chat/completions`, {
144
+ method: "POST",
145
+ headers: { "content-type": "application/json" },
146
+ body: JSON.stringify({
147
+ model: id,
148
+ messages: [{ role: "user", content: "hi" }],
149
+ max_tokens: 1,
150
+ stream: false,
151
+ }),
152
+ signal: AbortSignal.timeout(10_000),
161
153
  });
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}`);
154
+ // Model is alive if API responds (even with error)
155
+ return res.ok || res.status === 400 || res.status === 429;
156
+ } catch {
226
157
  return false;
227
158
  }
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
159
  }
243
160
 
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
161
  function getClientIP(req: http.IncomingMessage): string {
254
162
  const addr = req.socket.remoteAddress;
255
163
  if (!addr) return "unknown";
256
- // Normalize IPv6 mapped IPv4
257
- if (addr.startsWith("::ffff:")) return addr.slice(7);
258
- return addr;
164
+ return addr.startsWith("::ffff:") ? addr.slice(7) : addr;
259
165
  }
260
166
 
261
- // ── Security: Validate and sanitize path (A03/A10) ─────────────────
262
167
  function validatePath(rawUrl: string): URL | null {
263
- // Remove leading slashes and normalize
264
168
  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
169
+ if (!ALLOWED_PATH_PATTERN.test(`/${cleaned}`)) return null;
170
+ if (PATH_TRAVERSAL_PATTERN.test(cleaned)) return null;
277
171
  try {
278
172
  const decoded = decodeURIComponent(cleaned);
279
173
  if (decoded !== cleaned && !ALLOWED_PATH_PATTERN.test(`/${decoded}`)) {
@@ -282,7 +176,6 @@ function validatePath(rawUrl: string): URL | null {
282
176
  } catch {
283
177
  return null;
284
178
  }
285
-
286
179
  try {
287
180
  return new URL(cleaned, `${UPSTREAM}/`);
288
181
  } catch {
@@ -290,46 +183,36 @@ function validatePath(rawUrl: string): URL | null {
290
183
  }
291
184
  }
292
185
 
293
- // ── Security: Sanitize headers for upstream (A04) ──────────────────
294
186
  function sanitizeHeaders(
295
187
  incoming: http.IncomingHttpHeaders,
296
188
  targetHost: string,
297
189
  ): Record<string, string> {
298
190
  const sanitized: Record<string, string> = {};
299
-
300
191
  for (const [key, value] of Object.entries(incoming)) {
301
192
  const lower = key.toLowerCase();
302
-
303
- // Skip stripped headers
304
193
  if (STRIP_HEADERS.has(lower)) continue;
305
-
306
- // Skip pseudo-headers
307
194
  if (lower.startsWith(":")) continue;
308
-
309
- // Only forward safe headers
310
195
  if (typeof value === "string") {
311
196
  sanitized[lower] = value;
312
197
  } else if (Array.isArray(value)) {
313
198
  sanitized[lower] = value.join(", ");
314
199
  }
315
200
  }
316
-
317
- // Set required headers explicitly
318
201
  sanitized.host = targetHost;
319
202
  sanitized["accept-encoding"] = "identity";
320
203
  sanitized.connection = "close";
321
-
322
204
  return sanitized;
323
205
  }
324
206
 
325
207
  // ── Start local proxy ──────────────────────────────────────────────
326
208
  function startProxy(overridePort?: number): http.Server {
327
209
  const effectivePort = overridePort ?? PORT;
210
+
328
211
  const server = http.createServer((req, res) => {
329
212
  const clientIP = getClientIP(req);
330
213
  const startTime = Date.now();
331
214
 
332
- // A07: Rate limiting
215
+ // Rate limiting
333
216
  if (!checkRateLimit(clientIP)) {
334
217
  log("warn", "rate limit exceeded", { ip: clientIP });
335
218
  res.writeHead(429, { "content-type": "application/json" });
@@ -337,7 +220,7 @@ function startProxy(overridePort?: number): http.Server {
337
220
  return;
338
221
  }
339
222
 
340
- // A03: Method whitelist
223
+ // Method whitelist
341
224
  if (!ALLOWED_METHODS.has(req.method ?? "")) {
342
225
  log("audit", "method not allowed", {
343
226
  ip: clientIP,
@@ -349,7 +232,7 @@ function startProxy(overridePort?: number): http.Server {
349
232
  return;
350
233
  }
351
234
 
352
- // Handle CORS preflight (safe: no credentials)
235
+ // CORS preflight
353
236
  if (req.method === "OPTIONS") {
354
237
  res.writeHead(204, {
355
238
  "access-control-allow-origin": "http://localhost",
@@ -360,7 +243,7 @@ function startProxy(overridePort?: number): http.Server {
360
243
  return;
361
244
  }
362
245
 
363
- // A03/A10: Validate path
246
+ // Validate path
364
247
  const target = validatePath(req.url ?? "/");
365
248
  if (!target) {
366
249
  log("audit", "invalid path rejected", {
@@ -373,7 +256,7 @@ function startProxy(overridePort?: number): http.Server {
373
256
  return;
374
257
  }
375
258
 
376
- // A04: Sanitize headers
259
+ // Sanitize headers
377
260
  const fwd = sanitizeHeaders(req.headers, target.hostname);
378
261
 
379
262
  const proxy = https.request(
@@ -386,8 +269,6 @@ function startProxy(overridePort?: number): http.Server {
386
269
  },
387
270
  (upstream) => {
388
271
  const outHeaders: Record<string, string> = {};
389
-
390
- // Forward safe response headers only
391
272
  const safeResponseHeaders = [
392
273
  "content-type",
393
274
  "cache-control",
@@ -399,11 +280,8 @@ function startProxy(overridePort?: number): http.Server {
399
280
  outHeaders[h] = val;
400
281
  }
401
282
  }
402
-
403
- // Add security headers
404
283
  outHeaders["x-content-type-options"] = "nosniff";
405
284
  outHeaders["x-frame-options"] = "DENY";
406
-
407
285
  res.writeHead(upstream.statusCode ?? 502, outHeaders);
408
286
  upstream.pipe(res);
409
287
  },
@@ -423,13 +301,12 @@ function startProxy(overridePort?: number): http.Server {
423
301
  res.end(JSON.stringify({ error: "upstream error" }));
424
302
  });
425
303
 
426
- // A07: Per-request timeout (30s, shorter than global 120s)
304
+ // Per-request timeout
427
305
  proxy.setTimeout(30_000, () => {
428
306
  proxy.destroy(new Error("request timeout"));
429
307
  });
430
308
 
431
- // Handle client abort — only fires when client disconnects prematurely,
432
- // NOT when the readable stream ends normally (fixes 502 on every request)
309
+ // Handle client abort
433
310
  req.on("aborted", () => {
434
311
  if (!proxy.destroyed) {
435
312
  proxy.destroy();
@@ -450,12 +327,10 @@ function startProxy(overridePort?: number): http.Server {
450
327
  log("error", "server error", { code: err.code, message: err.message });
451
328
  });
452
329
 
453
- // A09: Log server start
454
330
  server.listen(effectivePort, HOST);
455
331
  log("info", `proxy listening on http://${HOST}:${effectivePort}`);
456
332
 
457
333
  startCleanupInterval();
458
-
459
334
  return server;
460
335
  }
461
336
 
@@ -465,25 +340,39 @@ export default async function (pi: ExtensionAPI) {
465
340
 
466
341
  const server = startProxy();
467
342
 
468
- log("info", "fetching free models...");
469
- const models = await fetchModels();
343
+ log("info", `checking ${KNOWN_MODELS.length} model(s) availability...`);
344
+
345
+ // Health check each model in parallel
346
+ const aliveChecks = await Promise.all(
347
+ KNOWN_MODELS.map(async (model) => {
348
+ const alive = await checkModelAlive(model.id);
349
+ if (alive) {
350
+ log("info", `✓ ${model.id} is alive`);
351
+ } else {
352
+ log("warn", `✗ ${model.id} is dead — skipping`);
353
+ }
354
+ return { ...model, alive };
355
+ }),
356
+ );
357
+
358
+ const aliveModels = aliveChecks.filter((m) => m.alive);
470
359
 
471
- if (models.length === 0) {
472
- log("warn", "no free models found — extension inactive");
360
+ if (aliveModels.length === 0) {
361
+ log("warn", "no alive models found — extension inactive");
473
362
  return;
474
363
  }
475
364
 
476
365
  log(
477
366
  "info",
478
- `${models.length} free model(s) registered: ${models.map((m) => `${m.id} (${m.contextWindow / 1000}K)`).join(", ")}`,
367
+ `${aliveModels.length} model(s) registered: ${aliveModels.map((m) => m.id).join(", ")}`,
479
368
  );
480
369
 
481
370
  pi.registerProvider("bansos", {
482
371
  baseUrl: `http://${HOST}:${PORT}/v1`,
483
372
  apiKey: "placeholder",
484
373
  api: "openai-completions",
485
- compat: { supportsDeveloperRole: true, supportsReasoningEffort: true },
486
- models: models.map((m) => ({
374
+ compat: { supportsDeveloperRole: false, supportsReasoningEffort: true },
375
+ models: aliveModels.map((m) => ({
487
376
  id: m.id,
488
377
  name: m.name,
489
378
  reasoning: m.reasoning,
@@ -491,6 +380,7 @@ export default async function (pi: ExtensionAPI) {
491
380
  contextWindow: m.contextWindow,
492
381
  maxTokens: m.maxTokens,
493
382
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
383
+ compat: { supportsDeveloperRole: false, supportsReasoningEffort: true },
494
384
  })),
495
385
  });
496
386
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-bansos",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Free model provider for pi via OpenCode Zen",
5
5
  "keywords": [
6
6
  "pi-package",