pi-freeflow 1.36.0 → 1.36.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/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.36.3
4
+
5
+ ### Patch Changes
6
+
7
+ - Restore OpenCode free models following upstream endpoint and gate migration
8
+
9
+ OpenCode free models were failing with HTTP 403 FreeTierError after upstream deprecated the older endpoint and moved free inference traffic to the v1 inference gateway. Upstream also requires public authorization and non-empty schema declarations on compatibility tools.
10
+
11
+ Requests now route to the active endpoint with the required public credentials and schema declarations, restoring free model access across both direct connections and deployed relays.
12
+
13
+ ## 1.36.2
14
+
15
+ ### Patch Changes
16
+
17
+ - Auto-recover OpenCode Zen 403 FreeTierError: when upstream OpenCode Zen refuses a session with `403 FreeTierError` ("OpenCode's free tier can only be used from within OpenCode"), the local proxy now catches the gate error in-flight and transparently fails over to a healthy KiloCode fallback model on the same wire API, rather than bubbling the 403 back to the coding agent.
18
+ - Prune decommissioned `ling-3.1-flash-free` from the active catalog and add it to the exclusion list, locking the verified catalog to 36 models.
19
+ - Update OpenCode version fallback to 1.19.0.
20
+
21
+ ## 1.36.1
22
+
23
+ ### Patch Changes
24
+
25
+ - Fix tool calls being rejected as invalid JSON on every model, and tool names coming back under a name the host never registered.
26
+
27
+ - A tool whose arguments arrived carrying the tail of the previous call is now repaired for **every** tool, not only tools declaring a nested object or array. The most-used tool, the shell command tool, declares nothing but scalars, so it was never repaired at all — the call was rejected and the turn looped on the same broken payload.
28
+ - Buffered tool arguments are released when a stream ends without its normal ending event, instead of being dropped: a truncated stream used to deliver a call with no arguments at all.
29
+ - The tool-name restore now runs on the Cline and OVH lanes too. Those lanes send a renamed tool upstream but never took it back, so a client received a call for a tool it does not have.
30
+ - A caller that declares both file-pattern and semantic-search tools now keeps both. The two share a name across hosts but are different tools, and collapsing them handed the model one tool under the other's shape.
31
+ - A truncated number is refused rather than completed, and an inner object is never promoted over the wrapper that declares it.
32
+ - A non-streaming answer from the Kilo lane is repaired like a streamed one.
33
+ - The free tier is the only lane that renames a tool now. The other lanes carry
34
+ the caller's own names in both directions, so the host dispatches natively
35
+ instead of waiting for a name that never comes back.
36
+ - Tools that declare an optional value as "value or nothing" are repaired
37
+ again. Both hosts describe optional settings that way when a tool is declared
38
+ strictly, and reading that as "always send it" silently switched repair off
39
+ for the shell command tool — the one that matters most.
40
+ - A model answering an optional setting with the word "none" as text, instead of
41
+ leaving it empty, is now corrected rather than rejected. Affects roughly one
42
+ model in nine.
43
+ - A streamed Anthropic-format answer is now repaired and mapped like the other
44
+ two formats. It was forwarded untouched.
45
+ - A stream that ends without an answer says so, instead of reporting an empty
46
+ successful one.
47
+
3
48
  ## 1.36.0
4
49
 
5
50
  ### Minor Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.36.0",
4
+ "version": "1.36.3",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/catalog.ts CHANGED
@@ -105,6 +105,12 @@ export const DEAD_MODEL_IDS = new Set<string>([
105
105
  "cline-free/deepseek-v4.1-flash",
106
106
  "cline-free/kimi-k3",
107
107
  "cline-free/solar-pro4",
108
+ // ling-3.1-flash-free: removed from upstream free docs (2026-10-15).
109
+ "ling-3.1-flash-free",
110
+ // inclusionai/ling-3.1-flash: demoted to paid on Kilo (2026-10-15) — answers 401 PAID_MODEL_AUTH_REQUIRED.
111
+ "inclusionai/ling-3.1-flash",
112
+ // ovh-free/Mistral-7B-Instruct-v0.3: removed upstream by OVH (2026-10-15) — answers 404 no route matched.
113
+ "ovh-free/Mistral-7B-Instruct-v0.3",
108
114
  ]);
109
115
  /**
110
116
  * Free-tier allowlist for anything entering the picker via network or stale disk.
package/src/config.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Configuration and path resolution for pi-freeflow
3
3
  */
4
4
 
