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/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
- clineChatBodyFromResponsesBody,
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 = clineChatBodyFromResponsesBody(parsedBody);
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
- } else if (KILO_MODEL_IDS.has(canonical)) {
1020
- isKilo = true;
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
- } else {
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();
@@ -332,31 +332,100 @@ export function upstreamToolNameFor(name: string): string {
332
332
  return isFindToolName(name) ? "glob" : name.trim();
333
333
  }
334
334
  /**
335
- * Restore record for the find->glob rename. True only when the caller sent
336
- * `find` (any case) without also sending `glob`: the single upstream `glob`
337
- * unambiguously stands for the caller `find`. When the caller sent both,
338
- * upstream collapses to one `glob` (first-seen wins) and no restore applies.
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
- renamedFindToGlob: boolean;
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 find->glob case. */
386
+ /** Inspect the original caller tools (pre-translate) for the rewrite cases. */
344
387
  export function buildFindGlobRestore(callerTools: unknown[]): FindGlobRestore {
345
- let sawFind = false;
346
- let sawGlob = false;
347
- for (const tool of callerTools) {
348
- const canon = canonicalizeTool(tool);
349
- if (!canon) continue;
350
- if (isFindToolName(canon.name)) sawFind = true;
351
- else if (canon.name.trim().toLowerCase() === "glob") sawGlob = true;
352
- if (sawFind && sawGlob) break;
353
- }
354
- return { renamedFindToGlob: sawFind && !sawGlob };
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
- /** Downstream name: map the upstream `glob` back to caller `find` when renamed. */
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
- if (restore.renamedFindToGlob && name.trim().toLowerCase() === "glob") return "find";
359
- return name;
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
- const upstreamName = upstreamToolNameFor(canon.name);
451
- const key = upstreamName.toLowerCase();
452
- if (seen.has(key)) continue;
453
- seen.add(key);
454
- const needsRename = upstreamName !== canon.name;
455
- if (!needsRename && alreadyTargetShape(rec, api)) {
456
- out.push(rec);
457
- continue;
458
- }
459
- const renamed: CanonicalTool = needsRename
460
- ? { ...canon, name: upstreamName }
461
- : canon;
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 clineChatBodyFromResponsesBody(body: Record<string, unknown>): Record<string, unknown> {
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 Cline only serves chat upstream. Round-trips through
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 {