@dianshuv/copilot-api 0.7.8 → 0.7.10

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.
Files changed (2) hide show
  1. package/dist/main.mjs +199 -21
  2. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -478,10 +478,128 @@ async function getGitHubUser() {
478
478
  return await response.json();
479
479
  }
480
480
 
481
+ //#endregion
482
+ //#region src/lib/fetch-retry.ts
483
+ const RETRYABLE_CAUSE_CODES = new Set([
484
+ "UND_ERR_SOCKET",
485
+ "ECONNRESET",
486
+ "ENOTFOUND",
487
+ "UND_ERR_CONNECT_TIMEOUT"
488
+ ]);
489
+ const RETRY_DELAYS_MS = [200, 600];
490
+ function getCauseCode(error) {
491
+ if (!(error instanceof Error)) return void 0;
492
+ const cause = error.cause;
493
+ if (typeof cause !== "object" || cause === null) return void 0;
494
+ const code = cause.code;
495
+ return typeof code === "string" ? code : void 0;
496
+ }
497
+ function isRetryable(error) {
498
+ const code = getCauseCode(error);
499
+ return code !== void 0 && RETRYABLE_CAUSE_CODES.has(code);
500
+ }
501
+ const RETRY_ATTEMPTS_KEY = "__copilotRetryAttempts";
502
+ const RETRY_AUTH_REFRESHED_KEY = "__copilotRetryAuthRefreshed";
503
+ /**
504
+ * Read retry metadata recorded on an error or response by fetchWithRetry.
505
+ * Returns attempts=1 (no retry) if the value is missing or malformed.
506
+ */
507
+ function getRetryAttempts(target) {
508
+ if (typeof target !== "object" || target === null) return {
509
+ attempts: 1,
510
+ authRefreshed: false
511
+ };
512
+ const meta = target;
513
+ return {
514
+ attempts: typeof meta[RETRY_ATTEMPTS_KEY] === "number" ? meta[RETRY_ATTEMPTS_KEY] : 1,
515
+ authRefreshed: meta[RETRY_AUTH_REFRESHED_KEY] === true
516
+ };
517
+ }
518
+ async function fetchWithRetry(input, init, options) {
519
+ let currentInit = init;
520
+ let authRefreshed = false;
521
+ let networkAttempts = 0;
522
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) try {
523
+ networkAttempts++;
524
+ const response = await fetch(input, currentInit);
525
+ if (response.status === 401 && !authRefreshed && options?.onUnauthorized) {
526
+ const refreshed = await tryRefreshAuth(options.onUnauthorized);
527
+ if (refreshed) {
528
+ await response.body?.cancel().catch(() => {});
529
+ consola.warn("Got 401 from upstream; refreshed token and retrying");
530
+ currentInit = refreshed;
531
+ authRefreshed = true;
532
+ attempt--;
533
+ continue;
534
+ }
535
+ }
536
+ annotateRetryMeta(response, networkAttempts, authRefreshed);
537
+ return response;
538
+ } catch (error) {
539
+ if (attempt === RETRY_DELAYS_MS.length || !isRetryable(error)) {
540
+ annotateRetryMeta(error, networkAttempts, authRefreshed);
541
+ throw error;
542
+ }
543
+ const delay = RETRY_DELAYS_MS[attempt];
544
+ consola.warn(`Upstream network error (${getCauseCode(error)}); retry ${attempt + 1}/${RETRY_DELAYS_MS.length} in ${delay}ms`);
545
+ await new Promise((resolve) => setTimeout(resolve, delay));
546
+ }
547
+ throw new Error("fetchWithRetry exhausted attempts without resolution");
548
+ }
549
+ function annotateRetryMeta(target, attempts, authRefreshed) {
550
+ if (typeof target !== "object" || target === null) return;
551
+ try {
552
+ const meta = target;
553
+ meta[RETRY_ATTEMPTS_KEY] = attempts;
554
+ meta[RETRY_AUTH_REFRESHED_KEY] = authRefreshed;
555
+ } catch {}
556
+ }
557
+ async function tryRefreshAuth(onUnauthorized) {
558
+ try {
559
+ return await onUnauthorized();
560
+ } catch (error) {
561
+ consola.warn("onUnauthorized callback failed:", error instanceof Error ? error.message : error);
562
+ return null;
563
+ }
564
+ }
565
+ /**
566
+ * Build an onUnauthorized callback that refreshes the Copilot token and
567
+ * returns a new RequestInit with an updated Authorization header.
568
+ */
569
+ function makeCopilotAuthRetry(refreshToken, init) {
570
+ return async () => {
571
+ const newToken = await refreshToken();
572
+ if (!newToken) return null;
573
+ const headers = new Headers(init.headers);
574
+ headers.set("Authorization", `Bearer ${newToken}`);
575
+ return {
576
+ ...init,
577
+ headers
578
+ };
579
+ };
580
+ }
581
+
582
+ //#endregion
583
+ //#region src/services/copilot/copilot-fetch.ts
584
+ /**
585
+ * Single transport seam for all upstream Copilot API calls.
586
+ *
587
+ * Wraps every request with:
588
+ * - Network-error retry (UND_ERR_SOCKET, ECONNRESET, ENOTFOUND, connect timeouts)
589
+ * - 401 → forceRefreshCopilotToken → retry once with the new bearer token
590
+ *
591
+ * Callers supply the path (e.g. "/chat/completions") and the usual RequestInit.
592
+ * The host prefix and resilience wiring are applied here so every endpoint
593
+ * gets the same treatment and adding a new endpoint is one call site.
594
+ */
595
+ function copilotFetch(path, init) {
596
+ return fetchWithRetry(`${copilotBaseUrl(state)}${path}`, init, { onUnauthorized: makeCopilotAuthRetry(forceRefreshCopilotToken, init) });
597
+ }
598
+
481
599
  //#endregion
