copillm 0.2.8 → 0.3.0-beta.1
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 +70 -2
- package/dist/agentconfig/load.js +9 -1
- package/dist/agentconfig/render.js +8 -5
- package/dist/agentconfig/schema.js +6 -0
- package/dist/auth/accountManager.js +118 -0
- package/dist/auth/accounts.js +161 -0
- package/dist/auth/copilotToken.js +92 -23
- package/dist/auth/credentials.js +216 -40
- package/dist/auth/deviceFlow.js +110 -23
- package/dist/auth/githubIdentity.js +14 -10
- package/dist/cli/agentEnv.js +15 -9
- package/dist/cli/auth/runAuth.js +206 -9
- package/dist/cli/commands/agents/claude.js +22 -2
- package/dist/cli/commands/agents/codex.js +22 -2
- package/dist/cli/commands/agents/copilot.js +25 -4
- package/dist/cli/commands/agents/pi.js +22 -2
- package/dist/cli/commands/agents/shared.js +57 -0
- package/dist/cli/commands/auth.js +58 -7
- package/dist/cli/commands/daemon.js +79 -17
- package/dist/cli/commands/models.js +0 -5
- package/dist/cli/copillmFlags.js +8 -0
- package/dist/cli/daemon/lifecycle.js +26 -0
- package/dist/cli/daemon/probes.js +99 -33
- package/dist/cli/daemon/runDaemon.js +21 -2
- package/dist/cli/index.js +12 -0
- package/dist/cli/integrations/claudeExport.js +6 -4
- package/dist/cli/integrations/refreshCodex.js +5 -2
- package/dist/cli/integrations/refreshPi.js +5 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/cli/shared/devMode.js +98 -0
- package/dist/config/accountId.js +44 -0
- package/dist/config/config.js +13 -2
- package/dist/config/home.js +69 -0
- package/dist/integrations/claude/cache.js +5 -2
- package/dist/integrations/claude/settingsConflict.js +5 -2
- package/dist/integrations/codex/init.js +31 -10
- package/dist/integrations/pi/init.js +8 -17
- package/dist/models/anthropicDefaults.js +13 -4
- package/dist/models/discovery.js +141 -15
- package/dist/server/accountResolver.js +85 -0
- package/dist/server/debugInfo.js +69 -24
- package/dist/server/errors.js +18 -0
- package/dist/server/proxy.js +40 -8
- package/dist/server/routes/debug.js +11 -1
- package/dist/server/routes/models.js +12 -6
- package/dist/server/routes/proxyForward.js +3 -3
- package/dist/server/routes/shared.js +66 -21
- package/dist/server/upstream/copilotClient.js +1 -30
- package/dist/server/upstream/retryPolicy.js +99 -0
- package/package.json +4 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stripOneMillionAlias } from "../../translation/openaiAnthropic.js";
|
|
2
|
+
import { isValidAccountId } from "../../config/accountId.js";
|
|
2
3
|
import { JsonRequestParseError } from "../errors.js";
|
|
3
4
|
export async function readJson(req) {
|
|
4
5
|
const chunks = [];
|
|
@@ -119,43 +120,87 @@ export function safePathname(rawUrl) {
|
|
|
119
120
|
return "/";
|
|
120
121
|
}
|
|
121
122
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
123
|
+
// First path segments that belong to real routes and must never be mistaken
|
|
124
|
+
// for an account prefix. (Direct matching already wins for these, so this is
|
|
125
|
+
// belt-and-suspenders against contrived nested paths.)
|
|
126
|
+
const RESERVED_FIRST_SEGMENTS = new Set([
|
|
127
|
+
"livez",
|
|
128
|
+
"healthz",
|
|
129
|
+
"models",
|
|
130
|
+
"v1",
|
|
131
|
+
"codex",
|
|
132
|
+
"anthropic",
|
|
133
|
+
"_debug"
|
|
134
|
+
]);
|
|
135
|
+
// Only these routes may be addressed with an `/<account>` prefix. The generic
|
|
136
|
+
// `/models` discovery route and all health/debug routes stay global.
|
|
137
|
+
const PREFIXABLE_KINDS = new Set([
|
|
138
|
+
"codex_models",
|
|
139
|
+
"anthropic_models",
|
|
140
|
+
"codex_responses",
|
|
141
|
+
"openai",
|
|
142
|
+
"anthropic"
|
|
143
|
+
]);
|
|
144
|
+
function matchRoute(method, pathname) {
|
|
133
145
|
if (method === "GET" && pathname === "/livez") {
|
|
134
|
-
return { kind: "livez", anthroShape: false };
|
|
146
|
+
return { kind: "livez", anthroShape: false, accountId: null };
|
|
135
147
|
}
|
|
136
148
|
if (method === "GET" && pathname === "/healthz") {
|
|
137
|
-
return { kind: "healthz", anthroShape: false };
|
|
149
|
+
return { kind: "healthz", anthroShape: false, accountId: null };
|
|
138
150
|
}
|
|
139
151
|
if (method === "GET" && (pathname === "/models" || pathname === "/v1/models")) {
|
|
140
|
-
return { kind: "models", anthroShape: false };
|
|
152
|
+
return { kind: "models", anthroShape: false, accountId: null };
|
|
141
153
|
}
|
|
142
154
|
if (method === "GET" && pathname === "/codex/v1/models") {
|
|
143
|
-
return { kind: "codex_models", anthroShape: false };
|
|
155
|
+
return { kind: "codex_models", anthroShape: false, accountId: null };
|
|
144
156
|
}
|
|
145
157
|
if (method === "GET" && pathname === "/anthropic/v1/models") {
|
|
146
|
-
return { kind: "anthropic_models", anthroShape: false };
|
|
158
|
+
return { kind: "anthropic_models", anthroShape: false, accountId: null };
|
|
147
159
|
}
|
|
148
160
|
if (method === "POST" && pathname === "/codex/v1/responses") {
|
|
149
|
-
return { kind: "codex_responses", anthroShape: false };
|
|
161
|
+
return { kind: "codex_responses", anthroShape: false, accountId: null };
|
|
150
162
|
}
|
|
151
163
|
if (method === "GET" && pathname === "/_debug") {
|
|
152
|
-
return { kind: "debug", anthroShape: false };
|
|
164
|
+
return { kind: "debug", anthroShape: false, accountId: null };
|
|
153
165
|
}
|
|
154
166
|
if (method === "POST" && pathname === "/v1/chat/completions") {
|
|
155
|
-
return { kind: "openai", anthroShape: false };
|
|
167
|
+
return { kind: "openai", anthroShape: false, accountId: null };
|
|
156
168
|
}
|
|
157
169
|
if (method === "POST" && (pathname === "/anthropic/v1/messages" || pathname === "/v1/messages")) {
|
|
158
|
-
return { kind: "anthropic", anthroShape: true };
|
|
170
|
+
return { kind: "anthropic", anthroShape: true, accountId: null };
|
|
171
|
+
}
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
export function resolveRoute(method, rawUrl) {
|
|
175
|
+
if (!method || !rawUrl) {
|
|
176
|
+
return { kind: "not_found", anthroShape: false, accountId: null };
|
|
177
|
+
}
|
|
178
|
+
let pathname;
|
|
179
|
+
try {
|
|
180
|
+
pathname = new URL(rawUrl, "http://127.0.0.1").pathname;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return { kind: "not_found", anthroShape: false, accountId: null };
|
|
184
|
+
}
|
|
185
|
+
// Try the path as-is first. This keeps every existing (unprefixed) route
|
|
186
|
+
// working unchanged and ensures a reserved first segment like `/codex/...`
|
|
187
|
+
// is always interpreted as a route, never as an account named "codex".
|
|
188
|
+
const direct = matchRoute(method, pathname);
|
|
189
|
+
if (direct) {
|
|
190
|
+
return direct;
|
|
191
|
+
}
|
|
192
|
+
// Otherwise, peel an optional leading `/<account>` segment and re-match the
|
|
193
|
+
// remainder against the prefixable routes.
|
|
194
|
+
const prefixMatch = pathname.match(/^\/([^/]+)(\/.*)$/);
|
|
195
|
+
if (prefixMatch) {
|
|
196
|
+
const candidate = prefixMatch[1];
|
|
197
|
+
const rest = prefixMatch[2];
|
|
198
|
+
if (isValidAccountId(candidate) && !RESERVED_FIRST_SEGMENTS.has(candidate)) {
|
|
199
|
+
const sub = matchRoute(method, rest);
|
|
200
|
+
if (sub && PREFIXABLE_KINDS.has(sub.kind)) {
|
|
201
|
+
return { ...sub, accountId: candidate };
|
|
202
|
+
}
|
|
203
|
+
}
|
|
159
204
|
}
|
|
160
|
-
return { kind: "not_found", anthroShape: false };
|
|
205
|
+
return { kind: "not_found", anthroShape: false, accountId: null };
|
|
161
206
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { accountBaseUrl } from "../../models/discovery.js";
|
|
3
3
|
import { isBenignSocketError } from "../requestLifecycle.js";
|
|
4
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "./retryPolicy.js";
|
|
4
5
|
const COPILOT_HEADERS = {
|
|
5
6
|
"Content-Type": "application/json",
|
|
6
7
|
"Copilot-Integration-Id": "vscode-chat",
|
|
@@ -17,9 +18,7 @@ const COPILOT_HEADERS = {
|
|
|
17
18
|
// flow through immediately.
|
|
18
19
|
"Accept-Encoding": "identity"
|
|
19
20
|
};
|
|
20
|
-
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
21
21
|
const MAX_UPSTREAM_ATTEMPTS = 3;
|
|
22
|
-
const BASE_BACKOFF_MS = 200;
|
|
23
22
|
export async function postToCopilot(input) {
|
|
24
23
|
let forceRefresh = false;
|
|
25
24
|
let authRefreshRetried = false;
|
|
@@ -99,34 +98,6 @@ function abortErrorFromSignal(signal) {
|
|
|
99
98
|
err.name = "AbortError";
|
|
100
99
|
return err;
|
|
101
100
|
}
|
|
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
101
|
async function discardUpstreamBody(response) {
|
|
131
102
|
try {
|
|
132
103
|
await response.arrayBuffer();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Shared retry primitives for upstream HTTP calls.
|
|
2
|
+
//
|
|
3
|
+
// Originally lived inline in `copilotClient.ts` only. Extracted so that
|
|
4
|
+
// other upstream-facing fetch sites (token exchange in `auth/copilotToken.ts`,
|
|
5
|
+
// future device-flow / model-discovery sites) can share the same policy
|
|
6
|
+
// instead of each rolling its own slightly-different version. The numerical
|
|
7
|
+
// constants and the retryable-status set must match `copilotClient.ts`'s
|
|
8
|
+
// previous behaviour exactly so existing tests and production semantics
|
|
9
|
+
// don't drift.
|
|
10
|
+
/**
|
|
11
|
+
* HTTP statuses that warrant a retry: transient server-side congestion /
|
|
12
|
+
* upstream outages. 401 is NOT here — auth failures are handled by a
|
|
13
|
+
* separate caller-driven "force refresh once" path in `copilotClient.ts`,
|
|
14
|
+
* and by `CopilotTokenManager.exchange()` as a terminal "bad credentials"
|
|
15
|
+
* signal.
|
|
16
|
+
*/
|
|
17
|
+
export const RETRYABLE_UPSTREAM_STATUSES = new Set([
|
|
18
|
+
408, 409, 425, 429, 500, 502, 503, 504
|
|
19
|
+
]);
|
|
20
|
+
export function isRetryableStatus(status) {
|
|
21
|
+
return RETRYABLE_UPSTREAM_STATUSES.has(status);
|
|
22
|
+
}
|
|
23
|
+
/** Base exponential backoff: 200ms × 2^(attempt-1). */
|
|
24
|
+
export const BASE_BACKOFF_MS = 200;
|
|
25
|
+
export function retryDelayMs(attempt) {
|
|
26
|
+
return BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Node `fetch` / undici transport error codes worth retrying. Excludes
|
|
30
|
+
* permanent errors like EACCES, ENOSPC, certificate failures. Both the
|
|
31
|
+
* direct `error.code` and the wrapped `error.cause.code` are checked
|
|
32
|
+
* because undici wraps the underlying socket error in a `TypeError:
|
|
33
|
+
* fetch failed` whose `.cause` carries the real code.
|
|
34
|
+
*
|
|
35
|
+
* EAI_AGAIN, EHOSTUNREACH, ENETUNREACH catch the common transient DNS
|
|
36
|
+
* and routing failures (home networks, corp VPN flaps, macOS wake-from-
|
|
37
|
+
* sleep). UND_ERR_* are undici's own timeouts and socket errors.
|
|
38
|
+
*/
|
|
39
|
+
const RETRYABLE_TRANSPORT_CODES = new Set([
|
|
40
|
+
"ECONNRESET",
|
|
41
|
+
"ECONNREFUSED",
|
|
42
|
+
"ETIMEDOUT",
|
|
43
|
+
"EAI_AGAIN",
|
|
44
|
+
"EHOSTUNREACH",
|
|
45
|
+
"ENETUNREACH",
|
|
46
|
+
"EPIPE",
|
|
47
|
+
"UND_ERR_SOCKET",
|
|
48
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
49
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
50
|
+
"UND_ERR_BODY_TIMEOUT"
|
|
51
|
+
]);
|
|
52
|
+
const RETRYABLE_TRANSPORT_MESSAGE_SUBSTRINGS = [
|
|
53
|
+
"timed out",
|
|
54
|
+
"timeout",
|
|
55
|
+
"econnreset",
|
|
56
|
+
"econnrefused",
|
|
57
|
+
"enotfound",
|
|
58
|
+
"eai_again",
|
|
59
|
+
"ehostunreach",
|
|
60
|
+
"enetunreach",
|
|
61
|
+
"socket hang up",
|
|
62
|
+
"other side closed",
|
|
63
|
+
"fetch failed"
|
|
64
|
+
];
|
|
65
|
+
export function isRetryableTransportError(error) {
|
|
66
|
+
if (!error || typeof error !== "object") {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const typedError = error;
|
|
70
|
+
if (matchesRetryableCode(typedError.code)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// Recurse into .cause to handle undici's `TypeError: fetch failed` wrapper
|
|
74
|
+
// (and any other wrapper layers); bounded depth so a self-referential
|
|
75
|
+
// cause chain can't run away.
|
|
76
|
+
if (causeHasRetryableCode(typedError.cause, 0)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (!(typedError instanceof Error)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const message = typedError.message.toLowerCase();
|
|
83
|
+
return RETRYABLE_TRANSPORT_MESSAGE_SUBSTRINGS.some((needle) => message.includes(needle));
|
|
84
|
+
}
|
|
85
|
+
function matchesRetryableCode(code) {
|
|
86
|
+
if (typeof code !== "string")
|
|
87
|
+
return false;
|
|
88
|
+
return RETRYABLE_TRANSPORT_CODES.has(code.toUpperCase());
|
|
89
|
+
}
|
|
90
|
+
function causeHasRetryableCode(cause, depth) {
|
|
91
|
+
if (!cause || typeof cause !== "object" || depth > 5) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const inner = cause;
|
|
95
|
+
if (matchesRetryableCode(inner.code)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return causeHasRetryableCode(inner.cause, depth + 1);
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copillm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0-beta.1",
|
|
4
4
|
"description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|
|
28
28
|
"dev": "tsx src/cli.ts",
|
|
29
|
+
"dev:start": "npm run build && node dist/cli.js --dev start",
|
|
30
|
+
"dev:stop": "npm run build && node dist/cli.js --dev stop",
|
|
31
|
+
"dev:status": "node dist/cli.js --dev status",
|
|
29
32
|
"lint": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit && eslint src",
|
|
30
33
|
"lint:boundaries": "eslint src",
|
|
31
34
|
"test": "vitest run",
|