opencode-copilot-account-switcher 0.10.6 → 0.10.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.
@@ -3,7 +3,6 @@ const RETRYABLE_MESSAGES = [
3
3
  "load failed",
4
4
  "failed to fetch",
5
5
  "network request failed",
6
- "sse read timed out",
7
6
  "unable to connect",
8
7
  "econnreset",
9
8
  "etimedout",
@@ -490,8 +489,9 @@ function stripOpenAIItemId(part) {
490
489
  };
491
490
  }
492
491
  function getInternalPatchClient(client) {
493
- const patch = client?._client?.patch;
494
- return typeof patch === "function" ? patch : undefined;
492
+ const internalClient = client?._client;
493
+ const patch = internalClient?.patch;
494
+ return typeof patch === "function" ? patch.bind(internalClient) : undefined;
495
495
  }
496
496
  function collectSessionRepairMatches(messages, predicate) {
497
497
  return (messages?.data ?? []).flatMap((message) => {
@@ -1077,21 +1077,25 @@ function withStreamDebugLogs(response, request) {
1077
1077
  }
1078
1078
  catch (error) {
1079
1079
  const message = getErrorMessage(error);
1080
+ const isSseReadTimeout = message.includes("sse read timed out");
1080
1081
  const retryable = RETRYABLE_MESSAGES.some((part) => message.includes(part));
1081
1082
  if (isDebugEnabled()) {
1082
1083
  debugLog("sse stream read error", {
1083
1084
  url: rawUrl,
1084
1085
  message,
1085
1086
  retryableByMessage: retryable,
1087
+ bypassedTimeoutWrap: isSseReadTimeout,
1086
1088
  });
1087
1089
  }
1088
- controller.error(retryable
1089
- ? toRetryableApiCallError(error, request, {
1090
- group: "stream",
1091
- statusCode: response.status,
1092
- responseHeaders: response.headers,
1093
- })
1094
- : error);
1090
+ controller.error(isSseReadTimeout
1091
+ ? error
1092
+ : retryable
1093
+ ? toRetryableApiCallError(error, request, {
1094
+ group: "stream",
1095
+ statusCode: response.status,
1096
+ responseHeaders: response.headers,
1097
+ })
1098
+ : error);
1095
1099
  }
1096
1100
  };
1097
1101
  void pump();
@@ -55,7 +55,18 @@ export declare function buildPluginHooks(input: {
55
55
  loadOfficialConfig?: (input: {
56
56
  getAuth: () => Promise<CopilotAuthState | undefined>;
57
57
  provider?: CopilotProviderConfig;
58
+ baseFetch?: typeof fetch;
59
+ version?: string;
58
60
  }) => Promise<OfficialCopilotConfig | undefined>;
61
+ finalizeRequestForSelection?: (input: {
62
+ request: Request | URL | string;
63
+ init?: RequestInit;
64
+ getAuth: () => Promise<CopilotAuthState | undefined>;
65
+ provider?: CopilotProviderConfig;
66
+ }) => Promise<{
67
+ request: Request | URL | string;
68
+ init?: RequestInit;
69
+ } | undefined>;
59
70
  loadOfficialChatHeaders?: (input: {
60
71
  client?: object;
61
72
  directory?: string;
@@ -19,6 +19,7 @@ const RATE_LIMIT_COOLDOWN_MS = 10 * 60 * 1000;
19
19
  const MAX_SESSION_BINDINGS = 256;
20
20
  const TOUCH_WRITE_CACHE_IDLE_TTL_MS = 30 * 60 * 1000;
21
21
  const MAX_TOUCH_WRITE_CACHE_ENTRIES = 2048;
22
+ const INTERNAL_DEBUG_LINK_HEADER = "x-opencode-debug-link-id";
22
23
  export class InjectCommandHandledError extends Error {
23
24
  constructor() {
24
25
  super("copilot-inject-handled");
@@ -186,6 +187,7 @@ function getInternalSessionID(request, init) {
186
187
  function stripInternalSessionHeader(request, init) {
187
188
  return mergeAndRewriteRequestHeaders(request, init, (headers) => {
188
189
  headers.delete("x-opencode-session-id");
190
+ headers.delete(INTERNAL_DEBUG_LINK_HEADER);
189
191
  });
190
192
  }
191
193
  function toLoadMap(value) {
@@ -223,6 +225,8 @@ function toErrorMessage(error) {
223
225
  function toConsumptionReasonText(reason) {
224
226
  if (reason === "subagent")
225
227
  return "子代理请求";
228
+ if (reason === "compaction")
229
+ return "上下文压缩";
226
230
  if (reason === "user-reselect")
227
231
  return "用户回合重选";
228
232
  return "常规请求";
@@ -239,6 +243,13 @@ function buildConsumptionToast(input) {
239
243
  variant: "info",
240
244
  };
241
245
  }
246
+ function shouldShowConsumptionToast(input) {
247
+ if (input.reason === "subagent")
248
+ return input.isFirstUse;
249
+ if (input.reason === "compaction")
250
+ return false;
251
+ return true;
252
+ }
242
253
  function chooseCandidateAccount(input) {
243
254
  const lowest = pickLowestWithRandom(input.candidates, input.loads, input.random);
244
255
  const boundName = input.sessionBindings.get(input.sessionID)?.accountName;
@@ -400,6 +411,48 @@ export function buildPluginHooks(input) {
400
411
  : undefined,
401
412
  }];
402
413
  };
414
+ const classifyRequestReason = async (requestInput) => {
415
+ const initiator = getMergedRequestHeader(requestInput.request, requestInput.init, "x-initiator");
416
+ if (initiator !== "agent") {
417
+ return {
418
+ reason: toReasonByInitiator(initiator),
419
+ };
420
+ }
421
+ const sessionLookup = input.client?.session?.get;
422
+ const messageLookup = input.client?.session?.message;
423
+ const messageIDHeader = getMergedRequestHeader(requestInput.request, requestInput.init, INTERNAL_DEBUG_LINK_HEADER);
424
+ if (typeof messageIDHeader === "string" && messageIDHeader.length > 0) {
425
+ const currentMessage = await messageLookup?.({
426
+ path: {
427
+ id: requestInput.sessionID,
428
+ messageID: messageIDHeader,
429
+ },
430
+ query: {
431
+ directory: input.directory,
432
+ },
433
+ throwOnError: true,
434
+ }).catch(() => undefined);
435
+ const parts = Array.isArray(currentMessage?.data?.parts) ? currentMessage.data.parts : undefined;
436
+ if (parts?.some((part) => part?.type === "compaction") === true) {
437
+ return {
438
+ reason: "compaction",
439
+ };
440
+ }
441
+ }
442
+ const session = await sessionLookup?.({
443
+ path: {
444
+ id: requestInput.sessionID,
445
+ },
446
+ query: {
447
+ directory: input.directory,
448
+ },
449
+ throwOnError: true,
450
+ }).catch(() => undefined);
451
+ const isTrueChildSession = typeof session?.data?.parentID === "string" && session.data.parentID.length > 0;
452
+ return {
453
+ reason: isTrueChildSession ? "subagent" : "regular",
454
+ };
455
+ };
403
456
  const getLatestLastAccountSwitchAt = async () => {
404
457
  const store = readRetryStoreContext(await loadStore().catch(() => undefined));
405
458
  return store?.lastAccountSwitchAt;
@@ -426,12 +479,42 @@ export function buildPluginHooks(input) {
426
479
  const loader = async (getAuth, provider) => {
427
480
  const authOverride = new AsyncLocalStorage();
428
481
  const getScopedAuth = async () => authOverride.getStore() ?? getAuth();
482
+ const providerConfig = provider;
429
483
  const config = await loadOfficialConfig({
430
484
  getAuth: getScopedAuth,
431
- provider: provider,
485
+ provider: providerConfig,
432
486
  });
433
487
  if (!config)
434
488
  return {};
489
+ const finalizeRequestForSelection = input.finalizeRequestForSelection
490
+ ?? (input.loadOfficialConfig
491
+ ? undefined
492
+ : async (selectionInput) => {
493
+ let captured;
494
+ const captureConfig = await loadOfficialConfig({
495
+ getAuth: getScopedAuth,
496
+ provider: providerConfig,
497
+ baseFetch: async (nextRequest, nextInit) => {
498
+ captured = {
499
+ request: nextRequest,
500
+ init: nextInit,
501
+ };
502
+ return new Response("{}", {
503
+ status: 200,
504
+ headers: {
505
+ "content-type": "application/json",
506
+ },
507
+ });
508
+ },
509
+ });
510
+ if (!captureConfig)
511
+ return undefined;
512
+ const inspectionRequest = selectionInput.request instanceof Request
513
+ ? selectionInput.request.clone()
514
+ : selectionInput.request;
515
+ await captureConfig.fetch(inspectionRequest, selectionInput.init).catch(() => undefined);
516
+ return captured;
517
+ });
435
518
  const store = await loadStore().catch(() => undefined);
436
519
  const retryStore = readRetryStoreContext(store);
437
520
  const fetchWithModelAccount = async (request, init) => {
@@ -447,7 +530,15 @@ export function buildPluginHooks(input) {
447
530
  maxEntries: touchWriteCacheMaxEntries,
448
531
  });
449
532
  const sessionID = getInternalSessionID(request, init);
450
- const initiator = getMergedRequestHeader(request, init, "x-initiator");
533
+ const finalized = await finalizeRequestForSelection?.({
534
+ request,
535
+ init,
536
+ getAuth: getScopedAuth,
537
+ provider: providerConfig,
538
+ }).catch(() => undefined);
539
+ const selectionRequest = finalized?.request ?? request;
540
+ const selectionInit = finalized?.init ?? init;
541
+ const initiator = getMergedRequestHeader(selectionRequest, selectionInit, "x-initiator");
451
542
  const allowReselect = initiator === "user";
452
543
  const candidates = latestStore ? resolveCopilotModelAccounts(latestStore, modelID) : [];
453
544
  const hasExplicitModelGroup = Boolean(latestStore
@@ -482,8 +573,13 @@ export function buildPluginHooks(input) {
482
573
  return config.fetch(outbound.request, outbound.init);
483
574
  }
484
575
  const candidateNames = candidates.map((item) => item.name);
576
+ const classification = sessionID.length > 0
577
+ ? await classifyRequestReason({ sessionID, request: selectionRequest, init: selectionInit })
578
+ : {
579
+ reason: toReasonByInitiator(initiator),
580
+ };
485
581
  let decisionLoads = loadMapToRecord(loads, candidateNames);
486
- let decisionReason = toReasonByInitiator(initiator);
582
+ let decisionReason = classification.reason;
487
583
  let decisionSwitched = false;
488
584
  let decisionSwitchFrom;
489
585
  let decisionSwitchBlockedBy;
@@ -755,19 +851,21 @@ export function buildPluginHooks(input) {
755
851
  retryAfterMs: decisionRetryAfterMs,
756
852
  },
757
853
  }).catch(() => undefined);
758
- const consumptionToast = buildConsumptionToast({
759
- accountName: finalChosenAccount,
760
- reason: decisionReason,
761
- switchFrom: decisionSwitchFrom,
762
- });
763
- await showStatusToast({
764
- client: input.client,
765
- message: consumptionToast.message,
766
- variant: consumptionToast.variant,
767
- warn: (scope, error) => {
768
- console.warn(`[${scope}] failed to show toast`, error);
769
- },
770
- }).catch(() => undefined);
854
+ if (shouldShowConsumptionToast({ reason: decisionReason, isFirstUse })) {
855
+ const consumptionToast = buildConsumptionToast({
856
+ accountName: finalChosenAccount,
857
+ reason: decisionReason,
858
+ switchFrom: decisionSwitchFrom,
859
+ });
860
+ await showStatusToast({
861
+ client: input.client,
862
+ message: consumptionToast.message,
863
+ variant: consumptionToast.variant,
864
+ warn: (scope, error) => {
865
+ console.warn(`[${scope}] failed to show toast`, error);
866
+ },
867
+ }).catch(() => undefined);
868
+ }
771
869
  return response;
772
870
  };
773
871
  if (retryStore?.networkRetryEnabled !== true)
@@ -890,6 +988,7 @@ export function buildPluginHooks(input) {
890
988
  },
891
989
  });
892
990
  }
991
+ output.headers[INTERNAL_DEBUG_LINK_HEADER] = hookInput.message.id;
893
992
  output.headers["x-opencode-session-id"] = hookInput.sessionID;
894
993
  };
895
994
  return {
@@ -69,7 +69,7 @@ export type RouteDecisionEvent = {
69
69
  groupSource: "model" | "active";
70
70
  candidateNames: string[];
71
71
  loads: Record<string, number>;
72
- reason: "regular" | "subagent" | "user-reselect" | "rate-limit-switch";
72
+ reason: "regular" | "subagent" | "compaction" | "user-reselect" | "rate-limit-switch";
73
73
  switched: boolean;
74
74
  switchFrom?: string;
75
75
  switchBlockedBy?: "no-cooled-down-candidate" | "replacement-load-higher" | "routing-state-read-failed" | "no-replacement-candidate";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",