@yeaft/webchat-agent 0.1.873 → 0.1.874

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.873",
3
+ "version": "0.1.874",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -1,26 +1,26 @@
1
1
  /**
2
2
  * Copilot model list.
3
3
  *
4
- * The Copilot CLI accepts `--model <id>` and the agent-facing API exposes
5
- * `GET https://api.githubcopilot.com/models` the same endpoint VS Code's
6
- * model picker uses. We hit it on demand (cached in-process for 10 min),
7
- * filter to picker-enabled chat models, and fall back to a curated static
8
- * list if the network call fails (so the UI never shows an empty picker).
4
+ * Source of truth (in order): another module can prime the in-process cache
5
+ * by calling `cacheCopilotModelsFromAcp(availableModels)` after a successful
6
+ * `session/new` that response carries the full model list the CLI itself
7
+ * would show in its `/model` picker, including pricing/usage metadata. If
8
+ * the cache is cold when `listCopilotModels()` is called (e.g. the model
9
+ * picker is opened before any Copilot conversation has started), we spawn
10
+ * a one-shot `copilot --acp` child, do the `initialize` + `session/new`
11
+ * handshake to get the same list, then close. Falls back to a static curated
12
+ * list if even that fails.
9
13
  *
10
- * Auth re-uses the existing `agent/yeaft/llm/credentials/github-copilot.js`
11
- * credential pipeline gh CLI / env / persisted OAuth all work; the user
12
- * does not need to paste an API key as long as Copilot CLI is logged in.
14
+ * Why ACP and not the HTTP `/models` endpoint: the HTTP endpoint is gated
15
+ * by org policy on enterprise accounts and frequently returns only legacy
16
+ * models. ACP `session/new` always returns the real per-account picker list.
13
17
  */
14
18
 
15
- import { getApiToken, copilotRequestHeaders, validateRawToken, resolveRawToken } from '../yeaft/llm/credentials/github-copilot.js';
16
- import { readFile } from 'fs/promises';
17
- import { homedir } from 'os';
18
- import { join } from 'path';
19
+ import { spawn } from 'child_process';
20
+ import { AcpClient } from './acp-client.js';
19
21
 
