copillm 0.2.9 → 0.3.0-beta.2

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.
@@ -1,14 +1,16 @@
1
1
  import { buildClaudeEnvBundle } from "../agentEnv.js";
2
2
  import { buildClaudeExportCommand as buildClaudeExport, computeAnthropicDefaults, readModelIdsFromCache } from "../../models/anthropicDefaults.js";
3
- export function buildClaudeExportCommand(port, callerSecret) {
4
- const modelIds = readModelIdsFromCache();
3
+ export function buildClaudeExportCommand(port, callerSecret, opts) {
4
+ const pathPrefix = opts?.pathPrefix ?? "";
5
+ const modelIds = readModelIdsFromCache(opts?.cacheId);
5
6
  const defaults = computeAnthropicDefaults(modelIds);
6
7
  const command = buildClaudeExport({
7
8
  port,
8
9
  callerSecret,
9
10
  defaults,
10
- enableGatewayDiscovery: true
11
+ enableGatewayDiscovery: true,
12
+ pathPrefix
11
13
  });
12
- const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true });
14
+ const bundle = buildClaudeEnvBundle({ port, callerSecret, defaults, enableGatewayDiscovery: true, pathPrefix });
13
15
  return { command, defaults, bundle };
14
16
  }
@@ -1,6 +1,6 @@
1
1
  import { getCopillmHome } from "../../config/home.js";
2
2
  import { defaultOutputDir, generateCodexHome } from "../../integrations/codex/init.js";
