copillm 0.2.9 → 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.
@@ -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,
@@ -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
- export function resolveRoute(method, rawUrl) {
123
- if (!method || !rawUrl) {
124
- return { kind: "not_found", anthroShape: false };
125
- }
126
- let pathname;
127
- try {
128
- pathname = new URL(rawUrl, "http://127.0.0.1").pathname;
129
- }
130
- catch {
131
- return { kind: "not_found", anthroShape: false };
132
- }
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.2.9",
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",