pi-freeflow 1.4.2 → 1.4.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.
- package/README.md +323 -279
- package/package.json +1 -1
- package/src/catalog.ts +250 -207
- package/src/commands.ts +670 -618
- package/src/config.ts +144 -145
- package/src/deploy.ts +496 -126
- package/src/index.ts +301 -277
- package/src/models.ts +355 -399
- package/src/proxy.ts +493 -500
- package/src/relay.ts +213 -210
- package/src/stream-pipe.ts +265 -263
package/src/catalog.ts
CHANGED
|
@@ -1,207 +1,250 @@
|
|
|
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
|
-
ALL_MODELS,
|
|
20
|
-
KILO_MODELS,
|
|
21
|
-
KILO_MODEL_IDS,
|
|
22
|
-
KNOWN_MODELS,
|
|
23
|
-
MODEL_MAP,
|
|
24
|
-
OPENCODE_MODELS,
|
|
25
|
-
getAllRegisteredModels,
|
|
26
|
-
} from "./models.ts";
|
|
27
|
-
import type {
|
|
28
|
-
CatalogCacheData,
|
|
29
|
-
RawModelItem,
|
|
30
|
-
RegisteredModel,
|
|
31
|
-
Upstream,
|
|
32
|
-
} from "./types.ts";
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
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
|
+
ALL_MODELS,
|
|
20
|
+
KILO_MODELS,
|
|
21
|
+
KILO_MODEL_IDS,
|
|
22
|
+
KNOWN_MODELS,
|
|
23
|
+
MODEL_MAP,
|
|
24
|
+
OPENCODE_MODELS,
|
|
25
|
+
getAllRegisteredModels,
|
|
26
|
+
} from "./models.ts";
|
|
27
|
+
import type {
|
|
28
|
+
CatalogCacheData,
|
|
29
|
+
RawModelItem,
|
|
30
|
+
RegisteredModel,
|
|
31
|
+
Upstream,
|
|
32
|
+
} from "./types.ts";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
|
|
36
|
+
*/
|
|
37
|
+
const DEAD_MODEL_IDS = new Set<string>(["deepseek-v4-flash-free", "x-preview-f-free"]);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* In-memory cache of currently active/available free models.
|
|
41
|
+
* Initialized with all 21 verified models for 0ms instant availability.
|
|
42
|
+
*/
|
|
43
|
+
let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
44
|
+
...m,
|
|
45
|
+
source: KILO_MODEL_IDS.has(m.id) ? ("kilo" as const) : ("opencode" as const),
|
|
46
|
+
}));
|
|
47
|
+
/**
|
|
48
|
+
* Get current in-memory alive catalog
|
|
49
|
+
*/
|
|
50
|
+
export function getAliveCatalog(): RegisteredModel[] {
|
|
51
|
+
return aliveCatalog;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Set in-memory alive catalog
|
|
56
|
+
*/
|
|
57
|
+
export function setAliveCatalog(catalog: RegisteredModel[]): void {
|
|
58
|
+
aliveCatalog = catalog;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Overlay a refreshed model list onto a base list without dropping base entries.
|
|
63
|
+
* Fresh entries win on id collision; unknown fresh ids are appended after the base.
|
|
64
|
+
* Guards the background refresh against a partial upstream cache removing
|
|
65
|
+
* verified static models from provider registration.
|
|
66
|
+
* Filters pruned dead IDs so they never re-enter via fresh upstream data.
|
|
67
|
+
*/
|
|
68
|
+
export function mergeCatalog(
|
|
69
|
+
base: RegisteredModel[],
|
|
70
|
+
fresh: RegisteredModel[],
|
|
71
|
+
): RegisteredModel[] {
|
|
72
|
+
const filteredFresh = fresh.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
73
|
+
const byId = new Map(base.map((m) => [m.id, m]));
|
|
74
|
+
for (const m of filteredFresh) byId.set(m.id, m);
|
|
75
|
+
// Ensure no dead IDs survive even if base was stale
|
|
76
|
+
return [...byId.values()].filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format a clean, human-readable display name for any upstream model ID.
|
|
81
|
+
*/
|
|
82
|
+
export function formatCleanDisplayName(id: string, customName?: string): string {
|
|
83
|
+
if (customName && customName.trim()) {
|
|
84
|
+
return customName.trim();
|
|
85
|
+
}
|
|
86
|
+
const known = MODEL_MAP.get(id);
|
|
87
|
+
if (known && known.name) {
|
|
88
|
+
return known.name;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Strip provider prefix ("nvidia/", "stepfun/", "dots-studio/", etc.)
|
|
92
|
+
let clean = id.replace(/^[a-zA-Z0-9_.-]+\//, "");
|
|
93
|
+
// Strip variant suffixes
|
|
94
|
+
clean = clean.replace(/:(free|preview|exacto|default|batch)$/i, "");
|
|
95
|
+
clean = clean.replace(/-(free|contributor|preview)$/i, "");
|
|
96
|
+
|
|
97
|
+
// Capitalize words with acronym preservation
|
|
98
|
+
const parts = clean.split(/[-_]/).map((w) => {
|
|
99
|
+
const lower = w.toLowerCase();
|
|
100
|
+
if (lower === "gpt") return "GPT";
|
|
101
|
+
if (lower === "ai") return "AI";
|
|
102
|
+
if (lower === "lfm") return "LFM";
|
|
103
|
+
if (lower === "hy3") return "Hy3";
|
|
104
|
+
if (lower === "mimo") return "MiMo";
|
|
105
|
+
if (lower === "ocr") return "OCR";
|
|
106
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
return parts.join(" ");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Enrich a raw upstream model item into a fully typed RegisteredModel.
|
|
114
|
+
*/
|
|
115
|
+
export function enrichModelDef(raw: RawModelItem, source: Upstream): RegisteredModel {
|
|
116
|
+
const known = MODEL_MAP.get(raw.id);
|
|
117
|
+
if (known) {
|
|
118
|
+
return { ...known, source };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const idLower = raw.id.toLowerCase();
|
|
122
|
+
const hasVision =
|
|
123
|
+
idLower.includes("vision") ||
|
|
124
|
+
idLower.includes("vl") ||
|
|
125
|
+
idLower.includes("omni") ||
|
|
126
|
+
idLower.includes("note") ||
|
|
127
|
+
idLower.includes("image");
|
|
128
|
+
const hasReasoning =
|
|
129
|
+
idLower.includes("reasoning") ||
|
|
130
|
+
idLower.includes("r1") ||
|
|
131
|
+
idLower.includes("o1") ||
|
|
132
|
+
idLower.includes("think") ||
|
|
133
|
+
idLower.includes("spark");
|
|
134
|
+
|
|
135
|
+
let contextWindow =
|
|
136
|
+
typeof raw.context_length === "number" ? raw.context_length : 262_144;
|
|
137
|
+
if (
|
|
138
|
+
idLower.includes("1m") ||
|
|
139
|
+
idLower.includes("ultra") ||
|
|
140
|
+
idLower.includes("lightning") ||
|
|
141
|
+
idLower.includes("mimo-v2.5") ||
|
|
142
|
+
idLower.includes("muse-spark")
|
|
143
|
+
) {
|
|
144
|
+
contextWindow = 1_048_576;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let maxTokens =
|
|
148
|
+
typeof raw.max_output_tokens === "number"
|
|
149
|
+
? raw.max_output_tokens
|
|
150
|
+
: 65_536;
|
|
151
|
+
if (idLower.includes("ultra") || idLower.includes("lightning")) {
|
|
152
|
+
maxTokens = 131_072;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const isResponses = raw.id === "muse-spark-1.2-contributor-free";
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
id: raw.id,
|
|
159
|
+
name: formatCleanDisplayName(raw.id),
|
|
160
|
+
source,
|
|
161
|
+
reasoning: hasReasoning,
|
|
162
|
+
contextWindow,
|
|
163
|
+
maxTokens,
|
|
164
|
+
api: isResponses ? "openai-responses" : undefined,
|
|
165
|
+
input: hasVision ? ["text", "image"] : ["text"],
|
|
166
|
+
thinkingFormat: source === "kilo" && hasReasoning ? "openrouter" : undefined,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Read cached catalog data from disk if valid and unexpired.
|
|
172
|
+
* Filters out pruned dead IDs so stale disk entries never repopulate the catalog.
|
|
173
|
+
*/
|
|
174
|
+
export function readCatalogCache(): CatalogCacheData | null {
|
|
175
|
+
try {
|
|
176
|
+
if (!fs.existsSync(CATALOG_CACHE_FILE)) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
180
|
+
const data = JSON.parse(raw) as CatalogCacheData;
|
|
181
|
+
if (Array.isArray(data.models)) {
|
|
182
|
+
data.models = data.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
183
|
+
}
|
|
184
|
+
if (Date.now() - data.timestamp < CATALOG_CACHE_TTL_MS) {
|
|
185
|
+
return data;
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logDebug("Failed reading catalog cache", { error: String(err) });
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Atomically write catalog cache data to disk using temporary file + rename.
|
|
195
|
+
*/
|
|
196
|
+
export function writeCatalogCache(data: CatalogCacheData): void {
|
|
197
|
+
try {
|
|
198
|
+
const dir = path.dirname(CATALOG_CACHE_FILE);
|
|
199
|
+
if (!fs.existsSync(dir)) {
|
|
200
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
201
|
+
}
|
|
202
|
+
const tmpPath = `${CATALOG_CACHE_FILE}.${randomUUID()}.tmp`;
|
|
203
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf8");
|
|
204
|
+
fs.renameSync(tmpPath, CATALOG_CACHE_FILE);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
logWarn("Could not persist catalog cache to disk", { error: String(err) });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Refresh free model catalog from OpenCode Zen and KiloCode Gateway endpoints.
|
|
212
|
+
* Falls back gracefully to cached or static models if network requests fail.
|
|
213
|
+
*/
|
|
214
|
+
export async function refreshCatalog(force = false): Promise<RegisteredModel[]> {
|
|
215
|
+
// Thin provider: no live fetch — subagents must not hit upstream directly
|
|
216
|
+
// (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
|
|
217
|
+
// We only serve disk cache if fresh, otherwise static 21-model aliveCatalog.
|
|
218
|
+
const disk = readCatalogCache();
|
|
219
|
+
if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
|
|
220
|
+
const age = Date.now() - (disk.timestamp ?? 0);
|
|
221
|
+
if (!force && age < CATALOG_CACHE_TTL_MS) {
|
|
222
|
+
aliveCatalog = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
223
|
+
return aliveCatalog;
|
|
224
|
+
}
|
|
225
|
+
// Stale cache still better than empty — return it without network (filtered)
|
|
226
|
+
const filtered = disk.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
227
|
+
if (filtered.length >= 21) {
|
|
228
|
+
aliveCatalog = filtered;
|
|
229
|
+
return aliveCatalog;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// No valid fresh cache — try stale disk cache directly (readCatalogCache returns null when expired)
|
|
233
|
+
try {
|
|
234
|
+
if (fs.existsSync(CATALOG_CACHE_FILE)) {
|
|
235
|
+
const raw = fs.readFileSync(CATALOG_CACHE_FILE, "utf8");
|
|
236
|
+
const stale = JSON.parse(raw) as CatalogCacheData;
|
|
237
|
+
if (Array.isArray(stale.models) && stale.models.length > 0) {
|
|
238
|
+
const filtered = stale.models.filter((m) => !DEAD_MODEL_IDS.has(m.id));
|
|
239
|
+
if (filtered.length >= 21) {
|
|
240
|
+
aliveCatalog = filtered;
|
|
241
|
+
return aliveCatalog;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} catch (err) {
|
|
246
|
+
logDebug("Failed reading stale catalog cache", { error: String(err) });
|
|
247
|
+
}
|
|
248
|
+
// No valid cache — return in-memory static 21 (host will refresh if needed)
|
|
249
|
+
return aliveCatalog;
|
|
250
|
+
}
|