opencode-copilot-account-switcher 0.11.0 → 0.12.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.
@@ -0,0 +1,240 @@
1
+ const AI_ERROR_MARKER = Symbol.for("vercel.ai.error");
2
+ const API_CALL_ERROR_MARKER = Symbol.for("vercel.ai.error.AI_APICallError");
3
+ export const COPILOT_RETRYABLE_MESSAGES = [
4
+ "load failed",
5
+ "failed to fetch",
6
+ "network request failed",
7
+ "unable to connect",
8
+ "econnreset",
9
+ "etimedout",
10
+ "socket hang up",
11
+ "unknown certificate",
12
+ "self signed certificate",
13
+ "unable to verify the first certificate",
14
+ "self-signed certificate in certificate chain",
15
+ ];
16
+ function toRequestUrl(request) {
17
+ return request instanceof Request ? request.url : request instanceof URL ? request.href : String(request);
18
+ }
19
+ function getErrorMessage(error) {
20
+ return String(error instanceof Error ? error.message : error).toLowerCase();
21
+ }
22
+ function isAbortError(error) {
23
+ return error instanceof Error && error.name === "AbortError";
24
+ }
25
+ function isSseReadTimeoutError(error) {
26
+ return getErrorMessage(error).includes("sse read timed out");
27
+ }
28
+ function isCopilotResponsesPath(request) {
29
+ const raw = toRequestUrl(request);
30
+ try {
31
+ const url = new URL(raw);
32
+ return url.pathname === "/responses";
33
+ }
34
+ catch {
35
+ return false;
36
+ }
37
+ }
38
+ function hasLongInputIds(payload) {
39
+ const input = payload?.input;
40
+ if (!Array.isArray(input))
41
+ return false;
42
+ return input.some((item) => {
43
+ const id = item?.id;
44
+ return typeof id === "string" && id.length > 64;
45
+ });
46
+ }
47
+ function collectInputItemIds(payload) {
48
+ const input = payload?.input;
49
+ if (!Array.isArray(input))
50
+ return [];
51
+ return [...new Set(input.flatMap((item) => {
52
+ const id = item?.id;
53
+ return typeof id === "string" && id.length > 0 ? [id] : [];
54
+ }))];
55
+ }
56
+ function isInputIdTooLongMessage(text) {
57
+ const message = text.toLowerCase();
58
+ return message.includes("string too long") && (message.includes("input id") || message.includes(".id'"));
59
+ }
60
+ function isConnectionMismatchInputIdMessage(text) {
61
+ const message = text.toLowerCase();
62
+ return message.includes("does not belong to this connection") && /item(?:\s+with)?\s+id/.test(message);
63
+ }
64
+ function isInputIdTooLongErrorBody(payload) {
65
+ if (!payload || typeof payload !== "object")
66
+ return false;
67
+ const error = payload.error;
68
+ const message = String(error?.message ?? "").toLowerCase();
69
+ return message.includes("string too long") && (message.includes("input id") || message.includes(".id'"));
70
+ }
71
+ function isConnectionMismatchInputIdErrorBody(payload) {
72
+ if (!payload || typeof payload !== "object")
73
+ return false;
74
+ const error = payload.error;
75
+ return isConnectionMismatchInputIdMessage(String(error?.message ?? ""));
76
+ }
77
+ function parseInputIdTooLongDetails(text) {
78
+ const matched = isInputIdTooLongMessage(text);
79
+ if (!matched)
80
+ return { matched };
81
+ const index = text.match(/input\[(\d+)\]\.id/i);
82
+ const length = text.match(/got a string with length\s+(\d+)/i) ?? text.match(/length\s+(\d+)/i);
83
+ return {
84
+ matched,
85
+ serverReportedIndex: index ? Number(index[1]) : undefined,
86
+ reportedLength: length ? Number(length[1]) : undefined,
87
+ };
88
+ }
89
+ export function isCopilotUrl(request) {
90
+ const raw = toRequestUrl(request);
91
+ try {
92
+ const url = new URL(raw);
93
+ return url.hostname === "api.githubcopilot.com" || url.hostname.startsWith("copilot-api.");
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ export function isRetryableCopilotTransportError(error) {
100
+ if (!error || isAbortError(error))
101
+ return false;
102
+ const message = getErrorMessage(error);
103
+ return COPILOT_RETRYABLE_MESSAGES.some((part) => message.includes(part));
104
+ }
105
+ function buildRetryableApiCallMessage(group, detail) {
106
+ return `Copilot retryable error [${group}]: ${detail}`;
107
+ }
108
+ export function toRetryableApiCallError(error, request, options) {
109
+ const base = error instanceof Error ? error : new Error(String(error));
110
+ const wrapped = new Error(buildRetryableApiCallMessage(options?.group ?? "transport", base.message));
111
+ wrapped.name = "AI_APICallError";
112
+ wrapped.url = request.url;
113
+ wrapped.requestBodyValues = options?.requestBodyValues ?? (() => {
114
+ if (!request.body)
115
+ return {};
116
+ try {
117
+ return JSON.parse(request.body);
118
+ }
119
+ catch {
120
+ return {};
121
+ }
122
+ })();
123
+ wrapped.statusCode = options?.statusCode;
124
+ wrapped.responseHeaders = options?.responseHeaders instanceof Headers
125
+ ? Object.fromEntries(options.responseHeaders.entries())
126
+ : options?.responseHeaders;
127
+ wrapped.responseBody = options?.responseBody;
128
+ wrapped.isRetryable = true;
129
+ wrapped.cause = error;
130
+ wrapped[AI_ERROR_MARKER] = true;
131
+ wrapped[API_CALL_ERROR_MARKER] = true;
132
+ return wrapped;
133
+ }
134
+ export function isRetryableApiCallError(error) {
135
+ return Boolean(error
136
+ && typeof error === "object"
137
+ && error[AI_ERROR_MARKER] === true
138
+ && error[API_CALL_ERROR_MARKER] === true);
139
+ }
140
+ function normalizeRetryableStatusResponse(response, request) {
141
+ if (response.status !== 499)
142
+ return response;
143
+ return response.clone().text().catch(() => "").then((responseBody) => {
144
+ throw toRetryableApiCallError(new Error(responseBody || `status code ${response.status}`), request, {
145
+ group: "status",
146
+ statusCode: response.status,
147
+ responseHeaders: response.headers,
148
+ responseBody: responseBody || undefined,
149
+ });
150
+ });
151
+ }
152
+ export function createCopilotRetryPolicy(options) {
153
+ const policy = {
154
+ matchesRequest: (request) => isCopilotUrl(request),
155
+ classifyFailure: async ({ error }) => {
156
+ if (isRetryableApiCallError(error)) {
157
+ return { retryable: false, category: "already-normalized" };
158
+ }
159
+ if (isRetryableCopilotTransportError(error) || options?.extraRetryableClassifier?.(error) === true) {
160
+ return { retryable: true, category: "transport" };
161
+ }
162
+ return { retryable: false, category: "none" };
163
+ },
164
+ handleResponse: async ({ response, request }) => normalizeRetryableStatusResponse(response, request),
165
+ normalizeFailure: ({ error, classification, request }) => {
166
+ if (classification.retryable && classification.category === "transport") {
167
+ return toRetryableApiCallError(error, request);
168
+ }
169
+ return error;
170
+ },
171
+ buildRepairPlan: async () => undefined,
172
+ shouldRunResponseRepair: (request) => isCopilotUrl(request) && isCopilotResponsesPath(request),
173
+ decideResponseRepair: async ({ request, response, requestPayload, sessionID }) => {
174
+ if (!isCopilotUrl(request) || !isCopilotResponsesPath(request)) {
175
+ return { kind: "skip" };
176
+ }
177
+ if (response.ok)
178
+ return { kind: "skip" };
179
+ const responseText = await response.clone().text().catch(() => "");
180
+ if (!responseText)
181
+ return { kind: "skip" };
182
+ const removableIds = collectInputItemIds(requestPayload);
183
+ let isConnectionMismatch = isConnectionMismatchInputIdMessage(responseText);
184
+ if (!isConnectionMismatch) {
185
+ try {
186
+ const bodyPayload = JSON.parse(responseText);
187
+ isConnectionMismatch = isConnectionMismatchInputIdErrorBody(bodyPayload);
188
+ }
189
+ catch {
190
+ isConnectionMismatch = false;
191
+ }
192
+ }
193
+ if (isConnectionMismatch && removableIds.length > 0) {
194
+ return {
195
+ kind: "connection-mismatch",
196
+ responseText,
197
+ shouldAttemptSessionRepair: Boolean(sessionID) && removableIds.length > 0,
198
+ };
199
+ }
200
+ if (!hasLongInputIds(requestPayload))
201
+ return { kind: "skip" };
202
+ let parsed = parseInputIdTooLongDetails(responseText);
203
+ let matched = parsed.matched;
204
+ if (!matched) {
205
+ try {
206
+ const bodyPayload = JSON.parse(responseText);
207
+ const error = bodyPayload.error;
208
+ parsed = parseInputIdTooLongDetails(String(error?.message ?? ""));
209
+ matched = parsed.matched || isInputIdTooLongErrorBody(bodyPayload);
210
+ }
211
+ catch {
212
+ matched = false;
213
+ }
214
+ }
215
+ if (!matched)
216
+ return { kind: "skip" };
217
+ return {
218
+ kind: "input-id-too-long",
219
+ responseText,
220
+ serverReportedIndex: parsed.serverReportedIndex,
221
+ reportedLength: parsed.reportedLength,
222
+ shouldAttemptSessionRepair: Boolean(sessionID) && hasLongInputIds(requestPayload),
223
+ };
224
+ },
225
+ normalizeStreamError: ({ error, request, statusCode, responseHeaders }) => {
226
+ if (isSseReadTimeoutError(error))
227
+ return error;
228
+ if (!isRetryableCopilotTransportError(error))
229
+ return error;
230
+ return toRetryableApiCallError(error, {
231
+ url: toRequestUrl(request),
232
+ }, {
233
+ group: "stream",
234
+ statusCode,
235
+ responseHeaders,
236
+ });
237
+ },
238
+ };
239
+ return policy;
240
+ }
@@ -0,0 +1,50 @@
1
+ export type SharedRetryNotifier = {
2
+ started: (state: {
3
+ remaining: number;
4
+ }) => Promise<void>;
5
+ progress: (state: {
6
+ remaining: number;
7
+ }) => Promise<void>;
8
+ repairWarning: (state: {
9
+ remaining: number;
10
+ }) => Promise<void>;
11
+ completed: (state: {
12
+ remaining: number;
13
+ }) => Promise<void>;
14
+ stopped: (state: {
15
+ remaining: number;
16
+ }) => Promise<void>;
17
+ };
18
+ export declare const noopSharedRetryNotifier: SharedRetryNotifier;
19
+ export declare function notifySharedRetryEvent(notifier: SharedRetryNotifier, event: keyof SharedRetryNotifier, remaining: number): Promise<void>;
20
+ export type SharedRetryErrorContainer = {
21
+ retryableMessages: string[];
22
+ isAbortError?: (error: unknown) => boolean;
23
+ };
24
+ export declare function getSharedErrorMessage(error: unknown): string;
25
+ export declare function isRetryableErrorByContainer(error: unknown, container: SharedRetryErrorContainer): boolean;
26
+ export type SharedFailOpenResult<T> = {
27
+ ok: true;
28
+ value: T;
29
+ } | {
30
+ ok: false;
31
+ error: unknown;
32
+ };
33
+ export declare function runSharedFailOpenBoundary<T>(options: {
34
+ action: () => Promise<T>;
35
+ isFailOpenError: (error: unknown) => boolean;
36
+ onFailOpen?: (error: unknown) => void;
37
+ }): Promise<SharedFailOpenResult<T>>;
38
+ export type SharedRetryIterationResult = {
39
+ handled: boolean;
40
+ stop: boolean;
41
+ shouldContinue: boolean;
42
+ };
43
+ export declare function runSharedRetryScheduler(options: {
44
+ initialShouldContinue: boolean;
45
+ runIteration: (input: {
46
+ attempts: number;
47
+ }) => Promise<SharedRetryIterationResult>;
48
+ }): Promise<{
49
+ attempts: number;
50
+ }>;
@@ -0,0 +1,56 @@
1
+ export const noopSharedRetryNotifier = {
2
+ started: async () => { },
3
+ progress: async () => { },
4
+ repairWarning: async () => { },
5
+ completed: async () => { },
6
+ stopped: async () => { },
7
+ };
8
+ export async function notifySharedRetryEvent(notifier, event, remaining) {
9
+ try {
10
+ await notifier[event]({ remaining });
11
+ }
12
+ catch (error) {
13
+ console.warn(`[copilot-network-retry] notifier ${event} failed`, error);
14
+ }
15
+ }
16
+ export function getSharedErrorMessage(error) {
17
+ return String(error instanceof Error ? error.message : error).toLowerCase();
18
+ }
19
+ export function isRetryableErrorByContainer(error, container) {
20
+ if (!error)
21
+ return false;
22
+ if (container.isAbortError?.(error))
23
+ return false;
24
+ const message = getSharedErrorMessage(error);
25
+ return container.retryableMessages.some((part) => message.includes(part));
26
+ }
27
+ export async function runSharedFailOpenBoundary(options) {
28
+ try {
29
+ return {
30
+ ok: true,
31
+ value: await options.action(),
32
+ };
33
+ }
34
+ catch (error) {
35
+ if (!options.isFailOpenError(error))
36
+ throw error;
37
+ options.onFailOpen?.(error);
38
+ return {
39
+ ok: false,
40
+ error,
41
+ };
42
+ }
43
+ }
44
+ export async function runSharedRetryScheduler(options) {
45
+ let attempts = 0;
46
+ let shouldContinue = options.initialShouldContinue;
47
+ while (shouldContinue) {
48
+ shouldContinue = false;
49
+ const result = await options.runIteration({ attempts });
50
+ if (!result.handled || result.stop)
51
+ break;
52
+ attempts += 1;
53
+ shouldContinue = result.shouldContinue;
54
+ }
55
+ return { attempts };
56
+ }
@@ -77,6 +77,7 @@ export type RouteDecisionEvent = {
77
77
  touchWriteError?: string;
78
78
  rateLimitMatched: boolean;
79
79
  retryAfterMs?: number;
80
+ finalRequestHeaders?: Record<string, string>;
80
81
  };
81
82
  export type RoutingEvent = SessionTouchEvent | RateLimitFlaggedEvent;
82
83
  export declare function appendRouteDecisionEvent(input: {
@@ -59,25 +59,6 @@ function renderAccountGrid(cells) {
59
59
  }
60
60
  return rows;
61
61
  }
62
- function formatActiveGroup(store) {
63
- const names = Array.isArray(store.activeAccountNames) ? store.activeAccountNames : [];
64
- if (names.length > 0)
65
- return names.join(", ");
66
- return "none";
67
- }
68
- function formatRoutingGroup(store) {
69
- const assignments = store.modelAccountAssignments ?? {};
70
- const modelIDs = Object.keys(assignments).sort((a, b) => a.localeCompare(b));
71
- const mapped = modelIDs
72
- .map((modelID) => {
73
- const names = assignments[modelID] ?? [];
74
- if (names.length === 0)
75
- return undefined;
76
- return `${modelID} -> ${names.join(", ")}`;
77
- })
78
- .filter((line) => Boolean(line));
79
- return mapped.length > 0 ? mapped.join("; ") : "none";
80
- }
81
62
  function buildSuccessMessage(store, _name) {
82
63
  const defaultNames = Array.isArray(store.activeAccountNames) && store.activeAccountNames.length > 0
83
64
  ? store.activeAccountNames
@@ -110,8 +91,6 @@ function buildSuccessMessage(store, _name) {
110
91
  lines.push("[routes]");
111
92
  lines.push("(none)");
112
93
  }
113
- lines.push(`活跃组: ${formatActiveGroup(store)}`);
114
- lines.push(`路由组: ${formatRoutingGroup(store)}`);
115
94
  return lines.join("\n");
116
95
  }
117
96
  function buildMissingActiveMessage() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",