3
- export async function refreshCodexHome(port, model, precomputed) {
3
+ export async function refreshCodexHome(port, model, precomputed, opts) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generateCodexHome({
@@ -9,7 +9,9 @@ export async function refreshCodexHome(port, model, precomputed) {
9
9
  port,
10
10
  providerId: "copillm",
11
11
  reasoningEffort: null,
12
- precomputed
12
+ precomputed,
13
+ pathPrefix: opts?.pathPrefix,
14
+ account: opts?.account
13
15
  });
14
16
  }
15
17
  catch (error) {
@@ -1,13 +1,15 @@
1
1
  import { getCopillmHome } from "../../config/home.js";
2
2
  import { defaultOutputDir as defaultPiOutputDir, generatePiHome } from "../../integrations/pi/init.js";
3
- export async function refreshPiHome(port, precomputed) {
3
+ export async function refreshPiHome(port, precomputed, opts) {
4
4
  try {
5
5
  const home = getCopillmHome();
6
6
  return await generatePiHome({
7
7
  outDir: defaultPiOutputDir(home),
8
8
  port,
9
9
  providerId: "copillm",
10
- precomputed
10
+ precomputed,
11
+ pathPrefix: opts?.pathPrefix,
12
+ account: opts?.account
11
13
  });
12
14
  }
13
15
  catch (error) {
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  const FALLBACK_PACKAGE_INFO = {
3
3
  name: "copillm",
4
- version: "0.2.9"
4
+ version: "0.3.0-beta.2"
5
5
  };
6
6
  export function getPackageInfo() {
7
7
  const envName = cleanPackageValue(process.env.COPILLM_PACKAGE_NAME);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Account-id validation, shared by the `auth` layer (which writes the accounts
3
+ * index) and the `models` layer (which embeds the id in a per-account model
4
+ * cache filename). Lives in `config` so both layers can import it without
5
+ * crossing a forbidden import boundary.
6
+ *
7
+ * An account id is embedded in filenames (`credentials.<id>.json`,
8
+ * `models.cache.<id>.json`) and in a keychain account string, so it must be a
9
+ * safe single path segment. GitHub logins are `[A-Za-z0-9-]`; copillm also
10
+ * permits `.` and `_` for synthetic ids.
11
+ */
12
+ export const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
13
+ export const MAX_ACCOUNT_ID_LENGTH = 64;
14
+ export class InvalidAccountIdError extends Error {
15
+ accountId;
16
+ constructor(accountId, reason) {
17
+ super(`Invalid account id "${accountId}": ${reason}`);
18
+ this.accountId = accountId;
19
+ this.name = "InvalidAccountIdError";
20
+ }
21
+ }
22
+ /**
23
+ * Validate an account id before it is used in a filename or keychain key.
24
+ * Throws `InvalidAccountIdError` on anything that isn't a safe path segment.
25
+ */
26
+ export function assertValidAccountId(accountId) {
27
+ if (accountId.length === 0) {
28
+ throw new InvalidAccountIdError(accountId, "id must not be empty.");
29
+ }
30
+ if (accountId.length > MAX_ACCOUNT_ID_LENGTH) {
31
+ throw new InvalidAccountIdError(accountId, `id must be at most ${MAX_ACCOUNT_ID_LENGTH} characters.`);
32
+ }
33
+ if (!ACCOUNT_ID_PATTERN.test(accountId)) {
34
+ throw new InvalidAccountIdError(accountId, "id may only contain letters, digits, '.', '_' and '-', and must start with a letter or digit.");
35
+ }
36
+ }
37
+ /**
38
+ * Non-throwing variant for hot paths (e.g. parsing an optional account prefix
39
+ * out of a request URL) where an invalid id should just mean "not an account
40
+ * prefix" rather than an error.
41
+ */
42
+ export function isValidAccountId(accountId) {
43
+ return accountId.length > 0 && accountId.length <= MAX_ACCOUNT_ID_LENGTH && ACCOUNT_ID_PATTERN.test(accountId);
44
+ }
@@ -20,6 +20,29 @@ export function credentialsPath() {
20
20
  export function credentialsReadPath() {
21
21
  return resolveReadablePath("credentials.json");
22
22
  }
23
+ /**
24
+ * Path to the multi-account index (`accounts.json`). Metadata only — never
25
+ * holds a token. Absent on single-account installs, which keep using the
26
+ * legacy `credentials.json` / keychain entry as the implicit default account.
27
+ */
28
+ export function accountsIndexPath() {
29
+ return path.join(getCopillmHome(), "accounts.json");
30
+ }
31
+ export function accountsIndexReadPath() {
32
+ return resolveReadablePath("accounts.json");
33
+ }
34
+ /**
35
+ * Plaintext-fallback credential file for a *named* (non-default) account. The
36
+ * default account keeps the legacy `credentials.json` path for backward
37
+ * compatibility; additional accounts are namespaced by id so their tokens
38
+ * never collide with — or overwrite — the pre-existing default.
39
+ */
40
+ export function accountCredentialsPath(accountId) {
41
+ return path.join(getCopillmHome(), `credentials.${accountId}.json`);
42
+ }
43
+ export function accountCredentialsReadPath(accountId) {
44
+ return resolveReadablePath(`credentials.${accountId}.json`);
45
+ }
23
46
  export function lockPath() {
24
47
  return path.join(getCopillmHome(), "copillm.pid");
25
48
  }
@@ -32,6 +55,18 @@ export function modelsCachePath() {
32
55
  export function modelsCacheReadPath() {
33
56
  return resolveReadablePath("models.cache.json");
34
57
  }
58
+ /**
59
+ * Per-account model-discovery cache. Different accounts can be entitled to
60
+ * different model catalogs, so each named account caches into its own
61
+ * `models.cache.<id>.json`. The primary/legacy account keeps the shared
62
+ * `models.cache.json` (above), so single-account installs are unaffected.
63
+ */
64
+ export function accountModelsCachePath(accountId) {
65
+ return path.join(getCopillmHome(), `models.cache.${accountId}.json`);
66
+ }
67
+ export function accountModelsCacheReadPath(accountId) {
68
+ return resolveReadablePath(`models.cache.${accountId}.json`);
69
+ }
35
70
  export function debugLogPath() {
36
71
  return path.join(getCopillmHome(), "debug.log");
37
72
  }
@@ -7,13 +7,14 @@ import { ensureSecureDirectory, writeFileSecureAtomic } from "../../config/fsSec
7
7
  import { buildCodexCatalog } from "../../server/codexSchema.js";
8
8
  import { inspectLock } from "../../server/lock.js";
9
9
  export async function generateCodexHome(options) {
10
- const { discovery } = await resolveStartContext(options.precomputed);
10
+ const { discovery } = await resolveStartContext(options.precomputed, options.account);
11
11
  const catalog = buildCodexCatalog(discovery.models);
12
12
  if (catalog.models.length === 0) {
13
13
  throw new Error("No Codex-eligible models found in the live catalog.");
14
14
  }
15
15
  const port = options.port;
16
- const proxyUrl = `http://127.0.0.1:${port}/codex/v1`;
16
+ const prefix = options.pathPrefix ?? "";
17
+ const proxyUrl = `http://127.0.0.1:${port}${prefix}/codex/v1`;
17
18
  const baseUrl = proxyUrl;
18
19
  const defaultModel = options.model ?? pickDefaultModel(catalog.models.map((model) => model.slug));
19
20
  if (!catalog.models.some((model) => model.slug === defaultModel)) {
@@ -47,11 +48,19 @@ export async function generateCodexHome(options) {
47
48
  * Exported so `generatePiHome` (and any future agent init) can use the
48
49
  * same loader and the same "if precomputed, reuse it" contract.
49
50
  */
50
- export async function resolveStartContext(precomputed) {
51
+ export async function resolveStartContext(precomputed, account) {
51
52
  if (precomputed) {
52
53
  return precomputed;
53
54
  }
54
55
  const config = loadConfig();
56
+ if (account) {
57
+ const discovery = await listModelsUnion(account.accountType, account.githubToken, 3, undefined, account.cacheId);
58
+ return {
59
+ config,
60
+ creds: { token: account.githubToken, accountType: account.accountType, source: "session" },
61
+ discovery
62
+ };
63
+ }
55
64
  const creds = await loadStoredCredential();
56
65
  if (!creds) {
57
66
  throw new Error("Not authenticated. Run `copillm login` first.");
@@ -4,7 +4,7 @@ import { ensureSecureDirectory, writeFileSecureAtomic } from "../../config/fsSec
4
4
  import { piAgentDir } from "../../config/home.js";
5
5
  import { resolveStartContext } from "../codex/init.js";
6
6
  export async function generatePiHome(options) {
7
- const { discovery } = await resolveStartContext(options.precomputed);
7
+ const { discovery } = await resolveStartContext(options.precomputed, options.account);
8
8
  const eligible = discovery.models.filter(isPickerEligible);
9
9
  // Split the catalog by which upstream endpoint each model supports. Models
10
10
  // that advertise `/chat/completions` flow through copillm's Anthropic surface
@@ -20,9 +20,10 @@ export async function generatePiHome(options) {
20
20
  if (anthropicEligible.length === 0 && responsesEligible.length === 0) {
21
21
  throw new Error("No models discovered for pi config.");
22
22
  }
23
- const proxyUrl = `http://127.0.0.1:${options.port}/anthropic`;
23
+ const prefix = options.pathPrefix ?? "";
24
+ const proxyUrl = `http://127.0.0.1:${options.port}${prefix}/anthropic`;
24
25
  // OpenAI SDK posts to `<baseUrl>/responses`, so the baseUrl must include `/v1`.
25
- const responsesProxyUrl = `http://127.0.0.1:${options.port}/codex/v1`;
26
+ const responsesProxyUrl = `http://127.0.0.1:${options.port}${prefix}/codex/v1`;
26
27
  const providerId = options.providerId.trim().length > 0 ? options.providerId : "copillm";
27
28
  const responsesProviderId = `${providerId}-responses`;
28
29
  const providers = {};
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { z } from "zod";
3
- import { modelsCacheReadPath } from "../config/home.js";
3
+ import { accountModelsCacheReadPath, modelsCacheReadPath } from "../config/home.js";
4
+ import { assertValidAccountId } from "../config/accountId.js";
4
5
  export const ANTHROPIC_FAMILIES = ["opus", "sonnet", "haiku"];
5
6
  const SUFFIX_BLOCKLIST = [
6
7
  "-high",
@@ -32,8 +33,15 @@ export function computeAnthropicDefaults(modelIds) {
32
33
  haiku: pickPlainLatest(byFamily.haiku)
33
34
  };
34
35
  }
35
- export function readModelIdsFromCache() {
36
- const file = modelsCacheReadPath();
36
+ export function readModelIdsFromCache(accountId) {
37
+ let file;
38
+ if (accountId === undefined) {
39
+ file = modelsCacheReadPath();
40
+ }
41
+ else {
42
+ assertValidAccountId(accountId);
43
+ file = accountModelsCacheReadPath(accountId);
44
+ }
37
45
  if (!fs.existsSync(file)) {
38
46
  return [];
39
47
  }
@@ -51,8 +59,9 @@ export function readModelIdsFromCache() {
51
59
  }
52
60
  export function buildClaudeExportCommand(input) {
53
61
  const token = input.callerSecret ?? "copillm-local";
62
+ const prefix = input.pathPrefix ?? "";
54
63
  const parts = [
55
- `ANTHROPIC_BASE_URL=http://127.0.0.1:${input.port}/anthropic`,
64
+ `ANTHROPIC_BASE_URL=http://127.0.0.1:${input.port}${prefix}/anthropic`,
56
65
  `ANTHROPIC_AUTH_TOKEN=${token}`
57
66
  ];
58
67
  if (input.defaults.opus) {
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
2
  import { setTimeout as defaultSleep } from "node:timers/promises";
3
3
  import { z } from "zod";
4
- import { modelsCachePath, modelsCacheReadPath } from "../config/home.js";
4
+ import { accountModelsCachePath, accountModelsCacheReadPath, modelsCachePath, modelsCacheReadPath } from "../config/home.js";
5
+ import { assertValidAccountId } from "../config/accountId.js";
5
6
  import { writeFileSecureAtomic } from "../config/fsSecurity.js";
6
7
  import { copilotBaseUrl } from "../config/upstream.js";
7
8
  const ModelSchema = z
@@ -50,7 +51,7 @@ const RETRYABLE_DISCOVERY_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503,
50
51
  export function accountBaseUrl(accountType) {
51
52
  return copilotBaseUrl(accountType);
52
53
  }
53
- export async function listModels(accountType, bearerToken, deps) {
54
+ export async function listModels(accountType, bearerToken, deps, accountId) {
54
55
  const fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
55
56
  const timeoutMs = deps?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
56
57
  try {
@@ -72,7 +73,7 @@ export async function listModels(accountType, bearerToken, deps) {
72
73
  if (!parsed.success) {
73
74
  throw new ModelDiscoverySchemaError("Model discovery response is invalid.");
74
75
  }
75
- saveModelCache(accountType, parsed.data);
76
+ saveModelCache(accountType, parsed.data, accountId);
76
77
  return {
77
78
  models: parsed.data,
78
79
  source: "live",
@@ -85,7 +86,7 @@ export async function listModels(accountType, bearerToken, deps) {
85
86
  if (!canUseCacheFallback(error)) {
86
87
  throw error;
87
88
  }
88
- const cached = readModelCache(accountType);
89
+ const cached = readModelCache(accountType, accountId);
89
90
  if (!cached) {
90
91
  const detail = error instanceof Error ? error.message : "unknown error";
91
92
  throw new Error(`Model discovery failed and no cache snapshot is available: ${detail}`);
@@ -119,7 +120,7 @@ export async function listModels(accountType, bearerToken, deps) {
119
120
  * specify. Each attempt's own retry budget lives inside `listModels`'s
120
121
  * cache-fallback path; this loop runs once per upstream call.
121
122
  */
122
- export async function listModelsUnion(accountType, bearerToken, attempts = 3, deps) {
123
+ export async function listModelsUnion(accountType, bearerToken, attempts = 3, deps, accountId) {
123
124
  const sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
124
125
  const seen = new Map();
125
126
  let lastResult = null;
@@ -127,7 +128,7 @@ export async function listModelsUnion(accountType, bearerToken, attempts = 3, de
127
128
  let consecutiveFailures = 0;
128
129
  for (let i = 0; i < attempts; i += 1) {
129
130
  try {
130
- const result = await listModels(accountType, bearerToken, deps);
131
+ const result = await listModels(accountType, bearerToken, deps, accountId);
131
132
  lastResult = result;
132
133
  consecutiveFailures = 0;
133
134
  for (const model of result.models) {
@@ -274,17 +275,38 @@ function canUseCacheFallback(error) {
274
275
  return true;
275
276
  }
276
277
  const CACHE_FALLBACK_STATUSES = new Set([401, 403, 408, 409, 425, 429]);
277
- function saveModelCache(accountType, models) {
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) {
278
300
  const payload = {
279
301
  version: 1,
280
302
  accountType,
281
303
  savedAtIso: new Date().toISOString(),
282
304
  models
283
305
  };
284
- writeFileSecureAtomic(modelsCachePath(), JSON.stringify(payload, null, 2), 0o600);
306
+ writeFileSecureAtomic(modelsCacheWriteFile(accountId), JSON.stringify(payload, null, 2), 0o600);
285
307
  }
286
- function readModelCache(accountType) {
287
- const filePath = modelsCacheReadPath();
308
+ function readModelCache(accountType, accountId) {
309
+ const filePath = modelsCacheReadFile(accountId);
288
310
  if (!fs.existsSync(filePath)) {
289
311
  return null;
290
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,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) {
@@ -48,6 +48,13 @@ export async function handleDebug(res, input) {
48
48
  bearer_present: input.tokenManager.current !== null,
49
49
  bearer_expires_at_unix: input.tokenManager.current?.expiresAtUnix ?? null
50
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
+ },
51
58
  user,
52
59
  user_error: userError,
53
60
  routes: [
@@ -4,16 +4,16 @@ import { buildCodexCatalog } from "../codexSchema.js";
4
4
  import { buildAnthropicModelsResponse } from "../anthropicModelsResponse.js";
5
5
  import { tokenErrorToHttpResponse } from "../errors.js";
6
6
  import { safeSendJson } from "../requestLifecycle.js";
7
- export async function handleModels(res, routeKind, config, tokenManager, githubToken) {
7
+ export async function handleModels(res, routeKind, account) {
8
8
  try {
9
- await tokenManager.ensureToken(false);
10
- if (!githubToken) {
9
+ await account.tokenManager.ensureToken(false);
10
+ if (!account.githubToken) {
11
11
  safeSendJson(res, 503, { error: "github_token_unavailable" });
12
12
  return;
13
13
  }
14
14
  const result = routeKind === "codex_models" || routeKind === "anthropic_models"
15
- ? await listModelsUnion(config.accountType, githubToken, 3)
16
- : 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);
17
17
  if (routeKind === "codex_models") {
18
18
  safeSendJson(res, 200, buildCodexCatalog(result.models));
19
19
  return;
@@ -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,