copilot-relay 0.3.4 → 0.3.6

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/README.md CHANGED
@@ -75,7 +75,17 @@ With `claudeSetup: true`, `start` manages `ANTHROPIC_BASE_URL`, a dummy
75
75
  `ANTHROPIC_AUTH_TOKEN` when auth is absent, and the configured GPT default via
76
76
  the top-level `model` field in `~/.claude/settings.json`. Exact
77
77
  `gpt-6-astra` and `gpt-5.6-sol` overrides are normalized to Claude-facing
78
- `[1m]` identities; unrelated model choices are preserved.
78
+ `[1m]` identities when discovery reports exactly 1M; other reported windows use
79
+ plain GPT IDs with Claude's numeric context setting. Unrelated model choices and
80
+ existing token-budget overrides are preserved.
81
+
82
+ Startup retains Copilot's actual context, prompt, and output limits and exposes
83
+ them on `GET /v1/models`. Managed Claude setup seeds
84
+ `CLAUDE_CODE_MAX_CONTEXT_TOKENS` and `CLAUDE_CODE_MAX_OUTPUT_TOKENS` when absent.
85
+ The default models support 1M total context, with up to 128K output tokens for
86
+ Astra and 64K for Opus. Input text is never shortened; smaller explicit output
87
+ budgets remain respected. See [full-context configuration](wiki/EN-Configuration.md)
88
+ for existing/manual Claude setups and the limits on input plus output.
79
89
 
80
90
  ## Config
81
91
 
@@ -116,7 +126,9 @@ Valid `thinkEffort`: `none`, `low`, `medium`, `high`, `xhigh`, `max`.
116
126
 
117
127
  `upstreamTimeoutSeconds` controls the maximum time a single Claude request can
118
128
  spend waiting on upstream Copilot calls, including chat, Responses, preflight,
119
- and bridge-managed WebSearch calls. The default is `180`.
129
+ and bridge-managed WebSearch calls. The default is `180`. Set `0` to disable the
130
+ relay deadline for very long requests; client cancellation and provider/transport
131
+ timeouts still apply. Existing saved timeouts are never changed by an upgrade.
120
132
 
121
133
  `webSearchBackend` controls bridge-managed Claude WebSearch. Leave it empty to
122
134
  use `gptModel`, or set a Copilot Responses model ID such as `gpt-5.5`.
@@ -21,6 +21,7 @@ copilotBaseUrl: https://api.githubcopilot.com
21
21
 
22
22
  # When true, start updates ~/.claude/settings.json with ANTHROPIC_BASE_URL
23
23
  # and a dummy ANTHROPIC_AUTH_TOKEN so Claude Code can use this proxy.
24
+ # Also seeds context/output budgets from model discovery when not already set.
24
25
  claudeSetup: true
25
26
 
26
27
  # Log verbosity:
@@ -43,6 +44,8 @@ logRetentionDays: 3
43
44
  thinkEffort: max
44
45
 
45
46
  # Max seconds to wait for one Claude request's upstream Copilot calls.
47
+ # Set 0 to disable the relay deadline for very long requests. Client cancellation
48
+ # and upstream/provider limits still apply.
46
49
  upstreamTimeoutSeconds: 180
47
50
 
48
51
  # Copilot model used for bridge-managed Claude WebSearch.
package/dist/main.js CHANGED
@@ -551,9 +551,12 @@ const oneMillionContextModelPattern = /^(gpt-5\.6-sol|gpt-6-astra)(?:\[1m\])*$/i
551
551
  const normalizeOneMillionContextModel = (model) => {
552
552
  return oneMillionContextModelPattern.exec(model)?.[1]?.toLowerCase();
553
553
  };
