@dianshuv/copilot-api 0.7.9 → 0.8.0

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 +386 -32
  2. package/package.json +1 -1
package/dist/main.mjs CHANGED
@@ -243,7 +243,7 @@ function compressToolResultContent(content) {
243
243
  return `${start}\n\n[... ${(content.length - COMPRESSED_SUMMARY_LENGTH).toLocaleString()} characters omitted for brevity ...]\n\n${end}`;
244
244
  }
245
245
  function calculateLimits(model, config, defaultContextWindow) {
246
- const rawTokenLimit = getEffectiveTokenLimit(model.id) ?? model.capabilities?.limits?.max_context_window_tokens ?? model.capabilities?.limits?.max_prompt_tokens ?? defaultContextWindow;
246
+ const rawTokenLimit = getEffectiveTokenLimit(config.tokenLimitCacheKeyOverride ?? model.id) ?? config.contextWindowOverride ?? model.capabilities?.limits?.max_context_window_tokens ?? model.capabilities?.limits?.max_prompt_tokens ?? defaultContextWindow;
247
247
  return {
248
248
  tokenLimit: Math.floor(rawTokenLimit * (1 - config.safetyMarginPercent / 100)),
249
249
  byteLimit: getEffectiveByteLimitBytes()
@@ -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.9";
1351
+ var version = "0.8.0";
1217
1352
 
1218
1353
  //#endregion
1219
1354
  //#region src/lib/adaptive-rate-limiter.ts
@@ -3260,9 +3395,66 @@ const getTokenCount = async (payload, model) => {
3260
3395
  };
3261
3396
  };
3262
3397
 
3398
+ //#endregion
3399
+ //#region src/lib/anthropic/beta.ts
3400
+ /**
3401
+ * Vendor-neutral utilities for manipulating the `anthropic-beta` request header.
3402
+ *
3403
+ * Lives in `lib/anthropic/` (not in either transport module) so both the
3404
+ * Anthropic-native and OpenAI-translated transport layers can share these
3405
+ * helpers without introducing cross-transport imports.
3406
+ */
3407
+ /** Anthropic beta feature that unlocks the 1M context window. */
3408
+ const CONTEXT_1M_BETA_FEATURE = "context-1m-2025-08-07";
3409
+ /**
3410
+ * Merge two comma-separated anthropic-beta header values. Trims whitespace,
3411
+ * drops empty tokens, and dedupes by exact string match. Returns a canonical
3412
+ * comma-joined string with no spaces.
3413
+ *
3414
+ * Either input may be undefined / empty.
3415
+ */
3416
+ function mergeBetaFeatures(existing, incoming) {
3417
+ const seen = /* @__PURE__ */ new Set();
3418
+ const out = [];
3419
+ for (const raw of [existing, incoming]) {
3420
+ if (!raw) continue;
3421
+ for (const part of raw.split(",")) {
3422
+ const f = part.trim();
3423
+ if (f.length === 0 || seen.has(f)) continue;
3424
+ seen.add(f);
3425
+ out.push(f);
3426
+ }
3427
+ }
3428
+ return out.join(",");
3429
+ }
3430
+ /**
3431
+ * Append the context-1m feature to an anthropic-beta header value, deduping
3432
+ * any prior occurrence. Returns the merged comma-separated string.
3433
+ */
3434
+ function appendContext1mBeta(existing) {
3435
+ return mergeBetaFeatures(existing, CONTEXT_1M_BETA_FEATURE);
3436
+ }
3437
+ /**
3438
+ * True iff a model id appears to be the suffixed 1M-context variant of an
3439
+ * Anthropic Claude model (e.g. claude-opus-4-8-1m, claude-opus-4.6-1m).
3440
+ *
3441
+ * Used as a state.models-independent signal for whether to inject the
3442
+ * context-1m-2025-08-07 beta header, so the 1M intent survives a stale or
3443
+ * empty model cache (where `resolveAnthropicModelForDirectPath` would return
3444
+ * undefined). Forwarding the beta is harmless to upstreams that ignore it.
3445
+ */
3446
+ function isOneMillionSuffixedClaudeId(modelId) {
3447
+ return modelId.startsWith("claude-") && modelId.endsWith("-1m");
3448
+ }
3449
+
3263
3450
  //#endregion
3264
3451
  //#region src/services/copilot/create-chat-completions.ts
3265
3452
  const GPT_MODEL_PATTERN = /^gpt-/i;
3453
+ /** Case-insensitive lookup of a header key in a plain-object header bag. */
3454
+ function findHeaderKey(headers, name) {
3455
+ const lower = name.toLowerCase();
3456
+ return Object.keys(headers).find((k) => k.toLowerCase() === lower);
3457
+ }
3266
3458
  const createChatCompletions = async (payload, options) => {
3267
3459
  if (!state.copilotToken) throw new Error("Copilot token not found");
3268
3460
  const vendor = options?.resolvedModel?.vendor;
@@ -3288,14 +3480,19 @@ const createChatCompletions = async (payload, options) => {
3288
3480
  }),
3289
3481
  "X-Initiator": options?.initiator ?? (isAgentCall ? "agent" : "user")
3290
3482
  };
