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
package/dist/models/discovery.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import { modelsCachePath, modelsCacheReadPath } from "../config/home.js";
|
|
4
|
+
import { accountModelsCachePath, accountModelsCacheReadPath, modelsCachePath, modelsCacheReadPath } from "../config/home.js";
|
|
5
|
+
import { assertValidAccountId } from "../config/accountId.js";
|
|
4
6
|
import { writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
5
7
|
import { copilotBaseUrl } from "../config/upstream.js";
|
|
6
8
|
const ModelSchema = z
|
|
@@ -20,18 +22,47 @@ const MODEL_RESOLUTION_RULES = [
|
|
|
20
22
|
{ id: "separator-normalized", normalize: (value) => normalizeModelId(value) },
|
|
21
23
|
{ id: "snapshot-trimmed", normalize: (value) => trimDateSnapshot(normalizeModelId(value)) }
|
|
22
24
|
];
|
|
25
|
+
/**
|
|
26
|
+
* Per-attempt timeout for the `/models` fetch. The catalog is typically
|
|
27
|
+
* <50ms on a healthy connection, so 15s leaves plenty of room for slow
|
|
28
|
+
* networks without freezing `copillm start` for a full minute.
|
|
29
|
+
*/
|
|
30
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
31
|
+
/**
|
|
32
|
+
* Exponential backoff base for `listModelsUnion` retry loop. Mirrors the
|
|
33
|
+
* 200ms / 400ms / 800ms shape used by `src/server/upstream/copilotClient.ts`
|
|
34
|
+
* and the new `CopilotTokenManager.exchange()` retry path. Boundary rules
|
|
35
|
+
* (`eslint.config.js`) keep `models` from importing the shared
|
|
36
|
+
* `retryPolicy.ts`, so we re-declare the small handful of constants here
|
|
37
|
+
* rather than introduce a new architectural dependency.
|
|
38
|
+
*/
|
|
39
|
+
const DEFAULT_BACKOFF_BASE_MS = 200;
|
|
40
|
+
/**
|
|
41
|
+
* Statuses worth retrying — same set as `retryPolicy.ts`. 401/403/404 are
|
|
42
|
+
* NOT here because they signal terminal credential / endpoint failures
|
|
43
|
+
* and retrying just delays the error the user needs to see.
|
|
44
|
+
*
|
|
45
|
+
* `canUseCacheFallback` (further down) is intentionally MORE permissive:
|
|
46
|
+
* it also includes 401/403/408 so that a misbehaving upstream serving 401
|
|
47
|
+
* to a perfectly-good token can degrade to the cached snapshot instead of
|
|
48
|
+
* surfacing a misleading auth error.
|
|
49
|
+
*/
|
|
50
|
+
const RETRYABLE_DISCOVERY_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
23
51
|
export function accountBaseUrl(accountType) {
|
|
24
52
|
return copilotBaseUrl(accountType);
|
|
25
53
|
}
|
|
26
|
-
export async function listModels(accountType, bearerToken) {
|
|
54
|
+
export async function listModels(accountType, bearerToken, deps, accountId) {
|
|
55
|
+
const fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
56
|
+
const timeoutMs = deps?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
27
57
|
try {
|
|
28
|
-
const response = await
|
|
58
|
+
const response = await fetchImpl(`${accountBaseUrl(accountType)}/models`, {
|
|
29
59
|
method: "GET",
|
|
30
60
|
headers: {
|
|
31
61
|
Authorization: `Bearer ${bearerToken}`,
|
|
32
62
|
"Content-Type": "application/json",
|
|
33
63
|
"User-Agent": "copillm/0.1.0"
|
|
34
|
-
}
|
|
64
|
+
},
|
|
65
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
35
66
|
});
|
|
36
67
|
if (!response.ok) {
|
|
37
68
|
throw new ModelDiscoveryHttpError(response.status);
|
|
@@ -40,9 +71,9 @@ export async function listModels(accountType, bearerToken) {
|
|
|
40
71
|
const candidateModels = extractModelArray(payload);
|
|
41
72
|
const parsed = z.array(ModelSchema).safeParse(candidateModels);
|
|
42
73
|
if (!parsed.success) {
|
|
43
|
-
throw new
|
|
74
|
+
throw new ModelDiscoverySchemaError("Model discovery response is invalid.");
|
|
44
75
|
}
|
|
45
|
-
saveModelCache(accountType, parsed.data);
|
|
76
|
+
saveModelCache(accountType, parsed.data, accountId);
|
|
46
77
|
return {
|
|
47
78
|
models: parsed.data,
|
|
48
79
|
source: "live",
|
|
@@ -55,7 +86,7 @@ export async function listModels(accountType, bearerToken) {
|
|
|
55
86
|
if (!canUseCacheFallback(error)) {
|
|
56
87
|
throw error;
|
|
57
88
|
}
|
|
58
|
-
const cached = readModelCache(accountType);
|
|
89
|
+
const cached = readModelCache(accountType, accountId);
|
|
59
90
|
if (!cached) {
|
|
60
91
|
const detail = error instanceof Error ? error.message : "unknown error";
|
|
61
92
|
throw new Error(`Model discovery failed and no cache snapshot is available: ${detail}`);
|
|
@@ -69,14 +100,37 @@ export async function listModels(accountType, bearerToken) {
|
|
|
69
100
|
};
|
|
70
101
|
}
|
|
71
102
|
}
|
|
72
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Run multiple discovery attempts and union the results across them.
|
|
105
|
+
*
|
|
106
|
+
* Two changes from the previous version:
|
|
107
|
+
*
|
|
108
|
+
* 1. **Exponential backoff between attempts** — was a tight loop that
|
|
109
|
+
* hammered Copilot 3× immediately on a 429 burst, extending the
|
|
110
|
+
* rate-limit lockout. Now sleeps 200ms × 2^(attempt-1) between
|
|
111
|
+
* iterations, matching the curve used in `copilotClient.ts` and the
|
|
112
|
+
* token-exchange retry.
|
|
113
|
+
* 2. **Short-circuit on terminal failures** — a schema-invalid 200 or
|
|
114
|
+
* a `Model discovery failed and no cache snapshot is available`
|
|
115
|
+
* surface no longer retries; both are deterministic failures that
|
|
116
|
+
* retrying can't fix and the misleading "across all attempts" error
|
|
117
|
+
* hid the real cause.
|
|
118
|
+
*
|
|
119
|
+
* `attempts` keeps its previous default of 3 for callers that don't
|
|
120
|
+
* specify. Each attempt's own retry budget lives inside `listModels`'s
|
|
121
|
+
* cache-fallback path; this loop runs once per upstream call.
|
|
122
|
+
*/
|
|
123
|
+
export async function listModelsUnion(accountType, bearerToken, attempts = 3, deps, accountId) {
|
|
124
|
+
const sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
73
125
|
const seen = new Map();
|
|
74
126
|
let lastResult = null;
|
|
75
127
|
let lastError;
|
|
128
|
+
let consecutiveFailures = 0;
|
|
76
129
|
for (let i = 0; i < attempts; i += 1) {
|
|
77
130
|
try {
|
|
78
|
-
const result = await listModels(accountType, bearerToken);
|
|
131
|
+
const result = await listModels(accountType, bearerToken, deps, accountId);
|
|
79
132
|
lastResult = result;
|
|
133
|
+
consecutiveFailures = 0;
|
|
80
134
|
for (const model of result.models) {
|
|
81
135
|
if (typeof model.id === "string" && !seen.has(model.id)) {
|
|
82
136
|
seen.set(model.id, model);
|
|
@@ -85,6 +139,26 @@ export async function listModelsUnion(accountType, bearerToken, attempts = 3) {
|
|
|
85
139
|
}
|
|
86
140
|
catch (error) {
|
|
87
141
|
lastError = error;
|
|
142
|
+
consecutiveFailures += 1;
|
|
143
|
+
// Schema failures are deterministic — same response shape, same error.
|
|
144
|
+
// Don't burn the rest of the retry budget; surface the real cause now.
|
|
145
|
+
if (error instanceof ModelDiscoverySchemaError) {
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
// HTTP failures that aren't on the retryable list (e.g. 401/403 with
|
|
149
|
+
// no cache, 404) are also deterministic. The cache-fallback path
|
|
150
|
+
// inside `listModels` has already had its chance to engage; if we got
|
|
151
|
+
// here it didn't.
|
|
152
|
+
if (error instanceof ModelDiscoveryHttpError && !RETRYABLE_DISCOVERY_STATUSES.has(error.status)) {
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Only sleep between attempts if the most recent attempt FAILED. Sleeping
|
|
157
|
+
// between successful attempts would burn wall-clock for no benefit when
|
|
158
|
+
// the union is already populated. Sleep schedule mirrors the rest of the
|
|
159
|
+
// codebase: 200ms × 2^(failure-1), so 200 → 400 → 800 between failures.
|
|
160
|
+
if (i < attempts - 1 && consecutiveFailures > 0) {
|
|
161
|
+
await sleepImpl(DEFAULT_BACKOFF_BASE_MS * Math.pow(2, consecutiveFailures - 1));
|
|
88
162
|
}
|
|
89
163
|
}
|
|
90
164
|
if (lastResult === null) {
|
|
@@ -157,30 +231,82 @@ export function resolveModelSelections(requestedModelIds, models) {
|
|
|
157
231
|
}
|
|
158
232
|
return { resolved, unresolved };
|
|
159
233
|
}
|
|
160
|
-
class ModelDiscoveryHttpError extends Error {
|
|
234
|
+
export class ModelDiscoveryHttpError extends Error {
|
|
161
235
|
status;
|
|
162
236
|
constructor(status) {
|
|
163
237
|
super(`Model discovery failed (${status}).`);
|
|
164
238
|
this.status = status;
|
|
239
|
+
this.name = "ModelDiscoveryHttpError";
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
export class ModelDiscoverySchemaError extends Error {
|
|
243
|
+
constructor(message) {
|
|
244
|
+
super(message);
|
|
245
|
+
this.name = "ModelDiscoverySchemaError";
|
|
165
246
|
}
|
|
166
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Statuses + error types that allow degrading to the on-disk cache instead
|
|
250
|
+
* of failing the caller.
|
|
251
|
+
*
|
|
252
|
+
* Widened from the previous `429 || >= 500` to include 401, 403, 408 — the
|
|
253
|
+
* exact case the seed audit hit: a transient 401 from `api.github.com/...`
|
|
254
|
+
* or its proxy in front of `/models` would re-throw and tell the user
|
|
255
|
+
* `Model discovery failed and no cache snapshot is available.` even when
|
|
256
|
+
* a perfectly good cached catalog existed. With this widening, a fresh
|
|
257
|
+
* cache (typical for users who've run `copillm start` recently) hides the
|
|
258
|
+
* blip from agent surfaces.
|
|
259
|
+
*
|
|
260
|
+
* Schema errors are intentionally NOT cache-eligible: a 200 with a body
|
|
261
|
+
* shape we don't recognize is a deterministic failure that the cache
|
|
262
|
+
* can't paper over, and surfacing the real `Model discovery response is
|
|
263
|
+
* invalid.` error is more useful than silently serving stale data.
|
|
264
|
+
*
|
|
265
|
+
* Non-HTTP errors (transport / AbortError / generic) DO fall back — those
|
|
266
|
+
* are exactly the kinds of transient failures the cache exists for.
|
|
267
|
+
*/
|
|
167
268
|
function canUseCacheFallback(error) {
|
|
269
|
+
if (error instanceof ModelDiscoverySchemaError) {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
168
272
|
if (error instanceof ModelDiscoveryHttpError) {
|
|
169
|
-
return error.status
|
|
273
|
+
return CACHE_FALLBACK_STATUSES.has(error.status) || error.status >= 500;
|
|
170
274
|
}
|
|
171
275
|
return true;
|
|
172
276
|
}
|
|
173
|
-
|
|
277
|
+
const CACHE_FALLBACK_STATUSES = new Set([401, 403, 408, 409, 425, 429]);
|
|
278
|
+
/**
|
|
279
|
+
* Resolve the model-cache file for an account. `undefined` → the shared
|
|
280
|
+
* `models.cache.json` used by the primary/legacy account and single-account
|
|
281
|
+
* installs; a string → the per-account `models.cache.<id>.json`. The caller
|
|
282
|
+
* (the daemon) decides which based on the account's storage scheme, so a
|
|
283
|
+
* default-account switch never makes two accounts share a catalog file.
|
|
284
|
+
*/
|
|
285
|
+
function modelsCacheWriteFile(accountId) {
|
|
286
|
+
if (accountId === undefined) {
|
|
287
|
+
return modelsCachePath();
|
|
288
|
+
}
|
|
289
|
+
assertValidAccountId(accountId);
|
|
290
|
+
return accountModelsCachePath(accountId);
|
|
291
|
+
}
|
|
292
|
+
function modelsCacheReadFile(accountId) {
|
|
293
|
+
if (accountId === undefined) {
|
|
294
|
+
return modelsCacheReadPath();
|
|
295
|
+
}
|
|
296
|
+
assertValidAccountId(accountId);
|
|
297
|
+
return accountModelsCacheReadPath(accountId);
|
|
298
|
+
}
|
|
299
|
+
function saveModelCache(accountType, models, accountId) {
|
|
174
300
|
const payload = {
|
|
175
301
|
version: 1,
|
|
176
302
|
accountType,
|
|
177
303
|
savedAtIso: new Date().toISOString(),
|
|
178
304
|
models
|
|
179
305
|
};
|
|
180
|
-
writeFileSecureAtomic(
|
|
306
|
+
writeFileSecureAtomic(modelsCacheWriteFile(accountId), JSON.stringify(payload, null, 2), 0o600);
|
|
181
307
|
}
|
|
182
|
-
function readModelCache(accountType) {
|
|
183
|
-
const filePath =
|
|
308
|
+
function readModelCache(accountType, accountId) {
|
|
309
|
+
const filePath = modelsCacheReadFile(accountId);
|
|
184
310
|
if (!fs.existsSync(filePath)) {
|
|
185
311
|
return null;
|
|
186
312
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { CopilotTokenManager } from "../auth/copilotToken.js";
|
|
2
|
+
import { loadStoredCredentialForAccount } from "../auth/credentials.js";
|
|
3
|
+
import { findAccount } from "../auth/accounts.js";
|
|
4
|
+
/**
|
|
5
|
+
* A resolver that knows only the default account. Used to preserve the exact
|
|
6
|
+
* single-account behaviour when the proxy is started without a multi-account
|
|
7
|
+
* resolver (e.g. test harnesses). A prefixed request for any other account
|
|
8
|
+
* resolves to `null` → the proxy returns `account_not_found`.
|
|
9
|
+
*/
|
|
10
|
+
export function singleAccountResolver(input) {
|
|
11
|
+
const def = {
|
|
12
|
+
accountId: input.accountId ?? null,
|
|
13
|
+
githubToken: input.githubToken,
|
|
14
|
+
tokenManager: input.tokenManager,
|
|
15
|
+
accountType: input.accountType,
|
|
16
|
+
cacheId: input.cacheId
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
default: def,
|
|
20
|
+
async resolveById(accountId) {
|
|
21
|
+
if (def.accountId !== null && accountId === def.accountId) {
|
|
22
|
+
return def;
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
},
|
|
26
|
+
describe() {
|
|
27
|
+
return { defaultAccountId: def.accountId, activeAccountIds: [] };
|
|
28
|
+
},
|
|
29
|
+
clearAll() {
|
|
30
|
+
def.tokenManager.clear();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The production resolver. Wraps the eagerly-built default account and lazily
|
|
36
|
+
* builds a bearer manager per named account the first time a request for it
|
|
37
|
+
* arrives. Bearer managers are cached for the daemon's lifetime so repeated
|
|
38
|
+
* requests reuse the same (refresh-coalescing) manager.
|
|
39
|
+
*/
|
|
40
|
+
export class DaemonAccountResolver {
|
|
41
|
+
default;
|
|
42
|
+
cache = new Map();
|
|
43
|
+
createTokenManager;
|
|
44
|
+
constructor(input) {
|
|
45
|
+
this.default = input.default;
|
|
46
|
+
this.createTokenManager = input.createTokenManager ?? ((githubToken) => new CopilotTokenManager(githubToken));
|
|
47
|
+
}
|
|
48
|
+
async resolveById(accountId) {
|
|
49
|
+
if (this.default.accountId !== null && accountId === this.default.accountId) {
|
|
50
|
+
return this.default;
|
|
51
|
+
}
|
|
52
|
+
const cached = this.cache.get(accountId);
|
|
53
|
+
if (cached) {
|
|
54
|
+
return cached;
|
|
55
|
+
}
|
|
56
|
+
const credential = await loadStoredCredentialForAccount(accountId);
|
|
57
|
+
if (!credential) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const record = findAccount(accountId);
|
|
61
|
+
// The cache file follows the account's storage scheme, mirroring the
|
|
62
|
+
// credential store: a legacy-storage account shares `models.cache.json`,
|
|
63
|
+
// a namespaced account gets its own `models.cache.<id>.json`.
|
|
64
|
+
const cacheId = record && record.storage === "legacy" ? undefined : accountId;
|
|
65
|
+
const resolved = {
|
|
66
|
+
accountId,
|
|
67
|
+
githubToken: credential.token,
|
|
68
|
+
tokenManager: this.createTokenManager(credential.token),
|
|
69
|
+
accountType: credential.accountType,
|
|
70
|
+
cacheId
|
|
71
|
+
};
|
|
72
|
+
this.cache.set(accountId, resolved);
|
|
73
|
+
return resolved;
|
|
74
|
+
}
|
|
75
|
+
describe() {
|
|
76
|
+
return { defaultAccountId: this.default.accountId, activeAccountIds: [...this.cache.keys()] };
|
|
77
|
+
}
|
|
78
|
+
clearAll() {
|
|
79
|
+
this.default.tokenManager.clear();
|
|
80
|
+
for (const resolved of this.cache.values()) {
|
|
81
|
+
resolved.tokenManager.clear();
|
|
82
|
+
}
|
|
83
|
+
this.cache.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/server/debugInfo.js
CHANGED
|
@@ -1,37 +1,82 @@
|
|
|
1
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
1
2
|
import { githubUserUrl } from "../config/upstream.js";
|
|
3
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "./upstream/retryPolicy.js";
|
|
2
4
|
const CACHE_TTL_MS = 5 * 60 * 1_000;
|
|
5
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
3
6
|
let cached = null;
|
|
7
|
+
/**
|
|
8
|
+
* Fetch the GitHub user summary with bounded retries on transient failures.
|
|
9
|
+
*
|
|
10
|
+
* Was: single fetch, single attempt. A transient 502 from `api.github.com/user`
|
|
11
|
+
* caused `auth status` to hide the user's login and `/_debug` to report
|
|
12
|
+
* `user_error: github_user_lookup_failed_502` instead of the user object.
|
|
13
|
+
*
|
|
14
|
+
* Now: retries 5xx/429/408/409/425 + transient transport errors up to
|
|
15
|
+
* `maxAttempts` (default 3) with exponential backoff (200ms / 400ms). Does
|
|
16
|
+
* NOT retry 401/403/404 — those are terminal credential / endpoint signals
|
|
17
|
+
* and retrying just delays the error the caller needs to surface.
|
|
18
|
+
*
|
|
19
|
+
* Cache write only happens on success.
|
|
20
|
+
*/
|
|
4
21
|
export async function getGithubUserSummary(githubToken, options = {}) {
|
|
5
22
|
const now = Date.now();
|
|
6
23
|
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
7
24
|
return cached.summary;
|
|
8
25
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
27
|
+
const sleepImpl = options.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
28
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
29
|
+
let lastError;
|
|
30
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetchImpl(githubUserUrl(), {
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `token ${githubToken}`,
|
|
36
|
+
Accept: "application/vnd.github+json",
|
|
37
|
+
"User-Agent": "copillm/0.1.0",
|
|
38
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
39
|
+
},
|
|
40
|
+
signal: typeof options.timeoutMs === "number" ? AbortSignal.timeout(options.timeoutMs) : undefined
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
lastError = error;
|
|
45
|
+
if (isRetryableTransportError(error) && attempt < maxAttempts) {
|
|
46
|
+
await sleepImpl(retryDelayMs(attempt));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
if (response.ok) {
|
|
52
|
+
const payload = (await response.json());
|
|
53
|
+
const summary = {
|
|
54
|
+
login: typeof payload.login === "string" ? payload.login : "",
|
|
55
|
+
id: typeof payload.id === "number" ? payload.id : 0,
|
|
56
|
+
name: typeof payload.name === "string" ? payload.name : null,
|
|
57
|
+
email: typeof payload.email === "string" ? payload.email : null,
|
|
58
|
+
type: typeof payload.type === "string" ? payload.type : "User",
|
|
59
|
+
avatar_url: typeof payload.avatar_url === "string" ? payload.avatar_url : null,
|
|
60
|
+
html_url: typeof payload.html_url === "string" ? payload.html_url : null,
|
|
61
|
+
plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
|
|
62
|
+
};
|
|
63
|
+
cached = { fetchedAt: Date.now(), summary };
|
|
64
|
+
return summary;
|
|
65
|
+
}
|
|
66
|
+
// Non-OK. 401/403/404 are terminal — fast-fail. Other retryable statuses
|
|
67
|
+
// (429, 5xx) drain the body and retry.
|
|
19
68
|
const detail = await response.text();
|
|
20
|
-
|
|
69
|
+
const snippet = detail.slice(0, 256);
|
|
70
|
+
lastError = new GithubUserFetchError(response.status, snippet);
|
|
71
|
+
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
72
|
+
await sleepImpl(retryDelayMs(attempt));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw lastError;
|
|
21
76
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
id: typeof payload.id === "number" ? payload.id : 0,
|
|
26
|
-
name: typeof payload.name === "string" ? payload.name : null,
|
|
27
|
-
email: typeof payload.email === "string" ? payload.email : null,
|
|
28
|
-
type: typeof payload.type === "string" ? payload.type : "User",
|
|
29
|
-
avatar_url: typeof payload.avatar_url === "string" ? payload.avatar_url : null,
|
|
30
|
-
html_url: typeof payload.html_url === "string" ? payload.html_url : null,
|
|
31
|
-
plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
|
|
32
|
-
};
|
|
33
|
-
cached = { fetchedAt: now, summary };
|
|
34
|
-
return summary;
|
|
77
|
+
// Unreachable: every loop iteration either returns, throws, or continues
|
|
78
|
+
// (and continue is gated on `attempt < maxAttempts`). Defend anyway.
|
|
79
|
+
throw lastError ?? new Error("GitHub user lookup exhausted retries without error context.");
|
|
35
80
|
}
|
|
36
81
|
export function clearGithubUserCache() {
|
|
37
82
|
cached = null;
|
package/dist/server/errors.js
CHANGED
|
@@ -141,6 +141,24 @@ export function upstreamStatusCategory(status) {
|
|
|
141
141
|
return "upstream_error";
|
|
142
142
|
}
|
|
143
143
|
export function healthFailure(error) {
|
|
144
|
+
return tokenErrorToHttpResponse(error);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Map a `CopilotTokenManagerError` (or any unknown error from the token
|
|
148
|
+
* exchange / refresh path) to an HTTP response. Single source of truth for
|
|
149
|
+
* the `/healthz`, `/models`, `/codex/v1/models`, and `/anthropic/v1/models`
|
|
150
|
+
* routes — previously `routes/models.ts` collapsed every token failure to a
|
|
151
|
+
* flat 503 `token_refresh_failed`, hiding the credential-vs-blip
|
|
152
|
+
* distinction that callers (codex, pi, claude) need to decide whether to
|
|
153
|
+
* retry. The discrimination logic now lives here.
|
|
154
|
+
*
|
|
155
|
+
* Maps:
|
|
156
|
+
* - 401/403 from the upstream token endpoint → HTTP 401 `github_auth_invalid`
|
|
157
|
+
* - 5xx/429/other transient from upstream → HTTP 503 `token_exchange_failed`
|
|
158
|
+
* - other `CopilotTokenManagerError` → HTTP 401 `token_refresh_failed`
|
|
159
|
+
* - anything else → HTTP 503 `token_refresh_unavailable`
|
|
160
|
+
*/
|
|
161
|
+
export function tokenErrorToHttpResponse(error) {
|
|
144
162
|
if (error instanceof CopilotTokenExchangeError) {
|
|
145
163
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
146
164
|
return {
|
package/dist/server/proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { singleAccountResolver } from "./accountResolver.js";
|
|
3
4
|
import { attachRequestLifecycle, isBenignSocketError, safeEnd, safeSendJson } from "./requestLifecycle.js";
|
|
4
5
|
import { InvalidRequestShapeError, JsonRequestParseError } from "./errors.js";
|
|
5
6
|
import { ProtocolTranslationError } from "../translation/openaiAnthropic.js";
|
|
@@ -10,6 +11,22 @@ import { handleProxyForward } from "./routes/proxyForward.js";
|
|
|
10
11
|
import { isLocalRequest, resolveRoute, safePathname } from "./routes/shared.js";
|
|
11
12
|
export async function startProxyServer(input) {
|
|
12
13
|
const debugEnabled = input.debug === true;
|
|
14
|
+
const resolver = input.accountResolver ??
|
|
15
|
+
singleAccountResolver({
|
|
16
|
+
tokenManager: input.tokenManager,
|
|
17
|
+
githubToken: input.githubToken ?? "",
|
|
18
|
+
accountType: input.config.accountType
|
|
19
|
+
});
|
|
20
|
+
// Resolve the account a request targets. Returns the default account for an
|
|
21
|
+
// unprefixed request; for an `/<account>` prefix, looks up the named account
|
|
22
|
+
// and answers 404 `account_not_found` when no credential is stored for it.
|
|
23
|
+
const resolveAccountForRoute = async (route) => {
|
|
24
|
+
if (route.accountId === null) {
|
|
25
|
+
return resolver.default;
|
|
26
|
+
}
|
|
27
|
+
const account = await resolver.resolveById(route.accountId);
|
|
28
|
+
return account;
|
|
29
|
+
};
|
|
13
30
|
const server = createServer(async (req, res) => {
|
|
14
31
|
const requestId = randomUUID();
|
|
15
32
|
const startedAt = Date.now();
|
|
@@ -43,13 +60,21 @@ export async function startProxyServer(input) {
|
|
|
43
60
|
handleLivez(res);
|
|
44
61
|
return;
|
|
45
62
|
case "healthz":
|
|
46
|
-
await handleHealthz(res,
|
|
63
|
+
await handleHealthz(res, resolver.default.tokenManager);
|
|
47
64
|
return;
|
|
48
65
|
case "models":
|
|
66
|
+
await handleModels(res, route.kind, resolver.default);
|
|
67
|
+
return;
|
|
49
68
|
case "codex_models":
|
|
50
|
-
case "anthropic_models":
|
|
51
|
-
await
|
|
69
|
+
case "anthropic_models": {
|
|
70
|
+
const account = await resolveAccountForRoute(route);
|
|
71
|
+
if (!account) {
|
|
72
|
+
safeSendJson(res, 404, { error: "account_not_found", detail: `No stored credential for account "${route.accountId}".` });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
await handleModels(res, route.kind, account);
|
|
52
76
|
return;
|
|
77
|
+
}
|
|
53
78
|
case "debug":
|
|
54
79
|
if (!debugEnabled) {
|
|
55
80
|
safeSendJson(res, 404, { error: "not_found" });
|
|
@@ -58,9 +83,10 @@ export async function startProxyServer(input) {
|
|
|
58
83
|
await handleDebug(res, {
|
|
59
84
|
config: input.config,
|
|
60
85
|
logger: input.logger,
|
|
61
|
-
tokenManager:
|
|
62
|
-
githubToken:
|
|
63
|
-
port: input.port
|
|
86
|
+
tokenManager: resolver.default.tokenManager,
|
|
87
|
+
githubToken: resolver.default.githubToken,
|
|
88
|
+
port: input.port,
|
|
89
|
+
accounts: resolver.describe()
|
|
64
90
|
});
|
|
65
91
|
return;
|
|
66
92
|
case "not_found":
|
|
@@ -68,18 +94,24 @@ export async function startProxyServer(input) {
|
|
|
68
94
|
return;
|
|
69
95
|
case "openai":
|
|
70
96
|
case "anthropic":
|
|
71
|
-
case "codex_responses":
|
|
97
|
+
case "codex_responses": {
|
|
98
|
+
const account = await resolveAccountForRoute(route);
|
|
99
|
+
if (!account) {
|
|
100
|
+
safeSendJson(res, 404, { error: "account_not_found", detail: `No stored credential for account "${route.accountId}".` });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
72
103
|
await handleProxyForward({
|
|
73
104
|
req,
|
|
74
105
|
res,
|
|
75
106
|
route,
|
|
76
107
|
config: input.config,
|
|
77
|
-
|
|
108
|
+
account,
|
|
78
109
|
logger: input.logger,
|
|
79
110
|
requestId,
|
|
80
111
|
signal: lifecycle.signal
|
|
81
112
|
});
|
|
82
113
|
return;
|
|
114
|
+
}
|
|
83
115
|
}
|
|
84
116
|
}
|
|
85
117
|
catch (error) {
|
|
@@ -8,7 +8,10 @@ export async function handleDebug(res, input) {
|
|
|
8
8
|
let userError = null;
|
|
9
9
|
if (input.githubToken) {
|
|
10
10
|
try {
|
|
11
|
-
|
|
11
|
+
// Bound the GitHub user lookup so a slow `api.github.com` cannot hang
|
|
12
|
+
// the `/_debug` handler indefinitely. Matches the bound used by the
|
|
13
|
+
// CLI's `auth status` path (`githubIdentity.ts:42-44`).
|
|
14
|
+
const summary = await getGithubUserSummary(input.githubToken, { timeoutMs: 4_000 });
|
|
12
15
|
user = {
|
|
13
16
|
login: summary.login,
|
|
14
17
|
id: summary.id,
|
|
@@ -45,6 +48,13 @@ export async function handleDebug(res, input) {
|
|
|
45
48
|
bearer_present: input.tokenManager.current !== null,
|
|
46
49
|
bearer_expires_at_unix: input.tokenManager.current?.expiresAtUnix ?? null
|
|
47
50
|
},
|
|
51
|
+
accounts: {
|
|
52
|
+
// Token is never included. Reports the default account id (null for a
|
|
53
|
+
// single-account install) and the named accounts that have served at
|
|
54
|
+
// least one request this daemon lifetime.
|
|
55
|
+
default: input.accounts?.defaultAccountId ?? null,
|
|
56
|
+
active: input.accounts?.activeAccountIds ?? []
|
|
57
|
+
},
|
|
48
58
|
user,
|
|
49
59
|
user_error: userError,
|
|
50
60
|
routes: [
|
|
@@ -2,17 +2,18 @@ import { CopilotTokenManagerError } from "../../auth/copilotToken.js";
|
|
|
2
2
|
import { listModels, listModelsUnion } from "../../models/discovery.js";
|
|
3
3
|
import { buildCodexCatalog } from "../codexSchema.js";
|
|
4
4
|
import { buildAnthropicModelsResponse } from "../anthropicModelsResponse.js";
|
|
5
|
+
import { tokenErrorToHttpResponse } from "../errors.js";
|
|
5
6
|
import { safeSendJson } from "../requestLifecycle.js";
|
|
6
|
-
export async function handleModels(res, routeKind,
|
|
7
|
+
export async function handleModels(res, routeKind, account) {
|
|
7
8
|
try {
|
|
8
|
-
await tokenManager.ensureToken(false);
|
|
9
|
-
if (!githubToken) {
|
|
9
|
+
await account.tokenManager.ensureToken(false);
|
|
10
|
+
if (!account.githubToken) {
|
|
10
11
|
safeSendJson(res, 503, { error: "github_token_unavailable" });
|
|
11
12
|
return;
|
|
12
13
|
}
|
|
13
14
|
const result = routeKind === "codex_models" || routeKind === "anthropic_models"
|
|
14
|
-
? await listModelsUnion(
|
|
15
|
-
: await listModels(
|
|
15
|
+
? await listModelsUnion(account.accountType, account.githubToken, 3, undefined, account.cacheId)
|
|
16
|
+
: await listModels(account.accountType, account.githubToken, undefined, account.cacheId);
|
|
16
17
|
if (routeKind === "codex_models") {
|
|
17
18
|
safeSendJson(res, 200, buildCodexCatalog(result.models));
|
|
18
19
|
return;
|
|
@@ -33,7 +34,12 @@ export async function handleModels(res, routeKind, config, tokenManager, githubT
|
|
|
33
34
|
}
|
|
34
35
|
catch (error) {
|
|
35
36
|
if (error instanceof CopilotTokenManagerError) {
|
|
36
|
-
|
|
37
|
+
// Discriminate credential failure (401/403 from upstream — terminal)
|
|
38
|
+
// from transient blip (5xx/429 from upstream — retryable by caller).
|
|
39
|
+
// Was: flat `503 token_refresh_failed` for both, which made codex/pi/
|
|
40
|
+
// claude blindly retry on the permanent case.
|
|
41
|
+
const mapped = tokenErrorToHttpResponse(error);
|
|
42
|
+
safeSendJson(res, mapped.httpStatus, mapped.payload);
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
throw error;
|
|
@@ -22,7 +22,7 @@ function translateRequestBody(routeKind, body) {
|
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
24
|
export async function handleProxyForward(input) {
|
|
25
|
-
const { req, res, route, config,
|
|
25
|
+
const { req, res, route, config, account, logger, requestId, signal } = input;
|
|
26
26
|
const requestBody = await readJson(req);
|
|
27
27
|
const translatedBody = translateRequestBody(route.kind, requestBody);
|
|
28
28
|
const requestedModel = readRequestedModel(translatedBody);
|
|
@@ -70,8 +70,8 @@ export async function handleProxyForward(input) {
|
|
|
70
70
|
}, "prepared upstream request");
|
|
71
71
|
try {
|
|
72
72
|
const upstream = await postToCopilot({
|
|
73
|
-
tokenManager,
|
|
74
|
-
accountType:
|
|
73
|
+
tokenManager: account.tokenManager,
|
|
74
|
+
accountType: account.accountType,
|
|
75
75
|
body: upstreamBody,
|
|
76
76
|
requestId,
|
|
77
77
|
logger,
|