copilot-relay 0.0.6 → 0.0.8

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
@@ -60,6 +60,7 @@ claudeSetup: true
60
60
  logLevel: info
61
61
  logRetentionDays: 3
62
62
  thinkEffort: xhigh
63
+ upstreamTimeoutSeconds: 180
63
64
  webSearchBackend:
64
65
  ```
65
66
 
@@ -75,6 +76,10 @@ Any other `logLevel` value is invalid and stops startup.
75
76
 
76
77
  Valid `thinkEffort`: `none`, `low`, `medium`, `high`, `xhigh`.
77
78
 
79
+ `upstreamTimeoutSeconds` controls the maximum time a single Claude request can
80
+ spend waiting on upstream Copilot calls, including chat, Responses, preflight,
81
+ and bridge-managed WebSearch calls. The default is `180`.
82
+
78
83
  `webSearchBackend` controls bridge-managed Claude WebSearch. Leave it empty to
79
84
  use `gptModel`, or set a Copilot Responses model ID such as `gpt-5.5`.
80
85
 
@@ -35,6 +35,9 @@ logRetentionDays: 3
35
35
  # Valid values: none, low, medium, high, xhigh.
36
36
  thinkEffort: xhigh
37
37
 
38
+ # Max seconds to wait for one Claude request's upstream Copilot calls.
39
+ upstreamTimeoutSeconds: 180
40
+
38
41
  # Copilot model used for bridge-managed Claude WebSearch.
39
42
  # Leave empty to use gptModel.
40
43
  webSearchBackend:
package/dist/main.js CHANGED
@@ -372,6 +372,7 @@ const defaultConfig = {
372
372
  opusModel: "claude-opus-4.8",
373
373
  port: 4142,
374
374
  thinkEffort: defaultReasoningEffort,
375
+ upstreamTimeoutSeconds: 180,
375
376
  webSearchBackend: void 0
376
377
  };
377
378
  const isLogLevelName = (value) => typeof value === "string" && logLevels.includes(value.toLowerCase());
@@ -403,6 +404,12 @@ const normalizeThinkEffort = (value) => {
403
404
  const normalized = value.toLowerCase() === "minimal" ? "low" : value.toLowerCase();
404
405
  return isReasoningEffort(normalized) ? normalized : void 0;
405
406
  };
407
+ const normalizeUpstreamTimeoutSeconds = (value) => {
408
+ if (value === void 0) return;
409
+ const timeout = normalizePositiveInteger(value);
410
+ if (timeout === void 0) throw new Error("Invalid upstreamTimeoutSeconds: expected a positive integer");
411
+ return timeout;
412
+ };
406
413
  const readRawConfig = async () => {
407
414
  try {
408
415
  return parseConfigYaml(await fs.readFile(paths.configPath, "utf8"));
@@ -476,6 +483,10 @@ const parseConfigYaml = (content) => {
476
483
  case "think_effort":
477
484
  config.thinkEffort = unquoteYamlScalar(value);
478
485
  break;
486
+ case "upstreamTimeoutSeconds":
487
+ case "upstream_timeout_seconds":
488
+ config.upstreamTimeoutSeconds = unquoteYamlScalar(value);
489
+ break;
479
490
  case "webSearchBackend":
480
491
  case "web_search_backend":
481
492
  config.webSearchBackend = unquoteYamlScalar(value);
@@ -522,6 +533,9 @@ const serializeConfig = (config) => [
522
533
  "# Default upstream thinking/reasoning effort: none, low, medium, high, xhigh.",
523
534
  `thinkEffort: ${config.thinkEffort}`,
524
535
  "",
536
+ "# Max seconds to wait for a single Claude request's upstream Copilot calls.",
537
+ `upstreamTimeoutSeconds: ${config.upstreamTimeoutSeconds}`,
538
+ "",
525
539
  "# Copilot model used for bridge-managed Claude WebSearch. Empty uses gptModel.",
526
540
  `webSearchBackend: ${config.webSearchBackend ?? ""}`,
527
541
  "",
@@ -542,6 +556,7 @@ async function readAppConfig() {
542
556
  const logRetentionDays$1 = normalizePositiveInteger(raw.logRetentionDays);
543
557
  const port = normalizePort(raw.port);
544
558
  const thinkEffort = normalizeThinkEffort(raw.thinkEffort);
559
+ const upstreamTimeoutSeconds = normalizeUpstreamTimeoutSeconds(raw.upstreamTimeoutSeconds);
545
560
  const config = {
546
561
  claudeSetup: claudeSetup ?? defaultConfig.claudeSetup,
547
562
  copilotBaseUrl: normalizeString(raw.copilotBaseUrl) ?? defaultConfig.copilotBaseUrl,
@@ -552,6 +567,7 @@ async function readAppConfig() {
552
567
  opusModel: normalizeString(raw.opusModel) ?? defaultConfig.opusModel,
553
568
  port: port ?? defaultConfig.port,
554
569
  thinkEffort: thinkEffort ?? defaultConfig.thinkEffort,
570
+ upstreamTimeoutSeconds: upstreamTimeoutSeconds ?? defaultConfig.upstreamTimeoutSeconds,
555
571
  webSearchBackend: normalizeString(raw.webSearchBackend)
556
572
  };
557
573
  await writeConfig(config);
@@ -590,6 +606,7 @@ const readProxyConfig = (config) => ({
590
606
  copilotToken: void 0,
591
607
  host: config.host,
592
608
  port: config.port,
609
+ upstreamTimeoutMs: config.upstreamTimeoutSeconds * 1e3,
593
610
  vsCodeVersion: vscodeVersion,
594
611
  webSearchBackend: config.webSearchBackend
595
612
  });
@@ -674,12 +691,50 @@ const editorPluginVersion = `copilot-chat/${copilotVersion}`;
674
691
  const userAgent = `GitHubCopilotChat/${copilotVersion}`;
675
692
  const apiVersion = "2025-04-01";
676
693
  const maxFetchAttempts = 2;
694
+ const copilotRequestTimeoutMs = 18e4;
677
695
  const getCopilotProviderContext = (config) => ({
678
696
  baseUrl: config.copilotBaseUrl,
679
697
  token: config.copilotToken,
680
698
  vsCodeVersion: config.vsCodeVersion
681
699
  });
682
700
  const shouldRetryResponse = (response) => response.status >= 500 && response.status <= 599;
701
+ const createCopilotRequestSignal = (signal, timeoutMs = copilotRequestTimeoutMs) => {
702
+ const signals = [signal, timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : void 0].filter((value) => value !== void 0);
703
+ if (signals.length === 0) return;
704
+ return signals.length === 1 ? signals[0] : AbortSignal.any(signals);
705
+ };
706
+ const getAbortName = (value) => typeof value === "object" && value !== null && "name" in value && typeof value.name === "string" ? value.name : void 0;
707
+ const isAbortLikeError = (error) => {
708
+ const name = getAbortName(error);
709
+ return name === "AbortError" || name === "TimeoutError";
710
+ };
711
+ const toCopilotAbortHTTPError = (error, signal, timeoutMs = copilotRequestTimeoutMs) => {
712
+ if (!signal?.aborted && !isAbortLikeError(error)) return;
713
+ const timedOut = getAbortName(signal?.reason) === "TimeoutError" || getAbortName(error) === "TimeoutError";
714
+ const message = timedOut ? `Copilot upstream request timed out after ${Math.round(timeoutMs / 1e3)}s.` : "Client request cancelled before Copilot upstream completed.";
715
+ const code = timedOut ? "upstream_timeout" : "request_cancelled";
716
+ return new HTTPError(message, new Response(JSON.stringify({ error: {
717
+ message,
718
+ code
719
+ } }), {
720
+ status: timedOut ? 504 : 499,
721
+ headers: { "content-type": "application/json" }
722
+ }), message);
723
+ };
724
+ const readCopilotJson = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
725
+ try {
726
+ return await response.json();
727
+ } catch (error) {
728
+ throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
729
+ }
730
+ };
731
+ const readCopilotText = async (response, signal, timeoutMs = copilotRequestTimeoutMs) => {
732
+ try {
733
+ return await response.text();
734
+ } catch (error) {
735
+ throw toCopilotAbortHTTPError(error, signal, timeoutMs) ?? error;
736
+ }
737
+ };
683
738
  const buildHeaders = (provider, init, options) => {
684
739
  const headers = new Headers(init.headers);
685
740
  headers.set("authorization", `Bearer ${provider.token}`);
@@ -699,18 +754,23 @@ const buildHeaders = (provider, init, options) => {
699
754
  };
700
755
  const fetchCopilot = async (provider, path$1, init, options = {}) => {
701
756
  if (!provider.token) throw new ProxyNotImplementedError("Copilot token is not configured for copilot-relay.");
757
+ const timeoutMs = options.timeoutMs ?? copilotRequestTimeoutMs;
758
+ const signal = createCopilotRequestSignal(options.signal, timeoutMs);
702
759
  let lastError;
703
760
  for (let attempt = 1; attempt <= maxFetchAttempts; attempt++) try {
704
761
  const started = performance.now();
705
762
  const response = await fetch(`${provider.baseUrl}${path$1}`, {
706
763
  ...init,
707
- headers: buildHeaders(provider, init, options)
764
+ headers: buildHeaders(provider, init, options),
765
+ signal
708
766
  });
709
767
  const ms = Math.round(performance.now() - started);
710
768
  log.debug(`Copilot ${init.method ?? "GET"} ${path$1} -> ${response.status} ${ms}ms (attempt ${attempt})`);
711
769
  if (!shouldRetryResponse(response) || attempt === maxFetchAttempts) return response;
712
770
  log.error(`Copilot ${path$1} returned ${response.status}; retrying (${attempt}/${maxFetchAttempts})`);
713
771
  } catch (error) {
772
+ const abortError = toCopilotAbortHTTPError(error, signal, timeoutMs);
773
+ if (abortError) throw abortError;
714
774
  lastError = error;
715
775
  if (attempt === maxFetchAttempts) throw error;
716
776
  log.error(`Copilot ${path$1} request failed; retrying (${attempt}/${maxFetchAttempts})`, error);
@@ -733,12 +793,21 @@ function buildResponsesRequestPayload(payload, reasoningEffort) {
733
793
  temperature: payload.temperature,
734
794
  top_p: payload.top_p,
735
795
  user: sanitizeUserIdentifier(payload.user),
796
+ prompt_cache_key: deriveResponsesCacheKey(payload),
736
797
  tools: translateTools(payload.tools),
737
798
  tool_choice: translateToolChoice(payload.tool_choice),
738
799
  reasoning: reasoningEffort ? { effort: reasoningEffort } : void 0,
739
800
  text: payload.response_format?.type === "json_object" ? { format: { type: "json_object" } } : void 0
740
801
  };
741
802
  }
803
+ function deriveResponsesCacheKey(payload) {
804
+ const user = sanitizeUserIdentifier(payload.user);
805
+ if (user) return `cr-${createHash("sha256").update(user).digest("hex").slice(0, 32)}`;
806
+ const systemPrompt = payload.messages.find((message) => message.role === "system");
807
+ const systemText = typeof systemPrompt?.content === "string" ? systemPrompt.content : Array.isArray(systemPrompt?.content) ? systemPrompt.content.map((part) => part.type === "text" ? part.text : "").join("") : "";
808
+ if (!systemText) return;
809
+ return `cr-sys-${createHash("sha256").update(systemText).digest("hex").slice(0, 32)}`;
810
+ }
742
811
  function translateResponsesToChatCompletion(response) {
743
812
  const assistantMessages = response.output.filter((item) => item.type === "message");
744
813
  const functionCalls = response.output.filter((item) => item.type === "function_call");
@@ -1077,6 +1146,7 @@ function getFinishReason(response, hasFunctionCalls) {
1077
1146
  //#endregion
1078
1147
  //#region src/copilot/chat.ts
1079
1148
  const usesMaxCompletionTokens = (modelId) => modelId.startsWith("gpt-5");
1149
+ const continuePrefillPrompt$1 = "Continue the assistant response from the previous assistant message.";
1080
1150
  const maxUserLength = 64;
1081
1151
  const sanitizeReasoningEffortForModel = (_modelId, reasoningEffort) => {
1082
1152
  return reasoningEffort ?? void 0;
@@ -1103,6 +1173,38 @@ const buildRequestPayload = (payload) => {
1103
1173
  user: sanitizeUserIdentifier(payload.user)
1104
1174
  };
1105
1175
  };
1176
+ const normalizeFinalAssistantPrefill$1 = (payload) => {
1177
+ const lastMessage = payload.messages.at(-1);
1178
+ if (lastMessage?.role !== "assistant") return payload;
1179
+ const messages = [...payload.messages];
1180
+ if (typeof lastMessage.content !== "string") {
1181
+ messages.pop();
1182
+ return {
1183
+ ...payload,
1184
+ messages
1185
+ };
1186
+ }
1187
+ const content = lastMessage.content.trimEnd();
1188
+ if (!content) {
1189
+ messages.pop();
1190
+ return {
1191
+ ...payload,
1192
+ messages
1193
+ };
1194
+ }
1195
+ messages[messages.length - 1] = {
1196
+ ...lastMessage,
1197
+ content
1198
+ };
1199
+ messages.push({
1200
+ role: "user",
1201
+ content: continuePrefillPrompt$1
1202
+ });
1203
+ return {
1204
+ ...payload,
1205
+ messages
1206
+ };
1207
+ };
1106
1208
  const isAgentInitiator = (messages) => messages.some((msg) => msg.role === "assistant" || msg.role === "tool") ? "agent" : "user";
1107
1209
  const messagesIncludeImage = (messages) => messages.some((msg) => typeof msg.content !== "string" && msg.content?.some((part) => part.type === "image_url"));
1108
1210
  const createChatCompletions = async (config, payload, options = {}) => {
@@ -1110,53 +1212,62 @@ const createChatCompletions = async (config, payload, options = {}) => {
1110
1212
  const requestedModel = options.requestedModel ?? payload.model;
1111
1213
  const requestedThinkEffort = options.requestedThinkEffort ?? payload.reasoning_effort ?? "none";
1112
1214
  const requestedThinking = options.requestedThinking ?? "none";
1215
+ const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
1113
1216
  const upstreamModelId = routeModelId(payload.model);
1114
1217
  const upstreamPayload = upstreamModelId === payload.model ? payload : {
1115
1218
  ...payload,
1116
1219
  model: upstreamModelId
1117
1220
  };
1221
+ const useResponsesApi = shouldUseResponsesApiForModel(upstreamPayload.model);
1222
+ const compatiblePayload = useResponsesApi ? upstreamPayload : normalizeFinalAssistantPrefill$1(upstreamPayload);
1118
1223
  const provider = getCopilotProviderContext(config);
1119
- const enableVision = messagesIncludeImage(upstreamPayload.messages);
1120
- const initiator = isAgentInitiator(upstreamPayload.messages);
1121
- const requestPayload = buildRequestPayload(upstreamPayload);
1224
+ const enableVision = messagesIncludeImage(compatiblePayload.messages);
1225
+ const initiator = isAgentInitiator(compatiblePayload.messages);
1226
+ const requestPayload = buildRequestPayload(compatiblePayload);
1122
1227
  log.debug([
1123
1228
  "Model request",
1124
1229
  `client=${client}`,
1125
1230
  `requested_model=${requestedModel}`,
1126
- `upstream_model=${upstreamPayload.model}`,
1231
+ `upstream_model=${compatiblePayload.model}`,
1127
1232
  `requested_think_effort=${requestedThinkEffort}`,
1128
1233
  `requested_thinking=${requestedThinking}`,
1129
1234
  `effective_think_effort=${requestPayload.reasoning_effort ?? "none"}`
1130
1235
  ].join(" "));
1131
1236
  log.debug("Full request payload", { payload: requestPayload });
1132
- if (shouldUseResponsesApiForModel(upstreamPayload.model)) return createResponses(provider, upstreamPayload, {
1237
+ if (useResponsesApi) return createResponses(provider, compatiblePayload, {
1133
1238
  vision: enableVision,
1134
- initiator
1239
+ initiator,
1240
+ signal,
1241
+ timeoutMs: options.timeoutMs
1135
1242
  });
1136
1243
  const response = await fetchCopilot(provider, "/chat/completions", {
1137
1244
  method: "POST",
1138
1245
  headers: {
1139
- accept: upstreamPayload.stream ? "text/event-stream" : "application/json",
1246
+ accept: compatiblePayload.stream ? "text/event-stream" : "application/json",
1140
1247
  "content-type": "application/json"
1141
1248
  },
1142
1249
  body: JSON.stringify(requestPayload)
1143
1250
  }, {
1144
1251
  vision: enableVision,
1145
- initiator
1252
+ initiator,
1253
+ signal,
1254
+ timeoutMs: options.timeoutMs
1146
1255
  });
1147
1256
  if (!response.ok) {
1148
- if (await shouldRetryWithResponses(response)) return createResponses(provider, upstreamPayload, {
1257
+ if (await shouldRetryWithResponses(response, signal, options.timeoutMs)) return createResponses(provider, compatiblePayload, {
1149
1258
  vision: enableVision,
1150
- initiator
1259
+ initiator,
1260
+ signal,
1261
+ timeoutMs: options.timeoutMs
1151
1262
  });
1152
1263
  throw new HTTPError("Failed to create chat completions", response, await logUpstreamError("Failed to create chat completions", response, {
1153
1264
  model: payload.model,
1154
1265
  request: requestPayload,
1155
1266
  route: "/chat/completions"
1156
- }));
1267
+ }, signal, options.timeoutMs));
1157
1268
  }
1158
- if (upstreamPayload.stream) return events(response);
1159
- return await response.json();
1269
+ if (compatiblePayload.stream) return events(response);
1270
+ return readCopilotJson(response, signal, options.timeoutMs);
1160
1271
  };
1161
1272
  async function createResponses(provider, payload, options) {
1162
1273
  const requestPayload = buildResponsesRequestPayload(payload, getRequestedReasoningEffort(payload));
@@ -1169,18 +1280,20 @@ async function createResponses(provider, payload, options) {
1169
1280
  body: JSON.stringify(requestPayload)
1170
1281
  }, {
1171
1282
  vision: options.vision,
1172
- initiator: options.initiator
1283
+ initiator: options.initiator,
1284
+ signal: options.signal,
1285
+ timeoutMs: options.timeoutMs
1173
1286
  });
1174
1287
  if (!response.ok) throw new HTTPError("Failed to create responses", response, await logUpstreamError("Failed to create responses", response, {
1175
1288
  model: payload.model,
1176
1289
  request: requestPayload,
1177
1290
  route: "/responses"
1178
- }));
1291
+ }, options.signal, options.timeoutMs));
1179
1292
  if (payload.stream) return translateResponsesStreamToChatCompletionStream(events(response));
1180
- return translateResponsesToChatCompletion(await response.json());
1293
+ return translateResponsesToChatCompletion(await readCopilotJson(response, options.signal, options.timeoutMs));
1181
1294
  }
1182
- async function logUpstreamError(message, response, context) {
1183
- const errorBody = await response.clone().text().catch(() => "");
1295
+ async function logUpstreamError(message, response, context, signal, timeoutMs) {
1296
+ const errorBody = await readCopilotText(response.clone(), signal, timeoutMs).catch(() => "");
1184
1297
  const detail = getUpstreamErrorDetail(response, errorBody);
1185
1298
  log.error(`${message}: route=${context.route} model=${context.model} status=${response.status}`, {
1186
1299
  message,
@@ -1206,9 +1319,9 @@ function getUpstreamErrorDetail(response, body) {
1206
1319
  return body.slice(0, 240);
1207
1320
  }
1208
1321
  }
1209
- async function shouldRetryWithResponses(response) {
1322
+ async function shouldRetryWithResponses(response, signal, timeoutMs) {
1210
1323
  try {
1211
- return (await response.clone().json()).error?.code === "unsupported_api_for_model";
1324
+ return (await readCopilotJson(response.clone(), signal, timeoutMs)).error?.code === "unsupported_api_for_model";
1212
1325
  } catch {
1213
1326
  return false;
1214
1327
  }
@@ -1217,12 +1330,17 @@ async function shouldRetryWithResponses(response) {
1217
1330
  //#endregion
1218
1331
  //#region src/lib/preflight.ts
1219
1332
  const getUpstreamModelIds = async (config) => {
1220
- const response = await fetchCopilot(getCopilotProviderContext(config), "/models", {
1333
+ const provider = getCopilotProviderContext(config);
1334
+ const signal = createCopilotRequestSignal(void 0, config.upstreamTimeoutMs);
1335
+ const response = await fetchCopilot(provider, "/models", {
1221
1336
  method: "GET",
1222
1337
  headers: { accept: "application/json" }
1338
+ }, {
1339
+ signal,
1340
+ timeoutMs: config.upstreamTimeoutMs
1223
1341
  });
1224
1342
  if (!response.ok) throw new HTTPError("Failed to validate upstream models", response);
1225
- const payload = await response.json();
1343
+ const payload = await readCopilotJson(response, signal, config.upstreamTimeoutMs);
1226
1344
  return new Set((payload.data ?? []).map((model) => model.id).filter((id) => typeof id === "string" && id.length > 0));
1227
1345
  };
1228
1346
  const ensureRequiredModels = async (config) => {
@@ -1245,7 +1363,8 @@ const validateModelRequest = async (config, model, thinkEffort) => {
1245
1363
  try {
1246
1364
  const response = await createChatCompletions(config, createProbePayload(model), {
1247
1365
  client: "generic",
1248
- requestedModel: model
1366
+ requestedModel: model,
1367
+ timeoutMs: config.upstreamTimeoutMs
1249
1368
  });
1250
1369
  if (typeof response !== "object" || response === null || !("choices" in response)) throw new Error(`Preflight request for ${model} unexpectedly streamed`);
1251
1370
  log.info(`Preflight OK: model=${model} think_effort=${thinkEffort}`);
@@ -1862,8 +1981,9 @@ const createFailedSearchExecution = (payload, requestedQuery, model, message) =>
1862
1981
  results: [],
1863
1982
  text: message
1864
1983
  });
1865
- const createClaudeWebSearchExecution = async (config, payload, requestedQuery) => {
1984
+ const createClaudeWebSearchExecution = async (config, payload, requestedQuery, options = {}) => {
1866
1985
  const backendModel = getWebSearchBackendModel(config);
1986
+ const signal = createCopilotRequestSignal(options.signal, options.timeoutMs);
1867
1987
  const response = await fetchCopilot(getCopilotProviderContext(config), "/responses", {
1868
1988
  method: "POST",
1869
1989
  headers: {
@@ -1871,12 +1991,16 @@ const createClaudeWebSearchExecution = async (config, payload, requestedQuery) =
1871
1991
  "content-type": "application/json"
1872
1992
  },
1873
1993
  body: JSON.stringify(buildWebSearchRequestPayload(payload, requestedQuery, backendModel))
1874
- }, { initiator: "agent" });
1994
+ }, {
1995
+ initiator: "agent",
1996
+ signal,
1997
+ timeoutMs: options.timeoutMs
1998
+ });
1875
1999
  if (!response.ok) {
1876
- const detail = await response.text().catch(() => "");
2000
+ const detail = await readCopilotText(response, signal, options.timeoutMs).catch(() => "");
1877
2001
  return createFailedSearchExecution(payload, requestedQuery, backendModel, [`Copilot web search is not available for model ${backendModel}.`, detail ? `Upstream response: ${detail}` : ""].filter(Boolean).join("\n"));
1878
2002
  }
1879
- const upstream = await response.json();
2003
+ const upstream = await readCopilotJson(response, signal, options.timeoutMs);
1880
2004
  const text = getResponseText(upstream);
1881
2005
  const query = getSearchQuery(upstream, requestedQuery);
1882
2006
  const results = parseSearchResults(text);
@@ -2224,6 +2348,7 @@ claudeRoutes.get("/models", (c) => c.json({
2224
2348
  claudeRoutes.post("/messages", async (c) => {
2225
2349
  const config = c.get("config");
2226
2350
  const claudePayload = await c.req.json();
2351
+ const requestSignal = createCopilotRequestSignal(c.req.raw.signal, config.upstreamTimeoutMs);
2227
2352
  log.debug("Full Claude request payload", { payload: claudePayload });
2228
2353
  try {
2229
2354
  const upstreamModel = translateModelName(claudePayload.model);
@@ -2236,13 +2361,18 @@ claudeRoutes.post("/messages", async (c) => {
2236
2361
  client: "claude",
2237
2362
  requestedModel: claudePayload.model,
2238
2363
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2239
- requestedThinking: getClaudeRequestedThinking(claudePayload)
2364
+ requestedThinking: getClaudeRequestedThinking(claudePayload),
2365
+ signal: requestSignal,
2366
+ timeoutMs: config.upstreamTimeoutMs
2240
2367
  });
2241
2368
  if (isNonStreamingResponse(response)) {
2242
2369
  const webSearchToolCall = shouldLetModelDecideWebSearch ? getClaudeWebSearchToolCallFromChatResponse(response, toolNameMapper) : void 0;
2243
2370
  let claudeResponse;
2244
2371
  if (webSearchToolCall) {
2245
- const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query);
2372
+ const search = await createClaudeWebSearchExecution(config, claudePayload, webSearchToolCall.query, {
2373
+ signal: requestSignal,
2374
+ timeoutMs: config.upstreamTimeoutMs
2375
+ });
2246
2376
  const searchResponse = createClaudeWebSearchResponse(search);
2247
2377
  if (search.results.length === 0) claudeResponse = searchResponse;
2248
2378
  else {
@@ -2250,7 +2380,9 @@ claudeRoutes.post("/messages", async (c) => {
2250
2380
  client: "claude",
2251
2381
  requestedModel: claudePayload.model,
2252
2382
  requestedThinkEffort: getClaudeRequestedThinkEffort(claudePayload),
2253
- requestedThinking: getClaudeRequestedThinking(claudePayload)
2383
+ requestedThinking: getClaudeRequestedThinking(claudePayload),
2384
+ signal: requestSignal,
2385
+ timeoutMs: config.upstreamTimeoutMs
2254
2386
  });
2255
2387
  if (!isNonStreamingResponse(finalResponse)) throw new HTTPError("Claude web search final answer request unexpectedly streamed", new Response("Claude web search final answer request unexpectedly streamed", {
2256
2388
  status: 502,
@@ -2433,6 +2565,8 @@ const start = defineCommand({
2433
2565
  config.copilotBaseUrl = nextConfig.copilotBaseUrl;
2434
2566
  config.host = nextConfig.host;
2435
2567
  config.port = nextConfig.port;
2568
+ config.upstreamTimeoutMs = nextConfig.upstreamTimeoutSeconds * 1e3;
2569
+ config.webSearchBackend = nextConfig.webSearchBackend;
2436
2570
  runtimeState.modelRouting = {
2437
2571
  gptModel: nextConfig.gptModel,
2438
2572
  opusModel: nextConfig.opusModel
@@ -2441,6 +2575,7 @@ const start = defineCommand({
2441
2575
  applyRuntimeConfig(appConfig);
2442
2576
  log.info(`Log level: ${appConfig.logLevel}`);
2443
2577
  log.info(`Think effort: ${appConfig.thinkEffort}`);
2578
+ log.info(`Upstream timeout: ${appConfig.upstreamTimeoutSeconds}s`);
2444
2579
  log.info(`Exposed models: ${getExposedModelIds().join(", ")}`);
2445
2580
  const authSession = await setupProxyAuth(config);
2446
2581
  try {
@@ -2470,7 +2605,7 @@ const start = defineCommand({
2470
2605
  }
2471
2606
  watchAppConfig((nextConfig) => {
2472
2607
  applyRuntimeConfig(nextConfig);
2473
- log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort}`);
2608
+ log.info(`Config reloaded: logLevel=${nextConfig.logLevel} thinkEffort=${nextConfig.thinkEffort} upstreamTimeoutSeconds=${nextConfig.upstreamTimeoutSeconds}`);
2474
2609
  });
2475
2610
  await new Promise((resolve$1, reject) => {
2476
2611
  server.on("close", resolve$1);