3291
- const response = await fetch(`${copilotBaseUrl(state)}/chat/completions`, {
3483
+ if (options?.anthropicBeta) {
3484
+ const existingKey = findHeaderKey(headers, "anthropic-beta") ?? "anthropic-beta";
3485
+ headers[existingKey] = mergeBetaFeatures(headers[existingKey], options.anthropicBeta);
3486
+ consola.debug(`[ChatCompletions] anthropic-beta after merge: ${headers[existingKey]}`);
3487
+ }
3488
+ const response = await copilotFetch("/chat/completions", {
3292
3489
  method: "POST",
3293
3490
  headers,
3294
3491
  body: JSON.stringify(wire)
3295
3492
  });
3296
3493
  if (!response.ok) {
3297
3494
  consola.error("Failed to create chat completions", response);
3298
- throw await HTTPError.fromResponse("Failed to create chat completions", response, payload.model);
3495
+ throw await HTTPError.fromResponse("Failed to create chat completions", response, options?.errorModelIdOverride ?? payload.model);
3299
3496
  }
3300
3497
  if (payload.stream) return events(response);
3301
3498
  return await response.json();
@@ -3723,6 +3920,7 @@ function recordErrorResponse(ctx, model, error, endpoint, stream) {
3723
3920
  }, Date.now() - ctx.startTime);
3724
3921
  if (endpoint !== void 0) {
3725
3922
  const metrics = extractErrorMetrics(error);
3923
+ const { attempts } = getRetryAttempts(error);
3726
3924
  captureRequest({
3727
3925
  model,
3728
3926
  inputTokens: 0,
@@ -3734,7 +3932,7 @@ function recordErrorResponse(ctx, model, error, endpoint, stream) {
3734
3932
  ...metrics,
3735
3933
  errorPhase: stream ? "pre_stream" : "non_stream",
3736
3934
  endpoint,
3737
- attempt: 1
3935
+ attempt: attempts
3738
3936
  });
3739
3937
  }
3740
3938
  }
@@ -3829,7 +4027,7 @@ function isNonStreaming(response) {
3829
4027
  return Object.hasOwn(response, "choices");
3830
4028
  }
3831
4029
  /** Build final payload with auto-truncate if needed */
3832
- async function buildFinalPayload(payload, model) {
4030
+ async function buildFinalPayload(payload, model, autoTruncateConfig = {}) {
3833
4031
  if (!state.autoTruncate || !model) {
3834
4032
  if (state.autoTruncate && !model) consola.warn(`Auto-truncate: Model '${payload.model}' not found in cached models, skipping`);
3835
4033
  return {
@@ -3838,7 +4036,7 @@ async function buildFinalPayload(payload, model) {
3838
4036
  };
3839
4037
  }
3840
4038
  try {
3841
- const check = await checkNeedsCompactionOpenAI(payload, model);
4039
+ const check = await checkNeedsCompactionOpenAI(payload, model, autoTruncateConfig);
3842
4040
  consola.debug(`Auto-truncate check: ${check.currentTokens} tokens (limit ${check.tokenLimit}), ${Math.round(check.currentBytes / 1024)}KB (limit ${check.byteLimit === Infinity ? "unlimited" : `${Math.round(check.byteLimit / 1024)}KB`}), needed: ${check.needed}${check.reason ? ` (${check.reason})` : ""}`);
3843
4041
  if (!check.needed) return {
3844
4042
  finalPayload: payload,
@@ -3849,7 +4047,7 @@ async function buildFinalPayload(payload, model) {
3849
4047
  else if (check.reason === "bytes") reasonText = "size";
3850
4048
  else reasonText = "tokens";
3851
4049
  consola.info(`Auto-truncate triggered: exceeds ${reasonText} limit`);
3852
- const truncateResult = await autoTruncateOpenAI(payload, model);
4050
+ const truncateResult = await autoTruncateOpenAI(payload, model, autoTruncateConfig);
3853
4051
  return {
3854
4052
  finalPayload: truncateResult.payload,
3855
4053
  truncateResult
@@ -4120,7 +4318,24 @@ async function handleStreamingResponse$1(opts) {
4120
4318
  endpoint: "chat_completions"
4121
4319
  });
4122
4320
  failTracking(ctx.trackingId, error);
4123
- throw error;
4321
+ try {
4322
+ const markerChunk = {
4323
+ id: `error-marker-${Date.now()}`,
4324
+ object: "chat.completion.chunk",
4325
+ created: Math.floor(Date.now() / 1e3),
4326
+ model: acc.model || payload.model,
4327
+ choices: [{
4328
+ index: 0,
4329
+ delta: { content: `\n\n[copilot-api: upstream stream terminated. Please retry.]` },
4330
+ finish_reason: "length",
4331
+ logprobs: null
4332
+ }]
4333
+ };
4334
+ await stream.writeSSE({
4335
+ data: JSON.stringify(markerChunk),
4336
+ event: "message"
4337
+ });
4338
+ } catch {}
4124
4339
  }
4125
4340
  }
4126
4341
  function parseStreamChunk(chunk, acc, checkRepetition) {
@@ -4223,7 +4438,7 @@ completionRoutes.post("/", async (c) => {
4223
4438
  //#region src/services/copilot/create-embeddings.ts
4224
4439
  const createEmbeddings = async (payload) => {
4225
4440
  if (!state.copilotToken) throw new Error("Copilot token not found");
4226
- const response = await fetch(`${copilotBaseUrl(state)}/embeddings`, {
4441
+ const response = await copilotFetch("/embeddings", {
4227
4442
  method: "POST",
4228
4443
  headers: copilotHeaders(state),
4229
4444
  body: JSON.stringify(payload)
@@ -4665,6 +4880,16 @@ async function handleGeminiGenerate(c, model, isStream) {
4665
4880
  endpoint: "chat_completions"
4666
4881
  });
4667
4882
  failTracking(ctx.trackingId, error);
4883
+ try {
4884
+ await s.write(`data: ${JSON.stringify({ candidates: [{
4885
+ content: {
4886
+ role: "model",
4887
+ parts: [{ text: `\n\n[copilot-api: upstream stream terminated. Please retry.]` }]
4888
+ },
4889
+ finishReason: "OTHER",
4890
+ index: 0
4891
+ }] })}\n\n`);
4892
+ } catch {}
4668
4893
  }
4669
4894
  });
4670
4895
  } catch (error) {
@@ -6677,7 +6902,7 @@ function modelSupportsContextEditing(modelId) {
6677
6902
  }
6678
6903
  function modelSupportsToolSearch(modelId) {
6679
6904
  const n = normalizeForMatching(modelId);
6680
- return n.includes("claude") && (n.includes("opus45") || n.includes("opus46") || n.includes("sonnet45") || n.includes("sonnet46"));
6905
+ return n.includes("claude") && (n.includes("opus45") || n.includes("opus46") || n.includes("opus47") || n.includes("opus48") || n.includes("sonnet45") || n.includes("sonnet46"));
6681
6906
  }
6682
6907
  function isContextEditingEnabled(modelId) {
6683
6908
  return modelSupportsContextEditing(modelId) && state.contextEditingMode !== "off";
@@ -6903,6 +7128,7 @@ async function createAnthropicMessages(payload, options) {
6903
7128
  };
6904
7129
  const betaHeaders = buildAnthropicBetaHeaders(filteredPayload.model, resolvedModel);
6905
7130
  Object.assign(headers, betaHeaders);
7131
+ if (options?.injectContext1mBeta) headers["anthropic-beta"] = appendContext1mBeta(headers["anthropic-beta"]);
6906
7132
  if (isContextEditingEnabled(filteredPayload.model)) {
6907
7133
  const hasThinking = filteredPayload.thinking?.type === "enabled";
6908
7134
  const cm = buildContextManagement(state.contextEditingMode, hasThinking);
@@ -6912,7 +7138,7 @@ async function createAnthropicMessages(payload, options) {
6912
7138
  }
6913
7139
  }
6914
7140
  consola.debug("Sending direct Anthropic request to Copilot /v1/messages");
6915
- const response = await fetch(`${copilotBaseUrl(state)}/v1/messages`, {
7141
+ const response = await copilotFetch("/v1/messages", {
6916
7142
  method: "POST",
6917
7143
  headers,
6918
7144
  body: JSON.stringify(filteredPayload)
@@ -6929,7 +7155,7 @@ async function createAnthropicMessages(payload, options) {
6929
7155
  thinking: filteredPayload.thinking,
6930
7156
  messageCount: filteredPayload.messages.length
6931
7157
  });
6932
- throw await HTTPError.fromResponse("Failed to create Anthropic messages", response, filteredPayload.model);
7158
+ throw await HTTPError.fromResponse("Failed to create Anthropic messages", response, options?.errorModelIdOverride ?? filteredPayload.model);
6933
7159
  }
6934
7160
  if (payload.stream) return events(response);
6935
7161
  return await response.json();
@@ -6952,13 +7178,58 @@ function stripServerToolsFromPayload(tools) {
6952
7178
  }
6953
7179
  return result.length > 0 ? result : void 0;
6954
7180
  }
7181
+ /** Context window unlocked by the context-1m-2025-08-07 beta header. */
7182
+ const ONE_MILLION_CONTEXT_WINDOW_TOKENS = 1e6;
7183
+ /**
7184
+ * Convert a Claude model id from the client-facing dash convention to the
7185
+ * upstream Copilot dot convention. The two conventions co-exist because
7186
+ * Anthropic-style clients use dashes ("claude-opus-4-8") and Copilot lists
7187
+ * the same model with dots ("claude-opus-4.8"). Only the first dash inside
7188
+ * the version segment is converted (a model id like "claude-opus-4.8-1m"
7189
+ * already in dot form is returned unchanged).
7190
+ */
7191
+ function dashToDotClaudeId(modelId) {
7192
+ if (!modelId.startsWith("claude-")) return modelId;
7193
+ return modelId.replace(/^(claude-[a-z]+-)(\d+)-(\d+)/, "$1$2.$3");
7194
+ }
7195
+ function resolveAnthropicModelForDirectPath(modelId) {
7196
+ const exact = findModelById(modelId);
7197
+ if (exact?.vendor === "Anthropic") return {
7198
+ model: exact,
7199
+ baseModelId: modelId,
7200
+ oneMillionFallback: false,
7201
+ effectiveContextWindowTokens: exact.capabilities?.limits?.max_context_window_tokens ?? 2e5
7202
+ };
7203
+ const dotted = dashToDotClaudeId(modelId);
7204
+ if (dotted !== modelId) {
7205
+ const dottedExact = findModelById(dotted);
7206
+ if (dottedExact?.vendor === "Anthropic") return {
7207
+ model: dottedExact,
7208
+ baseModelId: dotted,
7209
+ oneMillionFallback: false,
7210
+ effectiveContextWindowTokens: dottedExact.capabilities?.limits?.max_context_window_tokens ?? 2e5
7211
+ };
7212
+ }
7213
+ if (modelId.endsWith("-1m")) {
7214
+ const baseId = modelId.slice(0, -3);
7215
+ for (const candidateId of [baseId, dashToDotClaudeId(baseId)]) {
7216
+ const base = findModelById(candidateId);
7217
+ if (base?.vendor === "Anthropic") return {
7218
+ model: base,
7219
+ baseModelId: candidateId,
7220
+ oneMillionFallback: true,
7221
+ effectiveContextWindowTokens: ONE_MILLION_CONTEXT_WINDOW_TOKENS
7222
+ };
7223
+ }
7224
+ }
7225
+ }
6955
7226
  /**
6956
7227
  * Check if a model supports direct Anthropic API.
6957
7228
  * Returns true if redirect is disabled (direct API is on) and the model is from Anthropic vendor.
6958
7229
  */
6959
7230
  function supportsDirectAnthropicApi(modelId) {
6960
7231
  if (state.redirectAnthropic) return false;
6961
- return findModelById(modelId)?.vendor === "Anthropic";
7232
+ return resolveAnthropicModelForDirectPath(modelId) !== void 0;
6962
7233
  }
6963
7234
 
6964
7235
  //#endregion
@@ -7197,18 +7468,42 @@ function findLatestModel(familyPrefix, fallback) {
7197
7468
  const candidates = models.filter((m) => m.id.startsWith(familyPrefix));
7198
7469
  if (candidates.length === 0) return fallback;
7199
7470
  candidates.sort((a, b) => {
7200
- const versionA = extractVersion(a.id, familyPrefix);
7201
- return extractVersion(b.id, familyPrefix) - versionA;
7471
+ const [aMajor, aMinor] = extractVersion(a.id, familyPrefix);
7472
+ const [bMajor, bMinor] = extractVersion(b.id, familyPrefix);
7473
+ if (aMajor !== bMajor) return bMajor - aMajor;
7474
+ return bMinor - aMinor;
7202
7475
  });
7203
7476
  return candidates[0].id;
7204
7477
  }
7205
7478
  /**
7206
- * Extract numeric version from model ID.
7207
- * e.g., "claude-opus-4.5" with prefix "claude-opus" -> 4.5
7479
+ * Extract numeric [major, minor] version from a model id.
7480
+ *
7481
+ * Supports both naming conventions Anthropic/Copilot have used:
7482
+ * - dot: "claude-opus-4.5" → [4, 5]
7483
+ * - dash: "claude-opus-4-8" → [4, 8]
7484
+ * - dash double-digit: "claude-opus-4-10" → [4, 10]
7485
+ *
7486
+ * The dash form previously parsed as just the major via the regex
7487
+ * /^(\d+(?:\.\d+)?)/ (the dash stopped the match), which silently
7488
+ * downgraded dash-named candidates against any dot-named candidate in
7489
+ * findLatestModel. Parsing into a tuple also avoids the parseFloat
7490
+ * lossiness on double-digit minors ("4.10" → 4.1).
7491
+ *
7492
+ * Anything after the major/minor segment (date stamps, "-1m") is ignored.
7493
+ * The minor capture is bounded to 1-3 digits so that an 8-digit date suffix
7494
+ * directly after the major (e.g. "claude-opus-4-20250514") is NOT mistaken
7495
+ * for a minor version of 20_250_514 — without that bound, dated ids would
7496
+ * outrank legitimate dotted candidates like "claude-opus-4.8" in
7497
+ * findLatestModel's sort.
7498
+ *
7499
+ * Returns [0, 0] when no version can be extracted.
7208
7500
  */
7209
7501
  function extractVersion(modelId, prefix) {
7210
- const match = modelId.slice(prefix.length + 1).match(/^(\d+(?:\.\d+)?)/);
7211
- return match ? Number.parseFloat(match[1]) : 0;
7502
+ const match = modelId.slice(prefix.length + 1).match(/^(\d+)(?:[.-](\d{1,3}))?/);
7503
+ if (!match) return [0, 0];
7504
+ const major = Number.parseInt(match[1], 10);
7505
+ const rawMinor = match[2];
7506
+ return [major, rawMinor === void 0 ? 0 : Number.parseInt(rawMinor, 10) || 0];
7212
7507
  }
7213
7508
  function translateModelName(model) {
7214
7509
  const aliasMap = {
@@ -7222,6 +7517,8 @@ function translateModelName(model) {
7222
7517
  }
7223
7518
  if (/^claude-sonnet-4-5-\d+$/.test(model)) return "claude-sonnet-4.5";
7224
7519
  if (/^claude-sonnet-4-\d+$/.test(model)) return "claude-sonnet-4";
7520
+ if (model === "claude-opus-4-8-1m") return "claude-opus-4.8";
7521
+ if (model === "claude-opus-4-8") return "claude-opus-4.8";
7225
7522
  if (model === "claude-opus-4-7-1m") return "claude-opus-4.7-1m-internal";
7226
7523
  if (/^claude-opus-4-7$/.test(model)) return "claude-opus-4.7";
7227
7524
  if (model === "claude-opus-4-6-1m") return "claude-opus-4.6-1m";
@@ -7601,14 +7898,30 @@ function translateErrorToAnthropicErrorEvent(error) {
7601
7898
  */
7602
7899
  async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiatorOverride) {
7603
7900
  consola.debug("Using direct Anthropic API path for model:", anthropicPayload.model);
7604
- const selectedModel = findModelById(anthropicPayload.model);
7605
- let effectivePayload = anthropicPayload;
7901
+ const resolution = resolveAnthropicModelForDirectPath(anthropicPayload.model);
7902
+ const selectedModel = resolution?.model;
7903
+ const baseModelId = resolution?.baseModelId ?? anthropicPayload.model;
7904
+ const needsContext1mBeta = resolution?.oneMillionFallback ?? false;
7905
+ const originalModelId = anthropicPayload.model;
7906
+ const resolvedPayload = resolution && baseModelId !== originalModelId ? {
7907
+ ...anthropicPayload,
7908
+ model: baseModelId
7909
+ } : anthropicPayload;
7910
+ if (baseModelId !== originalModelId) {
7911
+ consola.debug(`[Anthropic] Mapping model for upstream: ${originalModelId} → ${baseModelId}${needsContext1mBeta ? " (+context-1m beta)" : ""}`);
7912
+ updateTrackerResolvedModel(ctx.trackingId, baseModelId);
7913
+ }
7914
+ const autoTruncateConfig = resolution && resolution.oneMillionFallback ? {
7915
+ contextWindowOverride: resolution.effectiveContextWindowTokens,
7916
+ tokenLimitCacheKeyOverride: originalModelId
7917
+ } : {};
7918
+ let effectivePayload = resolvedPayload;
7606
7919
  let truncateResult;
7607
7920
  if (state.autoTruncate && selectedModel) {
7608
- const check = await checkNeedsCompactionAnthropic(anthropicPayload, selectedModel);
7921
+ const check = await checkNeedsCompactionAnthropic(resolvedPayload, selectedModel, autoTruncateConfig);
7609
7922
  consola.debug(`[Anthropic] Auto-truncate check: ${check.currentTokens} tokens (limit ${check.tokenLimit}), ${Math.round(check.currentBytes / 1024)}KB (limit ${check.byteLimit === Infinity ? "unlimited" : `${Math.round(check.byteLimit / 1024)}KB`}), needed: ${check.needed}${check.reason ? ` (${check.reason})` : ""}`);
7610
7923
  if (check.needed) try {
7611
- truncateResult = await autoTruncateAnthropic(anthropicPayload, selectedModel);
7924
+ truncateResult = await autoTruncateAnthropic(resolvedPayload, selectedModel, autoTruncateConfig);
7612
7925
  if (truncateResult.wasCompacted) effectivePayload = truncateResult.payload;
7613
7926
  } catch (error) {
7614
7927
  consola.warn("[Anthropic] Auto-truncate failed, proceeding with original payload:", error instanceof Error ? error.message : error);
@@ -7616,7 +7929,11 @@ async function handleDirectAnthropicCompletion(c, anthropicPayload, ctx, initiat
7616
7929
  } else if (state.autoTruncate && !selectedModel) consola.debug(`[Anthropic] Model '${anthropicPayload.model}' not found, skipping auto-truncate`);
7617
7930
  if (state.manualApprove) await awaitApproval();
7618
7931
  try {
7619
- const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createAnthropicMessages(effectivePayload, { initiator: initiatorOverride }));
7932
+ const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createAnthropicMessages(effectivePayload, {
7933
+ initiator: initiatorOverride,
7934
+ injectContext1mBeta: needsContext1mBeta,
7935
+ errorModelIdOverride: needsContext1mBeta ? originalModelId : void 0
7936
+ }));
7620
7937
  ctx.queueWaitMs = queueWaitMs;
7621
7938
  if (Symbol.asyncIterator in response) {
7622
7939
  consola.debug("Streaming response from Copilot (direct Anthropic)");
@@ -7814,17 +8131,33 @@ const parseSubagentMarkerFromSystemReminder = (text) => {
7814
8131
  * Handle completion using OpenAI translation path (legacy)
7815
8132
  */
7816
8133
  async function handleTranslatedCompletion(c, anthropicPayload, ctx, initiatorOverride) {
8134
+ const originalModelId = anthropicPayload.model;
8135
+ const hasOneMillionSuffix = isOneMillionSuffixedClaudeId(originalModelId);
8136
+ const oneMResolution = resolveAnthropicModelForDirectPath(originalModelId);
8137
+ const needsContext1mBeta = hasOneMillionSuffix || (oneMResolution?.oneMillionFallback ?? false);
7817
8138
  const { payload: translatedPayload, toolNameMapping } = translateToOpenAI(anthropicPayload);
7818
8139
  consola.debug("Translated OpenAI request payload:", JSON.stringify(translatedPayload));
7819
8140
  updateTrackerResolvedModel(ctx.trackingId, translatedPayload.model);
7820
8141
  const selectedModel = findModelById(translatedPayload.model);
7821
- const { finalPayload: openAIPayload, truncateResult } = await buildFinalPayload(translatedPayload, selectedModel);
8142
+ const autoTruncateConfig = oneMResolution?.oneMillionFallback ? {
8143
+ contextWindowOverride: oneMResolution.effectiveContextWindowTokens,
8144
+ tokenLimitCacheKeyOverride: originalModelId
8145
+ } : {};
8146
+ const { finalPayload: openAIPayload, truncateResult } = await buildFinalPayload(translatedPayload, selectedModel, autoTruncateConfig);
7822
8147
  if (truncateResult) ctx.truncateResult = truncateResult;
8148
+ let anthropicBeta = c.req.header("anthropic-beta");
8149
+ if (needsContext1mBeta) anthropicBeta = appendContext1mBeta(anthropicBeta);
7823
8150
  if (state.manualApprove) await awaitApproval();
8151
+ let errorModelIdOverride;
8152
+ if (autoTruncateConfig.tokenLimitCacheKeyOverride !== void 0) errorModelIdOverride = autoTruncateConfig.tokenLimitCacheKeyOverride;
8153
+ else if (needsContext1mBeta && selectedModel) errorModelIdOverride = selectedModel.id;
8154
+ else if (hasOneMillionSuffix) errorModelIdOverride = originalModelId;
7824
8155
  try {
7825
8156
  const { result: response, queueWaitMs } = await executeWithAdaptiveRateLimit(() => createChatCompletions(openAIPayload, {
7826
8157
  initiator: initiatorOverride,
7827
- resolvedModel: selectedModel
8158
+ resolvedModel: selectedModel,
8159
+ anthropicBeta,
8160
+ errorModelIdOverride
7828
8161
  }));
7829
8162
  ctx.queueWaitMs = queueWaitMs;
7830
8163
  if (isNonStreaming(response)) return handleNonStreamingResponse({
@@ -8092,10 +8425,15 @@ async function handleCountTokens(c) {
8092
8425
  consola.warn("Model not found, returning default token count");
8093
8426
  return c.json({ input_tokens: 1 });
8094
8427
  }
8428
+ const directResolution = resolveAnthropicModelForDirectPath(anthropicPayload.model);
8429
+ const autoTruncateConfig = directResolution?.oneMillionFallback ? {
8430
+ contextWindowOverride: directResolution.effectiveContextWindowTokens,
8431
+ tokenLimitCacheKeyOverride: anthropicPayload.model
8432
+ } : {};
8095
8433
  if (state.autoTruncate) {
8096
- const truncateCheck = await checkNeedsCompactionAnthropic(anthropicPayload, selectedModel);
8434
+ const truncateCheck = await checkNeedsCompactionAnthropic(anthropicPayload, selectedModel, autoTruncateConfig);
8097
8435
  if (truncateCheck.needed) {
8098
- const contextWindow = selectedModel.capabilities?.limits?.max_context_window_tokens ?? 2e5;
8436
+ const contextWindow = autoTruncateConfig.contextWindowOverride ?? selectedModel.capabilities?.limits?.max_context_window_tokens ?? 2e5;
8099
8437
  const inflatedTokens = Math.floor(contextWindow * .95);
8100
8438
  consola.debug(`[count_tokens] Would trigger auto-truncate: ${truncateCheck.currentTokens} tokens > ${truncateCheck.tokenLimit}, returning inflated count: ${inflatedTokens}`);
8101
8439
  return c.json({ input_tokens: inflatedTokens });
@@ -8191,7 +8529,7 @@ const createResponses = async (payload, { vision, initiator, resolvedModel }) =>
8191
8529
  "X-Initiator": initiator
8192
8530
  };
8193
8531
  payload.service_tier = null;
8194
- const response = await fetch(`${copilotBaseUrl(state)}/responses`, {
8532
+ const response = await copilotFetch("/responses", {
8195
8533
  method: "POST",
8196
8534
  headers,
8197
8535
  body: JSON.stringify(payload)
@@ -8474,6 +8812,7 @@ const handleResponses = async (c) => {
8474
8812
  const idTracker = createStreamIdTracker();
8475
8813
  let finalResult;
8476
8814
  let streamErrorMessage;
8815
+ let lastSequenceNumber = -1;
8477
8816
  try {
8478
8817
  for await (const chunk of response) {
8479
8818
  consola.debug("Responses stream chunk:", JSON.stringify(chunk));
@@ -8484,6 +8823,10 @@ const handleResponses = async (c) => {
8484
8823
  if ("response" in parsed) finalResult = parsed.response;
8485
8824
  else if (eventType === "error" && "message" in parsed) streamErrorMessage = parsed.message;
8486
8825
  } catch {}
8826
+ try {
8827
+ const parsed = JSON.parse(rawData);
8828
+ if (typeof parsed.sequence_number === "number") lastSequenceNumber = parsed.sequence_number;
8829
+ } catch {}
8487
8830
  const processedData = fixStreamIds(rawData, eventType, idTracker);
8488
8831
  await stream.writeSSE({
8489
8832
  id: chunk.id,
@@ -8522,7 +8865,18 @@ const handleResponses = async (c) => {
8522
8865
  endpoint: "responses"
8523
8866
  });
8524
8867
  failTracking(trackingId, error);
8525
- throw error;
8868
+ try {
8869
+ await stream.writeSSE({
8870
+ event: "error",
8871
+ data: JSON.stringify({
8872
+ type: "error",
8873
+ code: "upstream_stream_terminated",
8874
+ message: "Upstream stream terminated mid-response. Please retry.",
8875
+ param: null,
8876
+ sequence_number: lastSequenceNumber + 1
8877
+ })
8878
+ });
8879
+ } catch {}
8526
8880
  }
8527
8881
  });
8528
8882
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dianshuv/copilot-api",
3
- "version": "0.7.9",
3
+ "version": "0.8.0",
4
4
  "description": "Turn GitHub Copilot into OpenAI/Anthropic API compatible server. Usable with Claude Code!",
5
5
  "author": "dianshuv",
6
6
  "type": "module",