@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.
- package/connection/message-router.js +28 -2
- package/context.js +2 -0
- package/conversation.js +30 -12
- package/package.json +1 -1
- package/providers/copilot.js +108 -57
- package/yeaft/config-api.js +30 -17
- package/yeaft/config.js +21 -9
- package/yeaft/llm/provider-merge.js +53 -0
- package/yeaft/llm/router.js +32 -14
- package/yeaft/web-bridge.js +3 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
package/providers/copilot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
478
|
-
//
|
|
479
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/yeaft/config-api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
336
|
+
?? resolveContextWindow(modelIdForInfo, { modelInfo });
|
|
325
337
|
const resolvedMaxOutput = overrides.maxOutputTokens ?? jsonConfig.maxOutputTokens
|
|
326
|
-
?? resolveMaxOutputTokens(
|
|
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
|
+
}
|
package/yeaft/llm/router.js
CHANGED
|
@@ -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(
|
|
282
|
-
const hit = this.#modelToProvider.get(
|
|
285
|
+
async #resolveAdapter(modelRef) {
|
|
286
|
+
const hit = this.#modelToProvider.get(modelRef);
|
|
283
287
|
if (!hit) {
|
|
284
288
|
throw new Error(
|
|
285
|
-
`Model "${
|
|
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
|
-
|
|
441
|
-
|
|
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
|
}
|
package/yeaft/web-bridge.js
CHANGED
|
@@ -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,
|