5
- import { createHash, randomBytes, randomUUID } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
6
  import { homedir } from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -21,7 +21,7 @@ try {
21
21
  export { PKG_VERSION };
22
22
 
23
23
  // ── Upstream endpoints ──────────────────────────────────────────────
24
- export const UPSTREAM_OPENCODE = "https://opencode.ai/zen";
24
+ export const UPSTREAM_OPENCODE = "https://opencode.ai/inference";
25
25
  export const KILO_CHAT_URL = "https://api.kilo.ai/api/gateway/chat/completions";
26
26
  export const KILO_RESPONSES_URL = "https://api.kilo.ai/api/gateway/responses";
27
27
  export const OPENCODE_API_URL = `${UPSTREAM_OPENCODE}/v1`;
@@ -74,7 +74,7 @@ export const PORT = resolvePort();
74
74
  // OPENCODE_VERSION_FLOOR pins the minimum; OPENCODE_VERSION_FALLBACK is the
75
75
  // last-known-good npm version used when the live lookup is stale/offline.
76
76
  export const OPENCODE_VERSION_FLOOR = "1.17.0";
77
- export const OPENCODE_VERSION_FALLBACK = "1.18.34";
77
+ export const OPENCODE_VERSION_FALLBACK = "1.19.0";
78
78
  /** Live npm lookup TTL: 6h — within one window the cached UA is reused. */
79
79
  export const OPENCODE_VERSION_TTL_MS = 6 * 60 * 60 * 1_000;
80
80
  /** Env override: exact `opencode/<version>` UA (or bare version) for tests/pins. */
@@ -250,14 +250,19 @@ export const OPENCODE_SESSION = createOpenCodeSessionId();
250
250
 
251
251
  export function opencodeHeaders(): Record<string, string> {
252
252
  const sessionId = OPENCODE_SESSION;
253
+ // Real OpenCode CLI (captured 2.0.28) does NOT send x-opencode-request on
254
+ // /inference/v1. Emitting msg_* there is treated as a non-OpenCode client and
255
+ // returns 403 FreeTierError — keep the helper for ID-shape tests, but omit
256
+ // the header on the wire.
253
257
  return {
258
+ Authorization: "Bearer public",
254
259
  "User-Agent": getOpenCodeUserAgent(),
255
260
  "x-opencode-client": OPENCODE_CLIENT,
256
261
  "x-opencode-project": OPENCODE_PROJECT,
257
262
  "x-opencode-session": sessionId,
258
263
  "x-opencode-session-id": sessionId,
259
264
  "x-session-affinity": sessionId,
260
- "x-opencode-request": createOpenCodeRequestId(),
265
+ "x-session-id": sessionId,
261
266
  };
262
267
  }
263
268
 
package/src/index.ts CHANGED
@@ -185,6 +185,7 @@ export default async function(pi: ExtensionAPI): Promise<void> {
185
185
  buildProviderConfig(models, actualPort),
186
186
  );
187
187
  };
