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.
Files changed (50) hide show
  1. package/README.md +70 -2
  2. package/dist/agentconfig/load.js +9 -1
  3. package/dist/agentconfig/render.js +8 -5
  4. package/dist/agentconfig/schema.js +6 -0
  5. package/dist/auth/accountManager.js +118 -0
  6. package/dist/auth/accounts.js +161 -0
  7. package/dist/auth/copilotToken.js +92 -23
  8. package/dist/auth/credentials.js +216 -40
  9. package/dist/auth/deviceFlow.js +110 -23
  10. package/dist/auth/githubIdentity.js +14 -10
  11. package/dist/cli/agentEnv.js +15 -9
  12. package/dist/cli/auth/runAuth.js +206 -9
  13. package/dist/cli/commands/agents/claude.js +22 -2
  14. package/dist/cli/commands/agents/codex.js +22 -2
  15. package/dist/cli/commands/agents/copilot.js +25 -4
  16. package/dist/cli/commands/agents/pi.js +22 -2
  17. package/dist/cli/commands/agents/shared.js +57 -0
  18. package/dist/cli/commands/auth.js +58 -7
  19. package/dist/cli/commands/daemon.js +79 -17
  20. package/dist/cli/commands/models.js +0 -5
  21. package/dist/cli/copillmFlags.js +8 -0
  22. package/dist/cli/daemon/lifecycle.js +26 -0
  23. package/dist/cli/daemon/probes.js +99 -33
  24. package/dist/cli/daemon/runDaemon.js +21 -2
  25. package/dist/cli/index.js +12 -0
  26. package/dist/cli/integrations/claudeExport.js +6 -4
  27. package/dist/cli/integrations/refreshCodex.js +5 -2
  28. package/dist/cli/integrations/refreshPi.js +5 -2
  29. package/dist/cli/packageInfo.js +1 -1
  30. package/dist/cli/shared/devMode.js +98 -0
  31. package/dist/config/accountId.js +44 -0
  32. package/dist/config/config.js +13 -2
  33. package/dist/config/home.js +69 -0
  34. package/dist/integrations/claude/cache.js +5 -2
  35. package/dist/integrations/claude/settingsConflict.js +5 -2
  36. package/dist/integrations/codex/init.js +31 -10
  37. package/dist/integrations/pi/init.js +8 -17
  38. package/dist/models/anthropicDefaults.js +13 -4
  39. package/dist/models/discovery.js +141 -15
  40. package/dist/server/accountResolver.js +85 -0
  41. package/dist/server/debugInfo.js +69 -24
  42. package/dist/server/errors.js +18 -0
  43. package/dist/server/proxy.js +40 -8
  44. package/dist/server/routes/debug.js +11 -1
  45. package/dist/server/routes/models.js +12 -6
  46. package/dist/server/routes/proxyForward.js +3 -3
  47. package/dist/server/routes/shared.js +66 -21
  48. package/dist/server/upstream/copilotClient.js +1 -30
  49. package/dist/server/upstream/retryPolicy.js +99 -0
  50. package/package.json +4 -1
@@ -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 fetch(`${accountBaseUrl(accountType)}/models`, {
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 Error("Model discovery response is invalid.");
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
- export async function listModelsUnion(accountType, bearerToken, attempts = 3) {
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 === 429 || error.status >= 500;
273
+ return CACHE_FALLBACK_STATUSES.has(error.status) || error.status >= 500;
170
274
  }
171
275
  return true;
172
276
  }
173
- function saveModelCache(accountType, models) {
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(modelsCachePath(), JSON.stringify(payload, null, 2), 0o600);
306
+ writeFileSecureAtomic(modelsCacheWriteFile(accountId), JSON.stringify(payload, null, 2), 0o600);
181
307
  }
182
- function readModelCache(accountType) {
183
- const filePath = modelsCacheReadPath();
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
+ }
@@ -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 response = await fetch(githubUserUrl(), {
10
- headers: {
11
- Authorization: `token ${githubToken}`,
12
- Accept: "application/vnd.github+json",
13
- "User-Agent": "copillm/0.1.0",
14
- "X-GitHub-Api-Version": "2022-11-28"
15
- },
16
- signal: typeof options.timeoutMs === "number" ? AbortSignal.timeout(options.timeoutMs) : undefined
17
- });
18
- if (!response.ok) {
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
- throw new GithubUserFetchError(response.status, detail.slice(0, 256));
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
- const payload = (await response.json());
23
- const summary = {
24
- login: typeof payload.login === "string" ? payload.login : "",
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;
@@ -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 {
@@ -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, input.tokenManager);
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 handleModels(res, route.kind, input.config, input.tokenManager, input.githubToken);
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: input.tokenManager,
62
- githubToken: input.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
- tokenManager: input.tokenManager,
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
- const summary = await getGithubUserSummary(input.githubToken);
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, config, tokenManager, githubToken) {
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(config.accountType, githubToken, 3)
15
- : await listModels(config.accountType, githubToken);
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
- safeSendJson(res, 503, { error: "token_refresh_failed" });
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, tokenManager, logger, requestId, signal } = input;
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: config.accountType,
73
+ tokenManager: account.tokenManager,
74
+ accountType: account.accountType,
75
75
  body: upstreamBody,
76
76
  requestId,
77
77
  logger,