554
- const normalizeClaudeModelId = (model) => {
554
+ const normalizeClaudeModelId = (model, contextWindowTokens) => {
555
555
  const normalized = normalizeOneMillionContextModel(model);
556
- return normalized ? `${normalized}[1m]` : model;
556
+ if (!normalized) return model;
557
+ const catalog = runtimeState.modelCatalog;
558
+ contextWindowTokens ??= catalog?.baseUrl === runtimeState.upstreamBaseUrl ? catalog?.models.get(normalized)?.limits?.max_context_window_tokens : void 0;
559
+ return contextWindowTokens !== void 0 && contextWindowTokens !== 1e6 ? normalized : `${normalized}[1m]`;
557
560
  };
558
561
  const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
559
562
  const getConfiguredModelRouting = () => runtimeState.modelRouting ?? defaultModelRouting;
@@ -717,8 +720,8 @@ const normalizeThinkEffort = (value) => {
717
720
  };
718
721
  const normalizeUpstreamTimeoutSeconds = (value) => {
719
722
  if (value === void 0) return;
720
- const timeout = normalizePositiveInteger(value);
721
- if (timeout === void 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a positive integer");
723
+ const timeout = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
724
+ if (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout < 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a non-negative integer (0 disables the deadline)");
722
725
  return timeout;
723
726
  };
724
727
  const readRawConfig = async () => {
@@ -844,7 +847,7 @@ const serializeConfig = (config) => [
844
847
  "# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh, max.",
845
848
  `thinkEffort: ${config.thinkEffort}`,
846
849
  "",
847
- "# Max seconds to wait for a single Claude request's upstream Copilot calls.",
850
+ "# Max seconds for one request's upstream calls; 0 disables the relay deadline.",
848
851
  `upstreamTimeoutSeconds: ${config.upstreamTimeoutSeconds}`,
849
852
  "",
850
853
  "# Copilot model used for bridge-managed Claude WebSearch. Empty uses gptModel.",
@@ -1190,11 +1193,11 @@ const knownModelEnvKeys = [
1190
1193
  "ANTHROPIC_SMALL_FAST_MODEL",
1191
1194
  "CLAUDE_CODE_SUBAGENT_MODEL"
1192
1195
  ];
1193
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1196
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1194
1197
  const hasMalformedModelOverride = (parsed) => {
1195
1198
  if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
1196
1199
  const env = parsed.env;
1197
- if (!isRecord$1(env)) return false;
1200
+ if (!isRecord$3(env)) return false;
1198
1201
  return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
1199
1202
  };
1200
1203
  const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
@@ -1221,7 +1224,7 @@ async function applyClaudeConfig(input) {
1221
1224
  };
1222
1225
  if (raw.trim().length > 0) try {
1223
1226
  const value = JSON.parse(raw);
1224
- if (!isRecord$1(value) || value.env !== void 0 && !isRecord$1(value.env) || hasMalformedModelOverride(value)) return {
1227
+ if (!isRecord$3(value) || value.env !== void 0 && !isRecord$3(value.env) || hasMalformedModelOverride(value)) return {
1225
1228
  configPath: configPath$1,
1226
1229
  changed: false,
1227
1230
  created: false
@@ -1234,15 +1237,18 @@ async function applyClaudeConfig(input) {
1234
1237
  created: false
1235
1238
  };
1236
1239
  }
1237
- const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
1240
+ const env = isRecord$3(parsed.env) ? { ...parsed.env } : {};
1238
1241
  const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
1239
1242
  const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
1240
- const claudeGptModel = normalizeClaudeModelId(gptModel);
1243
+ const contextWindow = input.gptLimits?.max_context_window_tokens;
1244
+ const claudeGptModel = normalizeClaudeModelId(gptModel, contextWindow);
1241
1245
  const shouldSeedTopLevelModel = !hadPrimaryModelOverride;
1242
- const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model) : void 0;
1243
- for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key]);
1246
+ const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
1247
+ for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
1244
1248
  env.ANTHROPIC_BASE_URL = baseUrl;
1245
1249
  if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
1250
+ if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
1251
+ if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
1246
1252
  const next = {
1247
1253
  ...parsed,
1248
1254
  ...nextModel !== void 0 && { model: nextModel },
@@ -1389,6 +1395,229 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
1389
1395
  throw lastError;
1390
1396
  };
1391
1397
 
1398
+ //#endregion
1399
+ //#region src/copilot/models.ts
1400
+ const pendingCatalogs = /* @__PURE__ */ new WeakMap();
1401
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1402
+ const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1403
+ function parseModelTokenLimits(value) {
1404
+ if (!isRecord$2(value)) return void 0;
1405
+ const context = value.max_context_window_tokens;
1406
+ const prompt = value.max_prompt_tokens;
1407
+ const output = value.max_output_tokens;
1408
+ const nonStreaming = value.max_non_streaming_output_tokens;
1409
+ if (!isPositiveInteger(context) || !isPositiveInteger(prompt) || !isPositiveInteger(output) || prompt > context || output > context || nonStreaming !== void 0 && (!isPositiveInteger(nonStreaming) || nonStreaming > output)) return;
1410
+ return {
1411
+ max_context_window_tokens: context,
1412
+ max_prompt_tokens: prompt,
1413
+ max_output_tokens: output,
1414
+ ...nonStreaming !== void 0 && { max_non_streaming_output_tokens: nonStreaming }
1415
+ };
1416
+ }
1417
+ function getCachedCopilotModel(config, model) {
1418
+ return config.modelCatalog?.baseUrl === config.copilotBaseUrl ? config.modelCatalog.models.get(normalizeCopilotModelId(model)) : void 0;
1419
+ }
1420
+ async function loadCopilotModelCatalog(config) {
1421
+ const provider = getCopilotProviderContext(config);
1422
+ const pending = pendingCatalogs.get(config);
1423
+ if (pending?.baseUrl === provider.baseUrl) return pending.promise;
1424
+ const promise = (async () => {
1425
+ const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
1426
+ const response = await fetchCopilot(provider, "/models", {
1427
+ method: "GET",
1428
+ headers: { accept: "application/json" }
1429
+ }, {
1430
+ signal,
1431
+ timeoutMs: config.upstreamTimeoutMs
1432
+ });
1433
+ if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
1434
+ const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
1435
+ if (!isRecord$2(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
1436
+ const models = /* @__PURE__ */ new Map();
1437
+ for (const model of payload.data) {
1438
+ if (!isRecord$2(model) || typeof model.id !== "string" || !model.id) continue;
1439
+ const capabilities = isRecord$2(model.capabilities) ? model.capabilities : void 0;
1440
+ const limits = parseModelTokenLimits(capabilities?.limits);
1441
+ models.set(model.id, {
1442
+ ...limits && { limits },
1443
+ ...typeof capabilities?.tokenizer === "string" && { tokenizer: capabilities.tokenizer }
1444
+ });
1445
+ }
1446
+ const catalog = {
1447
+ baseUrl: provider.baseUrl,
1448
+ models
1449
+ };
1450
+ if (config.copilotBaseUrl === provider.baseUrl) {
1451
+ config.modelCatalog = catalog;
1452
+ if (runtimeState.upstreamBaseUrl === provider.baseUrl) runtimeState.modelCatalog = catalog;
1453
+ }
1454
+ return catalog;
1455
+ })();
1456
+ pendingCatalogs.set(config, {
1457
+ baseUrl: provider.baseUrl,
1458
+ promise
1459
+ });
1460
+ try {
1461
+ return await promise;
1462
+ } finally {
1463
+ if (pendingCatalogs.get(config)?.promise === promise) pendingCatalogs.delete(config);
1464
+ }
1465
+ }
1466
+ async function boundModelOutputTokens(config, model, requested) {
1467
+ if (!config.modelCatalog) return requested;
1468
+ if (config.modelCatalog.baseUrl !== config.copilotBaseUrl || !config.modelCatalog.models.has(model)) {
1469
+ await loadCopilotModelCatalog(config);
1470
+ if (config.modelCatalog.baseUrl !== config.copilotBaseUrl) throw new Error("Copilot base URL changed during model discovery; retry the request.");
1471
+ }
1472
+ const limits = getCachedCopilotModel(config, model)?.limits;
1473
+ if (!limits || !isPositiveInteger(requested)) return requested;
1474
+ const bounded = Math.min(requested, limits.max_output_tokens);
1475
+ if (bounded !== requested) log.info(`Model output budget: model=${model} requested=${requested} effective=${bounded}`);
1476
+ return bounded;
1477
+ }
1478
+
1479
+ //#endregion
1480
+ //#region src/copilot/stream.ts
1481
+ const accumulateChunks = (chunks) => {
1482
+ const first = chunks[0];
1483
+ const toolCalls = [];
1484
+ let content = "";
1485
+ let reasoning = "";
1486
+ let finishReason = null;
1487
+ let usage;
1488
+ for (const chunk of chunks) {
1489
+ if (chunk.usage) usage = chunk.usage;
1490
+ const choice = chunk.choices[0];
1491
+ if (!choice) continue;
1492
+ if (typeof choice.delta?.content === "string") content += choice.delta.content;
1493
+ const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
1494
+ if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
1495
+ if (choice.finish_reason) finishReason = choice.finish_reason;
1496
+ for (const call of choice.delta?.tool_calls ?? []) {
1497
+ const existing = toolCalls[call.index];
1498
+ if (existing) {
1499
+ existing.function.arguments += call.function?.arguments ?? "";
1500
+ continue;
1501
+ }
1502
+ toolCalls[call.index] = {
1503
+ id: call.id ?? `call_${call.index}`,
1504
+ type: "function",
1505
+ function: {
1506
+ name: call.function?.name ?? "",
1507
+ arguments: call.function?.arguments ?? ""
1508
+ }
1509
+ };
1510
+ }
1511
+ }
1512
+ const collectedToolCalls = toolCalls.filter(Boolean);
1513
+ return {
1514
+ id: first?.id ?? "chat_stream_accumulated",
1515
+ object: "chat.completion",
1516
+ created: first?.created ?? 0,
1517
+ model: first?.model ?? "",
1518
+ choices: [{
1519
+ index: 0,
1520
+ message: {
1521
+ role: "assistant",
1522
+ content: content || null,
1523
+ ...reasoning ? { reasoning_text: reasoning } : {},
1524
+ ...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
1525
+ },
1526
+ finish_reason: finishReason ?? "stop"
1527
+ }],
1528
+ ...usage ? { usage } : {}
1529
+ };
1530
+ };
1531
+ const incompleteStreamError = () => {
1532
+ const message = "Copilot stream ended without a completed response.";
1533
+ return new HTTPError(message, new Response(JSON.stringify({ error: {
1534
+ type: "api_error",
1535
+ message
1536
+ } }), {
1537
+ status: 502,
1538
+ headers: { "content-type": "application/json" }
1539
+ }), message);
1540
+ };
1541
+ async function collectChatCompletionStream(stream, signal, timeoutMs) {
1542
+ const chunks = [];
1543
+ try {
1544
+ for await (const event of stream) {
1545
+ if (event.data === "[DONE]") break;
1546
+ if (!event.data) continue;
1547
+ const chunk = JSON.parse(event.data);
1548
+ if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
1549
+ chunks.push(chunk);
1550
+ }
1551
+ if (!chunks.some((chunk) => chunk.choices[0]?.finish_reason)) throw incompleteStreamError();
1552
+ return accumulateChunks(chunks);
1553
+ } catch (error) {
1554
+ throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
1555
+ }
1556
+ }
1557
+
1558
+ //#endregion
1559
+ //#region src/copilot/tool-schema.ts
1560
+ const schemaMapKeywords = new Set([
1561
+ "$defs",
1562
+ "definitions",
1563
+ "dependencies",
1564
+ "dependentSchemas",
1565
+ "patternProperties",
1566
+ "properties"
1567
+ ]);
1568
+ const nestedSchemaKeywords = new Set([
1569
+ "additionalItems",
1570
+ "additionalProperties",
1571
+ "allOf",
1572
+ "anyOf",
1573
+ "contains",
1574
+ "contentSchema",
1575
+ "else",
1576
+ "if",
1577
+ "items",
1578
+ "not",
1579
+ "oneOf",
1580
+ "prefixItems",
1581
+ "propertyNames",
1582
+ "then",
1583
+ "unevaluatedItems",
1584
+ "unevaluatedProperties"
1585
+ ]);
1586
+ const lookaroundPrefixes = [
1587
+ "(?=",
1588
+ "(?!",
1589
+ "(?<=",
1590
+ "(?<!"
1591
+ ];
1592
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1593
+ function hasUnsupportedPatternFeatures(pattern) {
1594
+ let inCharacterClass = false;
1595
+ for (let index = 0; index < pattern.length; index++) {
1596
+ const character = pattern[index];
1597
+ if (character === "\\") {
1598
+ if ((pattern[index + 1] === "p" || pattern[index + 1] === "P") && pattern[index + 2] === "{") return true;
1599
+ index += 1;
1600
+ continue;
1601
+ }
1602
+ if (character === "[") inCharacterClass = true;
1603
+ else if (character === "]") inCharacterClass = false;
1604
+ else if (!inCharacterClass && character === "(" && lookaroundPrefixes.some((prefix) => pattern.startsWith(prefix, index))) return true;
1605
+ }
1606
+ return false;
1607
+ }
1608
+ function normalizeSchemaValue(value) {
1609
+ if (Array.isArray(value)) return value.map(normalizeSchemaValue);
1610
+ return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
1611
+ }
1612
+ function normalizeResponsesToolSchema(schema) {
1613
+ return Object.fromEntries(Object.entries(schema).flatMap(([key, value]) => {
1614
+ if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return [];
1615
+ if (schemaMapKeywords.has(key) && isRecord$1(value)) return [[key, Object.fromEntries(Object.entries(value).map(([name, nested]) => [name, normalizeSchemaValue(nested)]))]];
1616
+ if (nestedSchemaKeywords.has(key)) return [[key, normalizeSchemaValue(value)]];
1617
+ return [[key, value]];
1618
+ }));
1619
+ }
1620
+
1392
1621
  //#endregion
1393
1622
  //#region src/copilot/responses.ts
1394
1623
  const responsesOnlyModelPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
@@ -1710,7 +1939,7 @@ function translateTools(tools) {
1710
1939
  type: "function",
1711
1940
  name: tool.function.name,
1712
1941
  description: tool.function.description,
1713
- parameters: tool.function.parameters
1942
+ parameters: normalizeResponsesToolSchema(tool.function.parameters)
1714
1943
  }));
1715
1944
  }
1716
1945
  function translateToolChoice(toolChoice) {
@@ -1833,9 +2062,15 @@ const createChatCompletions = async (config, payload, options = {}) => {
1833
2062
  const requestedThinking = options.requestedThinking ?? "none";
1834
2063
  const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
1835
2064
  const upstreamModelId = routeModelId(payload.model);
1836
- const upstreamPayload = upstreamModelId === payload.model ? payload : {
2065
+ const maxTokens = await boundModelOutputTokens(config, upstreamModelId, payload.max_tokens);
2066
+ const nonStreamingLimit = getCachedCopilotModel(config, upstreamModelId)?.limits?.max_non_streaming_output_tokens;
2067
+ const bufferResponse = !payload.stream && typeof maxTokens === "number" && nonStreamingLimit !== void 0 && maxTokens > nonStreamingLimit;
2068
+ const completeResponse = (response$1) => bufferResponse && !("choices" in response$1) ? collectChatCompletionStream(response$1, signal, options.timeoutMs) : response$1;
2069
+ const upstreamPayload = {
1837
2070
  ...payload,
1838
- model: upstreamModelId
2071
+ model: upstreamModelId,
2072
+ max_tokens: maxTokens,
2073
+ stream: bufferResponse ? true : payload.stream
1839
2074
  };
1840
2075
  const useResponsesApi = shouldUseResponsesApiForModel(upstreamPayload.model);
1841
2076
  const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill$1(upstreamPayload);
@@ -1854,13 +2089,13 @@ const createChatCompletions = async (config, payload, options = {}) => {
1854
2089
  `effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
1855
2090
  ].join(" "));
1856
2091
  log.debug("Full request payload", { payload: requestPayload });
1857
- if (useResponsesApi) return createResponses(provider, compatiblePayload, {
2092
+ if (useResponsesApi) return completeResponse(await createResponses(provider, compatiblePayload, {
1858
2093
  vision: enableVision,
1859
2094
  initiator,
1860
2095
  requestId: options.requestId,
1861
2096
  signal,
1862
2097
  timeoutMs: options.timeoutMs
1863
- });
2098
+ }));
1864
2099
  const response = await fetchCopilot(provider, "/chat/completions", {
1865
2100
  method: "POST",
1866
2101
  headers: {
@@ -1876,13 +2111,13 @@ const createChatCompletions = async (config, payload, options = {}) => {
1876
2111
  timeoutMs: options.timeoutMs
1877
2112
  });
1878
2113
  if (!response.ok) {
1879
- if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
2114
+ if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return completeResponse(await createResponses(provider, compatiblePayload, {
1880
2115
  vision: enableVision,
1881
2116
  initiator,
1882
2117
  requestId: options.requestId,
1883
2118
  signal,
1884
2119
  timeoutMs: options.timeoutMs
1885
- });
2120
+ }));
1886
2121
  throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
1887
2122
  model: payload.model,
1888
2123
  request: requestPayload,
@@ -1890,7 +2125,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
1890
2125
  requestId: options.requestId
1891
2126
  }, signal, options.timeoutMs));
1892
2127
  }
1893
- if (compatiblePayload.stream) return events(response);
2128
+ if (compatiblePayload.stream) return completeResponse(events(response));
1894
2129
  return readCopilotJson(response, signal, options.timeoutMs);
1895
2130
  };
1896
2131
  async function createResponses(provider, payload, options) {
@@ -1956,26 +2191,17 @@ async function shouldRetryWithResponses(response, signal, timeoutMs) {
1956
2191
 
1957
2192
  //#endregion
1958
2193
  //#region src/lib/preflight.ts
1959
- const fetchUpstreamModelIds = async (config) => {
1960
- const provider = getCopilotProviderContext(config);
1961
- const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
1962
- const response = await fetchCopilot(provider, "/models", {
1963
- method: "GET",
1964
- headers: { accept: "application/json" }
1965
- }, {
1966
- signal,
1967
- timeoutMs: config.upstreamTimeoutMs
1968
- });
1969
- if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
1970
- const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
1971
- return new Set((payload.data ?? []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0));
1972
- };
1973
2194
  const ensureRequiredModels = async (config) => {
1974
- const upstreamModels = await fetchUpstreamModelIds(config);
2195
+ const catalog = await loadCopilotModelCatalog(config);
1975
2196
  const requiredModels = getUpstreamModelIds();
1976
- const missingModels = requiredModels.filter((model) => !upstreamModels.has(model));
2197
+ const missingModels = requiredModels.filter((model) => !catalog.models.has(model));
1977
2198
  if (missingModels.length > 0) throw new Error(`Required Copilot model(s) unavailable upstream: ${missingModels.join(", ")}`);
1978
2199
  log.info(`Upstream models available: ${requiredModels.join(", ")}`);
2200
+ for (const model of requiredModels) {
2201
+ const limits = catalog.models.get(model)?.limits;
2202
+ if (limits) log.info(`Model token limits: model=${model} context=${limits.max_context_window_tokens} input=${limits.max_prompt_tokens} output=${limits.max_output_tokens} non_streaming_output=${limits.max_non_streaming_output_tokens ?? limits.max_output_tokens}`);
2203
+ else log.error(`Model token limits unavailable: model=${model}; preserving client budgets without assuming a capacity.`);
2204
+ }
1979
2205
  };
1980
2206
  const createProbePayload = (model) => ({
1981
2207
  model,
@@ -2729,56 +2955,6 @@ const chunkSettlesDecision = (chunk) => {
2729
2955
  return choice.delta?.tool_calls?.some((call) => call.function?.name) === true || !!choice.finish_reason;
2730
2956
  };
2731
2957
  const chunkHasWebSearchCall = (chunk, toolNameMapper, isWebSearchToolName) => chunk.choices[0]?.delta?.tool_calls?.some((call) => isWebSearchName(call.function?.name, toolNameMapper, isWebSearchToolName)) ?? false;
2732
- const accumulateChunks = (chunks) => {
2733
- const first = chunks[0];
2734
- const toolCalls = [];
2735
- let content = "";
2736
- let reasoning = "";
2737
- let finishReason = null;
2738
- let usage;
2739
- for (const chunk of chunks) {
2740
- if (chunk.usage) usage = chunk.usage;
2741
- const choice = chunk.choices[0];
2742
- if (!choice) continue;
2743
- if (typeof choice.delta?.content === "string") content += choice.delta.content;
2744
- const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
2745
- if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
2746
- if (choice.finish_reason) finishReason = choice.finish_reason;
2747
- for (const call of choice.delta?.tool_calls ?? []) {
2748
- const existing = toolCalls[call.index];
2749
- if (existing) {
2750
- existing.function.arguments += call.function?.arguments ?? "";
2751
- continue;
2752
- }
2753
- toolCalls[call.index] = {
2754
- id: call.id ?? `call_${call.index}`,
2755
- type: "function",
2756
- function: {
2757
- name: call.function?.name ?? "",
2758
- arguments: call.function?.arguments ?? ""
2759
- }
2760
- };
2761
- }
2762
- }
2763
- const collectedToolCalls = toolCalls.filter(Boolean);
2764
- return {
2765
- id: first?.id ?? "chat_stream_accumulated",
2766
- object: "chat.completion",
2767
- created: first?.created ?? 0,
2768
- model: first?.model ?? "",
2769
- choices: [{
2770
- index: 0,
2771
- message: {
2772
- role: "assistant",
2773
- content: content || null,
2774
- ...reasoning ? { reasoning_text: reasoning } : {},
2775
- ...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
2776
- },
2777
- finish_reason: finishReason ?? "stop"
2778
- }],
2779
- ...usage ? { usage } : {}
2780
- };
2781
- };
2782
2958
  const resolveWebSearchStreamDecision = async (stream, toolNameMapper, isWebSearchToolName, onChunk) => {
2783
2959
  const buffered = [];
2784
2960
  const iterator = stream[Symbol.asyncIterator]();
@@ -2860,6 +3036,7 @@ const encodingMap = {
2860
3036
  p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
2861
3037
  r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
2862
3038
  };
3039
+ const isSupportedTokenizer = (encoding) => Object.hasOwn(encodingMap, encoding);
2863
3040
  const encodingCache = /* @__PURE__ */ new Map();
2864
3041
  const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
2865
3042
  let tokens = 0;
@@ -2900,13 +3077,12 @@ const getEncodeChatFunction = async (encoding) => {
2900
3077
  const cached = encodingCache.get(encoding);
2901
3078
  if (cached) return cached;
2902
3079
  }
2903
- const supported = encoding;
2904
- if (!(supported in encodingMap)) {
3080
+ if (!isSupportedTokenizer(encoding)) {
2905
3081
  const fallback = await encodingMap.o200k_base();
2906
3082
  encodingCache.set(encoding, fallback);
2907
3083
  return fallback;
2908
3084
  }
2909
- const mod = await encodingMap[supported]();
3085
+ const mod = await encodingMap[encoding]();
2910
3086
  encodingCache.set(encoding, mod);
2911
3087
  return mod;
2912
3088
  };
@@ -3009,8 +3185,8 @@ const getTokenCount = async (payload, model) => {
3009
3185
  //#endregion
3010
3186
  //#region src/routes/claude.ts
3011
3187
  const claudeRoutes = new Hono();
3012
- const createTokenCountModel = (modelId) => ({
3013
- capabilities: { tokenizer: "o200k_base" },
3188
+ const createTokenCountModel = (modelId, tokenizer = "o200k_base") => ({
3189
+ capabilities: { tokenizer },
3014
3190
  id: modelId
3015
3191
  });
3016
3192
  const isNonStreamingResponse = (response) => typeof response === "object" && response !== null && Object.hasOwn(response, "choices");
@@ -3246,12 +3422,20 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
3246
3422
  };
3247
3423
  claudeRoutes.get("/models", (c) => c.json({
3248
3424
  object: "list",
3249
- data: getExposedModelIds().map((id) => ({
3250
- id,
3251
- object: "model",
3252
- created: 0,
3253
- owned_by: "github-copilot"
3254
- }))
3425
+ data: getExposedModelIds().map((id) => {
3426
+ const limits = getCachedCopilotModel(c.get("config"), id)?.limits;
3427
+ return {
3428
+ id,
3429
+ object: "model",
3430
+ created: 0,
3431
+ owned_by: "github-copilot",
3432
+ ...limits && {
3433
+ context_window: limits.max_context_window_tokens,
3434
+ max_input_tokens: limits.max_prompt_tokens,
3435
+ max_tokens: limits.max_output_tokens
3436
+ }
3437
+ };
3438
+ })
3255
3439
  }));
3256
3440
  claudeRoutes.post("/messages", async (c) => {
3257
3441
  const config = c.get("config");
@@ -3301,15 +3485,17 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
3301
3485
  const claudePayload = await c.req.json();
3302
3486
  const openAIPayload = translateToOpenAI(claudePayload);
3303
3487
  const exposedModels = getExposedModelIds();
3304
- const selectedModel = createTokenCountModel(openAIPayload.model === exposedModels[1] ? exposedModels[1] : exposedModels[0]);
3488
+ const upstreamModel = getCachedCopilotModel(c.get("config"), openAIPayload.model);
3489
+ const hasDiscoveredTokenizer = upstreamModel?.tokenizer !== void 0 && isSupportedTokenizer(upstreamModel.tokenizer);
3490
+ const selectedModel = createTokenCountModel(openAIPayload.model === exposedModels[1] ? exposedModels[1] : exposedModels[0], hasDiscoveredTokenizer ? upstreamModel?.tokenizer : void 0);
3305
3491
  const tokenCount = await getTokenCount(openAIPayload, selectedModel);
3306
3492
  const effectiveModelId = selectedModel.id;
3307
3493
  if (claudePayload.tools && claudePayload.tools.length > 0) {
3308
3494
  let mcpToolExist = false;
3309
3495
  if (claudeBeta?.startsWith("claude-code")) mcpToolExist = claudePayload.tools.some((tool) => tool.name.startsWith("mcp__"));
3310
- if (!mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
3496
+ if (!hasDiscoveredTokenizer && !mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
3311
3497
  }
3312
- const multiplier = effectiveModelId.startsWith("claude") ? 1.15 : 1;
3498
+ const multiplier = !hasDiscoveredTokenizer && effectiveModelId.startsWith("claude") ? 1.15 : 1;
3313
3499
  const finalTokenCount = Math.round((tokenCount.input + tokenCount.output) * multiplier);
3314
3500
  return c.json({ input_tokens: Math.max(1, finalTokenCount) });
3315
3501
  } catch (error) {
@@ -3429,6 +3615,7 @@ async function startRelay(appConfig) {
3429
3615
  runtimeState.debug = nextConfig.logLevel === "debug";
3430
3616
  runtimeState.thinkEffort = nextConfig.thinkEffort;
3431
3617
  config.copilotBaseUrl = nextConfig.copilotBaseUrl;
3618
+ runtimeState.upstreamBaseUrl = nextConfig.copilotBaseUrl;
3432
3619
  config.host = nextConfig.host;
3433
3620
  config.port = nextConfig.port;
3434
3621
  config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
@@ -3442,7 +3629,6 @@ async function startRelay(appConfig) {
3442
3629
  log.info(`Log level: ${appConfig.logLevel}`);
3443
3630
  log.info(`Think effort: ${appConfig.thinkEffort}`);
3444
3631
  log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
3445
- log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
3446
3632
  const authSession = await setupProxyAuth(config);
3447
3633
  try {
3448
3634
  await validateUpstream(config, appConfig.thinkEffort);
@@ -3450,6 +3636,7 @@ async function startRelay(appConfig) {
3450
3636
  log.error("Startup preflight failed:", error);
3451
3637
  process.exit(1);
3452
3638
  }
3639
+ log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
3453
3640
  const server = await startServer(config);
3454
3641
  await writeRelayPidFile(config);
3455
3642
  log.info(`copilot-relay version: ${appVersion}`);
@@ -3459,10 +3646,14 @@ async function startRelay(appConfig) {
3459
3646
  log.info(`copilot base url: ${formatUrlForDisplay(config.copilotBaseUrl)}`);
3460
3647
  const baseUrl = `http://${config.host}:${config.port}`;
3461
3648
  if (appConfig.claudeSetup) try {
3649
+ const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
3650
+ const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
3462
3651
  const claudeResult = await applyClaudeConfig({
3463
3652
  baseUrl,
3464
3653
  configPath: claudeConfigPath$1,
3465
- gptModel: appConfig.gptModel
3654
+ gptModel: appConfig.gptModel,
3655
+ gptLimits,
3656
+ maxOutputTokens: gptLimits && opusLimits ? Math.max(gptLimits.max_output_tokens, opusLimits.max_output_tokens) : void 0
3466
3657
  });
3467
3658
  if (claudeResult.changed) {
3468
3659
  log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);
@@ -3650,7 +3841,7 @@ const renderStatus = (status$1) => {
3650
3841
  const health = status$1.health;
3651
3842
  lines.push(health?.ok ? ` health ok (${health.ms}ms)` : ` health FAILED${health?.detail ? ` — ${health.detail}` : ""}`);
3652
3843
  lines.push(status$1.models?.length ? ` models ${status$1.models.join(", ")}` : " models unavailable");
3653
- if (status$1.deep) lines.push(status$1.deep.ok ? ` upstream ok (${status$1.deep.ms}ms) — end-to-end Copilot round trip` : ` upstream FAILED${status$1.deep.detail ? ` — ${status$1.deep.detail}` : ""}`);
3844
+ if (status$1.deep) lines.push(status$1.deep.ok ? ` upstream ok (${status$1.deep.ms}ms) — ${status$1.deep.detail ?? "end-to-end Copilot round trip"}` : ` upstream FAILED${status$1.deep.detail ? ` — ${status$1.deep.detail}` : ""}`);
3654
3845
  else lines.push(" upstream not checked (use --deep)");
3655
3846
  lines.push(` log ${status$1.logPath}`, ` config ${status$1.configPath}`, ...renderConfig(status$1.config, true));
3656
3847
  if (status$1.health?.ok && status$1.deep && !status$1.deep.ok) lines.push("", " The relay is listening but cannot reach Copilot.", " Try: copilot-relay auth");
@@ -3740,11 +3931,19 @@ const checkDeep = async (baseUrl, model) => {
3740
3931
  ok: false
3741
3932
  };
3742
3933
  }
3743
- const content = result.body?.content;
3744
- return Array.isArray(content) && content.length > 0 ? {
3745
- ms: result.ms,
3746
- ok: true
3747
- } : {
3934
+ const body = result.body;
3935
+ if (Array.isArray(body?.content)) {
3936
+ if (body.content.length > 0) return {
3937
+ ms: result.ms,
3938
+ ok: true
3939
+ };
3940
+ if (body.type === "message" && body.role === "assistant" && body.stop_reason === "max_tokens" && typeof body.usage?.output_tokens === "number" && Number.isFinite(body.usage.output_tokens) && body.usage.output_tokens > 0) return {
3941
+ detail: "output budget exhausted before visible text",
3942
+ ms: result.ms,
3943
+ ok: true
3944
+ };
3945
+ }
3946
+ return {
3748
3947
  detail: "empty response",
3749
3948
  ms: result.ms,
3750
3949
  ok: false