copillm 0.2.3 → 0.2.4
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/README.md +1 -0
- package/dist/agentconfig/apply.js +4 -3
- package/dist/agentconfig/load.js +34 -1
- package/dist/agentconfig/schema.js +22 -0
- package/dist/agents/registry.js +80 -6
- package/dist/cli/auth/ensure.js +30 -0
- package/dist/cli/auth/runAuth.js +38 -0
- package/dist/cli/commands/agents/claude.js +47 -0
- package/dist/cli/commands/agents/codex.js +48 -0
- package/dist/cli/commands/agents/copilot.js +49 -0
- package/dist/cli/commands/agents/pi.js +47 -0
- package/dist/cli/commands/agents/shared.js +28 -0
- package/dist/cli/commands/auth.js +99 -0
- package/dist/cli/commands/daemon.js +357 -0
- package/dist/cli/commands/env.js +135 -0
- package/dist/cli/commands/models.js +80 -0
- package/dist/cli/configCommands.js +10 -0
- package/dist/cli/copillmFlags.js +101 -0
- package/dist/cli/daemon/ensureRunning.js +66 -0
- package/dist/cli/daemon/lifecycle.js +61 -0
- package/dist/cli/daemon/probes.js +68 -0
- package/dist/cli/daemon/runDaemon.js +102 -0
- package/dist/cli/daemon/spawnEnv.js +12 -0
- package/dist/cli/index.js +43 -0
- package/dist/cli/integrations/banner.js +51 -0
- package/dist/cli/integrations/claudeExport.js +14 -0
- package/dist/cli/integrations/refreshCodex.js +19 -0
- package/dist/cli/integrations/refreshPi.js +17 -0
- package/dist/cli/shared/backends.js +31 -0
- package/dist/cli/shared/debug.js +44 -0
- package/dist/cli/shared/deprecation.js +7 -0
- package/dist/cli/shared/exitCodes.js +9 -0
- package/dist/cli/shared/output.js +14 -0
- package/dist/cli/shared/parseAgent.js +6 -0
- package/dist/cli.js +1 -1355
- package/dist/server/errors.js +195 -0
- package/dist/server/proxy.js +50 -885
- package/dist/server/routes/debug.js +65 -0
- package/dist/server/routes/health.js +32 -0
- package/dist/server/routes/models.js +41 -0
- package/dist/server/routes/proxyForward.js +108 -0
- package/dist/server/routes/shared.js +161 -0
- package/dist/server/upstream/copilotClient.js +137 -0
- package/dist/server/upstream/streaming.js +146 -0
- package/package.json +7 -2
package/dist/server/proxy.js
CHANGED
|
@@ -1,49 +1,13 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import { getGithubUserSummary, GithubUserFetchError } from "./debugInfo.js";
|
|
12
|
-
import { buildAnthropicModelsResponse } from "./anthropicModelsResponse.js";
|
|
13
|
-
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson, safeWrite } from "./requestLifecycle.js";
|
|
14
|
-
const COPILOT_HEADERS = {
|
|
15
|
-
"Content-Type": "application/json",
|
|
16
|
-
"Copilot-Integration-Id": "vscode-chat",
|
|
17
|
-
"Editor-Version": "vscode/1.95.0",
|
|
18
|
-
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
19
|
-
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
20
|
-
"Openai-Intent": "conversation-panel",
|
|
21
|
-
"X-GitHub-Api-Version": "2025-04-01",
|
|
22
|
-
"X-VScode-User-Agent-Library-Version": "electron-fetch",
|
|
23
|
-
// Disable gzip/br on the upstream response. Compressed SSE streams get
|
|
24
|
-
// buffered at gzip flush boundaries by undici's decoder, which causes
|
|
25
|
-
// visible "freeze then dump a paragraph" behaviour in Claude Code and
|
|
26
|
-
// other Anthropic-shape clients. Identity encoding lets each SSE event
|
|
27
|
-
// flow through immediately.
|
|
28
|
-
"Accept-Encoding": "identity"
|
|
29
|
-
};
|
|
30
|
-
const HEALTH_REFRESH_THRESHOLD_SECONDS = 60;
|
|
31
|
-
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
32
|
-
const MAX_UPSTREAM_ATTEMPTS = 3;
|
|
33
|
-
const BASE_BACKOFF_MS = 200;
|
|
34
|
-
const DAEMON_STARTED_AT_ISO = new Date().toISOString();
|
|
35
|
-
class JsonRequestParseError extends Error {
|
|
36
|
-
constructor(message) {
|
|
37
|
-
super(message);
|
|
38
|
-
this.name = "JsonRequestParseError";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
class InvalidRequestShapeError extends Error {
|
|
42
|
-
constructor(message) {
|
|
43
|
-
super(message);
|
|
44
|
-
this.name = "InvalidRequestShapeError";
|
|
45
|
-
}
|
|
46
|
-
}
|
|
3
|
+
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
|
|
4
|
+
import { InvalidRequestShapeError, JsonRequestParseError } from "./errors.js";
|
|
5
|
+
import { ProtocolTranslationError } from "../translation/openaiAnthropic.js";
|
|
6
|
+
import { handleHealthz, handleLivez } from "./routes/health.js";
|
|
7
|
+
import { handleModels } from "./routes/models.js";
|
|
8
|
+
import { handleDebug } from "./routes/debug.js";
|
|
9
|
+
import { handleProxyForward } from "./routes/proxyForward.js";
|
|
10
|
+
import { isLocalRequest, resolveRoute, safePathname } from "./routes/shared.js";
|
|
47
11
|
export async function startProxyServer(input) {
|
|
48
12
|
const debugEnabled = input.debug === true;
|
|
49
13
|
const server = createServer(async (req, res) => {
|
|
@@ -63,198 +27,72 @@ export async function startProxyServer(input) {
|
|
|
63
27
|
});
|
|
64
28
|
try {
|
|
65
29
|
if (!isLocalRequest(req)) {
|
|
66
|
-
|
|
30
|
+
safeSendJson(res, 403, { error: "non_loopback_request_rejected" });
|
|
67
31
|
return;
|
|
68
32
|
}
|
|
69
33
|
const route = resolveRoute(req.method, req.url);
|
|
70
34
|
if (input.config.requireCallerSecret && route.kind !== "livez" && route.kind !== "healthz") {
|
|
71
35
|
const auth = req.headers.authorization;
|
|
72
36
|
if (!input.callerSecret || auth !== `Bearer ${input.callerSecret}`) {
|
|
73
|
-
|
|
37
|
+
safeSendJson(res, 401, { error: "invalid_caller_secret" });
|
|
74
38
|
return;
|
|
75
39
|
}
|
|
76
40
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
if (route.kind === "healthz") {
|
|
82
|
-
const ttl = input.tokenManager.expiresInSeconds();
|
|
83
|
-
if (ttl !== null && ttl > HEALTH_REFRESH_THRESHOLD_SECONDS) {
|
|
84
|
-
sendJson(res, 200, {
|
|
85
|
-
status: "ok",
|
|
86
|
-
token_state: "fresh",
|
|
87
|
-
refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
|
|
88
|
-
bearer_ttl_seconds: ttl
|
|
89
|
-
});
|
|
41
|
+
switch (route.kind) {
|
|
42
|
+
case "livez":
|
|
43
|
+
handleLivez(res);
|
|
90
44
|
return;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
await input.tokenManager.ensureToken({ refreshThresholdSeconds: HEALTH_REFRESH_THRESHOLD_SECONDS });
|
|
94
|
-
const refreshedTtl = input.tokenManager.expiresInSeconds() ?? 0;
|
|
95
|
-
sendJson(res, 200, {
|
|
96
|
-
status: "ok",
|
|
97
|
-
token_state: "refreshed",
|
|
98
|
-
refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
|
|
99
|
-
bearer_ttl_seconds: refreshedTtl
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
catch (error) {
|
|
103
|
-
const failed = healthFailure(error);
|
|
104
|
-
sendJson(res, failed.httpStatus, failed.payload);
|
|
105
|
-
}
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (route.kind === "models" || route.kind === "codex_models" || route.kind === "anthropic_models") {
|
|
109
|
-
try {
|
|
110
|
-
await input.tokenManager.ensureToken(false);
|
|
111
|
-
const githubToken = input.githubToken;
|
|
112
|
-
if (!githubToken) {
|
|
113
|
-
sendJson(res, 503, { error: "github_token_unavailable" });
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
const result = route.kind === "codex_models" || route.kind === "anthropic_models"
|
|
117
|
-
? await listModelsUnion(input.config.accountType, githubToken, 3)
|
|
118
|
-
: await listModels(input.config.accountType, githubToken);
|
|
119
|
-
if (route.kind === "codex_models") {
|
|
120
|
-
sendJson(res, 200, buildCodexCatalog(result.models));
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
if (route.kind === "anthropic_models") {
|
|
124
|
-
sendJson(res, 200, buildAnthropicModelsResponse(result.models));
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
sendJson(res, 200, {
|
|
128
|
-
models: result.models,
|
|
129
|
-
discovery: {
|
|
130
|
-
source: result.source,
|
|
131
|
-
stale: result.stale,
|
|
132
|
-
cache_age_seconds: result.cacheAgeSeconds,
|
|
133
|
-
warning: result.warning
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
if (error instanceof CopilotTokenManagerError) {
|
|
139
|
-
sendJson(res, 503, { error: "token_refresh_failed" });
|
|
140
|
-
return;
|
|
141
|
-
}
|
|
142
|
-
throw error;
|
|
143
|
-
}
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
if (route.kind === "debug") {
|
|
147
|
-
if (!debugEnabled) {
|
|
148
|
-
sendJson(res, 404, { error: "not_found" });
|
|
45
|
+
case "healthz":
|
|
46
|
+
await handleHealthz(res, input.tokenManager);
|
|
149
47
|
return;
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
tokenManager: input.tokenManager,
|
|
155
|
-
githubToken: input.githubToken,
|
|
156
|
-
port: input.port
|
|
157
|
-
});
|
|
158
|
-
return;
|
|
159
|
-
}
|
|
160
|
-
if (route.kind === "not_found") {
|
|
161
|
-
sendJson(res, 404, { error: "not_found" });
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
const requestBody = await readJson(req);
|
|
165
|
-
const translatedBody = translateRequestBody(route.kind, requestBody);
|
|
166
|
-
const requestedModel = readRequestedModel(translatedBody);
|
|
167
|
-
if (input.config.selectedModels.length > 0 && !requestedModel) {
|
|
168
|
-
sendJson(res, 400, {
|
|
169
|
-
error: "model_not_selected",
|
|
170
|
-
detail: "Requested model is not enabled in local selection."
|
|
171
|
-
});
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
|
-
let resolvedModel = null;
|
|
175
|
-
try {
|
|
176
|
-
resolvedModel = requestedModel ? resolveModelId(requestedModel, input.config.selectedModels) : null;
|
|
177
|
-
}
|
|
178
|
-
catch (error) {
|
|
179
|
-
const detail = error instanceof Error ? error.message : "Model resolution failed.";
|
|
180
|
-
sendJson(res, 400, { error: "ambiguous_model_selection", detail });
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
if (input.config.selectedModels.length > 0 && !resolvedModel) {
|
|
184
|
-
sendJson(res, 400, {
|
|
185
|
-
error: "model_not_selected",
|
|
186
|
-
detail: "Requested model is not enabled in local selection."
|
|
187
|
-
});
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
const upstreamBody = resolvedModel ? rewriteRequestedModel(translatedBody, resolvedModel.id) : translatedBody;
|
|
191
|
-
const upstreamPath = route.kind === "codex_responses" ? "/responses" : "/chat/completions";
|
|
192
|
-
const isAnthropicStreaming = route.anthroShape && isStreamingRequestBody(translatedBody);
|
|
193
|
-
let prelude = null;
|
|
194
|
-
if (isAnthropicStreaming) {
|
|
195
|
-
beginAnthropicSseResponse(res, req);
|
|
196
|
-
prelude = writeAnthropicPrelude(res, requestedModel ?? "");
|
|
197
|
-
}
|
|
198
|
-
input.logger.debug({
|
|
199
|
-
event: "request_prepared",
|
|
200
|
-
request_id: requestId,
|
|
201
|
-
route: route.kind,
|
|
202
|
-
anthro_shape: route.anthroShape,
|
|
203
|
-
requested_model: requestedModel,
|
|
204
|
-
upstream_model: readRequestedModel(upstreamBody),
|
|
205
|
-
model_resolution_rule: resolvedModel?.rule ?? null,
|
|
206
|
-
upstream_path: upstreamPath,
|
|
207
|
-
...summarizeUpstreamPayload(upstreamBody)
|
|
208
|
-
}, "prepared upstream request");
|
|
209
|
-
try {
|
|
210
|
-
const upstream = await postToCopilot({
|
|
211
|
-
tokenManager: input.tokenManager,
|
|
212
|
-
accountType: input.config.accountType,
|
|
213
|
-
body: upstreamBody,
|
|
214
|
-
requestId,
|
|
215
|
-
logger: input.logger,
|
|
216
|
-
upstreamPath,
|
|
217
|
-
signal: lifecycle.signal
|
|
218
|
-
});
|
|
219
|
-
await forwardResponse(upstream, route.anthroShape, res, {
|
|
220
|
-
requestedModel: requestedModel ?? undefined,
|
|
221
|
-
prelude,
|
|
222
|
-
logger: input.logger,
|
|
223
|
-
requestId
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
if (isBenignSocketError(error)) {
|
|
228
|
-
input.logger.debug({ event: "upstream_aborted", request_id: requestId, err: error }, "upstream request aborted (client disconnected)");
|
|
229
|
-
safeEnd(res);
|
|
48
|
+
case "models":
|
|
49
|
+
case "codex_models":
|
|
50
|
+
case "anthropic_models":
|
|
51
|
+
await handleModels(res, route.kind, input.config, input.tokenManager, input.githubToken);
|
|
230
52
|
return;
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
writeAnthropicSseError(res, prelude, "token_refresh_failed");
|
|
53
|
+
case "debug":
|
|
54
|
+
if (!debugEnabled) {
|
|
55
|
+
safeSendJson(res, 404, { error: "not_found" });
|
|
235
56
|
return;
|
|
236
57
|
}
|
|
237
|
-
|
|
58
|
+
await handleDebug(res, {
|
|
59
|
+
config: input.config,
|
|
60
|
+
logger: input.logger,
|
|
61
|
+
tokenManager: input.tokenManager,
|
|
62
|
+
githubToken: input.githubToken,
|
|
63
|
+
port: input.port
|
|
64
|
+
});
|
|
238
65
|
return;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
66
|
+
case "not_found":
|
|
67
|
+
safeSendJson(res, 404, { error: "not_found" });
|
|
68
|
+
return;
|
|
69
|
+
case "openai":
|
|
70
|
+
case "anthropic":
|
|
71
|
+
case "codex_responses":
|
|
72
|
+
await handleProxyForward({
|
|
73
|
+
req,
|
|
74
|
+
res,
|
|
75
|
+
route,
|
|
76
|
+
config: input.config,
|
|
77
|
+
tokenManager: input.tokenManager,
|
|
78
|
+
logger: input.logger,
|
|
79
|
+
requestId,
|
|
80
|
+
signal: lifecycle.signal
|
|
81
|
+
});
|
|
242
82
|
return;
|
|
243
|
-
}
|
|
244
|
-
throw error;
|
|
245
83
|
}
|
|
246
84
|
}
|
|
247
85
|
catch (error) {
|
|
248
86
|
if (error instanceof JsonRequestParseError) {
|
|
249
|
-
|
|
87
|
+
safeSendJson(res, 400, { error: "invalid_request_json", detail: error.message });
|
|
250
88
|
return;
|
|
251
89
|
}
|
|
252
90
|
if (error instanceof InvalidRequestShapeError) {
|
|
253
|
-
|
|
91
|
+
safeSendJson(res, 400, { error: "invalid_request_shape", detail: error.message });
|
|
254
92
|
return;
|
|
255
93
|
}
|
|
256
94
|
if (error instanceof ProtocolTranslationError) {
|
|
257
|
-
|
|
95
|
+
safeSendJson(res, 400, { error: error.code, detail: error.message });
|
|
258
96
|
return;
|
|
259
97
|
}
|
|
260
98
|
if (isBenignSocketError(error)) {
|
|
@@ -263,7 +101,7 @@ export async function startProxyServer(input) {
|
|
|
263
101
|
return;
|
|
264
102
|
}
|
|
265
103
|
input.logger.error({ err: error, request_id: requestId }, "request failed");
|
|
266
|
-
|
|
104
|
+
safeSendJson(res, 500, { error: "internal_error" });
|
|
267
105
|
safeEnd(res);
|
|
268
106
|
}
|
|
269
107
|
});
|
|
@@ -300,677 +138,4 @@ export async function startProxyServer(input) {
|
|
|
300
138
|
})
|
|
301
139
|
};
|
|
302
140
|
}
|
|
303
|
-
|
|
304
|
-
let forceRefresh = false;
|
|
305
|
-
let authRefreshRetried = false;
|
|
306
|
-
for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt += 1) {
|
|
307
|
-
if (input.signal?.aborted) {
|
|
308
|
-
throw abortErrorFromSignal(input.signal);
|
|
309
|
-
}
|
|
310
|
-
try {
|
|
311
|
-
const attemptStartedAt = Date.now();
|
|
312
|
-
input.logger.debug({
|
|
313
|
-
event: "upstream_request",
|
|
314
|
-
request_id: input.requestId,
|
|
315
|
-
attempt,
|
|
316
|
-
upstream_path: input.upstreamPath,
|
|
317
|
-
force_refresh: forceRefresh
|
|
318
|
-
}, "posting upstream request");
|
|
319
|
-
const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
|
|
320
|
-
input.logger.debug({
|
|
321
|
-
event: "upstream_response",
|
|
322
|
-
request_id: input.requestId,
|
|
323
|
-
attempt,
|
|
324
|
-
upstream_path: input.upstreamPath,
|
|
325
|
-
status_code: response.status,
|
|
326
|
-
duration_ms: Date.now() - attemptStartedAt,
|
|
327
|
-
content_type: response.headers.get("content-type"),
|
|
328
|
-
retry_after: response.headers.get("retry-after")
|
|
329
|
-
}, "received upstream response");
|
|
330
|
-
forceRefresh = false;
|
|
331
|
-
if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
332
|
-
authRefreshRetried = true;
|
|
333
|
-
forceRefresh = true;
|
|
334
|
-
input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, reason: "upstream_auth_401" }, "retrying upstream request after forced token refresh");
|
|
335
|
-
await discardUpstreamBody(response);
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
if (isRetryableStatus(response.status) && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
339
|
-
input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, status_code: response.status }, "retrying upstream request");
|
|
340
|
-
await discardUpstreamBody(response);
|
|
341
|
-
await sleep(retryDelayMs(attempt));
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
return response;
|
|
345
|
-
}
|
|
346
|
-
catch (error) {
|
|
347
|
-
if (isBenignSocketError(error)) {
|
|
348
|
-
// Client disconnected — propagate so the request handler can clean up.
|
|
349
|
-
throw error;
|
|
350
|
-
}
|
|
351
|
-
if (!isRetryableTransportError(error) || attempt >= MAX_UPSTREAM_ATTEMPTS) {
|
|
352
|
-
throw error;
|
|
353
|
-
}
|
|
354
|
-
input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, reason: "transport_error" }, "retrying upstream request after transport error");
|
|
355
|
-
await sleep(retryDelayMs(attempt));
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
throw new Error("Upstream retry budget exhausted unexpectedly.");
|
|
359
|
-
}
|
|
360
|
-
async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath, signal) {
|
|
361
|
-
const bearer = await tokenManager.ensureToken({ forceRefresh });
|
|
362
|
-
return fetch(`${accountBaseUrl(accountType)}${upstreamPath}`, {
|
|
363
|
-
method: "POST",
|
|
364
|
-
headers: {
|
|
365
|
-
...COPILOT_HEADERS,
|
|
366
|
-
Authorization: `Bearer ${bearer}`,
|
|
367
|
-
"X-Request-Id": requestId
|
|
368
|
-
},
|
|
369
|
-
body: JSON.stringify(body),
|
|
370
|
-
signal
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
function abortErrorFromSignal(signal) {
|
|
374
|
-
const reason = signal.reason;
|
|
375
|
-
if (reason instanceof Error) {
|
|
376
|
-
return reason;
|
|
377
|
-
}
|
|
378
|
-
const err = new Error("Request aborted by client");
|
|
379
|
-
err.name = "AbortError";
|
|
380
|
-
return err;
|
|
381
|
-
}
|
|
382
|
-
async function forwardResponse(upstream, anthroShape, res, diagnostics) {
|
|
383
|
-
if (!upstream.ok) {
|
|
384
|
-
const upstreamError = await readUpstreamError(upstream);
|
|
385
|
-
const category = upstreamStatusCategory(upstream.status);
|
|
386
|
-
diagnostics.logger.warn({
|
|
387
|
-
event: "upstream_non_ok",
|
|
388
|
-
request_id: diagnostics.requestId,
|
|
389
|
-
status_code: upstream.status,
|
|
390
|
-
error: category,
|
|
391
|
-
upstream_content_type: upstreamError.contentType,
|
|
392
|
-
upstream_error_code: upstreamError.code,
|
|
393
|
-
upstream_error_type: upstreamError.type,
|
|
394
|
-
upstream_error_message: upstreamError.message,
|
|
395
|
-
upstream_response_bytes: upstreamError.responseBytes
|
|
396
|
-
}, "upstream request failed");
|
|
397
|
-
const message = formatUpstreamErrorMessage(category, upstreamError);
|
|
398
|
-
const prelude = diagnostics.prelude ?? null;
|
|
399
|
-
if (prelude) {
|
|
400
|
-
writeAnthropicSseError(res, prelude, message);
|
|
401
|
-
return;
|
|
402
|
-
}
|
|
403
|
-
sendJson(res, upstream.status, buildUpstreamErrorPayload(category, upstream.status, diagnostics.requestId, upstreamError, anthroShape));
|
|
404
|
-
return;
|
|
405
|
-
}
|
|
406
|
-
if (isEventStream(upstream)) {
|
|
407
|
-
if (anthroShape) {
|
|
408
|
-
if (!upstream.body) {
|
|
409
|
-
if (diagnostics.prelude) {
|
|
410
|
-
writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
|
|
414
|
-
return;
|
|
415
|
-
}
|
|
416
|
-
if (!diagnostics.prelude) {
|
|
417
|
-
beginAnthropicSseResponse(res);
|
|
418
|
-
}
|
|
419
|
-
const upstreamReadable = Readable.fromWeb(upstream.body);
|
|
420
|
-
await translateOpenAIStreamToAnthropic({
|
|
421
|
-
upstream: upstreamReadable,
|
|
422
|
-
downstream: res,
|
|
423
|
-
fallbackModel: diagnostics.requestedModel,
|
|
424
|
-
preEmittedMessageId: diagnostics.prelude?.messageId
|
|
425
|
-
});
|
|
426
|
-
return;
|
|
427
|
-
}
|
|
428
|
-
await pipeEventStream(upstream, res);
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
if (diagnostics.prelude) {
|
|
432
|
-
writeAnthropicSseError(res, diagnostics.prelude, "invalid_upstream_response");
|
|
433
|
-
return;
|
|
434
|
-
}
|
|
435
|
-
let json;
|
|
436
|
-
try {
|
|
437
|
-
json = (await upstream.json());
|
|
438
|
-
}
|
|
439
|
-
catch {
|
|
440
|
-
sendJson(res, 502, { error: "invalid_upstream_response" });
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
|
-
let payload = json;
|
|
444
|
-
if (anthroShape) {
|
|
445
|
-
try {
|
|
446
|
-
payload = openAIToAnthropic(json);
|
|
447
|
-
}
|
|
448
|
-
catch (error) {
|
|
449
|
-
if (error instanceof ProtocolTranslationError) {
|
|
450
|
-
sendJson(res, 502, { error: error.code, detail: error.message });
|
|
451
|
-
return;
|
|
452
|
-
}
|
|
453
|
-
throw error;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
sendJson(res, 200, payload);
|
|
457
|
-
}
|
|
458
|
-
async function pipeEventStream(upstream, res) {
|
|
459
|
-
if (!upstream.body) {
|
|
460
|
-
sendJson(res, 502, { error: "invalid_upstream_response", detail: "Upstream stream body is missing." });
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
res.statusCode = upstream.status;
|
|
464
|
-
res.setHeader("Content-Type", "text/event-stream");
|
|
465
|
-
const cacheControl = upstream.headers.get("cache-control");
|
|
466
|
-
if (cacheControl) {
|
|
467
|
-
res.setHeader("Cache-Control", cacheControl);
|
|
468
|
-
}
|
|
469
|
-
else {
|
|
470
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
471
|
-
}
|
|
472
|
-
const connection = upstream.headers.get("connection");
|
|
473
|
-
if (connection) {
|
|
474
|
-
res.setHeader("Connection", connection);
|
|
475
|
-
}
|
|
476
|
-
else {
|
|
477
|
-
res.setHeader("Connection", "keep-alive");
|
|
478
|
-
}
|
|
479
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
480
|
-
if (typeof res.flushHeaders === "function") {
|
|
481
|
-
res.flushHeaders();
|
|
482
|
-
}
|
|
483
|
-
if (res.socket && typeof res.socket.setNoDelay === "function") {
|
|
484
|
-
res.socket.setNoDelay(true);
|
|
485
|
-
}
|
|
486
|
-
try {
|
|
487
|
-
await pipeline(Readable.fromWeb(upstream.body), res);
|
|
488
|
-
}
|
|
489
|
-
catch (error) {
|
|
490
|
-
if (isBenignSocketError(error)) {
|
|
491
|
-
// Client went away mid-stream — normal for SSE consumers (Codex,
|
|
492
|
-
// Claude Code, pi) that cancel pending responses on user input.
|
|
493
|
-
return;
|
|
494
|
-
}
|
|
495
|
-
throw error;
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
function isEventStream(upstream) {
|
|
499
|
-
const contentType = upstream.headers.get("content-type");
|
|
500
|
-
return typeof contentType === "string" && contentType.toLowerCase().includes("text/event-stream");
|
|
501
|
-
}
|
|
502
|
-
function isStreamingRequestBody(body) {
|
|
503
|
-
return typeof body === "object" && body !== null && body.stream === true;
|
|
504
|
-
}
|
|
505
|
-
function beginAnthropicSseResponse(res, req) {
|
|
506
|
-
if (res.headersSent) {
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
res.statusCode = 200;
|
|
510
|
-
res.setHeader("Content-Type", "text/event-stream");
|
|
511
|
-
res.setHeader("Cache-Control", "no-cache");
|
|
512
|
-
res.setHeader("Connection", "keep-alive");
|
|
513
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
514
|
-
if (typeof res.flushHeaders === "function") {
|
|
515
|
-
res.flushHeaders();
|
|
516
|
-
}
|
|
517
|
-
const socket = res.socket ?? req?.socket;
|
|
518
|
-
if (socket && typeof socket.setNoDelay === "function") {
|
|
519
|
-
socket.setNoDelay(true);
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
export function writeAnthropicSseError(res, prelude, code) {
|
|
523
|
-
void prelude;
|
|
524
|
-
try {
|
|
525
|
-
safeWrite(res, `event: message_delta\ndata: ${JSON.stringify({
|
|
526
|
-
type: "message_delta",
|
|
527
|
-
delta: { stop_reason: "end_turn", stop_sequence: null },
|
|
528
|
-
usage: { input_tokens: 0, output_tokens: 0, cache_read_input_tokens: 0 }
|
|
529
|
-
})}\n\n`);
|
|
530
|
-
safeWrite(res, `event: error\ndata: ${JSON.stringify({ type: "error", error: { type: "api_error", message: code } })}\n\n`);
|
|
531
|
-
safeWrite(res, `event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n\n`);
|
|
532
|
-
}
|
|
533
|
-
finally {
|
|
534
|
-
safeEnd(res);
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
function isLocalRequest(req) {
|
|
538
|
-
const remote = req.socket.remoteAddress ?? "";
|
|
539
|
-
const local = req.socket.localAddress ?? "";
|
|
540
|
-
return isLoopbackAddress(remote) && (local.length === 0 || isLoopbackAddress(local));
|
|
541
|
-
}
|
|
542
|
-
function isLoopbackAddress(value) {
|
|
543
|
-
return value === "127.0.0.1" || value === "::1" || value === "::ffff:127.0.0.1";
|
|
544
|
-
}
|
|
545
|
-
async function readJson(req) {
|
|
546
|
-
const chunks = [];
|
|
547
|
-
for await (const chunk of req) {
|
|
548
|
-
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
549
|
-
}
|
|
550
|
-
if (chunks.length === 0) {
|
|
551
|
-
return {};
|
|
552
|
-
}
|
|
553
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
554
|
-
try {
|
|
555
|
-
return JSON.parse(text);
|
|
556
|
-
}
|
|
557
|
-
catch (error) {
|
|
558
|
-
if (error instanceof SyntaxError) {
|
|
559
|
-
throw new JsonRequestParseError("Failed to parse JSON body.");
|
|
560
|
-
}
|
|
561
|
-
throw error;
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
function sendJson(res, status, payload) {
|
|
565
|
-
safeSendJson(res, status, payload);
|
|
566
|
-
}
|
|
567
|
-
function readRequestedModel(payload) {
|
|
568
|
-
if (!payload || typeof payload !== "object") {
|
|
569
|
-
return null;
|
|
570
|
-
}
|
|
571
|
-
const maybeModel = payload.model;
|
|
572
|
-
return typeof maybeModel === "string" ? maybeModel : null;
|
|
573
|
-
}
|
|
574
|
-
function rewriteRequestedModel(payload, model) {
|
|
575
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
576
|
-
return payload;
|
|
577
|
-
}
|
|
578
|
-
return {
|
|
579
|
-
...payload,
|
|
580
|
-
model
|
|
581
|
-
};
|
|
582
|
-
}
|
|
583
|
-
function translateRequestBody(routeKind, body) {
|
|
584
|
-
if (routeKind !== "anthropic") {
|
|
585
|
-
return normaliseAliasedModelInPlace(body);
|
|
586
|
-
}
|
|
587
|
-
try {
|
|
588
|
-
return anthropicToOpenAI(body);
|
|
589
|
-
}
|
|
590
|
-
catch (error) {
|
|
591
|
-
if (error instanceof Error) {
|
|
592
|
-
throw new InvalidRequestShapeError(error.message);
|
|
593
|
-
}
|
|
594
|
-
throw new InvalidRequestShapeError("Invalid Anthropic request body.");
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* Defensive strip of the `[1m]` alias for pass-through routes (OpenAI chat
|
|
599
|
-
* completions, Codex responses). The Anthropic route already strips inside
|
|
600
|
-
* `anthropicToOpenAI`; this catches anything that might land on the
|
|
601
|
-
* pass-through paths with a hand-pasted aliased id so upstream Copilot
|
|
602
|
-
* always receives the canonical model id.
|
|
603
|
-
*/
|
|
604
|
-
function normaliseAliasedModelInPlace(body) {
|
|
605
|
-
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
606
|
-
const record = body;
|
|
607
|
-
if (typeof record.model === "string") {
|
|
608
|
-
const stripped = stripOneMillionAlias(record.model);
|
|
609
|
-
if (stripped !== record.model) {
|
|
610
|
-
record.model = stripped;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
return body;
|
|
615
|
-
}
|
|
616
|
-
async function handleDebug(res, input) {
|
|
617
|
-
const bearerTtlSeconds = input.tokenManager.expiresInSeconds();
|
|
618
|
-
const uptimeSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(DAEMON_STARTED_AT_ISO)) / 1_000));
|
|
619
|
-
let user = null;
|
|
620
|
-
let userError = null;
|
|
621
|
-
if (input.githubToken) {
|
|
622
|
-
try {
|
|
623
|
-
const summary = await getGithubUserSummary(input.githubToken);
|
|
624
|
-
user = {
|
|
625
|
-
login: summary.login,
|
|
626
|
-
id: summary.id,
|
|
627
|
-
type: summary.type
|
|
628
|
-
};
|
|
629
|
-
}
|
|
630
|
-
catch (error) {
|
|
631
|
-
if (error instanceof GithubUserFetchError) {
|
|
632
|
-
userError = `github_user_lookup_failed_${error.status}`;
|
|
633
|
-
}
|
|
634
|
-
else {
|
|
635
|
-
userError = error instanceof Error ? error.message : "unknown_error";
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
else {
|
|
640
|
-
userError = "github_token_unavailable_in_proxy";
|
|
641
|
-
}
|
|
642
|
-
sendJson(res, 200, {
|
|
643
|
-
server: {
|
|
644
|
-
port: input.port,
|
|
645
|
-
pid: process.pid,
|
|
646
|
-
node_version: process.version,
|
|
647
|
-
started_at_iso: DAEMON_STARTED_AT_ISO,
|
|
648
|
-
uptime_seconds: uptimeSeconds,
|
|
649
|
-
account_type: input.config.accountType,
|
|
650
|
-
selected_models: input.config.selectedModels,
|
|
651
|
-
require_caller_secret: input.config.requireCallerSecret,
|
|
652
|
-
log_level: input.logger.level,
|
|
653
|
-
log_file: process.env.COPILLM_LOG_FILE ?? null
|
|
654
|
-
},
|
|
655
|
-
auth: {
|
|
656
|
-
bearer_ttl_seconds: bearerTtlSeconds,
|
|
657
|
-
bearer_present: input.tokenManager.current !== null,
|
|
658
|
-
bearer_expires_at_unix: input.tokenManager.current?.expiresAtUnix ?? null
|
|
659
|
-
},
|
|
660
|
-
user,
|
|
661
|
-
user_error: userError,
|
|
662
|
-
routes: [
|
|
663
|
-
"GET /livez",
|
|
664
|
-
"GET /healthz",
|
|
665
|
-
"GET /models",
|
|
666
|
-
"GET /v1/models",
|
|
667
|
-
"GET /codex/v1/models",
|
|
668
|
-
"GET /anthropic/v1/models",
|
|
669
|
-
"POST /codex/v1/responses",
|
|
670
|
-
"POST /v1/chat/completions",
|
|
671
|
-
"POST /v1/messages",
|
|
672
|
-
"POST /anthropic/v1/messages",
|
|
673
|
-
"GET /_debug"
|
|
674
|
-
],
|
|
675
|
-
debug_enabled: true
|
|
676
|
-
});
|
|
677
|
-
}
|
|
678
|
-
function resolveRoute(method, rawUrl) {
|
|
679
|
-
if (!method || !rawUrl) {
|
|
680
|
-
return { kind: "not_found", anthroShape: false };
|
|
681
|
-
}
|
|
682
|
-
let pathname;
|
|
683
|
-
try {
|
|
684
|
-
pathname = new URL(rawUrl, "http://127.0.0.1").pathname;
|
|
685
|
-
}
|
|
686
|
-
catch {
|
|
687
|
-
return { kind: "not_found", anthroShape: false };
|
|
688
|
-
}
|
|
689
|
-
if (method === "GET" && pathname === "/livez") {
|
|
690
|
-
return { kind: "livez", anthroShape: false };
|
|
691
|
-
}
|
|
692
|
-
if (method === "GET" && pathname === "/healthz") {
|
|
693
|
-
return { kind: "healthz", anthroShape: false };
|
|
694
|
-
}
|
|
695
|
-
if (method === "GET" && (pathname === "/models" || pathname === "/v1/models")) {
|
|
696
|
-
return { kind: "models", anthroShape: false };
|
|
697
|
-
}
|
|
698
|
-
if (method === "GET" && pathname === "/codex/v1/models") {
|
|
699
|
-
return { kind: "codex_models", anthroShape: false };
|
|
700
|
-
}
|
|
701
|
-
if (method === "GET" && pathname === "/anthropic/v1/models") {
|
|
702
|
-
return { kind: "anthropic_models", anthroShape: false };
|
|
703
|
-
}
|
|
704
|
-
if (method === "POST" && pathname === "/codex/v1/responses") {
|
|
705
|
-
return { kind: "codex_responses", anthroShape: false };
|
|
706
|
-
}
|
|
707
|
-
if (method === "GET" && pathname === "/_debug") {
|
|
708
|
-
return { kind: "debug", anthroShape: false };
|
|
709
|
-
}
|
|
710
|
-
if (method === "POST" && pathname === "/v1/chat/completions") {
|
|
711
|
-
return { kind: "openai", anthroShape: false };
|
|
712
|
-
}
|
|
713
|
-
if (method === "POST" && (pathname === "/anthropic/v1/messages" || pathname === "/v1/messages")) {
|
|
714
|
-
return { kind: "anthropic", anthroShape: true };
|
|
715
|
-
}
|
|
716
|
-
return { kind: "not_found", anthroShape: false };
|
|
717
|
-
}
|
|
718
|
-
function isRetryableStatus(status) {
|
|
719
|
-
return RETRYABLE_UPSTREAM_STATUSES.has(status);
|
|
720
|
-
}
|
|
721
|
-
function retryDelayMs(attempt) {
|
|
722
|
-
return BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
723
|
-
}
|
|
724
|
-
function isRetryableTransportError(error) {
|
|
725
|
-
if (!error || typeof error !== "object") {
|
|
726
|
-
return false;
|
|
727
|
-
}
|
|
728
|
-
const typedError = error;
|
|
729
|
-
const directCode = typedError.code?.toUpperCase();
|
|
730
|
-
const causeCode = typedError.cause?.code?.toUpperCase();
|
|
731
|
-
if (directCode === "ECONNRESET" || directCode === "ECONNREFUSED" || directCode === "ETIMEDOUT") {
|
|
732
|
-
return true;
|
|
733
|
-
}
|
|
734
|
-
if (causeCode === "ECONNRESET" || causeCode === "ECONNREFUSED" || causeCode === "ETIMEDOUT") {
|
|
735
|
-
return true;
|
|
736
|
-
}
|
|
737
|
-
if (!(typedError instanceof Error)) {
|
|
738
|
-
return false;
|
|
739
|
-
}
|
|
740
|
-
const message = typedError.message.toLowerCase();
|
|
741
|
-
if (message.includes("timed out") || message.includes("timeout")) {
|
|
742
|
-
return true;
|
|
743
|
-
}
|
|
744
|
-
return message.includes("econnreset") || message.includes("econnrefused") || message.includes("enotfound");
|
|
745
|
-
}
|
|
746
|
-
function upstreamStatusCategory(status) {
|
|
747
|
-
if (status === 401 || status === 403) {
|
|
748
|
-
return "upstream_auth_error";
|
|
749
|
-
}
|
|
750
|
-
if (status === 429) {
|
|
751
|
-
return "upstream_rate_limited";
|
|
752
|
-
}
|
|
753
|
-
if (status >= 500) {
|
|
754
|
-
return "upstream_server_error";
|
|
755
|
-
}
|
|
756
|
-
if (status >= 400) {
|
|
757
|
-
return "upstream_request_error";
|
|
758
|
-
}
|
|
759
|
-
return "upstream_error";
|
|
760
|
-
}
|
|
761
|
-
async function readUpstreamError(response) {
|
|
762
|
-
const contentType = response.headers.get("content-type");
|
|
763
|
-
let text;
|
|
764
|
-
try {
|
|
765
|
-
text = await response.text();
|
|
766
|
-
}
|
|
767
|
-
catch {
|
|
768
|
-
return { contentType, code: null, type: null, message: null, responseBytes: null };
|
|
769
|
-
}
|
|
770
|
-
const trimmed = text.trim();
|
|
771
|
-
const responseBytes = Buffer.byteLength(text, "utf8");
|
|
772
|
-
if (trimmed.length === 0) {
|
|
773
|
-
return { contentType, code: null, type: null, message: null, responseBytes };
|
|
774
|
-
}
|
|
775
|
-
try {
|
|
776
|
-
const parsed = JSON.parse(trimmed);
|
|
777
|
-
const extracted = extractErrorFields(parsed);
|
|
778
|
-
if (extracted.message || extracted.code || extracted.type) {
|
|
779
|
-
return { contentType, responseBytes, ...extracted };
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
catch {
|
|
783
|
-
// Fall through to a plain-text snippet below.
|
|
784
|
-
}
|
|
785
|
-
return {
|
|
786
|
-
contentType,
|
|
787
|
-
code: null,
|
|
788
|
-
type: null,
|
|
789
|
-
message: truncateForDiagnostics(trimmed),
|
|
790
|
-
responseBytes
|
|
791
|
-
};
|
|
792
|
-
}
|
|
793
|
-
function extractErrorFields(payload) {
|
|
794
|
-
if (!payload || typeof payload !== "object") {
|
|
795
|
-
return { code: null, type: null, message: typeof payload === "string" ? truncateForDiagnostics(payload) : null };
|
|
796
|
-
}
|
|
797
|
-
const record = payload;
|
|
798
|
-
const nested = record.error;
|
|
799
|
-
if (typeof nested === "string") {
|
|
800
|
-
return {
|
|
801
|
-
code: readStringField(record, "code"),
|
|
802
|
-
type: readStringField(record, "type"),
|
|
803
|
-
message: truncateForDiagnostics(nested)
|
|
804
|
-
};
|
|
805
|
-
}
|
|
806
|
-
if (nested && typeof nested === "object") {
|
|
807
|
-
const errorRecord = nested;
|
|
808
|
-
return {
|
|
809
|
-
code: readStringField(errorRecord, "code") ?? readStringField(record, "code"),
|
|
810
|
-
type: readStringField(errorRecord, "type") ?? readStringField(record, "type"),
|
|
811
|
-
message: readTruncatedStringField(errorRecord, "message") ??
|
|
812
|
-
readTruncatedStringField(errorRecord, "detail") ??
|
|
813
|
-
readTruncatedStringField(record, "message") ??
|
|
814
|
-
readTruncatedStringField(record, "detail")
|
|
815
|
-
};
|
|
816
|
-
}
|
|
817
|
-
return {
|
|
818
|
-
code: readStringField(record, "code"),
|
|
819
|
-
type: readStringField(record, "type"),
|
|
820
|
-
message: readTruncatedStringField(record, "message") ?? readTruncatedStringField(record, "detail")
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
function buildUpstreamErrorPayload(category, statusCode, requestId, upstreamError, anthroShape) {
|
|
824
|
-
const code = upstreamError.code ?? category;
|
|
825
|
-
const type = upstreamError.type ?? category;
|
|
826
|
-
const message = formatUserFacingUpstreamErrorMessage(category, upstreamError);
|
|
827
|
-
if (anthroShape) {
|
|
828
|
-
return {
|
|
829
|
-
type: "error",
|
|
830
|
-
error: {
|
|
831
|
-
type,
|
|
832
|
-
message,
|
|
833
|
-
code,
|
|
834
|
-
upstream_status_code: statusCode,
|
|
835
|
-
request_id: requestId
|
|
836
|
-
}
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
return {
|
|
840
|
-
error: {
|
|
841
|
-
type,
|
|
842
|
-
code,
|
|
843
|
-
message,
|
|
844
|
-
upstream_status_code: statusCode,
|
|
845
|
-
request_id: requestId
|
|
846
|
-
}
|
|
847
|
-
};
|
|
848
|
-
}
|
|
849
|
-
function formatUpstreamErrorMessage(category, upstreamError) {
|
|
850
|
-
const parts = [upstreamError.code, upstreamError.type, upstreamError.message].filter((part) => typeof part === "string" && part.length > 0);
|
|
851
|
-
return parts.length > 0 ? `${category}: ${parts.join(": ")}` : category;
|
|
852
|
-
}
|
|
853
|
-
function formatUserFacingUpstreamErrorMessage(category, upstreamError) {
|
|
854
|
-
if (upstreamError.code && upstreamError.message) {
|
|
855
|
-
return `${upstreamError.code}: ${upstreamError.message}`;
|
|
856
|
-
}
|
|
857
|
-
if (upstreamError.message) {
|
|
858
|
-
return upstreamError.message;
|
|
859
|
-
}
|
|
860
|
-
return upstreamError.code ?? upstreamError.type ?? category;
|
|
861
|
-
}
|
|
862
|
-
function readStringField(record, key) {
|
|
863
|
-
const value = record[key];
|
|
864
|
-
return typeof value === "string" && value.length > 0 ? value : null;
|
|
865
|
-
}
|
|
866
|
-
function readTruncatedStringField(record, key) {
|
|
867
|
-
const value = readStringField(record, key);
|
|
868
|
-
return value ? truncateForDiagnostics(value) : null;
|
|
869
|
-
}
|
|
870
|
-
function truncateForDiagnostics(value) {
|
|
871
|
-
const maxChars = 500;
|
|
872
|
-
return value.length > maxChars ? `${value.slice(0, maxChars)}...` : value;
|
|
873
|
-
}
|
|
874
|
-
function summarizeUpstreamPayload(payload) {
|
|
875
|
-
let requestBytes = null;
|
|
876
|
-
try {
|
|
877
|
-
requestBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
|
878
|
-
}
|
|
879
|
-
catch {
|
|
880
|
-
requestBytes = null;
|
|
881
|
-
}
|
|
882
|
-
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
883
|
-
return { upstream_request_bytes: requestBytes };
|
|
884
|
-
}
|
|
885
|
-
const record = payload;
|
|
886
|
-
const messages = Array.isArray(record.messages) ? record.messages : null;
|
|
887
|
-
const input = Array.isArray(record.input) ? record.input : null;
|
|
888
|
-
return {
|
|
889
|
-
upstream_request_bytes: requestBytes,
|
|
890
|
-
stream: record.stream === true,
|
|
891
|
-
max_tokens: typeof record.max_tokens === "number" ? record.max_tokens : null,
|
|
892
|
-
message_count: messages?.length ?? null,
|
|
893
|
-
input_item_count: input?.length ?? null,
|
|
894
|
-
tool_count: Array.isArray(record.tools) ? record.tools.length : 0,
|
|
895
|
-
text_characters: sumTextCharacters(payload)
|
|
896
|
-
};
|
|
897
|
-
}
|
|
898
|
-
function sumTextCharacters(value) {
|
|
899
|
-
if (typeof value === "string") {
|
|
900
|
-
return value.length;
|
|
901
|
-
}
|
|
902
|
-
if (!value || typeof value !== "object") {
|
|
903
|
-
return 0;
|
|
904
|
-
}
|
|
905
|
-
if (Array.isArray(value)) {
|
|
906
|
-
return value.reduce((total, item) => total + sumTextCharacters(item), 0);
|
|
907
|
-
}
|
|
908
|
-
let total = 0;
|
|
909
|
-
const record = value;
|
|
910
|
-
for (const [key, nested] of Object.entries(record)) {
|
|
911
|
-
if (key === "text" || key === "content" || key === "arguments" || key === "input") {
|
|
912
|
-
total += sumTextCharacters(nested);
|
|
913
|
-
}
|
|
914
|
-
else if (nested && typeof nested === "object" && key !== "data" && key !== "image_url" && key !== "source") {
|
|
915
|
-
total += sumTextCharacters(nested);
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
return total;
|
|
919
|
-
}
|
|
920
|
-
function healthFailure(error) {
|
|
921
|
-
if (error instanceof CopilotTokenExchangeError) {
|
|
922
|
-
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
923
|
-
return {
|
|
924
|
-
httpStatus: 401,
|
|
925
|
-
payload: {
|
|
926
|
-
status: "unauthenticated",
|
|
927
|
-
error: "github_auth_invalid",
|
|
928
|
-
upstream_status_code: error.statusCode
|
|
929
|
-
}
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
return {
|
|
933
|
-
httpStatus: 503,
|
|
934
|
-
payload: {
|
|
935
|
-
status: "upstream_unreachable",
|
|
936
|
-
error: "token_exchange_failed",
|
|
937
|
-
upstream_status_code: error.statusCode
|
|
938
|
-
}
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
if (error instanceof CopilotTokenManagerError) {
|
|
942
|
-
return {
|
|
943
|
-
httpStatus: 401,
|
|
944
|
-
payload: {
|
|
945
|
-
status: "unauthenticated",
|
|
946
|
-
error: "token_refresh_failed"
|
|
947
|
-
}
|
|
948
|
-
};
|
|
949
|
-
}
|
|
950
|
-
return {
|
|
951
|
-
httpStatus: 503,
|
|
952
|
-
payload: {
|
|
953
|
-
status: "upstream_unreachable",
|
|
954
|
-
error: "token_refresh_unavailable"
|
|
955
|
-
}
|
|
956
|
-
};
|
|
957
|
-
}
|
|
958
|
-
function safePathname(rawUrl) {
|
|
959
|
-
if (!rawUrl) {
|
|
960
|
-
return "/";
|
|
961
|
-
}
|
|
962
|
-
try {
|
|
963
|
-
return new URL(rawUrl, "http://127.0.0.1").pathname;
|
|
964
|
-
}
|
|
965
|
-
catch {
|
|
966
|
-
return "/";
|
|
967
|
-
}
|
|
968
|
-
}
|
|
969
|
-
async function discardUpstreamBody(response) {
|
|
970
|
-
try {
|
|
971
|
-
await response.arrayBuffer();
|
|
972
|
-
}
|
|
973
|
-
catch {
|
|
974
|
-
// ignore body drain failures
|
|
975
|
-
}
|
|
976
|
-
}
|
|
141
|
+
export { writeAnthropicSseError } from "./errors.js";
|