copilot-relay 0.4.3 → 0.4.4

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/dist/main.js CHANGED
@@ -3,6 +3,7 @@ import { defineCommand, runMain } from "citty";
3
3
  import fs from "node:fs/promises";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
5
  import { AsyncLocalStorage } from "node:async_hooks";
6
+ import timers from "node:timers";
6
7
  import { inspect, isDeepStrictEqual, promisify } from "node:util";
7
8
  import consola from "consola";
8
9
  import os from "node:os";
@@ -268,7 +269,6 @@ const consoleSuppressed = new AsyncLocalStorage();
268
269
  const withoutLogging = (run) => loggingSuppressed.run(true, run);
269
270
  const withoutConsoleLogging = (run) => consoleSuppressed.run(true, run);
270
271
  let currentLogLevel = consolaLevelByName.info;
271
- const pendingLogWrites = /* @__PURE__ */ new Set();
272
272
  const registeredLogSecrets = /* @__PURE__ */ new Set();
273
273
  const logSecretForms = /* @__PURE__ */ new Set();
274
274
  let logSecretPattern;
@@ -320,9 +320,6 @@ const boundLogText = (value, maxBytes) => {
320
320
  return bytes.subarray(0, end).toString("utf8") + truncatedMarker;
321
321
  };
322
322
  const isDebugLogging = () => currentLogLevel >= consolaLevelByName.debug;
323
- const flushLogs = async () => {
324
- while (pendingLogWrites.size > 0) await Promise.all([...pendingLogWrites]);
325
- };
326
323
  const setLogLevel = (level) => {
327
324
  currentLogLevel = consolaLevelByName[level];
328
325
  consola.level = consolaLevelByName[level];
@@ -353,7 +350,7 @@ const ensurePrivateDirectory = async (directory) => {
353
350
  });
354
351
  const observed = await fs.lstat(directory);
355
352
  if (!observed.isDirectory() || observed.isSymbolicLink()) throw new Error("Refusing an unsafe log directory");
