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