copilot-relay 0.4.3 → 0.4.5

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
  });
@@ -758,6 +831,7 @@ const normalizeClaudeModelId = (model, contextWindowTokens) => {
758
831
  return contextWindowTokens !== void 0 && contextWindowTokens !== 1e6 ? normalized : `${normalized}[1m]`;
759
832
  };
760
833
  const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
834
+ const isClaudeModelId = (model) => model.startsWith("claude-");
761
835
  const getConfiguredModelRouting = () => getRuntimeState().modelRouting ?? defaultModelRouting;
762
836
  const getModelRouting = () => {
763
837
  const routing = getConfiguredModelRouting();
@@ -1839,8 +1913,7 @@ var RequestTrace = class RequestTrace {
1839
1913
  request: emptyBody(`upstream-${order}-request.bin`, "complete")
1840
1914
  };
1841
1915
  this.manifest.exchanges.push(exchange);
1842
- const bytes = Buffer.from(input.body ?? "");
1843
- this.append(exchange.request, bytes);
1916
+ this.append(exchange.request, input.body ?? "");
1844
1917
  try {
1845
1918
  const response = await (transportContext.getStore()?.fetch(input) ?? fallback());
1846
1919
  exchange.status = response.status;
@@ -1955,16 +2028,17 @@ var RequestTrace = class RequestTrace {
1955
2028
  }
1956
2029
  }, { highWaterMark: 0 });
1957
2030
  }
