pi-freeflow 1.40.1 → 1.42.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 +73 -0
- package/README.md +38 -23
- package/package.json +1 -1
- package/src/catalog.ts +57 -22
- package/src/cline-accounts.ts +279 -34
- package/src/config.ts +18 -1
- package/src/daemon.ts +2 -1
- package/src/deploy.ts +2 -2
- package/src/index.ts +28 -6
- package/src/models.ts +178 -100
- package/src/opencode-fingerprint.ts +78 -17
- package/src/proxy.ts +416 -31
- package/src/relay.ts +38 -0
- package/src/responses.ts +6 -0
- package/src/stream-pipe.ts +400 -68
- package/src/tool-translation.ts +15 -12
- package/src/toolcall-audit.ts +580 -0
- package/src/types.ts +2 -0
package/src/cline-accounts.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { writeFileAtomic } from "./atomic-write.ts";
|
|
|
16
16
|
import { RELAY_STATE_FILE } from "./config.ts";
|
|
17
17
|
import { logWarn } from "./logger.ts";
|
|
18
18
|
import type { FetchImpl } from "./cline-device-auth.ts";
|
|
19
|
-
import { isWorkosJwt } from "./cline-device-auth.ts";
|
|
19
|
+
import { isWorkosJwt, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
20
|
+
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
20
21
|
|
|
21
22
|
/** Env override for the pool file location (tests/CI sandbox). */
|
|
22
23
|
const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
|
|
@@ -115,37 +116,183 @@ export function _resetClinePoolCacheForTest(): void {
|
|
|
115
116
|
cachedMtime = -1;
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
/**
|
|
119
|
-
const
|
|
119
|
+
/** Bundled fallback version if dynamic fetch and disk cache are absent. */
|
|
120
|
+
export const BUNDLED_CLINE_VERSION = "4.1.25";
|
|
121
|
+
export const CLINE_CLIENT_VERSION = BUNDLED_CLINE_VERSION;
|
|
122
|
+
|
|
123
|
+
const CLINE_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
124
|
+
let liveClineVersion: string | null = null;
|
|
125
|
+
|
|
126
|
+
function clineVersionCachePath(): string {
|
|
127
|
+
return path.join(path.dirname(CLINE_POOL_FILE), "pi-freeflow-cline-version.json");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function _resetLiveClineVersionForTest(): void {
|
|
131
|
+
liveClineVersion = null;
|
|
132
|
+
}
|
|
120
133
|
|
|
121
134
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* with a bare bearer, and 200 with this set; every non-prefixed catalog model
|
|
125
|
-
* (e.g. z-ai/glm-5.3-flash) answers 200 either way. Header names and shape
|
|
126
|
-
* mirror reference/cline providers/request-headers.ts
|
|
127
|
-
* (DEFAULT_CLINE_REQUEST_HEADERS) — no credential is derived from them.
|
|
128
|
-
*
|
|
129
|
-
* `X-CLIENT-TYPE` is the Cline desktop app's identity, and it gates the
|
|
130
|
-
* `cline-free/*` id space (measured live 2026-09-22: that prefix answers 403
|
|
131
|
-
* "only available via Cline product surfaces" with a bare bearer, and 200 with
|
|
132
|
-
* this set; every non-prefixed id answers 200 either way). The header is kept
|
|
133
|
-
* because one catalog id still depends on it — after the 2026-10-13 prune,
|
|
134
|
-
* `cline-free/muse-spark-1.3-contributor` is the last prefixed model in the
|
|
135
|
-
* catalog. It also decided the free-model feed at the time: five entries for
|
|
136
|
-
* `cline-sdk` / `cline-cli` / the `VSCode Extension` label against six for
|
|
137
|
-
* `cline-desktop`, the extra one being `cline-free/kimi-k3`. That sixth model
|
|
138
|
-
* is retired upstream and no longer requested. Version and User-Agent stay
|
|
139
|
-
* as-is: the feed served the desktop list with them unchanged.
|
|
135
|
+
* Resolve effective Cline client version. Precedence:
|
|
136
|
+
* env override (PI_FREEFLOW_CLINE_VERSION) > in-memory version > fresh disk cache > BUNDLED_CLINE_VERSION.
|
|
140
137
|
*/
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
};
|
|
138
|
+
export function getClineVersion(): string {
|
|
139
|
+
const override = process.env.PI_FREEFLOW_CLINE_VERSION?.trim();
|
|
140
|
+
if (override) return override;
|
|
141
|
+
if (liveClineVersion) return liveClineVersion;
|
|
142
|
+
try {
|
|
143
|
+
const cachePath = clineVersionCachePath();
|
|
144
|
+
if (fs.existsSync(cachePath)) {
|
|
145
|
+
const data = JSON.parse(fs.readFileSync(cachePath, "utf8")) as { version?: string; checkedAt?: number };
|
|
146
|
+
if (typeof data.version === "string" && data.version.trim() && typeof data.checkedAt === "number" && Date.now() - data.checkedAt < CLINE_VERSION_TTL_MS) {
|
|
147
|
+
liveClineVersion = data.version.trim();
|
|
148
|
+
return liveClineVersion;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch { }
|
|
152
|
+
return BUNDLED_CLINE_VERSION;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Client headers the Cline API expects, evaluated dynamically with the current version.
|
|
157
|
+
*/
|
|
158
|
+
export function getClineClientHeaders(): Record<string, string> {
|
|
159
|
+
const ver = getClineVersion();
|
|
160
|
+
return {
|
|
161
|
+
"HTTP-Referer": "https://cline.bot",
|
|
162
|
+
"X-Title": "Cline",
|
|
163
|
+
"X-IS-MULTIROOT": "false",
|
|
164
|
+
"X-CLIENT-TYPE": "cline-desktop",
|
|
165
|
+
"X-CLIENT-VERSION": ver,
|
|
166
|
+
"User-Agent": `Cline/${ver}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Proxy object for CLINE_CLIENT_HEADERS providing full backward compatibility.
|
|
172
|
+
*/
|
|
173
|
+
export const CLINE_CLIENT_HEADERS: Record<string, string> = new Proxy({} as Record<string, string>, {
|
|
174
|
+
get(_target, prop) {
|
|
175
|
+
return getClineClientHeaders()[prop as string];
|
|
176
|
+
},
|
|
177
|
+
has(_target, prop) {
|
|
178
|
+
return prop in getClineClientHeaders();
|
|
179
|
+
},
|
|
180
|
+
ownKeys(_target) {
|
|
181
|
+
return Object.keys(getClineClientHeaders());
|
|
182
|
+
},
|
|
183
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
184
|
+
const val = getClineClientHeaders()[prop as string];
|
|
185
|
+
return val !== undefined ? { value: val, writable: false, enumerable: true, configurable: true } : undefined;
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Refresh the live Cline version from open-vsx or GitHub releases and persist to disk.
|
|
191
|
+
*/
|
|
192
|
+
export async function refreshClineVersion(fetchImpl: FetchImpl = fetchWithSystemCA): Promise<string> {
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetchImpl("https://open-vsx.org/api/saoudrizwan/claude-dev", {
|
|
195
|
+
headers: { "User-Agent": "pi-freeflow" },
|
|
196
|
+
signal: AbortSignal.timeout(5000),
|
|
197
|
+
});
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
const data = (await res.json()) as { version?: string };
|
|
200
|
+
if (typeof data.version === "string" && /^\d+\.\d+\.\d+/.test(data.version)) {
|
|
201
|
+
liveClineVersion = data.version.trim();
|
|
202
|
+
try {
|
|
203
|
+
const cachePath = clineVersionCachePath();
|
|
204
|
+
writeFileAtomic(cachePath, JSON.stringify({ version: liveClineVersion, checkedAt: Date.now() }));
|
|
205
|
+
} catch { }
|
|
206
|
+
return liveClineVersion;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch { }
|
|
210
|
+
return getClineVersion();
|
|
211
|
+
}
|
|
212
|
+
async function defaultClineRefresher(refreshToken: string): Promise<ClineRefreshResult | null> {
|
|
213
|
+
try {
|
|
214
|
+
const creds = await refreshClineToken(undefined, refreshToken);
|
|
215
|
+
return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Background auto-probing & keep-alive for Cline account tokens.
|
|
222
|
+
* Proactively refreshes tokens nearing expiry (<30 min remaining or expired)
|
|
223
|
+
* so that saved accounts never lapse into unrecoverable dead grants.
|
|
224
|
+
* Also performs a lightweight ping to verify the account is active.
|
|
225
|
+
*/
|
|
226
|
+
export async function keepClineTokensAlive(
|
|
227
|
+
opts: {
|
|
228
|
+
fetchImpl?: FetchImpl;
|
|
229
|
+
refreshImpl?: ClineRefresher;
|
|
230
|
+
} = {},
|
|
231
|
+
): Promise<{ refreshed: number; probed: number }> {
|
|
232
|
+
const pool = loadPool();
|
|
233
|
+
if (pool.accounts.length === 0) return { refreshed: 0, probed: 0 };
|
|
234
|
+
const fetchImpl = opts.fetchImpl ?? fetchWithSystemCA;
|
|
235
|
+
const refreshImpl = opts.refreshImpl ?? defaultClineRefresher;
|
|
236
|
+
const now = Date.now();
|
|
237
|
+
const REFRESH_THRESHOLD_MS = 30 * 60 * 1000;
|
|
238
|
+
let refreshed = 0;
|
|
239
|
+
let probed = 0;
|
|
240
|
+
|
|
241
|
+
for (const account of pool.accounts) {
|
|
242
|
+
if (!account.refreshToken) continue;
|
|
243
|
+
const isExpiringSoon = typeof account.expiresAt === "number" && (account.expiresAt - now < REFRESH_THRESHOLD_MS);
|
|
244
|
+
const isExpired = typeof account.expiresAt === "number" && account.expiresAt <= now;
|
|
245
|
+
const needsRefresh = isExpiringSoon || isExpired || isClineTokenStale(account);
|
|
246
|
+
|
|
247
|
+
if (needsRefresh) {
|
|
248
|
+
try {
|
|
249
|
+
const outcome = await refreshAccountInPlace(account, refreshImpl);
|
|
250
|
+
if (outcome === true) {
|
|
251
|
+
refreshed += 1;
|
|
252
|
+
}
|
|
253
|
+
} catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Light probe: ping models endpoint with light timeout to keep the session warm
|
|
259
|
+
try {
|
|
260
|
+
const token = account.token;
|
|
261
|
+
const probeRes = await fetchImpl("https://api.cline.bot/api/v1/models", {
|
|
262
|
+
method: "GET",
|
|
263
|
+
headers: {
|
|
264
|
+
...getClineClientHeaders(),
|
|
265
|
+
authorization: `Bearer ${token}`,
|
|
266
|
+
},
|
|
267
|
+
signal: AbortSignal.timeout(6000),
|
|
268
|
+
});
|
|
269
|
+
if (probeRes.ok) {
|
|
270
|
+
probed += 1;
|
|
271
|
+
}
|
|
272
|
+
} catch { }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { refreshed, probed };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const CLINE_MODEL_PREFIX = "cline/";
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Maps a catalog Cline model ID to the exact wire model ID expected by Cline API.
|
|
282
|
+
*/
|
|
283
|
+
export function clineUpstreamModelId(id: string): string {
|
|
284
|
+
const trimmed = id.trim();
|
|
285
|
+
if (trimmed === "cline/nemotron-3.5-super-vl") {
|
|
286
|
+
return "nvidia/nemotron-3.5-super-vl:free";
|
|
287
|
+
}
|
|
288
|
+
if (trimmed.startsWith(CLINE_MODEL_PREFIX)) {
|
|
289
|
+
return trimmed.slice(CLINE_MODEL_PREFIX.length);
|
|
290
|
+
}
|
|
291
|
+
if (trimmed.startsWith("cline-free/")) {
|
|
292
|
+
return trimmed.slice("cline-free/".length);
|
|
293
|
+
}
|
|
294
|
+
return trimmed;
|
|
295
|
+
}
|
|
149
296
|
|
|
150
297
|
/**
|
|
151
298
|
* Free-limit report carried by a Cline 429 body, e.g.
|
|
@@ -165,7 +312,19 @@ interface ClineFreeLimit {
|
|
|
165
312
|
resetAt?: number;
|
|
166
313
|
}
|
|
167
314
|
|
|
168
|
-
const CLINE_FREE_LIMIT_MARKER = /free limit reached on model/i;
|
|
315
|
+
const CLINE_FREE_LIMIT_MARKER = /(?:free limit reached on model|Daily limit reached for)/i;
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Extract epoch reset from X-RateLimit-Reset if present in headers or metadata.
|
|
319
|
+
* Converts epoch seconds (<1e11) to ms.
|
|
320
|
+
*/
|
|
321
|
+
function parseXRateLimitReset(text: string): number | null {
|
|
322
|
+
const m = text.match(/X-RateLimit-Reset[\\"\s:]+([0-9.]+)/i);
|
|
323
|
+
if (!m) return null;
|
|
324
|
+
const val = Number(m[1]);
|
|
325
|
+
if (!Number.isFinite(val) || val <= 0) return null;
|
|
326
|
+
return val < 1e11 ? Math.round(val * 1000) : Math.round(val);
|
|
327
|
+
}
|
|
169
328
|
|
|
170
329
|
/**
|
|
171
330
|
* Delay stated as `try again in <N>h <N>m`, `<N>m`, or a bare `<N>` (hours).
|
|
@@ -190,13 +349,15 @@ function parseResetDelayMs(text: string): number | null {
|
|
|
190
349
|
*/
|
|
191
350
|
export function parseClineFreeLimit(raw: string, now = Date.now()): ClineFreeLimit | null {
|
|
192
351
|
if (typeof raw !== "string" || !CLINE_FREE_LIMIT_MARKER.test(raw)) return null;
|
|
193
|
-
const named = raw.match(/free limit reached on model\s+(\
|
|
352
|
+
const named = raw.match(/(?:free limit reached on model|Daily limit reached for)\s+([^\\"\s,;]+)/i);
|
|
194
353
|
// The model name ends the sentence, so a trailing "." belongs to the prose.
|
|
195
354
|
const modelId = named ? named[1].replace(/[.,;:]+$/, "") : "";
|
|
196
|
-
const
|
|
355
|
+
const epochReset = parseXRateLimitReset(raw);
|
|
356
|
+
const delay = epochReset !== null ? null : parseResetDelayMs(raw);
|
|
357
|
+
const resetAt = epochReset !== null ? epochReset : delay !== null ? now + delay : undefined;
|
|
197
358
|
return {
|
|
198
359
|
...(modelId ? { modelId } : {}),
|
|
199
|
-
...(
|
|
360
|
+
...(resetAt !== undefined ? { resetAt } : {}),
|
|
200
361
|
};
|
|
201
362
|
}
|
|
202
363
|
|
|
@@ -893,6 +1054,82 @@ function exhaustedResponse(reason: string, status: number): Response {
|
|
|
893
1054
|
);
|
|
894
1055
|
}
|
|
895
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* For OpenRouter-backed Cline models, Cline sometimes answers HTTP 200
|
|
1059
|
+
* `text/event-stream` where the first event is an in-stream 429 error
|
|
1060
|
+
* (e.g. `stream_initialization_failed` with status 429 / daily limit reached).
|
|
1061
|
+
* Peek the first chunk: if it is an in-stream 429 error, convert it to a 429
|
|
1062
|
+
* Response so rollChat rolls to the next slot; otherwise, reconstruct the
|
|
1063
|
+
* stream with the peeked chunk re-enqueued.
|
|
1064
|
+
*/
|
|
1065
|
+
async function peekStreamForInStreamError(res: Response): Promise<{ res: Response; inStreamError: boolean; errorText: string }> {
|
|
1066
|
+
const contentType = res.headers.get("content-type") || "";
|
|
1067
|
+
if (!contentType.includes("text/event-stream") || !res.body) {
|
|
1068
|
+
return { res, inStreamError: false, errorText: "" };
|
|
1069
|
+
}
|
|
1070
|
+
try {
|
|
1071
|
+
const reader = res.body.getReader();
|
|
1072
|
+
const { done, value } = await reader.read();
|
|
1073
|
+
if (done || !value || value.byteLength === 0) {
|
|
1074
|
+
return {
|
|
1075
|
+
res: new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers }),
|
|
1076
|
+
inStreamError: false,
|
|
1077
|
+
errorText: "",
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
const text = new TextDecoder().decode(value);
|
|
1081
|
+
const isInStream429 = (
|
|
1082
|
+
text.includes('"stream_initialization_failed"') ||
|
|
1083
|
+
text.includes('"error":') ||
|
|
1084
|
+
text.includes('"Rate limit exceeded"') ||
|
|
1085
|
+
text.includes("Daily limit reached")
|
|
1086
|
+
) && (
|
|
1087
|
+
text.includes("429") ||
|
|
1088
|
+
/status["\s:]+429/i.test(text) ||
|
|
1089
|
+
/Rate limit exceeded/i.test(text) ||
|
|
1090
|
+
/Daily limit reached/i.test(text)
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
if (isInStream429) {
|
|
1094
|
+
try { await reader.cancel(); } catch { }
|
|
1095
|
+
const errRes = new Response(text, {
|
|
1096
|
+
status: 429,
|
|
1097
|
+
headers: { "content-type": "application/json" },
|
|
1098
|
+
});
|
|
1099
|
+
return { res: errRes, inStreamError: true, errorText: text };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const reconstructed = new ReadableStream<Uint8Array>({
|
|
1103
|
+
start(controller) {
|
|
1104
|
+
controller.enqueue(value);
|
|
1105
|
+
},
|
|
1106
|
+
async pull(controller) {
|
|
1107
|
+
try {
|
|
1108
|
+
const { done: nextDone, value: nextValue } = await reader.read();
|
|
1109
|
+
if (nextDone) {
|
|
1110
|
+
controller.close();
|
|
1111
|
+
} else if (nextValue) {
|
|
1112
|
+
controller.enqueue(nextValue);
|
|
1113
|
+
}
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
controller.error(err);
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
cancel(reason) {
|
|
1119
|
+
return reader.cancel(reason);
|
|
1120
|
+
},
|
|
1121
|
+
});
|
|
1122
|
+
|
|
1123
|
+
return {
|
|
1124
|
+
res: new Response(reconstructed, { status: res.status, statusText: res.statusText, headers: res.headers }),
|
|
1125
|
+
inStreamError: false,
|
|
1126
|
+
errorText: "",
|
|
1127
|
+
};
|
|
1128
|
+
} catch {
|
|
1129
|
+
return { res, inStreamError: false, errorText: "" };
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
896
1133
|
/**
|
|
897
1134
|
* POST one chat body to the Cline endpoint, rolling across every saved slot.
|
|
898
1135
|
* Each attempt sends its slot bearer explicitly; raw tokens never leave this
|
|
@@ -1027,6 +1264,14 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
|
1027
1264
|
};
|
|
1028
1265
|
let res = await attempt(account.token);
|
|
1029
1266
|
if (!res) continue;
|
|
1267
|
+
let inStreamLimitText: string | null = null;
|
|
1268
|
+
if (res.status === 200 && res.headers.get("content-type")?.includes("text/event-stream")) {
|
|
1269
|
+
const peek = await peekStreamForInStreamError(res);
|
|
1270
|
+
res = peek.res;
|
|
1271
|
+
if (peek.inStreamError) {
|
|
1272
|
+
inStreamLimitText = peek.errorText;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1030
1275
|
let kind = mapClineError(res.status);
|
|
1031
1276
|
if (kind === "ok" || kind === "client") return await succeed(res, kind);
|
|
1032
1277
|
// Auth failure on a refreshable slot that has not refreshed yet: one
|
|
@@ -1056,7 +1301,7 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
|
|
|
1056
1301
|
// for the requested model until the stated reset: remember it, so the next
|
|
1057
1302
|
// turn tries the other logins first.
|
|
1058
1303
|
if (res.status === 429) {
|
|
1059
|
-
const limit = await freeLimitFrom(res, now);
|
|
1304
|
+
const limit = inStreamLimitText ? parseClineFreeLimit(inStreamLimitText, now) : await freeLimitFrom(res, now);
|
|
1060
1305
|
if (limit) {
|
|
1061
1306
|
cappedSlots.add(account.slot);
|
|
1062
1307
|
if (limit.modelId) limitModelId = limit.modelId;
|
package/src/config.ts
CHANGED
|
@@ -75,7 +75,7 @@ export const PORT = resolvePort();
|
|
|
75
75
|
// OPENCODE_VERSION_FLOOR pins the minimum; OPENCODE_VERSION_FALLBACK is the
|
|
76
76
|
// last-known-good npm version used when the live lookup is stale/offline.
|
|
77
77
|
export const OPENCODE_VERSION_FLOOR = "1.17.0";
|
|
78
|
-
export const OPENCODE_VERSION_FALLBACK = "1.19.
|
|
78
|
+
export const OPENCODE_VERSION_FALLBACK = "1.19.4";
|
|
79
79
|
/** Live npm lookup TTL: 6h — within one window the cached UA is reused. */
|
|
80
80
|
export const OPENCODE_VERSION_TTL_MS = 6 * 60 * 60 * 1_000;
|
|
81
81
|
/** Env override: exact `opencode/<version>` UA (or bare version) for tests/pins. */
|
|
@@ -239,6 +239,10 @@ export function createOpenCodeId(descending: boolean, timestamp = Date.now()): s
|
|
|
239
239
|
return time + Array.from(bytes, (byte) => ID_CHARS[byte % 62]).join("");
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
+
// Upstream v1.19.4 refactored `SessionID.create` to compose the prefix itself
|
|
243
|
+
// (`schema.make("ses_" + descending())` in packages/schema/src/session-id.ts).
|
|
244
|
+
// The identifier generator is unchanged, so this call is still byte-for-byte
|
|
245
|
+
// equivalent: `ses_` + 12 hex timestamp chars + 14 base62 random chars.
|
|
242
246
|
export function createOpenCodeSessionId(): string {
|
|
243
247
|
return `ses_${createOpenCodeId(true)}`;
|
|
244
248
|
}
|
|
@@ -450,6 +454,19 @@ export function resolveUpstreamHealthPath(): string {
|
|
|
450
454
|
}
|
|
451
455
|
}
|
|
452
456
|
|
|
457
|
+
/** JSONL audit of tool-call turns that failed or needed repair (src/toolcall-audit.ts). */
|
|
458
|
+
export function resolveToolcallAuditPath(): string {
|
|
459
|
+
try {
|
|
460
|
+
return dataFilePath("toolcall-audit.jsonl");
|
|
461
|
+
} catch {
|
|
462
|
+
return path.join(
|
|
463
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
464
|
+
"..",
|
|
465
|
+
".toolcall-audit.jsonl",
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
453
470
|
export function resolveSessionPinPath(): string {
|
|
454
471
|
try {
|
|
455
472
|
return dataFilePath("pi-freeflow-session-pins.json");
|
package/src/daemon.ts
CHANGED
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
stopLeaseGC,
|
|
32
32
|
touchActivity,
|
|
33
33
|
} from "./lease.ts";
|
|
34
|
-
import { getActiveRequests, startProxy } from "./proxy.ts";
|
|
34
|
+
import { getActiveRequests, startClineKeepAlive, startProxy } from "./proxy.ts";
|
|
35
35
|
import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
|
|
36
36
|
|
|
37
37
|
/** True when this module was run as the entry script (not imported by tests). */
|
|
@@ -122,6 +122,7 @@ async function runDaemon(): Promise<void> {
|
|
|
122
122
|
touchActivity();
|
|
123
123
|
syncRelayStateFromDisk();
|
|
124
124
|
void seedCatalog();
|
|
125
|
+
startClineKeepAlive();
|
|
125
126
|
|
|
126
127
|
// Retire only when zero leases persist past the grace window with nothing
|
|
127
128
|
// in flight — never for request-idleness.
|
package/src/deploy.ts
CHANGED
|
@@ -23,8 +23,8 @@ import { randomBytes } from "node:crypto";
|
|
|
23
23
|
* auth gate (legacy/manual deploys); built-in deploys always embed one.
|
|
24
24
|
*/
|
|
25
25
|
function buildRelayWorkerCore(relayAuth: string): string {
|
|
26
|
-
return String.raw`//
|
|
27
|
-
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
26
|
+
return String.raw`// Upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
27
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai", "https://oai.endpoints.kepler.ai.cloud.ovh.net"];
|
|
28
28
|
const RELAY_AUTH = ${JSON.stringify(relayAuth)};
|
|
29
29
|
const resolveRelayTarget = function(target, relayPath) {
|
|
30
30
|
let targetUrl;
|
package/src/index.ts
CHANGED
|
@@ -145,22 +145,44 @@ export function buildProviderConfig(
|
|
|
145
145
|
contextWindow: m.contextWindow,
|
|
146
146
|
maxTokens: m.maxTokens,
|
|
147
147
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
148
|
-
compat:
|
|
148
|
+
compat: {
|
|
149
|
+
...(m.thinkingFormat
|
|
149
150
|
? {
|
|
150
151
|
supportsDeveloperRole: false,
|
|
151
152
|
thinkingFormat: m.thinkingFormat,
|
|
152
153
|
}
|
|
153
154
|
: m.api === "openai-responses"
|
|
154
155
|
? { sessionAffinityFormat: "openai-nosession" }
|
|
155
|
-
: m.source === "kilo" || m.source === "cline"
|
|
156
|
+
: m.source === "kilo" || m.source === "cline"
|
|
156
157
|
? {
|
|
157
158
|
supportsDeveloperRole: false,
|
|
158
159
|
supportsReasoningEffort: !!m.thinkingLevelMap,
|
|
159
160
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
161
|
+
// OVH is a plain OpenAI chat endpoint. Without an explicit format the
|
|
162
|
+
// host sees the loopback proxy URL, classifies the lane as a local
|
|
163
|
+
// backend and treats `Qwen3.x` ids as template-dialect Qwen, emitting
|
|
164
|
+
// `chat_template_kwargs` that OVH rejects with
|
|
165
|
+
// `400 feature 'extra arguments: ...' is not currently supported`.
|
|
166
|
+
// Pin the format so the host sends `reasoning_effort` instead.
|
|
167
|
+
: m.source === "ovh"
|
|
168
|
+
? {
|
|
169
|
+
supportsDeveloperRole: false,
|
|
170
|
+
supportsReasoningEffort: !!m.thinkingLevelMap,
|
|
171
|
+
thinkingFormat: "openai",
|
|
172
|
+
// The host derives these two from the loopback base URL before the
|
|
173
|
+
// spec override lands: `thinkingFormat` is overridden above, but
|
|
174
|
+
// `qwenPreserveThinking` was already latched on and still emits
|
|
175
|
+
// `chat_template_kwargs.preserve_thinking`. Clear it explicitly.
|
|
176
|
+
qwenPreserveThinking: false,
|
|
177
|
+
}
|
|
178
|
+
: {
|
|
179
|
+
supportsDeveloperRole: false,
|
|
180
|
+
supportsReasoningEffort: true,
|
|
181
|
+
}),
|
|
182
|
+
...(m.requiresReasoningContentOnAssistantMessages !== undefined
|
|
183
|
+
? { requiresReasoningContentOnAssistantMessages: m.requiresReasoningContentOnAssistantMessages }
|
|
184
|
+
: {}),
|
|
185
|
+
},
|
|
164
186
|
};
|
|
165
187
|
}),
|
|
166
188
|
};
|