482
600
  //#region src/services/copilot/get-models.ts
483
601
  const getModels = async () => {
484
- const response = await fetch(`${copilotBaseUrl(state)}/models`, { headers: copilotHeaders(state) });
602
+ const response = await copilotFetch("/models", { headers: copilotHeaders(state) });
485
603
  if (!response.ok) throw await HTTPError.fromResponse("Failed to get models", response);
486
604
  return await response.json();
487
605
  };
@@ -586,6 +704,23 @@ async function refreshCopilotTokenWithRetry(maxRetries = 3) {
586
704
  return null;
587
705
  }
588
706
  /**
707
+ * Force-refresh the Copilot token on demand (e.g. after a 401 response).
708
+ * Updates state.copilotToken on success and returns the new token, or null
709
+ * if refresh failed. Coalesces concurrent callers so multiple in-flight 401s
710
+ * only trigger one refresh.
711
+ */
712
+ let refreshInFlight = null;
713
+ async function forceRefreshCopilotToken() {
714
+ if (refreshInFlight) return refreshInFlight;
715
+ refreshInFlight = refreshCopilotTokenWithRetry().then((token) => {
716
+ if (token) state.copilotToken = token;
717
+ return token;
718
+ }).finally(() => {
719
+ refreshInFlight = null;
720
+ });
721
+ return refreshInFlight;
722
+ }
723
+ /**
589
724
  * Clear any existing token refresh timer.
590
725
  * Call this before setting up a new timer or during cleanup.
591
726
  */
