copilot-relay 0.3.5 → 0.3.7

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
@@ -547,13 +547,39 @@ const defaultModelRouting = {
547
547
  opusModel: "claude-opus-5"
548
548
  };
549
549
  const isReasoningEffort = (value) => value === "none" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max";
550
+ const invalidRequestEffort = (message) => new HTTPError(message, new Response(JSON.stringify({
551
+ type: "error",
552
+ error: {
553
+ type: "invalid_request_error",
554
+ message
555
+ }
556
+ }), {
557
+ status: 400,
558
+ headers: { "content-type": "application/json" }
559
+ }), message);
560
+ function getRequestReasoningEffort(request) {
561
+ let nativeEffort;
562
+ const outputConfig = request.output_config;
563
+ if (outputConfig !== void 0 && outputConfig !== null) {
564
+ if (typeof outputConfig !== "object" || Array.isArray(outputConfig)) throw invalidRequestEffort("Invalid output_config: expected an object.");
565
+ if ("effort" in outputConfig) nativeEffort = outputConfig.effort;
566
+ }
567
+ const requested = nativeEffort ?? request.reasoning_effort;
568
+ if (requested === void 0 || requested === null) return void 0;
569
+ if (!isReasoningEffort(requested)) throw invalidRequestEffort(`Invalid ${nativeEffort !== void 0 && nativeEffort !== null ? "output_config.effort" : "reasoning_effort"}: expected none, low, medium, high, xhigh, or max.`);
570
+ return requested;
571
+ }
572
+ const resolveReasoningEffort = (requested) => requested ?? runtimeState.thinkEffort ?? defaultReasoningEffort;
550
573
  const oneMillionContextModelPattern = /^(gpt-5\.6-sol|gpt-6-astra)(?:\[1m\])*$/i;
551
574
  const normalizeOneMillionContextModel = (model) => {
552
575
  return oneMillionContextModelPattern.exec(model)?.[1]?.toLowerCase();
553
576
  };
