@bahulam/code 0.1.24 → 0.1.25

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Local Agent — T18: Direct LLM API calls, <100ms startup, offline.
3
- * Replaces the SSE backend for --local mode.
2
+ * Local Agent — CLI-side orchestration for --local mode.
3
+ * Model calls use the shared Bahulam Gateway; tools and the ReAct loop stay local.
4
4
  * Yields events matching the same format as BahulamStreamClient.
5
5
  */
6
6
 
@@ -14,9 +14,70 @@ import {
14
14
  withMessageBreakpoint,
15
15
  needsExplicitCacheControl,
16
16
  } from './cache-control.mjs';
17
+ import { DEFAULT_REASONING_MODEL } from '../config/model-defaults.mjs';
18
+ import {
19
+ SUMMARY_MARKER,
20
+ DISTILLATION_MARKER,
21
+ contextReductionConfig,
22
+ collapseMessages,
23
+ estimateMessagesTokens,
24
+ resolveContextBudget,
25
+ } from './context-reduction.mjs';
26
+ import { normalizeUsage } from './usage-normalization.mjs';
27
+ import { fetchWithRetry, RequestError, requestErrorData } from './request-retry.mjs';
17
28
 
18
29
  const MAX_ITERATIONS = 50;
19
30
 
31
+ function toOpenAITool(tool) {
32
+ return {
33
+ type: 'function',
34
+ function: {
35
+ name: tool.name,
36
+ description: tool.description || '',
37
+ parameters: tool.input_schema || { type: 'object', properties: {} },
38
+ },
39
+ };
40
+ }
41
+
42
+ function toOpenAIMessage(message) {
43
+ if (message.role === 'assistant' && Array.isArray(message.content)) {
44
+ const text = message.content
45
+ .filter(block => block.type === 'text')
46
+ .map(block => block.text || '')
47
+ .join('');
48
+ const toolCalls = message.content
49
+ .filter(block => block.type === 'tool_use')
50
+ .map(block => ({
51
+ id: block.id,
52
+ type: 'function',
53
+ function: {
54
+ name: block.name,
55
+ arguments: JSON.stringify(block.input || {}),
56
+ },
57
+ }));
58
+ return {
59
+ role: 'assistant',
60
+ content: text || null,
61
+ ...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
62
+ };
63
+ }
64
+
65
+ if (message.role === 'user' && Array.isArray(message.content)) {
66
+ const results = message.content.filter(block => block.type === 'tool_result');
67
+ if (results.length > 0) {
68
+ // OpenAI-compatible APIs require one role=tool message per result.
69
+ // The caller expands this marker in _callGateway below.
70
+ return results.map(block => ({
71
+ role: 'tool',
72
+ tool_call_id: block.tool_use_id,
73
+ content: String(block.content || ''),
74
+ }));
75
+ }
76
+ }
77
+
78
+ return { role: message.role, content: message.content };
79
+ }
80
+
20
81
  /** Tool schemas for the LLM — proper parameter definitions. */
