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.
- package/dist/copilot-network-retry.js +200 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
}
|