converse-mcp-server 3.7.1 → 4.1.0

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/src/tools/chat.js CHANGED
@@ -29,9 +29,8 @@ import { SummarizationService } from '../services/summarizationService.js';
29
29
  import { exportConversation } from '../utils/conversationExporter.js';
30
30
  import { EFFORT_LADDER } from '../utils/reasoningEffort.js';
31
31
  import {
32
- getDefaultModelForProvider,
33
- getProviderUnavailableMessage,
34
- getAvailableProviders,
32
+ getAutoCandidates,
33
+ getAutoModelSpecs,
35
34
  resolveModelSpec,
36
35
  } from '../utils/modelRouting.js';
37
36
  import {
@@ -857,24 +856,52 @@ function buildAsyncResult(pipeline, title, finalSummary) {
857
856
  // --- Model resolution helpers ------------------------------------------------
858
857
 
859
858
  /**
860
- * Build the full provider-priority candidate list for an "auto" spec (used for
861
- * chat-mode failover). Skips text-only providers when the request has images.
859
+ * Convert router candidates into the engine's call-plan candidate shape.
862
860
  */
863
- function buildAutoCandidates(providers, config, hasImages) {
864
- return getAvailableProviders(providers, config, { hasImages }).map((name) => ({
865
- name,
866
- providerInstance: providers[name],
867
- resolvedModel: getDefaultModelForProvider(name),
868
- displayModel: 'auto',
861
+ function toPlanCandidates(candidates, displayModel) {
862
+ return candidates.map((c) => ({
863
+ name: c.providerName,
864
+ providerInstance: c.provider,
865
+ resolvedModel: c.resolvedModel,
866
+ displayModel,
867
+ resolveOptions: c.options,
869
868
  }));
870
869
  }
871
870
 
871
+ /**
872
+ * Resolve one explicit spec into a call plan, or a pre-failed entry carrying
873
+ * the router's error (unknown names include "did you mean" suggestions). A
874
+ * bare model name served by several providers yields a multi-candidate plan
875
+ * that fails over in provider priority order.
876
+ */
877
+ function resolveExplicitPlan(spec, providers, config) {
878
+ const resolution = resolveModelSpec(spec, providers, config);
879
+ if (resolution.status !== 'ok') {
880
+ return {
881
+ preFailed: {
882
+ model: spec,
883
+ ...(resolution.providerName && { provider: resolution.providerName }),
884
+ error: resolution.error,
885
+ },
886
+ };
887
+ }
888
+ return {
889
+ plan: {
890
+ modelSpec: spec,
891
+ displayModel: spec,
892
+ threadKey: spec,
893
+ candidates: toPlanCandidates(resolution.candidates, spec),
894
+ },
895
+ };
896
+ }
897
+
872
898
  /**
873
899
  * Resolve chat-mode call plans. Each "auto" spec (whether the list is exactly
874
900
  * ["auto"] or "auto" appears alongside explicit models) yields a plan with the
875
901
  * full provider-priority candidate list (failover); explicit models yield one
876
- * single-candidate plan each. Unavailable/unknown explicit models are returned
877
- * as pre-failed entries (surfaced as per-model failures).
902
+ * plan each, with failover candidates when a bare name is served by several
903
+ * providers. Unavailable/unknown explicit models are returned as pre-failed
904
+ * entries (surfaced as per-model failures).
878
905
  */
879
906
  function resolveChatCallPlans(models, providers, config, hasImages) {
880
907
  const callPlans = [];
@@ -882,7 +909,10 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
882
909
 
883
910
  for (const spec of models) {
884
911
  if (String(spec).toLowerCase() === 'auto') {
885
- const candidates = buildAutoCandidates(providers, config, hasImages);
912
+ const candidates = toPlanCandidates(
913
+ getAutoCandidates(providers, config, { hasImages }),
914
+ 'auto',
915
+ );
886
916
  if (candidates.length === 0) {
887
917
  // A single ["auto"] with no providers is a hard error; an "auto" entry
888
918
  // in a multi-model list becomes a per-model failure instead.
@@ -909,28 +939,11 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
909
939
  continue;
910
940
  }
911
941
 
912
- const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
913
- if (status === 'not_found') {
914
- preFailed.push({
915
- model: spec,
916
- provider: providerName,
917
- error: `Provider not found for model: ${spec}`,
918
- });
919
- } else if (status === 'unavailable') {
920
- preFailed.push({
921
- model: spec,
922
- provider: providerName,
923
- error: getProviderUnavailableMessage(providerName),
924
- });
942
+ const { plan, preFailed: failed } = resolveExplicitPlan(spec, providers, config);
943
+ if (failed) {
944
+ preFailed.push(failed);
925
945
  } else {
926
- callPlans.push({
927
- modelSpec: spec,
928
- displayModel: spec,
929
- threadKey: spec,
930
- candidates: [
931
- { name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
932
- ],
933
- });
946
+ callPlans.push(plan);
934
947
  }
935
948
  }
936
949
  return { callPlans, preFailed, error: null };
@@ -938,15 +951,15 @@ function resolveChatCallPlans(models, providers, config, hasImages) {
938
951
 
939
952
  /**
940
953
  * Resolve consensus-mode call plans. Single "auto" expands to the first 3
941
- * available providers' default models; each spec becomes a single-candidate plan.
954
+ * available providers' default models (as `namespace:model` specs); each spec
955
+ * becomes one plan.
942
956
  */
943
957
  function resolveConsensusCallPlans(models, providers, config, images) {
944
958
  const hasImages = Array.isArray(images) && images.length > 0;
945
959
 
946
960
  let modelsToProcess = models;
947
961
  if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
948
- const available = getAvailableProviders(providers, config, { hasImages, limit: 3 });
949
- modelsToProcess = available.map((name) => getDefaultModelForProvider(name));
962
+ modelsToProcess = getAutoModelSpecs(providers, config, { hasImages, limit: 3 });
950
963
  }
951
964
 
952
965
  const resolved = [];
@@ -956,20 +969,11 @@ function resolveConsensusCallPlans(models, providers, config, images) {
956
969
  preFailed.push({ model: spec || 'unknown', error: 'Invalid model specification' });
957
970
  continue;
958
971
  }
959
- const { providerName, provider, resolvedModel, status, options } = resolveModelSpec(spec, providers, config);
960
- if (status === 'not_found') {
961
- preFailed.push({ model: spec, provider: providerName, error: `Provider not found: ${providerName}` });
962
- } else if (status === 'unavailable') {
963
- preFailed.push({ model: spec, provider: providerName, error: getProviderUnavailableMessage(providerName) });
972
+ const { plan, preFailed: failed } = resolveExplicitPlan(spec, providers, config);
973
+ if (failed) {
974
+ preFailed.push(failed);
964
975
  } else {
965
- resolved.push({
966
- modelSpec: spec,
967
- displayModel: spec,
968
- threadKey: spec,
969
- candidates: [
970
- { name: providerName, providerInstance: provider, resolvedModel, displayModel: spec, resolveOptions: options },
971
- ],
972
- });
976
+ resolved.push(plan);
973
977
  }
974
978
  }
975
979
  return { resolved, preFailed };
@@ -1161,7 +1165,7 @@ chatTool.inputSchema = {
1161
1165
  items: { type: 'string' },
1162
1166
  minItems: 1,
1163
1167
  description:
1164
- 'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"]. In mode "chat" each model answers independently; in "consensus" they refine after seeing each other; in "roundtable" they speak in the given ORDER, each seeing the transcript. Default: ["auto"].',
1168
+ 'Models to use. Examples: ["auto"] (recommended), ["codex"], ["codex", "gemini", "claude"], ["codex:astra"], ["gpt-6-astra"]. Forms: "provider" (its default model), "provider:model" (that provider only), or a bare "model" (served by the first configured provider that offers it, local CLI providers first, failing over to the next). Providers: codex, gemini (agy), claude, copilot, openai, google, xai, anthropic, mistral, deepseek, openrouter. Unknown names are rejected with suggestions. In mode "chat" each model answers independently; in "consensus" they refine after seeing each other; in "roundtable" they speak in the given ORDER, each seeing the transcript. Default: ["auto"].',
1165
1169
  },
1166
1170
  mode: {
1167
1171
  type: 'string',
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Decide Tool - System One decision models
3
+ *
4
+ * Asks a decision model (TypeSafe's Jev family) typed questions about a state
5
+ * and returns calibrated answers: a probability for yes/no questions, a
6
+ * probability distribution for choices and rubric scores. Decision models
7
+ * never generate text, so this is a separate tool rather than a chat mode.
8
+ */
9
+
10
+ import { createToolResponse, createToolError } from './index.js';
11
+ import { resolveDecisionModel } from '../decisionProviders/index.js';
12
+ import { callSystemOne } from '../decisionProviders/systemOne.js';
13
+ import { validateAllPaths } from '../utils/fileValidator.js';
14
+ import { createLogger } from '../utils/logger.js';
15
+
16
+ const logger = createLogger('decide');
17
+
18
+ const QUESTION_TYPES = ['noul', 'choice', 'score'];
19
+ const QUESTION_FIELDS = ['type', 'instructions', 'criteria'];
20
+ const MAX_CHOICE_OPTIONS = 255;
21
+ const MIN_SCORE_LEVELS = 2;
22
+ const MAX_SCORE_LEVELS = 10;
23
+
24
+ function isPlainObject(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+
28
+ /** Criteria descriptions may be plain text or structured reference data. */
29
+ function isCriterionValue(value) {
30
+ return (typeof value === 'string' && value.trim() !== '') || isPlainObject(value) || Array.isArray(value);
31
+ }
32
+
33
+ function validateState(state) {
34
+ if (typeof state === 'string') {
35
+ return state.trim() ? null : '"state" must not be empty.';
36
+ }
37
+ if (isPlainObject(state) || Array.isArray(state)) return null;
38
+ return '"state" must be a string, an object, or an array.';
39
+ }
40
+
41
+ function validateQuestion(key, question) {
42
+ const at = `questions.${key}`;
43
+ if (!isPlainObject(question)) return `${at} must be an object with "type" and "instructions".`;
44
+
45
+ const unknown = Object.keys(question).filter((f) => !QUESTION_FIELDS.includes(f));
46
+ if (unknown.length) {
47
+ return `${at} has unknown field(s): ${unknown.join(', ')}. Allowed: ${QUESTION_FIELDS.join(', ')}.`;
48
+ }
49
+ if (!QUESTION_TYPES.includes(question.type)) {
50
+ return `${at}.type must be one of: ${QUESTION_TYPES.join(', ')}.`;
51
+ }
52
+
53
+ const { instructions, criteria } = question;
54
+ const hasInstructions =
55
+ (typeof instructions === 'string' && instructions.trim() !== '') ||
56
+ (isPlainObject(instructions) && Object.keys(instructions).length > 0) ||
57
+ (Array.isArray(instructions) && instructions.length > 0);
58
+ if (!hasInstructions) {
59
+ return `${at}.instructions must be a non-empty string, object, or array.`;
60
+ }
61
+
62
+ if (question.type === 'noul') {
63
+ if (criteria === undefined) return null;
64
+ const keys = isPlainObject(criteria) ? Object.keys(criteria).sort() : null;
65
+ if (!keys || keys.join(',') !== 'false,true' || !isCriterionValue(criteria.true) || !isCriterionValue(criteria.false)) {
66
+ return `${at}.criteria for a noul question must be { "true": "...", "false": "..." } with both descriptions.`;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ if (question.type === 'choice') {
72
+ if (!isPlainObject(criteria)) {
73
+ return `${at}.criteria for a choice question must map option names to descriptions (or null).`;
74
+ }
75
+ const options = Object.entries(criteria);
76
+ if (options.length < 2 || options.length > MAX_CHOICE_OPTIONS) {
77
+ return `${at}.criteria must have 2 to ${MAX_CHOICE_OPTIONS} options (got ${options.length}).`;
78
+ }
79
+ const bad = options.filter(([, v]) => v !== null && !isCriterionValue(v)).map(([k]) => k);
80
+ if (bad.length) {
81
+ return `${at}.criteria option(s) ${bad.join(', ')} need a description string, object, array, or null.`;
82
+ }
83
+ return null;
84
+ }
85
+
86
+ if (!Array.isArray(criteria)) {
87
+ return `${at}.criteria for a score question must be an ordered array of level descriptions.`;
88
+ }
89
+ if (criteria.length < MIN_SCORE_LEVELS || criteria.length > MAX_SCORE_LEVELS) {
90
+ return `${at}.criteria must have ${MIN_SCORE_LEVELS} to ${MAX_SCORE_LEVELS} levels (got ${criteria.length}).`;
91
+ }
92
+ if (!criteria.every(isCriterionValue)) {
93
+ return `${at}.criteria levels must each be a non-empty description.`;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Check questions locally: the API reports schema faults as nested validation
100
+ * dumps, and a local message is both clearer and free.
101
+ * @returns {string|null} First problem found
102
+ */
103
+ export function validateQuestions(questions) {
104
+ if (!isPlainObject(questions) || Object.keys(questions).length === 0) {
105
+ return '"questions" must be an object with at least one named question.';
106
+ }
107
+ for (const [key, question] of Object.entries(questions)) {
108
+ if (!key.trim()) return 'Question names must not be empty.';
109
+ const error = validateQuestion(key, question);
110
+ if (error) return error;
111
+ }
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * Read files into a `{ path: content }` map. Every file must load as text:
117
+ * a silently missing file would change the state being judged.
118
+ */
119
+ async function loadFiles(files, contextProcessor, config) {
120
+ const validation = await validateAllPaths(
121
+ { files },
122
+ { clientCwd: config?.server?.client_cwd },
123
+ );
124
+ if (!validation.valid) {
125
+ return { error: validation.errors.join('; ') };
126
+ }
127
+
128
+ const result = await contextProcessor.processUnifiedContext(
129
+ { files },
130
+ { enforceSecurityCheck: false, skipSecurityCheck: true, clientCwd: config?.server?.client_cwd },
131
+ );
132
+ const problems = [];
133
+ const contents = {};
134
+ for (const file of result.files) {
135
+ if (file.type === 'error') {
136
+ problems.push(`${file.originalPath}: ${file.error}`);
137
+ } else if (file.type !== 'text') {
138
+ problems.push(`${file.originalPath}: decision models accept text only`);
139
+ } else {
140
+ contents[file.originalPath] = file.content;
141
+ }
142
+ }
143
+ if (result.errors?.length) problems.push(...result.errors.map((e) => e.message));
144
+ return problems.length ? { error: problems.join('; ') } : { contents };
145
+ }
146
+
147
+ function fixed(n) {
148
+ return typeof n === 'number' ? n.toFixed(2) : String(n);
149
+ }
150
+
151
+ function byProbability(probabilities, label = (k) => k) {
152
+ return Object.entries(probabilities || {})
153
+ .sort(([, a], [, b]) => b - a)
154
+ .map(([k, p]) => `${label(k)} ${fixed(p)}`)
155
+ .join(', ');
156
+ }
157
+
158
+ function summarizeAnswer(key, answer) {
159
+ const type = answer?.type ?? 'unknown';
160
+ if (type === 'noul') {
161
+ return `- ${key} (noul): ${fixed(answer.noul)}`;
162
+ }
163
+ if (type === 'choice') {
164
+ return `- ${key} (choice): ${answer.choice} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities)}`;
165
+ }
166
+ if (type === 'score') {
167
+ const levels = Object.keys(answer.legend || answer.probabilities || {});
168
+ const range = levels.length ? ` on 0–${levels.length - 1}` : '';
169
+ const label = (k) => (answer.legend?.[k] ? `${k} ${answer.legend[k]}` : k);
170
+ return `- ${key} (score): ${fixed(answer.score)}${range} · confidence ${fixed(answer.confidence)} · ${byProbability(answer.probabilities, label)}`;
171
+ }
172
+ return `- ${key} (${type}): ${JSON.stringify(answer)}`;
173
+ }
174
+
175
+ function formatResult(response, candidate, failures) {
176
+ const usage = response.usage || {};
177
+ const header = [
178
+ `Decision · ${response.model || candidate.model} via ${candidate.provider.label}`,
179
+ usage.input_tokens !== undefined ? `${usage.input_tokens} input tokens` : null,
180
+ typeof usage.cost === 'number' ? `$${usage.cost.toFixed(6)}` : null,
181
+ ].filter(Boolean).join(' · ');
182
+
183
+ const lines = [header];
184
+ for (const failure of failures) {
185
+ lines.push(`(${failure.provider} failed, fell back: ${failure.message})`);
186
+ }
187
+ lines.push(...Object.entries(response.answers).map(([key, answer]) => summarizeAnswer(key, answer)));
188
+
189
+ const payload = {
190
+ model: response.model ?? candidate.model,
191
+ provider: candidate.providerName,
192
+ answers: response.answers,
193
+ usage: {
194
+ input_tokens: usage.input_tokens ?? null,
195
+ output_tokens: usage.output_tokens ?? null,
196
+ cost: typeof usage.cost === 'number' ? usage.cost : null,
197
+ },
198
+ ...(response.id ? { id: response.id } : {}),
199
+ };
200
+ return `${lines.join('\n')}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
201
+ }
202
+
203
+ /**
204
+ * Decide MCP Tool
205
+ * @param {object} args - Tool arguments
206
+ * @param {string|object|Array} [args.state] - Material to judge
207
+ * @param {object} args.questions - Named typed questions
208
+ * @param {string} [args.model] - Model spec, default "auto"
209
+ * @param {string[]} [args.files] - Text files added to the state
210
+ * @param {object} dependencies - Injected dependencies (config, contextProcessor, signal)
211
+ * @returns {Promise<object>} MCP tool response
212
+ */
213
+ export async function decideTool(args, dependencies) {
214
+ const { config, contextProcessor, signal } = dependencies;
215
+ const { state, questions, model = 'auto', files = [] } = args;
216
+
217
+ if (!Array.isArray(files) || !files.every((f) => typeof f === 'string' && f.trim())) {
218
+ return createToolError('"files" must be an array of file paths.');
219
+ }
220
+ if (state === undefined && files.length === 0) {
221
+ return createToolError('Provide "state", "files", or both: there is nothing to judge.');
222
+ }
223
+ const stateError = state === undefined ? null : validateState(state);
224
+ if (stateError) return createToolError(stateError);
225
+ const questionError = validateQuestions(questions);
226
+ if (questionError) return createToolError(questionError);
227
+
228
+ const route = resolveDecisionModel(model, config);
229
+ if (route.status !== 'ok') return createToolError(route.error);
230
+
231
+ let finalState = state;
232
+ if (files.length > 0) {
233
+ const loaded = await loadFiles(files, contextProcessor, config);
234
+ if (loaded.error) return createToolError(`Could not load files: ${loaded.error}`);
235
+ finalState = state === undefined ? { files: loaded.contents } : { input: state, files: loaded.contents };
236
+ }
237
+
238
+ const failures = [];
239
+ for (const candidate of route.candidates) {
240
+ try {
241
+ const response = await callSystemOne({
242
+ baseURL: candidate.provider.baseURL,
243
+ headers: candidate.provider.headers(config),
244
+ body: { model: candidate.model, state: finalState, questions },
245
+ signal,
246
+ });
247
+ return createToolResponse(formatResult(response, candidate, failures));
248
+ } catch (error) {
249
+ if (signal?.aborted) return createToolError('Decision request cancelled.');
250
+ logger.error('Decision request failed', { provider: candidate.providerName, model: candidate.model, error: error.message });
251
+ failures.push({ provider: candidate.providerName, message: error.message });
252
+ if (error.terminal) break;
253
+ }
254
+ }
255
+
256
+ const detail = failures.map((f) => `${f.provider}: ${f.message}`).join('; ');
257
+ return createToolError(`Decision request failed (${detail})`);
258
+ }
259
+
260
+ decideTool.description =
261
+ 'DECIDE — ask a System One decision model (TypeSafe Jev) typed questions about a state and get calibrated answers, not text. ' +
262
+ 'Question types: "noul" (yes/no → probability 0..1), "choice" (pick one of 2–255 named options → choice, per-option probabilities, confidence), ' +
263
+ '"score" (ordered rubric of 2–10 levels → weighted position, per-level probabilities, confidence). ' +
264
+ 'Batch many questions into one call: they are judged in parallel and in isolation against the same state at almost no extra cost. ' +
265
+ 'Best for fast atomic judgments (classify, route, verify, rank). Keep each question literal and narrow; do counting, arithmetic, and date comparison in code; ' +
266
+ 'split compound judgments into separate questions; treat low confidence as a signal to escalate. Text only, no explanations are returned. ' +
267
+ 'Limits: ~64k tokens per request, ~32k for state plus the longest question.';
268
+
269
+ decideTool.inputSchema = {
270
+ type: 'object',
271
+ properties: {
272
+ state: {
273
+ anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }],
274
+ description:
275
+ 'The material to judge: plain text, or JSON (an object with descriptively named fields is best; an array for sequences such as messages). Optional when "files" is given.',
276
+ },
277
+ questions: {
278
+ type: 'object',
279
+ description:
280
+ 'Named questions, all answered against the same state. The name is your own label and is returned as the answer key. ' +
281
+ 'Each question: { "type": "noul"|"choice"|"score", "instructions": string|object|array, "criteria": ... }. ' +
282
+ 'criteria — noul: optional { "true": "...", "false": "..." }; choice: required { "<option>": "description" | null } (2–255 options); ' +
283
+ 'score: required ordered array of level descriptions, lowest first (2–10 levels). ' +
284
+ 'instructions may be an object bundling the question with reference data, referenced by `name` in the text. ' +
285
+ 'Example: { "team": { "type": "choice", "instructions": "Which team should handle this?", "criteria": { "billing": "Payments, refunds", "technical": "Bugs, outages" } } }',
286
+ additionalProperties: {
287
+ type: 'object',
288
+ properties: {
289
+ type: { type: 'string', enum: QUESTION_TYPES },
290
+ instructions: { anyOf: [{ type: 'string' }, { type: 'object' }, { type: 'array' }] },
291
+ criteria: { anyOf: [{ type: 'object' }, { type: 'array' }] },
292
+ },
293
+ required: ['type', 'instructions'],
294
+ additionalProperties: false,
295
+ },
296
+ },
297
+ model: {
298
+ type: 'string',
299
+ description:
300
+ 'Decision model. "auto" (default): TypeSafe, falling back to OpenRouter. "jev-latest", "jev-1.13": first configured provider that serves it, with fallback. ' +
301
+ '"typesafe:jev-1.13.0", "openrouter:~typesafe/jev-latest": that provider only. Providers: typesafe (TYPESAFE_API_KEY), openrouter (OPENROUTER_API_KEY).',
302
+ },
303
+ files: {
304
+ type: 'array',
305
+ items: { type: 'string' },
306
+ description:
307
+ 'Text files added to the state as { "files": { "<path>": "<content>" } }; a given state moves to "input". Supports line ranges: file.txt{10:50}. Images are rejected.',
308
+ },
309
+ },
310
+ required: ['questions'],
311
+ additionalProperties: false,
312
+ };
@@ -9,6 +9,7 @@
9
9
  import { chatTool } from './chat.js';
10
10
  import { checkStatusTool } from './checkStatus.js';
11
11
  import { cancelJobTool } from './cancelJob.js';
12
+ import { decideTool } from './decide.js';
12
13
 
13
14
  /**
14
15
  * Tool registry map
@@ -19,6 +20,7 @@ const tools = {
19
20
  chat: chatTool,
20
21
  check_status: checkStatusTool,
21
22
  cancel_job: cancelJobTool,
23
+ decide: decideTool,
22
24
  };
23
25
 
24
26
  /**
@@ -20,12 +20,8 @@
20
20
 
21
21
  import { debugLog } from '../../utils/console.js';
22
22
  import { acquireProviderStream } from './streamShared.js';
23
- import {
24
- getDefaultModelForProvider,
25
- getProviderUnavailableMessage,
26
- getAvailableProviders,
27
- resolveModelSpec,
28
- } from '../../utils/modelRouting.js';
23
+ import { getAutoModelSpecs, resolveModelSpec } from '../../utils/modelRouting.js';
24
+ import { shouldFailoverToNextProvider } from './parallel.js';
29
25
 
30
26
  /**
31
27
  * Render a stored transcript (from prior laps or a prior chat/consensus thread)
@@ -169,7 +165,9 @@ export function formatLapTranscript(lapTurns) {
169
165
  * Resolve the ordered model list into a turn plan. Unlike the parallel engine,
170
166
  * unknown or unavailable models are NOT dropped — they are recorded with a
171
167
  * preFailReason so they keep their position in the order (and produce a failed
172
- * turn).
168
+ * turn). A bare model name served by several providers carries every serving
169
+ * provider in `candidates`, tried in order when a turn fails with a
170
+ * failover-worthy error.
173
171
  * @param {Array<string>} models - Ordered model list
174
172
  * @param {object} providers - Provider instances
175
173
  * @param {object} config - Configuration
@@ -181,16 +179,14 @@ export function resolveTurnPlan(models, providers, config, hasImages = false) {
181
179
  // (a single-model round-table is valid). Multiple explicit models resolve per-entry.
182
180
  let modelsToProcess = models;
183
181
  if (models.length === 1 && String(models[0]).toLowerCase() === 'auto') {
184
- const [firstAvailable] = getAvailableProviders(providers, config, {
182
+ const [firstAvailable] = getAutoModelSpecs(providers, config, {
185
183
  hasImages,
186
184
  limit: 1,
187
185
  });
188
186
 
189
187
  // If a provider is available, use its default model. Otherwise keep "auto"
190
188
  // so it resolves to a turn that fails cleanly (all-fail laps must complete).
191
- modelsToProcess = firstAvailable
192
- ? [getDefaultModelForProvider(firstAvailable)]
193
- : ['auto'];
189
+ modelsToProcess = firstAvailable ? [firstAvailable] : ['auto'];
194
190
  }
195
191
 
196
192
  return modelsToProcess.map((modelName) => {
@@ -200,39 +196,31 @@ export function resolveTurnPlan(models, providers, config, hasImages = false) {
200
196
  provider: null,
201
197
  providerInstance: null,
202
198
  resolvedModel: null,
199
+ candidates: [],
203
200
  preFailReason: 'Invalid model specification',
204
201
  };
205
202
  }
206
203
 
207
- const { providerName, provider, resolvedModel, status, options } =
208
- resolveModelSpec(modelName, providers, config);
204
+ const resolution = resolveModelSpec(modelName, providers, config);
209
205
 
210
- if (status === 'not_found') {
206
+ if (resolution.status !== 'ok') {
211
207
  return {
212
208
  model: modelName,
213
- provider: providerName,
209
+ provider: resolution.providerName,
214
210
  providerInstance: null,
215
- resolvedModel,
216
- preFailReason: `Provider not found: ${providerName}`,
217
- };
218
- }
219
-
220
- if (status === 'unavailable') {
221
- return {
222
- model: modelName,
223
- provider: providerName,
224
- providerInstance: null,
225
- resolvedModel,
226
- preFailReason: getProviderUnavailableMessage(providerName),
211
+ resolvedModel: null,
212
+ candidates: [],
213
+ preFailReason: resolution.error,
227
214
  };
228
215
  }
229
216
 
230
217
  return {
231
218
  model: modelName,
232
- provider: providerName,
233
- providerInstance: provider,
234
- resolvedModel,
235
- resolveOptions: options,
219
+ provider: resolution.providerName,
220
+ providerInstance: resolution.provider,
221
+ resolvedModel: resolution.resolvedModel,
222
+ resolveOptions: resolution.options,
223
+ candidates: resolution.candidates,
236
224
  preFailReason: null,
237
225
  };
238
226
  });
@@ -254,7 +242,9 @@ function buildTurnUserContent(packetText, contextMessage) {
254
242
  * Execute a single turn. Streams (updating job progress) when a job context is
255
243
  * present; otherwise performs a plain invoke. Cancellation propagates by throwing
256
244
  * so the lap aborts rather than demoting to a failed turn.
257
- * @returns {Promise<object>} Turn result { model, provider, status, response|error }
245
+ * @returns {Promise<object>} Turn result { model, provider, status, response|error };
246
+ * failed turns also carry the thrown `cause` for the failover decision
247
+ * (stripped before the turn is recorded)
258
248
  */
259
249
  async function executeTurn(
260
250
  plan,
@@ -353,6 +343,7 @@ async function executeTurn(
353
343
  provider: plan.provider,
354
344
  status: 'failed',
355
345
  error: error.message,
346
+ cause: error,
356
347
  };
357
348
  }
358
349
  }
@@ -436,24 +427,44 @@ export async function runRoundtableLap({
436
427
  { role: 'user', content: finalUserContent },
437
428
  ];
438
429
 
439
- const turnResult = await executeTurn(
440
- plan,
441
- messages,
442
- {
443
- reasoning_effort,
444
- signal: activeSignal,
445
- config,
446
- model: plan.resolvedModel,
447
- // Web search opt-in from an OpenRouter `:online` decoration; only ever
448
- // set for OpenRouter turns.
449
- ...(plan.resolveOptions?.web_search && { web_search: true }),
450
- },
451
- context,
452
- providerStreamNormalizer,
453
- i,
454
- );
430
+ let turnResult;
431
+ for (let ci = 0; ci < plan.candidates.length; ci++) {
432
+ const candidate = plan.candidates[ci];
433
+ turnResult = await executeTurn(
434
+ {
435
+ model: plan.model,
436
+ provider: candidate.providerName,
437
+ providerInstance: candidate.provider,
438
+ },
439
+ messages,
440
+ {
441
+ reasoning_effort,
442
+ signal: activeSignal,
443
+ config,
444
+ model: candidate.resolvedModel,
445
+ // Web search opt-in from an OpenRouter `:online` decoration; only ever
446
+ // set for OpenRouter turns.
447
+ ...(candidate.options?.web_search && { web_search: true }),
448
+ },
449
+ context,
450
+ providerStreamNormalizer,
451
+ i,
452
+ );
453
+ const isLastCandidate = ci === plan.candidates.length - 1;
454
+ if (
455
+ turnResult.status === 'success' ||
456
+ isLastCandidate ||
457
+ !shouldFailoverToNextProvider(turnResult.cause)
458
+ ) {
459
+ break;
460
+ }
461
+ debugLog(
462
+ `[Roundtable] Turn ${i + 1} (${plan.model}) failed on ${candidate.providerName}; failing over`,
463
+ );
464
+ }
455
465
 
456
- lapTurns.push({ ...turnResult, position: i });
466
+ const { cause: _cause, ...turn } = turnResult;
467
+ lapTurns.push({ ...turn, position: i });
457
468
  }
458
469
 
459
470
  if (context) {