copillm 0.2.7 → 0.2.9
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 +1 -1
- package/dist/agentconfig/render.js +65 -13
- package/dist/auth/copilotToken.js +92 -23
- package/dist/auth/credentials.js +47 -0
- package/dist/auth/deviceFlow.js +110 -23
- package/dist/auth/githubIdentity.js +14 -10
- package/dist/cli/agentEnv.js +13 -8
- package/dist/cli/commands/auth.js +31 -6
- package/dist/cli/commands/daemon.js +79 -17
- package/dist/cli/commands/models.js +0 -5
- package/dist/cli/daemon/lifecycle.js +26 -0
- package/dist/cli/daemon/probes.js +99 -33
- package/dist/cli/index.js +12 -0
- package/dist/cli/integrations/refreshCodex.js +3 -2
- package/dist/cli/integrations/refreshPi.js +3 -2
- package/dist/cli/packageInfo.js +1 -1
- package/dist/cli/shared/devMode.js +98 -0
- package/dist/config/config.js +13 -2
- package/dist/config/home.js +34 -0
- package/dist/integrations/claude/cache.js +5 -2
- package/dist/integrations/claude/settingsConflict.js +5 -2
- package/dist/integrations/codex/init.js +21 -9
- package/dist/integrations/pi/init.js +5 -15
- package/dist/models/discovery.js +112 -8
- package/dist/server/debugInfo.js +69 -24
- package/dist/server/errors.js +18 -0
- package/dist/server/routes/debug.js +4 -1
- package/dist/server/routes/models.js +7 -1
- package/dist/server/upstream/copilotClient.js +1 -30
- package/dist/server/upstream/retryPolicy.js +99 -0
- package/package.json +4 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import path from "node:path";
|
|
3
|
+
import { claudeConfigDir } from "../../config/home.js";
|
|
4
4
|
export function claudeGatewayCachePath() {
|
|
5
|
-
|
|
5
|
+
// Claude stores the gateway model-picker cache under its config home
|
|
6
|
+
// (CLAUDE_CONFIG_DIR). copillm owns that home, so we clear the copillm-owned
|
|
7
|
+
// copy — never the user's real ~/.claude.
|
|
8
|
+
return path.join(claudeConfigDir(), "cache", "gateway-models.json");
|
|
6
9
|
}
|
|
7
10
|
export function clearClaudeGatewayCache() {
|
|
8
11
|
const target = claudeGatewayCachePath();
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import
|
|
2
|
+
import { claudeConfigDir } from "../../config/home.js";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
export function claudeSettingsPath() {
|
|
5
|
-
|
|
5
|
+
// copillm-launched Claude reads settings from its copillm-owned config home
|
|
6
|
+
// (CLAUDE_CONFIG_DIR), so the conflict check inspects that file — not the
|
|
7
|
+
// user's real ~/.claude/settings.json.
|
|
8
|
+
return path.join(claudeConfigDir(), "settings.json");
|
|
6
9
|
}
|
|
7
10
|
export function detectClaudeSettingsConflicts(launcherEnv, settingsPathOverride) {
|
|
8
11
|
const settingsPath = settingsPathOverride ?? claudeSettingsPath();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { CopilotTokenManager } from "../../auth/copilotToken.js";
|
|
4
3
|
import { loadStoredCredential } from "../../auth/credentials.js";
|
|
5
4
|
import { loadConfig } from "../../config/config.js";
|
|
6
5
|
import { listModelsUnion } from "../../models/discovery.js";
|
|
@@ -8,14 +7,7 @@ import { ensureSecureDirectory, writeFileSecureAtomic } from "../../config/fsSec
|
|
|
8
7
|
import { buildCodexCatalog } from "../../server/codexSchema.js";
|
|
9
8
|
import { inspectLock } from "../../server/lock.js";
|
|
10
9
|
export async function generateCodexHome(options) {
|
|
11
|
-
const
|
|
12
|
-
const creds = await loadStoredCredential();
|
|
13
|
-
if (!creds) {
|
|
14
|
-
throw new Error("Not authenticated. Run `copillm login` first.");
|
|
15
|
-
}
|
|
16
|
-
const tokenManager = new CopilotTokenManager(creds.token);
|
|
17
|
-
await tokenManager.ensureToken(false);
|
|
18
|
-
const discovery = await listModelsUnion(config.accountType, creds.token, 3);
|
|
10
|
+
const { discovery } = await resolveStartContext(options.precomputed);
|
|
19
11
|
const catalog = buildCodexCatalog(discovery.models);
|
|
20
12
|
if (catalog.models.length === 0) {
|
|
21
13
|
throw new Error("No Codex-eligible models found in the live catalog.");
|
|
@@ -47,6 +39,26 @@ export async function generateCodexHome(options) {
|
|
|
47
39
|
exportCommand: `CODEX_HOME=${absOutDir} codex`
|
|
48
40
|
};
|
|
49
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Load credentials / config / model discovery once. When the caller has
|
|
44
|
+
* already done this work (e.g. `copillm start` orchestrating multiple init
|
|
45
|
+
* steps), it can pass them in via `precomputed` to skip the work.
|
|
46
|
+
*
|
|
47
|
+
* Exported so `generatePiHome` (and any future agent init) can use the
|
|
48
|
+
* same loader and the same "if precomputed, reuse it" contract.
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveStartContext(precomputed) {
|
|
51
|
+
if (precomputed) {
|
|
52
|
+
return precomputed;
|
|
53
|
+
}
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
const creds = await loadStoredCredential();
|
|
56
|
+
if (!creds) {
|
|
57
|
+
throw new Error("Not authenticated. Run `copillm login` first.");
|
|
58
|
+
}
|
|
59
|
+
const discovery = await listModelsUnion(config.accountType, creds.token, 3);
|
|
60
|
+
return { config, creds, discovery };
|
|
61
|
+
}
|
|
50
62
|
function pickDefaultModel(slugs) {
|
|
51
63
|
const preferred = ["gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.4", "gpt-5.2", "claude-opus-4.5", "claude-sonnet-4.6"];
|
|
52
64
|
for (const candidate of preferred) {
|
|
@@ -1,20 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
2
|
import path from "node:path";
|
|
4
|
-
import { CopilotTokenManager } from "../../auth/copilotToken.js";
|
|
5
|
-
import { loadStoredCredential } from "../../auth/credentials.js";
|
|
6
|
-
import { loadConfig } from "../../config/config.js";
|
|
7
|
-
import { listModelsUnion } from "../../models/discovery.js";
|
|
8
3
|
import { ensureSecureDirectory, writeFileSecureAtomic } from "../../config/fsSecurity.js";
|
|
4
|
+
import { piAgentDir } from "../../config/home.js";
|
|
5
|
+
import { resolveStartContext } from "../codex/init.js";
|
|
9
6
|
export async function generatePiHome(options) {
|
|
10
|
-
const
|
|
11
|
-
const creds = await loadStoredCredential();
|
|
12
|
-
if (!creds) {
|
|
13
|
-
throw new Error("Not authenticated. Run `copillm login` first.");
|
|
14
|
-
}
|
|
15
|
-
const tokenManager = new CopilotTokenManager(creds.token);
|
|
16
|
-
await tokenManager.ensureToken(false);
|
|
17
|
-
const discovery = await listModelsUnion(config.accountType, creds.token, 3);
|
|
7
|
+
const { discovery } = await resolveStartContext(options.precomputed);
|
|
18
8
|
const eligible = discovery.models.filter(isPickerEligible);
|
|
19
9
|
// Split the catalog by which upstream endpoint each model supports. Models
|
|
20
10
|
// that advertise `/chat/completions` flow through copillm's Anthropic surface
|
|
@@ -79,9 +69,9 @@ export async function generatePiHome(options) {
|
|
|
79
69
|
export function defaultOutputDir(home) {
|
|
80
70
|
return path.join(home, "pi");
|
|
81
71
|
}
|
|
82
|
-
/** Absolute path to
|
|
72
|
+
/** Absolute path to pi's `models.json`, under the copillm-owned pi agent dir. */
|
|
83
73
|
export function piModelsJsonPath() {
|
|
84
|
-
return path.join(
|
|
74
|
+
return path.join(piAgentDir(), "models.json");
|
|
85
75
|
}
|
|
86
76
|
/**
|
|
87
77
|
* Eligibility filter shared by both pi providers. Mirrors the gating in
|
package/dist/models/discovery.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { modelsCachePath, modelsCacheReadPath } from "../config/home.js";
|
|
4
5
|
import { writeFileSecureAtomic } from "../config/fsSecurity.js";
|
|
@@ -20,18 +21,47 @@ const MODEL_RESOLUTION_RULES = [
|
|
|
20
21
|
{ id: "separator-normalized", normalize: (value) => normalizeModelId(value) },
|
|
21
22
|
{ id: "snapshot-trimmed", normalize: (value) => trimDateSnapshot(normalizeModelId(value)) }
|
|
22
23
|
];
|
|
24
|
+
/**
|
|
25
|
+
* Per-attempt timeout for the `/models` fetch. The catalog is typically
|
|
26
|
+
* <50ms on a healthy connection, so 15s leaves plenty of room for slow
|
|
27
|
+
* networks without freezing `copillm start` for a full minute.
|
|
28
|
+
*/
|
|
29
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
30
|
+
/**
|
|
31
|
+
* Exponential backoff base for `listModelsUnion` retry loop. Mirrors the
|
|
32
|
+
* 200ms / 400ms / 800ms shape used by `src/server/upstream/copilotClient.ts`
|
|
33
|
+
* and the new `CopilotTokenManager.exchange()` retry path. Boundary rules
|
|
34
|
+
* (`eslint.config.js`) keep `models` from importing the shared
|
|
35
|
+
* `retryPolicy.ts`, so we re-declare the small handful of constants here
|
|
36
|
+
* rather than introduce a new architectural dependency.
|
|
37
|
+
*/
|
|
38
|
+
const DEFAULT_BACKOFF_BASE_MS = 200;
|
|
39
|
+
/**
|
|
40
|
+
* Statuses worth retrying — same set as `retryPolicy.ts`. 401/403/404 are
|
|
41
|
+
* NOT here because they signal terminal credential / endpoint failures
|
|
42
|
+
* and retrying just delays the error the user needs to see.
|
|
43
|
+
*
|
|
44
|
+
* `canUseCacheFallback` (further down) is intentionally MORE permissive:
|
|
45
|
+
* it also includes 401/403/408 so that a misbehaving upstream serving 401
|
|
46
|
+
* to a perfectly-good token can degrade to the cached snapshot instead of
|
|
47
|
+
* surfacing a misleading auth error.
|
|
48
|
+
*/
|
|
49
|
+
const RETRYABLE_DISCOVERY_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
23
50
|
export function accountBaseUrl(accountType) {
|
|
24
51
|
return copilotBaseUrl(accountType);
|
|
25
52
|
}
|
|
26
|
-
export async function listModels(accountType, bearerToken) {
|
|
53
|
+
export async function listModels(accountType, bearerToken, deps) {
|
|
54
|
+
const fetchImpl = deps?.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
55
|
+
const timeoutMs = deps?.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
27
56
|
try {
|
|
28
|
-
const response = await
|
|
57
|
+
const response = await fetchImpl(`${accountBaseUrl(accountType)}/models`, {
|
|
29
58
|
method: "GET",
|
|
30
59
|
headers: {
|
|
31
60
|
Authorization: `Bearer ${bearerToken}`,
|
|
32
61
|
"Content-Type": "application/json",
|
|
33
62
|
"User-Agent": "copillm/0.1.0"
|
|
34
|
-
}
|
|
63
|
+
},
|
|
64
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
35
65
|
});
|
|
36
66
|
if (!response.ok) {
|
|
37
67
|
throw new ModelDiscoveryHttpError(response.status);
|
|
@@ -40,7 +70,7 @@ export async function listModels(accountType, bearerToken) {
|
|
|
40
70
|
const candidateModels = extractModelArray(payload);
|
|
41
71
|
const parsed = z.array(ModelSchema).safeParse(candidateModels);
|
|
42
72
|
if (!parsed.success) {
|
|
43
|
-
throw new
|
|
73
|
+
throw new ModelDiscoverySchemaError("Model discovery response is invalid.");
|
|
44
74
|
}
|
|
45
75
|
saveModelCache(accountType, parsed.data);
|
|
46
76
|
return {
|
|
@@ -69,14 +99,37 @@ export async function listModels(accountType, bearerToken) {
|
|
|
69
99
|
};
|
|
70
100
|
}
|
|
71
101
|
}
|
|
72
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Run multiple discovery attempts and union the results across them.
|
|
104
|
+
*
|
|
105
|
+
* Two changes from the previous version:
|
|
106
|
+
*
|
|
107
|
+
* 1. **Exponential backoff between attempts** — was a tight loop that
|
|
108
|
+
* hammered Copilot 3× immediately on a 429 burst, extending the
|
|
109
|
+
* rate-limit lockout. Now sleeps 200ms × 2^(attempt-1) between
|
|
110
|
+
* iterations, matching the curve used in `copilotClient.ts` and the
|
|
111
|
+
* token-exchange retry.
|
|
112
|
+
* 2. **Short-circuit on terminal failures** — a schema-invalid 200 or
|
|
113
|
+
* a `Model discovery failed and no cache snapshot is available`
|
|
114
|
+
* surface no longer retries; both are deterministic failures that
|
|
115
|
+
* retrying can't fix and the misleading "across all attempts" error
|
|
116
|
+
* hid the real cause.
|
|
117
|
+
*
|
|
118
|
+
* `attempts` keeps its previous default of 3 for callers that don't
|
|
119
|
+
* specify. Each attempt's own retry budget lives inside `listModels`'s
|
|
120
|
+
* cache-fallback path; this loop runs once per upstream call.
|
|
121
|
+
*/
|
|
122
|
+
export async function listModelsUnion(accountType, bearerToken, attempts = 3, deps) {
|
|
123
|
+
const sleepImpl = deps?.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
73
124
|
const seen = new Map();
|
|
74
125
|
let lastResult = null;
|
|
75
126
|
let lastError;
|
|
127
|
+
let consecutiveFailures = 0;
|
|
76
128
|
for (let i = 0; i < attempts; i += 1) {
|
|
77
129
|
try {
|
|
78
|
-
const result = await listModels(accountType, bearerToken);
|
|
130
|
+
const result = await listModels(accountType, bearerToken, deps);
|
|
79
131
|
lastResult = result;
|
|
132
|
+
consecutiveFailures = 0;
|
|
80
133
|
for (const model of result.models) {
|
|
81
134
|
if (typeof model.id === "string" && !seen.has(model.id)) {
|
|
82
135
|
seen.set(model.id, model);
|
|
@@ -85,6 +138,26 @@ export async function listModelsUnion(accountType, bearerToken, attempts = 3) {
|
|
|
85
138
|
}
|
|
86
139
|
catch (error) {
|
|
87
140
|
lastError = error;
|
|
141
|
+
consecutiveFailures += 1;
|
|
142
|
+
// Schema failures are deterministic — same response shape, same error.
|
|
143
|
+
// Don't burn the rest of the retry budget; surface the real cause now.
|
|
144
|
+
if (error instanceof ModelDiscoverySchemaError) {
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
// HTTP failures that aren't on the retryable list (e.g. 401/403 with
|
|
148
|
+
// no cache, 404) are also deterministic. The cache-fallback path
|
|
149
|
+
// inside `listModels` has already had its chance to engage; if we got
|
|
150
|
+
// here it didn't.
|
|
151
|
+
if (error instanceof ModelDiscoveryHttpError && !RETRYABLE_DISCOVERY_STATUSES.has(error.status)) {
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Only sleep between attempts if the most recent attempt FAILED. Sleeping
|
|
156
|
+
// between successful attempts would burn wall-clock for no benefit when
|
|
157
|
+
// the union is already populated. Sleep schedule mirrors the rest of the
|
|
158
|
+
// codebase: 200ms × 2^(failure-1), so 200 → 400 → 800 between failures.
|
|
159
|
+
if (i < attempts - 1 && consecutiveFailures > 0) {
|
|
160
|
+
await sleepImpl(DEFAULT_BACKOFF_BASE_MS * Math.pow(2, consecutiveFailures - 1));
|
|
88
161
|
}
|
|
89
162
|
}
|
|
90
163
|
if (lastResult === null) {
|
|
@@ -157,19 +230,50 @@ export function resolveModelSelections(requestedModelIds, models) {
|
|
|
157
230
|
}
|
|
158
231
|
return { resolved, unresolved };
|
|
159
232
|
}
|
|
160
|
-
class ModelDiscoveryHttpError extends Error {
|
|
233
|
+
export class ModelDiscoveryHttpError extends Error {
|
|
161
234
|
status;
|
|
162
235
|
constructor(status) {
|
|
163
236
|
super(`Model discovery failed (${status}).`);
|
|
164
237
|
this.status = status;
|
|
238
|
+
this.name = "ModelDiscoveryHttpError";
|
|
165
239
|
}
|
|
166
240
|
}
|
|
241
|
+
export class ModelDiscoverySchemaError extends Error {
|
|
242
|
+
constructor(message) {
|
|
243
|
+
super(message);
|
|
244
|
+
this.name = "ModelDiscoverySchemaError";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Statuses + error types that allow degrading to the on-disk cache instead
|
|
249
|
+
* of failing the caller.
|
|
250
|
+
*
|
|
251
|
+
* Widened from the previous `429 || >= 500` to include 401, 403, 408 — the
|
|
252
|
+
* exact case the seed audit hit: a transient 401 from `api.github.com/...`
|
|
253
|
+
* or its proxy in front of `/models` would re-throw and tell the user
|
|
254
|
+
* `Model discovery failed and no cache snapshot is available.` even when
|
|
255
|
+
* a perfectly good cached catalog existed. With this widening, a fresh
|
|
256
|
+
* cache (typical for users who've run `copillm start` recently) hides the
|
|
257
|
+
* blip from agent surfaces.
|
|
258
|
+
*
|
|
259
|
+
* Schema errors are intentionally NOT cache-eligible: a 200 with a body
|
|
260
|
+
* shape we don't recognize is a deterministic failure that the cache
|
|
261
|
+
* can't paper over, and surfacing the real `Model discovery response is
|
|
262
|
+
* invalid.` error is more useful than silently serving stale data.
|
|
263
|
+
*
|
|
264
|
+
* Non-HTTP errors (transport / AbortError / generic) DO fall back — those
|
|
265
|
+
* are exactly the kinds of transient failures the cache exists for.
|
|
266
|
+
*/
|
|
167
267
|
function canUseCacheFallback(error) {
|
|
268
|
+
if (error instanceof ModelDiscoverySchemaError) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
168
271
|
if (error instanceof ModelDiscoveryHttpError) {
|
|
169
|
-
return error.status
|
|
272
|
+
return CACHE_FALLBACK_STATUSES.has(error.status) || error.status >= 500;
|
|
170
273
|
}
|
|
171
274
|
return true;
|
|
172
275
|
}
|
|
276
|
+
const CACHE_FALLBACK_STATUSES = new Set([401, 403, 408, 409, 425, 429]);
|
|
173
277
|
function saveModelCache(accountType, models) {
|
|
174
278
|
const payload = {
|
|
175
279
|
version: 1,
|
package/dist/server/debugInfo.js
CHANGED
|
@@ -1,37 +1,82 @@
|
|
|
1
|
+
import { setTimeout as defaultSleep } from "node:timers/promises";
|
|
1
2
|
import { githubUserUrl } from "../config/upstream.js";
|
|
3
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "./upstream/retryPolicy.js";
|
|
2
4
|
const CACHE_TTL_MS = 5 * 60 * 1_000;
|
|
5
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
3
6
|
let cached = null;
|
|
7
|
+
/**
|
|
8
|
+
* Fetch the GitHub user summary with bounded retries on transient failures.
|
|
9
|
+
*
|
|
10
|
+
* Was: single fetch, single attempt. A transient 502 from `api.github.com/user`
|
|
11
|
+
* caused `auth status` to hide the user's login and `/_debug` to report
|
|
12
|
+
* `user_error: github_user_lookup_failed_502` instead of the user object.
|
|
13
|
+
*
|
|
14
|
+
* Now: retries 5xx/429/408/409/425 + transient transport errors up to
|
|
15
|
+
* `maxAttempts` (default 3) with exponential backoff (200ms / 400ms). Does
|
|
16
|
+
* NOT retry 401/403/404 — those are terminal credential / endpoint signals
|
|
17
|
+
* and retrying just delays the error the caller needs to surface.
|
|
18
|
+
*
|
|
19
|
+
* Cache write only happens on success.
|
|
20
|
+
*/
|
|
4
21
|
export async function getGithubUserSummary(githubToken, options = {}) {
|
|
5
22
|
const now = Date.now();
|
|
6
23
|
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
7
24
|
return cached.summary;
|
|
8
25
|
}
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
27
|
+
const sleepImpl = options.sleepImpl ?? ((ms) => defaultSleep(ms));
|
|
28
|
+
const maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
|
|
29
|
+
let lastError;
|
|
30
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
31
|
+
let response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetchImpl(githubUserUrl(), {
|
|
34
|
+
headers: {
|
|
35
|
+
Authorization: `token ${githubToken}`,
|
|
36
|
+
Accept: "application/vnd.github+json",
|
|
37
|
+
"User-Agent": "copillm/0.1.0",
|
|
38
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
39
|
+
},
|
|
40
|
+
signal: typeof options.timeoutMs === "number" ? AbortSignal.timeout(options.timeoutMs) : undefined
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
lastError = error;
|
|
45
|
+
if (isRetryableTransportError(error) && attempt < maxAttempts) {
|
|
46
|
+
await sleepImpl(retryDelayMs(attempt));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
if (response.ok) {
|
|
52
|
+
const payload = (await response.json());
|
|
53
|
+
const summary = {
|
|
54
|
+
login: typeof payload.login === "string" ? payload.login : "",
|
|
55
|
+
id: typeof payload.id === "number" ? payload.id : 0,
|
|
56
|
+
name: typeof payload.name === "string" ? payload.name : null,
|
|
57
|
+
email: typeof payload.email === "string" ? payload.email : null,
|
|
58
|
+
type: typeof payload.type === "string" ? payload.type : "User",
|
|
59
|
+
avatar_url: typeof payload.avatar_url === "string" ? payload.avatar_url : null,
|
|
60
|
+
html_url: typeof payload.html_url === "string" ? payload.html_url : null,
|
|
61
|
+
plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
|
|
62
|
+
};
|
|
63
|
+
cached = { fetchedAt: Date.now(), summary };
|
|
64
|
+
return summary;
|
|
65
|
+
}
|
|
66
|
+
// Non-OK. 401/403/404 are terminal — fast-fail. Other retryable statuses
|
|
67
|
+
// (429, 5xx) drain the body and retry.
|
|
19
68
|
const detail = await response.text();
|
|
20
|
-
|
|
69
|
+
const snippet = detail.slice(0, 256);
|
|
70
|
+
lastError = new GithubUserFetchError(response.status, snippet);
|
|
71
|
+
if (isRetryableStatus(response.status) && attempt < maxAttempts) {
|
|
72
|
+
await sleepImpl(retryDelayMs(attempt));
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
throw lastError;
|
|
21
76
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
id: typeof payload.id === "number" ? payload.id : 0,
|
|
26
|
-
name: typeof payload.name === "string" ? payload.name : null,
|
|
27
|
-
email: typeof payload.email === "string" ? payload.email : null,
|
|
28
|
-
type: typeof payload.type === "string" ? payload.type : "User",
|
|
29
|
-
avatar_url: typeof payload.avatar_url === "string" ? payload.avatar_url : null,
|
|
30
|
-
html_url: typeof payload.html_url === "string" ? payload.html_url : null,
|
|
31
|
-
plan_name: typeof payload.plan?.name === "string" ? payload.plan.name : null
|
|
32
|
-
};
|
|
33
|
-
cached = { fetchedAt: now, summary };
|
|
34
|
-
return summary;
|
|
77
|
+
// Unreachable: every loop iteration either returns, throws, or continues
|
|
78
|
+
// (and continue is gated on `attempt < maxAttempts`). Defend anyway.
|
|
79
|
+
throw lastError ?? new Error("GitHub user lookup exhausted retries without error context.");
|
|
35
80
|
}
|
|
36
81
|
export function clearGithubUserCache() {
|
|
37
82
|
cached = null;
|
package/dist/server/errors.js
CHANGED
|
@@ -141,6 +141,24 @@ export function upstreamStatusCategory(status) {
|
|
|
141
141
|
return "upstream_error";
|
|
142
142
|
}
|
|
143
143
|
export function healthFailure(error) {
|
|
144
|
+
return tokenErrorToHttpResponse(error);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Map a `CopilotTokenManagerError` (or any unknown error from the token
|
|
148
|
+
* exchange / refresh path) to an HTTP response. Single source of truth for
|
|
149
|
+
* the `/healthz`, `/models`, `/codex/v1/models`, and `/anthropic/v1/models`
|
|
150
|
+
* routes — previously `routes/models.ts` collapsed every token failure to a
|
|
151
|
+
* flat 503 `token_refresh_failed`, hiding the credential-vs-blip
|
|
152
|
+
* distinction that callers (codex, pi, claude) need to decide whether to
|
|
153
|
+
* retry. The discrimination logic now lives here.
|
|
154
|
+
*
|
|
155
|
+
* Maps:
|
|
156
|
+
* - 401/403 from the upstream token endpoint → HTTP 401 `github_auth_invalid`
|
|
157
|
+
* - 5xx/429/other transient from upstream → HTTP 503 `token_exchange_failed`
|
|
158
|
+
* - other `CopilotTokenManagerError` → HTTP 401 `token_refresh_failed`
|
|
159
|
+
* - anything else → HTTP 503 `token_refresh_unavailable`
|
|
160
|
+
*/
|
|
161
|
+
export function tokenErrorToHttpResponse(error) {
|
|
144
162
|
if (error instanceof CopilotTokenExchangeError) {
|
|
145
163
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
146
164
|
return {
|
|
@@ -8,7 +8,10 @@ export async function handleDebug(res, input) {
|
|
|
8
8
|
let userError = null;
|
|
9
9
|
if (input.githubToken) {
|
|
10
10
|
try {
|
|
11
|
-
|
|
11
|
+
// Bound the GitHub user lookup so a slow `api.github.com` cannot hang
|
|
12
|
+
// the `/_debug` handler indefinitely. Matches the bound used by the
|
|
13
|
+
// CLI's `auth status` path (`githubIdentity.ts:42-44`).
|
|
14
|
+
const summary = await getGithubUserSummary(input.githubToken, { timeoutMs: 4_000 });
|
|
12
15
|
user = {
|
|
13
16
|
login: summary.login,
|
|
14
17
|
id: summary.id,
|
|
@@ -2,6 +2,7 @@ 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
7
|
export async function handleModels(res, routeKind, config, tokenManager, githubToken) {
|
|
7
8
|
try {
|
|
@@ -33,7 +34,12 @@ export async function handleModels(res, routeKind, config, tokenManager, githubT
|
|
|
33
34
|
}
|
|
34
35
|
catch (error) {
|
|
35
36
|
if (error instanceof CopilotTokenManagerError) {
|
|
36
|
-
|
|
37
|
+
// Discriminate credential failure (401/403 from upstream — terminal)
|
|
38
|
+
// from transient blip (5xx/429 from upstream — retryable by caller).
|
|
39
|
+
// Was: flat `503 token_refresh_failed` for both, which made codex/pi/
|
|
40
|
+
// claude blindly retry on the permanent case.
|
|
41
|
+
const mapped = tokenErrorToHttpResponse(error);
|
|
42
|
+
safeSendJson(res, mapped.httpStatus, mapped.payload);
|
|
37
43
|
return;
|
|
38
44
|
}
|
|
39
45
|
throw error;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { accountBaseUrl } from "../../models/discovery.js";
|
|
3
3
|
import { isBenignSocketError } from "../requestLifecycle.js";
|
|
4
|
+
import { isRetryableStatus, isRetryableTransportError, retryDelayMs } from "./retryPolicy.js";
|
|
4
5
|
const COPILOT_HEADERS = {
|
|
5
6
|
"Content-Type": "application/json",
|
|
6
7
|
"Copilot-Integration-Id": "vscode-chat",
|
|
@@ -17,9 +18,7 @@ const COPILOT_HEADERS = {
|
|
|
17
18
|
// flow through immediately.
|
|
18
19
|
"Accept-Encoding": "identity"
|
|
19
20
|
};
|
|
20
|
-
const RETRYABLE_UPSTREAM_STATUSES = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
21
21
|
const MAX_UPSTREAM_ATTEMPTS = 3;
|
|
22
|
-
const BASE_BACKOFF_MS = 200;
|
|
23
22
|
export async function postToCopilot(input) {
|
|
24
23
|
let forceRefresh = false;
|
|
25
24
|
let authRefreshRetried = false;
|
|
@@ -99,34 +98,6 @@ function abortErrorFromSignal(signal) {
|
|
|
99
98
|
err.name = "AbortError";
|
|
100
99
|
return err;
|
|
101
100
|
}
|
|
102
|
-
function isRetryableStatus(status) {
|
|
103
|
-
return RETRYABLE_UPSTREAM_STATUSES.has(status);
|
|
104
|
-
}
|
|
105
|
-
function retryDelayMs(attempt) {
|
|
106
|
-
return BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
107
|
-
}
|
|
108
|
-
function isRetryableTransportError(error) {
|
|
109
|
-
if (!error || typeof error !== "object") {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
const typedError = error;
|
|
113
|
-
const directCode = typedError.code?.toUpperCase();
|
|
114
|
-
const causeCode = typedError.cause?.code?.toUpperCase();
|
|
115
|
-
if (directCode === "ECONNRESET" || directCode === "ECONNREFUSED" || directCode === "ETIMEDOUT") {
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
if (causeCode === "ECONNRESET" || causeCode === "ECONNREFUSED" || causeCode === "ETIMEDOUT") {
|
|
119
|
-
return true;
|
|
120
|
-
}
|
|
121
|
-
if (!(typedError instanceof Error)) {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
const message = typedError.message.toLowerCase();
|
|
125
|
-
if (message.includes("timed out") || message.includes("timeout")) {
|
|
126
|
-
return true;
|
|
127
|
-
}
|
|
128
|
-
return message.includes("econnreset") || message.includes("econnrefused") || message.includes("enotfound");
|
|
129
|
-
}
|
|
130
101
|
async function discardUpstreamBody(response) {
|
|
131
102
|
try {
|
|
132
103
|
await response.arrayBuffer();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Shared retry primitives for upstream HTTP calls.
|
|
2
|
+
//
|
|
3
|
+
// Originally lived inline in `copilotClient.ts` only. Extracted so that
|
|
4
|
+
// other upstream-facing fetch sites (token exchange in `auth/copilotToken.ts`,
|
|
5
|
+
// future device-flow / model-discovery sites) can share the same policy
|
|
6
|
+
// instead of each rolling its own slightly-different version. The numerical
|
|
7
|
+
// constants and the retryable-status set must match `copilotClient.ts`'s
|
|
8
|
+
// previous behaviour exactly so existing tests and production semantics
|
|
9
|
+
// don't drift.
|
|
10
|
+
/**
|
|
11
|
+
* HTTP statuses that warrant a retry: transient server-side congestion /
|
|
12
|
+
* upstream outages. 401 is NOT here — auth failures are handled by a
|
|
13
|
+
* separate caller-driven "force refresh once" path in `copilotClient.ts`,
|
|
14
|
+
* and by `CopilotTokenManager.exchange()` as a terminal "bad credentials"
|
|
15
|
+
* signal.
|
|
16
|
+
*/
|
|
17
|
+
export const RETRYABLE_UPSTREAM_STATUSES = new Set([
|
|
18
|
+
408, 409, 425, 429, 500, 502, 503, 504
|
|
19
|
+
]);
|
|
20
|
+
export function isRetryableStatus(status) {
|
|
21
|
+
return RETRYABLE_UPSTREAM_STATUSES.has(status);
|
|
22
|
+
}
|
|
23
|
+
/** Base exponential backoff: 200ms × 2^(attempt-1). */
|
|
24
|
+
export const BASE_BACKOFF_MS = 200;
|
|
25
|
+
export function retryDelayMs(attempt) {
|
|
26
|
+
return BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempt - 1));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Node `fetch` / undici transport error codes worth retrying. Excludes
|
|
30
|
+
* permanent errors like EACCES, ENOSPC, certificate failures. Both the
|
|
31
|
+
* direct `error.code` and the wrapped `error.cause.code` are checked
|
|
32
|
+
* because undici wraps the underlying socket error in a `TypeError:
|
|
33
|
+
* fetch failed` whose `.cause` carries the real code.
|
|
34
|
+
*
|
|
35
|
+
* EAI_AGAIN, EHOSTUNREACH, ENETUNREACH catch the common transient DNS
|
|
36
|
+
* and routing failures (home networks, corp VPN flaps, macOS wake-from-
|
|
37
|
+
* sleep). UND_ERR_* are undici's own timeouts and socket errors.
|
|
38
|
+
*/
|
|
39
|
+
const RETRYABLE_TRANSPORT_CODES = new Set([
|
|
40
|
+
"ECONNRESET",
|
|
41
|
+
"ECONNREFUSED",
|
|
42
|
+
"ETIMEDOUT",
|
|
43
|
+
"EAI_AGAIN",
|
|
44
|
+
"EHOSTUNREACH",
|
|
45
|
+
"ENETUNREACH",
|
|
46
|
+
"EPIPE",
|
|
47
|
+
"UND_ERR_SOCKET",
|
|
48
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
49
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
50
|
+
"UND_ERR_BODY_TIMEOUT"
|
|
51
|
+
]);
|
|
52
|
+
const RETRYABLE_TRANSPORT_MESSAGE_SUBSTRINGS = [
|
|
53
|
+
"timed out",
|
|
54
|
+
"timeout",
|
|
55
|
+
"econnreset",
|
|
56
|
+
"econnrefused",
|
|
57
|
+
"enotfound",
|
|
58
|
+
"eai_again",
|
|
59
|
+
"ehostunreach",
|
|
60
|
+
"enetunreach",
|
|
61
|
+
"socket hang up",
|
|
62
|
+
"other side closed",
|
|
63
|
+
"fetch failed"
|
|
64
|
+
];
|
|
65
|
+
export function isRetryableTransportError(error) {
|
|
66
|
+
if (!error || typeof error !== "object") {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const typedError = error;
|
|
70
|
+
if (matchesRetryableCode(typedError.code)) {
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
// Recurse into .cause to handle undici's `TypeError: fetch failed` wrapper
|
|
74
|
+
// (and any other wrapper layers); bounded depth so a self-referential
|
|
75
|
+
// cause chain can't run away.
|
|
76
|
+
if (causeHasRetryableCode(typedError.cause, 0)) {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
if (!(typedError instanceof Error)) {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const message = typedError.message.toLowerCase();
|
|
83
|
+
return RETRYABLE_TRANSPORT_MESSAGE_SUBSTRINGS.some((needle) => message.includes(needle));
|
|
84
|
+
}
|
|
85
|
+
function matchesRetryableCode(code) {
|
|
86
|
+
if (typeof code !== "string")
|
|
87
|
+
return false;
|
|
88
|
+
return RETRYABLE_TRANSPORT_CODES.has(code.toUpperCase());
|
|
89
|
+
}
|
|
90
|
+
function causeHasRetryableCode(cause, depth) {
|
|
91
|
+
if (!cause || typeof cause !== "object" || depth > 5) {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const inner = cause;
|
|
95
|
+
if (matchesRetryableCode(inner.code)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return causeHasRetryableCode(inner.cause, depth + 1);
|
|
99
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copillm",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9",
|
|
4
4
|
"description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"scripts": {
|
|
27
27
|
"build": "tsc -p tsconfig.json",
|
|
28
28
|
"dev": "tsx src/cli.ts",
|
|
29
|
+
"dev:start": "npm run build && node dist/cli.js --dev start",
|
|
30
|
+
"dev:stop": "npm run build && node dist/cli.js --dev stop",
|
|
31
|
+
"dev:status": "node dist/cli.js --dev status",
|
|
29
32
|
"lint": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.tests.json --noEmit && eslint src",
|
|
30
33
|
"lint:boundaries": "eslint src",
|
|
31
34
|
"test": "vitest run",
|