pi-freeflow 1.44.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +11 -13
- package/package.json +1 -1
- package/src/catalog.ts +38 -14
- package/src/client-recovery.ts +238 -0
- package/src/client-staging.ts +316 -0
- package/src/client.ts +117 -472
- package/src/cline-accounts.ts +26 -1223
- package/src/cline-device-auth.ts +5 -3
- package/src/cline-pool.ts +691 -0
- package/src/cline-roll.ts +642 -0
- package/src/commands-cline.ts +283 -0
- package/src/commands-relay.ts +808 -0
- package/src/commands-startup.ts +264 -0
- package/src/commands.ts +119 -1202
- package/src/config-paths.ts +254 -0
- package/src/config.ts +6 -687
- package/src/daemon.ts +16 -1
- package/src/deploy.ts +3 -1
- package/src/fingerprint-env.ts +334 -0
- package/src/fingerprint-tools.ts +348 -0
- package/src/index.ts +10 -6
- package/src/lane-translation.ts +263 -0
- package/src/models.ts +70 -112
- package/src/opencode-fingerprint.ts +29 -1310
- package/src/opencode-identity.ts +464 -0
- package/src/proxy-lanes.ts +1125 -0
- package/src/proxy-ports.ts +317 -0
- package/src/proxy.ts +74 -1385
- package/src/relay-codec.ts +365 -0
- package/src/relay-state.ts +10 -367
- package/src/relay.ts +78 -31
- package/src/sse-aggregator.ts +659 -0
- package/src/stream-cloak.ts +882 -0
- package/src/stream-pipe.ts +22 -871
- package/src/system-ca-fetch.ts +15 -4
- package/src/token.ts +7 -2
- package/src/tool-args.ts +10 -3
- package/src/tool-translation.ts +1 -236
- package/src/toolcall-audit.ts +20 -7
- package/src/update-checker.ts +17 -1
- package/src/update-core.ts +5 -2
- package/src/updater.ts +8 -3
- package/src/upstream-health.ts +22 -2
- package/src/zen-sidecar.ts +1 -0
- package/src/zen-transport.ts +48 -18
package/src/daemon.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import fs from "node:fs";
|
|
13
14
|
import { fileURLToPath } from "node:url";
|
|
14
15
|
import {
|
|
15
16
|
DAEMON_GC_MS,
|
|
@@ -34,12 +35,26 @@ import {
|
|
|
34
35
|
import { getActiveRequests, startClineKeepAlive, startProxy } from "./proxy.ts";
|
|
35
36
|
import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
|
|
36
37
|
|
|
38
|
+
/** True when this module was run as the entry script (not imported by tests). */
|
|
39
|
+
function safeCanonicalPath(p: string): string {
|
|
40
|
+
try {
|
|
41
|
+
return fs.realpathSync.native(path.resolve(p));
|
|
42
|
+
} catch {
|
|
43
|
+
return path.resolve(p);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
/** True when this module was run as the entry script (not imported by tests). */
|
|
38
48
|
export function isDaemonMain(): boolean {
|
|
39
49
|
try {
|
|
40
50
|
const entry = process.argv[1];
|
|
41
51
|
if (!entry) return false;
|
|
42
|
-
|
|
52
|
+
const canonicalEntry = safeCanonicalPath(entry);
|
|
53
|
+
const canonicalSelf = safeCanonicalPath(fileURLToPath(import.meta.url));
|
|
54
|
+
if (process.platform === "win32") {
|
|
55
|
+
return canonicalEntry.toLowerCase() === canonicalSelf.toLowerCase();
|
|
56
|
+
}
|
|
57
|
+
return canonicalEntry === canonicalSelf;
|
|
43
58
|
} catch {
|
|
44
59
|
return false;
|
|
45
60
|
}
|
package/src/deploy.ts
CHANGED
|
@@ -80,6 +80,7 @@ const isPrivateHostname = function(h) {
|
|
|
80
80
|
return false
|
|
81
81
|
}
|
|
82
82
|
if (host.includes(":")) {
|
|
83
|
+
if (host.startsWith("64:ff9b:") || host.startsWith("2002:")) return true
|
|
83
84
|
if (host.startsWith("fc") || host.startsWith("fd")) return true
|
|
84
85
|
if (/^fe[89ab]/.test(host)) return true
|
|
85
86
|
return false
|
|
@@ -121,7 +122,7 @@ async function relayHandler(req) {
|
|
|
121
122
|
DENY_HEADERS.forEach((h) => headers.delete(h));
|
|
122
123
|
headers.delete("x-relay-auth");
|
|
123
124
|
try {
|
|
124
|
-
const init = { method: req.method, headers };
|
|
125
|
+
const init = { method: req.method, headers, redirect: "manual" };
|
|
125
126
|
if (req.method !== "GET" && req.method !== "HEAD") { init.body = req.body; init.duplex = "half"; }
|
|
126
127
|
const response = await fetch(resolved.url, init);
|
|
127
128
|
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
@@ -242,6 +243,7 @@ export async function deployVercelRelay(
|
|
|
242
243
|
|
|
243
244
|
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
244
245
|
const depId = depJson.id || depJson.uid;
|
|
246
|
+
if (!depId) throw new Error("Vercel did not return a deployment id");
|
|
245
247
|
const projectId = depJson.projectId || name;
|
|
246
248
|
|
|
247
249
|
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Zen free-tier environment block and request body normalization.
|
|
3
|
+
*
|
|
4
|
+
* Enforces the `<env>` system-prompt block required by the OpenCode free-tier gate,
|
|
5
|
+
* normalizes cache keys, Responses bodies, and OpenAI-compat usage options.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
clampedEffortFor,
|
|
10
|
+
enforceAutoToolChoice,
|
|
11
|
+
sanitizeReplayItemNames,
|
|
12
|
+
stripCommentaryPhase,
|
|
13
|
+
} from "./fingerprint-tools.ts";
|
|
14
|
+
|
|
15
|
+
/** Complete `<env>` … `</env>` pair; non-greedy so two blocks stay two matches. */
|
|
16
|
+
export const ENV_PAIR_RE = /<env>[\s\S]*?<\/env>/g;
|
|
17
|
+
/** A tag whose partner is missing or was stripped: it can shadow the real pair. */
|
|
18
|
+
export const ENV_TAG_RE = /<\/?env>/g;
|
|
19
|
+
/** The gate's load-bearing key. Any value passes; a different key does not. */
|
|
20
|
+
export const WORKING_DIRECTORY_RE = /Working directory:[ \t]*([^\n<]*)/;
|
|
21
|
+
/** The gate reads the key, not a usable path: this block is a fingerprint. */
|
|
22
|
+
export const FALLBACK_WORKING_DIRECTORY = "(current project)";
|
|
23
|
+
/** Multiple newlines (3 or more) normalized to double newlines. */
|
|
24
|
+
const MULTI_NEWLINE_RE = /\n{3,}/g;
|
|
25
|
+
|
|
26
|
+
/** One mutable piece of system-prompt text: the only place a block can live. */
|
|
27
|
+
export type SystemText = { read(): string; write(text: string): void };
|
|
28
|
+
|
|
29
|
+
/** `container[key]` as a mutable string segment. */
|
|
30
|
+
export function stringSegment(container: Record<string, unknown>, key: string): SystemText {
|
|
31
|
+
return {
|
|
32
|
+
read: (): string => (typeof container[key] === "string" ? (container[key] as string) : ""),
|
|
33
|
+
write: (text: string): void => {
|
|
34
|
+
container[key] = text;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The text blocks of a parts array (`content[]` / Anthropic `system[]`). */
|
|
40
|
+
export function textBlockSegments(blocks: unknown[]): SystemText[] {
|
|
41
|
+
const segments: SystemText[] = [];
|
|
42
|
+
for (const block of blocks) {
|
|
43
|
+
if (!block || typeof block !== "object" || Array.isArray(block)) continue;
|
|
44
|
+
const rec = block as Record<string, unknown>;
|
|
45
|
+
if (typeof rec.text !== "string") continue;
|
|
46
|
+
segments.push({
|
|
47
|
+
read: (): string => rec.text as string,
|
|
48
|
+
write: (text: string): void => {
|
|
49
|
+
rec.text = text;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return segments;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Text segments of a `content` field: a plain string, or the text blocks of a
|
|
58
|
+
* parts array (Chat Completions spells message content both ways). A parts
|
|
59
|
+
* array with no text block gains one, because there is nowhere else to append.
|
|
60
|
+
*/
|
|
61
|
+
export function contentSegments(message: Record<string, unknown>): SystemText[] {
|
|
62
|
+
const content = message.content;
|
|
63
|
+
if (!Array.isArray(content)) {
|
|
64
|
+
if (typeof content !== "string") message.content = "";
|
|
65
|
+
return [stringSegment(message, "content")];
|
|
66
|
+
}
|
|
67
|
+
const segments = textBlockSegments(content);
|
|
68
|
+
if (segments.length > 0) return segments;
|
|
69
|
+
const block: Record<string, unknown> = { type: "text", text: "" };
|
|
70
|
+
content.push(block);
|
|
71
|
+
return [stringSegment(block, "text")];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Chat Completions system prompt: every `role: "system"` message in order.
|
|
76
|
+
* A body with none gains one at the top.
|
|
77
|
+
*/
|
|
78
|
+
export function chatSystemSegments(body: Record<string, unknown>): SystemText[] {
|
|
79
|
+
const existing = body.messages;
|
|
80
|
+
// A non-array messages value carries no system prompt; coerce to an array
|
|
81
|
+
// so the env block still lands (a shape violation either way, but a 429
|
|
82
|
+
// without the block hides the real error).
|
|
83
|
+
if (existing !== undefined && !Array.isArray(existing)) body.messages = [];
|
|
84
|
+
const messages: unknown[] = Array.isArray(body.messages) ? body.messages : ((body.messages = []) as unknown[]);
|
|
85
|
+
const segments: SystemText[] = [];
|
|
86
|
+
for (const message of messages) {
|
|
87
|
+
if (!message || typeof message !== "object" || Array.isArray(message)) continue;
|
|
88
|
+
const rec = message as Record<string, unknown>;
|
|
89
|
+
if (rec.role !== "system") continue;
|
|
90
|
+
segments.push(...contentSegments(rec));
|
|
91
|
+
}
|
|
92
|
+
if (segments.length > 0) return segments;
|
|
93
|
+
const added: Record<string, unknown> = { role: "system", content: "" };
|
|
94
|
+
messages.unshift(added);
|
|
95
|
+
return [stringSegment(added, "content")];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Anthropic Messages system prompt: `system` is a string or a text-block array. */
|
|
99
|
+
export function messagesSystemSegments(body: Record<string, unknown>): SystemText[] {
|
|
100
|
+
const system = body.system;
|
|
101
|
+
if (Array.isArray(system)) {
|
|
102
|
+
const segments = textBlockSegments(system);
|
|
103
|
+
if (segments.length > 0) return segments;
|
|
104
|
+
const block: Record<string, unknown> = { type: "text", text: "" };
|
|
105
|
+
system.push(block);
|
|
106
|
+
return [stringSegment(block, "text")];
|
|
107
|
+
}
|
|
108
|
+
if (typeof system !== "string") body.system = "";
|
|
109
|
+
return [stringSegment(body, "system")];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Responses system prompt: `instructions`, or the `system` / `developer` items
|
|
114
|
+
* a host seats in `input[]` instead. Real OpenCode clients send `instructions`,
|
|
115
|
+
* so a host that sends neither gets one.
|
|
116
|
+
*/
|
|
117
|
+
export function responsesSystemSegments(body: Record<string, unknown>): SystemText[] {
|
|
118
|
+
if (typeof body.instructions === "string") return [stringSegment(body, "instructions")];
|
|
119
|
+
const input = body.input;
|
|
120
|
+
if (Array.isArray(input)) {
|
|
121
|
+
const segments: SystemText[] = [];
|
|
122
|
+
for (const item of input) {
|
|
123
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
124
|
+
const rec = item as Record<string, unknown>;
|
|
125
|
+
if (rec.role !== "system" && rec.role !== "developer") continue;
|
|
126
|
+
if (typeof rec.content === "string") {
|
|
127
|
+
segments.push(stringSegment(rec, "content"));
|
|
128
|
+
} else if (Array.isArray(rec.content)) {
|
|
129
|
+
// Array-content system/developer items (Responses text-block parts):
|
|
130
|
+
// edit the text blocks in place rather than ignoring the item.
|
|
131
|
+
segments.push(...textBlockSegments(rec.content));
|
|
132
|
+
} else {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (segments.length > 0) return segments;
|
|
137
|
+
}
|
|
138
|
+
body.instructions = "";
|
|
139
|
+
return [stringSegment(body, "instructions")];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Every complete env block in the system text, in wire order. Upstream reads
|
|
144
|
+
* only the FIRST pair, which is why order decides whether a request passes.
|
|
145
|
+
*/
|
|
146
|
+
export function envBlocks(segments: SystemText[]): Array<{ segment: number; start: number; end: number; body: string }> {
|
|
147
|
+
const blocks: Array<{ segment: number; start: number; end: number; body: string }> = [];
|
|
148
|
+
segments.forEach((segment, index) => {
|
|
149
|
+
for (const match of segment.read().matchAll(ENV_PAIR_RE)) {
|
|
150
|
+
if (match.index === undefined) continue;
|
|
151
|
+
blocks.push({ segment: index, start: match.index, end: match.index + match[0].length, body: match[0] });
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
return blocks;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Drop every env block and stray tag from `text`, keeping one pair verbatim. */
|
|
158
|
+
export function stripEnvBlocks(text: string, keep?: { start: number; end: number }): string {
|
|
159
|
+
const strip = (part: string): string => part.replace(ENV_PAIR_RE, "").replace(ENV_TAG_RE, "");
|
|
160
|
+
const result =
|
|
161
|
+
keep === undefined
|
|
162
|
+
? strip(text)
|
|
163
|
+
: `${strip(text.slice(0, keep.start))}${text.slice(keep.start, keep.end)}${strip(text.slice(keep.end))}`;
|
|
164
|
+
return result.replace(MULTI_NEWLINE_RE, "\n\n").trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The `Working directory:` value the caller already stated, if any. */
|
|
168
|
+
export function statedWorkingDirectory(segments: SystemText[]): string | null {
|
|
169
|
+
for (const segment of segments) {
|
|
170
|
+
const value = WORKING_DIRECTORY_RE.exec(segment.read())?.[1]?.trim();
|
|
171
|
+
if (value) return value;
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build the exact 5-line env block the live CLI sends (v1 1.19.4 MITM, SG):
|
|
178
|
+
* Working directory, Workspace root, Is git, Platform, Today's date.
|
|
179
|
+
* The directory values fall back to the fingerprint constant when the caller
|
|
180
|
+
* stated nothing — the gate reads the keys, not usable paths.
|
|
181
|
+
*/
|
|
182
|
+
export function buildLiveEnvBlock(workingDirectory: string): string {
|
|
183
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
184
|
+
return [
|
|
185
|
+
"<env>",
|
|
186
|
+
` Working directory: ${workingDirectory}`,
|
|
187
|
+
` Workspace root: ${workingDirectory}`,
|
|
188
|
+
" Is git: false",
|
|
189
|
+
` Platform: ${process.platform}`,
|
|
190
|
+
` Today's date: ${today}`,
|
|
191
|
+
"</env>",
|
|
192
|
+
].join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Guarantee the free-tier environment gate: exactly one `<env>` … `</env>` pair
|
|
197
|
+
* in the system prompt, with `Working directory:` inside it.
|
|
198
|
+
*
|
|
199
|
+
* Measured 2026-10-24 (issues #117/#118): a body with the block answers 200 and
|
|
200
|
+
* the same body without it answers 429 FreeUsageLimitError, on the same machine
|
|
201
|
+
* within the same minute, with the retry-after countdown still hours away from
|
|
202
|
+
* 00:00 UTC. The countdown is a generic error body, not a quota window, so a
|
|
203
|
+
* host that never emits the block fails on the direct route no matter what
|
|
204
|
+
* else the lane gets right.
|
|
205
|
+
*
|
|
206
|
+
* Two measured traps decide the shape of this function:
|
|
207
|
+
* - Upstream reads only the FIRST pair in the text. A stale or partial copy
|
|
208
|
+
* above the real block shadows it and still 429s, even though the block
|
|
209
|
+
* itself is complete and correct. So a fix must leave exactly one pair, not
|
|
210
|
+
* merely append one.
|
|
211
|
+
* - `Working directory:` is load-bearing. A plausible-looking block carrying
|
|
212
|
+
* `Platform:` or `Shell:` instead is refused; a block carrying only
|
|
213
|
+
* `Working directory:` is accepted. So an existing block without the key is
|
|
214
|
+
* replaced rather than kept and duplicated.
|
|
215
|
+
*
|
|
216
|
+
* A host block that already satisfies both rides through byte-for-byte: the
|
|
217
|
+
* host knows its environment, this module does not. Anything else is folded
|
|
218
|
+
* into the last system message, which upstream accepts on every wire shape.
|
|
219
|
+
*/
|
|
220
|
+
export function ensureEnvBlock(body: Record<string, unknown>, pathname: string): void {
|
|
221
|
+
if (!body || typeof body !== "object") return;
|
|
222
|
+
const segments = pathname.endsWith("/responses")
|
|
223
|
+
? responsesSystemSegments(body)
|
|
224
|
+
: pathname.endsWith("/messages")
|
|
225
|
+
? messagesSystemSegments(body)
|
|
226
|
+
: chatSystemSegments(body);
|
|
227
|
+
if (segments.length === 0) return;
|
|
228
|
+
const blocks = envBlocks(segments);
|
|
229
|
+
const carrying = blocks.find((block) => WORKING_DIRECTORY_RE.test(block.body));
|
|
230
|
+
// Already the single pair upstream reads, with the key inside: leave it be.
|
|
231
|
+
if (carrying !== undefined && blocks.length === 1) return;
|
|
232
|
+
segments.forEach((segment, index) => {
|
|
233
|
+
segment.write(stripEnvBlocks(segment.read(), carrying !== undefined && carrying.segment === index ? carrying : undefined));
|
|
234
|
+
});
|
|
235
|
+
if (carrying !== undefined) return;
|
|
236
|
+
const last = segments[segments.length - 1];
|
|
237
|
+
const text = last.read();
|
|
238
|
+
last.write(`${text ? `${text}\n\n` : ""}${buildLiveEnvBlock(statedWorkingDirectory(segments) ?? FALLBACK_WORKING_DIRECTORY)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Normalize the camelCase `promptCacheKey` alias onto `prompt_cache_key` so
|
|
243
|
+
* every session-key reader sees one spelling. A non-empty snake_case value
|
|
244
|
+
* wins; neither spelling is ever stripped on the Zen lane.
|
|
245
|
+
*/
|
|
246
|
+
export function normalizePromptCacheKey(body: Record<string, unknown>): void {
|
|
247
|
+
const camelKey = body["promptCacheKey"];
|
|
248
|
+
if (typeof camelKey === "string" && camelKey.length > 0 && (typeof body["prompt_cache_key"] !== "string" || (body["prompt_cache_key"] as string).length === 0)) {
|
|
249
|
+
body["prompt_cache_key"] = camelKey;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Normalize a Zen Responses body to what upstream accepts. OMP/Pi hosts
|
|
255
|
+
* sometimes carry Chat Completions leftovers on the responses path and
|
|
256
|
+
* upstream rejects them with 400:
|
|
257
|
+
* - `messages` becomes `input` (copied when `input` is absent, dropped either
|
|
258
|
+
* way — the responses API has no `messages` field).
|
|
259
|
+
* - `max_tokens` / `max_completion_tokens` become `max_output_tokens` (copied
|
|
260
|
+
* when absent, dropped either way).
|
|
261
|
+
* - `temperature` / `top_p` are dropped (Responses rejects them).
|
|
262
|
+
* - `response_format` is dropped (Chat Completions field, Responses rejects it).
|
|
263
|
+
* - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
|
|
264
|
+
* - `tool_choice` is auto-or-absent: `"auto"` rides verbatim and everything
|
|
265
|
+
else is dropped, because Zen supports no other value (see
|
|
266
|
+
enforceAutoToolChoice).
|
|
267
|
+
* - `store` defaults to false (never persisted server-side).
|
|
268
|
+
* - Assistant message items lose `phase: "commentary"`; upstream 400s the whole
|
|
269
|
+
request otherwise (see stripCommentaryPhase).
|
|
270
|
+
* - Reasoning effort is clamped per model: when the model's thinkingLevelMap
|
|
271
|
+
declares the requested effort as null (unsupported), the effort is clamped
|
|
272
|
+
to the highest supported level. Covers all OpenCode models generically
|
|
273
|
+
(muse-spark, mimo, nemotron, etc.), not just a single model family.
|
|
274
|
+
* Zen-responses only: chat/messages paths keep their own fields, and Kilo/Cline
|
|
275
|
+
* bodies never reach here (the proxy fingerprints Zen bodies exclusively).
|
|
276
|
+
*/
|
|
277
|
+
export function normalizeResponsesBody(body: Record<string, unknown>): void {
|
|
278
|
+
if (!body || typeof body !== "object") return;
|
|
279
|
+
if (body.input === undefined && Array.isArray(body.messages)) {
|
|
280
|
+
body.input = body.messages;
|
|
281
|
+
}
|
|
282
|
+
delete body.messages;
|
|
283
|
+
stripCommentaryPhase(body);
|
|
284
|
+
// After input[] exists: a replayed call name is only folded once the array is
|
|
285
|
+
// the one upstream will read.
|
|
286
|
+
sanitizeReplayItemNames(body);
|
|
287
|
+
if (body.max_output_tokens === undefined) {
|
|
288
|
+
if (typeof body.max_tokens === "number") body.max_output_tokens = body.max_tokens;
|
|
289
|
+
else if (typeof body.max_completion_tokens === "number") body.max_output_tokens = body.max_completion_tokens;
|
|
290
|
+
}
|
|
291
|
+
delete body.max_tokens;
|
|
292
|
+
delete body.max_completion_tokens;
|
|
293
|
+
delete body.temperature;
|
|
294
|
+
delete body.top_p;
|
|
295
|
+
delete body.response_format;
|
|
296
|
+
if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
|
|
297
|
+
if (typeof body.model === "string") {
|
|
298
|
+
const reasoning = body.reasoning;
|
|
299
|
+
if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
300
|
+
const effort = (reasoning as Record<string, unknown>).effort;
|
|
301
|
+
if (typeof effort === "string") {
|
|
302
|
+
const clamped = clampedEffortFor(body.model as string, effort);
|
|
303
|
+
if (clamped !== null) (reasoning as Record<string, unknown>).effort = clamped;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
|
|
307
|
+
const flat = body[key];
|
|
308
|
+
if (typeof flat === "string") {
|
|
309
|
+
const clamped = clampedEffortFor(body.model as string, flat);
|
|
310
|
+
if (clamped !== null) body[key] = clamped;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// After the effort clamp: dropping a forced tool call is what triggers the
|
|
315
|
+
// native-effort restore, so it runs once clamping is settled.
|
|
316
|
+
enforceAutoToolChoice(body);
|
|
317
|
+
if (body.store === undefined) body.store = false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* OpenAI-compat body variant for the default lane: mark the body for usage
|
|
322
|
+
* accounting the way the CLI's openai-compat request does. Merges into an
|
|
323
|
+
* existing stream_options object rather than replacing it. Applied to every
|
|
324
|
+
* chat-lane enforcement (see enforceOpencodeFingerprint).
|
|
325
|
+
*/
|
|
326
|
+
export function applyOpenAiCompatVariant(body: Record<string, unknown>): void {
|
|
327
|
+
if (!body || typeof body !== "object") return;
|
|
328
|
+
const existing = body.stream_options;
|
|
329
|
+
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
330
|
+
(existing as Record<string, unknown>).include_usage = true;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
body.stream_options = { include_usage: true };
|
|
334
|
+
}
|