356
- if (process.platform === "win32") return;
353
+ if (process.platform === "win32") return observed;
357
354
  const handle = await fs.open(directory, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
358
355
  try {
359
356
  const opened = await handle.stat();
@@ -362,12 +359,26 @@ const ensurePrivateDirectory = async (directory) => {
362
359
  } finally {
363
360
  await handle.close();
364
361
  }
362
+ return observed;
365
363
  };
364
+ const logDirectories = [paths.appDir, paths.logsDir];
366
365
  const ensureLogDirectory = async () => {
367
- await ensurePrivateDirectory(paths.appDir);
368
- await ensurePrivateDirectory(paths.logsDir);
369
- };
370
- const appendPrivateLog = async (filePath, content) => {
366
+ const checked = [];
367
+ for (const directory of logDirectories) checked.push(await ensurePrivateDirectory(directory));
368
+ return checked;
369
+ };
370
+ let logQueue = [];
371
+ let logWriteChain = Promise.resolve();
372
+ let activeLog;
373
+ let logIdleClose;
374
+ const maxLogWriteBytes = 256 * 1024;
375
+ const hasMode = (stats, mode) => process.platform === "win32" || (stats.mode & 511) === mode;
376
+ const isSameDirectory = (current, checked) => current !== void 0 && current.isDirectory() && sameFile$2(current, checked) && hasMode(current, 448);
377
+ const isStillActive = async (active) => {
378
+ const [current, ...directories] = await Promise.all([active.filePath, ...logDirectories].map((target) => fs.lstat(target).catch(() => void 0)));
379
+ return current !== void 0 && current.isFile() && current.nlink === 1 && sameFile$2(current, active.identity) && hasMode(current, 384) && active.directories.every((checked, index) => isSameDirectory(directories[index], checked));
380
+ };
381
+ const openPrivateLog = async (filePath, directories) => {
371
382
  const observed = await fs.lstat(filePath).catch((error) => {
372
383
  if (!isMissing$1(error)) throw error;
373
384
  });
@@ -379,28 +390,88 @@ const appendPrivateLog = async (filePath, content) => {
379
390
  const current = await fs.lstat(filePath);
380
391
  if (!opened.isFile() || opened.nlink !== 1 || current.isSymbolicLink() || !sameFile$2(opened, current) || observed && !sameFile$2(observed, opened)) throw new Error("Log file changed while opening");
381
392
  if (process.platform !== "win32") await handle.chmod(384);
382
- await handle.appendFile(content);
383
- } finally {
384
- await handle.close();
393
+ return {
394
+ filePath,
395
+ handle,
396
+ identity: opened,
397
+ directories
398
+ };
399
+ } catch (error) {
400
+ await handle.close().catch(() => void 0);
401
+ throw error;
385
402
  }
386
403
  };
387
- /**
388
- * Appends one already-rendered entry.
389
- *
390
- * Takes strings rather than raw values: rendering happens once in wrapFileLog,
391
- * so the console and the file are guaranteed to carry the same redacted text.
392
- * Inspecting again here would reintroduce the gap this closes - the file would
393
- * be scrubbed while the console showed the original object. See #47.
394
- */
395
- const writeLogFile = async (level, values) => {
396
- await ensureLogDirectory();
397
- await cleanupLogsIfDue();
398
- const line = [
399
- (/* @__PURE__ */ new Date()).toISOString(),
400
- level,
401
- values.join(" ")
402
- ].join(" ");
403
- await appendPrivateLog(getLogPath(), `${line}\n`);
404
+ const closeActiveLog = async () => {
405
+ const closing = activeLog;
406
+ activeLog = void 0;
407
+ await closing?.handle.close().catch(() => void 0);
408
+ };
409
+ const logIdleCloseMs = 1e3;
410
+ const scheduleLogIdleClose = () => {
411
+ timers.clearTimeout(logIdleClose);
412
+ logIdleClose = timers.setTimeout(() => {
413
+ logWriteChain = logWriteChain.then(closeActiveLog);
414
+ }, logIdleCloseMs);
415
+ logIdleClose.unref();
416
+ };
417
+ const openActiveLog = async (filePath) => {
418
+ if (activeLog?.filePath === filePath && await isStillActive(activeLog)) return activeLog.handle;
419
+ await closeActiveLog();
420
+ activeLog = await openPrivateLog(filePath, await ensureLogDirectory());
421
+ return activeLog.handle;
422
+ };
423
+ const appendLogLines = async (filePath, lines) => {
424
+ const handle = await openActiveLog(filePath);
425
+ let chunk = "";
426
+ let chunkBytes = 0;
427
+ for (const line of lines) {
428
+ const lineBytes = Buffer.byteLength(line);
429
+ if (chunk !== "" && chunkBytes + lineBytes > maxLogWriteBytes) {
430
+ await handle.appendFile(chunk);
431
+ chunk = "";
432
+ chunkBytes = 0;
433
+ }
434
+ chunk += line;
435
+ chunkBytes += lineBytes;
436
+ }
437
+ await handle.appendFile(chunk);
438
+ };
439
+ const drainLogQueue = async () => {
440
+ const entries = logQueue;
441
+ logQueue = [];
442
+ await cleanupLogsIfDue().catch(() => void 0);
443
+ const groups = [];
444
+ for (const entry of entries) {
445
+ const last = groups.at(-1);
446
+ if (last?.filePath === entry.filePath) last.lines.push(entry.line);
447
+ else groups.push({
448
+ filePath: entry.filePath,
449
+ lines: [entry.line]
450
+ });
451
+ }
452
+ for (const group of groups) try {
453
+ await appendLogLines(group.filePath, group.lines);
454
+ } catch {
455
+ await closeActiveLog();
456
+ }
457
+ scheduleLogIdleClose();
458
+ };
459
+ const queueLogEntry = (entry) => {
460
+ logQueue.push(entry);
461
+ if (logQueue.length === 1) {
462
+ timers.clearTimeout(logIdleClose);
463
+ logWriteChain = logWriteChain.then(drainLogQueue).catch(() => void 0);
464
+ }
465
+ };
466
+ const flushLogs = async () => {
467
+ let pending = true;
468
+ while (pending) {
469
+ const closed = logWriteChain.then(closeActiveLog);
470
+ logWriteChain = closed;
471
+ await closed;
472
+ pending = logQueue.length > 0;
473
+ }
474
+ timers.clearTimeout(logIdleClose);
404
475
  };
405
476
  const cleanupLogsIfDue = async () => {
406
477
  const now = Date.now();
@@ -430,9 +501,11 @@ const wrapFileLog = (level, fn) => ((...args) => {
430
501
  if (!writesToFile && !writesToConsole) return quiet ? void 0 : fn(...args);
431
502
  const rendered = [boundLogText(args.map((value) => boundLogText(escapeLogLineSeparators(scrubLogSecrets(scrubSensitiveUrls(formatLogValue(value)))), maxLogArgumentBytes)).join(" "), maxLogPayloadBytes)];
432
503
  if (writesToFile) {
433
- const pending = writeLogFile(level, rendered).catch(() => void 0);
434
- pendingLogWrites.add(pending);
435
- pending.then(() => pendingLogWrites.delete(pending));
504
+ const now = /* @__PURE__ */ new Date();
505
+ queueLogEntry({
506
+ filePath: getLogPath(now),
507
+ line: `${now.toISOString()} ${level} ${rendered.join(" ")}\n`
508
+ });
436
509
  }
437
510
  return quiet ? void 0 : fn(...rendered);
438
511
  });
@@ -1839,8 +1912,7 @@ var RequestTrace = class RequestTrace {
1839
1912
  request: emptyBody(`upstream-${order}-request.bin`, "complete")
1840
1913
  };
1841
1914
  this.manifest.exchanges.push(exchange);
1842
- const bytes = Buffer.from(input.body ?? "");
1843
- this.append(exchange.request, bytes);
1915
+ this.append(exchange.request, input.body ?? "");
1844
1916
  try {
1845
1917
  const response = await (transportContext.getStore()?.fetch(input) ?? fallback());
1846
1918
  exchange.status = response.status;
@@ -1955,16 +2027,17 @@ var RequestTrace = class RequestTrace {
1955
2027
  }
1956
2028
  }, { highWaterMark: 0 });
1957
2029
  }
1958
- append(body, bytes) {
1959
- body.bytes += bytes.byteLength;
2030
+ append(body, data) {
2031
+ const byteLength = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
2032
+ body.bytes += byteLength;
1960
2033
  if (!this.captureDirectory || this.manifest.captureError) return;
1961
- if (body.chunks.length >= 1e5 || this.queuedBytes + bytes.byteLength > maxQueuedBytes) {
2034
+ if (body.chunks.length >= 1e5 || this.queuedBytes + byteLength > maxQueuedBytes) {
1962
2035
  this.manifest.captureError = "capture_queue_limit";
1963
2036
  this.manifest.captureState = "incomplete";
1964
2037
  return;
1965
2038
  }
1966
- body.chunks.push(bytes.byteLength);
1967
- const buffer = Buffer.from(bytes);
2039
+ body.chunks.push(byteLength);
2040
+ const buffer = Buffer.from(data);
1968
2041
  this.queuedBytes += buffer.length;
1969
2042
  this.queue = this.queue.then(async () => {
1970
2043
  await this.checkDirectories();
@@ -2162,7 +2235,8 @@ const maxFetchAttempts = 2;
2162
2235
  const copilotRequestTimeoutMs = 18e4;
2163
2236
  const copilotDispatcher = new Agent({
2164
2237
  allowH2: false,
2165
- connect: { allowH2: false }
2238
+ connect: { allowH2: false },
2239
+ keepAliveTimeout: 5e4
2166
2240
  });
2167
2241
  const getCopilotProviderContext = (config) => ({
2168
2242
  baseUrl: config.copilotBaseUrl,
@@ -2414,6 +2488,11 @@ const parseStringArray = (value) => {
2414
2488
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return;
2415
2489
  return value;
2416
2490
  };
2491
+ const parseReasoningEfforts = (supports) => {
2492
+ if (supports === void 0) return;
2493
+ if (supports.reasoning_effort === false || !Object.hasOwn(supports, "reasoning_effort")) return [];
2494
+ return parseStringArray(supports.reasoning_effort);
2495
+ };
2417
2496
  async function loadCopilotModelCatalog(config) {
2418
2497
  const provider = getCopilotProviderContext(config);
2419
2498
  const pending = pendingCatalogs.get(config);
@@ -2437,13 +2516,14 @@ async function loadCopilotModelCatalog(config) {
2437
2516
  const limits = parseModelTokenLimits(capabilities?.limits);
2438
2517
  const supports = isRecord$3(capabilities?.supports) ? capabilities.supports : void 0;
2439
2518
  const supportedEndpoints = parseStringArray(model.supported_endpoints);
2440
- const reasoningEfforts = supports?.reasoning_effort === false ? [] : parseStringArray(supports?.reasoning_effort);
2519
+ const reasoningEfforts = parseReasoningEfforts(supports);
2441
2520
  models$1.set(model.id, {
2442
2521
  ...limits && { limits },
2443
2522
  ...typeof capabilities?.tokenizer === "string" && { tokenizer: capabilities.tokenizer },
2444
2523
  ...supportedEndpoints !== void 0 && { supportedEndpoints },
2445
2524
  ...typeof capabilities?.type === "string" && { type: capabilities.type },
2446
- ...reasoningEfforts !== void 0 && { reasoningEfforts }
2525
+ ...reasoningEfforts !== void 0 && { reasoningEfforts },
2526
+ ...typeof model.name === "string" && model.name !== "" && { name: model.name }
2447
2527
  });
2448
2528
  }
2449
2529
  const catalog = {
@@ -2483,6 +2563,75 @@ async function boundModelOutputTokens(config, model, requested) {
2483
2563
  return bounded;
2484
2564
  }
2485
2565
 
2566
+ //#endregion
2567
+ //#region src/copilot/endpoint.ts
2568
+ const copilotEndpoints = [
2569
+ "/chat/completions",
2570
+ "/responses",
2571
+ "/v1/messages"
2572
+ ];
2573
+ const legacyResponsesPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
2574
+ function selectCopilotEndpoint(config, model) {
2575
+ const capabilities = getCachedCopilotModel(config, model);
2576
+ const advertisedEndpoints = capabilities?.supportedEndpoints;
2577
+ if (capabilities?.type !== void 0 && capabilities.type !== "chat") return { reason: "unsupported-model-type" };
2578
+ const isClaude = model.startsWith("claude-");
2579
+ const protocolMode = config.claudeUpstreamApi ?? "chat-completions";
2580
+ if (isClaude && protocolMode === "messages") return {
2581
+ endpoint: "/v1/messages",
2582
+ source: "policy",
2583
+ responsesFallback: false
2584
+ };
2585
+ if (isClaude && protocolMode === "auto" && advertisedEndpoints?.includes("/v1/messages")) return {
2586
+ endpoint: "/v1/messages",
2587
+ source: "catalog",
2588
+ responsesFallback: false
2589
+ };
2590
+ const isChatPinned = isClaude && protocolMode === "chat-completions";
2591
+ const preferredEndpoint = legacyResponsesPattern.test(model) ? "/responses" : "/chat/completions";
2592
+ if (advertisedEndpoints === void 0) return {
2593
+ endpoint: isChatPinned ? "/chat/completions" : preferredEndpoint,
2594
+ source: isChatPinned ? "policy" : "legacy",
2595
+ responsesFallback: !isChatPinned && preferredEndpoint === "/chat/completions"
2596
+ };
2597
+ if (advertisedEndpoints.length === 0) return { reason: "no-advertised-endpoint" };
2598
+ if (isChatPinned) {
2599
+ if (advertisedEndpoints.includes("/chat/completions")) return {
2600
+ endpoint: "/chat/completions",
2601
+ source: "policy",
2602
+ responsesFallback: false
2603
+ };
2604
+ return { reason: advertisedEndpoints.some((endpoint) => endpoint === "/responses" || endpoint === "/v1/messages") ? "protocol-policy-conflict" : "unsupported-relay-endpoint" };
2605
+ }
2606
+ const selectedEndpoint = [preferredEndpoint, preferredEndpoint === "/responses" ? "/chat/completions" : "/responses"].find((endpoint) => advertisedEndpoints.includes(endpoint));
2607
+ if (!selectedEndpoint) return { reason: "unsupported-relay-endpoint" };
2608
+ return {
2609
+ endpoint: selectedEndpoint,
2610
+ source: "catalog",
2611
+ responsesFallback: selectedEndpoint === "/chat/completions" && advertisedEndpoints.includes("/responses")
2612
+ };
2613
+ }
2614
+ const unavailableEndpointMessages = {
2615
+ "unsupported-model-type": "The selected upstream model does not advertise a chat interface.",
2616
+ "no-advertised-endpoint": "The selected upstream model advertises no inference endpoints.",
2617
+ "unsupported-relay-endpoint": "The selected upstream model advertises no compatible relay endpoint.",
2618
+ "protocol-policy-conflict": "The selected upstream model has no Chat endpoint allowed by claudeUpstreamApi. Choose an advertised protocol explicitly."
2619
+ };
2620
+ function requireCopilotEndpoint(config, model) {
2621
+ const selection = selectCopilotEndpoint(config, model);
2622
+ if (selection.endpoint) return selection;
2623
+ const message = unavailableEndpointMessages[selection.reason];
2624
+ throw new HTTPError(message, Response.json({
2625
+ type: "error",
2626
+ error: {
2627
+ type: "invalid_request_error",
2628
+ code: "relay_unsupported_endpoint",
2629
+ reason: selection.reason,
2630
+ message
2631
+ }
2632
+ }, { status: 400 }), message);
2633
+ }
2634
+
2486
2635
  //#endregion
2487
2636
  //#region src/lib/terminal.ts
2488
2637
  const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
@@ -2527,6 +2676,7 @@ const reasonLabels = {
2527
2676
  "no-advertised-endpoint": "No advertised route",
2528
2677
  "protocol-policy-conflict": "Protocol policy conflict",
2529
2678
  "unsupported-api": "Unsupported API",
2679
+ "model-not-supported": "Model not supported",
2530
2680
  "unsupported-effort": "Unsupported effort",
2531
2681
  "unsafe-or-noncanonical-id": "Unsupported model ID",
2532
2682
  "upstream-response-failed": "Upstream failed",
@@ -2626,6 +2776,7 @@ const probeHint = (code) => {
2626
2776
  if (code === "refusal") return "Refusal: inspect the failing request's evidence; a short probe does not explain its cause.";
2627
2777
  if (code.includes("budget-exhausted")) return "Output limit: reachability is not completion; review the probe's output budget.";
2628
2778
  if (code === "model-mismatch") return "Model mismatch: compare selected, sent and reported IDs with --details.";
2779
+ if (code === "model-not-supported") return "Model not supported: upstream lists this ID but rejects inference for it; choose another model.";
2629
2780
  if (apiFailureCodes.includes(code) || /^HTTP-5\d\d$/.test(code)) return "API failure: the request ID locates the local summary; --details shows evidence on a new probe.";
2630
2781
  };
2631
2782
 
@@ -3905,6 +4056,12 @@ const getEncodeChatFunction = async (encoding) => {
3905
4056
  encodingCache.set(encoding, encoder);
3906
4057
  return encoder;
3907
4058
  };
4059
+ const preloadTokenizers = async (reported) => {
4060
+ const encodings = new Set(["o200k_base"]);
4061
+ for (const tokenizer of reported) if (tokenizer !== void 0 && isSupportedTokenizer(tokenizer)) encodings.add(tokenizer);
4062
+ for (const encoding of encodings) await getEncodeChatFunction(encoding);
4063
+ };
4064
+ const loadedTokenizers = () => [...encodingCache.keys()];
3908
4065
  const getTokenizerFromModel = (model) => model.capabilities.tokenizer || "o200k_base";
3909
4066
  const getModelConstants = (model) => model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
3910
4067
  funcInit: 10,
@@ -4001,75 +4158,6 @@ const getTokenCount = async (payload, model) => {
4001
4158
  };
4002
4159
  };
4003
4160
 
4004
- //#endregion
4005
- //#region src/copilot/endpoint.ts
4006
- const copilotEndpoints = [
4007
- "/chat/completions",
4008
- "/responses",
4009
- "/v1/messages"
4010
- ];
4011
- const legacyResponsesPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
4012
- function selectCopilotEndpoint(config, model) {
4013
- const capabilities = getCachedCopilotModel(config, model);
4014
- const advertisedEndpoints = capabilities?.supportedEndpoints;
4015
- if (capabilities?.type !== void 0 && capabilities.type !== "chat") return { reason: "unsupported-model-type" };
4016
- const isClaude = model.startsWith("claude-");
4017
- const protocolMode = config.claudeUpstreamApi ?? "chat-completions";
4018
- if (isClaude && protocolMode === "messages") return {
4019
- endpoint: "/v1/messages",
4020
- source: "policy",
4021
- responsesFallback: false
4022
- };
4023
- if (isClaude && protocolMode === "auto" && advertisedEndpoints?.includes("/v1/messages")) return {
4024
- endpoint: "/v1/messages",
4025
- source: "catalog",
4026
- responsesFallback: false
4027
- };
4028
- const isChatPinned = isClaude && protocolMode === "chat-completions";
4029
- const preferredEndpoint = legacyResponsesPattern.test(model) ? "/responses" : "/chat/completions";
4030
- if (advertisedEndpoints === void 0) return {
4031
- endpoint: isChatPinned ? "/chat/completions" : preferredEndpoint,
4032
- source: isChatPinned ? "policy" : "legacy",
4033
- responsesFallback: !isChatPinned && preferredEndpoint === "/chat/completions"
4034
- };
4035
- if (advertisedEndpoints.length === 0) return { reason: "no-advertised-endpoint" };
4036
- if (isChatPinned) {
4037
- if (advertisedEndpoints.includes("/chat/completions")) return {
4038
- endpoint: "/chat/completions",
4039
- source: "policy",
4040
- responsesFallback: false
4041
- };
4042
- return { reason: advertisedEndpoints.some((endpoint) => endpoint === "/responses" || endpoint === "/v1/messages") ? "protocol-policy-conflict" : "unsupported-relay-endpoint" };
4043
- }
4044
- const selectedEndpoint = [preferredEndpoint, preferredEndpoint === "/responses" ? "/chat/completions" : "/responses"].find((endpoint) => advertisedEndpoints.includes(endpoint));
4045
- if (!selectedEndpoint) return { reason: "unsupported-relay-endpoint" };
4046
- return {
4047
- endpoint: selectedEndpoint,
4048
- source: "catalog",
4049
- responsesFallback: selectedEndpoint === "/chat/completions" && advertisedEndpoints.includes("/responses")
4050
- };
4051
- }
4052
- const unavailableEndpointMessages = {
4053
- "unsupported-model-type": "The selected upstream model does not advertise a chat interface.",
4054
- "no-advertised-endpoint": "The selected upstream model advertises no inference endpoints.",
4055
- "unsupported-relay-endpoint": "The selected upstream model advertises no compatible relay endpoint.",
4056
- "protocol-policy-conflict": "The selected upstream model has no Chat endpoint allowed by claudeUpstreamApi. Choose an advertised protocol explicitly."
4057
- };
4058
- function requireCopilotEndpoint(config, model) {
4059
- const selection = selectCopilotEndpoint(config, model);
4060
- if (selection.endpoint) return selection;
4061
- const message = unavailableEndpointMessages[selection.reason];
4062
- throw new HTTPError(message, Response.json({
4063
- type: "error",
4064
- error: {
4065
- type: "invalid_request_error",
4066
- code: "relay_unsupported_endpoint",
4067
- reason: selection.reason,
4068
- message
4069
- }
4070
- }, { status: 400 }), message);
4071
- }
4072
-
4073
4161
  //#endregion
4074
4162
  //#region src/copilot/tool-schema.ts
4075
4163
  const schemaMapKeywords = new Set([
@@ -4120,17 +4208,45 @@ function hasUnsupportedPatternFeatures(pattern) {
4120
4208
  }
4121
4209
  return false;
4122
4210
  }
4211
+ const removed = Symbol("removed");
4212
+ function mapRecord(record$3, map) {
4213
+ const keys = Object.keys(record$3);
4214
+ let entries;
4215
+ for (let index = 0; index < keys.length; index++) {
4216
+ const key = keys[index];
4217
+ const value = record$3[key];
4218
+ const mapped = map(key, value);
4219
+ if (!entries && Object.is(mapped, value)) continue;
4220
+ entries ??= keys.slice(0, index).map((kept) => [kept, record$3[kept]]);
4221
+ if (mapped !== removed) entries.push([key, mapped]);
4222
+ }
4223
+ return entries ? Object.fromEntries(entries) : record$3;
4224
+ }
4225
+ function mapArray(values) {
4226
+ let copy;
4227
+ for (let index = 0; index < values.length; index++) {
4228
+ if (!(index in values)) continue;
4229
+ const value = values[index];
4230
+ const mapped = normalizeSchemaValue(value);
4231
+ if (!copy && Object.is(mapped, value)) continue;
4232
+ copy ??= values.slice(0, index);
4233
+ copy[index] = mapped;
4234
+ }
4235
+ if (!copy) return values;
4236
+ copy.length = values.length;
4237
+ return copy;
4238
+ }
4123
4239
  function normalizeSchemaValue(value) {
4124
- if (Array.isArray(value)) return value.map(normalizeSchemaValue);
4240
+ if (Array.isArray(value)) return mapArray(value);
4125
4241
  return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
4126
4242
  }
4127
4243
  function normalizeResponsesToolSchema(schema) {
4128
- return Object.fromEntries(Object.entries(schema).flatMap(([key, value]) => {
4129
- if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return [];
4130
- if (schemaMapKeywords.has(key) && isRecord$1(value)) return [[key, Object.fromEntries(Object.entries(value).map(([name, nested]) => [name, normalizeSchemaValue(nested)]))]];
4131
- if (nestedSchemaKeywords.has(key)) return [[key, normalizeSchemaValue(value)]];
4132
- return [[key, value]];
4133
- }));
4244
+ return mapRecord(schema, (key, value) => {
4245
+ if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
4246
+ if (schemaMapKeywords.has(key) && isRecord$1(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
4247
+ if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
4248
+ return value;
4249
+ });
4134
4250
  }
4135
4251
 
4136
4252
  //#endregion
@@ -5824,6 +5940,13 @@ const settleDiagnostic = async (trace) => {
5824
5940
  clearTimeout(timer);
5825
5941
  }
5826
5942
  };
5943
+ const datedSnapshot = /^(.+)-\d{4}-\d{2}-\d{2}$/;
5944
+ const reportsSelectedModel = (endpoint, id, reported) => {
5945
+ if (normalizeCopilotModelId(reported) === id) return true;
5946
+ if (endpoint === "/v1/messages" && id === "claude-opus-5.5" && reported === "claude-opus-5-5") return true;
5947
+ if (endpoint === "/responses" && id === "gpt-5.6-sol-fast" && normalizeCopilotModelId(reported) === "gpt-5.6-sol") return true;
5948
+ return !datedSnapshot.test(id) && datedSnapshot.exec(reported)?.[1] === id;
5949
+ };
5827
5950
  const failureFromEvidence = (row, diagnostic) => {
5828
5951
  if ([
5829
5952
  "probe-timeout",
@@ -5869,7 +5992,8 @@ async function probeModels(config, entries, options) {
5869
5992
  "xhigh",
5870
5993
  "max"
5871
5994
  ].filter((effort$1) => model.reasoningEfforts?.includes(effort$1));
5872
- const defaultProbeEffort = advertisesNoReasoningEffort(model) ? void 0 : recognizedEfforts.find(isReasoningEffort) ?? "low";
5995
+ const lowestEffort = recognizedEfforts.find((effort$1) => effort$1 !== "none") ?? recognizedEfforts.find(isReasoningEffort);
5996
+ const defaultProbeEffort = advertisesNoReasoningEffort(model) ? void 0 : lowestEffort ?? "low";
5873
5997
  const effort = options.effort ?? defaultProbeEffort;
5874
5998
  const maxTokens = Math.min(options.maxTokens, model.limits?.max_output_tokens ?? options.maxTokens, model.limits?.max_non_streaming_output_tokens ?? options.maxTokens);
5875
5999
  const timeoutMs = Math.min(options.timeoutMs, config.upstreamTimeoutMs > 0 ? config.upstreamTimeoutMs : Infinity);
@@ -5959,11 +6083,11 @@ async function probeModels(config, entries, options) {
5959
6083
  row.detail = "upstream-response-incomplete";
5960
6084
  }
5961
6085
  if (code === "unsupported_api_for_model") row.detail = "unsupported-api";
6086
+ if (code === "model_not_supported") row.detail = "model-not-supported";
5962
6087
  } else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
5963
6088
  else {
5964
6089
  row.reported = safeId(body.model, config.copilotToken) ? body.model : "unreported";
5965
- const nativeOpusSpelling = endpoint === "/v1/messages" && id === "claude-opus-5.5" && row.reported === "claude-opus-5-5";
5966
- if (normalizeCopilotModelId(row.reported) !== id && !nativeOpusSpelling) row.detail = "model-mismatch";
6090
+ if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
5967
6091
  else if (body.stop_reason === "max_tokens") {
5968
6092
  row.status = "INCOMPLETE";
5969
6093
  const outputTokens = record$1(body.usage) ? body.usage.output_tokens : void 0;
@@ -6022,14 +6146,139 @@ async function probeModels(config, entries, options) {
6022
6146
  return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
6023
6147
  }
6024
6148
 
6149
+ //#endregion
6150
+ //#region src/lib/model-listing.ts
6151
+ const normalizeModelSearch = (text$1) => text$1.toLowerCase().replace(/[^a-z0-9]/g, "");
6152
+ const contains = (model, fragment) => normalizeModelSearch(model.id).includes(fragment) || normalizeModelSearch(model.name ?? "").includes(fragment);
6153
+ const minimumFragment = 4;
6154
+ const firstMatches = (models$1, fragments) => {
6155
+ for (const fragment of fragments) {
6156
+ const matches = models$1.filter((model) => contains(model, fragment));
6157
+ if (matches.length > 0) return matches;
6158
+ }
6159
+ return [];
6160
+ };
6161
+ const closestModels = (models$1, search) => {
6162
+ const starts = [];
6163
+ const ends = [];
6164
+ for (let length = search.length - 1; length >= minimumFragment; length--) {
6165
+ starts.push(search.slice(0, length));
6166
+ ends.push(search.slice(search.length - length));
6167
+ }
6168
+ const near = new Set([...firstMatches(models$1, starts), ...firstMatches(models$1, ends)]);
6169
+ return models$1.filter((model) => near.has(model));
6170
+ };
6171
+ const searchModels = (models$1, search) => {
6172
+ const wanted = normalizeModelSearch(search);
6173
+ const matches = models$1.filter((model) => contains(model, wanted));
6174
+ if (matches.length === 0) return {
6175
+ found: false,
6176
+ models: closestModels(models$1, wanted)
6177
+ };
6178
+ const usable = matches.filter((model) => model.unusable === void 0);
6179
+ return {
6180
+ found: true,
6181
+ models: matches,
6182
+ chosen: usable.find((model) => normalizeModelSearch(model.id) === wanted) ?? (usable.length === 1 ? usable[0] : void 0)
6183
+ };
6184
+ };
6185
+ const maximumIdWidth = 40;
6186
+ const lowerFirst = (text$1) => text$1.charAt(0).toLowerCase() + text$1.slice(1);
6187
+ const detailsOf = (model) => {
6188
+ const details = [];
6189
+ if (model.name !== void 0) details.push(model.name);
6190
+ if (model.unusable !== void 0) details.push(`cannot be gptModel or opusModel: ${lowerFirst(model.unusable)}`);
6191
+ return details.join(" · ");
6192
+ };
6193
+ const renderModelRows = (models$1) => {
6194
+ const described = models$1.filter((model) => detailsOf(model) !== "");
6195
+ const width = Math.min(maximumIdWidth, Math.max(0, ...described.map((model) => model.id.length)));
6196
+ return models$1.map((model) => {
6197
+ const details = detailsOf(model);
6198
+ if (details === "") return model.id;
6199
+ return `${model.id.padEnd(width)} ${details}`;
6200
+ });
6201
+ };
6202
+ const routes = {
6203
+ gptModel: "requests without \"opus\" in the model name",
6204
+ opusModel: "requests with \"opus\" in the model name"
6205
+ };
6206
+ const applyNote = "A running relay applies the change to new requests; restarting reruns the startup check.";
6207
+ const keyFor = (id) => id.toLowerCase().includes("opus") ? "opusModel" : "gptModel";
6208
+ const problemOf = (model) => {
6209
+ if (!model.advertised) return "is not advertised by upstream";
6210
+ if (model.unusable !== void 0) return `cannot be used (${lowerFirst(model.unusable)})`;
6211
+ };
6212
+ const settingOf = (model) => `${model.key}: ${model.value}`;
6213
+ const renderConfigGuide = (configPath$1, configured, chosen) => {
6214
+ const width = Math.max(...configured.map((model) => settingOf(model).length));
6215
+ const lines = [`Config file: ${configPath$1}`];
6216
+ for (const model of configured) lines.push(` ${settingOf(model).padEnd(width)} # ${routes[model.key]}`);
6217
+ for (const model of configured) {
6218
+ const problem = problemOf(model);
6219
+ if (problem !== void 0) lines.push(` Warning: ${model.key} ${model.value} ${problem}; the startup check rejects it.`);
6220
+ }
6221
+ if (chosen === void 0) {
6222
+ lines.push("Set gptModel or opusModel to a chat model ID exactly as listed.", applyNote);
6223
+ return lines;
6224
+ }
6225
+ const key = keyFor(chosen.id);
6226
+ if (configured.some((model) => model.key === key && model.value === chosen.id)) {
6227
+ lines.push(`${key} already uses ${chosen.id}.`);
6228
+ return lines;
6229
+ }
6230
+ lines.push(`To use ${chosen.id}, set this line in the config file:`, ` ${key}: ${chosen.id}`, applyNote);
6231
+ return lines;
6232
+ };
6233
+
6025
6234
  //#endregion
6026
6235
  //#region src/models.ts
6236
+ var ModelsUsageError = class extends Error {};
6237
+ const displayName = (name) => {
6238
+ const text$1 = sanitizeTerminalString(name ?? "").trim();
6239
+ if (text$1 === "") return;
6240
+ return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
6241
+ };
6242
+ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map((id) => {
6243
+ const selection = selectCopilotEndpoint(config, id);
6244
+ const name = displayName(catalog.models.get(id)?.name);
6245
+ return {
6246
+ id: sanitizeTerminalString(id),
6247
+ ...name !== void 0 && { name },
6248
+ ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6249
+ };
6250
+ });
6251
+ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel"].map((key) => {
6252
+ const id = normalizeCopilotModelId(appConfig[key]);
6253
+ const selection = selectCopilotEndpoint(config, id);
6254
+ return {
6255
+ key,
6256
+ value: sanitizeTerminalString(appConfig[key]),
6257
+ advertised: catalog.models.has(id),
6258
+ ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6259
+ };
6260
+ });
6261
+ const listingHeading = (search, result) => {
6262
+ if (search === "") {
6263
+ if (result.models.length === 0) return "No models advertised by upstream.";
6264
+ return `Upstream-advertised models (${result.models.length}):`;
6265
+ }
6266
+ const label = sanitizeTerminalString(search);
6267
+ if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
6268
+ if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
6269
+ return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
6270
+ };
6027
6271
  const models = defineCommand({
6028
6272
  meta: {
6029
6273
  name: "models",
6030
6274
  description: "List upstream models; use --deep to test inference availability."
6031
6275
  },
6032
6276
  args: {
6277
+ search: {
6278
+ type: "positional",
6279
+ required: false,
6280
+ description: "Find models by ID or display name and print the config line to use."
6281
+ },
6033
6282
  deep: {
6034
6283
  type: "boolean",
6035
6284
  description: "Send real inference probes through an isolated relay pipeline; consumes Copilot usage."
@@ -6076,6 +6325,9 @@ const models = defineCommand({
6076
6325
  args["total-timeout"]
6077
6326
  ].some((value) => value !== void 0))) throw new Error("deep required");
6078
6327
  if (args.effort !== void 0 && !isReasoningEffort(args.effort)) throw new Error("invalid effort");
6328
+ const search = args._.join(" ").trim();
6329
+ if (search !== "" && args.deep) throw new ModelsUsageError("A search lists models; check one with copilot-relay models --deep --model <id>.");
6330
+ if (search !== "" && normalizeModelSearch(search) === "") throw new ModelsUsageError("A search needs at least one letter or digit.");
6079
6331
  const probeOptions = {
6080
6332
  maxTokens: positive(args["max-tokens"], 4096),
6081
6333
  timeoutMs: positive(args.timeout, 30) * 1e3,
@@ -6085,22 +6337,23 @@ const models = defineCommand({
6085
6337
  };
6086
6338
  const prepare = async () => {
6087
6339
  failure = "Could not load relay configuration";
6088
- const appConfig = await readAppConfig();
6089
- setLogLevel(appConfig.logLevel);
6090
- registerSensitiveOrigin(appConfig.copilotBaseUrl);
6091
- const config$1 = readProxyConfig(appConfig);
6340
+ const appConfig$1 = await readAppConfig();
6341
+ setLogLevel(appConfig$1.logLevel);
6342
+ registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
6343
+ const config$1 = readProxyConfig(appConfig$1);
6092
6344
  failure = "Could not authenticate with GitHub Copilot";
6093
6345
  await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
6094
6346
  failure = "Could not fetch upstream model catalog";
6095
6347
  return {
6348
+ appConfig: appConfig$1,
6096
6349
  config: config$1,
6097
6350
  catalog: await loadCopilotModelCatalog(config$1)
6098
6351
  };
6099
6352
  };
6100
- const { config, catalog } = args.deep ? await withoutConsoleLogging(prepare) : await prepare();
6353
+ const { appConfig, config, catalog } = args.deep ? await withoutConsoleLogging(prepare) : await prepare();
6101
6354
  if (args.deep) {
6102
6355
  failure = "Selected model is not advertised by upstream";
6103
- if (args.model !== void 0 && !catalog.models.has(args.model)) throw new Error("unknown model");
6356
+ if (args.model !== void 0 && !catalog.models.has(args.model)) throw new ModelsUsageError("Find the exact ID with copilot-relay models <search>.");
6104
6357
  const entries = [...catalog.models].sort(([a], [b]) => {
6105
6358
  if (a < b) return -1;
6106
6359
  return a > b ? 1 : 0;
@@ -6109,12 +6362,20 @@ const models = defineCommand({
6109
6362
  process.exitCode = await probeModels(config, entries, probeOptions);
6110
6363
  return;
6111
6364
  }
6112
- const ids = [...catalog.models.keys()].sort().map(sanitizeTerminalString);
6113
- console.log(scrubSensitiveUrls(ids.length ? `Upstream-advertised models (${ids.length}):\n${ids.join("\n")}` : "No models advertised by upstream."));
6114
- console.log("Inference, tool, and effort compatibility are not verified by this listing.");
6365
+ const listed = listedModels(config, catalog);
6366
+ const result = search === "" ? {
6367
+ found: true,
6368
+ models: listed
6369
+ } : searchModels(listed, search);
6370
+ const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
6371
+ if (result.models.length > 0) lines.push("Inference, tool, and effort compatibility are not verified by this listing.", `Test one with copilot-relay models --deep --model ${result.chosen?.id ?? "<id>"}; it consumes Copilot usage.`);
6372
+ lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
6373
+ console.log(scrubSensitiveUrls(lines.join("\n")));
6374
+ if (!result.found) process.exitCode = 1;
6115
6375
  } catch (error) {
6116
6376
  let detail = "Check configuration, authentication, and upstream connectivity.";
6117
- if (error instanceof HTTPError) {
6377
+ if (error instanceof ModelsUsageError) detail = error.message;
6378
+ else if (error instanceof HTTPError) {
6118
6379
  if (error.detail !== void 0) detail = error.response.status === 504 ? "request timed out" : "request cancelled";
6119
6380
  else detail = `HTTP ${error.response.status}`;
6120
6381
  await error.response.body?.cancel().catch(() => {});
@@ -6670,9 +6931,9 @@ const replay = defineCommand({
6670
6931
  const result = await replayCapture(args.target);
6671
6932
  console.log(result.verdict);
6672
6933
  if (result.summary) {
6673
- const { method, path: path$1, status: status$1, routes, outcome } = result.summary;
6934
+ const { method, path: path$1, status: status$1, routes: routes$1, outcome } = result.summary;
6674
6935
  console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
6675
- console.log(`upstream=${routes.join(" -> ") || "none"}`);
6936
+ console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
6676
6937
  for (const exchange of result.summary.exchanges) {
6677
6938
  const usage = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
6678
6939
  console.log(`exchange=${exchange.order} path=${exchange.path} status=${exchange.status ?? "unknown"} outcome=${exchange.outcome}${usage ? ` ${usage}` : ""}${exchange.refusalCategory ? ` refusal_category=${exchange.refusalCategory}` : ""}`);
@@ -7306,6 +7567,12 @@ async function startRelay(appConfig) {
7306
7567
  process.exit(1);
7307
7568
  }
7308
7569
  log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
7570
+ try {
7571
+ await preloadTokenizers(getUpstreamModelIds().map((id) => getCachedCopilotModel(config, id)?.tokenizer));
7572
+ log.info(`Tokenizers loaded: ${loadedTokenizers().join(", ")}`);
7573
+ } catch (error) {
7574
+ log.error("Tokenizer preload failed; count_tokens loads tokenizers on first use:", error);
7575
+ }
7309
7576
  const server = await startServer(config);
7310
7577
  await writeRelayPidFile(config);
7311
7578
  log.info(`copilot-relay version: ${appVersion}`);