copillm 0.2.2 → 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 -1348
- 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
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getGithubUserSummary, GithubUserFetchError } from "../debugInfo.js";
|
|
2
|
+
import { safeSendJson } from "../requestLifecycle.js";
|
|
3
|
+
const DAEMON_STARTED_AT_ISO = new Date().toISOString();
|
|
4
|
+
export async function handleDebug(res, input) {
|
|
5
|
+
const bearerTtlSeconds = input.tokenManager.expiresInSeconds();
|
|
6
|
+
const uptimeSeconds = Math.max(0, Math.floor((Date.now() - Date.parse(DAEMON_STARTED_AT_ISO)) / 1_000));
|
|
7
|
+
let user = null;
|
|
8
|
+
let userError = null;
|
|
9
|
+
if (input.githubToken) {
|
|
10
|
+
try {
|
|
11
|
+
const summary = await getGithubUserSummary(input.githubToken);
|
|
12
|
+
user = {
|
|
13
|
+
login: summary.login,
|
|
14
|
+
id: summary.id,
|
|
15
|
+
type: summary.type
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (error instanceof GithubUserFetchError) {
|
|
20
|
+
userError = `github_user_lookup_failed_${error.status}`;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
userError = error instanceof Error ? error.message : "unknown_error";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
userError = "github_token_unavailable_in_proxy";
|
|
29
|
+
}
|
|
30
|
+
safeSendJson(res, 200, {
|
|
31
|
+
server: {
|
|
32
|
+
port: input.port,
|
|
33
|
+
pid: process.pid,
|
|
34
|
+
node_version: process.version,
|
|
35
|
+
started_at_iso: DAEMON_STARTED_AT_ISO,
|
|
36
|
+
uptime_seconds: uptimeSeconds,
|
|
37
|
+
account_type: input.config.accountType,
|
|
38
|
+
selected_models: input.config.selectedModels,
|
|
39
|
+
require_caller_secret: input.config.requireCallerSecret,
|
|
40
|
+
log_level: input.logger.level,
|
|
41
|
+
log_file: process.env.COPILLM_LOG_FILE ?? null
|
|
42
|
+
},
|
|
43
|
+
auth: {
|
|
44
|
+
bearer_ttl_seconds: bearerTtlSeconds,
|
|
45
|
+
bearer_present: input.tokenManager.current !== null,
|
|
46
|
+
bearer_expires_at_unix: input.tokenManager.current?.expiresAtUnix ?? null
|
|
47
|
+
},
|
|
48
|
+
user,
|
|
49
|
+
user_error: userError,
|
|
50
|
+
routes: [
|
|
51
|
+
"GET /livez",
|
|
52
|
+
"GET /healthz",
|
|
53
|
+
"GET /models",
|
|
54
|
+
"GET /v1/models",
|
|
55
|
+
"GET /codex/v1/models",
|
|
56
|
+
"GET /anthropic/v1/models",
|
|
57
|
+
"POST /codex/v1/responses",
|
|
58
|
+
"POST /v1/chat/completions",
|
|
59
|
+
"POST /v1/messages",
|
|
60
|
+
"POST /anthropic/v1/messages",
|
|
61
|
+
"GET /_debug"
|
|
62
|
+
],
|
|
63
|
+
debug_enabled: true
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { healthFailure } from "../errors.js";
|
|
2
|
+
import { safeSendJson } from "../requestLifecycle.js";
|
|
3
|
+
const HEALTH_REFRESH_THRESHOLD_SECONDS = 60;
|
|
4
|
+
export function handleLivez(res) {
|
|
5
|
+
safeSendJson(res, 200, { status: "ok", uptime_seconds: Math.floor(process.uptime()) });
|
|
6
|
+
}
|
|
7
|
+
export async function handleHealthz(res, tokenManager) {
|
|
8
|
+
const ttl = tokenManager.expiresInSeconds();
|
|
9
|
+
if (ttl !== null && ttl > HEALTH_REFRESH_THRESHOLD_SECONDS) {
|
|
10
|
+
safeSendJson(res, 200, {
|
|
11
|
+
status: "ok",
|
|
12
|
+
token_state: "fresh",
|
|
13
|
+
refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
|
|
14
|
+
bearer_ttl_seconds: ttl
|
|
15
|
+
});
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
await tokenManager.ensureToken({ refreshThresholdSeconds: HEALTH_REFRESH_THRESHOLD_SECONDS });
|
|
20
|
+
const refreshedTtl = tokenManager.expiresInSeconds() ?? 0;
|
|
21
|
+
safeSendJson(res, 200, {
|
|
22
|
+
status: "ok",
|
|
23
|
+
token_state: "refreshed",
|
|
24
|
+
refresh_threshold_seconds: HEALTH_REFRESH_THRESHOLD_SECONDS,
|
|
25
|
+
bearer_ttl_seconds: refreshedTtl
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
const failed = healthFailure(error);
|
|
30
|
+
safeSendJson(res, failed.httpStatus, failed.payload);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { CopilotTokenManagerError } from "../../auth/copilotToken.js";
|
|
2
|
+
import { listModels, listModelsUnion } from "../../models/discovery.js";
|
|
3
|
+
import { buildCodexCatalog } from "../codexSchema.js";
|
|
4
|
+
import { buildAnthropicModelsResponse } from "../anthropicModelsResponse.js";
|
|
5
|
+
import { safeSendJson } from "../requestLifecycle.js";
|
|
6
|
+
export async function handleModels(res, routeKind, config, tokenManager, githubToken) {
|
|
7
|
+
try {
|
|
8
|
+
await tokenManager.ensureToken(false);
|
|
9
|
+
if (!githubToken) {
|
|
10
|
+
safeSendJson(res, 503, { error: "github_token_unavailable" });
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const result = routeKind === "codex_models" || routeKind === "anthropic_models"
|
|
14
|
+
? await listModelsUnion(config.accountType, githubToken, 3)
|
|
15
|
+
: await listModels(config.accountType, githubToken);
|
|
16
|
+
if (routeKind === "codex_models") {
|
|
17
|
+
safeSendJson(res, 200, buildCodexCatalog(result.models));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (routeKind === "anthropic_models") {
|
|
21
|
+
safeSendJson(res, 200, buildAnthropicModelsResponse(result.models));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
safeSendJson(res, 200, {
|
|
25
|
+
models: result.models,
|
|
26
|
+
discovery: {
|
|
27
|
+
source: result.source,
|
|
28
|
+
stale: result.stale,
|
|
29
|
+
cache_age_seconds: result.cacheAgeSeconds,
|
|
30
|
+
warning: result.warning
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
if (error instanceof CopilotTokenManagerError) {
|
|
36
|
+
safeSendJson(res, 503, { error: "token_refresh_failed" });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { resolveModelId } from "../../models/discovery.js";
|
|
2
|
+
import { CopilotTokenManagerError } from "../../auth/copilotToken.js";
|
|
3
|
+
import { anthropicToOpenAI } from "../../translation/openaiAnthropic.js";
|
|
4
|
+
import { writeAnthropicPrelude } from "../../translation/streamingOpenAIToAnthropic.js";
|
|
5
|
+
import { isBenignSocketError, safeEnd, safeSendJson } from "../requestLifecycle.js";
|
|
6
|
+
import { InvalidRequestShapeError, writeAnthropicSseError } from "../errors.js";
|
|
7
|
+
import { postToCopilot } from "../upstream/copilotClient.js";
|
|
8
|
+
import { beginAnthropicSseResponse, forwardResponse, isStreamingRequestBody } from "../upstream/streaming.js";
|
|
9
|
+
import { normaliseAliasedModelInPlace, readJson, readRequestedModel, rewriteRequestedModel, summarizeUpstreamPayload } from "./shared.js";
|
|
10
|
+
function translateRequestBody(routeKind, body) {
|
|
11
|
+
if (routeKind !== "anthropic") {
|
|
12
|
+
return normaliseAliasedModelInPlace(body);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
return anthropicToOpenAI(body);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error) {
|
|
19
|
+
throw new InvalidRequestShapeError(error.message);
|
|
20
|
+
}
|
|
21
|
+
throw new InvalidRequestShapeError("Invalid Anthropic request body.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function handleProxyForward(input) {
|
|
25
|
+
const { req, res, route, config, tokenManager, logger, requestId, signal } = input;
|
|
26
|
+
const requestBody = await readJson(req);
|
|
27
|
+
const translatedBody = translateRequestBody(route.kind, requestBody);
|
|
28
|
+
const requestedModel = readRequestedModel(translatedBody);
|
|
29
|
+
if (config.selectedModels.length > 0 && !requestedModel) {
|
|
30
|
+
safeSendJson(res, 400, {
|
|
31
|
+
error: "model_not_selected",
|
|
32
|
+
detail: "Requested model is not enabled in local selection."
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let resolvedModel = null;
|
|
37
|
+
try {
|
|
38
|
+
resolvedModel = requestedModel ? resolveModelId(requestedModel, config.selectedModels) : null;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
const detail = error instanceof Error ? error.message : "Model resolution failed.";
|
|
42
|
+
safeSendJson(res, 400, { error: "ambiguous_model_selection", detail });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (config.selectedModels.length > 0 && !resolvedModel) {
|
|
46
|
+
safeSendJson(res, 400, {
|
|
47
|
+
error: "model_not_selected",
|
|
48
|
+
detail: "Requested model is not enabled in local selection."
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const upstreamBody = resolvedModel ? rewriteRequestedModel(translatedBody, resolvedModel.id) : translatedBody;
|
|
53
|
+
const upstreamPath = route.kind === "codex_responses" ? "/responses" : "/chat/completions";
|
|
54
|
+
const isAnthropicStreaming = route.anthroShape && isStreamingRequestBody(translatedBody);
|
|
55
|
+
let prelude = null;
|
|
56
|
+
if (isAnthropicStreaming) {
|
|
57
|
+
beginAnthropicSseResponse(res, req);
|
|
58
|
+
prelude = writeAnthropicPrelude(res, requestedModel ?? "");
|
|
59
|
+
}
|
|
60
|
+
logger.debug({
|
|
61
|
+
event: "request_prepared",
|
|
62
|
+
request_id: requestId,
|
|
63
|
+
route: route.kind,
|
|
64
|
+
anthro_shape: route.anthroShape,
|
|
65
|
+
requested_model: requestedModel,
|
|
66
|
+
upstream_model: readRequestedModel(upstreamBody),
|
|
67
|
+
model_resolution_rule: resolvedModel?.rule ?? null,
|
|
68
|
+
upstream_path: upstreamPath,
|
|
69
|
+
...summarizeUpstreamPayload(upstreamBody)
|
|
70
|
+
}, "prepared upstream request");
|
|
71
|
+
try {
|
|
72
|
+
const upstream = await postToCopilot({
|
|
73
|
+
tokenManager,
|
|
74
|
+
accountType: config.accountType,
|
|
75
|
+
body: upstreamBody,
|
|
76
|
+
requestId,
|
|
77
|
+
logger,
|
|
78
|
+
upstreamPath,
|
|
79
|
+
signal
|
|
80
|
+
});
|
|
81
|
+
await forwardResponse(upstream, route.anthroShape, res, {
|
|
82
|
+
requestedModel: requestedModel ?? undefined,
|
|
83
|
+
prelude,
|
|
84
|
+
logger,
|
|
85
|
+
requestId
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (isBenignSocketError(error)) {
|
|
90
|
+
logger.debug({ event: "upstream_aborted", request_id: requestId, err: error }, "upstream request aborted (client disconnected)");
|
|
91
|
+
safeEnd(res);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (error instanceof CopilotTokenManagerError) {
|
|
95
|
+
if (prelude) {
|
|
96
|
+
writeAnthropicSseError(res, prelude, "token_refresh_failed");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
safeSendJson(res, 503, { error: "token_refresh_failed" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (prelude) {
|
|
103
|
+
writeAnthropicSseError(res, prelude, "internal_error");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { stripOneMillionAlias } from "../../translation/openaiAnthropic.js";
|
|
2
|
+
import { JsonRequestParseError } from "../errors.js";
|
|
3
|
+
export async function readJson(req) {
|
|
4
|
+
const chunks = [];
|
|
5
|
+
for await (const chunk of req) {
|
|
6
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
7
|
+
}
|
|
8
|
+
if (chunks.length === 0) {
|
|
9
|
+
return {};
|
|
10
|
+
}
|
|
11
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(text);
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (error instanceof SyntaxError) {
|
|
17
|
+
throw new JsonRequestParseError("Failed to parse JSON body.");
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function readRequestedModel(payload) {
|
|
23
|
+
if (!payload || typeof payload !== "object") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const maybeModel = payload.model;
|
|
27
|
+
return typeof maybeModel === "string" ? maybeModel : null;
|
|
28
|
+
}
|
|
29
|
+
export function rewriteRequestedModel(payload, model) {
|
|
30
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
31
|
+
return payload;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
...payload,
|
|
35
|
+
model
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Defensive strip of the `[1m]` alias for pass-through routes (OpenAI chat
|
|
40
|
+
* completions, Codex responses). The Anthropic route already strips inside
|
|
41
|
+
* `anthropicToOpenAI`; this catches anything that might land on the
|
|
42
|
+
* pass-through paths with a hand-pasted aliased id so upstream Copilot
|
|
43
|
+
* always receives the canonical model id.
|
|
44
|
+
*/
|
|
45
|
+
export function normaliseAliasedModelInPlace(body) {
|
|
46
|
+
if (body && typeof body === "object" && !Array.isArray(body)) {
|
|
47
|
+
const record = body;
|
|
48
|
+
if (typeof record.model === "string") {
|
|
49
|
+
const stripped = stripOneMillionAlias(record.model);
|
|
50
|
+
if (stripped !== record.model) {
|
|
51
|
+
record.model = stripped;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return body;
|
|
56
|
+
}
|
|
57
|
+
export function summarizeUpstreamPayload(payload) {
|
|
58
|
+
let requestBytes = null;
|
|
59
|
+
try {
|
|
60
|
+
requestBytes = Buffer.byteLength(JSON.stringify(payload), "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
requestBytes = null;
|
|
64
|
+
}
|
|
65
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
66
|
+
return { upstream_request_bytes: requestBytes };
|
|
67
|
+
}
|
|
68
|
+
const record = payload;
|
|
69
|
+
const messages = Array.isArray(record.messages) ? record.messages : null;
|
|
70
|
+
const input = Array.isArray(record.input) ? record.input : null;
|
|
71
|
+
return {
|
|
72
|
+
upstream_request_bytes: requestBytes,
|
|
73
|
+
stream: record.stream === true,
|
|
74
|
+
max_tokens: typeof record.max_tokens === "number" ? record.max_tokens : null,
|
|
75
|
+
message_count: messages?.length ?? null,
|
|
76
|
+
input_item_count: input?.length ?? null,
|
|
77
|
+
tool_count: Array.isArray(record.tools) ? record.tools.length : 0,
|
|
78
|
+
text_characters: sumTextCharacters(payload)
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function sumTextCharacters(value) {
|
|
82
|
+
if (typeof value === "string") {
|
|
83
|
+
return value.length;
|
|
84
|
+
}
|
|
85
|
+
if (!value || typeof value !== "object") {
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(value)) {
|
|
89
|
+
return value.reduce((total, item) => total + sumTextCharacters(item), 0);
|
|
90
|
+
}
|
|
91
|
+
let total = 0;
|
|
92
|
+
const record = value;
|
|
93
|
+
for (const [key, nested] of Object.entries(record)) {
|
|
94
|
+
if (key === "text" || key === "content" || key === "arguments" || key === "input") {
|
|
95
|
+
total += sumTextCharacters(nested);
|
|
96
|
+
}
|
|
97
|
+
else if (nested && typeof nested === "object" && key !== "data" && key !== "image_url" && key !== "source") {
|
|
98
|
+
total += sumTextCharacters(nested);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return total;
|
|
102
|
+
}
|
|
103
|
+
export function isLocalRequest(req) {
|
|
104
|
+
const remote = req.socket.remoteAddress ?? "";
|
|
105
|
+
const local = req.socket.localAddress ?? "";
|
|
106
|
+
return isLoopbackAddress(remote) && (local.length === 0 || isLoopbackAddress(local));
|
|
107
|
+
}
|
|
108
|
+
function isLoopbackAddress(value) {
|
|
109
|
+
return value === "127.0.0.1" || value === "::1" || value === "::ffff:127.0.0.1";
|
|
110
|
+
}
|
|
111
|
+
export function safePathname(rawUrl) {
|
|
112
|
+
if (!rawUrl) {
|
|
113
|
+
return "/";
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
return new URL(rawUrl, "http://127.0.0.1").pathname;
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return "/";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
export function resolveRoute(method, rawUrl) {
|
|
123
|
+
if (!method || !rawUrl) {
|
|
124
|
+
return { kind: "not_found", anthroShape: false };
|
|
125
|
+
}
|
|
126
|
+
let pathname;
|
|
127
|
+
try {
|
|
128
|
+
pathname = new URL(rawUrl, "http://127.0.0.1").pathname;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return { kind: "not_found", anthroShape: false };
|
|
132
|
+
}
|
|
133
|
+
if (method === "GET" && pathname === "/livez") {
|
|
134
|
+
return { kind: "livez", anthroShape: false };
|
|
135
|
+
}
|
|
136
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
137
|
+
return { kind: "healthz", anthroShape: false };
|
|
138
|
+
}
|
|
139
|
+
if (method === "GET" && (pathname === "/models" || pathname === "/v1/models")) {
|
|
140
|
+
return { kind: "models", anthroShape: false };
|
|
141
|
+
}
|
|
142
|
+
if (method === "GET" && pathname === "/codex/v1/models") {
|
|
143
|
+
return { kind: "codex_models", anthroShape: false };
|
|
144
|
+
}
|
|
145
|
+
if (method === "GET" && pathname === "/anthropic/v1/models") {
|
|
146
|
+
return { kind: "anthropic_models", anthroShape: false };
|
|
147
|
+
}
|
|
148
|
+
if (method === "POST" && pathname === "/codex/v1/responses") {
|
|
149
|
+
return { kind: "codex_responses", anthroShape: false };
|
|
150
|
+
}
|
|
151
|
+
if (method === "GET" && pathname === "/_debug") {
|
|
152
|
+
return { kind: "debug", anthroShape: false };
|
|
153
|
+
}
|
|
154
|
+
if (method === "POST" && pathname === "/v1/chat/completions") {
|
|
155
|
+
return { kind: "openai", anthroShape: false };
|
|
156
|
+
}
|
|
157
|
+
if (method === "POST" && (pathname === "/anthropic/v1/messages" || pathname === "/v1/messages")) {
|
|
158
|
+
return { kind: "anthropic", anthroShape: true };
|
|
159
|
+
}
|
|
160
|
+
return { kind: "not_found", anthroShape: false };
|
|
161
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { accountBaseUrl } from "../../models/discovery.js";
|
|
3
|
+
import { isBenignSocketError } from "../requestLifecycle.js";
|
|
4
|
+
const COPILOT_HEADERS = {
|
|
5
|
+
"Content-Type": "application/json",
|
|
6
|
+
"Copilot-Integration-Id": "vscode-chat",
|
|
7
|
+
"Editor-Version": "vscode/1.95.0",
|
|
8
|
+
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
|
9
|
+
"User-Agent": "GitHubCopilotChat/0.26.7",
|
|
10
|
+
"Openai-Intent": "conversation-panel",
|
|
11
|
+
"X-GitHub-Api-Version": "2025-04-01",
|
|
12
|
+
"X-VScode-User-Agent-Library-Version": "electron-fetch",
|
|
13
|
+
// Disable gzip/br on the upstream response. Compressed SSE streams get
|
|
14
|
+
// buffered at gzip flush boundaries by undici's decoder, which causes
|
|
15
|
+
// visible "freeze then dump a paragraph" behaviour in Claude Code and
|
|
16
|
+
// other Anthropic-shape clients. Identity encoding lets each SSE event
|
|
17
|
+
// flow through immediately.
|
|
18
|
+
"Accept-Encoding": "identity"
|
|
19
|
+
};
|
|
20
|
+
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
21
|
+
const MAX_UPSTREAM_ATTEMPTS = 3;
|
|
22
|
+
const BASE_BACKOFF_MS = 200;
|
|
23
|
+
export async function postToCopilot(input) {
|
|
24
|
+
let forceRefresh = false;
|
|
25
|
+
let authRefreshRetried = false;
|
|
26
|
+
for (let attempt = 1; attempt <= MAX_UPSTREAM_ATTEMPTS; attempt += 1) {
|
|
27
|
+
if (input.signal?.aborted) {
|
|
28
|
+
throw abortErrorFromSignal(input.signal);
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
const attemptStartedAt = Date.now();
|
|
32
|
+
input.logger.debug({
|
|
33
|
+
event: "upstream_request",
|
|
34
|
+
request_id: input.requestId,
|
|
35
|
+
attempt,
|
|
36
|
+
upstream_path: input.upstreamPath,
|
|
37
|
+
force_refresh: forceRefresh
|
|
38
|
+
}, "posting upstream request");
|
|
39
|
+
const response = await postWithCurrentBearer(input.tokenManager, input.accountType, input.body, forceRefresh, input.requestId, input.upstreamPath, input.signal);
|
|
40
|
+
input.logger.debug({
|
|
41
|
+
event: "upstream_response",
|
|
42
|
+
request_id: input.requestId,
|
|
43
|
+
attempt,
|
|
44
|
+
upstream_path: input.upstreamPath,
|
|
45
|
+
status_code: response.status,
|
|
46
|
+
duration_ms: Date.now() - attemptStartedAt,
|
|
47
|
+
content_type: response.headers.get("content-type"),
|
|
48
|
+
retry_after: response.headers.get("retry-after")
|
|
49
|
+
}, "received upstream response");
|
|
50
|
+
forceRefresh = false;
|
|
51
|
+
if (response.status === 401 && !authRefreshRetried && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
52
|
+
authRefreshRetried = true;
|
|
53
|
+
forceRefresh = true;
|
|
54
|
+
input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, reason: "upstream_auth_401" }, "retrying upstream request after forced token refresh");
|
|
55
|
+
await discardUpstreamBody(response);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (isRetryableStatus(response.status) && attempt < MAX_UPSTREAM_ATTEMPTS) {
|
|
59
|
+
input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, status_code: response.status }, "retrying upstream request");
|
|
60
|
+
await discardUpstreamBody(response);
|
|
61
|
+
await sleep(retryDelayMs(attempt));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
return response;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (isBenignSocketError(error)) {
|
|
68
|
+
// Client disconnected — propagate so the request handler can clean up.
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
if (!isRetryableTransportError(error) || attempt >= MAX_UPSTREAM_ATTEMPTS) {
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
input.logger.warn({ event: "upstream_retry", request_id: input.requestId, attempt, reason: "transport_error" }, "retrying upstream request after transport error");
|
|
75
|
+
await sleep(retryDelayMs(attempt));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
throw new Error("Upstream retry budget exhausted unexpectedly.");
|
|
79
|
+
}
|
|
80
|
+
async function postWithCurrentBearer(tokenManager, accountType, body, forceRefresh, requestId, upstreamPath, signal) {
|
|
81
|
+
const bearer = await tokenManager.ensureToken({ forceRefresh });
|
|
82
|
+
return fetch(`${accountBaseUrl(accountType)}${upstreamPath}`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: {
|
|
85
|
+
...COPILOT_HEADERS,
|
|
86
|
+
Authorization: `Bearer ${bearer}`,
|
|
87
|
+
"X-Request-Id": requestId
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify(body),
|
|
90
|
+
signal
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function abortErrorFromSignal(signal) {
|
|
94
|
+
const reason = signal.reason;
|
|
95
|
+
if (reason instanceof Error) {
|
|
96
|
+
return reason;
|
|
97
|
+
}
|
|
98
|
+
const err = new Error("Request aborted by client");
|
|
99
|
+
err.name = "AbortError";
|
|
100
|
+
return err;
|
|
101
|
+
}
|
|
102
|
+
function isRetryableStatus(status) {
|
|
103
|
+
return RETRYABLE_UPSTREAM_STATUSES.has(status);
|
|
104
|
+
}
|
|
105
|
+
function retryDelayMs(attempt) {
|
|
106
|
+
return BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
107
|
+
}
|
|
108
|
+
function isRetryableTransportError(error) {
|
|
109
|
+
if (!error || typeof error !== "object") {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
const typedError = error;
|
|
113
|
+
const directCode = typedError.code?.toUpperCase();
|
|
114
|
+
const causeCode = typedError.cause?.code?.toUpperCase();
|
|
115
|
+
if (directCode === "ECONNRESET" || directCode === "ECONNREFUSED" || directCode === "ETIMEDOUT") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (causeCode === "ECONNRESET" || causeCode === "ECONNREFUSED" || causeCode === "ETIMEDOUT") {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (!(typedError instanceof Error)) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
const message = typedError.message.toLowerCase();
|
|
125
|
+
if (message.includes("timed out") || message.includes("timeout")) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
return message.includes("econnreset") || message.includes("econnrefused") || message.includes("enotfound");
|
|
129
|
+
}
|
|
130
|
+
async function discardUpstreamBody(response) {
|
|
131
|
+
try {
|
|
132
|
+
await response.arrayBuffer();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// ignore body drain failures
|
|
136
|
+
}
|
|
137
|
+
}
|