554
- const normalizeClaudeModelId = (model) => {
577
+ const normalizeClaudeModelId = (model, contextWindowTokens) => {
555
578
  const normalized = normalizeOneMillionContextModel(model);
556
- return normalized ? `${normalized}[1m]` : model;
579
+ if (!normalized) return model;
580
+ const catalog = runtimeState.modelCatalog;
581
+ contextWindowTokens ??= catalog?.baseUrl === runtimeState.upstreamBaseUrl ? catalog?.models.get(normalized)?.limits?.max_context_window_tokens : void 0;
582
+ return contextWindowTokens !== void 0 && contextWindowTokens !== 1e6 ? normalized : `${normalized}[1m]`;
557
583
  };
558
584
  const normalizeCopilotModelId = (model) => normalizeOneMillionContextModel(model) ?? model;
559
585
  const getConfiguredModelRouting = () => runtimeState.modelRouting ?? defaultModelRouting;
@@ -717,8 +743,8 @@ const normalizeThinkEffort = (value) => {
717
743
  };
718
744
  const normalizeUpstreamTimeoutSeconds = (value) => {
719
745
  if (value === void 0) return;
720
- const timeout = normalizePositiveInteger(value);
721
- if (timeout === void 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a positive integer");
746
+ const timeout = typeof value === "string" && /^\d+$/.test(value) ? Number(value) : value;
747
+ if (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout < 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a non-negative integer (0 disables the deadline)");
722
748
  return timeout;
723
749
  };
724
750
  const readRawConfig = async () => {
@@ -841,10 +867,10 @@ const serializeConfig = (config) => [
841
867
  "# Number of days to keep files in ~/.copilot-relay/logs.",
842
868
  `logRetentionDays: ${config.logRetentionDays}`,
843
869
  "",
844
- "# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh, max.",
870
+ "# Fallback effort when the request omits it: none, low, medium, high, xhigh, max.",
845
871
  `thinkEffort: ${config.thinkEffort}`,
846
872
  "",
847
- "# Max seconds to wait for a single Claude request's upstream Copilot calls.",
873
+ "# Max seconds for one request's upstream calls; 0 disables the relay deadline.",
848
874
  `upstreamTimeoutSeconds: ${config.upstreamTimeoutSeconds}`,
849
875
  "",
850
876
  "# Copilot model used for bridge-managed Claude WebSearch. Empty uses gptModel.",
@@ -1190,11 +1216,11 @@ const knownModelEnvKeys = [
1190
1216
  "ANTHROPIC_SMALL_FAST_MODEL",
1191
1217
  "CLAUDE_CODE_SUBAGENT_MODEL"
1192
1218
  ];
1193
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1219
+ const isRecord$3 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1194
1220
  const hasMalformedModelOverride = (parsed) => {
1195
1221
  if (parsed.model !== void 0 && typeof parsed.model !== "string") return true;
1196
1222
  const env = parsed.env;
1197
- if (!isRecord$1(env)) return false;
1223
+ if (!isRecord$3(env)) return false;
1198
1224
  return knownModelEnvKeys.some((key) => env[key] !== void 0 && typeof env[key] !== "string");
1199
1225
  };
1200
1226
  const hasPrimaryModelOverride = (parsed, env) => typeof parsed.model === "string" || typeof env.ANTHROPIC_MODEL === "string";
@@ -1221,7 +1247,7 @@ async function applyClaudeConfig(input) {
1221
1247
  };
1222
1248
  if (raw.trim().length > 0) try {
1223
1249
  const value = JSON.parse(raw);
1224
- if (!isRecord$1(value) || value.env !== void 0 && !isRecord$1(value.env) || hasMalformedModelOverride(value)) return {
1250
+ if (!isRecord$3(value) || value.env !== void 0 && !isRecord$3(value.env) || hasMalformedModelOverride(value)) return {
1225
1251
  configPath: configPath$1,
1226
1252
  changed: false,
1227
1253
  created: false
@@ -1234,15 +1260,18 @@ async function applyClaudeConfig(input) {
1234
1260
  created: false
1235
1261
  };
1236
1262
  }
1237
- const env = isRecord$1(parsed.env) ? { ...parsed.env } : {};
1263
+ const env = isRecord$3(parsed.env) ? { ...parsed.env } : {};
1238
1264
  const previousBaseUrl = typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : void 0;
1239
1265
  const hadPrimaryModelOverride = hasPrimaryModelOverride(parsed, env);
1240
- const claudeGptModel = normalizeClaudeModelId(gptModel);
1266
+ const contextWindow = input.gptLimits?.max_context_window_tokens;
1267
+ const claudeGptModel = normalizeClaudeModelId(gptModel, contextWindow);
1241
1268
  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]);
1269
+ const nextModel = typeof parsed.model === "string" ? normalizeClaudeModelId(parsed.model, contextWindow) : void 0;
1270
+ for (const key of knownModelEnvKeys) if (typeof env[key] === "string") env[key] = normalizeClaudeModelId(env[key], contextWindow);
1244
1271
  env.ANTHROPIC_BASE_URL = baseUrl;
1245
1272
  if (typeof env.ANTHROPIC_AUTH_TOKEN !== "string" || !env.ANTHROPIC_AUTH_TOKEN) env.ANTHROPIC_AUTH_TOKEN = "dummy";
1273
+ if (contextWindow !== void 0 && env.CLAUDE_CODE_MAX_CONTEXT_TOKENS === void 0) env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = String(contextWindow);
1274
+ if (input.maxOutputTokens !== void 0 && env.CLAUDE_CODE_MAX_OUTPUT_TOKENS === void 0) env.CLAUDE_CODE_MAX_OUTPUT_TOKENS = String(input.maxOutputTokens);
1246
1275
  const next = {
1247
1276
  ...parsed,
1248
1277
  ...nextModel !== void 0 && { model: nextModel },
@@ -1389,6 +1418,229 @@ const fetchCopilot = async (provider, path$1, init, options = {}) => {
1389
1418
  throw lastError;
1390
1419
  };
1391
1420
 
1421
+ //#endregion
1422
+ //#region src/copilot/models.ts
1423
+ const pendingCatalogs = /* @__PURE__ */ new WeakMap();
1424
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1425
+ const isPositiveInteger = (value) => typeof value === "number" && Number.isSafeInteger(value) && value > 0;
1426
+ function parseModelTokenLimits(value) {
1427
+ if (!isRecord$2(value)) return void 0;
1428
+ const context = value.max_context_window_tokens;
1429
+ const prompt = value.max_prompt_tokens;
1430
+ const output = value.max_output_tokens;
1431
+ const nonStreaming = value.max_non_streaming_output_tokens;
1432
+ if (!isPositiveInteger(context) || !isPositiveInteger(prompt) || !isPositiveInteger(output) || prompt > context || output > context || nonStreaming !== void 0 && (!isPositiveInteger(nonStreaming) || nonStreaming > output)) return;
1433
+ return {
1434
+ max_context_window_tokens: context,
1435
+ max_prompt_tokens: prompt,
1436
+ max_output_tokens: output,
1437
+ ...nonStreaming !== void 0 && { max_non_streaming_output_tokens: nonStreaming }
1438
+ };
1439
+ }
1440
+ function getCachedCopilotModel(config, model) {
1441
+ return config.modelCatalog?.baseUrl === config.copilotBaseUrl ? config.modelCatalog.models.get(normalizeCopilotModelId(model)) : void 0;
1442
+ }
1443
+ async function loadCopilotModelCatalog(config) {
1444
+ const provider = getCopilotProviderContext(config);
1445
+ const pending = pendingCatalogs.get(config);
1446
+ if (pending?.baseUrl === provider.baseUrl) return pending.promise;
1447
+ const promise = (async () => {
1448
+ const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
1449
+ const response = await fetchCopilot(provider, "/models", {
1450
+ method: "GET",
1451
+ headers: { accept: "application/json" }
1452
+ }, {
1453
+ signal,
1454
+ timeoutMs: config.upstreamTimeoutMs
1455
+ });
1456
+ if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
1457
+ const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
1458
+ if (!isRecord$2(payload) || !Array.isArray(payload.data)) throw new Error("Copilot /models returned an invalid model catalog.");
1459
+ const models = /* @__PURE__ */ new Map();
1460
+ for (const model of payload.data) {
1461
+ if (!isRecord$2(model) || typeof model.id !== "string" || !model.id) continue;
1462
+ const capabilities = isRecord$2(model.capabilities) ? model.capabilities : void 0;
1463
+ const limits = parseModelTokenLimits(capabilities?.limits);
1464
+ models.set(model.id, {
1465
+ ...limits && { limits },
1466
+ ...typeof capabilities?.tokenizer === "string" && { tokenizer: capabilities.tokenizer }
1467
+ });
1468
+ }
1469
+ const catalog = {
1470
+ baseUrl: provider.baseUrl,
1471
+ models
1472
+ };
1473
+ if (config.copilotBaseUrl === provider.baseUrl) {
1474
+ config.modelCatalog = catalog;
1475
+ if (runtimeState.upstreamBaseUrl === provider.baseUrl) runtimeState.modelCatalog = catalog;
1476
+ }
1477
+ return catalog;
1478
+ })();
1479
+ pendingCatalogs.set(config, {
1480
+ baseUrl: provider.baseUrl,
1481
+ promise
1482
+ });
1483
+ try {
1484
+ return await promise;
1485
+ } finally {
1486
+ if (pendingCatalogs.get(config)?.promise === promise) pendingCatalogs.delete(config);
1487
+ }
1488
+ }
1489
+ async function boundModelOutputTokens(config, model, requested) {
1490
+ if (!config.modelCatalog) return requested;
1491
+ if (config.modelCatalog.baseUrl !== config.copilotBaseUrl || !config.modelCatalog.models.has(model)) {
1492
+ await loadCopilotModelCatalog(config);
1493
+ if (config.modelCatalog.baseUrl !== config.copilotBaseUrl) throw new Error("Copilot base URL changed during model discovery; retry the request.");
1494
+ }
1495
+ const limits = getCachedCopilotModel(config, model)?.limits;
1496
+ if (!limits || !isPositiveInteger(requested)) return requested;
1497
+ const bounded = Math.min(requested, limits.max_output_tokens);
1498
+ if (bounded !== requested) log.info(`Model output budget: model=${model} requested=${requested} effective=${bounded}`);
1499
+ return bounded;
1500
+ }
1501
+
1502
+ //#endregion
1503
+ //#region src/copilot/stream.ts
1504
+ const accumulateChunks = (chunks) => {
1505
+ const first = chunks[0];
1506
+ const toolCalls = [];
1507
+ let content = "";
1508
+ let reasoning = "";
1509
+ let finishReason = null;
1510
+ let usage;
1511
+ for (const chunk of chunks) {
1512
+ if (chunk.usage) usage = chunk.usage;
1513
+ const choice = chunk.choices[0];
1514
+ if (!choice) continue;
1515
+ if (typeof choice.delta?.content === "string") content += choice.delta.content;
1516
+ const chunkReasoning = choice.delta?.reasoning_text ?? choice.delta?.reasoning_content;
1517
+ if (typeof chunkReasoning === "string") reasoning += chunkReasoning;
1518
+ if (choice.finish_reason) finishReason = choice.finish_reason;
1519
+ for (const call of choice.delta?.tool_calls ?? []) {
1520
+ const existing = toolCalls[call.index];
1521
+ if (existing) {
1522
+ existing.function.arguments += call.function?.arguments ?? "";
1523
+ continue;
1524
+ }
1525
+ toolCalls[call.index] = {
1526
+ id: call.id ?? `call_${call.index}`,
1527
+ type: "function",
1528
+ function: {
1529
+ name: call.function?.name ?? "",
1530
+ arguments: call.function?.arguments ?? ""
1531
+ }
1532
+ };
1533
+ }
1534
+ }
1535
+ const collectedToolCalls = toolCalls.filter(Boolean);
1536
+ return {
1537
+ id: first?.id ?? "chat_stream_accumulated",
1538
+ object: "chat.completion",
1539
+ created: first?.created ?? 0,
1540
+ model: first?.model ?? "",
1541
+ choices: [{
1542
+ index: 0,
1543
+ message: {
1544
+ role: "assistant",
1545
+ content: content || null,
1546
+ ...reasoning ? { reasoning_text: reasoning } : {},
1547
+ ...collectedToolCalls.length > 0 ? { tool_calls: collectedToolCalls } : {}
1548
+ },
1549
+ finish_reason: finishReason ?? "stop"
1550
+ }],
1551
+ ...usage ? { usage } : {}
1552
+ };
1553
+ };
1554
+ const incompleteStreamError = () => {
1555
+ const message = "Copilot stream ended without a completed response.";
1556
+ return new HTTPError(message, new Response(JSON.stringify({ error: {
1557
+ type: "api_error",
1558
+ message
1559
+ } }), {
1560
+ status: 502,
1561
+ headers: { "content-type": "application/json" }
1562
+ }), message);
1563
+ };
1564
+ async function collectChatCompletionStream(stream, signal, timeoutMs) {
1565
+ const chunks = [];
1566
+ try {
1567
+ for await (const event of stream) {
1568
+ if (event.data === "[DONE]") break;
1569
+ if (!event.data) continue;
1570
+ const chunk = JSON.parse(event.data);
1571
+ if (!chunk || !Array.isArray(chunk.choices)) throw incompleteStreamError();
1572
+ chunks.push(chunk);
1573
+ }
1574
+ if (!chunks.some((chunk) => chunk.choices[0]?.finish_reason)) throw incompleteStreamError();
1575
+ return accumulateChunks(chunks);
1576
+ } catch (error) {
1577
+ throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
1578
+ }
1579
+ }
1580
+
1581
+ //#endregion
1582
+ //#region src/copilot/tool-schema.ts
1583
+ const schemaMapKeywords = new Set([
1584
+ "$defs",
1585
+ "definitions",
1586
+ "dependencies",
1587
+ "dependentSchemas",
1588
+ "patternProperties",
1589
+ "properties"
1590
+ ]);
1591
+ const nestedSchemaKeywords = new Set([
1592
+ "additionalItems",
1593
+ "additionalProperties",
1594
+ "allOf",
1595
+ "anyOf",
1596
+ "contains",
1597
+ "contentSchema",
1598
+ "else",
1599
+ "if",
1600
+ "items",
1601
+ "not",
1602
+ "oneOf",
1603
+ "prefixItems",
1604
+ "propertyNames",
1605
+ "then",
1606
+ "unevaluatedItems",
1607
+ "unevaluatedProperties"
1608
+ ]);
1609
+ const lookaroundPrefixes = [
1610
+ "(?=",
1611
+ "(?!",
1612
+ "(?<=",
1613
+ "(?<!"
1614
+ ];
1615
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1616
+ function hasUnsupportedPatternFeatures(pattern) {
1617
+ let inCharacterClass = false;
1618
+ for (let index = 0; index < pattern.length; index++) {
1619
+ const character = pattern[index];
1620
+ if (character === "\\") {
1621
+ if ((pattern[index + 1] === "p" || pattern[index + 1] === "P") && pattern[index + 2] === "{") return true;
1622
+ index += 1;
1623
+ continue;
1624
+ }
1625
+ if (character === "[") inCharacterClass = true;
1626
+ else if (character === "]") inCharacterClass = false;
1627
+ else if (!inCharacterClass && character === "(" && lookaroundPrefixes.some((prefix) => pattern.startsWith(prefix, index))) return true;
1628
+ }
1629
+ return false;
1630
+ }
1631
+ function normalizeSchemaValue(value) {
1632
+ if (Array.isArray(value)) return value.map(normalizeSchemaValue);
1633
+ return isRecord$1(value) ? normalizeResponsesToolSchema(value) : value;
1634
+ }
1635
+ function normalizeResponsesToolSchema(schema) {
1636
+ return Object.fromEntries(Object.entries(schema).flatMap(([key, value]) => {
1637
+ if (key === "pattern" && typeof value === "string" && hasUnsupportedPatternFeatures(value)) return [];
1638
+ if (schemaMapKeywords.has(key) && isRecord$1(value)) return [[key, Object.fromEntries(Object.entries(value).map(([name, nested]) => [name, normalizeSchemaValue(nested)]))]];
1639
+ if (nestedSchemaKeywords.has(key)) return [[key, normalizeSchemaValue(value)]];
1640
+ return [[key, value]];
1641
+ }));
1642
+ }
1643
+
1392
1644
  //#endregion
1393
1645
  //#region src/copilot/responses.ts
1394
1646
  const responsesOnlyModelPattern = /^(?:gpt-5\.5|gpt-5\.6|gpt-6-astra)(?:-|$)/i;
@@ -1710,7 +1962,7 @@ function translateTools(tools) {
1710
1962
  type: "function",
1711
1963
  name: tool.function.name,
1712
1964
  description: tool.function.description,
1713
- parameters: tool.function.parameters
1965
+ parameters: normalizeResponsesToolSchema(tool.function.parameters)
1714
1966
  }));
1715
1967
  }
1716
1968
  function translateToolChoice(toolChoice) {
@@ -1764,7 +2016,7 @@ const sanitizeReasoningEffortForModel = (_modelId, reasoningEffort) => {
1764
2016
  return reasoningEffort ?? void 0;
1765
2017
  };
1766
2018
  const getRequestedReasoningEffort = (payload) => {
1767
- return sanitizeReasoningEffortForModel(payload.model, runtimeState.thinkEffort ?? defaultReasoningEffort);
2019
+ return sanitizeReasoningEffortForModel(payload.model, resolveReasoningEffort(getRequestReasoningEffort(payload)));
1768
2020
  };
1769
2021
  const sanitizeUserIdentifier = (user) => {
1770
2022
  if (!user) return;
@@ -1829,13 +2081,21 @@ const messagesIncludeImage = (messages) => messages.some((msg) => typeof msg.con
1829
2081
  const createChatCompletions = async (config, payload, options = {}) => {
1830
2082
  const client = options.client ?? "generic";
1831
2083
  const requestedModel = options.requestedModel ?? payload.model;
1832
- const requestedThinkEffort = options.requestedThinkEffort ?? payload.reasoning_effort ?? "none";
2084
+ const requestedThinkEffort = options.requestedThinkEffort ?? getRequestReasoningEffort(payload) ?? "none";
2085
+ const reasoningEffort = getRequestedReasoningEffort(payload);
1833
2086
  const requestedThinking = options.requestedThinking ?? "none";
1834
2087
  const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
1835
2088
  const upstreamModelId = routeModelId(payload.model);
1836
- const upstreamPayload = upstreamModelId === payload.model ? payload : {
2089
+ const maxTokens = await boundModelOutputTokens(config, upstreamModelId, payload.max_tokens);
2090
+ const nonStreamingLimit = getCachedCopilotModel(config, upstreamModelId)?.limits?.max_non_streaming_output_tokens;
2091
+ const bufferResponse = !payload.stream && typeof maxTokens === "number" && nonStreamingLimit !== void 0 && maxTokens > nonStreamingLimit;
2092
+ const completeResponse = (response$1) => bufferResponse && !("choices" in response$1) ? collectChatCompletionStream(response$1, signal, options.timeoutMs) : response$1;
2093
+ const upstreamPayload = {
1837
2094
  ...payload,
1838
- model: upstreamModelId
2095
+ model: upstreamModelId,
2096
+ reasoning_effort: reasoningEffort,
2097
+ max_tokens: maxTokens,
2098
+ stream: bufferResponse ? true : payload.stream
1839
2099
  };
1840
2100
  const useResponsesApi = shouldUseResponsesApiForModel(upstreamPayload.model);
1841
2101
  const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill$1(upstreamPayload);
@@ -1854,13 +2114,13 @@ const createChatCompletions = async (config, payload, options = {}) => {
1854
2114
  `effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
1855
2115
  ].join(" "));
1856
2116
  log.debug("Full request payload", { payload: requestPayload });
1857
- if (useResponsesApi) return createResponses(provider, compatiblePayload, {
2117
+ if (useResponsesApi) return completeResponse(await createResponses(provider, compatiblePayload, {
1858
2118
  vision: enableVision,
1859
2119
  initiator,
1860
2120
  requestId: options.requestId,
1861
2121
  signal,
1862
2122
  timeoutMs: options.timeoutMs
1863
- });
2123
+ }));
1864
2124
  const response = await fetchCopilot(provider, "/chat/completions", {
1865
2125
  method: "POST",
1866
2126
  headers: {
@@ -1876,13 +2136,13 @@ const createChatCompletions = async (config, payload, options = {}) => {
1876
2136
  timeoutMs: options.timeoutMs
1877
2137
  });
1878
2138
  if (!response.ok) {
1879
- if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
2139
+ if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return completeResponse(await createResponses(provider, compatiblePayload, {
1880
2140
  vision: enableVision,
1881
2141
  initiator,
1882
2142
  requestId: options.requestId,
1883
2143
  signal,
1884
2144
  timeoutMs: options.timeoutMs
1885
- });
2145
+ }));
1886
2146
  throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
1887
2147
  model: payload.model,
1888
2148
  request: requestPayload,
@@ -1890,7 +2150,7 @@ const createChatCompletions = async (config, payload, options = {}) => {
1890
2150
  requestId: options.requestId
1891
2151
  }, signal, options.timeoutMs));
1892
2152
  }
1893
- if (compatiblePayload.stream) return events(response);
2153
+ if (compatiblePayload.stream) return completeResponse(events(response));
1894
2154
  return readCopilotJson(response, signal, options.timeoutMs);
1895
2155
  };
1896
2156
  async function createResponses(provider, payload, options) {
@@ -1956,26 +2216,17 @@ async function shouldRetryWithResponses(response, signal, timeoutMs) {
1956
2216
 
1957
2217
  //#endregion
1958
2218
  //#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
2219
  const ensureRequiredModels = async (config) => {
1974
- const upstreamModels = await fetchUpstreamModelIds(config);
2220
+ const catalog = await loadCopilotModelCatalog(config);
1975
2221
  const requiredModels = getUpstreamModelIds();
1976
- const missingModels = requiredModels.filter((model) => !upstreamModels.has(model));
2222
+ const missingModels = requiredModels.filter((model) => !catalog.models.has(model));
1977
2223
  if (missingModels.length > 0) throw new Error(`Required Copilot model(s) unavailable upstream: ${missingModels.join(", ")}`);
1978
2224
  log.info(`Upstream models available: ${requiredModels.join(", ")}`);
2225
+ for (const model of requiredModels) {
2226
+ const limits = catalog.models.get(model)?.limits;
2227
+ 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}`);
2228
+ else log.error(`Model token limits unavailable: model=${model}; preserving client budgets without assuming a capacity.`);
2229
+ }
1979
2230
  };
1980
2231
  const createProbePayload = (model) => ({
1981
2232
  model,
@@ -2099,7 +2350,7 @@ function translateToOpenAI(payload, _settings, toolNameMapper) {
2099
2350
  stream: payload.stream,
2100
2351
  temperature: payload.temperature,
2101
2352
  top_p: payload.top_p,
2102
- reasoning_effort: runtimeState.thinkEffort ?? defaultReasoningEffort,
2353
+ reasoning_effort: resolveReasoningEffort(getRequestReasoningEffort(payload)),
2103
2354
  user: payload.metadata?.user_id,
2104
2355
  tools,
2105
2356
  tool_choice: tools && tools.length > 0 ? translateClaudeToolChoiceToOpenAI(payload.tool_choice, mapper) : void 0
@@ -2544,6 +2795,7 @@ const buildWebSearchRequestPayload = (payload, requestedQuery, model) => ({
2544
2795
  model,
2545
2796
  input: buildSearchInput(payload, requestedQuery),
2546
2797
  tools: [{ type: "web_search_preview" }],
2798
+ reasoning: { effort: resolveReasoningEffort(getRequestReasoningEffort(payload)) },
2547
2799
  max_output_tokens: Math.max(256, Math.min(payload.max_tokens ?? 1024, 1200)),
2548
2800
  temperature: payload.temperature,
2549
2801
  top_p: payload.top_p
@@ -2729,56 +2981,6 @@ const chunkSettlesDecision = (chunk) => {
2729
2981
  return choice.delta?.tool_calls?.some((call) => call.function?.name) === true || !!choice.finish_reason;
2730
2982
  };
2731
2983
  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
2984
  const resolveWebSearchStreamDecision = async (stream, toolNameMapper, isWebSearchToolName, onChunk) => {
2783
2985
  const buffered = [];
2784
2986
  const iterator = stream[Symbol.asyncIterator]();
@@ -2860,6 +3062,7 @@ const encodingMap = {
2860
3062
  p50k_edit: () => import("gpt-tokenizer/encoding/p50k_edit"),
2861
3063
  r50k_base: () => import("gpt-tokenizer/encoding/r50k_base")
2862
3064
  };
3065
+ const isSupportedTokenizer = (encoding) => Object.hasOwn(encodingMap, encoding);
2863
3066
  const encodingCache = /* @__PURE__ */ new Map();
2864
3067
  const calculateToolCallsTokens = (toolCalls, encoder, constants) => {
2865
3068
  let tokens = 0;
@@ -2900,13 +3103,12 @@ const getEncodeChatFunction = async (encoding) => {
2900
3103
  const cached = encodingCache.get(encoding);
2901
3104
  if (cached) return cached;
2902
3105
  }
2903
- const supported = encoding;
2904
- if (!(supported in encodingMap)) {
3106
+ if (!isSupportedTokenizer(encoding)) {
2905
3107
  const fallback = await encodingMap.o200k_base();
2906
3108
  encodingCache.set(encoding, fallback);
2907
3109
  return fallback;
2908
3110
  }
2909
- const mod = await encodingMap[supported]();
3111
+ const mod = await encodingMap[encoding]();
2910
3112
  encodingCache.set(encoding, mod);
2911
3113
  return mod;
2912
3114
  };
@@ -3009,12 +3211,12 @@ const getTokenCount = async (payload, model) => {
3009
3211
  //#endregion
3010
3212
  //#region src/routes/claude.ts
3011
3213
  const claudeRoutes = new Hono();
3012
- const createTokenCountModel = (modelId) => ({
3013
- capabilities: { tokenizer: "o200k_base" },
3214
+ const createTokenCountModel = (modelId, tokenizer = "o200k_base") => ({
3215
+ capabilities: { tokenizer },
3014
3216
  id: modelId
3015
3217
  });
3016
3218
  const isNonStreamingResponse = (response) => typeof response === "object" && response !== null && Object.hasOwn(response, "choices");
3017
- const getClaudeRequestedThinkEffort = (payload) => payload.reasoning_effort ?? "none";
3219
+ const getClaudeRequestedThinkEffort = (payload) => getRequestReasoningEffort(payload) ?? "none";
3018
3220
  const getClaudeRequestedThinking = (payload) => {
3019
3221
  if (!payload.thinking) return "none";
3020
3222
  return [`type:${payload.thinking.type}`, `budget:${payload.thinking.budget_tokens ?? "none"}`].join(",");
@@ -3192,7 +3394,10 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
3192
3394
  const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(effectiveResponse, toolNameMapper) : void 0;
3193
3395
  let claudeResponse;
3194
3396
  if (webSearchToolCall) {
3195
- const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
3397
+ const search = await createClaudeWebSearchExecution(config, {
3398
+ ...claudePayload,
3399
+ reasoning_effort: openAIPayload.reasoning_effort
3400
+ }, webSearchToolCall.query, {
3196
3401
  requestId,
3197
3402
  signal: requestSignal,
3198
3403
  timeoutMs: config.upstreamTimeoutMs
@@ -3246,17 +3451,33 @@ const handleClaudeMessageRequest = async (config, claudePayload, requestSignal,
3246
3451
  };
3247
3452
  claudeRoutes.get("/models", (c) => c.json({
3248
3453
  object: "list",
3249
- data: getExposedModelIds().map((id) => ({
3250
- id,
3251
- object: "model",
3252
- created: 0,
3253
- owned_by: "github-copilot"
3254
- }))
3454
+ data: getExposedModelIds().map((id) => {
3455
+ const limits = getCachedCopilotModel(c.get("config"), id)?.limits;
3456
+ return {
3457
+ id,
3458
+ object: "model",
3459
+ created: 0,
3460
+ owned_by: "github-copilot",
3461
+ ...limits && {
3462
+ context_window: limits.max_context_window_tokens,
3463
+ max_input_tokens: limits.max_prompt_tokens,
3464
+ max_tokens: limits.max_output_tokens
3465
+ }
3466
+ };
3467
+ })
3255
3468
  }));
3256
3469
  claudeRoutes.post("/messages", async (c) => {
3257
3470
  const config = c.get("config");
3258
3471
  const requestId = c.get("requestId");
3259
3472
  const claudePayload = await c.req.json();
3473
+ try {
3474
+ getRequestReasoningEffort(claudePayload);
3475
+ } catch (error) {
3476
+ if (!(error instanceof HTTPError)) throw error;
3477
+ c.set("requestErrorMessage", error.message);
3478
+ log.error(`request_id=${requestId} ${error.message}`);
3479
+ return error.response;
3480
+ }
3260
3481
  const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
3261
3482
  log.debug("Full Claude request payload", { payload: claudePayload });
3262
3483
  if (claudePayload.stream) return streamSSE(c, async (stream) => {
@@ -3301,19 +3522,25 @@ claudeRoutes.post("/messages/count_tokens", async (c) => {
3301
3522
  const claudePayload = await c.req.json();
3302
3523
  const openAIPayload = translateToOpenAI(claudePayload);
3303
3524
  const exposedModels = getExposedModelIds();
3304
- const selectedModel = createTokenCountModel(openAIPayload.model === exposedModels[1] ? exposedModels[1] : exposedModels[0]);
3525
+ const upstreamModel = getCachedCopilotModel(c.get("config"), openAIPayload.model);
3526
+ const hasDiscoveredTokenizer = upstreamModel?.tokenizer !== void 0 && isSupportedTokenizer(upstreamModel.tokenizer);
3527
+ const selectedModel = createTokenCountModel(openAIPayload.model === exposedModels[1] ? exposedModels[1] : exposedModels[0], hasDiscoveredTokenizer ? upstreamModel?.tokenizer : void 0);
3305
3528
  const tokenCount = await getTokenCount(openAIPayload, selectedModel);
3306
3529
  const effectiveModelId = selectedModel.id;
3307
3530
  if (claudePayload.tools && claudePayload.tools.length > 0) {
3308
3531
  let mcpToolExist = false;
3309
3532
  if (claudeBeta?.startsWith("claude-code")) mcpToolExist = claudePayload.tools.some((tool) => tool.name.startsWith("mcp__"));
3310
- if (!mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
3533
+ if (!hasDiscoveredTokenizer && !mcpToolExist && effectiveModelId.startsWith("claude")) tokenCount.input += 346;
3311
3534
  }
3312
- const multiplier = effectiveModelId.startsWith("claude") ? 1.15 : 1;
3535
+ const multiplier = !hasDiscoveredTokenizer && effectiveModelId.startsWith("claude") ? 1.15 : 1;
3313
3536
  const finalTokenCount = Math.round((tokenCount.input + tokenCount.output) * multiplier);
3314
3537
  return c.json({ input_tokens: Math.max(1, finalTokenCount) });
3315
3538
  } catch (error) {
3316
3539
  log.error("Error counting tokens:", error);
3540
+ if (error instanceof HTTPError) {
3541
+ c.set("requestErrorMessage", error.message);
3542
+ return error.response;
3543
+ }
3317
3544
  return c.json({ input_tokens: 1 });
3318
3545
  }
3319
3546
  });
@@ -3429,6 +3656,7 @@ async function startRelay(appConfig) {
3429
3656
  runtimeState.debug = nextConfig.logLevel === "debug";
3430
3657
  runtimeState.thinkEffort = nextConfig.thinkEffort;
3431
3658
  config.copilotBaseUrl = nextConfig.copilotBaseUrl;
3659
+ runtimeState.upstreamBaseUrl = nextConfig.copilotBaseUrl;
3432
3660
  config.host = nextConfig.host;
3433
3661
  config.port = nextConfig.port;
3434
3662
  config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
@@ -3440,9 +3668,8 @@ async function startRelay(appConfig) {
3440
3668
  };
3441
3669
  applyRuntimeConfig(appConfig);
3442
3670
  log.info(`Log level: ${appConfig.logLevel}`);
3443
- log.info(`Think effort: ${appConfig.thinkEffort}`);
3671
+ log.info(`Default think effort: ${appConfig.thinkEffort}`);
3444
3672
  log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
3445
- log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
3446
3673
  const authSession = await setupProxyAuth(config);
3447
3674
  try {
3448
3675
  await validateUpstream(config, appConfig.thinkEffort);
@@ -3450,6 +3677,7 @@ async function startRelay(appConfig) {
3450
3677
  log.error("Startup preflight failed:", error);
3451
3678
  process.exit(1);
3452
3679
  }
3680
+ log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
3453
3681
  const server = await startServer(config);
3454
3682
  await writeRelayPidFile(config);
3455
3683
  log.info(`copilot-relay version: ${appVersion}`);
@@ -3459,10 +3687,14 @@ async function startRelay(appConfig) {
3459
3687
  log.info(`copilot base url: ${formatUrlForDisplay(config.copilotBaseUrl)}`);
3460
3688
  const baseUrl = `http://${config.host}:${config.port}`;
3461
3689
  if (appConfig.claudeSetup) try {
3690
+ const gptLimits = getCachedCopilotModel(config, appConfig.gptModel)?.limits;
3691
+ const opusLimits = getCachedCopilotModel(config, appConfig.opusModel)?.limits;
3462
3692
  const claudeResult = await applyClaudeConfig({
3463
3693
  baseUrl,
3464
3694
  configPath: claudeConfigPath$1,
3465
- gptModel: appConfig.gptModel
3695
+ gptModel: appConfig.gptModel,
3696
+ gptLimits,
3697
+ maxOutputTokens: gptLimits && opusLimits ? Math.max(gptLimits.max_output_tokens, opusLimits.max_output_tokens) : void 0
3466
3698
  });
3467
3699
  if (claudeResult.changed) {
3468
3700
  log.info(`claude settings ${claudeResult.created ? "created" : "updated"}: ${claudeResult.configPath}`);