pi-freeflow 1.34.0 → 1.35.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 +30 -0
- package/README.md +65 -33
- package/package.json +1 -1
- package/src/catalog.ts +13 -1
- package/src/cline-accounts.ts +15 -13
- package/src/config.ts +11 -0
- package/src/index.ts +3 -3
- package/src/models.ts +181 -46
- package/src/opencode-fingerprint.ts +143 -16
- package/src/proxy.ts +188 -13
- package/src/tool-translation.ts +112 -33
- package/src/types.ts +162 -162
package/src/proxy.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
PATH_TRAVERSAL_PATTERN,
|
|
29
29
|
MAX_BODY_BYTES,
|
|
30
30
|
PORT,
|
|
31
|
+
OVH_CHAT_URL,
|
|
31
32
|
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
32
33
|
UPSTREAM_OPENCODE,
|
|
33
34
|
opencodeHeaders,
|
|
@@ -41,14 +42,14 @@ import {
|
|
|
41
42
|
type FindGlobRestore,
|
|
42
43
|
} from "./opencode-fingerprint.ts";
|
|
43
44
|
import { isDebugEnabled, log } from "./logger.ts";
|
|
44
|
-
import { getModelUpstream, isClineModel, KILO_MODEL_IDS, MODEL_MAP, resolveCanonicalModelId } from "./models.ts";
|
|
45
|
+
import { getModelUpstream, isClineModel, isOvhModel, KILO_MODEL_IDS, MODEL_MAP, ovhUpstreamModelId, resolveCanonicalModelId } from "./models.ts";
|
|
45
46
|
import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
|
|
46
47
|
import { rollChat } from "./cline-accounts.ts";
|
|
47
48
|
import { fetchWithSystemCA } from "./system-ca-fetch.ts";
|
|
48
49
|
import {
|
|
49
50
|
chatResponsesJsonFromChatCompletion,
|
|
50
51
|
chatResponsesSseFromChatCompletion,
|
|
51
|
-
|
|
52
|
+
chatBodyFromResponsesBody,
|
|
52
53
|
translateToolsForPath,
|
|
53
54
|
} from "./tool-translation.ts";
|
|
54
55
|
import { buildArgRepairSchemas } from "./tool-args.ts";
|
|
@@ -261,7 +262,7 @@ async function handleClineRequest(opts: {
|
|
|
261
262
|
const model = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
|
|
262
263
|
let chatBody: Record<string, unknown>;
|
|
263
264
|
if (responsesRequest) {
|
|
264
|
-
chatBody =
|
|
265
|
+
chatBody = chatBodyFromResponsesBody(parsedBody);
|
|
265
266
|
} else {
|
|
266
267
|
chatBody = { ...parsedBody };
|
|
267
268
|
if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
|
|
@@ -384,12 +385,179 @@ async function handleClineRequest(opts: {
|
|
|
384
385
|
}
|
|
385
386
|
}
|
|
386
387
|
|
|
388
|
+
/**
|
|
389
|
+
* Serve an OVH-model request direct (never relay pool, no opencode
|
|
390
|
+
* fingerprint). Chat-only upstream: OVH's responses endpoint rejects every
|
|
391
|
+
* array `input` shape with 422 "input: data did not match any variant of
|
|
392
|
+
* untagged enum ResponseInput" and accepts only a bare string (probed
|
|
393
|
+
* 2026-10-13), so a responses-path body translates to chat upstream and the
|
|
394
|
+
* chat answer translates back — the same shape mapping the Cline lane uses.
|
|
395
|
+
* Keyless: no Authorization header, because OVH answers anonymous callers.
|
|
396
|
+
* Upstream always streams; callers that asked for plain JSON get the
|
|
397
|
+
* aggregated object, and a streamed responses caller gets SSE re-emitted
|
|
398
|
+
* from the mapped Responses object.
|
|
399
|
+
*/
|
|
400
|
+
async function handleOvhRequest(opts: {
|
|
401
|
+
parsedBody: Record<string, unknown>;
|
|
402
|
+
pathname: string;
|
|
403
|
+
req: http.IncomingMessage;
|
|
404
|
+
res: http.ServerResponse;
|
|
405
|
+
reqId: string;
|
|
406
|
+
clientRequestedStream: boolean;
|
|
407
|
+
}): Promise<void> {
|
|
408
|
+
const { parsedBody, pathname, req, res, reqId, clientRequestedStream } = opts;
|
|
409
|
+
const responsesRequest = pathname.endsWith("/responses");
|
|
410
|
+
const requestedModel = typeof parsedBody.model === "string" ? parsedBody.model : "unknown";
|
|
411
|
+
let chatBody: Record<string, unknown>;
|
|
412
|
+
if (responsesRequest) {
|
|
413
|
+
chatBody = chatBodyFromResponsesBody(parsedBody);
|
|
414
|
+
} else {
|
|
415
|
+
chatBody = { ...parsedBody };
|
|
416
|
+
if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
|
|
417
|
+
delete chatBody.prompt_cache_key;
|
|
418
|
+
}
|
|
419
|
+
// OVH knows only the bare id. Canonicalise before stripping: a short alias
|
|
420
|
+
// (`llama-3.3-70b`) must resolve to `ovh-free/Meta-Llama-3_3-70B-Instruct`
|
|
421
|
+
// first, or it reaches OVH unrecognised and answers 404 model_not_found.
|
|
422
|
+
chatBody.model = ovhUpstreamModelId(requestedModel);
|
|
423
|
+
// Bypassing the fingerprint does not exempt OVH from malformed tool
|
|
424
|
+
// arguments: capture the caller's own schemas before translation for repair.
|
|
425
|
+
const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
|
|
426
|
+
chatBody.stream = true;
|
|
427
|
+
// Header-wait timeout + client-disconnect abort, cleared once headers arrive
|
|
428
|
+
// so a long stream is not killed at the timeout ceiling.
|
|
429
|
+
const controller = new AbortController();
|
|
430
|
+
const timeoutId = setTimeout(
|
|
431
|
+
() => controller.abort(upstreamTimeoutError()),
|
|
432
|
+
UPSTREAM_HEADER_TIMEOUT_MS,
|
|
433
|
+
);
|
|
434
|
+
const abortOnClientGone = () => {
|
|
435
|
+
if (!res.writableEnded) controller.abort();
|
|
436
|
+
};
|
|
437
|
+
res.once("close", abortOnClientGone);
|
|
438
|
+
req.once("error", abortOnClientGone);
|
|
439
|
+
let upstreamRes: Response;
|
|
440
|
+
try {
|
|
441
|
+
// Keyless, direct (never the relay pool). OVH serves anonymous callers but
|
|
442
|
+
// rate-limits them, so a 429 here is a real per-IP cap and must NOT carry
|
|
443
|
+
// the relay-deploy hint — this lane can never ride a relay.
|
|
444
|
+
upstreamRes = await fetch(OVH_CHAT_URL, {
|
|
445
|
+
method: "POST",
|
|
446
|
+
headers: { "Content-Type": "application/json" },
|
|
447
|
+
body: JSON.stringify(chatBody),
|
|
448
|
+
signal: controller.signal,
|
|
449
|
+
});
|
|
450
|
+
} catch (e) {
|
|
451
|
+
log("error", "ovh upstream fetch failed", { error: String(e), model: requestedModel }, reqId);
|
|
452
|
+
if (!res.headersSent) {
|
|
453
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
454
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
455
|
+
} else if (!res.writableEnded) {
|
|
456
|
+
res.end();
|
|
457
|
+
}
|
|
458
|
+
return;
|
|
459
|
+
} finally {
|
|
460
|
+
clearTimeout(timeoutId);
|
|
461
|
+
res.off("close", abortOnClientGone);
|
|
462
|
+
req.off("error", abortOnClientGone);
|
|
463
|
+
}
|
|
464
|
+
if (res.writableEnded) {
|
|
465
|
+
try { await upstreamRes.body?.cancel(); } catch { }
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
if (!upstreamRes.ok) {
|
|
469
|
+
log("warn", `ovh upstream ${upstreamRes.status} for model ${requestedModel}`, { status: upstreamRes.status, model: requestedModel }, reqId);
|
|
470
|
+
const raw = await upstreamRes.text().catch(() => null);
|
|
471
|
+
if (raw === null) {
|
|
472
|
+
log("error", "ovh upstream body unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
|
|
473
|
+
if (!res.headersSent) {
|
|
474
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
475
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
476
|
+
} else if (!res.writableEnded) {
|
|
477
|
+
res.end();
|
|
478
|
+
}
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (!res.headersSent) {
|
|
482
|
+
res.writeHead(upstreamRes.status, { "content-type": upstreamRes.headers.get("content-type") || "application/json" });
|
|
483
|
+
res.end(raw);
|
|
484
|
+
} else if (!res.writableEnded) {
|
|
485
|
+
res.end();
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (!responsesRequest && clientRequestedStream && upstreamRes.body) {
|
|
490
|
+
res.writeHead(upstreamRes.status, {
|
|
491
|
+
"content-type": upstreamRes.headers.get("content-type") || "text/event-stream",
|
|
492
|
+
"cache-control": "no-cache, no-transform",
|
|
493
|
+
connection: "keep-alive",
|
|
494
|
+
"x-accel-buffering": "no",
|
|
495
|
+
});
|
|
496
|
+
// Fetched direct, never through the relay pool: attributing an OVH-side
|
|
497
|
+
// stream failure to an unrelated relay would mark a healthy relay failed.
|
|
498
|
+
pipeUpstreamStream(
|
|
499
|
+
Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
|
|
500
|
+
res,
|
|
501
|
+
req,
|
|
502
|
+
reqId,
|
|
503
|
+
"direct",
|
|
504
|
+
argSchemas.size > 0
|
|
505
|
+
? { callerHadTools: true, argSchemas, pathname: "/v1/chat/completions" }
|
|
506
|
+
: undefined,
|
|
507
|
+
);
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const sseText = await upstreamRes.text().catch(() => null);
|
|
511
|
+
if (sseText === null) {
|
|
512
|
+
log("error", "ovh upstream stream unreadable", { status: upstreamRes.status, model: requestedModel }, reqId);
|
|
513
|
+
if (!res.headersSent) {
|
|
514
|
+
res.writeHead(502, { "content-type": "application/json" });
|
|
515
|
+
res.end(JSON.stringify({ error: "upstream error" }));
|
|
516
|
+
} else if (!res.writableEnded) {
|
|
517
|
+
res.end();
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (!responsesRequest) {
|
|
522
|
+
const data = convertSseToJson(sseText, pathname, true, undefined, undefined, undefined, argSchemas);
|
|
523
|
+
if (!res.headersSent) {
|
|
524
|
+
res.writeHead(upstreamRes.status, { "content-type": "application/json" });
|
|
525
|
+
res.end(data);
|
|
526
|
+
} else if (!res.writableEnded) {
|
|
527
|
+
res.end();
|
|
528
|
+
}
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const chat = sseToChatCompletionJson(sseText, true, undefined, undefined, undefined, argSchemas);
|
|
532
|
+
const resp = chatResponsesJsonFromChatCompletion(chat, requestedModel);
|
|
533
|
+
if (clientRequestedStream) {
|
|
534
|
+
if (!res.headersSent) {
|
|
535
|
+
res.writeHead(200, {
|
|
536
|
+
"content-type": "text/event-stream",
|
|
537
|
+
"cache-control": "no-cache, no-transform",
|
|
538
|
+
connection: "keep-alive",
|
|
539
|
+
"x-accel-buffering": "no",
|
|
540
|
+
});
|
|
541
|
+
res.end(chatResponsesSseFromChatCompletion(resp));
|
|
542
|
+
} else if (!res.writableEnded) {
|
|
543
|
+
res.end();
|
|
544
|
+
}
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (!res.headersSent) {
|
|
548
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
549
|
+
res.end(JSON.stringify(resp));
|
|
550
|
+
} else if (!res.writableEnded) {
|
|
551
|
+
res.end();
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
387
555
|
/**
|
|
388
556
|
* Relay eligibility: only free-catalog models ride relay egress. Returns true
|
|
389
557
|
* when the model is a non-empty string present in MODEL_MAP or KILO_MODEL_IDS
|
|
390
558
|
* (Kilo traffic leaves via its own branch regardless), and when the model is
|
|
391
559
|
* missing/empty (e.g. GET /v1/models carries no body — preserve routing).
|
|
392
|
-
* Cline models are direct-only and never relay-eligible. Anything else
|
|
560
|
+
* Cline and OVH models are direct-only and never relay-eligible. Anything else
|
|
393
561
|
* (unknown/paid model ids, non-strings) goes direct upstream.
|
|
394
562
|
*/
|
|
395
563
|
export function isRelayEligibleModel(model: unknown): boolean {
|
|
@@ -397,7 +565,7 @@ export function isRelayEligibleModel(model: unknown): boolean {
|
|
|
397
565
|
if (typeof model !== "string") return false;
|
|
398
566
|
if (model.trim() === "") return true;
|
|
399
567
|
const canonical = resolveCanonicalModelId(model.trim());
|
|
400
|
-
if (isClineModel(canonical)) return false;
|
|
568
|
+
if (isClineModel(canonical) || isOvhModel(canonical)) return false;
|
|
401
569
|
return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical) || isFreeCatalogId(canonical);
|
|
402
570
|
}
|
|
403
571
|
|
|
@@ -935,7 +1103,7 @@ export function startProxy(
|
|
|
935
1103
|
id: m.id,
|
|
936
1104
|
object: "model",
|
|
937
1105
|
created: 0,
|
|
938
|
-
owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : "opencode",
|
|
1106
|
+
owned_by: m.source === "kilo" ? "kilocode" : m.source === "cline" ? "clinecode" : m.source === "ovh" ? "ovhcloud" : "opencode",
|
|
939
1107
|
})),
|
|
940
1108
|
});
|
|
941
1109
|
res.writeHead(200, {
|
|
@@ -1007,6 +1175,7 @@ export function startProxy(
|
|
|
1007
1175
|
const bodyStr = Buffer.concat(bodyChunks).toString();
|
|
1008
1176
|
let isKilo = false;
|
|
1009
1177
|
let isCline = false;
|
|
1178
|
+
let isOvh = false;
|
|
1010
1179
|
let parsedBody: Record<string, unknown> | null = null;
|
|
1011
1180
|
let clientRequestedStream = false;
|
|
1012
1181
|
|
|
@@ -1016,8 +1185,10 @@ export function startProxy(
|
|
|
1016
1185
|
const canonical = resolveCanonicalModelId(parsedBody.model);
|
|
1017
1186
|
if (isClineModel(canonical)) {
|
|
1018
1187
|
isCline = true;
|
|
1019
|
-
|
|
1020
|
-
|
|
1188
|
+
} else if (isOvhModel(canonical)) {
|
|
1189
|
+
isOvh = true;
|
|
1190
|
+
} else if (KILO_MODEL_IDS.has(canonical)) {
|
|
1191
|
+
isKilo = true;
|
|
1021
1192
|
}
|
|
1022
1193
|
}
|
|
1023
1194
|
if (parsedBody && typeof parsedBody === "object") {
|
|
@@ -1055,7 +1226,7 @@ export function startProxy(
|
|
|
1055
1226
|
let callerFindGlob: FindGlobRestore | undefined;
|
|
1056
1227
|
let callerInjected: string[] | undefined;
|
|
1057
1228
|
let callerArgSchemas: Map<string, Record<string, unknown>> | undefined;
|
|
1058
|
-
if (!isKilo && !isCline && parsedBody) {
|
|
1229
|
+
if (!isKilo && !isCline && !isOvh && parsedBody) {
|
|
1059
1230
|
const fp = enforceOpencodeFingerprint(parsedBody, target.pathname);
|
|
1060
1231
|
callerHadTools = fp.callerHadTools;
|
|
1061
1232
|
callerCaseRestore = fp.caseRestore;
|
|
@@ -1066,8 +1237,8 @@ export function startProxy(
|
|
|
1066
1237
|
}
|
|
1067
1238
|
// Streaming cloak: thread this request's placeholder records into the SSE
|
|
1068
1239
|
// pipe so streamed deltas get the same strip+restore as aggregated bodies.
|
|
1069
|
-
// Kilo/Cline bypass the fingerprint, so their streams stay verbatim.
|
|
1070
|
-
const streamCloak: StreamCloakOptions | undefined = !isKilo && !isCline && callerInjected !== undefined
|
|
1240
|
+
// Kilo/Cline/OVH bypass the fingerprint, so their streams stay verbatim.
|
|
1241
|
+
const streamCloak: StreamCloakOptions | undefined = !isKilo && !isCline && !isOvh && callerInjected !== undefined
|
|
1071
1242
|
? { callerHadTools, caseRestore: callerCaseRestore, findGlob: callerFindGlob, injected: callerInjected, argSchemas: callerArgSchemas, pathname: target.pathname }
|
|
1072
1243
|
: undefined;
|
|
1073
1244
|
|
|
@@ -1078,7 +1249,7 @@ export function startProxy(
|
|
|
1078
1249
|
// anthropic-messages models reach /v1/messages. A request on the wrong path
|
|
1079
1250
|
// means the host still holds a pre-fix provider registration (stale disk
|
|
1080
1251
|
// cache or no restart after upgrade) and upstream answers 500.
|
|
1081
|
-
if (!isKilo && !isCline && typeof parsedBody?.model === "string") {
|
|
1252
|
+
if (!isKilo && !isCline && !isOvh && typeof parsedBody?.model === "string") {
|
|
1082
1253
|
const knownDef = MODEL_MAP.get(String(parsedBody.model));
|
|
1083
1254
|
if (target.pathname.endsWith("/chat/completions") && knownDef?.api && knownDef.api !== "openai-completions") {
|
|
1084
1255
|
log("warn", `model ${String(parsedBody.model)} expects ${knownDef.api} but got ${target.pathname} — stale provider registration (restart Pi/OMP after upgrade)`, { model: String(parsedBody.model), path: target.pathname }, reqId);
|
|
@@ -1218,7 +1389,11 @@ export function startProxy(
|
|
|
1218
1389
|
res.writeHead(response.status, { "content-type": ct });
|
|
1219
1390
|
res.end(data);
|
|
1220
1391
|
}
|
|
1221
|
-
|
|
1392
|
+
} else if (isOvh && parsedBody) {
|
|
1393
|
+
// OVH serves chat upstream only (see handleOvhRequest): direct, keyless,
|
|
1394
|
+
// no fingerprint, and a responses-path body translated to chat and back.
|
|
1395
|
+
await handleOvhRequest({ parsedBody, pathname: target.pathname, req, res, reqId, clientRequestedStream });
|
|
1396
|
+
} else {
|
|
1222
1397
|
// OpenCode routing — relay egress is for free-catalog models
|
|
1223
1398
|
// only; unknown/paid models go direct upstream.
|
|
1224
1399
|
const relayState = getActiveRelayState();
|
package/src/tool-translation.ts
CHANGED
|
@@ -332,31 +332,100 @@ export function upstreamToolNameFor(name: string): string {
|
|
|
332
332
|
return isFindToolName(name) ? "glob" : name.trim();
|
|
333
333
|
}
|
|
334
334
|
/**
|
|
335
|
-
* Restore
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
335
|
+
* Restore records for the upstream-name rewrites: `find`->`glob` and the
|
|
336
|
+
* sanitization of names upstream rejects. `renamed` maps every rewritten
|
|
337
|
+
* upstream name (lowercased) back to the caller's original, so an emitted
|
|
338
|
+
* call still resolves to the tool the caller actually registered.
|
|
339
|
+
*
|
|
340
|
+
* `renamedFindToGlob` is true only when the caller sent `find` (any case)
|
|
341
|
+
* without also sending `glob`: the single upstream `glob` unambiguously stands
|
|
342
|
+
* for the caller `find`. When the caller sent both, upstream collapses to one
|
|
343
|
+
* `glob` (first-seen wins) and no single-name restore applies.
|
|
339
344
|
*/
|
|
340
345
|
export interface FindGlobRestore {
|
|
341
|
-
|
|
346
|
+
renamedFindToGlob: boolean;
|
|
347
|
+
/** Sanitized upstream name (lowercased) -> caller name. Absent when every
|
|
348
|
+
* caller name was already upstream-legal, so nothing was rewritten. */
|
|
349
|
+
renamed?: Record<string, string>;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Upstream accepts only `^[a-zA-Z0-9_.-]+$` as a tool name and answers
|
|
354
|
+
* `400 \`name\` must match` otherwise. Hosts do send such names — measured in
|
|
355
|
+
* live Pi sessions: `TG drive` (space), `env:production` (colon) — so the
|
|
356
|
+
* whole request dies on one badly named tool.
|
|
357
|
+
*
|
|
358
|
+
* Every character outside the allowed set folds to `_`, and a collision with
|
|
359
|
+
* an already-emitted name gets a numeric suffix. The caller name is recorded
|
|
360
|
+
* so `restoreToolNameForCaller` puts it back; the model never sees a name it
|
|
361
|
+
* cannot match against its own earlier call. A name that already matches is
|
|
362
|
+
* returned unchanged, so well-formed tool sets are untouched.
|
|
363
|
+
*/
|
|
364
|
+
const UPSTREAM_NAME_SHAPE = /^[a-zA-Z0-9_.-]+$/;
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Fold one caller tool name to a shape upstream accepts. Deterministic, and a
|
|
368
|
+
* pure function of the name: the same input always sanitizes to the same
|
|
369
|
+
* output, which is what lets the caller name be recovered downstream.
|
|
370
|
+
*/
|
|
371
|
+
export function sanitizeUpstreamToolName(name: string, taken: ReadonlySet<string>): string {
|
|
372
|
+
const trimmed = name.trim();
|
|
373
|
+
if (UPSTREAM_NAME_SHAPE.test(trimmed)) return trimmed;
|
|
374
|
+
let folded = trimmed.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
375
|
+
// A name made entirely of invalid characters would sanitize to a leading
|
|
376
|
+
// run of underscores and collide with every other such name; anchor it.
|
|
377
|
+
if (folded.length === 0) folded = "tool";
|
|
378
|
+
let candidate = folded;
|
|
379
|
+
let n = 2;
|
|
380
|
+
while (taken.has(candidate.toLowerCase())) {
|
|
381
|
+
candidate = `${folded}_${n}`;
|
|
382
|
+
n += 1;
|
|
383
|
+
}
|
|
384
|
+
return candidate;
|
|
342
385
|
}
|
|
343
|
-
/** Inspect the original caller tools (pre-translate) for the
|
|
386
|
+
/** Inspect the original caller tools (pre-translate) for the rewrite cases. */
|
|
344
387
|
export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
388
|
+
let sawFind = false;
|
|
389
|
+
let sawGlob = false;
|
|
390
|
+
const renamed: Record<string, string> = {};
|
|
391
|
+
// Same first-seen-wins order and case rules as translateToolsForPath, so
|
|
392
|
+
// the recorded map keys match the names that actually reach upstream.
|
|
393
|
+
const seen = new Set<string>();
|
|
394
|
+
for (const tool of callerTools) {
|
|
395
|
+
const canon = canonicalizeTool(tool);
|
|
396
|
+
if (!canon) continue;
|
|
397
|
+
const callerName = canon.name;
|
|
398
|
+
if (isFindToolName(callerName)) sawFind = true;
|
|
399
|
+
else if (callerName.trim().toLowerCase() === "glob") sawGlob = true;
|
|
400
|
+
// Mirrors translateToolsForPath: dedup on the lowercased
|
|
401
|
+
// pre-sanitization name (a collapsed duplicate never reaches upstream),
|
|
402
|
+
// then sanitize with the caller's casing intact.
|
|
403
|
+
const dedupKey = upstreamToolNameFor(callerName).toLowerCase();
|
|
404
|
+
if (seen.has(dedupKey)) continue;
|
|
405
|
+
seen.add(dedupKey);
|
|
406
|
+
const sanitized = sanitizeUpstreamToolName(upstreamToolNameFor(callerName), seen);
|
|
407
|
+
if (sanitized.toLowerCase() !== dedupKey) seen.add(sanitized.toLowerCase());
|
|
408
|
+
// Only character-folding rewrites belong in the sanitized map. The
|
|
409
|
+
// find->glob rename is a collision case with its own flag: when the
|
|
410
|
+
// caller also sent `glob`, upstream `glob` really is the caller's glob,
|
|
411
|
+
// so recording it here would misroute a genuine call.
|
|
412
|
+
if (sanitized !== callerName && !isFindToolName(callerName)) {
|
|
413
|
+
renamed[sanitized.toLowerCase()] = callerName;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const restored: FindGlobRestore = { renamedFindToGlob: sawFind && !sawGlob, renamed };
|
|
417
|
+
return restored;
|
|
355
418
|
}
|
|
356
|
-
/**
|
|
419
|
+
/**
|
|
420
|
+
* Downstream name: undo the upstream rewrites. The sanitized map is checked
|
|
421
|
+
* first because it is keyed by the exact name upstream saw; the find->glob
|
|
422
|
+
* case stays as the single-name fallback.
|
|
423
|
+
*/
|
|
357
424
|
export function restoreToolNameForCaller(name: string, restore: FindGlobRestore): string {
|
|
358
|
-
|
|
359
|
-
return
|
|
425
|
+
const sanitized = restore.renamed?.[name.trim().toLowerCase()];
|
|
426
|
+
if (sanitized !== undefined) return sanitized;
|
|
427
|
+
if (restore.renamedFindToGlob && name.trim().toLowerCase() === "glob") return "find";
|
|
428
|
+
return name;
|
|
360
429
|
}
|
|
361
430
|
function retargetChoice(value: unknown, from: string, to: string): unknown {
|
|
362
431
|
if (typeof value === "string") {
|
|
@@ -447,18 +516,25 @@ export function translateToolsForPath(tools: unknown[], pathname: string): Recor
|
|
|
447
516
|
out.push(rec);
|
|
448
517
|
continue;
|
|
449
518
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
519
|
+
// Duplicate collapse stays first-seen-wins on the lowercased upstream name
|
|
520
|
+
// (`Bash` never duplicates `bash`, `find` collapses with `glob`) and runs on
|
|
521
|
+
// the pre-sanitization name: the emitted name keeps the caller's casing, and
|
|
522
|
+
// the sanitizer only ever folds characters the upstream shape forbids.
|
|
523
|
+
const dedupKey = upstreamToolNameFor(canon.name).toLowerCase();
|
|
524
|
+
if (seen.has(dedupKey)) continue;
|
|
525
|
+
seen.add(dedupKey);
|
|
526
|
+
// The find->glob rename and the name sanitization share one upstream name,
|
|
527
|
+
// so a caller tool needing either is emitted under a single new name.
|
|
528
|
+
const upstreamName = sanitizeUpstreamToolName(upstreamToolNameFor(canon.name), seen);
|
|
529
|
+
if (upstreamName.toLowerCase() !== dedupKey) seen.add(upstreamName.toLowerCase());
|
|
530
|
+
const needsRename = upstreamName !== canon.name;
|
|
531
|
+
if (!needsRename && alreadyTargetShape(rec, api)) {
|
|
532
|
+
out.push(rec);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const renamed: CanonicalTool = needsRename
|
|
536
|
+
? { ...canon, name: upstreamName }
|
|
537
|
+
: canon;
|
|
462
538
|
const converted =
|
|
463
539
|
api === "responses"
|
|
464
540
|
? toResponsesTool(renamed)
|
|
@@ -584,8 +660,11 @@ export function responsesInputToChatMessages(input: unknown): Record<string, unk
|
|
|
584
660
|
* instructions (as the leading system message), input, tools (reshaped to
|
|
585
661
|
* chat via translateToolsForPath), tool_choice, and plain sampling scalars.
|
|
586
662
|
* Stream policy stays with the caller: this maps shape, never behavior.
|
|
663
|
+
*
|
|
664
|
+
* Lane-neutral: used by every direct-only lane that serves chat upstream and
|
|
665
|
+
* answers a responses-path caller (Cline, OVHcloud AI Endpoints).
|
|
587
666
|
*/
|
|
588
|
-
export function
|
|
667
|
+
export function chatBodyFromResponsesBody(body: Record<string, unknown>): Record<string, unknown> {
|
|
589
668
|
const out: Record<string, unknown> = {};
|
|
590
669
|
if (typeof body.model === "string") out.model = body.model;
|
|
591
670
|
const messages: Record<string, unknown>[] = [];
|
|
@@ -657,7 +736,7 @@ export function chatResponsesJsonFromChatCompletion(chat: Record<string, unknown
|
|
|
657
736
|
/**
|
|
658
737
|
* Wrap a Responses object in the minimal valid Responses SSE sequence
|
|
659
738
|
* (created + completed + DONE) so streamed responses-path clients get a
|
|
660
|
-
* stream even though
|
|
739
|
+
* stream even though the upstream lane only serves chat. Round-trips through
|
|
661
740
|
* sseToResponsesJson: the completed event carries the full object.
|
|
662
741
|
*/
|
|
663
742
|
export function chatResponsesSseFromChatCompletion(resp: Record<string, unknown>): string {
|