1958
- append(body, bytes) {
1959
- body.bytes += bytes.byteLength;
2031
+ append(body, data) {
2032
+ const byteLength = typeof data === "string" ? Buffer.byteLength(data) : data.byteLength;
2033
+ body.bytes += byteLength;
1960
2034
  if (!this.captureDirectory || this.manifest.captureError) return;
1961
- if (body.chunks.length >= 1e5 || this.queuedBytes + bytes.byteLength > maxQueuedBytes) {
2035
+ if (body.chunks.length >= 1e5 || this.queuedBytes + byteLength > maxQueuedBytes) {
1962
2036
  this.manifest.captureError = "capture_queue_limit";
1963
2037
  this.manifest.captureState = "incomplete";
1964
2038
  return;
1965
2039
  }
1966
- body.chunks.push(bytes.byteLength);
1967
- const buffer = Buffer.from(bytes);
2040
+ body.chunks.push(byteLength);
2041
+ const buffer = Buffer.from(data);
1968
2042
  this.queuedBytes += buffer.length;
1969
2043
  this.queue = this.queue.then(async () => {
1970
2044
  await this.checkDirectories();
@@ -2162,7 +2236,8 @@ const maxFetchAttempts = 2;
2162
2236
  const copilotRequestTimeoutMs = 18e4;
2163
2237
  const copilotDispatcher = new Agent({
2164
2238
  allowH2: false,
2165
- connect: { allowH2: false }
2239
+ connect: { allowH2: false },
2240
+ keepAliveTimeout: 5e4
2166
2241
  });
2167
2242
  const getCopilotProviderContext = (config) => ({
2168
2243
  baseUrl: config.copilotBaseUrl,
@@ -2414,6 +2489,11 @@ const parseStringArray = (value) => {
2414
2489
  if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) return;
2415
2490
  return value;
2416
2491
  };
2492
+ const parseReasoningEfforts = (supports) => {
2493
+ if (supports === void 0) return;
2494
+ if (supports.reasoning_effort === false || !Object.hasOwn(supports, "reasoning_effort")) return [];
2495
+ return parseStringArray(supports.reasoning_effort);
2496
+ };
2417
2497
  async function loadCopilotModelCatalog(config) {
2418
2498
  const provider = getCopilotProviderContext(config);
2419
2499
  const pending = pendingCatalogs.get(config);
@@ -2437,13 +2517,14 @@ async function loadCopilotModelCatalog(config) {
2437
2517
  const limits = parseModelTokenLimits(capabilities?.limits);
2438
2518
  const supports = isRecord$3(capabilities?.supports) ? capabilities.supports : void 0;
2439
2519
  const supportedEndpoints = parseStringArray(model.supported_endpoints);
2440
- const reasoningEfforts = supports?.reasoning_effort === false ? [] : parseStringArray(supports?.reasoning_effort);
2520
+ const reasoningEfforts = parseReasoningEfforts(supports);
2441
2521
  models$1.set(model.id, {
2442
2522
  ...limits && { limits },
2443
2523
  ...typeof capabilities?.tokenizer === "string" && { tokenizer: capabilities.tokenizer },
2444
2524
  ...supportedEndpoints !== void 0 && { supportedEndpoints },
2445
2525
  ...typeof capabilities?.type === "string" && { type: capabilities.type },
2446
- ...reasoningEfforts !== void 0 && { reasoningEfforts }
2526
+ ...reasoningEfforts !== void 0 && { reasoningEfforts },
2527
+ ...typeof model.name === "string" && model.name !== "" && { name: model.name }
2447
2528
  });
2448
2529
  }
2449
2530
  const catalog = {
@@ -2483,6 +2564,75 @@ async function boundModelOutputTokens(config, model, requested) {
2483
2564
  return bounded;
2484
2565
  }
2485
2566
 
2567
+ //#endregion
2568
+ //#region src/copilot/endpoint.ts
2569
+ const copilotEndpoints = [
2570
+ "/chat/completions",
2571
+ "/responses",
2572
+ "/v1/messages"
2573
+ ];
2574
+ const legacyResponsesPattern = /^(?:gpt-5\.4(?!-mini)|gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
2575
+ function selectCopilotEndpoint(config, model) {
2576
+ const capabilities = getCachedCopilotModel(config, model);
2577
+ const advertisedEndpoints = capabilities?.supportedEndpoints;
2578
+ if (capabilities?.type !== void 0 && capabilities.type !== "chat") return { reason: "unsupported-model-type" };
2579
+ const isClaude = isClaudeModelId(model);
2580
+ const protocolMode = config.claudeUpstreamApi ?? "chat-completions";
2581
+ if (isClaude && protocolMode === "messages") return {
2582
+ endpoint: "/v1/messages",
2583
+ source: "policy",
2584
+ responsesFallback: false
2585
+ };
2586
+ if (isClaude && protocolMode === "auto" && advertisedEndpoints?.includes("/v1/messages")) return {
2587
+ endpoint: "/v1/messages",
2588
+ source: "catalog",
2589
+ responsesFallback: false
2590
+ };
2591
+ const isChatPinned = isClaude && protocolMode === "chat-completions";
2592
+ const preferredEndpoint = legacyResponsesPattern.test(model) ? "/responses" : "/chat/completions";
2593
+ if (advertisedEndpoints === void 0) return {
2594
+ endpoint: isChatPinned ? "/chat/completions" : preferredEndpoint,
2595
+ source: isChatPinned ? "policy" : "legacy",
2596
+ responsesFallback: !isChatPinned && preferredEndpoint === "/chat/completions"
2597
+ };
2598
+ if (advertisedEndpoints.length === 0) return { reason: "no-advertised-endpoint" };
2599
+ if (isChatPinned) {
2600
+ if (advertisedEndpoints.includes("/chat/completions")) return {
2601
+ endpoint: "/chat/completions",
2602
+ source: "policy",
2603
+ responsesFallback: false
2604
+ };
2605
+ return { reason: advertisedEndpoints.some((endpoint) => endpoint === "/responses" || endpoint === "/v1/messages") ? "protocol-policy-conflict" : "unsupported-relay-endpoint" };
2606
+ }
2607
+ const selectedEndpoint = [preferredEndpoint, preferredEndpoint === "/responses" ? "/chat/completions" : "/responses"].find((endpoint) => advertisedEndpoints.includes(endpoint));
2608
+ if (!selectedEndpoint) return { reason: "unsupported-relay-endpoint" };
2609
+ return {
2610
+ endpoint: selectedEndpoint,
2611
+ source: "catalog",
2612
+ responsesFallback: selectedEndpoint === "/chat/completions" && advertisedEndpoints.includes("/responses")
2613
+ };
2614
+ }
2615
+ const unavailableEndpointMessages = {
2616
+ "unsupported-model-type": "The selected upstream model does not advertise a chat interface.",
2617
+ "no-advertised-endpoint": "The selected upstream model advertises no inference endpoints.",
2618
+ "unsupported-relay-endpoint": "The selected upstream model advertises no compatible relay endpoint.",
2619
+ "protocol-policy-conflict": "The selected upstream model has no Chat endpoint allowed by claudeUpstreamApi. Choose an advertised protocol explicitly."
2620
+ };
2621
+ function requireCopilotEndpoint(config, model) {
2622
+ const selection = selectCopilotEndpoint(config, model);
2623
+ if (selection.endpoint) return selection;
2624
+ const message = unavailableEndpointMessages[selection.reason];
2625
+ throw new HTTPError(message, Response.json({
2626
+ type: "error",
2627
+ error: {
2628
+ type: "invalid_request_error",
2629
+ code: "relay_unsupported_endpoint",
2630
+ reason: selection.reason,
2631
+ message
2632
+ }
2633
+ }, { status: 400 }), message);
2634
+ }
2635
+
2486
2636
  //#endregion
2487
2637
  //#region src/lib/terminal.ts
2488
2638
  const colorEnabled = (env = process.env, isTTY = Boolean(process.stdout.isTTY)) => {
@@ -2527,6 +2677,7 @@ const reasonLabels = {
2527
2677
  "no-advertised-endpoint": "No advertised route",
2528
2678
  "protocol-policy-conflict": "Protocol policy conflict",
2529
2679
  "unsupported-api": "Unsupported API",
2680
+ "model-not-supported": "Model not supported",
2530
2681
  "unsupported-effort": "Unsupported effort",
2531
2682
  "unsafe-or-noncanonical-id": "Unsupported model ID",
2532
2683
  "upstream-response-failed": "Upstream failed",
@@ -2626,6 +2777,7 @@ const probeHint = (code) => {
2626
2777
  if (code === "refusal") return "Refusal: inspect the failing request's evidence; a short probe does not explain its cause.";
2627
2778
  if (code.includes("budget-exhausted")) return "Output limit: reachability is not completion; review the probe's output budget.";
2628
2779
  if (code === "model-mismatch") return "Model mismatch: compare selected, sent and reported IDs with --details.";
2780
+ if (code === "model-not-supported") return "Model not supported: upstream lists this ID but rejects inference for it; choose another model.";
2629
2781
  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
2782
  };
2631
2783
 
@@ -2760,7 +2912,7 @@ const createClaudeToolNameMapper = (tools, options = {}) => {
2760
2912
  function translateModelName(model) {
2761
2913
  return routeModelId(model);
2762
2914
  }
2763
- function translateToOpenAI(payload, _settings, toolNameMapper) {
2915
+ function translateToOpenAI(payload, _settings, toolNameMapper, options = {}) {
2764
2916
  const model = translateModelName(payload.model);
2765
2917
  const mapper = toolNameMapper ?? createClaudeToolNameMapper(payload.tools, { ...getToolNameMapperOptionsForModel(model) });
2766
2918
  const tools = translateClaudeToolsToOpenAI(payload.tools, mapper);
@@ -2768,7 +2920,7 @@ function translateToOpenAI(payload, _settings, toolNameMapper) {
2768
2920
  validateClaudeMessages(payload.messages);
2769
2921
  return {
2770
2922
  model,
2771
- messages: normalizeFinalAssistantPrefill$1(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper)),
2923
+ messages: normalizeFinalAssistantPrefill$1(translateClaudeMessagesToOpenAI(payload.messages, payload.system, mapper, options.endpoint === "/chat/completions" && isClaudeModelId(model))),
2772
2924
  max_tokens: payload.max_tokens,
2773
2925
  stop: payload.stop_sequences,
2774
2926
  stream: payload.stream,
@@ -2794,20 +2946,50 @@ function validateClaudeMessages(messages, native = false) {
2794
2946
  if (typeof message.content !== "string" && (!Array.isArray(message.content) || message.content.some((block) => !block || block.type !== "text" || typeof block.text !== "string"))) throw invalidMessage("System message content must contain only text.");
2795
2947
  }
2796
2948
  }
2797
- function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper) {
2798
- const systemMessages = handleSystemPrompt(system);
2799
- const otherMessages = claudeMessages.flatMap((message) => {
2800
- switch (message.role) {
2801
- case "user": return handleUserMessage(message);
2802
- case "assistant": return handleAssistantMessage(message, toolNameMapper);
2803
- case "system":
2804
- if (message.output_config !== void 0 && (typeof message.content === "string" ? message.content.length === 0 : message.content.every((block) => block.text.length === 0))) return [];
2805
- return handleSystemPrompt(message.content);
2806
- default: throw invalidMessage("Unsupported message role.");
2807
- }
2808
- });
2809
- return [...systemMessages, ...otherMessages];
2949
+ function translateClaudeMessagesToOpenAI(claudeMessages, system, toolNameMapper, claudeChatRoute) {
2950
+ const translated = handleSystemPrompt(system);
2951
+ if (claudeChatRoute && hasCacheBreakpoint(system)) markCacheBreakpoint(translated.at(-1));
2952
+ for (const message of claudeMessages) {
2953
+ const start$1 = translated.length;
2954
+ translated.push(...translateClaudeMessage(message, toolNameMapper, claudeChatRoute));
2955
+ if (claudeChatRoute) markMessageBreakpoints(message, translated, start$1);
2956
+ }
2957
+ return translated;
2958
+ }
2959
+ function markMessageBreakpoints(message, translated, start$1) {
2960
+ if (message.role !== "user" || !Array.isArray(message.content)) {
2961
+ if (hasCacheBreakpoint(message.content)) markCacheBreakpoint(translated.at(-1));
2962
+ return;
2963
+ }
2964
+ let toolIndex = start$1;
2965
+ for (const block of message.content) if (block.type === "tool_result") {
2966
+ if (isCacheBreakpoint(block)) markCacheBreakpoint(translated[toolIndex]);
2967
+ toolIndex += 1;
2968
+ } else if (isCacheBreakpoint(block)) markCacheBreakpoint(translated.at(-1));
2969
+ }
2970
+ function translateClaudeMessage(message, toolNameMapper, claudeChatRoute) {
2971
+ switch (message.role) {
2972
+ case "user": return handleUserMessage(message);
2973
+ case "assistant": return handleAssistantMessage(message, toolNameMapper);
2974
+ case "system":
2975
+ if (message.output_config !== void 0 && (typeof message.content === "string" ? message.content.length === 0 : message.content.every((block) => block.text.length === 0))) return [];
2976
+ return claudeChatRoute ? toSystemReminder(message.content) : handleSystemPrompt(message.content);
2977
+ default: throw invalidMessage("Unsupported message role.");
2978
+ }
2979
+ }
2980
+ function toSystemReminder(content) {
2981
+ const text$1 = typeof content === "string" ? content : content.map((block) => block.text).join("\n\n");
2982
+ if (text$1.trim().length === 0) return [];
2983
+ return [{
2984
+ role: "user",
2985
+ content: `<system-reminder>\n${text$1}\n</system-reminder>`
2986
+ }];
2810
2987
  }
2988
+ const isCacheBreakpoint = (block) => "cache_control" in block && Boolean(block.cache_control);
2989
+ const hasCacheBreakpoint = (content) => Array.isArray(content) && content.some(isCacheBreakpoint);
2990
+ const markCacheBreakpoint = (message) => {
2991
+ if (message) message.copilot_cache_control = { type: "ephemeral" };
2992
+ };
2811
2993
  const invalidMessage = (message) => new HTTPError(message, new Response(JSON.stringify({
2812
2994
  type: "error",
2813
2995
  error: {
@@ -3905,6 +4087,12 @@ const getEncodeChatFunction = async (encoding) => {
3905
4087
  encodingCache.set(encoding, encoder);
3906
4088
  return encoder;
3907
4089
  };
4090
+ const preloadTokenizers = async (reported) => {
4091
+ const encodings = new Set(["o200k_base"]);
4092
+ for (const tokenizer of reported) if (tokenizer !== void 0 && isSupportedTokenizer(tokenizer)) encodings.add(tokenizer);
4093
+ for (const encoding of encodings) await getEncodeChatFunction(encoding);
4094
+ };
4095
+ const loadedTokenizers = () => [...encodingCache.keys()];
3908
4096
  const getTokenizerFromModel = (model) => model.capabilities.tokenizer || "o200k_base";
3909
4097
  const getModelConstants = (model) => model.id === "gpt-3.5-turbo" || model.id === "gpt-4" ? {
3910
4098
  funcInit: 10,
@@ -4001,75 +4189,6 @@ const getTokenCount = async (payload, model) => {
4001
4189
  };
4002
4190
  };
4003
4191
 
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
4192
  //#endregion
4074
4193
  //#region src/copilot/tool-schema.ts
4075
4194
  const schemaMapKeywords = new Set([
@@ -4120,17 +4239,45 @@ function hasUnsupportedPatternFeatures(pattern) {
4120
4239
  }
4121
4240
  return false;
4122
4241
  }
4242
+ const removed = Symbol("removed");
4243
+ function mapRecord(record$3, map) {
4244
+ const keys = Object.keys(record$3);
4245
+ let entries;
4246
+ for (let index = 0; index < keys.length; index++) {
4247
+ const key = keys[index];
4248
+ const value = record$3[key];
4249
+ const mapped = map(key, value);
4250
+ if (!entries && Object.is(mapped, value)) continue;
4251
+ entries ??= keys.slice(0, index).map((kept) => [kept, record$3[kept]]);
4252
+ if (mapped !== removed) entries.push([key, mapped]);
4253
+ }
4254
+ return entries ? Object.fromEntries(entries) : record$3;
4255
+ }
4256
+ function mapArray(values) {
4257
+ let copy;
4258
+ for (let index = 0; index < values.length; index++) {
4259
+ if (!(index in values)) continue;
4260
+ const value = values[index];
4261
+ const mapped = normalizeSchemaValue(value);
4262
+ if (!copy && Object.is(mapped, value)) continue;
4263
+ copy ??= values.slice(0, index);
4264
+ copy[index] = mapped;
4265
+ }
4266
+ if (!copy) return values;
4267
+ copy.length = values.length;
4268
+ return copy;
4269
+ }
4123
4270
  function normalizeSchemaValue(value) {
4124
- if (Array.isArray(value)) return value.map(normalizeSchemaValue);
4271
+ if (Array.isArray(value)) return mapArray(value);
4125
4272
  return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
4126
4273
  }
4127
4274
  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
- }));
4275
+ return mapRecord(schema, (key, value) => {
4276
+ if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return removed;
4277
+ if (schemaMapKeywords.has(key) && isRecord$1(value)) return mapRecord(value, (_name, nested) => normalizeSchemaValue(nested));
4278
+ if (nestedSchemaKeywords.has(key)) return normalizeSchemaValue(value);
4279
+ return value;
4280
+ });
4134
4281
  }
4135
4282
 
4136
4283
  //#endregion
@@ -4702,14 +4849,14 @@ async function logUpstreamError(message, response, context, signal, timeoutMs) {
4702
4849
  route: context.route,
4703
4850
  model: context.model,
4704
4851
  requestId: context.requestId,
4705
- request: context.request,
4706
4852
  response: {
4707
4853
  status: response.status,
4708
4854
  statusText: response.statusText || void 0,
4709
4855
  url: response.url || void 0,
4710
4856
  headers: Object.fromEntries(response.headers.entries()),
4711
4857
  body: errorBody || void 0
4712
- }
4858
+ },
4859
+ request: context.request
4713
4860
  });
4714
4861
  return detail;
4715
4862
  }
@@ -5336,6 +5483,10 @@ const createQueuedClaudeStreamWriter = (write) => {
5336
5483
  const writeClaudeStreamEvents = async (events$1, writeEvent) => {
5337
5484
  for (const event of events$1) await writeEvent(event);
5338
5485
  };
5486
+ const translationEndpoint = (config, model) => {
5487
+ const selection = selectCopilotEndpoint(config, normalizeCopilotModelId(model));
5488
+ return selection.endpoint && !selection.responsesFallback ? selection.endpoint : void 0;
5489
+ };
5339
5490
  const handleClaudeMessageRequest = async (config, claudePayload, requestSignal, requestId, writeEvent, requestHeaders) => {
5340
5491
  const upstreamModel = translateModelName(claudePayload.model);
5341
5492
  if (shouldUseNativeMessages(config, upstreamModel)) return handleNativeMessages(config, {
@@ -5349,7 +5500,7 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
5349
5500
  const shouldLetModelDecideWebSearch = hasClaudeWebSearch(claudePayload);
5350
5501
  const decisionPayload = shouldLetModelDecideWebSearch ? prepareClaudeWebSearchDecisionPayload(claudePayload) : claudePayload;
5351
5502
  const toolNameMapper = createClaudeToolNameMapper(decisionPayload.tools, { ...getToolNameMapperOptionsForModel(upstreamModel) });
5352
- const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper);
5503
+ const openAIPayload = translateToOpenAI(decisionPayload, void 0, toolNameMapper, { endpoint: translationEndpoint(config, upstreamModel) });
5353
5504
  const canStreamWebSearchDecision = shouldLetModelDecideWebSearch && !!writeEvent;
5354
5505
  if (shouldLetModelDecideWebSearch && !canStreamWebSearchDecision) openAIPayload.stream = false;
5355
5506
  const response = await createChatCompletions(config, openAIPayload, {
@@ -5601,7 +5752,7 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
5601
5752
  const { output_config: _outputConfig, clear_at: _clearAt,...textMessage } = message;
5602
5753
  return textMessage;
5603
5754
  })
5604
- } : claudePayload);
5755
+ } : claudePayload, void 0, void 0, { endpoint: translationEndpoint(c.get("config"), translateModelName(claudePayload.model)) });
5605
5756
  const exposedModels = getExposedModelIds();
5606
5757
  const upstreamModel = getCachedCopilotModel(c.get("config"), openAIPayload.model);
5607
5758
  const hasDiscoveredTokenizer = upstreamModel?.tokenizer !== void 0 && isSupportedTokenizer(upstreamModel.tokenizer);
@@ -5824,6 +5975,13 @@ const settleDiagnostic = async (trace) => {
5824
5975
  clearTimeout(timer);
5825
5976
  }
5826
5977
  };
5978
+ const datedSnapshot = /^(.+)-\d{4}-\d{2}-\d{2}$/;
5979
+ const reportsSelectedModel = (endpoint, id, reported) => {
5980
+ if (normalizeCopilotModelId(reported) === id) return true;
5981
+ if (endpoint === "/v1/messages" && id === "claude-opus-5.5" && reported === "claude-opus-5-5") return true;
5982
+ if (endpoint === "/responses" && id === "gpt-5.6-sol-fast" && normalizeCopilotModelId(reported) === "gpt-5.6-sol") return true;
5983
+ return !datedSnapshot.test(id) && datedSnapshot.exec(reported)?.[1] === id;
5984
+ };
5827
5985
  const failureFromEvidence = (row, diagnostic) => {
5828
5986
  if ([
5829
5987
  "probe-timeout",
@@ -5869,7 +6027,8 @@ async function probeModels(config, entries, options) {
5869
6027
  "xhigh",
5870
6028
  "max"
5871
6029
  ].filter((effort$1) => model.reasoningEfforts?.includes(effort$1));
5872
- const defaultProbeEffort = advertisesNoReasoningEffort(model) ? void 0 : recognizedEfforts.find(isReasoningEffort) ?? "low";
6030
+ const lowestEffort = recognizedEfforts.find((effort$1) => effort$1 !== "none") ?? recognizedEfforts.find(isReasoningEffort);
6031
+ const defaultProbeEffort = advertisesNoReasoningEffort(model) ? void 0 : lowestEffort ?? "low";
5873
6032
  const effort = options.effort ?? defaultProbeEffort;
5874
6033
  const maxTokens = Math.min(options.maxTokens, model.limits?.max_output_tokens ?? options.maxTokens, model.limits?.max_non_streaming_output_tokens ?? options.maxTokens);
5875
6034
  const timeoutMs = Math.min(options.timeoutMs, config.upstreamTimeoutMs > 0 ? config.upstreamTimeoutMs : Infinity);
@@ -5959,11 +6118,11 @@ async function probeModels(config, entries, options) {
5959
6118
  row.detail = "upstream-response-incomplete";
5960
6119
  }
5961
6120
  if (code === "unsupported_api_for_model") row.detail = "unsupported-api";
6121
+ if (code === "model_not_supported") row.detail = "model-not-supported";
5962
6122
  } else if (!record$1(body) || !Array.isArray(body.content)) row.detail = "malformed-response";
5963
6123
  else {
5964
6124
  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";
6125
+ if (!reportsSelectedModel(endpoint, id, row.reported)) row.detail = "model-mismatch";
5967
6126
  else if (body.stop_reason === "max_tokens") {
5968
6127
  row.status = "INCOMPLETE";
5969
6128
  const outputTokens = record$1(body.usage) ? body.usage.output_tokens : void 0;
@@ -6022,14 +6181,139 @@ async function probeModels(config, entries, options) {
6022
6181
  return results.length > 0 && results.every((row) => row.status === "PASS") ? 0 : 2;
6023
6182
  }
6024
6183
 
6184
+ //#endregion
6185
+ //#region src/lib/model-listing.ts
6186
+ const normalizeModelSearch = (text$1) => text$1.toLowerCase().replace(/[^a-z0-9]/g, "");
6187
+ const contains = (model, fragment) => normalizeModelSearch(model.id).includes(fragment) || normalizeModelSearch(model.name ?? "").includes(fragment);
6188
+ const minimumFragment = 4;
6189
+ const firstMatches = (models$1, fragments) => {
6190
+ for (const fragment of fragments) {
6191
+ const matches = models$1.filter((model) => contains(model, fragment));
6192
+ if (matches.length > 0) return matches;
6193
+ }
6194
+ return [];
6195
+ };
6196
+ const closestModels = (models$1, search) => {
6197
+ const starts = [];
6198
+ const ends = [];
6199
+ for (let length = search.length - 1; length >= minimumFragment; length--) {
6200
+ starts.push(search.slice(0, length));
6201
+ ends.push(search.slice(search.length - length));
6202
+ }
6203
+ const near = new Set([...firstMatches(models$1, starts), ...firstMatches(models$1, ends)]);
6204
+ return models$1.filter((model) => near.has(model));
6205
+ };
6206
+ const searchModels = (models$1, search) => {
6207
+ const wanted = normalizeModelSearch(search);
6208
+ const matches = models$1.filter((model) => contains(model, wanted));
6209
+ if (matches.length === 0) return {
6210
+ found: false,
6211
+ models: closestModels(models$1, wanted)
6212
+ };
6213
+ const usable = matches.filter((model) => model.unusable === void 0);
6214
+ return {
6215
+ found: true,
6216
+ models: matches,
6217
+ chosen: usable.find((model) => normalizeModelSearch(model.id) === wanted) ?? (usable.length === 1 ? usable[0] : void 0)
6218
+ };
6219
+ };
6220
+ const maximumIdWidth = 40;
6221
+ const lowerFirst = (text$1) => text$1.charAt(0).toLowerCase() + text$1.slice(1);
6222
+ const detailsOf = (model) => {
6223
+ const details = [];
6224
+ if (model.name !== void 0) details.push(model.name);
6225
+ if (model.unusable !== void 0) details.push(`cannot be gptModel or opusModel: ${lowerFirst(model.unusable)}`);
6226
+ return details.join(" · ");
6227
+ };
6228
+ const renderModelRows = (models$1) => {
6229
+ const described = models$1.filter((model) => detailsOf(model) !== "");
6230
+ const width = Math.min(maximumIdWidth, Math.max(0, ...described.map((model) => model.id.length)));
6231
+ return models$1.map((model) => {
6232
+ const details = detailsOf(model);
6233
+ if (details === "") return model.id;
6234
+ return `${model.id.padEnd(width)} ${details}`;
6235
+ });
6236
+ };
6237
+ const routes = {
6238
+ gptModel: "requests without \"opus\" in the model name",
6239
+ opusModel: "requests with \"opus\" in the model name"
6240
+ };
6241
+ const applyNote = "A running relay applies the change to new requests; restarting reruns the startup check.";
6242
+ const keyFor = (id) => id.toLowerCase().includes("opus") ? "opusModel" : "gptModel";
6243
+ const problemOf = (model) => {
6244
+ if (!model.advertised) return "is not advertised by upstream";
6245
+ if (model.unusable !== void 0) return `cannot be used (${lowerFirst(model.unusable)})`;
6246
+ };
6247
+ const settingOf = (model) => `${model.key}: ${model.value}`;
6248
+ const renderConfigGuide = (configPath$1, configured, chosen) => {
6249
+ const width = Math.max(...configured.map((model) => settingOf(model).length));
6250
+ const lines = [`Config file: ${configPath$1}`];
6251
+ for (const model of configured) lines.push(` ${settingOf(model).padEnd(width)} # ${routes[model.key]}`);
6252
+ for (const model of configured) {
6253
+ const problem = problemOf(model);
6254
+ if (problem !== void 0) lines.push(` Warning: ${model.key} ${model.value} ${problem}; the startup check rejects it.`);
6255
+ }
6256
+ if (chosen === void 0) {
6257
+ lines.push("Set gptModel or opusModel to a chat model ID exactly as listed.", applyNote);
6258
+ return lines;
6259
+ }
6260
+ const key = keyFor(chosen.id);
6261
+ if (configured.some((model) => model.key === key && model.value === chosen.id)) {
6262
+ lines.push(`${key} already uses ${chosen.id}.`);
6263
+ return lines;
6264
+ }
6265
+ lines.push(`To use ${chosen.id}, set this line in the config file:`, ` ${key}: ${chosen.id}`, applyNote);
6266
+ return lines;
6267
+ };
6268
+
6025
6269
  //#endregion
6026
6270
  //#region src/models.ts
6271
+ var ModelsUsageError = class extends Error {};
6272
+ const displayName = (name) => {
6273
+ const text$1 = sanitizeTerminalString(name ?? "").trim();
6274
+ if (text$1 === "") return;
6275
+ return text$1.length > 80 ? `${text$1.slice(0, 77)}...` : text$1;
6276
+ };
6277
+ const listedModels = (config, catalog) => [...catalog.models.keys()].sort().map((id) => {
6278
+ const selection = selectCopilotEndpoint(config, id);
6279
+ const name = displayName(catalog.models.get(id)?.name);
6280
+ return {
6281
+ id: sanitizeTerminalString(id),
6282
+ ...name !== void 0 && { name },
6283
+ ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6284
+ };
6285
+ });
6286
+ const configuredModels = (appConfig, config, catalog) => ["gptModel", "opusModel"].map((key) => {
6287
+ const id = normalizeCopilotModelId(appConfig[key]);
6288
+ const selection = selectCopilotEndpoint(config, id);
6289
+ return {
6290
+ key,
6291
+ value: sanitizeTerminalString(appConfig[key]),
6292
+ advertised: catalog.models.has(id),
6293
+ ...!selection.endpoint && { unusable: probeReason(selection.reason) }
6294
+ };
6295
+ });
6296
+ const listingHeading = (search, result) => {
6297
+ if (search === "") {
6298
+ if (result.models.length === 0) return "No models advertised by upstream.";
6299
+ return `Upstream-advertised models (${result.models.length}):`;
6300
+ }
6301
+ const label = sanitizeTerminalString(search);
6302
+ if (result.found) return `Upstream models matching "${label}" (${result.models.length}):`;
6303
+ if (result.models.length === 0) return `No upstream model matches "${label}". List them all with copilot-relay models.`;
6304
+ return `No upstream model matches "${label}". Closest IDs (${result.models.length}):`;
6305
+ };
6027
6306
  const models = defineCommand({
6028
6307
  meta: {
6029
6308
  name: "models",
6030
6309
  description: "List upstream models; use --deep to test inference availability."
6031
6310
  },
6032
6311
  args: {
6312
+ search: {
6313
+ type: "positional",
6314
+ required: false,
6315
+ description: "Find models by ID or display name and print the config line to use."
6316
+ },
6033
6317
  deep: {
6034
6318
  type: "boolean",
6035
6319
  description: "Send real inference probes through an isolated relay pipeline; consumes Copilot usage."
@@ -6076,6 +6360,9 @@ const models = defineCommand({
6076
6360
  args["total-timeout"]
6077
6361
  ].some((value) => value !== void 0))) throw new Error("deep required");
6078
6362
  if (args.effort !== void 0 && !isReasoningEffort(args.effort)) throw new Error("invalid effort");
6363
+ const search = args._.join(" ").trim();
6364
+ if (search !== "" && args.deep) throw new ModelsUsageError("A search lists models; check one with copilot-relay models --deep --model <id>.");
6365
+ if (search !== "" && normalizeModelSearch(search) === "") throw new ModelsUsageError("A search needs at least one letter or digit.");
6079
6366
  const probeOptions = {
6080
6367
  maxTokens: positive(args["max-tokens"], 4096),
6081
6368
  timeoutMs: positive(args.timeout, 30) * 1e3,
@@ -6085,22 +6372,23 @@ const models = defineCommand({
6085
6372
  };
6086
6373
  const prepare = async () => {
6087
6374
  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);
6375
+ const appConfig$1 = await readAppConfig();
6376
+ setLogLevel(appConfig$1.logLevel);
6377
+ registerSensitiveOrigin(appConfig$1.copilotBaseUrl);
6378
+ const config$1 = readProxyConfig(appConfig$1);
6092
6379
  failure = "Could not authenticate with GitHub Copilot";
6093
6380
  await setupProxyAuth(config$1, args.deep ? { onDeviceCode: (url, code) => console.error(`Sign in: open ${terminalText(url)} and enter ${terminalText(code)}.`) } : void 0);
6094
6381
  failure = "Could not fetch upstream model catalog";
6095
6382
  return {
6383
+ appConfig: appConfig$1,
6096
6384
  config: config$1,
6097
6385
  catalog: await loadCopilotModelCatalog(config$1)
6098
6386
  };
6099
6387
  };
6100
- const { config, catalog } = args.deep ? await withoutConsoleLogging(prepare) : await prepare();
6388
+ const { appConfig, config, catalog } = args.deep ? await withoutConsoleLogging(prepare) : await prepare();
6101
6389
  if (args.deep) {
6102
6390
  failure = "Selected model is not advertised by upstream";
6103
- if (args.model !== void 0 && !catalog.models.has(args.model)) throw new Error("unknown model");
6391
+ if (args.model !== void 0 && !catalog.models.has(args.model)) throw new ModelsUsageError("Find the exact ID with copilot-relay models <search>.");
6104
6392
  const entries = [...catalog.models].sort(([a], [b]) => {
6105
6393
  if (a < b) return -1;
6106
6394
  return a > b ? 1 : 0;
@@ -6109,12 +6397,20 @@ const models = defineCommand({
6109
6397
  process.exitCode = await probeModels(config, entries, probeOptions);
6110
6398
  return;
6111
6399
  }
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.");
6400
+ const listed = listedModels(config, catalog);
6401
+ const result = search === "" ? {
6402
+ found: true,
6403
+ models: listed
6404
+ } : searchModels(listed, search);
6405
+ const lines = [listingHeading(search, result), ...renderModelRows(result.models)];
6406
+ 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.`);
6407
+ lines.push("", ...renderConfigGuide(paths.configPath, configuredModels(appConfig, config, catalog), result.chosen));
6408
+ console.log(scrubSensitiveUrls(lines.join("\n")));
6409
+ if (!result.found) process.exitCode = 1;
6115
6410
  } catch (error) {
6116
6411
  let detail = "Check configuration, authentication, and upstream connectivity.";
6117
- if (error instanceof HTTPError) {
6412
+ if (error instanceof ModelsUsageError) detail = error.message;
6413
+ else if (error instanceof HTTPError) {
6118
6414
  if (error.detail !== void 0) detail = error.response.status === 504 ? "request timed out" : "request cancelled";
6119
6415
  else detail = `HTTP ${error.response.status}`;
6120
6416
  await error.response.body?.cancel().catch(() => {});
@@ -6670,9 +6966,9 @@ const replay = defineCommand({
6670
6966
  const result = await replayCapture(args.target);
6671
6967
  console.log(result.verdict);
6672
6968
  if (result.summary) {
6673
- const { method, path: path$1, status: status$1, routes, outcome } = result.summary;
6969
+ const { method, path: path$1, status: status$1, routes: routes$1, outcome } = result.summary;
6674
6970
  console.log(`${method} ${path$1} status=${status$1 ?? "unknown"} outcome=${outcome}`);
6675
- console.log(`upstream=${routes.join(" -> ") || "none"}`);
6971
+ console.log(`upstream=${routes$1.join(" -> ") || "none"}`);
6676
6972
  for (const exchange of result.summary.exchanges) {
6677
6973
  const usage = Object.entries(exchange.usage).map(([key, value]) => `${key}=${value}`).join(" ");
6678
6974
  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 +7602,12 @@ async function startRelay(appConfig) {
7306
7602
  process.exit(1);
7307
7603
  }
7308
7604
  log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
7605
+ try {
7606
+ await preloadTokenizers(getUpstreamModelIds().map((id) => getCachedCopilotModel(config, id)?.tokenizer));
7607
+ log.info(`Tokenizers loaded: ${loadedTokenizers().join(", ")}`);
7608
+ } catch (error) {
7609
+ log.error("Tokenizer preload failed; count_tokens loads tokenizers on first use:", error);
7610
+ }
7309
7611
  const server = await startServer(config);
7310
7612
  await writeRelayPidFile(config);
7311
7613
  log.info(`copilot-relay version: ${appVersion}`);