pi-freeflow 1.36.1 → 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,23 @@
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
+
3
21
  ## 1.36.1
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.36.1",
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
  }
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
 
package/src/proxy.ts CHANGED
@@ -80,6 +80,7 @@ import {
80
80
  import { pipeUpstreamStream, type StreamCloakOptions } from "./stream-pipe.ts";
81
81
  import {
82
82
  decideZenRoute,
83
+ isFreeTierGate,
83
84
  isUnprovenSession,
84
85
  isUpstreamGated,
85
86
  pickFailoverModel,
@@ -563,6 +564,172 @@ async function handleOvhRequest(opts: {
563
564
  }
564
565
  }
565
566
 
567
+ /**
568
+ * Keyless gateway handling for Kilo models (both direct Kilo models and
569
+ * Zen sessions failed over to Kilo). Serves chat completions and responses
570
+ * natively, repairs tool arguments, and recovers from 400 token overflows.
571
+ */
572
+ async function handleKiloRequest(opts: {
573
+ parsedBody: Record<string, unknown>;
574
+ pathname: string;
575
+ req: http.IncomingMessage;
576
+ res: http.ServerResponse;
577
+ reqId: string;
578
+ clientRequestedStream: boolean;
579
+ sessionKey?: string | null;
580
+ }): Promise<void> {
581
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
582
+ const isStream = clientRequestedStream;
583
+ const kiloController = new AbortController();
584
+ const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
585
+ const kiloTimeoutId = setTimeout(
586
+ () => kiloController.abort(upstreamTimeoutError()),
587
+ UPSTREAM_HEADER_TIMEOUT_MS,
588
+ );
589
+ const abortKiloOnClientGone = () => {
590
+ if (!res.writableEnded) kiloController.abort();
591
+ };
592
+ res.once("close", abortKiloOnClientGone);
593
+ req.once("error", abortKiloOnClientGone);
594
+ let response: Response;
595
+ const kiloUrl = pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
596
+ try {
597
+ response = await relayFetch(
598
+ kiloUrl,
599
+ {
600
+ method: "POST",
601
+ headers: {
602
+ "Content-Type": "application/json",
603
+ },
604
+ body: JSON.stringify(parsedBody),
605
+ signal: kiloController.signal,
606
+ },
607
+ reqId,
608
+ );
609
+ if (response.status === 400 && !res.writableEnded) {
610
+ const retryBody = { ...parsedBody };
611
+ let changed = false;
612
+ if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
613
+ retryBody.max_output_tokens = 4096;
614
+ changed = true;
615
+ }
616
+ if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
617
+ retryBody.max_tokens = 4096;
618
+ changed = true;
619
+ }
620
+ if (retryBody.model === "dots-studio/dots-3-note-preview:free") {
621
+ retryBody.model = "nvidia/nemotron-3.5-lightning:free";
622
+ changed = true;
623
+ }
624
+ if (changed) {
625
+ log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
626
+ try {
627
+ const retryRes = await relayFetch(
628
+ kiloUrl,
629
+ {
630
+ method: "POST",
631
+ headers: {
632
+ "Content-Type": "application/json",
633
+ },
634
+ body: JSON.stringify(retryBody),
635
+ signal: kiloController.signal,
636
+ },
637
+ reqId,
638
+ );
639
+ if (retryRes.ok) {
640
+ log("info", `kilo 400 recovery succeeded with model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
641
+ response = retryRes;
642
+ }
643
+ } catch { }
644
+ }
645
+ }
646
+ } finally {
647
+ clearTimeout(kiloTimeoutId);
648
+ res.off("close", abortKiloOnClientGone);
649
+ req.off("error", abortKiloOnClientGone);
650
+ }
651
+ if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
652
+
653
+ if (isStream && response.ok && response.body) {
654
+ const ct = response.headers.get("content-type") || "text/event-stream";
655
+ res.writeHead(response.status, {
656
+ "content-type": ct,
657
+ "cache-control": "no-cache, no-transform",
658
+ connection: "keep-alive",
659
+ "x-accel-buffering": "no",
660
+ });
661
+ pipeUpstreamStream(
662
+ Readable.fromWeb(
663
+ response.body as unknown as WebReadableStream,
664
+ ),
665
+ res,
666
+ req,
667
+ reqId,
668
+ undefined,
669
+ kiloArgSchemas.size > 0
670
+ ? {
671
+ callerHadTools: true,
672
+ argSchemas: kiloArgSchemas,
673
+ pathname: pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
674
+ }
675
+ : undefined,
676
+ );
677
+ } else {
678
+ let rawText = await response.text();
679
+ if (response.ok && kiloArgSchemas.size > 0) {
680
+ rawText = convertSseToJson(rawText, pathname, true, undefined, undefined, undefined, kiloArgSchemas);
681
+ rawText = repairJsonToolArguments(rawText, pathname, kiloArgSchemas);
682
+ }
683
+ const data = withRateLimitHint(response.status, rawText);
684
+ const ct = response.headers.get("content-type") || "application/json";
685
+ res.writeHead(response.status, { "content-type": ct });
686
+ res.end(data);
687
+ }
688
+ }
689
+
690
+ /**
691
+ * In-flight auto-recovery for Zen 403 FreeTierError: immediately fail over
692
+ * the current request to a healthy Kilo model instead of surfacing 403 to the caller.
693
+ * Returns true when recovery was dispatched; false when failover is impossible.
694
+ */
695
+ async function tryZenGateAutoFailover(opts: {
696
+ parsedBody: Record<string, unknown> | null;
697
+ pathname: string;
698
+ req: http.IncomingMessage;
699
+ res: http.ServerResponse;
700
+ reqId: string;
701
+ clientRequestedStream: boolean;
702
+ sessionKey?: string | null;
703
+ }): Promise<boolean> {
704
+ const { parsedBody, pathname, req, res, reqId, clientRequestedStream, sessionKey } = opts;
705
+ if (!parsedBody || res.headersSent || res.writableEnded) return false;
706
+ const fallbackModel = pickFailoverModel(parsedBody.model);
707
+ if (!fallbackModel) return false;
708
+ try {
709
+ const fromModel = String(parsedBody.model ?? "?");
710
+ parsedBody.model = fallbackModel;
711
+ if (pathname.endsWith("/responses")) {
712
+ prepareResponsesFailoverBody(parsedBody);
713
+ } else {
714
+ prepareChatFailoverBody(parsedBody);
715
+ }
716
+ log("warn", `zen 403 FreeTierError auto-fixed: failing over ${fromModel} -> ${fallbackModel}`, { model: fromModel, failover: fallbackModel }, reqId);
717
+ await handleKiloRequest({
718
+ parsedBody,
719
+ pathname,
720
+ req,
721
+ res,
722
+ reqId,
723
+ clientRequestedStream,
724
+ sessionKey,
725
+ });
726
+ return true;
727
+ } catch (err) {
728
+ log("warn", `zen 403 FreeTierError auto-failover failed: ${String(err)}`, { error: String(err) }, reqId);
729
+ return false;
730
+ }
731
+ }
732
+
566
733
  /**
567
734
  * Relay eligibility: only free-catalog models ride relay egress. Returns true
568
735
  * when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
@@ -690,11 +857,11 @@ export function sanitizeHeaders(
690
857
  sanitized.connection = "keep-alive";
691
858
  return sanitized;
692
859
  }
693
- export async function isProxyAlive(port: number): Promise<boolean> {
860
+ export async function isProxyAlive(port: number, timeoutMs = 600): Promise<boolean> {
694
861
  if (!Number.isInteger(port) || port < 1 || port > 65535) return false;
695
862
  try {
696
863
  const res = await fetch(`http://${HOST}:${port}/v1/models`, {
697
- signal: AbortSignal.timeout(1500),
864
+ signal: AbortSignal.timeout(timeoutMs),
698
865
  });
699
866
  const ct = res.headers.get("content-type") || "";
700
867
  return res.ok && ct.includes("application/json");
@@ -1277,133 +1444,15 @@ export function startProxy(
1277
1444
  // token: the proxy only passes the body and maps the answer.
1278
1445
  await handleClineRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
1279
1446
  } else if (isKilo && parsedBody) {
1280
- // Header-wait timeout + client-disconnect abort: once headers
1281
- // arrive the timer is cleared so a long stream is not killed at
1282
- // the timeout ceiling; the stream phase is owned by
1283
- // pipeUpstreamStream and its close handling.
1284
- const kiloController = new AbortController();
1285
- // Kilo bodies ride verbatim, but its models malform arguments the same
1286
- // way: capture the caller's schemas so the reply can be repaired too.
1287
- const kiloArgSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
1288
- const kiloTimeoutId = setTimeout(
1289
- () => kiloController.abort(upstreamTimeoutError()),
1290
- UPSTREAM_HEADER_TIMEOUT_MS,
1291
- );
1292
- const abortKiloOnClientGone = () => {
1293
- if (!res.writableEnded) kiloController.abort();
1294
- };
1295
- res.once("close", abortKiloOnClientGone);
1296
- req.once("error", abortKiloOnClientGone);
1297
- let response: Response;
1298
- // Kilo serves both wire APIs natively: responses-shaped bodies go to
1299
- // its responses endpoint, chat bodies to chat completions. Sending a
1300
- // responses body to the chat URL (or vice versa) would 400.
1301
- const kiloUrl = target.pathname.endsWith("/responses") ? KILO_RESPONSES_URL : KILO_CHAT_URL;
1302
- try {
1303
- response = await relayFetch(
1304
- kiloUrl,
1305
- {
1306
- method: "POST",
1307
- headers: {
1308
- // Keyless, like Zen: Kilo's gateway rejects a placeholder bearer
1309
- // (live 2026-09-22 — "Bearer kilo-free" answers 401 INVALID_TOKEN,
1310
- // omitting Authorization answers 200 for every free catalog model).
1311
- "Content-Type": "application/json",
1312
- },
1313
- body: JSON.stringify(parsedBody),
1314
- signal: kiloController.signal,
1315
- },
1316
- reqId,
1317
- );
1318
- // Kilo 400 recovery: if upstream Kilo rejected the body with 400 (e.g.
1319
- // prompt + max_output_tokens exceeded model's operational context limit),
1320
- // retry once with clamped max tokens and fallback to a 1M model.
1321
- if (response.status === 400 && !res.writableEnded) {
1322
- const retryBody = { ...parsedBody };
1323
- let changed = false;
1324
- if (typeof retryBody.max_output_tokens === "number" && retryBody.max_output_tokens > 4096) {
1325
- retryBody.max_output_tokens = 4096;
1326
- changed = true;
1327
- }
1328
- if (typeof retryBody.max_tokens === "number" && retryBody.max_tokens > 4096) {
1329
- retryBody.max_tokens = 4096;
1330
- changed = true;
1331
- }
1332
- if (retryBody.model === "dots-studio/dots-3-note-preview:free") {
1333
- retryBody.model = "nvidia/nemotron-3.5-lightning:free";
1334
- changed = true;
1335
- }
1336
- if (changed) {
1337
- log("warn", `kilo 400 for model ${String(parsedBody.model)} — retrying with clamped tokens`, { model: parsedBody.model }, reqId);
1338
- try {
1339
- const retryRes = await relayFetch(
1340
- kiloUrl,
1341
- {
1342
- method: "POST",
1343
- headers: {
1344
- "Content-Type": "application/json",
1345
- },
1346
- body: JSON.stringify(retryBody),
1347
- signal: kiloController.signal,
1348
- },
1349
- reqId,
1350
- );
1351
- if (retryRes.ok) {
1352
- log("info", `kilo 400 recovery succeeded with model ${String(retryBody.model)}`, { model: retryBody.model }, reqId);
1353
- response = retryRes;
1354
- }
1355
- } catch { }
1356
- }
1357
- }
1358
- } finally {
1359
- clearTimeout(kiloTimeoutId);
1360
- res.off("close", abortKiloOnClientGone);
1361
- req.off("error", abortKiloOnClientGone);
1362
- }
1363
- if (response.ok) recordUpstreamSuccess("kilo", { sessionKey });
1364
-
1365
- if (isStream && response.ok && response.body) {
1366
- const ct =
1367
- response.headers.get("content-type") || "text/event-stream";
1368
- res.writeHead(response.status, {
1369
- "content-type": ct,
1370
- "cache-control": "no-cache, no-transform",
1371
- connection: "keep-alive",
1372
- "x-accel-buffering": "no",
1373
- });
1374
- // Kilo is fetched directly (not via the relay pool), so pass undefined as
1375
- // the issuer: attributing kilo-side stream failures to an unrelated
1376
- // opencode relay would mark a healthy relay as failed.
1377
- pipeUpstreamStream(
1378
- Readable.fromWeb(
1379
- response.body as unknown as WebReadableStream,
1380
- ),
1381
- res,
1382
- req,
1383
- reqId,
1384
- undefined,
1385
- kiloArgSchemas.size > 0
1386
- ? {
1387
- callerHadTools: true,
1388
- argSchemas: kiloArgSchemas,
1389
- pathname: target.pathname.endsWith("/responses") ? "/v1/responses" : "/v1/chat/completions",
1390
- }
1391
- : undefined,
1392
- );
1393
- } else {
1394
- let rawText = await response.text();
1395
- if (response.ok && kiloArgSchemas.size > 0) {
1396
- // Kilo passes `stream` through verbatim, so a non-streaming caller gets
1397
- // a plain JSON body: the SSE aggregator is a no-op on it, and the JSON
1398
- // repair is what actually applies here.
1399
- rawText = convertSseToJson(rawText, target.pathname, true, undefined, undefined, undefined, kiloArgSchemas);
1400
- rawText = repairJsonToolArguments(rawText, target.pathname, kiloArgSchemas);
1401
- }
1402
- const data = withRateLimitHint(response.status, rawText);
1403
- const ct = response.headers.get("content-type") || "application/json";
1404
- res.writeHead(response.status, { "content-type": ct });
1405
- res.end(data);
1406
- }
1447
+ await handleKiloRequest({
1448
+ parsedBody,
1449
+ pathname: target.pathname,
1450
+ req,
1451
+ res,
1452
+ reqId,
1453
+ clientRequestedStream,
1454
+ sessionKey,
1455
+ });
1407
1456
  } else if (isOvh && parsedBody) {
1408
1457
  // OVH serves chat upstream only (see handleOvhRequest): direct, keyless,
1409
1458
  // no fingerprint, and a responses-path body translated to chat and back.
@@ -1666,6 +1715,18 @@ export function startProxy(
1666
1715
  log("warn", `upstream ${response.status} for model ${String((parsedBody as Record<string, unknown> | null)?.model ?? "?")} via ${servedRelay === "direct" ? "direct upstream" : `relay ${servedRelay}`}`, { status: response.status, model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: servedRelay }, reqId);
1667
1716
  }
1668
1717
  let rawText = await response.text();
1718
+ if (response.status === 403 && isFreeTierGate(403, rawText)) {
1719
+ const recovered = await tryZenGateAutoFailover({
1720
+ parsedBody: parsedBody as Record<string, unknown> | null,
1721
+ pathname: target.pathname,
1722
+ req,
1723
+ res,
1724
+ reqId,
1725
+ clientRequestedStream,
1726
+ sessionKey,
1727
+ });
1728
+ if (recovered) return;
1729
+ }
1669
1730
  let ct =
1670
1731
  response.headers.get("content-type") ||
1671
1732
  "application/json";
@@ -1824,14 +1885,28 @@ export function startProxy(
1824
1885
  // Gated upstream: a 403 free-tier gate is a final answer, not a
1825
1886
  // stream — buffer it and attach the recovery hint instead of
1826
1887
  // piping it through as a stream body.
1888
+ // Gated upstream: a 403 free-tier gate is auto-recovered via Kilo failover.
1889
+ // If failover is impossible, buffer it and attach the recovery hint.
1827
1890
  if (upstreamRes.status === 403) {
1828
- const data = withFreeTierHint(403, await upstreamRes.text());
1891
+ const rawText403 = await upstreamRes.text();
1892
+ if (isFreeTierGate(403, rawText403)) {
1893
+ const recovered = await tryZenGateAutoFailover({
1894
+ parsedBody: parsedBody as Record<string, unknown> | null,
1895
+ pathname: target.pathname,
1896
+ req,
1897
+ res,
1898
+ reqId,
1899
+ clientRequestedStream,
1900
+ sessionKey,
1901
+ });
1902
+ if (recovered) return;
1903
+ }
1904
+ const data = withFreeTierHint(403, rawText403);
1829
1905
  const ct403 = upstreamRes.headers.get("content-type") || "application/json";
1830
1906
  res.writeHead(403, { "content-type": ct403 });
1831
1907
  res.end(data);
1832
1908
  return;
1833
1909
  }
1834
-
1835
1910
  const outHeaders: Record<string, string> = {};
1836
1911
  for (const h of ["content-type", "cache-control", "x-request-id"] as const) {
1837
1912
  const v = upstreamRes.headers.get(h);
@@ -190,6 +190,39 @@ export const OMP_FINGERPRINT_DEFS: Record<Exclude<FingerprintToolName, "edit">,
190
190
  },
191
191
  };
192
192
 
193
+ /** Minimal valid schemas for injected compat tools to satisfy upstream inference gate. */
194
+ export const COMPAT_FINGERPRINT_SCHEMAS: Record<FingerprintToolName, Record<string, unknown>> = {
195
+ bash: {
196
+ type: "object",
197
+ properties: { command: { type: "string", description: "Shell command to run." } },
198
+ required: ["command"],
199
+ },
200
+ read: {
201
+ type: "object",
202
+ properties: { path: { type: "string", description: "File path to read." } },
203
+ required: ["path"],
204
+ },
205
+ glob: {
206
+ type: "object",
207
+ properties: { path: { type: "string", description: "Search path." } },
208
+ },
209
+ grep: {
210
+ type: "object",
211
+ properties: { pattern: { type: "string", description: "Search pattern." } },
212
+ required: ["pattern"],
213
+ },
214
+ edit: {
215
+ type: "object",
216
+ properties: { path: { type: "string", description: "File path to edit." } },
217
+ required: ["path"],
218
+ },
219
+ write: {
220
+ type: "object",
221
+ properties: { path: { type: "string", description: "File path to write." } },
222
+ required: ["path"],
223
+ },
224
+ };
225
+
193
226
  /** Caller tool names that only an OMP host sends (never Pi). */
194
227
  export const OMP_CALLER_MARKERS = ["ask", "task", "todo", "hub", "lsp"] as const;
195
228
 
@@ -757,8 +790,51 @@ export function chatResponsesJsonFromChatCompletion(chat: Record<string, unknown
757
790
  */
758
791
  export function chatResponsesSseFromChatCompletion(resp: Record<string, unknown>): string {
759
792
  const head = { type: "response.created", response: { id: resp.id, object: "response", model: resp.model } };
793
+ let out = `event: response.created\ndata: ${JSON.stringify(head)}\n\n`;
794
+ const output = resp.output;
795
+ if (Array.isArray(output)) {
796
+ for (let idx = 0; idx < output.length; idx++) {
797
+ const item = output[idx] as Record<string, unknown>;
798
+ if (item && item.type === "message" && Array.isArray(item.content)) {
799
+ for (const part of item.content) {
800
+ const p = part as Record<string, unknown>;
801
+ if (p && p.type === "output_text" && typeof p.text === "string" && p.text.length > 0) {
802
+ out += `event: response.output_text.delta\ndata: ${JSON.stringify({
803
+ type: "response.output_text.delta",
804
+ output_index: idx,
805
+ delta: p.text,
806
+ })}\n\n`;
807
+ }
808
+ }
809
+ } else if (item && item.type === "function_call") {
810
+ out += `event: response.output_item.added\ndata: ${JSON.stringify({
811
+ type: "response.output_item.added",
812
+ output_index: idx,
813
+ item: {
814
+ type: "function_call",
815
+ id: item.id,
816
+ call_id: item.call_id,
817
+ name: item.name,
818
+ arguments: "",
819
+ },
820
+ })}\n\n`;
821
+ if (typeof item.arguments === "string" && item.arguments.length > 0) {
822
+ out += `event: response.function_call_arguments.delta\ndata: ${JSON.stringify({
823
+ type: "response.function_call_arguments.delta",
824
+ output_index: idx,
825
+ delta: item.arguments,
826
+ })}\n\n`;
827
+ }
828
+ out += `event: response.output_item.done\ndata: ${JSON.stringify({
829
+ type: "response.output_item.done",
830
+ output_index: idx,
831
+ item,
832
+ })}\n\n`;
833
+ }
834
+ }
835
+ }
760
836
  const done = { type: "response.completed", response: resp };
761
- return `event: response.created\ndata: ${JSON.stringify(head)}\n\n` +
762
- `event: response.completed\ndata: ${JSON.stringify(done)}\n\n` +
763
- `data: [DONE]\n\n`;
837
+ out += `event: response.completed\ndata: ${JSON.stringify(done)}\n\n`;
838
+ out += `data: [DONE]\n\n`;
839
+ return out;
764
840
  }