pi-freeflow 1.2.0 → 1.2.1
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/LICENSE +21 -0
- package/README.md +7 -11
- package/extensions/index.ts +4 -2137
- package/package.json +25 -5
- package/src/catalog.ts +255 -0
- package/src/commands.ts +448 -0
- package/src/config.ts +145 -0
- package/src/deploy.ts +126 -0
- package/src/index.ts +243 -0
- package/src/logger.ts +327 -0
- package/src/models.ts +343 -0
- package/src/normalizer.ts +173 -0
- package/src/proxy.ts +467 -0
- package/src/rate-limiter.ts +148 -0
- package/src/relay-state.ts +208 -0
- package/src/relay.ts +198 -0
- package/src/stream-pipe.ts +154 -0
- package/src/types.ts +164 -0
package/package.json
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-freeflow",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.2.
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "1.2.1",
|
|
5
|
+
"description": "Multi-cloud rolling fallback relay for OpenCode Zen and KiloCode free models in Oh My Pi (OMP) and Pi",
|
|
6
|
+
"main": "extensions/index.ts",
|
|
7
|
+
"types": "src/index.ts",
|
|
6
8
|
"keywords": [
|
|
7
9
|
"pi-package",
|
|
8
10
|
"pi-extension",
|
|
9
|
-
"
|
|
11
|
+
"oh-my-pi",
|
|
12
|
+
"omp",
|
|
13
|
+
"free-models",
|
|
14
|
+
"opencode",
|
|
15
|
+
"kilocode",
|
|
16
|
+
"ai-models",
|
|
17
|
+
"relay"
|
|
10
18
|
],
|
|
11
19
|
"author": "trefeon",
|
|
12
20
|
"license": "MIT",
|
|
@@ -22,6 +30,18 @@
|
|
|
22
30
|
},
|
|
23
31
|
"files": [
|
|
24
32
|
"extensions",
|
|
25
|
-
"
|
|
26
|
-
|
|
33
|
+
"src",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "node --experimental-strip-types --test test/**/*.test.ts",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"smoke": "node --experimental-strip-types -e \"import('./extensions/index.ts').then(() => console.log('✓ Smoke test passed: extensions/index.ts loaded successfully')).catch(err => { console.error(err); process.exit(1); })\""
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
44
|
+
"@types/node": "^22.13.9",
|
|
45
|
+
"typescript": "^5.8.2"
|
|
46
|
+
}
|
|
27
47
|
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic catalog discovery, caching, and model enrichment for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Provides 1-hour atomic disk caching with safe offline fallback to verified static definitions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import {
|
|
11
|
+
CATALOG_CACHE_FILE,
|
|
12
|
+
CATALOG_CACHE_TTL_MS,
|
|
13
|
+
KILO_CHAT_URL,
|
|
14
|
+
OPENCODE_API_URL,
|
|
15
|
+
opencodeHeaders,
|
|
16
|
+
} from "./config.ts";
|
|
17
|
+
import { log, logDebug, logWarn } from "./logger.ts";
|
|
18
|
+
import {
|
|
19
|
+
KILO_MODELS,
|
|
20
|
+
KNOWN_MODELS,
|
|
21
|
+
MODEL_MAP,
|
|
22
|
+
OPENCODE_MODELS,
|
|
23
|
+
} from "./models.ts";
|
|
24
|
+
import type {
|
|
25
|
+
CatalogCacheData,
|
|
26
|
+
RawModelItem,
|
|
27
|
+
RegisteredModel,
|
|
28
|
+
Upstream,
|
|
29
|
+
} from "./types.ts";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* In-memory cache of currently active/available free models.
|
|
33
|
+
* Initialized with all 23 verified models for 0ms instant availability.
|
|
34
|
+
*/
|
|
35
|
+
let aliveCatalog: RegisteredModel[] = [
|
|
36
|
+
...OPENCODE_MODELS.map((m) => ({ ...m, source: "opencode" as const })),
|
|
37
|
+
...KILO_MODELS.map((m) => ({ ...m, source: "kilo" as const })),
|
|
38
|
+
];
|
|
39
|
+
/**
|
|
40
|
+
* Get current in-memory alive catalog
|
|
41
|
+
*/
|
|
42
|
+
export function getAliveCatalog(): RegisteredModel[] {
|
|
43
|
+
return aliveCatalog;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Set in-memory alive catalog
|
|
48
|
+
*/
|
|
49
|
+
export function setAliveCatalog(catalog: RegisteredModel[]): void {
|
|
50
|
+
aliveCatalog = catalog;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Format a clean, human-readable display name for any upstream model ID.
|
|
55
|
+
*/
|
|
56
|
+
export function formatCleanDisplayName(id: string, customName?: string): string {
|
|
57
|
+
if (customName && customName.trim()) {
|
|
58
|
+
return customName.trim();
|
|
59
|
+
}
|
|
60
|
+
const known = MODEL_MAP.get(id);
|
|
61
|
+
if (known && known.name) {
|
|
62
|
+
return known.name;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
|
|
66
|
+
let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
|
|
67
|
+
// Strip variant suffixes
|
|
68
|
+
clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
|
|
69
|
+
clean = clean.replace(/-(free|contributor|preview)$/i, "");
|
|
70
|
+
|
|
71
|
+
// Capitalize words with acronym preservation
|
|
72
|
+
const parts = clean.split(/[-_]/).map((w) => {
|
|
73
|
+
const lower = w.toLowerCase();
|
|
74
|
+
if (lower === "gpt") return "GPT";
|
|
75
|
+
if (lower === "ai") return "AI";
|
|
76
|
+
if (lower === "lfm") return "LFM";
|
|
77
|
+
if (lower === "hy3") return "Hy3";
|
|
78
|
+
if (lower === "mimo") return "MiMo";
|
|
79
|
+
if (lower === "ocr") return "OCR";
|
|
80
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return parts.join(" ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Enrich a raw upstream model item into a fully typed RegisteredModel.
|
|
88
|
+
*/
|
|
89
|
+
export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
|
|
90
|
+
const known = MODEL_MAP.get(raw.id);
|
|
91
|
+
if (known) {
|
|
92
|
+
return { ...known, source };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const idLower = raw.id.toLowerCase();
|
|
96
|
+
const hasVision =
|
|
97
|
+
idLower.includes("vision") ||
|
|
98
|
+
idLower.includes("vl") ||
|
|
99
|
+
idLower.includes("omni") ||
|
|
100
|
+
idLower.includes("note") ||
|
|
101
|
+
idLower.includes("image");
|
|
102
|
+
const hasReasoning =
|
|
103
|
+
idLower.includes("reasoning") ||
|
|
104
|
+
idLower.includes("r1") ||
|
|
105
|
+
idLower.includes("o1") ||
|
|
106
|
+
idLower.includes("think") ||
|
|
107
|
+
idLower.includes("alpha") ||
|
|
108
|
+
idLower.includes("spark");
|
|
109
|
+
|
|
110
|
+
let contextWindow =
|
|
111
|
+
typeof raw.context_length === "number" ? raw.context_length : 262_144;
|
|
112
|
+
if (
|
|
113
|
+
idLower.includes("1m") ||
|
|
114
|
+
idLower.includes("ultra") ||
|
|
115
|
+
idLower.includes("lightning") ||
|
|
116
|
+
idLower.includes("mimo-v2.5") ||
|
|
117
|
+
idLower.includes("muse-spark")
|
|
118
|
+
) {
|
|
119
|
+
contextWindow = 1_048_576;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let maxTokens =
|
|
123
|
+
typeof raw.max_output_tokens === "number"
|
|
124
|
+
? raw.max_output_tokens
|
|
125
|
+
: 65_536;
|
|
126
|
+
if (idLower.includes("ultra") || idLower.includes("lightning")) {
|
|
127
|
+
maxTokens = 131_072;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const isResponses = raw.id === "muse-spark-1.2-contributor-free";
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
id: raw.id,
|
|
134
|
+
name: formatCleanDisplayName(raw.id),
|
|
135
|
+
source,
|
|
136
|
+
reasoning: hasReasoning,
|
|
137
|
+
contextWindow,
|
|
138
|
+
maxTokens,
|
|
139
|
+
api: isResponses ? "openai-responses" : undefined,
|
|
140
|
+
input: hasVision ? ["text", "image"] : ["text"],
|
|
141
|
+
thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Read cached catalog data from disk if valid and unexpired.
|
|
147
|
+
*/
|
|
148
|
+
export function readCatalogCache(): CatalogCacheData | null {
|
|
149
|
+
try {
|
|
150
|
+
if (!fs.existsSync(CATALOG_CACHE_FILE)) {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
154
|
+
const data = JSON.parse(raw) as CatalogCacheData;
|
|
155
|
+
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
156
|
+
return data;
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
logDebug("Failed reading catalog cache", { error: String(err) });
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Atomically write catalog cache data to disk using temporary file + rename.
|
|
166
|
+
*/
|
|
167
|
+
export function writeCatalogCache(data: CatalogCacheData): void {
|
|
168
|
+
try {
|
|
169
|
+
const dir = path.dirname(CATALOG_CACHE_FILE);
|
|
170
|
+
if (!fs.existsSync(dir)) {
|
|
171
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
172
|
+
}
|
|
173
|
+
const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
|
|
174
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
175
|
+
fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
|
|
176
|
+
} catch (err) {
|
|
177
|
+
logWarn("Could not persist catalog cache to disk", { error: String(err) });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
|
|
183
|
+
* Falls back gracefully to cached or static models if network requests fail.
|
|
184
|
+
*/
|
|
185
|
+
export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
186
|
+
if (!force) {
|
|
187
|
+
const disk = readCatalogCache();
|
|
188
|
+
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
189
|
+
aliveCatalog = disk.models;
|
|
190
|
+
return aliveCatalog;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 1. Fetch OpenCode Zen models
|
|
195
|
+
let opencodeList: RegisteredModel[] = [];
|
|
196
|
+
try {
|
|
197
|
+
const r = await fetch(`${OPENCODE_API_URL}/models`, {
|
|
198
|
+
headers: opencodeHeaders(),
|
|
199
|
+
signal: AbortSignal.timeout(10_000),
|
|
200
|
+
});
|
|
201
|
+
if (r.ok) {
|
|
202
|
+
const d = await r.json();
|
|
203
|
+
const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
|
|
204
|
+
const aliveIds = new Set(items.map((m) => m.id));
|
|
205
|
+
opencodeList = KNOWN_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
|
|
206
|
+
...m,
|
|
207
|
+
source: "opencode" as const,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
logDebug("OpenCode dynamic model fetch failed, using defaults", { error: String(err) });
|
|
212
|
+
}
|
|
213
|
+
if (!opencodeList.length) {
|
|
214
|
+
opencodeList = KNOWN_MODELS.map((m) => ({ ...m, source: "opencode" as const }));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 2. Fetch KiloCode Gateway models
|
|
218
|
+
let kiloList: RegisteredModel[] = [];
|
|
219
|
+
try {
|
|
220
|
+
const kiloModelsUrl = KILO_CHAT_URL.replace("/chat/completions", "/models");
|
|
221
|
+
const r = await fetch(kiloModelsUrl, {
|
|
222
|
+
headers: { Authorization: "Bearer kilo-free" },
|
|
223
|
+
signal: AbortSignal.timeout(10_000),
|
|
224
|
+
});
|
|
225
|
+
if (r.ok) {
|
|
226
|
+
const d = await r.json();
|
|
227
|
+
const items: RawModelItem[] = Array.isArray(d?.data) ? d.data : [];
|
|
228
|
+
const aliveIds = new Set(items.map((m) => m.id));
|
|
229
|
+
kiloList = KILO_MODELS.filter((m) => aliveIds.has(m.id)).map((m) => ({
|
|
230
|
+
...m,
|
|
231
|
+
source: "kilo" as const,
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
logDebug("KiloCode dynamic model fetch failed, using defaults", { error: String(err) });
|
|
236
|
+
}
|
|
237
|
+
if (!kiloList.length) {
|
|
238
|
+
kiloList = KILO_MODELS.map((m) => ({ ...m, source: "kilo" as const }));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const all = [...opencodeList, ...kiloList];
|
|
242
|
+
aliveCatalog = all;
|
|
243
|
+
|
|
244
|
+
// Write rich models to cache atomically
|
|
245
|
+
const data: CatalogCacheData = {
|
|
246
|
+
timestamp: Date.now(),
|
|
247
|
+
opencode: opencodeList.map((m) => m.id),
|
|
248
|
+
kilo: kiloList.map((m) => m.id),
|
|
249
|
+
models: all,
|
|
250
|
+
};
|
|
251
|
+
writeCatalogCache(data);
|
|
252
|
+
|
|
253
|
+
log("info", `Catalog refreshed: ${opencodeList.length} OpenCode + ${kiloList.length} KiloCode free models available`);
|
|
254
|
+
return all;
|
|
255
|
+
}
|