opencode-copilot-account-switcher 0.2.4 → 0.2.6

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.
@@ -1,3 +1,4 @@
1
+ import { appendFileSync } from "node:fs";
1
2
  const RETRYABLE_MESSAGES = [
2
3
  "load failed",
3
4
  "failed to fetch",
@@ -12,12 +13,152 @@ const RETRYABLE_MESSAGES = [
12
13
  "unable to verify the first certificate",
13
14
  "self-signed certificate in certificate chain",
14
15
  ];
16
+ const defaultDebugLogFile = (() => {
17
+ const tmp = process.env.TEMP || process.env.TMP || "/tmp";
18
+ return `${tmp}/opencode-copilot-retry-debug.log`;
19
+ })();
20
+ function isDebugEnabled() {
21
+ return process.env.OPENCODE_COPILOT_RETRY_DEBUG === "1";
22
+ }
23
+ function debugLog(message, details) {
24
+ if (!isDebugEnabled())
25
+ return;
26
+ const suffix = details ? ` ${JSON.stringify(details)}` : "";
27
+ const line = `[copilot-network-retry debug] ${new Date().toISOString()} ${message}${suffix}`;
28
+ console.warn(line);
29
+ const filePath = process.env.OPENCODE_COPILOT_RETRY_DEBUG_FILE || defaultDebugLogFile;
30
+ if (!filePath)
31
+ return;
32
+ try {
33
+ appendFileSync(filePath, `${line}\n`);
34
+ }
35
+ catch (error) {
36
+ console.warn(`[copilot-network-retry debug] failed to write log file ${JSON.stringify({ filePath, error: String(error) })}`);
37
+ }
38
+ }
15
39
  function isAbortError(error) {
16
40
  return error instanceof Error && error.name === "AbortError";
17
41
  }
18
42
  function getErrorMessage(error) {
19
43
  return String(error instanceof Error ? error.message : error).toLowerCase();
20
44
  }
45
+ function isInputIdTooLongErrorBody(payload) {
46
+ if (!payload || typeof payload !== "object")
47
+ return false;
48
+ const error = payload.error;
49
+ const message = String(error?.message ?? "").toLowerCase();
50
+ return message.includes("invalid 'input[") && message.includes(".id'") && message.includes("string too long");
51
+ }
52
+ function isInputIdTooLongMessage(text) {
53
+ const message = text.toLowerCase();
54
+ return message.includes("invalid 'input[") && message.includes(".id'") && message.includes("string too long");
55
+ }
56
+ function hasLongInputIds(payload) {
57
+ const input = payload.input;
58
+ if (!Array.isArray(input))
59
+ return false;
60
+ return input.some((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
61
+ }
62
+ function stripLongInputIds(payload) {
63
+ const input = payload.input;
64
+ if (!Array.isArray(input))
65
+ return payload;
66
+ let changed = false;
67
+ const nextInput = input.map((item) => {
68
+ if (!item || typeof item !== "object")
69
+ return item;
70
+ const id = item.id;
71
+ if (typeof id === "string" && id.length > 64) {
72
+ changed = true;
73
+ const clone = { ...item };
74
+ delete clone.id;
75
+ return clone;
76
+ }
77
+ return item;
78
+ });
79
+ if (!changed)
80
+ return payload;
81
+ return {
82
+ ...payload,
83
+ input: nextInput,
84
+ };
85
+ }
86
+ function parseJsonBody(init) {
87
+ if (typeof init?.body !== "string")
88
+ return undefined;
89
+ try {
90
+ const parsed = JSON.parse(init.body);
91
+ if (!parsed || typeof parsed !== "object")
92
+ return undefined;
93
+ return parsed;
94
+ }
95
+ catch {
96
+ return undefined;
97
+ }
98
+ }
99
+ function buildRetryInit(init, payload) {
100
+ const headers = new Headers(init?.headers);
101
+ if (!headers.has("content-type")) {
102
+ headers.set("content-type", "application/json");
103
+ }
104
+ return {
105
+ ...init,
106
+ headers,
107
+ body: JSON.stringify(payload),
108
+ };
109
+ }
110
+ async function maybeRetryInputIdTooLong(request, init, response, baseFetch) {
111
+ if (response.status !== 400)
112
+ return response;
113
+ const requestPayload = parseJsonBody(init);
114
+ if (!requestPayload || !hasLongInputIds(requestPayload)) {
115
+ debugLog("skip input-id retry: request has no long ids");
116
+ return response;
117
+ }
118
+ debugLog("input-id retry candidate", {
119
+ status: response.status,
120
+ contentType: response.headers.get("content-type") ?? undefined,
121
+ });
122
+ const responseText = await response
123
+ .clone()
124
+ .text()
125
+ .catch(() => "");
126
+ if (!responseText) {
127
+ debugLog("skip input-id retry: empty response body");
128
+ return response;
129
+ }
130
+ let matched = isInputIdTooLongMessage(responseText);
131
+ if (!matched) {
132
+ try {
133
+ const bodyPayload = JSON.parse(responseText);
134
+ matched = isInputIdTooLongErrorBody(bodyPayload);
135
+ }
136
+ catch {
137
+ matched = false;
138
+ }
139
+ }
140
+ debugLog("input-id retry detection", {
141
+ matched,
142
+ bodyPreview: responseText.slice(0, 200),
143
+ });
144
+ if (!matched)
145
+ return response;
146
+ const sanitized = stripLongInputIds(requestPayload);
147
+ if (sanitized === requestPayload) {
148
+ debugLog("skip input-id retry: sanitize made no changes");
149
+ return response;
150
+ }
151
+ debugLog("input-id retry triggered", {
152
+ removedLongIds: true,
153
+ hadPreviousResponseId: typeof requestPayload.previous_response_id === "string",
154
+ });
155
+ const retried = await baseFetch(request, buildRetryInit(init, sanitized));
156
+ debugLog("input-id retry response", {
157
+ status: retried.status,
158
+ contentType: retried.headers.get("content-type") ?? undefined,
159
+ });
160
+ return retried;
161
+ }
21
162
  function toRetryableSystemError(error) {
22
163
  const base = error instanceof Error ? error : new Error(String(error));
23
164
  const wrapped = new Error(`[copilot-network-retry normalized] ${base.message}`);
@@ -38,6 +179,47 @@ function isCopilotUrl(request) {
38
179
  return false;
39
180
  }
40
181
  }
182
+ function withStreamDebugLogs(response, request) {
183
+ if (!isDebugEnabled())
184
+ return response;
185
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
186
+ if (!contentType.includes("text/event-stream") || !response.body)
187
+ return response;
188
+ const rawUrl = request instanceof Request ? request.url : request instanceof URL ? request.href : String(request);
189
+ const stream = new ReadableStream({
190
+ start(controller) {
191
+ const reader = response.body.getReader();
192
+ const pump = async () => {
193
+ try {
194
+ while (true) {
195
+ const next = await reader.read();
196
+ if (next.done) {
197
+ debugLog("sse stream finished", { url: rawUrl });
198
+ controller.close();
199
+ break;
200
+ }
201
+ controller.enqueue(next.value);
202
+ }
203
+ }
204
+ catch (error) {
205
+ const message = getErrorMessage(error);
206
+ debugLog("sse stream read error", {
207
+ url: rawUrl,
208
+ message,
209
+ retryableByMessage: RETRYABLE_MESSAGES.some((part) => message.includes(part)),
210
+ });
211
+ controller.error(error);
212
+ }
213
+ };
214
+ void pump();
215
+ },
216
+ });
217
+ return new Response(stream, {
218
+ status: response.status,
219
+ statusText: response.statusText,
220
+ headers: response.headers,
221
+ });
222
+ }
41
223
  export function isRetryableCopilotFetchError(error) {
42
224
  if (!error || isAbortError(error))
43
225
  return false;
@@ -47,10 +229,27 @@ export function isRetryableCopilotFetchError(error) {
47
229
  export function createCopilotRetryingFetch(baseFetch, options) {
48
230
  void options;
49
231
  return async function retryingFetch(request, init) {
232
+ debugLog("fetch start", {
233
+ url: request instanceof Request ? request.url : request instanceof URL ? request.href : String(request),
234
+ isCopilot: isCopilotUrl(request),
235
+ });
50
236
  try {
51
- return await baseFetch(request, init);
237
+ const response = await baseFetch(request, init);
238
+ debugLog("fetch resolved", {
239
+ status: response.status,
240
+ contentType: response.headers.get("content-type") ?? undefined,
241
+ });
242
+ if (isCopilotUrl(request)) {
243
+ const retried = await maybeRetryInputIdTooLong(request, init, response, baseFetch);
244
+ return withStreamDebugLogs(retried, request);
245
+ }
246
+ return response;
52
247
  }
53
248
  catch (error) {
249
+ debugLog("fetch threw", {
250
+ message: getErrorMessage(error),
251
+ retryableByMessage: isRetryableCopilotFetchError(error),
252
+ });
54
253
  if (!isCopilotUrl(request) || !isRetryableCopilotFetchError(error)) {
55
254
  throw error;
56
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",