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.
- package/README.md +69 -1
- package/dist/agentconfig/load.js +9 -1
- package/dist/agentconfig/schema.js +6 -0
- package/dist/auth/accountManager.js +118 -0
- package/dist/auth/accounts.js +161 -0
- package/dist/auth/credentials.js +196 -67
- package/dist/cli/agentEnv.js +2 -1
- package/dist/cli/auth/runAuth.js +243 -7
- 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 +27 -1
- package/dist/cli/copillmFlags.js +8 -0
- package/dist/cli/daemon/runDaemon.js +21 -2
- package/dist/cli/integrations/claudeExport.js +6 -4
- package/dist/cli/integrations/refreshCodex.js +4 -2
- package/dist/cli/integrations/refreshPi.js +4 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/config/accountId.js +44 -0
- package/dist/config/home.js +35 -0
- package/dist/integrations/codex/init.js +12 -3
- package/dist/integrations/pi/init.js +4 -3
- package/dist/models/anthropicDefaults.js +13 -4
- package/dist/models/discovery.js +32 -10
- package/dist/server/accountResolver.js +85 -0
- package/dist/server/proxy.js +40 -8
- package/dist/server/routes/debug.js +7 -0
- package/dist/server/routes/models.js +5 -5
- package/dist/server/routes/proxyForward.js +3 -3
- package/dist/server/routes/shared.js +66 -21
- package/package.json +1 -1
|
@@ -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
|
|
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) {
|
package/dist/cli/packageInfo.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/config/home.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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) {
|
package/dist/models/discovery.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
306
|
+
writeFileSecureAtomic(modelsCacheWriteFile(accountId), JSON.stringify(payload, null, 2), 0o600);
|
|
285
307
|
}
|
|
286
|
-
function readModelCache(accountType) {
|
|
287
|
-
const filePath =
|
|
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
|
+
}
|
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) {
|
|
@@ -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,
|
|
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(
|
|
16
|
-
: await listModels(
|
|
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,
|
|
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,
|