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 +45 -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 +88 -5
- package/src/probe.ts +7 -7
- package/src/proxy.ts +234 -144
- package/src/stream-pipe.ts +85 -1
- package/src/tool-args.ts +194 -52
- package/src/tool-translation.ts +179 -87
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.
|
|
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
|
}
|
|
@@ -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: "
|
|
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
|
|
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
|
-
|
|
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
|
|