188
+ registerCatalog(registeredCatalog);
188
189
  const ensureDaemon = async (ui?: ExtensionUIContext): Promise<void> => {
189
190
  const reattach = (port: number): void => {
190
191
  if (port !== actualPort) {
package/src/models.ts CHANGED
@@ -1,18 +1,18 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 37 verified free models (live-verified 2026-10-13):
5
- * - 11 OpenCode Zen models (2 Responses API + 9 Chat Completions)
6
- * - 15 KiloCode Keyless Gateway models (OpenRouter format)
4
+ * Defines the 34 verified free models (live-verified 2026-10-15):
5
+ * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
6
+ * - 14 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 2 Cline direct-only models (per-user pool)
8
- * - 9 OVHcloud AI Endpoints models (keyless, direct-only)
8
+ * - 8 OVHcloud AI Endpoints models (keyless, direct-only)
9
9
  */
10
10
 
11
11
  import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
12
12
 
13
13
  /**
14
14
  * OpenCode Zen free models verified against the live catalog and inference APIs.
15
- * Endpoint: https://opencode.ai/zen/v1
15
+ * Endpoint: https://opencode.ai/inference/v1
16
16
  */
17
17
  export const OPENCODE_MODELS: ModelDef[] = [
18
18
  {
@@ -200,23 +200,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
200
200
  max: null,
201
201
  },
202
202
  },
203
- {
204
- id: "ling-3.1-flash-free",
205
- name: "Ling 3.1 Flash Free [OpenCode]",
206
- reasoning: true,
207
- contextWindow: 262_144,
208
- maxTokens: 32_768,
209
- input: ["text"],
210
- thinkingLevelMap: {
211
- off: null,
212
- minimal: "minimal",
213
- low: "low",
214
- medium: "medium",
215
- high: "high",
216
- xhigh: "xhigh",
217
- max: "max",
218
- },
219
- },
220
203
  ];
221
204
 
222
205
  /**
@@ -386,17 +369,6 @@ export const KILO_MODELS: ModelDef[] = [
386
369
  thinkingFormat: "openrouter",
387
370
  thinkingLevelMap: KILO_REASONING_MAP,
388
371
  },
389
- {
390
- // Added 2026-10-07: live on Kilo free list (isFree:true, 0/0 pricing, 262K ctx)
391
- id: "inclusionai/ling-3.1-flash",
392
- name: "Ling 3.1 Flash [Kilo]",
393
- reasoning: true,
394
- contextWindow: 262_144,
395
- maxTokens: 32_768,
396
- input: ["text"],
397
- thinkingFormat: "openrouter",
398
- thinkingLevelMap: KILO_REASONING_MAP,
399
- },
400
372
  ];
401
373
 
402
374
  /**
@@ -569,14 +541,6 @@ export const OVH_MODELS: ModelDef[] = [
569
541
  maxTokens: 16_384,
570
542
  input: ["text", "image"],
571
543
  },
572
- {
573
- id: "ovh-free/Mistral-7B-Instruct-v0.3",
574
- name: "Mistral 7B Instruct v0.3 [OVH]",
575
- reasoning: false,
576
- contextWindow: 65_536,
577
- maxTokens: 65_536,
578
- input: ["text"],
579
- },
580
544
  {
581
545
  id: "ovh-free/Meta-Llama-3_3-70B-Instruct",
582
546
  name: "Meta Llama 3.3 70B Instruct [OVH]",
@@ -625,8 +589,6 @@ export const MODEL_ALIASES: Record<string, string> = {
625
589
  "longcat-2.5-preview": "longcat-2.5-preview-free",
626
590
  "longcat": "longcat-2.5-preview-free",
627
591
  "step-5-preview": "step-5-preview-free",
628
- "ling-3.1-flash": "ling-3.1-flash-free",
629
- "ling-3.1-flash:kilo": "inclusionai/ling-3.1-flash",
630
592
  // provider-prefixed short aliases (slash-normalized)
631
593
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
632
594
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
@@ -640,7 +602,6 @@ export const MODEL_ALIASES: Record<string, string> = {
640
602
  "qwen3.6-27b": "ovh-free/Qwen3.6-27B",
641
603
  "qwen3.8-27b": "ovh-free/Qwen3.8-27B",
642
604
  "mistral-small-3.2": "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
643
- "mistral-7b-v0.3": "ovh-free/Mistral-7B-Instruct-v0.3",
644
605
  "llama-3.3-70b": "ovh-free/Meta-Llama-3_3-70B-Instruct",
645
606
  };
646
607
 
@@ -26,6 +26,7 @@
26
26
  * per-request map (never module-global).
27
27
  */
28
28
  import {
29
+ COMPAT_FINGERPRINT_SCHEMAS,
29
30
  COMPAT_TOOL_DESCRIPTION,
30
31
  OMP_FINGERPRINT_DEFS,
31
32
  OPENCODE_FINGERPRINT_TOOLS,
@@ -105,7 +106,7 @@ export function isPlaceholderToolName(name: unknown): boolean {
105
106
  * functions below.
106
107
  */
107
108
  function fingerprintFill(name: FingerprintToolName, omp: boolean): { description: string; schema: Record<string, unknown> } {
108
- if (!omp || name === "edit") return { description: COMPAT_TOOL_DESCRIPTION, schema: { type: "object", properties: {} } };
109
+ if (!omp || name === "edit") return { description: COMPAT_TOOL_DESCRIPTION, schema: COMPAT_FINGERPRINT_SCHEMAS[name] };
109
110
  const def = OMP_FINGERPRINT_DEFS[name];
110
111
  return { description: def.description, schema: { ...(def.parameters as Record<string, unknown>) } };
111
112
  }
@@ -557,8 +558,11 @@ export function enforceOpencodeFingerprint(
557
558
  const injected = missing.filter((n) => !ompLike || n === "edit");
558
559
  const injectedReal = ompLike ? missing.filter((n) => n !== "edit") : [];
559
560
 
561
+ // Zen alone asks for a rename: its free-tier gate looks for the sextet name
562
+ // `glob`, so a Pi caller that sent `find` needs it renamed into that slot.
563
+ // Every other lane accepts the caller's own names untouched.
560
564
  if (Array.isArray(body.tools)) {
561
- body.tools = translateToolsForPath(body.tools, pathname);
565
+ body.tools = translateToolsForPath(body.tools, pathname, { forFingerprint: true });
562
566
  }
563
567
 
564
568
  const before = Array.isArray(body.tools) ? body.tools.length : 0;
@@ -881,10 +885,14 @@ export function sseToResponsesJson(
881
885
  }
882
886
  } catch { }
883
887
 
888
+ // Nothing in the stream carried a response object: the upstream turn ended
889
+ // without one. Report that honestly — a `completed` answer with zero output
890
+ // reads to the host as "the model chose to say nothing", which is a
891
+ // different (and wrong) claim than "the stream was cut".
884
892
  return {
885
893
  id: "resp_fallback",
886
894
  object: "response",
887
- status: "completed",
895
+ status: "incomplete",
888
896
  output: [],
889
897
  };
890
898
  }
@@ -895,8 +903,11 @@ export function sseToResponsesJson(
895
903
  * never see tool_use: stray placeholder input folds to text, matching the
896
904
  * SSE aggregator fallback below. Ids ride verbatim; `input` is repaired
897
905
  * against the caller's declared schema when the emitted shape cannot be right.
906
+ *
907
+ * Exported so the streaming cloak applies these identical rules to a `message`
908
+ * frame that arrives mid-stream, instead of forwarding it verbatim.
898
909
  */
899
- function cloakMessagesContent(
910
+ export function cloakMessagesContent(
900
911
  msg: unknown,
901
912
  callerHadTools: boolean,
902
913
  caseRestore?: CaseRestoreMap,
@@ -1089,7 +1100,79 @@ export function convertSseToJson(
1089
1100
  return JSON.stringify(sseToMessagesJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
1090
1101
  }
1091
1102
  return JSON.stringify(sseToChatCompletionJson(trimmed, callerHadTools, caseRestore, findGlob, injected, argSchemas));
1103
+ } catch (error) {
1104
+ // Every caller has already committed to `content-type: application/json`
1105
+ // by the time this runs, so handing back the raw SSE bytes would deliver a
1106
+ // parse error with no diagnosis. Say what happened, and keep the payload
1107
+ // reachable under `raw` so nothing is silently destroyed.
1108
+ return JSON.stringify({
1109
+ error: {
1110
+ message: "upstream stream could not be aggregated into a JSON answer",
1111
+ detail: String(error).slice(0, 300),
1112
+ raw: sseText.slice(0, 2000),
1113
+ },
1114
+ });
1115
+ }
1116
+ }
1117
+
1118
+ /**
1119
+ * Repair the tool calls inside an already-JSON answer body.
1120
+ *
1121
+ * A lane that passes the caller's `stream` through untouched (Kilo) answers a
1122
+ * non-streaming caller with a plain `chat.completion` / `response` object, not
1123
+ * SSE. The SSE aggregators above never see that body, so the malformed
1124
+ * arguments reached the host unrepaired on exactly the requests that asked for
1125
+ * no stream. Repairs each call in place and re-serializes only when something
1126
+ * actually changed, so a well-formed answer keeps its byte-identical body.
1127
+ * Returns the input string untouched when it is not JSON or has no tool calls.
1128
+ */
1129
+ export function repairJsonToolArguments(
1130
+ bodyText: string,
1131
+ pathname: string,
1132
+ argSchemas: ReadonlyMap<string, Record<string, unknown>> | undefined,
1133
+ ): string {
1134
+ if (argSchemas === undefined || argSchemas.size === 0 || !bodyText.trimStart().startsWith("{")) return bodyText;
1135
+ let parsed: unknown;
1136
+ try {
1137
+ parsed = JSON.parse(bodyText);
1092
1138
  } catch {
1093
- return sseText;
1139
+ return bodyText;
1140
+ }
1141
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return bodyText;
1142
+ const body = parsed as Record<string, unknown>;
1143
+ let changed = false;
1144
+ const repairCall = (name: string, args: string): string => {
1145
+ const repaired = repairToolArguments(name, args, argSchemas);
1146
+ if (repaired !== args) changed = true;
1147
+ return repaired;
1148
+ };
1149
+ if (pathname.endsWith("/responses")) {
1150
+ const output = body.output;
1151
+ if (!Array.isArray(output)) return bodyText;
1152
+ for (const item of output) {
1153
+ if (item === null || typeof item !== "object" || Array.isArray(item)) continue;
1154
+ const rec = item as Record<string, unknown>;
1155
+ if (rec.type !== "function_call" || typeof rec.name !== "string" || typeof rec.arguments !== "string") continue;
1156
+ rec.arguments = repairCall(rec.name, rec.arguments);
1157
+ }
1158
+ } else {
1159
+ const choices = body.choices;
1160
+ if (!Array.isArray(choices)) return bodyText;
1161
+ for (const choice of choices) {
1162
+ if (choice === null || typeof choice !== "object" || Array.isArray(choice)) continue;
1163
+ const message = (choice as Record<string, unknown>).message;
1164
+ if (message === null || typeof message !== "object" || Array.isArray(message)) continue;
1165
+ const calls = (message as Record<string, unknown>).tool_calls;
1166
+ if (!Array.isArray(calls)) continue;
1167
+ for (const call of calls) {
1168
+ if (call === null || typeof call !== "object" || Array.isArray(call)) continue;
1169
+ const fn = (call as Record<string, unknown>).function;
1170
+ if (fn === null || typeof fn !== "object" || Array.isArray(fn)) continue;
1171
+ const fnRec = fn as Record<string, unknown>;
1172
+ if (typeof fnRec.name !== "string" || typeof fnRec.arguments !== "string") continue;
1173
+ fnRec.arguments = repairCall(fnRec.name, fnRec.arguments);
1174
+ }
1175
+ }
1094
1176
  }
1177
+ return changed ? JSON.stringify(body) : bodyText;
1095
1178
  }
package/src/probe.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { opencodeHeaders } from "./config.ts";
1
+ import { OPENCODE_API_URL, opencodeHeaders } from "./config.ts";
2
2
  import { ensureChatFingerprintTools } from "./opencode-fingerprint.ts";
3
3
 
4
4
  /**
@@ -17,7 +17,7 @@ export interface RelayProbeOptions {
17
17
  /**
18
18
  * Probe mode:
19
19
  * - "models" (default): GET /v1/models (reachability probe)
20
- * - "chat": POST /zen/v1/chat/completions (verifies free tier inference & spoofing)
20
+ * - "chat": POST /inference/v1/chat/completions (verifies free tier inference & spoofing)
21
21
  */
22
22
  endpoint?: "models" | "chat";
23
23
  /** Model to use for chat probe (defaults to "nemotron-3.5-lightning-free") */
@@ -76,7 +76,7 @@ async function probeRelayOnce(
76
76
 
77
77
  if (isDirectOpenCode) {
78
78
  if (isChat) {
79
- fetchUrl = "https://opencode.ai/zen/v1/chat/completions";
79
+ fetchUrl = `${OPENCODE_API_URL}/chat/completions`;
80
80
  method = "POST";
81
81
  headers["content-type"] = "application/json";
82
82
  const probePayload: Record<string, unknown> = {
@@ -88,15 +88,15 @@ async function probeRelayOnce(
88
88
  ensureChatFingerprintTools(probePayload);
89
89
  body = JSON.stringify(probePayload);
90
90
  } else {
91
- fetchUrl = "https://opencode.ai/zen/v1/models";
91
+ fetchUrl = `${OPENCODE_API_URL}/models`;
92
92
  }
93
93
  } else {
94
94
  if (isChat) {
95
- fetchUrl = `${cleanUrl}/zen/v1/chat/completions`;
95
+ fetchUrl = `${cleanUrl}/inference/v1/chat/completions`;
96
96
  method = "POST";
97
97
  headers["content-type"] = "application/json";
98
98
  headers["x-relay-target"] = "https://opencode.ai";
99
- headers["x-relay-path"] = "/zen/v1/chat/completions";
99
+ headers["x-relay-path"] = "/inference/v1/chat/completions";
100
100
  const probePayload: Record<string, unknown> = {
101
101
  model: opts?.model || "nemotron-3.5-lightning-free",
102
102
  max_tokens: 1,
@@ -108,7 +108,7 @@ async function probeRelayOnce(
108
108
  } else {
109
109
  fetchUrl = `${cleanUrl}/v1/models`;
110
110
  headers["x-relay-target"] = "https://opencode.ai";
111
- headers["x-relay-path"] = "/zen/v1/models";
111
+ headers["x-relay-path"] = "/inference/v1/models";
112
112
  }
113
113
  }
114
114