pi-freeflow 1.2.1 → 1.3.0

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.2.1",
4
+ "version": "1.3.0",
5
5
  "description": "Multi-cloud rolling fallback relay for OpenCode Zen and KiloCode free models in Oh My Pi (OMP) and Pi",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
@@ -23,6 +23,11 @@
23
23
  "url": "https://github.com/trefeon/pi-freeflow.git"
24
24
  },
25
25
  "homepage": "https://github.com/trefeon/pi-freeflow#readme",
26
+ "omp": {
27
+ "extensions": [
28
+ "./extensions"
29
+ ]
30
+ },
26
31
  "pi": {
27
32
  "extensions": [
28
33
  "./extensions"
package/src/catalog.ts CHANGED
@@ -183,73 +183,22 @@ export function writeCatalogCache(data: CatalogCacheData): void {
183
183
  * Falls back gracefully to cached or static models if network requests fail.
184
184
  */
185
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) {
186
+ // Thin provider: no live fetch — subagents must not hit upstream directly
187
+ // (proxy-only). Host Pi/OMP owns dynamic discovery via fetchDynamicModels (24h).
188
+ // We only serve disk cache if fresh, otherwise static 23-model aliveCatalog.
189
+ const disk = readCatalogCache();
190
+ if (disk && Array.isArray(disk.models) && disk.models.length > 0) {
191
+ const age = Date.now() - (disk.timestamp ?? 0);
192
+ if (!force && age < CATALOG_CACHE_TTL_MS) {
189
193
  aliveCatalog = disk.models;
190
194
  return aliveCatalog;
191
195
  }
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
- }));
196
+ // Stale cache still better than empty — return it without network
197
+ if (disk.models.length === 23) {
198
+ aliveCatalog = disk.models;
199
+ return aliveCatalog;
233
200
  }
234
- } catch (err) {
235
- logDebug("KiloCode dynamic model fetch failed, using defaults", { error: String(err) });
236
201
  }
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;
202
+ // No valid cache — return in-memory static 23 (host will refresh if needed)
203
+ return aliveCatalog;
255
204
  }
package/src/config.ts CHANGED
@@ -53,7 +53,7 @@ export const VERCEL_API = "https://api.vercel.com";
53
53
  export const RELAY_MAX_TOKENS = 131_072;
54
54
 
55
55
  // ── Catalog & Logging constants ─────────────────────────────────────
56
- export const CATALOG_CACHE_TTL_MS = 3_600_000; // 1 hour
56
+ export const CATALOG_CACHE_TTL_MS = 86_400_000; // 24 hours — delegate to host fetchDynamicModels
57
57
  export const LOG_MAX_BYTES = 5 * 1024 * 1024; // 5MB
58
58
  export const LOG_MAX_FILES = 3;
59
59
 
package/src/index.ts CHANGED
@@ -44,7 +44,6 @@ export * from "./catalog.ts";
44
44
  export * from "./relay-state.ts";
45
45
  export * from "./relay.ts";
46
46
  export * from "./deploy.ts";
47
- export * from "./normalizer.ts";
48
47
  export * from "./stream-pipe.ts";
49
48
  export * from "./proxy.ts";
50
49
  export * from "./commands.ts";
@@ -208,6 +207,8 @@ export default async function (pi: ExtensionAPI): Promise<void> {
208
207
  });
209
208
 
210
209
  pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
210
+ const freshRelayState = resolveRelayState();
211
+ setActiveRelayState(freshRelayState, false);
211
212
  setStatusUi(ctx.ui);
212
213
  let provider: string | undefined;
213
214
  let modelId: string | undefined;
package/src/models.ts CHANGED
@@ -21,7 +21,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
21
21
  maxTokens: 384_000,
22
22
  input: ["text"],
