@yeaft/webchat-agent 0.1.937 → 0.1.939

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.
@@ -337,7 +337,25 @@ export async function handleMessage(msg) {
337
337
 
338
338
  // LLM configuration (read/write ~/.yeaft/config.json)
339
339
  case 'get_llm_config': {
340
- const config = getLlmConfig(ctx.CONFIG?.yeaftDir);
340
+ if (msg.globalConfig && typeof msg.globalConfig === 'object') {
341
+ ctx.globalLlmConfig = msg.globalConfig;
342
+ }
343
+ const config = getLlmConfig(ctx.CONFIG?.yeaftDir, ctx.globalLlmConfig);
344
+ sendToServer({ type: 'llm_config', ...config });
345
+ break;
346
+ }
347
+
348
+ case 'llm_global_config_updated': {
349
+ ctx.globalLlmConfig = msg.globalConfig && typeof msg.globalConfig === 'object'
350
+ ? msg.globalConfig
351
+ : { providers: [] };
352
+ // Existing sessions cache AdapterRouter instances. Drop them so the
353
+ // next Yeaft turn sees the new global providers, without writing them
354
+ // to the node-local config.json.
355
+ resetYeaftSession().catch(err => {
356
+ console.error('[LLM] Failed to reload Yeaft session after global config update:', err.message);
357
+ });
358
+ const config = getLlmConfig(ctx.CONFIG?.yeaftDir, ctx.globalLlmConfig);
341
359
  sendToServer({ type: 'llm_config', ...config });
342
360
  break;
343
361
  }
@@ -351,7 +369,10 @@ export async function handleMessage(msg) {
351
369
  const incomingLanguage = typeof msg.config?.language === 'string' && msg.config.language
352
370
  ? msg.config.language
353
371
  : null;
354
- const result = updateLlmConfig(msg.config || {}, ctx.CONFIG?.yeaftDir);
372
+ if (msg.globalConfig && typeof msg.globalConfig === 'object') {
373
+ ctx.globalLlmConfig = msg.globalConfig;
374
+ }
375
+ const result = updateLlmConfig(msg.config || {}, ctx.CONFIG?.yeaftDir, ctx.globalLlmConfig);
355
376
  // task-708: live locale propagation. When the user flips the UI
356
377
  // language dropdown, push the new value into every cached Engine
357
378
  // (per-VP pool + 1:1 chat session.engine) so the very next turn
@@ -362,6 +383,11 @@ export async function handleMessage(msg) {
362
383
  }
363
384
  if (!result.error) {
364
385
  refreshYeaftStatus({ reason: 'llm_config_updated' }).catch(() => {});
386
+ if (!incomingLanguage) {
387
+ resetYeaftSession().catch(err => {
388
+ console.error('[LLM] Failed to reload Yeaft session after local config update:', err.message);
389
+ });
390
+ }
365
391
  }
366
392
  sendToServer({ type: 'llm_config_updated', ...result });
367
393
  break;
package/context.js CHANGED
@@ -18,6 +18,8 @@ export default {
18
18
  slashCommandDescriptions: {},
19
19
  // MCP servers 列表 (从 ~/.claude.json 读取): [{ name, enabled, source }]
20
20
  mcpServers: [],
21
+ // Server-owned user-global LLM providers. Runtime-only: never persisted to ~/.yeaft/config.json.
22
+ globalLlmConfig: { providers: [] },
21
23
  // 连接相关
22
24
  reconnectTimer: null,
23
25
  pendingAuthTempId: null,
package/conversation.js CHANGED
@@ -457,11 +457,19 @@ export async function resumeConversation(msg) {
457
457
  if (id === conversationId || (claudeSessionId && conv.claudeSessionId === claudeSessionId)) {
458
458
  console.log(`[Resume] Cleaning up old conversation: ${id} (claudeSessionId: ${conv.claudeSessionId})`);
459
459
  if (conv.providerOptions && !priorProviderOptions) priorProviderOptions = conv.providerOptions;
460
- if (conv.abortController) {
461
- conv.abortController.abort();
462
- }
463
- if (conv.inputStream) {
464
- try { conv.inputStream.done(); } catch {}
460
+ let cleanupDriver = null;
461
+ try {
462
+ cleanupDriver = getProvider(conv.providerName || provider || DEFAULT_PROVIDER);
463
+ } catch { /* fallback to legacy cleanup below */ }
464
+ if (typeof cleanupDriver?.dispose === 'function') {
465
+ cleanupDriver.dispose(conv, 'resume cleanup');
466
+ } else {
467
+ if (conv.abortController) {
468
+ conv.abortController.abort();
469
+ }
470
+ if (conv.inputStream) {
471
+ try { conv.inputStream.done(); } catch {}
472
+ }
465
473
  }
466
474
  ctx.conversations.delete(id);
467
475
  }
@@ -783,17 +791,23 @@ export async function handleUserInput(msg) {
783
791
 
784
792
  // /clear for capable providers — reset session in-place without spawning new turn
785
793
  if (slashCommand.type === 'slash' && slashCommand.command === '/clear') {
794
+ state.turnActive = true;
795
+ state.turnCompletedEmitted = false;
796
+ state.turnErrorEmitted = false;
786
797
  if (typeof driver.clear === 'function' && driver.capabilities?.clear) {
787
798
  try { await driver.clear(state); } catch (err) {
788
799
  console.warn(`[${conversationId}] driver.clear failed:`, err?.message || err);
789
800
  }
790
801
  }
791
- ctx.sendToServer({
792
- type: 'turn_completed',
793
- conversationId,
794
- claudeSessionId: state.sessionId || state.claudeSessionId,
795
- workDir: state.workDir
796
- });
802
+ if (!state.turnCompletedEmitted) {
803
+ ctx.sendToServer({
804
+ type: 'turn_completed',
805
+ conversationId,
806
+ claudeSessionId: state.sessionId || state.claudeSessionId,
807
+ workDir: state.workDir
808
+ });
809
+ }
810
+ state.turnActive = false;
797
811
  return;
798
812
  }
799
813
 
@@ -812,6 +826,10 @@ export async function handleUserInput(msg) {
812
826
  });
813
827
  } finally {
814
828
  state.turnActive = false;
829
+ if (state._abortKillTimer) {
830
+ clearTimeout(state._abortKillTimer);
831
+ state._abortKillTimer = null;
832
+ }
815
833
  sendConversationList();
816
834
  }
817
835
  return;
@@ -976,7 +994,7 @@ export function handleAskUserAnswer(msg) {
976
994
  if (typeof driver.respondToPermissionRequest === 'function') {
977
995
  const ans = msg.answers || {};
978
996
  const optionId = typeof ans === 'string' ? ans
979
- : ans.optionId || ans.option || Object.values(ans)[0];
997
+ : ans.optionId || ans.option || ans['Copilot permission'] || Object.values(ans)[0];
980
998
  driver.respondToPermissionRequest(state, msg.requestId, optionId);
981
999
  return;
982
1000
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.937",
3
+ "version": "0.1.939",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -38,12 +38,7 @@ export async function start(opts) {
38
38
  const conversationId = opts.conversationId;
39
39
  // Tear down any prior entry so we don't leak children.
40
40
  const prior = ctx.conversations.get(conversationId);
41
- if (prior?.copilotChild) {
42
- try { prior.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
43
- }
44
- if (prior?.acpClient) {
45
- try { prior.acpClient.close('replaced'); } catch { /* noop */ }
46
- }
41
+ dispose(prior, 'replaced');
47
42
 
48
43
  const providerOptions = opts.providerOptions || prior?.providerOptions || {};
49
44
  const model = providerOptions.model || DEFAULT_COPILOT_MODEL;
@@ -116,31 +111,16 @@ async function _bootAcp(state, resumeSessionId, model) {
116
111
  }
117
112
  });
118
113
  child.on('error', (err) => {
119
- sendOutput(state.conversationId, {
120
- type: 'result',
121
- subtype: 'error',
122
- session_id: state.sessionId,
123
- is_error: true,
124
- error: `copilot process error: ${err?.message || err}`,
125
- });
114
+ _sendTurnError(state, `copilot process error: ${err?.message || err}`);
126
115
  });
127
116
  child.on('close', (code) => {
128
117
  if (state.turnActive) {
129
118
  const tail = stderrBuf.trim().slice(-2000);
130
- sendOutput(state.conversationId, {
131
- type: 'result',
132
- subtype: 'error',
133
- session_id: state.sessionId,
134
- is_error: true,
135
- error: tail || `copilot exited mid-turn (code ${code})`,
136
- });
137
- ctx.sendToServer({
138
- type: 'turn_completed',
139
- conversationId: state.conversationId,
140
- claudeSessionId: state.sessionId,
141
- workDir: state.workDir,
142
- });
143
- state.turnActive = false;
119
+ _sendTurnError(state, tail || `copilot exited mid-turn (code ${code})`);
120
+ _completeTurn(state);
121
+ }
122
+ if (state.acpClient) {
123
+ try { state.acpClient.close(`copilot exited (code ${code})`); } catch { /* noop */ }
144
124
  }
145
125
  // Drain any in-flight permission prompts so the frontend dialog unwedges
146
126
  // and the Promise GC roots release.
@@ -180,6 +160,7 @@ async function _bootAcp(state, resumeSessionId, model) {
180
160
  state.claudeSessionId = resumeSessionId;
181
161
  if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
182
162
  if (Array.isArray(r?.models?.availableModels)) cacheCopilotModelsFromAcp(r.models.availableModels);
163
+ _sendSessionIdUpdate(state);
183
164
  } else {
184
165
  if (resumeSessionId && !state.acpCapabilities.loadSession) {
185
166
  // Surface the downgrade — silently handing back a fresh session would
@@ -198,6 +179,7 @@ async function _bootAcp(state, resumeSessionId, model) {
198
179
  state.claudeSessionId = state.sessionId;
199
180
  if (Array.isArray(r?.modes?.availableModes)) state.acpModes = r.modes.availableModes;
200
181
  if (Array.isArray(r?.models?.availableModels)) cacheCopilotModelsFromAcp(r.models.availableModels);
182
+ _sendSessionIdUpdate(state);
201
183
  }
202
184
 
203
185
  // 3) Emit a system_init envelope so the UI populates tools / model panels.
@@ -224,14 +206,8 @@ export async function sendInput(state, prompt, opts = {}) {
224
206
  try {
225
207
  await _bootAcp(state, state.sessionId || null, state.model);
226
208
  } catch (err) {
227
- sendOutput(conversationId, {
228
- type: 'result',
229
- subtype: 'error',
230
- session_id: state.sessionId,
231
- is_error: true,
232
- error: `copilot ACP reinit failed: ${err?.message || err}`,
233
- });
234
- ctx.sendToServer({ type: 'turn_completed', conversationId, claudeSessionId: state.sessionId, workDir: state.workDir });
209
+ _sendTurnError(state, `copilot ACP reinit failed: ${err?.message || err}`);
210
+ _completeTurn(state, conversationId);
235
211
  return;
236
212
  }
237
213
  }
@@ -247,6 +223,8 @@ export async function sendInput(state, prompt, opts = {}) {
247
223
  const abortController = new AbortController();
248
224
  state.abortController = abortController;
249
225
  state.turnActive = true;
226
+ state.turnCompletedEmitted = false;
227
+ state.turnErrorEmitted = false;
250
228
  state.turnResultReceived = false;
251
229
 
252
230
  // Build prompt content blocks. ACP ContentBlock variants: text, image,
@@ -289,21 +267,9 @@ export async function sendInput(state, prompt, opts = {}) {
289
267
  error: isErr ? `copilot stop_reason=${stopReason}` : undefined,
290
268
  });
291
269
  } catch (err) {
292
- sendOutput(conversationId, {
293
- type: 'result',
294
- subtype: 'error',
295
- session_id: state.sessionId,
296
- is_error: true,
297
- error: err?.message || String(err),
298
- });
270
+ _sendTurnError(state, err?.message || String(err));
299
271
  } finally {
300
- state.turnActive = false;
301
- ctx.sendToServer({
302
- type: 'turn_completed',
303
- conversationId,
304
- claudeSessionId: state.sessionId,
305
- workDir: state.workDir,
306
- });
272
+ _completeTurn(state, conversationId);
307
273
  }
308
274
  }
309
275
 
@@ -330,12 +296,44 @@ export function abort(state) {
330
296
  }
331
297
  }
332
298
 
299
+ export function dispose(state, reason = 'disposed') {
300
+ if (!state) return;
301
+ if (state.abortController) {
302
+ try { state.abortController.abort(); } catch { /* noop */ }
303
+ state.abortController = null;
304
+ }
305
+ if (state._abortKillTimer) {
306
+ clearTimeout(state._abortKillTimer);
307
+ state._abortKillTimer = null;
308
+ }
309
+ _drainPendingPermissions(state, reason);
310
+ if (state.acpClient) {
311
+ try { state.acpClient.close(reason); } catch { /* noop */ }
312
+ state.acpClient = null;
313
+ }
314
+ if (state.copilotChild) {
315
+ try { state.copilotChild.kill('SIGTERM'); } catch { /* noop */ }
316
+ state.copilotChild = null;
317
+ }
318
+ state.initialized = false;
319
+ state.turnActive = false;
320
+ }
321
+
333
322
  /**
334
323
  * /clear support: ask ACP for a brand-new session under the same
335
324
  * conversationId. Keeps the child alive — no spawn cost.
336
325
  */
337
326
  export async function clear(state) {
338
- if (!state?.acpClient) return;
327
+ if (!state) return;
328
+ if (!state.initialized || !state.acpClient) {
329
+ try {
330
+ await _bootAcp(state, null, state.model);
331
+ return;
332
+ } catch (err) {
333
+ _sendTurnError(state, `copilot ACP reinit failed during clear: ${err?.message || err}`);
334
+ return;
335
+ }
336
+ }
339
337
  // A fresh session invalidates any in-flight permission prompts.
340
338
  _drainPendingPermissions(state, 'session cleared');
341
339
  try {
@@ -345,6 +343,7 @@ export async function clear(state) {
345
343
  });
346
344
  state.sessionId = r?.sessionId || randomUUID();
347
345
  state.claudeSessionId = state.sessionId;
346
+ _sendSessionIdUpdate(state);
348
347
  sendOutput(state.conversationId, {
349
348
  type: 'system',
350
349
  subtype: 'init',
@@ -355,6 +354,7 @@ export async function clear(state) {
355
354
  permissionMode: state.allowAllTools ? 'bypassPermissions' : 'default',
356
355
  });
357
356
  } catch (err) {
357
+ _sendTurnError(state, `copilot clear failed: ${err?.message || err}`);
358
358
  if (ctx?.CONFIG?.debug) console.warn('[copilot] clear failed:', err?.message || err);
359
359
  }
360
360
  }
@@ -365,6 +365,40 @@ function sendOutput(conversationId, data) {
365
365
  ctx.sendToServer({ type: 'claude_output', conversationId, data });
366
366
  }
367
367
 
368
+ function _sendSessionIdUpdate(state) {
369
+ if (!state?.conversationId || !state.sessionId) return;
370
+ ctx.sendToServer({
371
+ type: 'session_id_update',
372
+ conversationId: state.conversationId,
373
+ claudeSessionId: state.sessionId,
374
+ workDir: state.workDir,
375
+ });
376
+ }
377
+
378
+ function _sendTurnError(state, error) {
379
+ if (!state || state.turnErrorEmitted) return;
380
+ state.turnErrorEmitted = true;
381
+ sendOutput(state.conversationId, {
382
+ type: 'result',
383
+ subtype: 'error',
384
+ session_id: state.sessionId,
385
+ is_error: true,
386
+ error,
387
+ });
388
+ }
389
+
390
+ function _completeTurn(state, conversationId = state?.conversationId) {
391
+ if (!state || state.turnCompletedEmitted) return;
392
+ state.turnCompletedEmitted = true;
393
+ state.turnActive = false;
394
+ ctx.sendToServer({
395
+ type: 'turn_completed',
396
+ conversationId,
397
+ claudeSessionId: state.sessionId,
398
+ workDir: state.workDir,
399
+ });
400
+ }
401
+
368
402
  function _handleAcpNotification(state, method, params) {
369
403
  if (method === 'session/update') {
370
404
  _handleSessionUpdate(state, params);
@@ -474,19 +508,36 @@ async function _handlePermissionRequest(state, params) {
474
508
  const allow = opt.find(o => o.kind === 'allow_always' || o.kind === 'allow_once') || opt[0];
475
509
  return { outcome: { outcome: 'selected', optionId: allow.optionId } };
476
510
  }
477
- // Otherwise route through the existing ask-user wire path. We do it inline
478
- // here using a per-state Promise; the frontend responds via the standard
479
- // `ask_user_response` message which conversation.js routes back into the
480
- // driver via `respondToPermissionRequest(state, requestId, optionId)`.
511
+ // Otherwise route through the existing AskUserQuestion UI. Emit the same
512
+ // Claude-style tool_use first, then link it with ask_user_question so the
513
+ // regular card renders and answer routing can stay provider-agnostic.
481
514
  const requestId = `copilot-perm-${randomUUID()}`;
515
+ const question = _formatPermissionPrompt(params);
516
+ const questions = [{
517
+ header: 'Copilot permission',
518
+ question,
519
+ options: opt.map(o => ({ label: o.name || o.optionId })),
520
+ multiSelect: false,
521
+ }];
522
+ sendOutput(state.conversationId, {
523
+ type: 'assistant',
524
+ message: {
525
+ role: 'assistant',
526
+ content: [{
527
+ type: 'tool_use',
528
+ id: requestId,
529
+ name: 'AskUserQuestion',
530
+ input: { questions },
531
+ }],
532
+ },
533
+ });
482
534
  return new Promise((resolve) => {
483
535
  state.pendingPermissions.set(requestId, { resolve, options: opt });
484
536
  ctx.sendToServer({
485
537
  type: 'ask_user_question',
486
538
  conversationId: state.conversationId,
487
539
  requestId,
488
- question: _formatPermissionPrompt(params),
489
- options: opt.map(o => ({ id: o.optionId, label: o.name || o.optionId, kind: o.kind })),
540
+ questions,
490
541
  });
491
542
  });
492
543
  }
@@ -518,7 +569,7 @@ export function respondToPermissionRequest(state, requestId, optionId) {
518
569
  return false;
519
570
  }
520
571
  state.pendingPermissions.delete(requestId);
521
- const opt = slot.options.find(o => o.optionId === optionId) || slot.options[0];
572
+ const opt = slot.options.find(o => o.optionId === optionId || o.name === optionId) || slot.options[0];
522
573
  slot.resolve({ outcome: { outcome: 'selected', optionId: opt?.optionId || optionId } });
523
574
  return true;
524
575
  }
@@ -13,6 +13,7 @@ import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, serializeModelForPersistence } from './models.js';
15
15
  import { normaliseYeaftSection } from './config.js';
16
+ import { mergeLlmConfigs } from './llm/provider-merge.js';
16
17
 
17
18
  /**
18
19
  * Read the LLM-relevant portion of config.json.
@@ -20,7 +21,7 @@ import { normaliseYeaftSection } from './config.js';
20
21
  * @param {string} [dir] — Yeaft data directory
21
22
  * @returns {{ providers, primaryModel, fastModel, language } | { error: string }}
22
23
  */
23
- export function getLlmConfig(dir) {
24
+ function readLocalLlmConfig(dir) {
24
25
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
25
26
  const configPath = join(root, 'config.json');
26
27
 
@@ -28,22 +29,27 @@ export function getLlmConfig(dir) {
28
29
  return { providers: [], primaryModel: null, fastModel: null, language: 'en', needsSetup: true };
29
30
  }
30
31
 
31
- try {
32
- const raw = readFileSync(configPath, 'utf8');
33
- const json = JSON.parse(raw);
34
- const providers = Array.isArray(json.providers) ? json.providers : [];
35
-
36
- // Detect if config still has default/placeholder values (first-time setup needed)
37
- const needsSetup = providers.length === 0 || providers.every(p =>
38
- p.apiKey === 'proxy' || p.apiKey === '' || !p.apiKey
39
- );
32
+ const raw = readFileSync(configPath, 'utf8');
33
+ const json = JSON.parse(raw);
34
+ const providers = Array.isArray(json.providers) ? json.providers : [];
35
+ return {
36
+ providers,
37
+ primaryModel: json.primaryModel || null,
38
+ fastModel: json.fastModel || null,
39
+ language: json.language || 'en',
40
+ needsSetup: providers.length === 0 || providers.every(p => p.apiKey === 'proxy' || p.apiKey === '' || (!p.apiKey && !p.credentialProvider)),
41
+ };
42
+ }
40
43
 
44
+ export function getLlmConfig(dir, globalConfig = {}) {
45
+ try {
46
+ const agentConfig = readLocalLlmConfig(dir);
47
+ const effectiveConfig = mergeLlmConfigs(globalConfig, agentConfig);
41
48
  return {
42
- providers,
43
- primaryModel: json.primaryModel || null,
44
- fastModel: json.fastModel || null,
45
- language: json.language || 'en',
46
- needsSetup,
49
+ ...effectiveConfig,
50
+ agentConfig,
51
+ effectiveConfig,
52
+ globalConfig: { providers: Array.isArray(globalConfig.providers) ? globalConfig.providers : [] },
47
53
  };
48
54
  } catch (e) {
49
55
  return { error: `Failed to read config.json: ${e.message}` };
@@ -58,7 +64,7 @@ export function getLlmConfig(dir) {
58
64
  * @param {string} [dir] — Yeaft data directory
59
65
  * @returns {{ providers, primaryModel, fastModel, language } | { error: string }}
60
66
  */
61
- export function updateLlmConfig(update, dir) {
67
+ export function updateLlmConfig(update, dir, globalConfig = {}) {
62
68
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
63
69
  const configPath = join(root, 'config.json');
64
70
 
@@ -120,12 +126,19 @@ export function updateLlmConfig(update, dir) {
120
126
  return { error: `Failed to write config.json: ${e.message}` };
121
127
  }
122
128
 
123
- return {
129
+ const agentConfig = {
124
130
  providers: Array.isArray(existing.providers) ? existing.providers : [],
125
131
  primaryModel: existing.primaryModel || null,
126
132
  fastModel: existing.fastModel || null,
127
133
  language: existing.language || 'en',
128
134
  };
135
+ const effectiveConfig = mergeLlmConfigs(globalConfig, agentConfig);
136
+ return {
137
+ ...effectiveConfig,
138
+ agentConfig,
139
+ effectiveConfig,
140
+ globalConfig: { providers: Array.isArray(globalConfig.providers) ? globalConfig.providers : [] },
141
+ };
129
142
  }
130
143
 
131
144
  // ─── Yeaft runtime settings (task-318) ────────────────────────────
package/yeaft/config.js CHANGED
@@ -23,6 +23,7 @@ import { existsSync, readFileSync } from 'fs';
23
23
  import { join } from 'path';
24
24
  import { DEFAULT_YEAFT_DIR } from './init.js';
25
25
  import { resolveModel, parseModelRef, normalizeProviderModels, resolveContextWindow, resolveMaxOutputTokens } from './models.js';
26
+ import { mergeLlmConfigs } from './llm/provider-merge.js';
26
27
 
27
28
  /** Default configuration values. */
28
29
  const DEFAULTS = {
@@ -294,36 +295,47 @@ export function loadConfig(overrides = {}) {
294
295
  }
295
296
 
296
297
  // ─── Build config from config.json ────────────────────────
297
- const providers = Array.isArray(jsonConfig.providers) ? jsonConfig.providers : null;
298
+ const agentProviders = Array.isArray(jsonConfig.providers) ? jsonConfig.providers : [];
299
+ const mergedLlmConfig = mergeLlmConfigs(overrides.globalLlmConfig || {}, {
300
+ providers: agentProviders,
301
+ primaryModel: jsonConfig.primaryModel || null,
302
+ fastModel: jsonConfig.fastModel || null,
303
+ language: jsonConfig.language || DEFAULTS.language,
304
+ });
305
+ const providers = mergedLlmConfig.providers;
298
306
 
299
307
  // Resolve primary model
300
308
  let model = 'claude-sonnet-4-20250514';
301
- let primaryModel = jsonConfig.primaryModel || null;
309
+ let modelIdForInfo = model;
310
+ let primaryModel = mergedLlmConfig.primaryModel || null;
302
311
  if (primaryModel) {
303
312
  const parsed = parseModelRef(primaryModel);
304
- model = parsed.modelId;
313
+ model = parsed.providerName?.startsWith('global:') ? primaryModel : parsed.modelId;
314
+ modelIdForInfo = parsed.modelId;
305
315
  }
306
316
 
307
317
  // Resolve fast model
308
- let fastModel = jsonConfig.fastModel || primaryModel || null;
318
+ let fastModel = mergedLlmConfig.fastModel || primaryModel || null;
309
319
  let fastModelId = null;
310
320
  if (fastModel) {
311
321
  const parsed = parseModelRef(fastModel);
312
- fastModelId = parsed.modelId;
322
+ fastModelId = parsed.providerName?.startsWith('global:') ? fastModel : parsed.modelId;
313
323
  }
314
324
 
315
325
  // Resolve model info for adapter/baseUrl/thinking metadata. Token limits
316
326
  // (contextWindow / maxOutputTokens) are NOT read from here — they live in
317
327
  // models.dev and are resolved via resolveContextWindow / resolveMaxOutputTokens
318
328
  // a few lines below so the live models.dev snapshot is the source of truth.
319
- const modelInfo = resolveModel(model);
329
+ // For disambiguated global provider refs (`global:<provider>/<model>`), keep
330
+ // the runtime model ref intact above but resolve metadata by the raw model id.
331
+ const modelInfo = resolveModel(modelIdForInfo);
320
332
 
321
333
  // Pre-resolve token limits once so we can both write them onto config and
322
334
  // pass `config` to the resolver chain consistently below.
323
335
  const resolvedMaxContext = overrides.maxContextTokens ?? jsonConfig.maxContextTokens
324
- ?? resolveContextWindow(model, { modelInfo });
336
+ ?? resolveContextWindow(modelIdForInfo, { modelInfo });
325
337
  const resolvedMaxOutput = overrides.maxOutputTokens ?? jsonConfig.maxOutputTokens
326
- ?? resolveMaxOutputTokens(model, { modelInfo });
338
+ ?? resolveMaxOutputTokens(modelIdForInfo, { modelInfo });
327
339
 
328
340
  const config = {
329
341
  // Model
@@ -335,7 +347,7 @@ export function loadConfig(overrides = {}) {
335
347
  modelInfo: modelInfo || null,
336
348
 
337
349
  // Providers
338
- providers: providers,
350
+ providers: providers.length > 0 ? providers : null,
339
351
 
340
352
  // General settings
341
353
  language: overrides.language || jsonConfig.language || DEFAULTS.language,
@@ -0,0 +1,53 @@
1
+ function cloneProvider(provider, scope) {
2
+ return {
3
+ ...provider,
4
+ scope,
5
+ source: scope,
6
+ originalName: provider.name,
7
+ models: Array.isArray(provider.models)
8
+ ? provider.models.map(m => (m && typeof m === 'object' ? { ...m } : m))
9
+ : [],
10
+ };
11
+ }
12
+
13
+ function modelId(entry) {
14
+ if (typeof entry === 'string') return entry;
15
+ if (entry && typeof entry === 'object' && typeof entry.id === 'string') return entry.id;
16
+ return '';
17
+ }
18
+
19
+ export function buildModelRef(provider, entry) {
20
+ const id = modelId(entry);
21
+ return provider?.name && id ? `${provider.name}/${id}` : id;
22
+ }
23
+
24
+ export function mergeLlmConfigs(globalConfig = {}, agentConfig = {}) {
25
+ const agentProviders = Array.isArray(agentConfig.providers)
26
+ ? agentConfig.providers.map(p => cloneProvider(p, 'agent'))
27
+ : [];
28
+ const localNames = new Set(agentProviders.map(p => p.name).filter(Boolean));
29
+ const usedNames = new Set(localNames);
30
+ const globalProviders = [];
31
+
32
+ for (const raw of Array.isArray(globalConfig.providers) ? globalConfig.providers : []) {
33
+ if (!raw?.name) continue;
34
+ const provider = cloneProvider(raw, 'global');
35
+ if (usedNames.has(provider.name)) {
36
+ let candidate = `global:${provider.name}`;
37
+ let i = 2;
38
+ while (usedNames.has(candidate)) candidate = `global:${provider.name}:${i++}`;
39
+ provider.name = candidate;
40
+ }
41
+ usedNames.add(provider.name);
42
+ globalProviders.push(provider);
43
+ }
44
+
45
+ const providers = [...globalProviders, ...agentProviders];
46
+ return {
47
+ providers,
48
+ primaryModel: agentConfig.primaryModel || null,
49
+ fastModel: agentConfig.fastModel || null,
50
+ language: agentConfig.language || 'en',
51
+ needsSetup: providers.length === 0 || providers.every(p => (!p.apiKey || p.apiKey === 'proxy') && !p.credentialProvider && !p.githubToken),
52
+ };
53
+ }
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { LLMAdapter } from './adapter.js';
25
- import { getThinkingCapability, normalizeEffort } from '../models.js';
25
+ import { getThinkingCapability, normalizeEffort, parseModelRef } from '../models.js';
26
26
  import { pairSanitize } from '../pair-sanitize.js';
27
27
 
28
28
  /**
@@ -106,7 +106,7 @@ export function filterEffortForModel(params) {
106
106
  const { effort: _drop, ...rest } = params;
107
107
  return rest;
108
108
  }
109
- const cap = getThinkingCapability(params.model);
109
+ const cap = getThinkingCapability(parseModelRef(params.model).modelId);
110
110
  if (!cap.supportsThinking || cap.thinkingProtocol === 'none') {
111
111
  const { effort: _drop, ...rest } = params;
112
112
  return rest;
@@ -220,6 +220,10 @@ export class AdapterRouter extends LLMAdapter {
220
220
  for (const raw of provider.models) {
221
221
  const entry = normalizeModelEntry(raw);
222
222
  if (!entry) continue;
223
+ const ref = provider.name ? `${provider.name}/${entry.id}` : entry.id;
224
+ if (!this.#modelToProvider.has(ref)) {
225
+ this.#modelToProvider.set(ref, { provider, entry });
226
+ }
223
227
  if (!this.#modelToProvider.has(entry.id)) {
224
228
  this.#modelToProvider.set(entry.id, { provider, entry });
225
229
  }
@@ -276,13 +280,13 @@ export class AdapterRouter extends LLMAdapter {
276
280
  * Resolve a model ID to its provider's adapter (lazy-created, cached).
277
281
  *
278
282
  * @param {string} modelId
279
- * @returns {Promise<LLMAdapter>}
283
+ * @returns {Promise<{adapter: LLMAdapter, modelId: string}>}
280
284
  */
281
- async #resolveAdapter(modelId) {
282
- const hit = this.#modelToProvider.get(modelId);
285
+ async #resolveAdapter(modelRef) {
286
+ const hit = this.#modelToProvider.get(modelRef);
283
287
  if (!hit) {
284
288
  throw new Error(
285
- `Model "${modelId}" not found in any provider. ` +
289
+ `Model "${modelRef}" not found in any provider. ` +
286
290
  `Available models: ${[...this.#modelToProvider.keys()].join(', ') || '(none)'}. ` +
287
291
  `Check your config.json providers[].models arrays.`
288
292
  );
@@ -308,7 +312,7 @@ export class AdapterRouter extends LLMAdapter {
308
312
  const apiKeyFp = apiKey ? this.#shortFingerprint(apiKey) : 'none';
309
313
  const cacheKey = `${provider.name}::${protocol}::${apiKeyFp}`;
310
314
  const cached = this.#adapterCache.get(cacheKey);
311
- if (cached) return cached;
315
+ if (cached) return { adapter: cached, modelId: entry.id };
312
316
 
313
317
  // Token rotation eviction: when a credential provider hands us a NEW
314
318
  // fingerprint for the same (provider, protocol) pair, drop the stale
@@ -345,7 +349,7 @@ export class AdapterRouter extends LLMAdapter {
345
349
  }
346
350
 
347
351
  this.#adapterCache.set(cacheKey, adapter);
348
- return adapter;
352
+ return { adapter, modelId: entry.id };
349
353
  }
350
354
 
351
355
  /**
@@ -362,6 +366,11 @@ export class AdapterRouter extends LLMAdapter {
362
366
  */
363
367
  async #resolveApiKey(provider) {
364
368
  const name = provider && provider.credentialProvider;
369
+ if (name === 'github-copilot' && provider?.githubToken) {
370
+ const { exchangeToken } = await import('./credentials/github-copilot.js');
371
+ const exchanged = await exchangeToken(provider.githubToken);
372
+ return exchanged.token;
373
+ }
365
374
  if (!name) return provider?.apiKey || '';
366
375
  const { getCredentialProvider, CREDENTIAL_PROVIDER_NAMES } = await import('./credentials/index.js');
367
376
  const cp = getCredentialProvider(name);
@@ -402,8 +411,8 @@ export class AdapterRouter extends LLMAdapter {
402
411
  async *stream(params) {
403
412
  const filtered = filterEffortForModel(params);
404
413
  const sanitized = sanitizeMessagesForWire(filtered);
405
- const adapter = await this.#resolveAdapter(sanitized.model);
406
- yield* adapter.stream(sanitized);
414
+ const { adapter, modelId } = await this.#resolveAdapter(sanitized.model);
415
+ yield* adapter.stream({ ...sanitized, model: modelId });
407
416
  }
408
417
 
409
418
  /**
@@ -415,8 +424,8 @@ export class AdapterRouter extends LLMAdapter {
415
424
  async call(params) {
416
425
  const filtered = filterEffortForModel(params);
417
426
  const sanitized = sanitizeMessagesForWire(filtered);
418
- const adapter = await this.#resolveAdapter(sanitized.model);
419
- return adapter.call(sanitized);
427
+ const { adapter, modelId } = await this.#resolveAdapter(sanitized.model);
428
+ return adapter.call({ ...sanitized, model: modelId });
420
429
  }
421
430
 
422
431
  /**
@@ -437,8 +446,17 @@ export class AdapterRouter extends LLMAdapter {
437
446
  */
438
447
  listAvailableModels() {
439
448
  const result = [];
440
- for (const [modelId, hit] of this.#modelToProvider) {
441
- result.push({ modelId, providerName: hit.provider.name });
449
+ const seen = new Set();
450
+ for (const provider of this.#providers) {
451
+ if (!Array.isArray(provider.models)) continue;
452
+ for (const raw of provider.models) {
453
+ const entry = normalizeModelEntry(raw);
454
+ if (!entry) continue;
455
+ const key = `${provider.name}/${entry.id}`;
456
+ if (seen.has(key)) continue;
457
+ seen.add(key);
458
+ result.push({ modelId: entry.id, providerName: provider.name });
459
+ }
442
460
  }
443
461
  return result;
444
462
  }
@@ -2725,6 +2725,7 @@ async function ensureSessionLoaded() {
2725
2725
  const yeaftDir = ctx.CONFIG?.yeaftDir;
2726
2726
  session = await loadSession({
2727
2727
  ...(yeaftDir && { dir: yeaftDir }),
2728
+ configOverrides: { globalLlmConfig: ctx.globalLlmConfig || { providers: [] } },
2728
2729
  skipMCP: false,
2729
2730
  skipSkills: false,
2730
2731
  serverMode: true,
@@ -3850,6 +3851,7 @@ export async function handleYeaftLoadHistory(msg) {
3850
3851
  const yeaftDir = ctx.CONFIG?.yeaftDir;
3851
3852
  session = await loadSession({
3852
3853
  ...(yeaftDir && { dir: yeaftDir }),
3854
+ configOverrides: { globalLlmConfig: ctx.globalLlmConfig || { providers: [] } },
3853
3855
  skipMCP: false,
3854
3856
  skipSkills: false,
3855
3857
  serverMode: true,
@@ -4165,6 +4167,7 @@ export async function resetYeaftSession() {
4165
4167
  const yeaftDir = ctx.CONFIG?.yeaftDir;
4166
4168
  session = await loadSession({
4167
4169
  ...(yeaftDir && { dir: yeaftDir }),
4170
+ configOverrides: { globalLlmConfig: ctx.globalLlmConfig || { providers: [] } },
4168
4171
  skipMCP: false,
4169
4172
  skipSkills: false,
4170
4173
  serverMode: true,