@yeaft/webchat-agent 0.1.838 → 0.1.840

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.838",
3
+ "version": "0.1.840",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,521 @@
1
+ /**
2
+ * github-copilot.js — GitHub Copilot credential provider.
3
+ *
4
+ * Resolves a GitHub OAuth token usable with the Copilot API and exchanges
5
+ * it for the short-lived Copilot API token. Ported from hermes' Python
6
+ * `copilot_auth.py` (https://github.com/hermes-agent), same client id and
7
+ * the same VS Code / Copilot CLI headers so a working `gh auth login` on
8
+ * the host machine is enough — the user does NOT need to paste an API key.
9
+ *
10
+ * Resolution order (matches hermes / Copilot CLI):
11
+ * 1. env COPILOT_GITHUB_TOKEN
12
+ * 2. env GH_TOKEN
13
+ * 3. env GITHUB_TOKEN
14
+ * 4. disk ~/.yeaft/credentials/github-copilot.json (persisted device flow)
15
+ * 5. shell `gh auth token` (with GITHUB_TOKEN/GH_TOKEN stripped so gh
16
+ * reads its own credential store instead of echoing env back)
17
+ *
18
+ * Token kinds:
19
+ * gho_* OAuth ✓
20
+ * github_pat_* Fine-grained PAT ✓ (needs Copilot Requests perm)
21
+ * ghu_* GitHub App token ✓
22
+ * ghp_* Classic PAT ✗ Copilot API rejects these.
23
+ *
24
+ * The exchanged Copilot API token is a short-lived (~30min) string used
25
+ * as `Authorization: Bearer <token>`. We cache it in-process keyed by a
26
+ * sha256 fingerprint of the raw token (so different raw tokens don't
27
+ * collide) and refresh 120s before expiry.
28
+ *
29
+ * NOTE: This module makes NO network calls at import time. All I/O is
30
+ * lazy. Tests can stub `fetch` and `child_process.execFile` to exercise
31
+ * every branch without touching real GitHub.
32
+ */
33
+
34
+ import { execFile } from 'child_process';
35
+ import { promisify } from 'util';
36
+ import { readFile, writeFile, mkdir, stat, chmod } from 'fs/promises';
37
+ import { createHash } from 'crypto';
38
+ import { homedir } from 'os';
39
+ import { join, dirname } from 'path';
40
+
41
+ const execFileAsync = promisify(execFile);
42
+
43
+ // Same client_id used by Copilot CLI / opencode / hermes. Public — fine to ship.
44
+ export const COPILOT_OAUTH_CLIENT_ID = 'Ov23li8tweQw6odWQebz';
45
+
46
+ const CLASSIC_PAT_PREFIX = 'ghp_';
47
+ const SUPPORTED_PREFIXES = ['gho_', 'github_pat_', 'ghu_'];
48
+ const ENV_VARS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'];
49
+
50
+ const TOKEN_EXCHANGE_URL = 'https://api.github.com/copilot_internal/v2/token';
51
+ const DEVICE_CODE_URL_TEMPLATE = host => `https://${host}/login/device/code`;
52
+ const ACCESS_TOKEN_URL_TEMPLATE = host => `https://${host}/login/oauth/access_token`;
53
+
54
+ const EDITOR_VERSION = 'vscode/1.104.1';
55
+ const EXCHANGE_USER_AGENT = 'GitHubCopilotChat/0.26.7';
56
+ const REQUEST_USER_AGENT = 'claude-web-chat/1.0';
57
+
58
+ const JWT_REFRESH_MARGIN_SECONDS = 120;
59
+ const DEVICE_POLL_SAFETY_MARGIN_SECONDS = 3;
60
+ const DEFAULT_DEVICE_TIMEOUT_SECONDS = 300;
61
+
62
+ const CREDENTIALS_DIR = join(homedir(), '.yeaft', 'credentials');
63
+ const CREDENTIALS_FILE = join(CREDENTIALS_DIR, 'github-copilot.json');
64
+
65
+ // In-process cache: rawTokenFingerprint → { apiToken, expiresAt }.
66
+ // Keyed by fingerprint (not the token itself) so we never keep the secret
67
+ // in two places. Reset via _resetCacheForTests.
68
+ const _jwtCache = new Map();
69
+
70
+ // In-flight exchange promises keyed by fingerprint. Dedupes concurrent
71
+ // `exchangeToken` calls for the same raw token so we make ONE network call
72
+ // instead of N when several requests race during a cold start or a refresh.
73
+ const _exchangeInFlight = new Map();
74
+
75
+ /**
76
+ * Reset all in-process state. Tests only.
77
+ */
78
+ export function _resetCacheForTests() {
79
+ _jwtCache.clear();
80
+ _exchangeInFlight.clear();
81
+ }
82
+
83
+ /**
84
+ * Anchor for `gh auth token` output validation. gh prints either a bare
85
+ * token followed by a newline or, on misconfiguration, a help banner /
86
+ * error text. We only accept output that looks like a GitHub token, so
87
+ * the help banner doesn't get treated as a credential.
88
+ */
89
+ const GH_TOKEN_SHAPE = /^(gho_|github_pat_|ghu_|ghs_)[A-Za-z0-9_]{20,}$/;
90
+
91
+ /**
92
+ * Validate a raw GitHub token shape. Copilot API rejects classic PATs.
93
+ *
94
+ * @param {string} token
95
+ * @returns {{valid: boolean, message: string}}
96
+ */
97
+ export function validateRawToken(token) {
98
+ const trimmed = (token || '').trim();
99
+ if (!trimmed) return { valid: false, message: 'Empty token' };
100
+ if (trimmed.startsWith(CLASSIC_PAT_PREFIX)) {
101
+ return {
102
+ valid: false,
103
+ message:
104
+ 'Classic Personal Access Tokens (ghp_*) are not supported by the ' +
105
+ 'Copilot API. Use `gh auth login` (produces gho_*) or a fine-grained ' +
106
+ 'PAT with the Copilot Requests permission.',
107
+ };
108
+ }
109
+ // Accept supported prefixes OR anything else that's non-empty — GitHub may
110
+ // introduce new prefixes; we surface server-side rejection later rather
111
+ // than refusing here.
112
+ const supported = SUPPORTED_PREFIXES.some(p => trimmed.startsWith(p));
113
+ if (!supported) {
114
+ return { valid: true, message: 'Unknown prefix, will try anyway' };
115
+ }
116
+ return { valid: true, message: 'OK' };
117
+ }
118
+
119
+ /**
120
+ * Read the persisted device-flow token, if any. Returns null on missing /
121
+ * malformed file. Does NOT throw — callers fall through to the next source.
122
+ *
123
+ * @returns {Promise<{token: string, source: string, obtainedAt: number} | null>}
124
+ */
125
+ export async function readPersistedToken() {
126
+ try {
127
+ const buf = await readFile(CREDENTIALS_FILE, 'utf8');
128
+ const obj = JSON.parse(buf);
129
+ if (obj && typeof obj.token === 'string' && obj.token) return obj;
130
+ return null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Persist a device-flow token. File mode is 0600 so other users on the
138
+ * machine can't read it. Directory created with 0700.
139
+ *
140
+ * @param {{token: string, source: string}} entry
141
+ */
142
+ export async function writePersistedToken({ token, source }) {
143
+ await mkdir(CREDENTIALS_DIR, { recursive: true, mode: 0o700 });
144
+ const payload = JSON.stringify({ token, source, obtainedAt: Date.now() }, null, 2);
145
+ await writeFile(CREDENTIALS_FILE, payload, { mode: 0o600 });
146
+ // mkdir's `mode` is masked by umask on some systems; ensure dir perms too.
147
+ try {
148
+ const s = await stat(CREDENTIALS_DIR);
149
+ if ((s.mode & 0o077) !== 0) {
150
+ await chmod(CREDENTIALS_DIR, 0o700);
151
+ }
152
+ } catch { /* best effort */ }
153
+ }
154
+
155
+ /**
156
+ * Run `gh auth token`. Strips GITHUB_TOKEN/GH_TOKEN from the subprocess
157
+ * env so gh reads `hosts.yml` instead of echoing the env back at us.
158
+ *
159
+ * @param {object} [opts]
160
+ * @param {string} [opts.hostname] — pass --hostname to gh
161
+ * @returns {Promise<string | null>}
162
+ */
163
+ export async function tryGhCliToken({ hostname } = {}) {
164
+ const args = ['auth', 'token'];
165
+ if (hostname) args.push('--hostname', hostname);
166
+
167
+ const env = { ...process.env };
168
+ delete env.GITHUB_TOKEN;
169
+ delete env.GH_TOKEN;
170
+
171
+ // Don't bother probing every Homebrew path — `gh` on PATH covers 99% of
172
+ // installs. If the user's `gh` isn't on PATH the env vars or device flow
173
+ // are the fallback.
174
+ try {
175
+ const { stdout } = await execFileAsync('gh', args, {
176
+ timeout: 5000,
177
+ env,
178
+ windowsHide: true,
179
+ });
180
+ const trimmed = (stdout || '').trim();
181
+ if (!trimmed) return null;
182
+ // Guard against gh printing a help banner / error text when not logged
183
+ // in. We only trust output that matches the GitHub token shape.
184
+ if (!GH_TOKEN_SHAPE.test(trimmed)) return null;
185
+ return trimmed;
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Resolve a raw GitHub token from env / disk / gh CLI. Returns
193
+ * `{token, source}` or `null` if no usable token is available.
194
+ *
195
+ * Sources are tried in priority order; the first valid one wins. Classic
196
+ * PATs (ghp_*) found in env are SKIPPED (they don't work with Copilot)
197
+ * rather than returned as an error — the next source still gets a chance.
198
+ *
199
+ * @param {object} [opts]
200
+ * @param {string} [opts.hostname] — passed through to gh CLI
201
+ * @returns {Promise<{token: string, source: string} | null>}
202
+ */
203
+ export async function resolveRawToken({ hostname } = {}) {
204
+ // 1-3: env vars
205
+ for (const name of ENV_VARS) {
206
+ const val = (process.env[name] || '').trim();
207
+ if (!val) continue;
208
+ if (validateRawToken(val).valid) {
209
+ return { token: val, source: `env:${name}` };
210
+ }
211
+ }
212
+
213
+ // 4: persisted device-flow token
214
+ const persisted = await readPersistedToken();
215
+ if (persisted && validateRawToken(persisted.token).valid) {
216
+ return { token: persisted.token, source: persisted.source || 'persisted' };
217
+ }
218
+
219
+ // 5: gh CLI
220
+ const ghToken = await tryGhCliToken({ hostname });
221
+ if (ghToken && validateRawToken(ghToken).valid) {
222
+ return { token: ghToken, source: 'gh-cli' };
223
+ }
224
+
225
+ return null;
226
+ }
227
+
228
+ /**
229
+ * sha256-prefix fingerprint of a raw token. Never log the token itself.
230
+ */
231
+ function tokenFingerprint(rawToken) {
232
+ return createHash('sha256').update(rawToken, 'utf8').digest('hex').slice(0, 16);
233
+ }
234
+
235
+ /**
236
+ * Exchange a raw GitHub token for a short-lived Copilot API token. Caches
237
+ * in-process; refreshes 120s before expiry. On failure, throws — caller
238
+ * may choose to fall back to the raw token (see `getApiToken`).
239
+ *
240
+ * @param {string} rawToken
241
+ * @param {object} [opts]
242
+ * @param {typeof fetch} [opts.fetchFn] — for tests
243
+ * @returns {Promise<{apiToken: string, expiresAt: number}>}
244
+ */
245
+ export async function exchangeToken(rawToken, { fetchFn = fetch } = {}) {
246
+ if (!rawToken) throw new Error('exchangeToken: empty rawToken');
247
+
248
+ const fp = tokenFingerprint(rawToken);
249
+ const cached = _jwtCache.get(fp);
250
+ const now = Math.floor(Date.now() / 1000);
251
+ if (cached && now < cached.expiresAt - JWT_REFRESH_MARGIN_SECONDS) {
252
+ return cached;
253
+ }
254
+
255
+ // Dedupe: if another caller is already exchanging the same token, await
256
+ // their result instead of firing a parallel request. Cleared in finally
257
+ // so a failure doesn't poison the next attempt.
258
+ const inFlight = _exchangeInFlight.get(fp);
259
+ if (inFlight) return inFlight;
260
+
261
+ const promise = (async () => {
262
+ const res = await fetchFn(TOKEN_EXCHANGE_URL, {
263
+ method: 'GET',
264
+ headers: {
265
+ Authorization: `token ${rawToken}`,
266
+ 'User-Agent': EXCHANGE_USER_AGENT,
267
+ Accept: 'application/json',
268
+ 'Editor-Version': EDITOR_VERSION,
269
+ },
270
+ });
271
+
272
+ if (!res.ok) {
273
+ const body = await res.text().catch(() => '');
274
+ throw new Error(`Copilot token exchange failed: HTTP ${res.status} ${body.slice(0, 200)}`);
275
+ }
276
+ const data = await res.json();
277
+ const apiToken = data && typeof data.token === 'string' ? data.token : '';
278
+ if (!apiToken) throw new Error('Copilot token exchange returned empty token');
279
+
280
+ const expiresAtRaw = data.expires_at;
281
+ const expiresAt =
282
+ typeof expiresAtRaw === 'number' && expiresAtRaw > 0
283
+ ? expiresAtRaw
284
+ : now + 1800; // hermes default: 30 minutes
285
+
286
+ const entry = { apiToken, expiresAt };
287
+ _jwtCache.set(fp, entry);
288
+ return entry;
289
+ })();
290
+
291
+ _exchangeInFlight.set(fp, promise);
292
+ try {
293
+ return await promise;
294
+ } finally {
295
+ _exchangeInFlight.delete(fp);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Convenience: resolve a raw token AND exchange it. On exchange failure,
301
+ * falls back to the raw token (matches hermes' `get_copilot_api_token`).
302
+ *
303
+ * Returns null if no raw token can be resolved at all.
304
+ *
305
+ * @param {object} [opts]
306
+ * @param {string} [opts.hostname]
307
+ * @param {typeof fetch} [opts.fetchFn]
308
+ * @returns {Promise<{token: string, source: string, exchanged: boolean} | null>}
309
+ */
310
+ export async function getApiToken({ hostname, fetchFn = fetch } = {}) {
311
+ const raw = await resolveRawToken({ hostname });
312
+ if (!raw) return null;
313
+ try {
314
+ const { apiToken } = await exchangeToken(raw.token, { fetchFn });
315
+ return { token: apiToken, source: raw.source, exchanged: true };
316
+ } catch {
317
+ return { token: raw.token, source: raw.source, exchanged: false };
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Headers that Copilot API expects in addition to Authorization. These
323
+ * match the VS Code / Copilot CLI conventions and unlock the internal-only
324
+ * models for accounts that have them.
325
+ *
326
+ * @param {object} [opts]
327
+ * @param {boolean} [opts.isAgentTurn] default true
328
+ * @param {boolean} [opts.isVision] default false
329
+ * @returns {Record<string, string>}
330
+ */
331
+ export function copilotRequestHeaders({ isAgentTurn = true, isVision = false } = {}) {
332
+ const h = {
333
+ 'Editor-Version': EDITOR_VERSION,
334
+ 'User-Agent': REQUEST_USER_AGENT,
335
+ 'Copilot-Integration-Id': 'vscode-chat',
336
+ 'Openai-Intent': 'conversation-edits',
337
+ 'x-initiator': isAgentTurn ? 'agent' : 'user',
338
+ };
339
+ if (isVision) h['Copilot-Vision-Request'] = 'true';
340
+ return h;
341
+ }
342
+
343
+ // ─── Device Flow (used by future UI handler) ─────────────────────
344
+
345
+ /**
346
+ * Step 1 of the GitHub OAuth device flow. Returns the user-visible
347
+ * verification URI + user code plus the device_code needed for polling.
348
+ *
349
+ * @param {object} [opts]
350
+ * @param {string} [opts.host] default github.com
351
+ * @param {typeof fetch} [opts.fetchFn]
352
+ * @returns {Promise<{deviceCode: string, userCode: string, verificationUri: string, interval: number, expiresIn: number}>}
353
+ */
354
+ export async function startDeviceFlow({ host = 'github.com', fetchFn = fetch } = {}) {
355
+ const body = new URLSearchParams({
356
+ client_id: COPILOT_OAUTH_CLIENT_ID,
357
+ scope: 'read:user',
358
+ });
359
+ const res = await fetchFn(DEVICE_CODE_URL_TEMPLATE(host), {
360
+ method: 'POST',
361
+ headers: {
362
+ Accept: 'application/json',
363
+ 'Content-Type': 'application/x-www-form-urlencoded',
364
+ 'User-Agent': REQUEST_USER_AGENT,
365
+ },
366
+ body: body.toString(),
367
+ });
368
+ if (!res.ok) {
369
+ throw new Error(`GitHub device code request failed: HTTP ${res.status}`);
370
+ }
371
+ const data = await res.json();
372
+ if (!data.device_code || !data.user_code) {
373
+ throw new Error('GitHub did not return a device code');
374
+ }
375
+ return {
376
+ deviceCode: data.device_code,
377
+ userCode: data.user_code,
378
+ verificationUri: data.verification_uri || `https://${host}/login/device`,
379
+ interval: Math.max(Number(data.interval) || 5, 1),
380
+ expiresIn: Number(data.expires_in) || DEFAULT_DEVICE_TIMEOUT_SECONDS,
381
+ };
382
+ }
383
+
384
+ /**
385
+ * Poll the access-token endpoint exactly once. Returns one of:
386
+ * { status: 'success', token }
387
+ * { status: 'pending' }
388
+ * { status: 'slow_down', interval } — caller MUST adopt the new interval
389
+ * { status: 'expired' }
390
+ * { status: 'denied' }
391
+ * { status: 'error', error }
392
+ *
393
+ * @param {{deviceCode: string, host?: string, fetchFn?: typeof fetch}} params
394
+ */
395
+ export async function pollDeviceFlow({ deviceCode, host = 'github.com', fetchFn = fetch }) {
396
+ const body = new URLSearchParams({
397
+ client_id: COPILOT_OAUTH_CLIENT_ID,
398
+ device_code: deviceCode,
399
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
400
+ });
401
+ const res = await fetchFn(ACCESS_TOKEN_URL_TEMPLATE(host), {
402
+ method: 'POST',
403
+ headers: {
404
+ Accept: 'application/json',
405
+ 'Content-Type': 'application/x-www-form-urlencoded',
406
+ 'User-Agent': REQUEST_USER_AGENT,
407
+ },
408
+ body: body.toString(),
409
+ });
410
+ // GitHub returns 200 even for the pending/slow_down cases; treat HTTP
411
+ // failures as transient and let the caller's loop retry.
412
+ if (!res.ok) {
413
+ return { status: 'error', error: `HTTP ${res.status}` };
414
+ }
415
+ const data = await res.json();
416
+ if (data.access_token) {
417
+ return { status: 'success', token: data.access_token };
418
+ }
419
+ switch (data.error) {
420
+ case 'authorization_pending':
421
+ return { status: 'pending' };
422
+ case 'slow_down': {
423
+ const next = Number(data.interval);
424
+ return { status: 'slow_down', interval: Number.isFinite(next) && next > 0 ? next : null };
425
+ }
426
+ case 'expired_token':
427
+ return { status: 'expired' };
428
+ case 'access_denied':
429
+ return { status: 'denied' };
430
+ default:
431
+ return { status: 'error', error: data.error || 'unknown' };
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Run the full device flow: kick off, then poll until success/failure/timeout.
437
+ * Calls `onPending({userCode, verificationUri, expiresIn})` immediately so
438
+ * the caller can present the code to the user.
439
+ *
440
+ * @param {object} opts
441
+ * @param {(info: {userCode: string, verificationUri: string, expiresIn: number}) => void} opts.onPending
442
+ * @param {AbortSignal} [opts.signal]
443
+ * @param {number} [opts.timeoutSeconds]
444
+ * @param {string} [opts.host]
445
+ * @param {typeof fetch} [opts.fetchFn]
446
+ * @param {(ms: number) => Promise<void>} [opts.sleepFn] — for tests
447
+ * @returns {Promise<string>} the raw OAuth token
448
+ */
449
+ export async function runDeviceFlow({
450
+ onPending,
451
+ signal,
452
+ timeoutSeconds = DEFAULT_DEVICE_TIMEOUT_SECONDS,
453
+ host = 'github.com',
454
+ fetchFn = fetch,
455
+ sleepFn = ms => new Promise(r => setTimeout(r, ms)),
456
+ } = {}) {
457
+ const start = await startDeviceFlow({ host, fetchFn });
458
+ if (typeof onPending === 'function') {
459
+ onPending({
460
+ userCode: start.userCode,
461
+ verificationUri: start.verificationUri,
462
+ expiresIn: start.expiresIn,
463
+ });
464
+ }
465
+ const deadline = Date.now() + timeoutSeconds * 1000;
466
+ let interval = start.interval;
467
+ while (Date.now() < deadline) {
468
+ if (signal?.aborted) throw new Error('device flow aborted');
469
+ // Race the sleep against the abort signal so cancellation is observed
470
+ // immediately instead of waiting out the current poll interval.
471
+ await abortableSleep(sleepFn, (interval + DEVICE_POLL_SAFETY_MARGIN_SECONDS) * 1000, signal);
472
+ if (signal?.aborted) throw new Error('device flow aborted');
473
+ const r = await pollDeviceFlow({ deviceCode: start.deviceCode, host, fetchFn });
474
+ if (r.status === 'success') {
475
+ // Don't fail the flow if persist fails — the token in memory is still
476
+ // usable for this process. Warn so a recurring failure stays visible.
477
+ try {
478
+ await writePersistedToken({ token: r.token, source: 'device-flow' });
479
+ } catch (err) {
480
+ console.warn(`[copilot-auth] failed to persist device-flow token: ${err.message}`);
481
+ }
482
+ return r.token;
483
+ }
484
+ if (r.status === 'pending') continue;
485
+ if (r.status === 'slow_down') {
486
+ if (r.interval) interval = r.interval;
487
+ else interval += 5;
488
+ continue;
489
+ }
490
+ if (r.status === 'expired') throw new Error('device code expired');
491
+ if (r.status === 'denied') throw new Error('authorization denied');
492
+ // status === 'error' — treat as transient; loop continues.
493
+ }
494
+ throw new Error('device flow timed out');
495
+ }
496
+
497
+ /**
498
+ * Sleep that resolves either when the timeout elapses or when the abort
499
+ * signal fires (whichever comes first). The signal listener is removed in
500
+ * the cleanup paths so we don't leak listeners across many poll cycles.
501
+ */
502
+ function abortableSleep(sleepFn, ms, signal) {
503
+ if (!signal) return sleepFn(ms);
504
+ return new Promise(resolve => {
505
+ let done = false;
506
+ const onAbort = () => {
507
+ if (done) return;
508
+ done = true;
509
+ signal.removeEventListener('abort', onAbort);
510
+ resolve();
511
+ };
512
+ if (signal.aborted) return onAbort();
513
+ signal.addEventListener('abort', onAbort);
514
+ sleepFn(ms).then(() => {
515
+ if (done) return;
516
+ done = true;
517
+ signal.removeEventListener('abort', onAbort);
518
+ resolve();
519
+ });
520
+ });
521
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * credentials/index.js — Registry of credential providers.
3
+ *
4
+ * A "credential provider" knows how to produce an apiKey for a provider
5
+ * dynamically — at request time — instead of the user pasting a static
6
+ * string into config.json. Today we ship one: `github-copilot` (env vars,
7
+ * disk-cached device-flow token, or the `gh` CLI).
8
+ *
9
+ * Contract:
10
+ * getApiKey() → Promise<string>
11
+ * Throws if no credential is available so the router surfaces a
12
+ * clear error rather than sending an empty Authorization header.
13
+ *
14
+ * Routing-time contract: the registry is consulted ONLY when a provider
15
+ * config sets `credentialProvider: "<name>"`. Providers without this
16
+ * field follow the existing static `apiKey` path unchanged — that is the
17
+ * regression guard.
18
+ */
19
+
20
+ import * as githubCopilot from './github-copilot.js';
21
+
22
+ /**
23
+ * @typedef {{ getApiKey: () => Promise<string>, name: string }} CredentialProvider
24
+ */
25
+
26
+ /**
27
+ * @param {string} name
28
+ * @returns {CredentialProvider | null}
29
+ */
30
+ export function getCredentialProvider(name) {
31
+ if (name === 'github-copilot') {
32
+ return {
33
+ name: 'github-copilot',
34
+ async getApiKey() {
35
+ const r = await githubCopilot.getApiToken();
36
+ if (!r || !r.token) {
37
+ throw new Error(
38
+ 'github-copilot credential provider could not resolve a token. ' +
39
+ 'Try: set COPILOT_GITHUB_TOKEN/GH_TOKEN/GITHUB_TOKEN env var, ' +
40
+ 'or run `gh auth login`, or sign in via the device flow.'
41
+ );
42
+ }
43
+ return r.token;
44
+ },
45
+ };
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Names of registered credential providers. UI uses this to populate the
52
+ * picker; keep in sync with `getCredentialProvider` above.
53
+ */
54
+ export const CREDENTIAL_PROVIDER_NAMES = ['github-copilot'];
@@ -4,13 +4,19 @@
4
4
  * Given a providers array from config.json:
5
5
  * [{ name, baseUrl, apiKey, protocol?, models[] }, ...]
6
6
  *
7
- * The router resolves model provider, lazy-creates the right adapter
8
- * (AnthropicAdapter or OpenAIResponsesAdapter based on protocol), caches it,
9
- * and forwards stream()/call() to the resolved adapter.
7
+ * `models[]` accepts two shapes (mixable in the same provider):
8
+ * - bare string id: "gpt-5" ← legacy, still supported
9
+ * - object: { id: "gpt-5", protocol?: "..." } ← per-model override
10
10
  *
11
- * protocol must be one of:
12
- * - "anthropic" — Anthropic Messages API (required for claude-* models)
13
- * - "openai-responses" OpenAI Responses API (default for everything else)
11
+ * Effective protocol for each (provider, model) is resolved in this order:
12
+ * 1. per-model `protocol` override on the model entry
13
+ * 2. provider-level `protocol` (explicit config wins over inference)
14
+ * 3. heuristic by model id (claude-* → anthropic, gpt-/o1-/o3-/o4-/chatgpt-* → openai-responses)
15
+ * 4. default `openai-responses`
16
+ *
17
+ * This lets a single provider (e.g. GitHub Copilot, a unified proxy) serve
18
+ * both Anthropic and OpenAI families without splitting into two provider
19
+ * entries.
14
20
  *
15
21
  * Phase 7 removed the legacy "openai" (Chat Completions) protocol entirely.
16
22
  */
@@ -19,6 +25,55 @@ import { LLMAdapter } from './adapter.js';
19
25
  import { getThinkingCapability, normalizeEffort } from '../models.js';
20
26
  import { pairSanitize } from '../pair-sanitize.js';
21
27
 
28
+ /**
29
+ * Normalize a model entry to its `{id, protocol?}` object form. Accepts
30
+ * either a bare string or an object so legacy `models: ["gpt-5"]` configs
31
+ * keep working unchanged.
32
+ *
33
+ * @param {string|object} entry
34
+ * @returns {{id: string, protocol?: string} | null}
35
+ */
36
+ export function normalizeModelEntry(entry) {
37
+ if (typeof entry === 'string') {
38
+ return entry ? { id: entry } : null;
39
+ }
40
+ if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id) {
41
+ const out = { id: entry.id };
42
+ if (typeof entry.protocol === 'string' && entry.protocol) {
43
+ out.protocol = entry.protocol;
44
+ }
45
+ return out;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Infer the wire protocol from a model id when neither the model entry
52
+ * nor the provider declared one. Centralized so the LlmTab preview and
53
+ * the router agree on the same rule.
54
+ *
55
+ * Returns null when the id doesn't match a known family — the caller
56
+ * falls back to the provider-level protocol (or the global default).
57
+ */
58
+ export function inferProtocolFromModelId(modelId) {
59
+ if (typeof modelId !== 'string' || !modelId) return null;
60
+ const id = modelId.toLowerCase();
61
+ // Anthropic family: claude-*, claude (bare), or anything starting with
62
+ // "claude" so vendor-prefixed ids like "anthropic.claude-..." also match.
63
+ if (id.startsWith('claude') || id.includes('/claude') || id.includes('.claude')) {
64
+ return 'anthropic';
65
+ }
66
+ // OpenAI Responses-API family. Models that route through /v1/responses:
67
+ // gpt-*, o1*, o3*, o4*, chatgpt-*, codex-*, omni-*.
68
+ // Note: Chat-Completions-only models are intentionally NOT matched here —
69
+ // they fall through to the provider-level protocol and the router will
70
+ // refuse if that doesn't resolve to a supported value.
71
+ if (/^(gpt-|o1|o3|o4|chatgpt-|codex-|omni-)/.test(id)) {
72
+ return 'openai-responses';
73
+ }
74
+ return null;
75
+ }
76
+
22
77
  /**
23
78
  * task-327a: feature-flag accessor. Read lazily so tests can flip.
24
79
  */
@@ -123,10 +178,10 @@ function sliceUnchanged(original, cleaned) {
123
178
  * AdapterRouter — Implements LLMAdapter, routes by model → provider.
124
179
  */
125
180
  export class AdapterRouter extends LLMAdapter {
126
- /** @type {Map<string, object>} modelId provider config */
181
+ /** @type {Map<string, {provider: object, entry: {id: string, protocol?: string}}>} */
127
182
  #modelToProvider;
128
183
 
129
- /** @type {Map<string, LLMAdapter>} providerName → cached adapter */
184
+ /** @type {Map<string, LLMAdapter>} providerName::protocol → cached adapter */
130
185
  #adapterCache;
131
186
 
132
187
  /** @type {object[]} raw providers array */
@@ -142,13 +197,17 @@ export class AdapterRouter extends LLMAdapter {
142
197
  this.#modelToProvider = new Map();
143
198
  this.#adapterCache = new Map();
144
199
 
145
- // Build model → provider index
146
- // First provider wins if model appears in multiple providers
200
+ // Build model id { provider, entry } index. First provider wins if a
201
+ // model id appears in multiple providers. Each model entry may declare
202
+ // its own `protocol`; we keep the normalized entry so #effectiveProtocol
203
+ // can consult it later without re-parsing.
147
204
  for (const provider of providers) {
148
205
  if (!Array.isArray(provider.models)) continue;
149
- for (const modelId of provider.models) {
150
- if (!this.#modelToProvider.has(modelId)) {
151
- this.#modelToProvider.set(modelId, provider);
206
+ for (const raw of provider.models) {
207
+ const entry = normalizeModelEntry(raw);
208
+ if (!entry) continue;
209
+ if (!this.#modelToProvider.has(entry.id)) {
210
+ this.#modelToProvider.set(entry.id, { provider, entry });
152
211
  }
153
212
  }
154
213
  }
@@ -157,27 +216,46 @@ export class AdapterRouter extends LLMAdapter {
157
216
  /**
158
217
  * Resolve the effective wire protocol for a (provider, model) pair.
159
218
  *
160
- * Phase 7: only "anthropic" and "openai-responses" are supported. Claude
161
- * model IDs require provider.protocol === "anthropic" — there is no
162
- * chat-completions fallback any more.
219
+ * Resolution order:
220
+ * 1. Per-model entry override (provider.models[i].protocol)
221
+ * 2. Provider-level protocol (explicit config wins over inference)
222
+ * 3. Heuristic from model id (claude-* → anthropic, gpt-* → openai-responses)
223
+ * 4. Default "openai-responses"
224
+ *
225
+ * Claude model ids without an anthropic-compatible resolution still throw
226
+ * — chat-completions fallback was removed in Phase 7.
163
227
  *
164
228
  * @param {object} provider — Provider config
165
- * @param {string} modelId
229
+ * @param {{id: string, protocol?: string}} entry — Normalized model entry
166
230
  * @returns {'anthropic' | 'openai-responses'}
167
231
  */
168
- #effectiveProtocol(provider, modelId) {
169
- const declared = provider.protocol || 'openai-responses';
170
- if (typeof modelId === 'string' && modelId.startsWith('claude-')) {
171
- if (declared !== 'anthropic') {
232
+ #effectiveProtocol(provider, entry) {
233
+ const modelId = entry.id;
234
+ const perModel = entry.protocol;
235
+ const inferred = inferProtocolFromModelId(modelId);
236
+ const providerLevel = provider.protocol;
237
+ const resolved = perModel || providerLevel || inferred || 'openai-responses';
238
+
239
+ // Use the SAME predicate as inferProtocolFromModelId so the guard never
240
+ // disagrees with the inference (e.g. "my-claude-proxy" → infer=null →
241
+ // guard wouldn't fire either; "claude-opus-*" → infer=anthropic →
242
+ // guard enforces anthropic). Prevents confusing "resolved openai-responses
243
+ // for claude-*" errors on ids the heuristic didn't actually match.
244
+ if (inferred === 'anthropic') {
245
+ if (resolved !== 'anthropic') {
246
+ const parts = [];
247
+ if (perModel) parts.push(`per-model="${perModel}"`);
248
+ if (providerLevel) parts.push(`provider-level="${providerLevel}"`);
249
+ const detail = parts.length ? ` (${parts.join(', ')})` : '';
172
250
  throw new Error(
173
- `Claude models require provider.protocol="anthropic"; ` +
251
+ `Claude models require protocol="anthropic"; ` +
174
252
  `chat-completions fallback removed in Phase 7. ` +
175
- `Provider "${provider.name}" declares protocol="${declared}" for model "${modelId}".`
253
+ `Provider "${provider.name}" resolved protocol="${resolved}" for model "${modelId}"${detail}.`
176
254
  );
177
255
  }
178
256
  return 'anthropic';
179
257
  }
180
- return declared;
258
+ return resolved;
181
259
  }
182
260
 
183
261
  /**
@@ -187,36 +265,61 @@ export class AdapterRouter extends LLMAdapter {
187
265
  * @returns {Promise<LLMAdapter>}
188
266
  */
189
267
  async #resolveAdapter(modelId) {
190
- const provider = this.#modelToProvider.get(modelId);
191
- if (!provider) {
268
+ const hit = this.#modelToProvider.get(modelId);
269
+ if (!hit) {
192
270
  throw new Error(
193
271
  `Model "${modelId}" not found in any provider. ` +
194
272
  `Available models: ${[...this.#modelToProvider.keys()].join(', ') || '(none)'}. ` +
195
273
  `Check your config.json providers[].models arrays.`
196
274
  );
197
275
  }
276
+ const { provider, entry } = hit;
198
277
 
199
278
  // Compute the effective protocol per model — a single provider may need
200
279
  // two adapters (e.g. mixed config: openai-responses for gpt-5*, anthropic
201
280
  // for claude-*). Cache key includes the protocol.
202
- const protocol = this.#effectiveProtocol(provider, modelId);
203
- const cacheKey = `${provider.name}::${protocol}`;
281
+ const protocol = this.#effectiveProtocol(provider, entry);
282
+
283
+ // Resolve apiKey. Default path: provider.apiKey (static string from
284
+ // config.json) — completely unchanged from before. Opt-in path: when
285
+ // provider.credentialProvider is set, ask the credential registry for
286
+ // a live token. Throws with a clear hint if no token is available.
287
+ const apiKey = await this.#resolveApiKey(provider);
288
+
289
+ // Cache key includes a short fingerprint of the apiKey so that when a
290
+ // credential provider rotates the token (e.g. Copilot 30-min refresh)
291
+ // we rebuild the adapter rather than reuse one with a stale Authorization
292
+ // header. For static providers the apiKey never changes so this stays
293
+ // a one-time-build cache exactly like before.
294
+ const apiKeyFp = apiKey ? this.#shortFingerprint(apiKey) : 'none';
295
+ const cacheKey = `${provider.name}::${protocol}::${apiKeyFp}`;
204
296
  const cached = this.#adapterCache.get(cacheKey);
205
297
  if (cached) return cached;
206
298
 
299
+ // Token rotation eviction: when a credential provider hands us a NEW
300
+ // fingerprint for the same (provider, protocol) pair, drop the stale
301
+ // entry so the cache doesn't grow unboundedly over a long-lived process
302
+ // (Copilot tokens rotate every ~30 min). Static-apiKey providers never
303
+ // change fingerprint, so this loop never finds anything to evict for
304
+ // them — back-compat preserved.
305
+ const prefix = `${provider.name}::${protocol}::`;
306
+ for (const key of this.#adapterCache.keys()) {
307
+ if (key.startsWith(prefix)) this.#adapterCache.delete(key);
308
+ }
309
+
207
310
  let adapter;
208
311
 
209
312
  if (protocol === 'anthropic') {
210
313
  const { AnthropicAdapter } = await import('./anthropic.js');
211
314
  adapter = new AnthropicAdapter({
212
- apiKey: provider.apiKey,
315
+ apiKey,
213
316
  baseUrl: provider.baseUrl,
214
317
  });
215
318
  } else if (protocol === 'openai-responses') {
216
319
  // OpenAI Responses API (/v1/responses) — canonical OpenAI-compatible path.
217
320
  const { OpenAIResponsesAdapter } = await import('./openai-responses.js');
218
321
  adapter = new OpenAIResponsesAdapter({
219
- apiKey: provider.apiKey,
322
+ apiKey,
220
323
  baseUrl: provider.baseUrl,
221
324
  });
222
325
  } else {
@@ -231,6 +334,51 @@ export class AdapterRouter extends LLMAdapter {
231
334
  return adapter;
232
335
  }
233
336
 
337
+ /**
338
+ * Resolve the apiKey for a provider. If `credentialProvider` is set,
339
+ * delegate to the registry; otherwise return the static `apiKey` from
340
+ * config (unchanged from before this feature).
341
+ *
342
+ * Kept as a separate method so the credential registry is imported
343
+ * lazily — providers that don't use a credential provider never pay
344
+ * the import cost or touch `child_process` / disk.
345
+ *
346
+ * @param {object} provider
347
+ * @returns {Promise<string>}
348
+ */
349
+ async #resolveApiKey(provider) {
350
+ const name = provider && provider.credentialProvider;
351
+ if (!name) return provider?.apiKey || '';
352
+ const { getCredentialProvider, CREDENTIAL_PROVIDER_NAMES } = await import('./credentials/index.js');
353
+ const cp = getCredentialProvider(name);
354
+ if (!cp) {
355
+ throw new Error(
356
+ `Unknown credentialProvider "${name}" on provider "${provider.name}". ` +
357
+ `Known providers: ${CREDENTIAL_PROVIDER_NAMES.join(', ')}. ` +
358
+ `Remove the field to use the static apiKey.`
359
+ );
360
+ }
361
+ return cp.getApiKey();
362
+ }
363
+
364
+ /**
365
+ * Short stable fingerprint of an apiKey for use in the adapter cache key.
366
+ * Never log the apiKey itself. 8 hex chars is plenty for in-process
367
+ * uniqueness — we only need to distinguish "same token" vs "rotated".
368
+ */
369
+ #shortFingerprint(s) {
370
+ // Tiny non-crypto FNV-1a 32-bit hash — avoids loading `crypto` on the
371
+ // hot path. Collisions don't matter for security (the apiKey is still
372
+ // sent verbatim in the request); they only matter for cache freshness
373
+ // and FNV is more than enough.
374
+ let h = 0x811c9dc5;
375
+ for (let i = 0; i < s.length; i += 1) {
376
+ h ^= s.charCodeAt(i);
377
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
378
+ }
379
+ return h.toString(16).padStart(8, '0');
380
+ }
381
+
234
382
  /**
235
383
  * Stream a model response — routes to the correct provider adapter.
236
384
  *
@@ -264,7 +412,8 @@ export class AdapterRouter extends LLMAdapter {
264
412
  * @returns {object|null} — Provider config or null
265
413
  */
266
414
  getProviderForModel(modelId) {
267
- return this.#modelToProvider.get(modelId) || null;
415
+ const hit = this.#modelToProvider.get(modelId);
416
+ return hit ? hit.provider : null;
268
417
  }
269
418
 
270
419
  /**
@@ -274,8 +423,8 @@ export class AdapterRouter extends LLMAdapter {
274
423
  */
275
424
  listAvailableModels() {
276
425
  const result = [];
277
- for (const [modelId, provider] of this.#modelToProvider) {
278
- result.push({ modelId, providerName: provider.name });
426
+ for (const [modelId, hit] of this.#modelToProvider) {
427
+ result.push({ modelId, providerName: hit.provider.name });
279
428
  }
280
429
  return result;
281
430
  }
package/unify/models.js CHANGED
@@ -446,6 +446,9 @@ export function normalizeProviderModels(provider) {
446
446
  const max = coercePositiveInt(entry.maxOutput);
447
447
  if (ctx !== undefined) norm.contextWindow = ctx;
448
448
  if (max !== undefined) norm.maxOutput = max;
449
+ if (typeof entry.protocol === 'string' && entry.protocol.trim()) {
450
+ norm.protocol = entry.protocol.trim();
451
+ }
449
452
  out.push(norm);
450
453
  }
451
454
  // silently skip anything else (null / missing id / numbers)
@@ -465,10 +468,14 @@ export function serializeModelForPersistence(entry) {
465
468
  if (!entry || typeof entry !== 'object') return entry;
466
469
  const ctx = coercePositiveInt(entry.contextWindow);
467
470
  const max = coercePositiveInt(entry.maxOutput);
468
- if (ctx === undefined && max === undefined) return entry.id;
471
+ const proto = typeof entry.protocol === 'string' && entry.protocol.trim()
472
+ ? entry.protocol.trim()
473
+ : undefined;
474
+ if (ctx === undefined && max === undefined && proto === undefined) return entry.id;
469
475
  const obj = { id: entry.id };
470
476
  if (ctx !== undefined) obj.contextWindow = ctx;
471
477
  if (max !== undefined) obj.maxOutput = max;
478
+ if (proto !== undefined) obj.protocol = proto;
472
479
  return obj;
473
480
  }
474
481