opencode-copilot-account-switcher 0.2.3 → 0.2.5

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,13 +13,118 @@ const RETRYABLE_MESSAGES = [
12
13
  "unable to verify the first certificate",
13
14
  "self-signed certificate in certificate chain",
14
15
  ];
15
- const RETRYABLE_PATH_SEGMENTS = ["/chat/completions", "/responses", "/models", "/token"];
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
+ }
16
39
  function isAbortError(error) {
17
40
  return error instanceof Error && error.name === "AbortError";
18
41
  }
19
42
  function getErrorMessage(error) {
20
43
  return String(error instanceof Error ? error.message : error).toLowerCase();
21
44
  }
45
+ function isJsonContentType(headers) {
46
+ return headers.get("content-type")?.toLowerCase().includes("application/json") === true;
47
+ }
48
+ function isInputIdTooLongErrorBody(payload) {
49
+ if (!payload || typeof payload !== "object")
50
+ return false;
51
+ const error = payload.error;
52
+ const message = String(error?.message ?? "").toLowerCase();
53
+ return message.includes("invalid 'input[") && message.includes(".id'") && message.includes("string too long");
54
+ }
55
+ function hasLongInputIds(payload) {
56
+ const input = payload.input;
57
+ if (!Array.isArray(input))
58
+ return false;
59
+ return input.some((item) => typeof item?.id === "string" && (item.id?.length ?? 0) > 64);
60
+ }
61
+ function stripLongInputIds(payload) {
62
+ const input = payload.input;
63
+ if (!Array.isArray(input))
64
+ return payload;
65
+ let changed = false;
66
+ const nextInput = input.map((item) => {
67
+ if (!item || typeof item !== "object")
68
+ return item;
69
+ const id = item.id;
70
+ if (typeof id === "string" && id.length > 64) {
71
+ changed = true;
72
+ const clone = { ...item };
73
+ delete clone.id;
74
+ return clone;
75
+ }
76
+ return item;
77
+ });
78
+ if (!changed)
79
+ return payload;
80
+ return {
81
+ ...payload,
82
+ input: nextInput,
83
+ };
84
+ }
85
+ function parseJsonBody(init) {
86
+ if (typeof init?.body !== "string")
87
+ return undefined;
88
+ try {
89
+ const parsed = JSON.parse(init.body);
90
+ if (!parsed || typeof parsed !== "object")
91
+ return undefined;
92
+ return parsed;
93
+ }
94
+ catch {
95
+ return undefined;
96
+ }
97
+ }
98
+ function buildRetryInit(init, payload) {
99
+ const headers = new Headers(init?.headers);
100
+ if (!headers.has("content-type")) {
101
+ headers.set("content-type", "application/json");
102
+ }
103
+ return {
104
+ ...init,
105
+ headers,
106
+ body: JSON.stringify(payload),
107
+ };
108
+ }
109
+ async function maybeRetryInputIdTooLong(request, init, response, baseFetch) {
110
+ if (response.status !== 400)
111
+ return response;
112
+ const requestPayload = parseJsonBody(init);
113
+ if (!requestPayload || !hasLongInputIds(requestPayload))
114
+ return response;
115
+ if (!isJsonContentType(response.headers))
116
+ return response;
117
+ const bodyPayload = await response
118
+ .clone()
119
+ .json()
120
+ .catch(() => undefined);
121
+ if (!isInputIdTooLongErrorBody(bodyPayload))
122
+ return response;
123
+ const sanitized = stripLongInputIds(requestPayload);
124
+ if (sanitized === requestPayload)
125
+ return response;
126
+ return baseFetch(request, buildRetryInit(init, sanitized));
127
+ }
22
128
  function toRetryableSystemError(error) {
23
129
  const base = error instanceof Error ? error : new Error(String(error));
24
130
  const wrapped = new Error(`[copilot-network-retry normalized] ${base.message}`);
@@ -33,14 +139,53 @@ function isCopilotUrl(request) {
33
139
  try {
34
140
  const url = new URL(raw);
35
141
  const isCopilotHost = url.hostname === "api.githubcopilot.com" || url.hostname.startsWith("copilot-api.");
36
- if (!isCopilotHost)
37
- return false;
38
- return RETRYABLE_PATH_SEGMENTS.some((segment) => url.pathname.includes(segment));
142
+ return isCopilotHost;
39
143
  }
40
144
  catch {
41
145
  return false;
42
146
  }
43
147
  }
148
+ function withStreamDebugLogs(response, request) {
149
+ if (!isDebugEnabled())
150
+ return response;
151
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
152
+ if (!contentType.includes("text/event-stream") || !response.body)
153
+ return response;
154
+ const rawUrl = request instanceof Request ? request.url : request instanceof URL ? request.href : String(request);
155
+ const stream = new ReadableStream({
156
+ start(controller) {
157
+ const reader = response.body.getReader();
158
+ const pump = async () => {
159
+ try {
160
+ while (true) {
161
+ const next = await reader.read();
162
+ if (next.done) {
163
+ debugLog("sse stream finished", { url: rawUrl });
164
+ controller.close();
165
+ break;
166
+ }
167
+ controller.enqueue(next.value);
168
+ }
169
+ }
170
+ catch (error) {
171
+ const message = getErrorMessage(error);
172
+ debugLog("sse stream read error", {
173
+ url: rawUrl,
174
+ message,
175
+ retryableByMessage: RETRYABLE_MESSAGES.some((part) => message.includes(part)),
176
+ });
177
+ controller.error(error);
178
+ }
179
+ };
180
+ void pump();
181
+ },
182
+ });
183
+ return new Response(stream, {
184
+ status: response.status,
185
+ statusText: response.statusText,
186
+ headers: response.headers,
187
+ });
188
+ }
44
189
  export function isRetryableCopilotFetchError(error) {
45
190
  if (!error || isAbortError(error))
46
191
  return false;
@@ -50,10 +195,27 @@ export function isRetryableCopilotFetchError(error) {
50
195
  export function createCopilotRetryingFetch(baseFetch, options) {
51
196
  void options;
52
197
  return async function retryingFetch(request, init) {
198
+ debugLog("fetch start", {
199
+ url: request instanceof Request ? request.url : request instanceof URL ? request.href : String(request),
200
+ isCopilot: isCopilotUrl(request),
201
+ });
53
202
  try {
54
- return await baseFetch(request, init);
203
+ const response = await baseFetch(request, init);
204
+ debugLog("fetch resolved", {
205
+ status: response.status,
206
+ contentType: response.headers.get("content-type") ?? undefined,
207
+ });
208
+ if (isCopilotUrl(request)) {
209
+ const retried = await maybeRetryInputIdTooLong(request, init, response, baseFetch);
210
+ return withStreamDebugLogs(retried, request);
211
+ }
212
+ return response;
55
213
  }
56
214
  catch (error) {
215
+ debugLog("fetch threw", {
216
+ message: getErrorMessage(error),
217
+ retryableByMessage: isRetryableCopilotFetchError(error),
218
+ });
57
219
  if (!isCopilotUrl(request) || !isRetryableCopilotFetchError(error)) {
58
220
  throw error;
59
221
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-copilot-account-switcher",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "GitHub Copilot account switcher plugin for OpenCode",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",