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 +3 -0
- package/extensions/index.ts +507 -0
- package/package.json +21 -0
package/README.md
ADDED
|
@@ -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
|
+
}
|