23
23
  thinkingLevelMap: {
24
- off: "low",
24
+ off: null,
25
25
  minimal: "low",
26
26
  low: "low",
27
27
  medium: "high",
@@ -38,7 +38,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
38
38
  maxTokens: 131_072,
39
39
  input: ["text", "image"],
40
40
  thinkingLevelMap: {
41
- off: "low",
41
+ off: null,
42
42
  minimal: "low",
43
43
  low: "low",
44
44
  medium: "high",
@@ -73,7 +73,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
73
73
  maxTokens: 131_072,
74
74
  input: ["text", "image"],
75
75
  thinkingLevelMap: {
76
- off: "low",
76
+ off: null,
77
77
  minimal: "low",
78
78
  low: "low",
79
79
  medium: "medium",
@@ -90,7 +90,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
90
90
  maxTokens: 128_000,
91
91
  input: ["text"],
92
92
  thinkingLevelMap: {
93
- off: "low",
93
+ off: null,
94
94
  minimal: "low",
95
95
  low: "low",
96
96
  medium: "high",
@@ -107,7 +107,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
107
107
  maxTokens: 128_000,
108
108
  input: ["text"],
109
109
  thinkingLevelMap: {
110
- off: "low",
110
+ off: null,
111
111
  minimal: "low",
112
112
  low: "low",
113
113
  medium: "high",
@@ -124,7 +124,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
124
124
  maxTokens: 262_144,
125
125
  input: ["text"],
126
126
  thinkingLevelMap: {
127
- off: "low",
127
+ off: null,
128
128
  minimal: "low",
129
129
  low: "low",
130
130
  medium: "high",
@@ -141,7 +141,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
141
141
  maxTokens: 32_000,
142
142
  input: ["text"],
143
143
  thinkingLevelMap: {
144
- off: "high",
144
+ off: null,
145
145
  minimal: "high",
146
146
  low: "high",
147
147
  medium: "high",
@@ -158,7 +158,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
158
158
  maxTokens: 131_072,
159
159
  input: ["text"],
160
160
  thinkingLevelMap: {
161
- off: "low",
161
+ off: null,
162
162
  minimal: "low",
163
163
  low: "low",
164
164
  medium: "high",
package/src/proxy.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  } from "./config.ts";
25
25
  import { isDebugEnabled, log } from "./logger.ts";
26
26
  import { KILO_MODEL_IDS } from "./models.ts";
27
- import { normalizeRequestBody } from "./normalizer.ts";
27
+ // normalize removed host pi-ai already normalizes thinking/reasoning before proxy
28
28
  import { checkRateLimit } from "./rate-limiter.ts";
29
29
  import { relayFetch } from "./relay.ts";
30
30
  import { getActiveRelayState } from "./relay-state.ts";
@@ -143,10 +143,12 @@ export function startProxy(
143
143
 
144
144
  // Serve ONLY our registered free models. Never forward /v1/models to upstream
145
145
  // to prevent paid/proprietary upstream models from leaking into the model picker.
146
- if (
147
- req.method === "GET" &&
148
- (req.url === "/v1/models" || req.url === "/v1/models/")
149
- ) {
146
+ // Use pathname check so /v1/models?query variants are also guarded (no leak).
147
+ let reqPathname: string | null = null;
148
+ try {
149
+ reqPathname = new URL(req.url ?? "/", `http://${HOST}`).pathname;
150
+ } catch {}
151
+ if (req.method === "GET" && (reqPathname === "/v1/models" || reqPathname === "/v1/models/")) {
150
152
  const alive = getAliveCatalog();
151
153
  const body = JSON.stringify({
152
154
  object: "list",
@@ -207,7 +209,14 @@ export function startProxy(
207
209
  const upstream: Upstream = isKilo ? "kilo" : "opencode";
208
210
  const isStream = parsedBody?.stream === true;
209
211
 
210
- if (!checkRateLimit(clientIP, upstream)) {
212
+ // Seamless sub-agent rate-limit: when relay pool is active, bypass
213
+ // local per-IP quota (127.0.0.1 shared by all subagents) — upstream
214
+ // quota is per-egress-IP and relayFetch already rolls on 429 across
215
+ // 7 candidates until a response succeeds. Without this, parallel
216
+ // subagents sharing the daemon would hit local 429 before relay failover.
217
+ const relayPreview = getActiveRelayState();
218
+ const willUseRelay = relayPreview.enabled && Boolean(relayPreview.url || relayPreview.relays.length > 0);
219
+ if (!willUseRelay && !checkRateLimit(clientIP, upstream)) {
211
220
  res.writeHead(429, { "content-type": "application/json" });
212
221
  res.end(JSON.stringify({ error: "rate limit exceeded" }));
213
222
  return;
@@ -216,7 +225,6 @@ export function startProxy(
216
225
  try {
217
226
  if (isKilo && parsedBody) {
218
227
  const kiloBodyObj = structuredClone(parsedBody);
219
- normalizeRequestBody(kiloBodyObj, true, isKilo, reqId);
220
228
  const response = await relayFetch(
221
229
  KILO_CHAT_URL,
222
230
  {
@@ -271,7 +279,6 @@ export function startProxy(
271
279
  try {
272
280
  if (parsedBody) {
273
281
  const relayBodyObj = structuredClone(parsedBody);
274
- normalizeRequestBody(relayBodyObj, true, isKilo, reqId);
275
282
  const relayBody = Buffer.from(JSON.stringify(relayBodyObj));
276
283
  const response = await relayFetch(
277
284
  fullUrl,
@@ -327,7 +334,6 @@ export function startProxy(
327
334
  let directBody = Buffer.concat(bodyChunks);
328
335
  if (parsedBody) {
329
336
  const directBodyObj = structuredClone(parsedBody);
330
- normalizeRequestBody(directBodyObj, false, isKilo, reqId);
331
337
  directBody = Buffer.from(JSON.stringify(directBodyObj));
332
338
  }
333
339
 
@@ -89,8 +89,9 @@ export function resolveRelayState(): RelayState {
89
89
 
90
90
  // In-memory global relay state
91
91
  let activeRelayState: RelayState = resolveRelayState();
92
+ // Monotonic counter to distribute primary relay across concurrent subagents
93
+ let roundRobinCounter = 0;
92
94
  let activeStatusUi: ExtensionUIContext | null = null;
93
-
94
95
  /**
95
96
  * Mtime of the on-disk state file at the moment we last read or wrote it.
96
97
  * Lets worker processes pick up relay-pool changes persisted by another
@@ -113,6 +114,11 @@ lastKnownStateMtimeMs = currentDiskStateMtimeMs();
113
114
  * Get active in-memory relay state
114
115
  */
115
116
  export function getActiveRelayState(): RelayState {
117
+ const currentMtime = currentDiskStateMtimeMs();
118
+ if (currentMtime > 0 && currentMtime > lastKnownStateMtimeMs) {
119
+ activeRelayState = resolveRelayState();
120
+ lastKnownStateMtimeMs = currentMtime;
121
+ }
116
122
  return activeRelayState;
117
123
  }
118
124
 
@@ -174,9 +180,13 @@ export function getOrderedRelayUrls(): string[] {
174
180
  if (activeIdx < 0) {
175
181
  activeIdx = 0;
176
182
  }
183
+ // Rotate starting point per-request to avoid thundering herd when many
184
+ // subagents hit the shared 127.0.0.1 daemon at once — each request
185
+ // tries a different primary relay, but still rolls seamlessly on 429.
186
+ const startIdx = (activeIdx + (roundRobinCounter++ % activeRelayState.relays.length)) % activeRelayState.relays.length;
177
187
  const ordered: string[] = [];
178
188
  for (let i = 0; i < activeRelayState.relays.length; i++) {
179
- const r = activeRelayState.relays[(activeIdx + i) % activeRelayState.relays.length];
189
+ const r = activeRelayState.relays[(startIdx + i) % activeRelayState.relays.length];
180
190
  if (r?.url?.trim()) {
181
191
  ordered.push(r.url.trim());
182
192
  }
package/src/relay.ts CHANGED
@@ -24,16 +24,11 @@ import {
24
24
  export function isRetriableStatus(status: number): boolean {
25
25
  return (
26
26
  status === 429 ||
27
+ status === 408 ||
28
+ status === 500 ||
27
29
  status === 502 ||
28
30
  status === 503 ||
29
31
  status === 504 ||
30
- status === 408 ||
31
- status === 404 ||
32
- status === 410 ||
33
- status === 402 ||
34
- status === 403 ||
35
- status === 500 ||
36
- status === 400 ||
37
32
  (status >= 520 && status <= 530)
38
33
  );
39
34
  }
package/src/normalizer.ts DELETED
@@ -1,173 +0,0 @@
1
- /**
2
- * Request payload normalization for pi-freeflow
3
- *
4
- * Normalizes tool choice, translates Anthropic thinking to OpenAI reasoning_effort,
5
- * applies per-model thinkingLevelMap translation, and enforces token clamping.
6
- */
7
-
8
- import { RELAY_MAX_TOKENS } from "./config.ts";
9
- import { isDebugEnabled, log } from "./logger.ts";
10
- import { MODEL_MAP } from "./models.ts";
11
- import type { ModelDef } from "./types.ts";
12
-
13
- /**
14
- * Normalizes an OpenAI-compatible / Anthropic request payload before forwarding to upstream.
15
- *
16
- * 1. Strips empty tools and normalizes tool_choice (none -> stripped, other non-auto -> auto for OpenCode).
17
- * 2. Translates Anthropic thinking ({ type: "enabled", budget_tokens }) to OpenAI reasoning_effort.
18
- * 3. Applies per-model thinkingLevelMap and reasoning effort normalization.
19
- * 4. Clamps token limits (min 16 for OpenCode, maxTokens from modelDef, RELAY_MAX_TOKENS for relays).
20
- */
21
- export function normalizeRequestBody(
22
- body: Record<string, unknown>,
23
- isRelay = false,
24
- isKilo = false,
25
- reqId?: string,
26
- ): Record<string, unknown> {
27
- const DBG = isDebugEnabled();
28
- const modelId = typeof body.model === "string" ? body.model : "";
29
- const modelDef = MODEL_MAP.get(modelId);
30
- const isResponsesApi = modelId === "muse-spark-1.2-contributor-free";
31
-
32
- if (DBG) {
33
- log(
34
- "debug",
35
- `normalize: incoming model=${modelId} kilo=${isKilo} relay=${isRelay}`,
36
- {
37
- reasoning_effort: body.reasoning_effort,
38
- reasoning: body.reasoning,
39
- thinking: (body as Record<string, unknown>).thinking,
40
- tool_choice: body.tool_choice,
41
- toolsLen: Array.isArray(body.tools) ? body.tools.length : undefined,
42
- },
43
- reqId,
44
- );
45
- }
46
-
47
- // 1. Tool choice & empty tools normalization (pi-ai compat: opencode only supports auto)
48
- if (Array.isArray(body.tools) && body.tools.length === 0) {
49
- delete body.tools;
50
- delete body.tool_choice;
51
- }
52
- if (body.tool_choice === "none") {
53
- delete body.tool_choice;
54
- delete body.tools;
55
- } else if (!isKilo && body.tool_choice && body.tool_choice !== "auto") {
56
- body.tool_choice = "auto";
57
- }
58
-
59
- // 1b. Anthropic thinking -> OpenAI reasoning_effort auto-translate
60
- // Pi sends anthropic `thinking: {type:"enabled",budget_tokens}` when provider is anthropic.
61
- // Our proxy is always openai-completions/responses upstream, so translate.
62
- // Ref: pi-ai api/anthropic-messages.js (thinking.type adaptive/enabled/disabled) -> api/openai-completions.js (reasoning_effort)
63
- const thinkingRaw = (body as Record<string, unknown>).thinking;
64
- if (thinkingRaw && typeof thinkingRaw === "object") {
65
- const th = thinkingRaw as Record<string, unknown>;
66
- if (th.type === "disabled") {
67
- delete (body as Record<string, unknown>).thinking;
68
- // Mark as off so downstream reasoning mapping can clear effort
69
- if (!body.reasoning_effort && !body.reasoning) {
70
- body.reasoning_effort = "off";
71
- }
72
- } else if (th.type === "enabled" || th.type === "adaptive") {
73
- delete (body as Record<string, unknown>).thinking;
74
- // Preserve budget as hint if no explicit effort set
75
- if (!body.reasoning_effort && typeof th.budget_tokens === "number") {
76
- const budget = th.budget_tokens as number;
77
- if (budget >= 8000) body.reasoning_effort = "xhigh";
78
- else if (budget >= 4000) body.reasoning_effort = "high";
79
- else if (budget >= 2000) body.reasoning_effort = "medium";
80
- else body.reasoning_effort = "low";
81
- }
82
- }
83
- }
84
-
85
- // 2. Reasoning normalization — per-model thinkingLevelMap aware
86
- // Ref: pi-ai api/openai-completions.js (compat.thinkingFormat branches) + api/openai-responses-shared.js
87
- const mapEffort = (rawEffort: string): string | null | undefined => {
88
- const key = rawEffort.toLowerCase() as keyof NonNullable<
89
- ModelDef["thinkingLevelMap"]
90
- >;
91
- let mapped: string | null | undefined;
92
- if (modelDef?.thinkingLevelMap && key in modelDef.thinkingLevelMap) {
93
- mapped = modelDef.thinkingLevelMap[key] as string | null;
94
- } else if (rawEffort === "xhigh" || rawEffort === "max") {
95
- mapped = isResponsesApi
96
- ? "xhigh"
97
- : modelId === "x-preview-f-free"
98
- ? "max"
99
- : "xhigh";
100
- } else if (rawEffort === "high" || rawEffort === "medium") {
101
- mapped = "high";
102
- } else if (rawEffort === "minimal") {
103
- mapped = "minimal";
104
- } else if (rawEffort === "none" || rawEffort === "off") {
105
- mapped = null;
106
- } else {
107
- mapped = "low";
108
- }
109
-
110
- if (isResponsesApi && mapped === "max") {
111
- mapped = "xhigh";
112
- }
113
- return mapped;
114
- };
115
- if (typeof body.reasoning_effort === "string") {
116
- const mapped = mapEffort(body.reasoning_effort);
117
- if (mapped === null || mapped === undefined) {
118
- delete body.reasoning_effort;
119
- } else {
120
- body.reasoning_effort = mapped;
121
- }
122
- }
123
- if (body.reasoning && typeof body.reasoning === "object") {
124
- const r = body.reasoning as Record<string, unknown>;
125
- if (r.effort === "none" || r.effort === "off") {
126
- delete r.effort;
127
- } else if (typeof r.effort === "string") {
128
- const mapped = mapEffort(r.effort);
129
- if (mapped === null || mapped === undefined) {
130
- delete r.effort;
131
- } else {
132
- r.effort = mapped;
133
- }
134
- }
135
- if (isResponsesApi && r.effort === "max") r.effort = "xhigh";
136
- }
137
-
138
- // 3. Max & Min tokens clamping (model-specific clamp + Vercel relay clamp)
139
- const modelMax = modelDef?.maxTokens ?? RELAY_MAX_TOKENS;
140
- const clampTokens = (val: number): number => {
141
- let clamped = val;
142
- if (!isKilo && clamped < 16) clamped = 16;
143
- if (clamped > modelMax) clamped = modelMax;
144
- if (isRelay && clamped > RELAY_MAX_TOKENS) clamped = RELAY_MAX_TOKENS;
145
- return clamped;
146
- };
147
-
148
- if (typeof body.max_tokens === "number") {
149
- body.max_tokens = clampTokens(body.max_tokens);
150
- }
151
- if (typeof body.maxTokens === "number") {
152
- body.maxTokens = clampTokens(body.maxTokens);
153
- }
154
- if (typeof body.max_output_tokens === "number") {
155
- body.max_output_tokens = clampTokens(body.max_output_tokens);
156
- }
157
-
158
- if (DBG) {
159
- log(
160
- "debug",
161
- `normalize: outgoing model=${modelId}`,
162
- {
163
- reasoning_effort: body.reasoning_effort,
164
- reasoning: body.reasoning,
165
- max_tokens: body.max_tokens,
166
- max_output_tokens: body.max_output_tokens,
167
- },
168
- reqId,
169
- );
170
- }
171
-
172
- return body;
173
- }