@@ -1213,7 +1348,7 @@ const patchClaude = defineCommand({
1213
1348
 
1214
1349
  //#endregion
1215
1350
  //#region package.json
1216
- var version = "0.7.8";
1351
+ var version = "0.7.10";
1217
1352
 
1218
1353
  //#endregion
1219
1354
  //#region src/lib/adaptive-rate-limiter.ts
@@ -3280,17 +3415,16 @@ const createChatCompletions = async (payload, options) => {
3280
3415
  const enableVision = wire.messages.some((x) => typeof x.content !== "string" && x.content?.some((x) => x.type === "image_url"));
3281
3416
  const isAgentCall = wire.messages.some((msg) => ["assistant", "tool"].includes(msg.role));
3282
3417
  const modelSupportsVision = options?.resolvedModel?.capabilities?.supports?.vision !== false;
3283
- const headers = {
3284
- ...copilotHeaders(state, {
3285
- vision: enableVision && modelSupportsVision,
3286
- modelRequestHeaders: options?.resolvedModel?.request_headers,
3287
- intent: isAgentCall ? "conversation-agent" : "conversation-panel"
3288
- }),
3289
- "X-Initiator": options?.initiator ?? (isAgentCall ? "agent" : "user")
3290
- };
3291
- const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, {
3418
+ const response = await copilotFetch("/chat/completions", {
3292
3419
  method: "POST",
3293
- headers,
3420
+ headers: {
3421
+ ...copilotHeaders(state, {
3422
+ vision: enableVision && modelSupportsVision,
3423
+ modelRequestHeaders: options?.resolvedModel?.request_headers,
3424
+ intent: isAgentCall ? "conversation-agent" : "conversation-panel"
3425
+ }),
3426
+ "X-Initiator": options?.initiator ?? (isAgentCall ? "agent" : "user")
3427
+ },
3294
3428
  body: JSON.stringify(wire)
3295
3429
  });
3296
3430
  if (!response.ok) {
@@ -3723,6 +3857,7 @@ function recordErrorResponse(ctx, model, error, endpoint, stream) {
3723
3857
  }, Date.now() - ctx.startTime);
3724
3858
  if (endpoint !== void 0) {
3725
3859
  const metrics = extractErrorMetrics(error);
3860
+ const { attempts } = getRetryAttempts(error);
3726
3861
  captureRequest({
3727
3862
  model,
3728
3863
  inputTokens: 0,
@@ -3734,7 +3869,7 @@ function recordErrorResponse(ctx, model, error, endpoint, stream) {
3734
3869
  ...metrics,
3735
3870
  errorPhase: stream ? "pre_stream" : "non_stream",
3736
3871
  endpoint,
3737
- attempt: 1
3872
+ attempt: attempts
3738
3873
  });
3739
3874
  }
3740
3875
  }
@@ -4120,7 +4255,24 @@ async function handleStreamingResponse$1(opts) {
4120
4255
  endpoint: "chat_completions"
4121
4256
  });
4122
4257
  failTracking(ctx.trackingId, error);
4123
- throw error;
4258
+ try {
4259
+ const markerChunk = {
4260
+ id: `error-marker-${Date.now()}`,
4261
+ object: "chat.completion.chunk",
4262
+ created: Math.floor(Date.now() / 1e3),
4263
+ model: acc.model || payload.model,
4264
+ choices: [{
4265
+ index: 0,
4266
+ delta: { content: `\n\n[copilot-api: upstream stream terminated. Please retry.]` },
4267
+ finish_reason: "length",
4268
+ logprobs: null
4269
+ }]
4270
+ };
4271
+ await stream.writeSSE({
4272
+ data: JSON.stringify(markerChunk),
4273
+ event: "message"
4274
+ });
4275
+ } catch {}
4124
4276
  }
4125
4277
  }
4126
4278
  function parseStreamChunk(chunk, acc, checkRepetition) {
@@ -4223,7 +4375,7 @@ completionRoutes.post("/", async (c) => {
4223
4375
  //#region src/services/copilot/create-embeddings.ts
4224
4376
  const createEmbeddings = async (payload) => {
4225
4377
  if (!state.copilotToken) throw new Error("Copilot token not found");
4226
- const response = await fetch(`${copilotBaseUrl(state)}/embeddings`, {
4378
+ const response = await copilotFetch("/embeddings", {
4227
4379
  method: "POST",
4228
4380
  headers: copilotHeaders(state),
4229
4381
  body: JSON.stringify(payload)
@@ -4665,6 +4817,16 @@ async function handleGeminiGenerate(c, model, isStream) {
4665
4817
  endpoint: "chat_completions"
4666
4818
  });
4667
4819
  failTracking(ctx.trackingId, error);
4820
+ try {
4821
+ await s.write(`data: ${JSON.stringify({ candidates: [{
4822
+ content: {
4823
+ role: "model",
4824
+ parts: [{ text: `\n\n[copilot-api: upstream stream terminated. Please retry.]` }]
4825
+ },
4826
+ finishReason: "OTHER",
4827
+ index: 0
4828
+ }] })}\n\n`);
4829
+ } catch {}
4668
4830
  }
4669
4831
  });
4670
4832
  } catch (error) {
@@ -4713,10 +4875,10 @@ function handleNonStreamResponse(c, response, model, ctx, payload) {
4713
4875
  * the Copilot model list, so if Copilot adds native support the request
4714
4876
  * goes through unchanged.
4715
4877
  */
4716
- const GEMINI_FORCED_ALIASES = { "gemini-2.5-pro": "gemini-3.1-pro-preview" };
4878
+ const GEMINI_FORCED_ALIASES = { "gemini-3.1-pro-preview-customtools": "gemini-3.1-pro-preview" };
4717
4879
  const GEMINI_CONDITIONAL_ALIASES = {
4718
- "gemini-2.5-flash-lite": "gemini-3-flash-preview",
4719
- "gemini-2.5-flash": "gemini-3-flash-preview"
4880
+ "gemini-2.5-flash-lite": "gemini-3.5-flash",
4881
+ "gemini-2.5-flash": "gemini-3.5-flash"
4720
4882
  };
4721
4883
  function resolveGeminiModelAlias(model) {
4722
4884
  if (model in GEMINI_FORCED_ALIASES) return GEMINI_FORCED_ALIASES[model];
@@ -6912,7 +7074,7 @@ async function createAnthropicMessages(payload, options) {
6912
7074
  }
6913
7075
  }
6914
7076
  consola.debug("Sending direct Anthropic request to Copilot /v1/messages");
6915
- const response = await fetch(`${copilotBaseUrl(state)}/v1/messages`, {
7077
+ const response = await copilotFetch("/v1/messages", {
6916
7078
  method: "POST",
6917
7079
  headers,
6918
7080
  body: JSON.stringify(filteredPayload)
@@ -8191,7 +8353,7 @@ const createResponses = async (payload, { vision, initiator, resolvedModel }) =>
8191
8353
  "X-Initiator": initiator
8192
8354
  };
8193
8355
  payload.service_tier = null;
8194
- const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
8356
+ const response = await copilotFetch("/responses", {
8195
8357
  method: "POST",
8196
8358
  headers,
8197
8359
  body: JSON.stringify(payload)
@@ -8474,6 +8636,7 @@ const handleResponses = async (c) => {
8474
8636
  const idTracker = createStreamIdTracker();
8475
8637
  let finalResult;
8476
8638
  let streamErrorMessage;
8639
+ let lastSequenceNumber = -1;
8477
8640
  try {
8478
8641
  for await (const chunk of response) {
8479
8642
  consola.debug("Responses stream chunk:", JSON.stringify(chunk));
@@ -8484,6 +8647,10 @@ const handleResponses = async (c) => {
8484
8647
  if ("response" in parsed) finalResult = parsed.response;
8485
8648
  else if (eventType === "error" && "message" in parsed) streamErrorMessage = parsed.message;
8486
8649
  } catch {}
8650
+ try {
8651
+ const parsed = JSON.parse(rawData);
8652
+ if (typeof parsed.sequence_number === "number") lastSequenceNumber = parsed.sequence_number;
8653
+ } catch {}
8487
8654
  const processedData = fixStreamIds(rawData, eventType, idTracker);
8488
8655
  await stream.writeSSE({
8489
8656
  id: chunk.id,
@@ -8522,7 +8689,18 @@ const handleResponses = async (c) => {
8522
8689
  endpoint: "responses"
8523
8690
  });
8524
8691
  failTracking(trackingId, error);
8525
- throw error;
8692
+ try {
8693
+ await stream.writeSSE({
8694
+ event: "error",
8695
+ data: JSON.stringify({
8696
+ type: "error",
8697
+ code: "upstream_stream_terminated",
8698
+ message: "Upstream stream terminated mid-response. Please retry.",
8699
+ param: null,
8700
+ sequence_number: lastSequenceNumber + 1
8701
+ })
8702
+ });
8703
+ } catch {}
8526
8704
  }
8527
8705
  });
8528
8706
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dianshuv/copilot-api",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
5
5
  "author": "dianshuv",
6
6
  "type": "module",