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 +18 -0
- package/package.json +1 -1
- package/src/catalog.ts +6 -0
- package/src/config.ts +9 -4
- package/src/index.ts +1 -0
- package/src/models.ts +5 -44
- package/src/opencode-fingerprint.ts +2 -1
- package/src/probe.ts +7 -7
- package/src/proxy.ts +206 -131
- package/src/tool-translation.ts +79 -3
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.
|
|
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
|
|
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/
|
|
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.
|
|
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-
|
|
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
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
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
|
-
* -
|
|
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/
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
91
|
+
fetchUrl = `${OPENCODE_API_URL}/models`;
|
|
92
92
|
}
|
|
93
93
|
} else {
|
|
94
94
|
if (isChat) {
|
|
95
|
-
fetchUrl = `${cleanUrl}/
|
|
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"] = "/
|
|
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"] = "/
|
|
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(
|
|
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
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
|
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);
|
package/src/tool-translation.ts
CHANGED
|
@@ -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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
837
|
+
out += `event: response.completed\ndata: ${JSON.stringify(done)}\n\n`;
|
|
838
|
+
out += `data: [DONE]\n\n`;
|
|
839
|
+
return out;
|
|
764
840
|
}
|