20
- // Curated fallback — mirrors the CLI's own hardcoded default model list
21
- // (`jF` in @github/copilot/app.js@1.0.59). Used when /models is unavailable
22
- // or when org policy hides the picker list at the API (some enterprise orgs
23
- // gate it). `--model <id>` still accepts these IDs at chat time.
22
+ // Curated fallback — last resort if no ACP cache and no live probe works.
23
+ // Vendor inferred from id prefix when missing.
24
24
  export const FALLBACK_COPILOT_MODELS = Object.freeze([
25
25
  { id: 'claude-sonnet-4.6', label: 'Claude Sonnet 4.6', vendor: 'Anthropic' },
26
26
  { id: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5', vendor: 'Anthropic' },
@@ -32,7 +32,6 @@ export const FALLBACK_COPILOT_MODELS = Object.freeze([
32
32
  { id: 'gpt-5.5', label: 'GPT-5.5', vendor: 'OpenAI' },
33
33
  { id: 'gpt-5.4', label: 'GPT-5.4', vendor: 'OpenAI' },
34
34
  { id: 'gpt-5.3-codex', label: 'GPT-5.3 Codex', vendor: 'OpenAI' },
35
- { id: 'gpt-5.2-codex', label: 'GPT-5.2 Codex', vendor: 'OpenAI' },
36
35
  { id: 'gpt-5.2', label: 'GPT-5.2', vendor: 'OpenAI' },
37
36
  { id: 'gpt-5.4-mini', label: 'GPT-5.4 Mini', vendor: 'OpenAI' },
38
37
  { id: 'gpt-5-mini', label: 'GPT-5 Mini', vendor: 'OpenAI' },
@@ -41,58 +40,93 @@ export const FALLBACK_COPILOT_MODELS = Object.freeze([
41
40
 
42
41
  export const DEFAULT_COPILOT_MODEL = 'claude-sonnet-4.5';
43
42
 
44
- const MODELS_ENDPOINT = 'https://api.githubcopilot.com/models';
45
- const CACHE_TTL_MS = 10 * 60 * 1000;
46
- const COPILOT_CLI_CONFIG = join(homedir(), '.copilot', 'config.json');
43
+ const CACHE_TTL_MS = 30 * 60 * 1000;
44
+ const PROBE_TIMEOUT_MS = 8000;
45
+
46
+ let _cache = null; // { models, fetchedAt }
47
+ let _inflight = null;
48
+
49
+ function _vendorFromId(id) {
50
+ const s = String(id || '').toLowerCase();
51
+ if (s.startsWith('claude')) return 'Anthropic';
52
+ if (s.startsWith('gpt') || s.startsWith('o1') || s.startsWith('o3') || s.startsWith('chatgpt')) return 'OpenAI';
53
+ if (s.startsWith('gemini')) return 'Google';
54
+ return '';
55
+ }
56
+
57
+ function _normalizeAcpModel(m) {
58
+ if (!m || !m.modelId) return null;
59
+ // Skip "auto" sentinel — not a real model, just a router placeholder.
60
+ if (m.modelId === 'auto') return null;
61
+ const meta = m._meta || {};
62
+ return {
63
+ id: m.modelId,
64
+ label: m.name || m.modelId,
65
+ vendor: _vendorFromId(m.modelId),
66
+ usage: meta.copilotUsage || '', // "1x" / "0.33x" / "15x"
67
+ priceCategory: meta.copilotPriceCategory || '', // "low" / "medium" / "high"
68
+ enablement: meta.copilotEnablement || '', // "enabled" / "disabled"
69
+ };
70
+ }
47
71
 
48
72
  /**
49
- * Last-resort token source: the Copilot CLI itself caches an OAuth token at
50
- * ~/.copilot/config.json after `copilot login`. If the standard yeaft
51
- * credential pipeline (env / gh CLI / persisted device flow) didn't surface
52
- * a token, we re-use the CLI's. Same auth surface — no extra perm needed.
73
+ * Prime the in-process cache from an ACP `session/new` response (preferred).
74
+ * Called from copilot.js after a successful handshake.
53
75
  */
54
- async function _resolveCopilotCliToken() {
55
- try {
56
- const raw = await readFile(COPILOT_CLI_CONFIG, 'utf8');
57
- // Copilot CLI's config.json starts with `//` comments. Strip line comments
58
- // before JSON.parse the file otherwise has no string-context `//`.
59
- const cleaned = raw.split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
60
- const cfg = JSON.parse(cleaned);
61
- const tokens = cfg?.copilotTokens && typeof cfg.copilotTokens === 'object' ? cfg.copilotTokens : null;
62
- if (!tokens) return null;
63
- for (const tok of Object.values(tokens)) {
64
- if (typeof tok === 'string' && validateRawToken(tok).valid) return tok;
65
- }
66
- return null;
67
- } catch {
68
- return null;
69
- }
76
+ export function cacheCopilotModelsFromAcp(availableModels) {
77
+ if (!Array.isArray(availableModels)) return;
78
+ const models = availableModels.map(_normalizeAcpModel).filter(Boolean);
79
+ if (!models.length) return;
80
+ _cache = { models, fetchedAt: Date.now() };
70
81
  }
71
82
 
72
- async function _resolveBearerToken() {
73
- // The /models endpoint accepts the GitHub OAuth token directly as a Bearer
74
- // no token exchange needed (unlike the chat-completion endpoints). So we
75
- // prefer the raw OAuth from the standard yeaft pipeline first, then fall
76
- // back to the Copilot CLI's own cached OAuth at ~/.copilot/config.json.
77
- try {
78
- const raw = await resolveRawToken();
79
- if (raw?.token) return raw.token;
80
- } catch { /* fall through */ }
81
- const cliRaw = await _resolveCopilotCliToken();
82
- if (cliRaw) return cliRaw;
83
- // Last resort: try exchanged token (works for chat endpoints; may also work
84
- // if /models gained that auth path in future).
85
- const cred = await getApiToken();
86
- return cred?.token || null;
87
- }
83
+ /**
84
+ * Spawn a short-lived `copilot --acp` child, do initialize + session/new,
85
+ * pull `models.availableModels` out of the response, then close. Times out
86
+ * after PROBE_TIMEOUT_MS. Resolves to an array (possibly empty) or rejects.
87
+ */
88
+ function _probeAcpModels() {
89
+ return new Promise((resolve, reject) => {
90
+ let child;
91
+ let client;
92
+ let done = false;
93
+ const finish = (err, models) => {
94
+ if (done) return;
95
+ done = true;
96
+ clearTimeout(timer);
97
+ try { client?.close(); } catch {}
98
+ try { child?.kill('SIGTERM'); } catch {}
99
+ if (err) reject(err); else resolve(models || []);
100
+ };
101
+ try {
102
+ child = spawn('copilot', ['--acp'], { stdio: ['pipe', 'pipe', 'pipe'] });
103
+ } catch (e) { return reject(e); }
104
+ const timer = setTimeout(() => finish(new Error('ACP probe timeout')), PROBE_TIMEOUT_MS);
105
+ child.on('error', (e) => finish(e));
106
+ child.on('exit', () => finish(new Error('ACP child exited')));
107
+ child.stderr.on('data', () => { /* swallow */ });
88
108
 
89
- let _cache = null; // { models, fetchedAt }
90
- let _inflight = null; // dedupes concurrent cold-cache calls
109
+ client = new AcpClient({
110
+ stdin: child.stdin,
111
+ stdout: child.stdout,
112
+ onError: (e) => finish(e),
113
+ });
114
+
115
+ (async () => {
116
+ try {
117
+ await client.request('initialize', { protocolVersion: 1, clientCapabilities: {} });
118
+ const r = await client.request('session/new', { cwd: process.cwd(), mcpServers: [] });
119
+ finish(null, r?.models?.availableModels || []);
120
+ } catch (e) {
121
+ finish(e);
122
+ }
123
+ })();
124
+ });
125
+ }
91
126
 
92
127
  /**
93
- * Returns a list of `{id, label, vendor, preview, family}` records,
94
- * picker-enabled chat models only. Cached for 10 minutes. Never throws
95
- * falls back to the static list on any error (including network timeout).
128
+ * Returns picker-enabled chat models for the signed-in Copilot account.
129
+ * Cache for 30 min. Never throws falls back to the curated static list.
96
130
  */
97
131
  export async function listCopilotModels({ force = false } = {}) {
98
132
  if (!force && _cache && Date.now() - _cache.fetchedAt < CACHE_TTL_MS) {
@@ -100,35 +134,15 @@ export async function listCopilotModels({ force = false } = {}) {
100
134
  }
101
135
  if (_inflight) return (await _inflight).slice();
102
136
  _inflight = (async () => {
103
- const ac = new AbortController();
104
- const timer = setTimeout(() => ac.abort(), 5000);
105
137
  try {
106
- const token = await _resolveBearerToken();
107
- if (!token) return FALLBACK_COPILOT_MODELS.slice();
108
- const res = await fetch(MODELS_ENDPOINT, {
109
- signal: ac.signal,
110
- headers: { ...copilotRequestHeaders({ isAgentTurn: false }), Authorization: `Bearer ${token}` },
111
- });
112
- if (!res.ok) return FALLBACK_COPILOT_MODELS.slice();
113
- const body = await res.json();
114
- const data = Array.isArray(body?.data) ? body.data : [];
115
- const models = data
116
- .filter(m => m && m.model_picker_enabled && m.capabilities?.type === 'chat')
117
- .map(m => ({
118
- id: m.id,
119
- label: m.name || m.id,
120
- vendor: m.vendor || '',
121
- preview: !!m.preview,
122
- family: m.capabilities?.family || '',
123
- }));
124
- if (!models.length) return FALLBACK_COPILOT_MODELS.slice();
125
- _cache = { models, fetchedAt: Date.now() };
126
- return models.slice();
127
- } catch {
128
- return FALLBACK_COPILOT_MODELS.slice();
129
- } finally {
130
- clearTimeout(timer);
131
- }
138
+ const acpModels = await _probeAcpModels();
139
+ const models = acpModels.map(_normalizeAcpModel).filter(Boolean);
140
+ if (models.length) {
141
+ _cache = { models, fetchedAt: Date.now() };
142
+ return models;
143
+ }
144
+ } catch { /* fall through to static */ }
145
+ return FALLBACK_COPILOT_MODELS.slice();
132
146
  })();
133
147
  try {
134
148
  return (await _inflight).slice();
@@ -139,3 +153,5 @@ export async function listCopilotModels({ force = false } = {}) {
139
153
 
140
154
  /** Tests only. */
141
155
  export function _resetCopilotModelsCacheForTests() { _cache = null; _inflight = null; }
156
+ /** Tests only. */
157
+ export function _normalizeAcpModelForTests(m) { return _normalizeAcpModel(m); }
@@ -6,7 +6,7 @@ import { join } from 'path';
6
6
  import { DatabaseSync } from 'node:sqlite';
7
7
  import ctx from '../context.js';
8
8
  import { AcpClient } from './acp-client.js';
9
- import { listCopilotModels, DEFAULT_COPILOT_MODEL } from './copilot-models.js';
9
+ import { listCopilotModels, DEFAULT_COPILOT_MODEL, cacheCopilotModelsFromAcp } from './copilot-models.js';
10
10
 
11
11
  export const name = 'copilot';
12
12
 
@@ -179,6 +179,7 @@ async function _bootAcp(state, resumeSessionId, model) {
179
179
  state.sessionId = resumeSessionId;
180
180
  state.claudeSessionId = resumeSessionId;
181
181
  if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
182
+ if (Array.isArray(r?.models?.availableModels)) cacheCopilotModelsFromAcp(r.models.availableModels);
182
183
  } else {
183
184
  if (resumeSessionId && !state.acpCapabilities.loadSession) {
184
185
  // Surface the downgrade — silently handing back a fresh session would
@@ -196,6 +197,7 @@ async function _bootAcp(state, resumeSessionId, model) {
196
197
  state.sessionId = r?.sessionId || randomUUID();
197
198
  state.claudeSessionId = state.sessionId;
198
199
  if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
200
+ if (Array.isArray(r?.models?.availableModels)) cacheCopilotModelsFromAcp(r.models.availableModels);
199
201
  }
200
202
 
201
203
  // 3) Emit a system_init envelope so the UI populates tools / model panels.