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/src/index.ts CHANGED
@@ -1,277 +1,301 @@
1
- /**
2
- * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
- *
4
- * Provides access to 23 free models (9 OpenCode Zen + 14 KiloCode Gateway) with:
5
- * - Single-port daemon reuse on 18080 across concurrent subagents
6
- * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
- * - 0ms instant startup with verified static catalog and background live health checks
8
- * - Per-model thinking/reasoning translation and streaming SSE pass-through
9
- */
10
-
11
- import type * as http from "node:http";
12
- import {
13
- getAliveCatalog,
14
- readCatalogCache,
15
- refreshCatalog,
16
- setAliveCatalog,
17
- } from "./catalog.ts";
18
- import { createCommandSpec, updateStatusBar } from "./commands.ts";
19
- import { DEFAULT_HOST, HOST, PORT } from "./config.ts";
20
- import { log, logInfo, logWarn } from "./logger.ts";
21
- import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, getAllRegisteredModels, resolveCanonicalModelId } from "./models.ts";
22
- import { isProxyAlive, startProxy } from "./proxy.ts";
23
- import { resetRateLimits } from "./rate-limiter.ts";
24
- import {
25
- getActiveRelayState,
26
- resolveRelayState,
27
- setActiveRelayState,
28
- setStatusUi,
29
- setFreeFlowModelActive,
30
- } from "./relay-state.ts";
31
- import type {
32
- ExtensionAPI,
33
- ExtensionContext,
34
- ProviderConfig,
35
- RegisteredModel,
36
- } from "./types.ts";
37
-
38
- // Re-export all sub-modules for clean library and programmatic usage
39
- export * from "./types.ts";
40
- export * from "./config.ts";
41
- export * from "./logger.ts";
42
- export * from "./rate-limiter.ts";
43
- export * from "./models.ts";
44
- export * from "./catalog.ts";
45
- export * from "./relay-state.ts";
46
- export * from "./relay.ts";
47
- export * from "./deploy.ts";
48
- export * from "./stream-pipe.ts";
49
- export * from "./proxy.ts";
50
- export * from "./commands.ts";
51
-
52
- /**
53
- * Construct standard ProviderConfig for pi-ai / OMP registration.
54
- */
55
- export function buildProviderConfig(
56
- models: RegisteredModel[],
57
- port: number = PORT,
58
- ): ProviderConfig {
59
- return {
60
- baseUrl: `http://${HOST}:${port}/v1`,
61
- apiKey: "placeholder",
62
- api: "openai-completions",
63
- compat: { supportsDeveloperRole: false },
64
- models: models.map((m) => {
65
- const efforts = m.thinkingLevelMap
66
- ? (Object.keys(m.thinkingLevelMap) as (keyof typeof m.thinkingLevelMap)[]).filter(
67
- (k) => m.thinkingLevelMap![k] !== null && k !== "off",
68
- )
69
- : ["minimal", "low", "medium", "high", "xhigh"];
70
-
71
- return {
72
- id: m.id,
73
- name: m.name,
74
- api: m.api,
75
- reasoning: m.reasoning,
76
- thinking: m.reasoning
77
- ? {
78
- mode: "effort",
79
- efforts: efforts.length > 0 ? efforts : ["low", "high", "max"],
80
- }
81
- : undefined,
82
- thinkingLevelMap: m.thinkingLevelMap,
83
- input: m.input ?? ["text"],
84
- contextWindow: m.contextWindow,
85
- maxTokens: m.maxTokens,
86
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
87
- compat: m.thinkingFormat
88
- ? {
89
- supportsDeveloperRole: false,
90
- thinkingFormat: m.thinkingFormat,
91
- }
92
- : m.api === "openai-responses"
93
- ? { sessionAffinityFormat: "openai-nosession" }
94
- : m.source === "kilo"
95
- ? {
96
- supportsDeveloperRole: false,
97
- supportsReasoningEffort: false,
98
- }
99
- : {
100
- supportsDeveloperRole: false,
101
- supportsReasoningEffort: true,
102
- },
103
- };
104
- }),
105
- };
106
- }
107
-
108
- /**
109
- * Main extension entrypoint
110
- */
111
- export default async function (pi: ExtensionAPI): Promise<void> {
112
- logInfo("pi-freeflow extension initializing...");
113
-
114
-
115
- let server: http.Server | null = null;
116
- let actualPort = PORT;
117
-
118
- // 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
119
- const alreadyRunning = await isProxyAlive(PORT);
120
- if (alreadyRunning) {
121
- logInfo(
122
- `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`,
123
- );
124
- actualPort = PORT;
125
- } else {
126
- try {
127
- const r = await startProxy();
128
- server = r.server;
129
- actualPort = r.port;
130
- } catch (e) {
131
- log(
132
- "error",
133
- "extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
134
- { error: String(e) },
135
- );
136
- return;
137
- }
138
- }
139
-
140
- // 3. Instant 0ms Static Catalog Registration
141
- // Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
142
- const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
143
- ...m,
144
- source: KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode",
145
- }));
146
- setAliveCatalog(registeredCatalog);
147
- pi.registerProvider(
148
- "freeflow",
149
- buildProviderConfig(registeredCatalog, actualPort),
150
- );
151
-
152
- // 4. Background Catalog Refresh
153
- // Asynchronously probe live upstreams and update the provider if alive model list changes
154
- refreshCatalog(false)
155
- .then((aliveModels) => {
156
- if (aliveModels.length > 0) {
157
- setAliveCatalog(aliveModels);
158
- pi.registerProvider(
159
- "freeflow",
160
- buildProviderConfig(registeredCatalog, actualPort),
161
- );
162
- logInfo(
163
- `Catalog refreshed: ${aliveModels.length} models verified active`,
164
- );
165
- }
166
- })
167
- .catch((err) => {
168
- logWarn("Background catalog refresh failed; retaining static catalog", {
169
- error: String(err),
170
- });
171
- });
172
-
173
- // 5. Register slash command
174
- const commandSpec = createCommandSpec(pi, (updatedModels) => {
175
- pi.registerProvider(
176
- "freeflow",
177
- buildProviderConfig(updatedModels, actualPort),
178
- );
179
- });
180
- pi.registerCommand("freeflow", commandSpec);
181
-
182
- // 6. Lifecycle Listeners
183
- function isFreeFlowModelMatch(provider?: string, modelId?: string): boolean {
184
- if (provider === "freeflow") return true;
185
- if (typeof modelId === "string" && modelId.trim()) {
186
- const clean = modelId.trim();
187
- const canonical = resolveCanonicalModelId(clean);
188
- return (
189
- MODEL_MAP.has(clean) ||
190
- MODEL_MAP.has(canonical) ||
191
- getAliveCatalog().some((m) => m.id === clean || m.id === canonical)
192
- );
193
- }
194
- // When provider and modelId are not yet populated on session_start,
195
- // default to true so the widget is present when starting with freeflow
196
- if (!provider && !modelId) {
197
- return true;
198
- }
199
- return false;
200
- }
201
-
202
- pi.on?.("session_start", async (_event, ctx: ExtensionContext) => {
203
- const freshRelayState = resolveRelayState();
204
- setStatusUi(ctx.ui);
205
-
206
- let provider: string | undefined;
207
- let modelId: string | undefined;
208
- if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
209
- const m = ctx.model;
210
- if ("provider" in m && typeof m.provider === "string") {
211
- provider = m.provider;
212
- }
213
- if ("id" in m && typeof m.id === "string") {
214
- modelId = m.id;
215
- }
216
- }
217
- const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
218
- setFreeFlowModelActive(isFreeFlow);
219
-
220
- if (freshRelayState.mode !== "off") {
221
- freshRelayState.enabled = freshRelayState.relays.length > 0;
222
- setActiveRelayState(freshRelayState, false);
223
- }
224
-
225
- if (isFreeFlow) {
226
- updateStatusBar(ctx.ui);
227
- } else {
228
- ctx.ui?.setStatus?.("freeflow", undefined);
229
- }
230
- });
231
-
232
- pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
233
- const freshRelayState = resolveRelayState();
234
- setStatusUi(ctx.ui);
235
- let provider: string | undefined;
236
- let modelId: string | undefined;
237
- if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
238
- const m = event.model;
239
- if ("provider" in m && typeof m.provider === "string") {
240
- provider = m.provider;
241
- }
242
- if ("id" in m && typeof m.id === "string") {
243
- modelId = m.id;
244
- }
245
- } else if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
246
- const m = ctx.model;
247
- if ("provider" in m && typeof m.provider === "string") {
248
- provider = m.provider;
249
- }
250
- if ("id" in m && typeof m.id === "string") {
251
- modelId = m.id;
252
- }
253
- }
254
- const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
255
- setFreeFlowModelActive(isFreeFlow);
256
-
257
- if (freshRelayState.mode !== "off") {
258
- freshRelayState.enabled = freshRelayState.relays.length > 0;
259
- setActiveRelayState(freshRelayState, false);
260
- }
261
-
262
- if (isFreeFlow) {
263
- updateStatusBar(ctx.ui);
264
- } else {
265
- ctx.ui?.setStatus?.("freeflow", undefined);
266
- }
267
- });
268
- pi.on?.("session_shutdown", () => {
269
- if (server) {
270
- logInfo("shutting down proxy daemon...");
271
- server.close();
272
- server = null;
273
- resetRateLimits();
274
- logInfo("shutdown complete");
275
- }
276
- });
277
- }
1
+ /**
2
+ * pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
3
+ *
4
+ * Provides access to 21 free models (7 OpenCode Zen + 14 KiloCode Gateway) with:
5
+ * - Single-port daemon reuse on 18080 across concurrent subagents
6
+ * - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
7
+ * - 0ms instant startup with verified static catalog and background live health checks
8
+ * - Per-model thinking/reasoning translation and streaming SSE pass-through
9
+ */
10
+
11
+ import type * as http from "node:http";
12
+ import {
13
+ getAliveCatalog,
14
+ mergeCatalog,
15
+ readCatalogCache,
16
+ refreshCatalog,
17
+ setAliveCatalog,
18
+ } from "./catalog.ts";
19
+ import { createCommandSpec, updateStatusBar } from "./commands.ts";
20
+ import { DEFAULT_HOST, HOST, PORT } from "./config.ts";
21
+ import { log, logInfo, logWarn } from "./logger.ts";
22
+ import { ALL_MODELS, KILO_MODEL_IDS, MODEL_MAP, getAllRegisteredModels, resolveCanonicalModelId } from "./models.ts";
23
+ import { isProxyAlive, startProxy } from "./proxy.ts";
24
+ import { resetRateLimits } from "./rate-limiter.ts";
25
+ import {
26
+ getActiveRelayState,
27
+ resolveRelayState,
28
+ setActiveRelayState,
29
+ setStatusUi,
30
+ setFreeFlowModelActive,
31
+ } from "./relay-state.ts";
32
+ import type {
33
+ ExtensionAPI,
34
+ ExtensionContext,
35
+ ProviderConfig,
36
+ RegisteredModel,
37
+ } from "./types.ts";
38
+
39
+ // Re-export all sub-modules for clean library and programmatic usage
40
+ export * from "./types.ts";
41
+ export * from "./config.ts";
42
+ export * from "./logger.ts";
43
+ export * from "./rate-limiter.ts";
44
+ export * from "./models.ts";
45
+ export * from "./catalog.ts";
46
+ export * from "./relay-state.ts";
47
+ export * from "./relay.ts";
48
+ export * from "./deploy.ts";
49
+ export * from "./stream-pipe.ts";
50
+ export * from "./proxy.ts";
51
+ export * from "./commands.ts";
52
+
53
+ /**
54
+ * Construct standard ProviderConfig for pi-ai / OMP registration.
55
+ */
56
+ export function buildProviderConfig(
57
+ models: RegisteredModel[],
58
+ port: number = PORT,
59
+ ): ProviderConfig {
60
+ return {
61
+ baseUrl: `http://${HOST}:${port}/v1`,
62
+ apiKey: "placeholder",
63
+ api: "openai-completions",
64
+ compat: { supportsDeveloperRole: false },
65
+ models: models.map((m) => {
66
+ const efforts = m.thinkingLevelMap
67
+ ? (Object.keys(m.thinkingLevelMap) as (keyof typeof m.thinkingLevelMap)[]).filter(
68
+ (k) => m.thinkingLevelMap![k] !== null && k !== "off",
69
+ )
70
+ : ["minimal", "low", "medium", "high", "xhigh"];
71
+
72
+ return {
73
+ id: m.id,
74
+ name: m.name,
75
+ api: m.api,
76
+ reasoning: m.reasoning,
77
+ thinking: m.reasoning
78
+ ? {
79
+ mode: "effort",
80
+ efforts: efforts.length > 0 ? efforts : ["low", "high", "max"],
81
+ }
82
+ : undefined,
83
+ thinkingLevelMap: m.thinkingLevelMap,
84
+ input: m.input ?? ["text"],
85
+ contextWindow: m.contextWindow,
86
+ maxTokens: m.maxTokens,
87
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
88
+ compat: m.thinkingFormat
89
+ ? {
90
+ supportsDeveloperRole: false,
91
+ thinkingFormat: m.thinkingFormat,
92
+ }
93
+ : m.api === "openai-responses"
94
+ ? { sessionAffinityFormat: "openai-nosession" }
95
+ : m.source === "kilo"
96
+ ? {
97
+ supportsDeveloperRole: false,
98
+ supportsReasoningEffort: false,
99
+ }
100
+ : {
101
+ supportsDeveloperRole: false,
102
+ supportsReasoningEffort: true,
103
+ },
104
+ };
105
+ }),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Main extension entrypoint
111
+ */
112
+ export default async function (pi: ExtensionAPI): Promise<void> {
113
+ logInfo("pi-freeflow extension initializing...");
114
+
115
+
116
+ let server: http.Server | null = null;
117
+ let actualPort = PORT;
118
+
119
+ // 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
120
+ const alreadyRunning = await isProxyAlive(PORT);
121
+ if (alreadyRunning) {
122
+ logInfo(
123
+ `Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`,
124
+ );
125
+ actualPort = PORT;
126
+ } else {
127
+ try {
128
+ const r = await startProxy();
129
+ server = r.server;
130
+ actualPort = r.port;
131
+ } catch (e) {
132
+ log(
133
+ "error",
134
+ "extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
135
+ { error: String(e) },
136
+ );
137
+ return;
138
+ }
139
+ }
140
+
141
+ // 3. Instant 0ms Static Catalog Registration
142
+ // Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
143
+ const registeredCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
144
+ ...m,
145
+ source: KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode",
146
+ }));
147
+ setAliveCatalog(registeredCatalog);
148
+ const registerCatalog = (models: RegisteredModel[]): void => {
149
+
150
+ pi.registerProvider(
151
+ "freeflow",
152
+ buildProviderConfig(models, actualPort),
153
+ );
154
+ };
155
+ registerCatalog(registeredCatalog);
156
+ // Self-heal: if this session attached to an external daemon that later died,
157
+ // re-bind on the next lifecycle event so model calls recover without a restart.
158
+ let ensuringDaemon = false;
159
+ const ensureDaemon = async (): Promise<void> => {
160
+ if (ensuringDaemon) return;
161
+ ensuringDaemon = true;
162
+ try {
163
+ if (await isProxyAlive(actualPort)) return;
164
+ const r = await startProxy();
165
+ if (r.server) server = r.server;
166
+ if (r.port) actualPort = r.port;
167
+ registerCatalog(getAliveCatalog());
168
+ logWarn("proxy daemon was lost — re-bound locally", { port: actualPort });
169
+ } catch (e) {
170
+ logWarn("proxy daemon lost and re-bind failed", { error: String(e) });
171
+ } finally {
172
+ ensuringDaemon = false;
173
+ }
174
+ };
175
+
176
+ // 4. Background Catalog Refresh
177
+ // Asynchronously probe live upstreams and update the provider if alive model list changes
178
+ refreshCatalog(false)
179
+ .then((aliveModels) => {
180
+ if (aliveModels.length > 0) {
181
+ setAliveCatalog(aliveModels);
182
+ registerCatalog(mergeCatalog(registeredCatalog, aliveModels));
183
+ logInfo(
184
+ `Catalog refreshed: ${aliveModels.length} models verified active`,
185
+ );
186
+ }
187
+ })
188
+ .catch((err) => {
189
+ logWarn("Background catalog refresh failed; retaining static catalog", {
190
+ error: String(err),
191
+ });
192
+ });
193
+
194
+ // 5. Register slash command
195
+ const commandSpec = createCommandSpec(pi, (updatedModels) => {
196
+ registerCatalog(updatedModels);
197
+ });
198
+ pi.registerCommand("freeflow", commandSpec);
199
+
200
+ // 6. Lifecycle Listeners
201
+ function isFreeFlowModelMatch(provider?: string, modelId?: string): boolean {
202
+ if (provider === "freeflow") return true;
203
+ if (typeof modelId === "string" && modelId.trim()) {
204
+ const clean = modelId.trim();
205
+ const canonical = resolveCanonicalModelId(clean);
206
+ return (
207
+ MODEL_MAP.has(clean) ||
208
+ MODEL_MAP.has(canonical) ||
209
+ getAliveCatalog().some((m) => m.id === clean || m.id === canonical)
210
+ );
211
+ }
212
+ // When provider and modelId are not yet populated on session_start,
213
+ // default to true so the widget is present when starting with freeflow
214
+ if (!provider && !modelId) {
215
+ return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ pi.on?.("session_start", async (_event, ctx: ExtensionContext) => {
221
+ await ensureDaemon();
222
+ const freshRelayState = resolveRelayState();
223
+ setStatusUi(ctx.ui);
224
+
225
+ let provider: string | undefined;
226
+ let modelId: string | undefined;
227
+ if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
228
+ const m = ctx.model;
229
+ if ("provider" in m && typeof m.provider === "string") {
230
+ provider = m.provider;
231
+ }
232
+ if ("id" in m && typeof m.id === "string") {
233
+ modelId = m.id;
234
+ }
235
+ }
236
+ const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
237
+ setFreeFlowModelActive(isFreeFlow);
238
+
239
+ if (freshRelayState.mode !== "off") {
240
+ freshRelayState.enabled = freshRelayState.relays.length > 0;
241
+ setActiveRelayState(freshRelayState, false);
242
+ }
243
+
244
+ if (isFreeFlow) {
245
+ updateStatusBar(ctx.ui);
246
+ } else {
247
+ ctx.ui?.setStatus?.("freeflow", undefined);
248
+ }
249
+ });
250
+
251
+ pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
252
+ await ensureDaemon();
253
+ const freshRelayState = resolveRelayState();
254
+ setStatusUi(ctx.ui);
255
+ let provider: string | undefined;
256
+ let modelId: string | undefined;
257
+ if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
258
+ const m = event.model;
259
+ if ("provider" in m && typeof m.provider === "string") {
260
+ provider = m.provider;
261
+ }
262
+ if ("id" in m && typeof m.id === "string") {
263
+ modelId = m.id;
264
+ }
265
+ } else if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
266
+ const m = ctx.model;
267
+ if ("provider" in m && typeof m.provider === "string") {
268
+ provider = m.provider;
269
+ }
270
+ if ("id" in m && typeof m.id === "string") {
271
+ modelId = m.id;
272
+ }
273
+ }
274
+ const isFreeFlow = isFreeFlowModelMatch(provider, modelId);
275
+ setFreeFlowModelActive(isFreeFlow);
276
+
277
+ if (freshRelayState.mode !== "off") {
278
+ freshRelayState.enabled = freshRelayState.relays.length > 0;
279
+ setActiveRelayState(freshRelayState, false);
280
+ }
281
+
282
+ if (isFreeFlow) {
283
+ updateStatusBar(ctx.ui);
284
+ } else {
285
+ ctx.ui?.setStatus?.("freeflow", undefined);
286
+ }
287
+ });
288
+ pi.on?.("session_shutdown", () => {
289
+ if (server) {
290
+ logInfo("shutting down proxy daemon...");
291
+ const closing = server;
292
+ server = null;
293
+ closing.close();
294
+ // Node 18.2+: closeIdleConnections exists at runtime even if lib types lag
295
+ const closable = closing as unknown as { closeIdleConnections?: () => void };
296
+ closable.closeIdleConnections?.();
297
+ resetRateLimits();
298
+ logInfo("shutdown complete");
299
+ }
300
+ });
301
+ }