21
82
  const TOOL_SCHEMAS = [
22
83
  {
@@ -201,6 +262,12 @@ export class LocalAgent {
201
262
  toolExecutor,
202
263
  verbose = false,
203
264
  openRouterKey = null,
265
+ gatewayUrl = null,
266
+ gatewayToken = null,
267
+ product = 'bahulam',
268
+ sessionId = null,
269
+ executionId = null,
270
+ approvalManager = null,
204
271
  cwd = null,
205
272
  systemPromptOverride = null,
206
273
  maxTurns = null,
@@ -211,10 +278,17 @@ export class LocalAgent {
211
278
  // the (scoped) toolExecutor; this only makes the schemas visible
212
279
  // to the model.
213
280
  extraToolSchemas = [],
281
+ summarizerModel = null,
214
282
  }) {
215
283
  this.apiKey = apiKey;
216
284
  this.openRouterKey = openRouterKey;
217
- this.model = model || 'claude-sonnet-4-20250514';
285
+ this.gatewayUrl = (gatewayUrl || '').replace(/\/+$/, '');
286
+ this.gatewayToken = gatewayToken;
287
+ this.product = product;
288
+ this.sessionId = sessionId;
289
+ this.executionId = executionId;
290
+ this.approvalManager = approvalManager;
291
+ this.model = model || DEFAULT_REASONING_MODEL;
218
292
  this.toolExecutor = toolExecutor;
219
293
  this.verbose = verbose;
220
294
  this.cwd = cwd || process.cwd();
@@ -224,11 +298,14 @@ export class LocalAgent {
224
298
  this.stagnationDetection = stagnationDetection;
225
299
  this.stagnationThreshold = stagnationThreshold;
226
300
  this.extraToolSchemas = Array.isArray(extraToolSchemas) ? extraToolSchemas : [];
301
+ this.summarizerModel = summarizerModel || process.env.BAHULAM_SUMMARIZE_MODEL || process.env.BAHULAM_CHAT_SUMMARIZER_MODEL || this.model;
227
302
  this._cancelled = false;
303
+ this._requestSequence = 0;
304
+ this._pendingInterventions = [];
228
305
  this.promptCache = new PromptCache();
229
306
  }
230
307
 
231
- async *execute(instruction, context = {}) {
308
+ async *execute(instruction, context = {}, priorHistory = []) {
232
309
  this._cancelled = false;
233
310
  const startTime = Date.now();
234
311
  let toolCount = 0;
@@ -249,7 +326,39 @@ export class LocalAgent {
249
326
 
250
327
  const tools = this._buildToolDefs();
251
328
  const systemPrompt = this._buildSystemPrompt(context, retrievedContext);
252
- const messages = [{ role: 'user', content: instruction }];
329
+ // Keep the same turn contract as BahulamStreamClient. The REPL owns
330
+ // the canonical agent history and passes it here when the npm-owned
331
+ // local/direct transports are selected.
332
+ const messages = Array.isArray(priorHistory)
333
+ ? priorHistory.map(message => ({ ...message }))
334
+ : [];
335
+ const lastMessage = messages[messages.length - 1];
336
+ if (lastMessage?.role !== 'user' || lastMessage.content !== instruction) {
337
+ messages.push({ role: 'user', content: instruction });
338
+ }
339
+
340
+ const applyReduction = async () => {
341
+ const reduction = await this._reduceContext(messages, { systemPrompt, tools });
342
+ if (!reduction) return null;
343
+ messages.splice(0, messages.length, ...reduction.messages);
344
+ if (Array.isArray(priorHistory)) {
345
+ priorHistory.splice(0, priorHistory.length, ...reduction.messages);
346
+ }
347
+ if (reduction.usage) {
348
+ this.promptCache.updateStats(reduction.usage);
349
+ usageTotals.input_tokens += reduction.usage.input_tokens || 0;
350
+ usageTotals.output_tokens += reduction.usage.output_tokens || 0;
351
+ usageTotals.cache_read_tokens += reduction.usage.cache_read_input_tokens || 0;
352
+ usageTotals.cache_creation_tokens += reduction.usage.cache_creation_input_tokens || 0;
353
+ }
354
+ return reduction;
355
+ };
356
+
357
+ // Check before the first call and again after every completed
358
+ // tool-call/result cycle. The latter prevents a long ReAct run from
359
+ // growing past the model budget before its next inference.
360
+ let reduction = await applyReduction();
361
+ if (reduction) yield { type: 'summarize', data: reduction.event };
253
362
 
254
363
  const stagnation = createStagnationTracker({
255
364
  enabled: this.stagnationDetection,
@@ -262,11 +371,45 @@ export class LocalAgent {
262
371
  return;
263
372
  }
264
373
 
374
+ // Match the remote live-steering contract. The REPL may submit a
375
+ // follow-up while a local model/tool call is in flight; apply it
376
+ // at the next safe model boundary and retain it in canonical
377
+ // agent history for the following turn.
378
+ const interventions = this._pendingInterventions.splice(0);
379
+ for (const intervention of interventions) {
380
+ const message = { role: 'user', content: intervention.instruction };
381
+ messages.push(message);
382
+ if (Array.isArray(priorHistory)) priorHistory.push({ ...message });
383
+ yield {
384
+ type: 'user_intervention_delivered',
385
+ data: {
386
+ intervention_id: intervention.interventionId,
387
+ delivered_at_tool: null,
388
+ },
389
+ };
390
+ }
391
+
392
+ if (i > 0) {
393
+ reduction = await applyReduction();
394
+ if (reduction) yield { type: 'summarize', data: reduction.event };
395
+ }
396
+
265
397
  let response;
266
398
  try {
267
399
  response = await this._callLLM(systemPrompt, messages, tools);
268
400
  } catch (err) {
269
- yield { type: 'error', data: { message: `LLM API error: ${err.message}`, fatal: true } };
401
+ const errorData = requestErrorData(err, {
402
+ phase: this.gatewayUrl ? 'gateway' : 'provider',
403
+ provider: this.gatewayUrl ? 'bahulam-gateway' : 'direct-provider',
404
+ });
405
+ yield {
406
+ type: 'error',
407
+ data: {
408
+ ...errorData,
409
+ message: `LLM API error: ${errorData.message}`,
410
+ fatal: true,
411
+ },
412
+ };
270
413
  return;
271
414
  }
272
415
 
@@ -283,6 +426,7 @@ export class LocalAgent {
283
426
  // Process content blocks
284
427
  let hasToolUse = false;
285
428
  const assistantContent = [];
429
+ const toolResults = [];
286
430
 
287
431
  for (const block of content) {
288
432
  if (block.type === 'text') {
@@ -298,11 +442,7 @@ export class LocalAgent {
298
442
  const message = stagnationMessage(name, stagnationResult.count);
299
443
  yield { type: 'stagnation', data: { tool: name, count: stagnationResult.count, message } };
300
444
  assistantContent.push(block);
301
- messages.push({ role: 'assistant', content: assistantContent.slice() });
302
- messages.push({
303
- role: 'user',
304
- content: [{ type: 'tool_result', tool_use_id: id, content: message }],
305
- });
445
+ toolResults.push({ tool_use_id: id, content: message });
306
446
  continue;
307
447
  }
308
448
 
@@ -312,7 +452,12 @@ export class LocalAgent {
312
452
  let result;
313
453
  const toolStart = Date.now();
314
454
  try {
315
- result = await this.toolExecutor.execute(name, input || {});
455
+ const approval = this.approvalManager
456
+ ? await this.approvalManager.check(name, input || {}, false, { source: 'local-agent' })
457
+ : { approved: true };
458
+ result = approval?.approved
459
+ ? await this.toolExecutor.execute(name, input || {})
460
+ : { success: false, output: `Tool call not approved: ${approval?.reason || name}` };
316
461
  } catch (err) {
317
462
  result = { success: false, output: `Error: ${err.message}` };
318
463
  }
@@ -350,15 +495,27 @@ export class LocalAgent {
350
495
  };
351
496
 
352
497
  assistantContent.push(block);
353
- messages.push({ role: 'assistant', content: assistantContent.slice() });
498
+ toolResults.push({
499
+ tool_use_id: id,
500
+ content: result.output || JSON.stringify(result),
501
+ });
502
+ }
503
+ }
504
+
505
+ // Keep one provider-shaped assistant turn for the complete tool batch.
506
+ // Appending the cumulative assistant content once per tool duplicates
507
+ // earlier tool calls and makes the next request grow quadratically.
508
+ if (hasToolUse) {
509
+ messages.push({ role: 'assistant', content: assistantContent.slice() });
510
+ for (const result of toolResults) {
354
511
  messages.push({
355
512
  role: 'user',
356
- content: [{ type: 'tool_result', tool_use_id: id, content: result.output || JSON.stringify(result) }],
513
+ content: [{ type: 'tool_result', ...result }],
357
514
  });
358
515
  }
359
516
  }
360
517
 
361
- if (!hasToolUse || stopReason === 'end_turn') {
518
+ if ((!hasToolUse || stopReason === 'end_turn') && this._pendingInterventions.length === 0) {
362
519
  const duration = (Date.now() - startTime) / 1000;
363
520
  yield {
364
521
  type: 'complete',
@@ -385,27 +542,93 @@ export class LocalAgent {
385
542
  };
386
543
  }
387
544
 
388
- async _callLLM(systemPrompt, messages, tools) {
545
+ async _callLLM(systemPrompt, messages, tools, modelOverride = this.model) {
546
+ // Local orchestration still uses the shared Bahulam Gateway for model
547
+ // access. The gateway resolves identity, credits/BYOK, and provider
548
+ // routing; this process owns the ReAct/tool loop.
549
+ if (this.gatewayUrl && this.gatewayToken) {
550
+ return this._callGateway(systemPrompt, messages, tools, modelOverride);
551
+ }
552
+
389
553
  const isClaude = this.model.startsWith('claude') || this.model.startsWith('anthropic/claude');
390
554
 
391
555
  // Use Anthropic direct API only for Claude models when we have an Anthropic key
392
556
  if (isClaude && this.apiKey && this.apiKey.startsWith('sk-ant-')) {
393
- return this._callClaude(systemPrompt, messages, tools);
557
+ return this._callClaude(systemPrompt, messages, tools, modelOverride);
394
558
  }
395
559
 
396
560
  // Everything else goes through OpenRouter (DeepSeek, GPT, Gemini, or Claude via OR)
397
561
  if (this.openRouterKey) {
398
- return this._callOpenRouter(systemPrompt, messages, tools);
562
+ return this._callOpenRouter(systemPrompt, messages, tools, modelOverride);
399
563
  }
400
564
 
401
565
  if (this.apiKey) {
402
- return this._callClaude(systemPrompt, messages, tools);
566
+ return this._callClaude(systemPrompt, messages, tools, modelOverride);
403
567
  }
404
568
 
405
569
  throw new Error('No API key configured. Set ANTHROPIC_API_KEY or configure OpenRouter key.');
406
570
  }
407
571
 
408
- async _callClaude(systemPrompt, messages, tools) {
572
+ async _callGateway(systemPrompt, messages, tools, modelOverride = this.model) {
573
+ const base = this.gatewayUrl.endsWith('/v1')
574
+ ? this.gatewayUrl
575
+ : `${this.gatewayUrl}/v1`;
576
+ const headers = {
577
+ Authorization: `Bearer ${this.gatewayToken}`,
578
+ 'Content-Type': 'application/json',
579
+ Accept: 'application/json',
580
+ 'X-Product': this.product,
581
+ 'X-Bahulam-Request-ID': this._nextRequestId(),
582
+ };
583
+ if (this.sessionId) headers['X-Bahulam-Session-ID'] = this.sessionId;
584
+ if (this.executionId) headers['X-Bahulam-Execution-ID'] = this.executionId;
585
+
586
+ const body = {
587
+ model: modelOverride,
588
+ messages: [
589
+ { role: 'system', content: systemPrompt },
590
+ ...messages.flatMap(toOpenAIMessage),
591
+ ],
592
+ tools: tools.length > 0 ? tools.map(toOpenAITool) : undefined,
593
+ stream: false,
594
+ };
595
+ const resp = await fetchWithRetry(`${base}/chat/completions`, {
596
+ method: 'POST',
597
+ headers,
598
+ body: JSON.stringify(body),
599
+ });
600
+ if (!resp.ok) {
601
+ const text = await resp.text().catch(() => '');
602
+ throw new RequestError(`Bahulam Gateway ${resp.status}: ${text.slice(0, 300)}`, {
603
+ status: resp.status,
604
+ code: resp.status === 401 || resp.status === 403 ? 'gateway_authentication_error' : `gateway_http_${resp.status}`,
605
+ retryable: false,
606
+ });
607
+ }
608
+
609
+ const data = await resp.json();
610
+ const message = data.choices?.[0]?.message || {};
611
+ const content = [];
612
+ if (message.content) content.push({ type: 'text', text: message.content });
613
+ for (const call of message.tool_calls || []) {
614
+ const fn = call.function || {};
615
+ let input = {};
616
+ try { input = fn.arguments ? JSON.parse(fn.arguments) : {}; } catch {}
617
+ content.push({
618
+ type: 'tool_use',
619
+ id: call.id,
620
+ name: fn.name,
621
+ input,
622
+ });
623
+ }
624
+ return {
625
+ content,
626
+ stopReason: data.choices?.[0]?.finish_reason || null,
627
+ usage: normalizeUsage(data.usage),
628
+ };
629
+ }
630
+
631
+ async _callClaude(systemPrompt, messages, tools, modelOverride = this.model) {
409
632
  // PRD-071 Phase 2 — cache_control breakpoints for Anthropic direct.
410
633
  // Extended 1-hour TTL beta on the persistent prefix (system + tools).
411
634
  // Message history breakpoint stays at default 5-min TTL.
@@ -413,7 +636,7 @@ export class LocalAgent {
413
636
  const cachedTools = cacheableTools(tools);
414
637
  const cachedMessages = withMessageBreakpoint(messages);
415
638
 
416
- const resp = await fetch('https://api.anthropic.com/v1/messages', {
639
+ const resp = await fetchWithRetry('https://api.anthropic.com/v1/messages', {
417
640
  method: 'POST',
418
641
  headers: {
419
642
  'x-api-key': this.apiKey,
@@ -422,7 +645,7 @@ export class LocalAgent {
422
645
  'content-type': 'application/json',
423
646
  },
424
647
  body: JSON.stringify({
425
- model: this.model,
648
+ model: modelOverride,
426
649
  system: cachedSystem,
427
650
  messages: cachedMessages,
428
651
  tools: cachedTools.length > 0 ? cachedTools : undefined,
@@ -431,15 +654,19 @@ export class LocalAgent {
431
654
  });
432
655
  if (!resp.ok) {
433
656
  const text = await resp.text().catch(() => '');
434
- throw new Error(`Claude API ${resp.status}: ${text.slice(0, 200)}`);
657
+ throw new RequestError(`Claude API ${resp.status}: ${text.slice(0, 200)}`, {
658
+ status: resp.status,
659
+ code: `anthropic_http_${resp.status}`,
660
+ retryable: false,
661
+ });
435
662
  }
436
663
  const data = await resp.json();
437
664
  return { content: data.content || [], stopReason: data.stop_reason, usage: data.usage || null };
438
665
  }
439
666
 
440
- async _callOpenRouter(systemPrompt, messages, tools) {
667
+ async _callOpenRouter(systemPrompt, messages, tools, modelOverride = this.model) {
441
668
  // OpenRouter requires provider prefix (e.g. anthropic/claude-sonnet-4-20250514)
442
- let model = this.model;
669
+ let model = modelOverride;
443
670
  if (model.startsWith('claude') && !model.includes('/')) {
444
671
  model = `anthropic/${model}`;
445
672
  }
@@ -467,11 +694,12 @@ export class LocalAgent {
467
694
  const headers = {
468
695
  'Authorization': `Bearer ${this.openRouterKey}`,
469
696
  'Content-Type': 'application/json',
697
+ 'X-Request-ID': this._nextRequestId(),
470
698
  };
471
699
  // OpenRouter forwards `anthropic-beta` to Anthropic upstreams.
472
700
  if (isAnthropic) headers['anthropic-beta'] = ANTHROPIC_BETA_HEADER;
473
701
 
474
- const resp = await fetch('https://openrouter.ai/api/v1/chat/completions', {
702
+ const resp = await fetchWithRetry('https://openrouter.ai/api/v1/chat/completions', {
475
703
  method: 'POST',
476
704
  headers,
477
705
  body: JSON.stringify({
@@ -485,7 +713,11 @@ export class LocalAgent {
485
713
  });
486
714
  if (!resp.ok) {
487
715
  const text = await resp.text().catch(() => '');
488
- throw new Error(`OpenRouter API ${resp.status}: ${text.slice(0, 200)}`);
716
+ throw new RequestError(`OpenRouter API ${resp.status}: ${text.slice(0, 200)}`, {
717
+ status: resp.status,
718
+ code: `openrouter_http_${resp.status}`,
719
+ retryable: false,
720
+ });
489
721
  }
490
722
  const data = await resp.json();
491
723
  const choice = data.choices?.[0];
@@ -499,7 +731,87 @@ export class LocalAgent {
499
731
  return {
500
732
  content,
501
733
  stopReason: choice?.finish_reason === 'stop' ? 'end_turn' : 'tool_use',
502
- usage: _normalizeOpenRouterUsage(data.usage),
734
+ usage: normalizeUsage(data.usage),
735
+ };
736
+ }
737
+
738
+ _nextRequestId() {
739
+ this._requestSequence += 1;
740
+ return `npm-${this.sessionId || 'local'}-${Date.now()}-${this._requestSequence}`.slice(0, 256);
741
+ }
742
+
743
+ async _reduceContext(messages, { systemPrompt = '', tools = [] } = {}) {
744
+ const config = contextReductionConfig(process.env, this.product);
745
+ const fixedPromptTokens = estimateMessagesTokens([
746
+ { role: 'system', content: systemPrompt },
747
+ { role: 'system', content: tools },
748
+ ]);
749
+ const budget = resolveContextBudget({
750
+ product: this.product,
751
+ model: this.model,
752
+ fixedPromptTokens,
753
+ explicitThreshold: config.threshold,
754
+ });
755
+ const preserve = config.preserve || budget.preserve;
756
+ const estimated = estimateMessagesTokens(messages);
757
+ if (!config.enabled || estimated <= budget.threshold || messages.length <= preserve + 2) {
758
+ return null;
759
+ }
760
+
761
+ const source = messages.slice(0, -preserve);
762
+ let summary = null;
763
+ let summaryUsage = null;
764
+ let appliedStrategy = config.strategy;
765
+ const prompt = config.strategy === 'distillation'
766
+ ? 'You are the Bahulam coding-context distiller. Summarize the middle of the earlier conversation while preserving exact active ingredients: user intent, decisions, file paths, edits, commands, test results, errors, constraints, and unfinished work. Keep it structured and concise. Do not invent facts.'
767
+ : 'You are the Bahulam context summarizer. Summarize the earlier conversation for another coding-agent turn. Preserve user intent, decisions, files changed, commands/results, errors, constraints, and unfinished work. Do not invent facts. Return concise plain text only.';
768
+ const transcript = source.map(message => ({
769
+ role: message.role,
770
+ content: typeof message.content === 'string'
771
+ ? message.content
772
+ : JSON.stringify(message.content || ''),
773
+ }));
774
+ try {
775
+ const response = await this._callLLM(
776
+ prompt,
777
+ [{ role: 'user', content: JSON.stringify(transcript) }],
778
+ [],
779
+ this.summarizerModel,
780
+ );
781
+ summaryUsage = response.usage || null;
782
+ summary = response.content
783
+ ?.filter(block => block.type === 'text')
784
+ .map(block => block.text || '')
785
+ .join('\n')
786
+ .trim() || null;
787
+ } catch (error) {
788
+ // Match the backend's fail-open behavior: a failed reduction
789
+ // must never prevent the actual user turn from running.
790
+ appliedStrategy = 'summarization_failed';
791
+ if (this.verbose) process.stderr.write(`[context] summarization skipped: ${error.message}\n`);
792
+ }
793
+ if (!summary) return null;
794
+ if (!summary.startsWith(SUMMARY_MARKER) && !summary.startsWith(DISTILLATION_MARKER)) {
795
+ summary = `${config.strategy === 'distillation' ? DISTILLATION_MARKER : SUMMARY_MARKER}\n${summary}`;
796
+ }
797
+ const reduced = collapseMessages(messages, summary, preserve);
798
+ return {
799
+ messages: reduced,
800
+ event: {
801
+ phase: 'pre_turn',
802
+ strategy: appliedStrategy,
803
+ collapsed_messages: source.length,
804
+ kept_recent: preserve,
805
+ before_tokens: estimated,
806
+ threshold: budget.threshold,
807
+ budget_source: budget.source,
808
+ context_length: budget.contextLength || null,
809
+ target_tokens: budget.targetTokens || null,
810
+ source: this.gatewayUrl ? 'gateway' : 'provider',
811
+ summary_preview: summary.replace(/^\[[^\]]+\]\s*/, '').split('\n', 1)[0].slice(0, 120),
812
+ summary_usage: summaryUsage,
813
+ },
814
+ usage: summaryUsage,
503
815
  };
504
816
  }
505
817
 
@@ -541,6 +853,18 @@ export class LocalAgent {
541
853
  }
542
854
 
543
855
  cancel() { this._cancelled = true; }
856
+
857
+ /** Queue a live follow-up for the next local model boundary. */
858
+ sendIntervention(instruction, { idempotencyKey = null } = {}) {
859
+ const text = String(instruction || '').trim();
860
+ if (!text) return Promise.resolve({ status: 'error', error: 'instruction is empty' });
861
+ const interventionId = idempotencyKey || this._nextRequestId();
862
+ if (this._pendingInterventions.some(item => item.interventionId === interventionId)) {
863
+ return Promise.resolve({ status: 'duplicate', interventionId });
864
+ }
865
+ this._pendingInterventions.push({ instruction: text, interventionId });
866
+ return Promise.resolve({ status: 'accepted', interventionId });
867
+ }
544
868
  }
545
869
 
546
870
  // Shape the accumulated per-turn totals into the same envelope the remote
@@ -561,19 +885,3 @@ function _buildLocalUsageEnvelope(model, totals) {
561
885
  }],
562
886
  };
563
887
  }
564
-
565
- // Normalize OpenRouter usage into Anthropic's field names so downstream
566
- // consumers (PromptCache, pricing.calculateCost) don't branch on shape.
567
- // OpenRouter returns OpenAI-style: prompt_tokens, completion_tokens,
568
- // prompt_tokens_details.cached_tokens. When the underlying model is
569
- // Anthropic, OpenRouter also relays cache_read_input_tokens verbatim.
570
- function _normalizeOpenRouterUsage(usage) {
571
- if (!usage) return null;
572
- const cachedFromOpenAI = usage.prompt_tokens_details?.cached_tokens || 0;
573
- return {
574
- input_tokens: usage.prompt_tokens || 0,
575
- output_tokens: usage.completion_tokens || 0,
576
- cache_read_input_tokens: usage.cache_read_input_tokens || cachedFromOpenAI || 0,
577
- cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
578
- };
579
- }
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Mode Selector
3
3
  *
4
- * remote (default): All requests go to Bahulam backend.
4
+ * local (default): CLI-side orchestration via Bahulam Gateway.
5
+ * The npm process runs the ReAct loop and tool execution; model
6
+ * calls route through the gateway. Requires `bahulam login`.
7
+ *
8
+ * remote: All requests go to Bahulam backend.
5
9
  * Backend handles orchestration, model selection, tool routing.
6
10
  * User's provider and models configured via web Settings page.
7
- *
8
- * local: For local LLMs (Ollama, LM Studio, etc.)
9
- * Direct API call, no backend. Only when user explicitly opts in.
10
11
  */
11
12
 
12
13
  let _probeCache = { available: null, timestamp: 0 };
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Shared model-selection contract used by every runtime transport.
3
+ *
4
+ * Remote/bundled send the context fields to /api/execute. Local/direct use
5
+ * `model` for their provider request, while retaining the same override and
6
+ * mode metadata for observability and delegation.
7
+ */
8
+
9
+ import { CHAT_MODE_DEFAULTS } from '../config/model-defaults.mjs';
10
+
11
+ const MODE_ALIASES = Object.freeze({
12
+ fast: 'fast',
13
+ thinking: 'thinking',
14
+ extra: 'extra_thinking',
15
+ extra_thinking: 'extra_thinking',
16
+ max: 'max_thinking',
17
+ max_thinking: 'max_thinking',
18
+ });
19
+
20
+ function clean(value) {
21
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
22
+ }
23
+
24
+ function cleanOverrides(value) {
25
+ if (!value || typeof value !== 'object') return {};
26
+ return Object.fromEntries(Object.entries(value)
27
+ .map(([role, model]) => [role, clean(model)])
28
+ .filter(([, model]) => model));
29
+ }
30
+
31
+ export function resolveModelSelection({
32
+ explicitModel = null,
33
+ modelOverrides = {},
34
+ modelMode = null,
35
+ modelRoute = null,
36
+ modeModels = {},
37
+ profileModels = {},
38
+ fallbackModel = null,
39
+ } = {}) {
40
+ const overrides = cleanOverrides(modelOverrides);
41
+ const rawExplicit = clean(explicitModel);
42
+ const explicitMode = rawExplicit ? MODE_ALIASES[rawExplicit.toLowerCase()] : null;
43
+ const explicit = explicitMode ? null : rawExplicit;
44
+ const reasoningOverride = clean(overrides.reasoning);
45
+ const requestedMode = MODE_ALIASES[clean(modelMode)?.toLowerCase()] || explicitMode || null;
46
+ const profileReasoning = clean(profileModels.reasoning);
47
+ const profileLocal = clean(profileModels.local);
48
+ const modeModel = requestedMode
49
+ ? clean(modeModels[requestedMode])
50
+ || (requestedMode === 'fast' ? clean(profileModels.fast) : profileReasoning)
51
+ || CHAT_MODE_DEFAULTS[requestedMode]
52
+ : null;
53
+ const model = explicit || reasoningOverride || modeModel || profileReasoning || profileLocal || clean(fallbackModel);
54
+
55
+ return {
56
+ model,
57
+ modelOverride: explicit || reasoningOverride || null,
58
+ modelOverrides: overrides,
59
+ modelMode: requestedMode,
60
+ modelRoute: clean(modelRoute),
61
+ };
62
+ }
63
+
64
+ /** Add the shared selection fields to an /api/execute-style context. */
65
+ export function applyModelSelection(context, selection) {
66
+ const next = { ...context };
67
+ if (selection.modelOverride) next.model_override = selection.modelOverride;
68
+ if (Object.keys(selection.modelOverrides || {}).length) next.model_overrides = selection.modelOverrides;
69
+ if (selection.modelMode) next.model_mode = selection.modelMode;
70
+ if (selection.modelRoute) next.model_route = selection.modelRoute;
71
+ return next;
72
+ }