converse-mcp-server 2.29.2 → 3.0.1
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/.env.example +6 -3
- package/README.md +96 -94
- package/docs/API.md +703 -1562
- package/docs/ARCHITECTURE.md +13 -11
- package/docs/EXAMPLES.md +241 -667
- package/docs/PROVIDERS.md +104 -79
- package/package.json +1 -1
- package/src/async/asyncJobStore.js +2 -2
- package/src/async/jobRunner.js +6 -1
- package/src/async/providerStreamNormalizer.js +59 -1
- package/src/config.js +4 -3
- package/src/prompts/helpPrompt.js +43 -61
- package/src/providers/anthropic.js +0 -31
- package/src/providers/claude.js +0 -14
- package/src/providers/codex.js +15 -21
- package/src/providers/copilot.js +36 -202
- package/src/providers/deepseek.js +73 -53
- package/src/providers/gemini-cli.js +0 -3
- package/src/providers/google.js +10 -27
- package/src/providers/interface.js +0 -3
- package/src/providers/mistral.js +169 -63
- package/src/providers/openai-compatible.js +113 -28
- package/src/providers/openai.js +14 -66
- package/src/providers/openrouter-discovery.js +308 -0
- package/src/providers/openrouter.js +452 -282
- package/src/providers/xai.js +298 -280
- package/src/services/summarizationService.js +4 -14
- package/src/systemPrompts.js +19 -2
- package/src/tools/cancelJob.js +7 -1
- package/src/tools/chat.js +957 -995
- package/src/tools/checkStatus.js +1 -0
- package/src/tools/index.js +2 -6
- package/src/tools/modes/parallel.js +488 -0
- package/src/tools/modes/roundtable.js +495 -0
- package/src/tools/modes/streamShared.js +31 -0
- package/src/utils/contextProcessor.js +1 -38
- package/src/utils/conversationExporter.js +6 -26
- package/src/utils/formatStatus.js +46 -19
- package/src/utils/modelRouting.js +207 -4
- package/src/providers/openrouter-endpoints-client.js +0 -220
- package/src/tools/consensus.js +0 -1813
- package/src/tools/conversation.js +0 -1233
|
@@ -10,6 +10,16 @@ import { SummarizationService } from '../services/summarizationService.js';
|
|
|
10
10
|
import { debugLog, debugError } from './console.js';
|
|
11
11
|
import { JOB_STATUS } from '../async/asyncJobStore.js';
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Normalize a job's mode, mapping the pre-v3 `conversation` tool name to the
|
|
15
|
+
* `roundtable` mode so durable pre-v3 snapshots render with the right branch.
|
|
16
|
+
* @param {string} mode
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function normalizeMode(mode) {
|
|
20
|
+
return mode === 'conversation' ? 'roundtable' : mode;
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
/**
|
|
14
24
|
* Format job list as human-readable text with titles and summaries
|
|
15
25
|
* @param {object} jobsList - Jobs list object with summary
|
|
@@ -57,23 +67,26 @@ export async function formatJobListHumanReadable(jobsList, dependencies = {}) {
|
|
|
57
67
|
completed_with_errors: '⚠️',
|
|
58
68
|
}[job.status] || '❓';
|
|
59
69
|
|
|
70
|
+
const mode = normalizeMode(job.mode || job.tool);
|
|
60
71
|
const provider =
|
|
61
|
-
job.provider || (
|
|
72
|
+
job.provider || (mode === 'chat' ? 'unknown' : 'multiple');
|
|
62
73
|
|
|
63
74
|
// Format start time as readable date/time
|
|
64
75
|
const startTime = job.created_at
|
|
65
76
|
? new Date(job.created_at).toLocaleString()
|
|
66
77
|
: 'unknown';
|
|
67
78
|
|
|
68
|
-
// Format: emoji STATUS |
|
|
79
|
+
// Format: emoji STATUS | MODE | id | sequence | started | time | [progress] | provider
|
|
69
80
|
const sequenceStr = '1/1';
|
|
70
81
|
|
|
71
82
|
// Build base status line
|
|
72
|
-
let statusLine = `${statusEmoji} ${job.status.toUpperCase()} | ${
|
|
83
|
+
let statusLine = `${statusEmoji} ${job.status.toUpperCase()} | ${mode.toUpperCase()} | ${job.continuation_id} | ${sequenceStr} | ${startTime} | ${timeStr}`;
|
|
73
84
|
|
|
74
|
-
// Add
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
// Add per-mode progress if applicable
|
|
86
|
+
const progressText =
|
|
87
|
+
job.consensus_progress || job.roundtable_progress || job.chat_progress;
|
|
88
|
+
if (progressText) {
|
|
89
|
+
statusLine += ` | ${progressText}`;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
92
|
statusLine += ` | ${provider}`;
|
|
@@ -144,16 +157,18 @@ export async function formatHumanReadableStatus(
|
|
|
144
157
|
// Add sequence info if provided
|
|
145
158
|
const sequenceStr = options.sequence ? ` | ${options.sequence}` : '';
|
|
146
159
|
|
|
160
|
+
const mode = normalizeMode(jobStatus.mode || jobStatus.tool);
|
|
161
|
+
|
|
147
162
|
// Build complete status line with all info
|
|
148
|
-
let statusLine = `${statusEmoji} ${jobStatus.status.toUpperCase()} | ${
|
|
163
|
+
let statusLine = `${statusEmoji} ${jobStatus.status.toUpperCase()} | ${mode.toUpperCase()} | ${jobStatus.continuation_id}${sequenceStr} | Started: ${startTime} | ${timeStr} elapsed`;
|
|
149
164
|
|
|
150
165
|
// Add title if available
|
|
151
166
|
if (jobStatus.title) {
|
|
152
167
|
statusLine += ` | "${jobStatus.title}"`;
|
|
153
168
|
}
|
|
154
169
|
|
|
155
|
-
// Add progress
|
|
156
|
-
if (
|
|
170
|
+
// Add per-mode progress + models list
|
|
171
|
+
if (mode === 'consensus') {
|
|
157
172
|
if (jobStatus.consensus_progress) {
|
|
158
173
|
statusLine += ` | ${jobStatus.consensus_progress}`;
|
|
159
174
|
} else if (jobStatus.providers) {
|
|
@@ -166,24 +181,28 @@ export async function formatHumanReadableStatus(
|
|
|
166
181
|
const total = providerEntries.length;
|
|
167
182
|
statusLine += ` | ${completed}/${total} responded`;
|
|
168
183
|
}
|
|
169
|
-
// Add models list for consensus
|
|
170
184
|
if (jobStatus.models_list) {
|
|
171
185
|
statusLine += ` | ${jobStatus.models_list}`;
|
|
172
186
|
}
|
|
173
|
-
} else if (
|
|
174
|
-
// Add turn progress and models list
|
|
175
|
-
if (jobStatus.
|
|
176
|
-
statusLine += ` | ${jobStatus.
|
|
187
|
+
} else if (mode === 'roundtable') {
|
|
188
|
+
// Add turn progress and models list (x/y turns)
|
|
189
|
+
if (jobStatus.roundtable_progress) {
|
|
190
|
+
statusLine += ` | ${jobStatus.roundtable_progress} turns`;
|
|
177
191
|
}
|
|
178
192
|
if (jobStatus.models_list) {
|
|
179
193
|
statusLine += ` | ${jobStatus.models_list}`;
|
|
180
194
|
}
|
|
181
|
-
} else
|
|
182
|
-
//
|
|
195
|
+
} else {
|
|
196
|
+
// chat mode
|
|
197
|
+
if (jobStatus.chat_progress) {
|
|
198
|
+
statusLine += ` | ${jobStatus.chat_progress}`;
|
|
199
|
+
}
|
|
183
200
|
if (jobStatus.provider && jobStatus.model) {
|
|
184
201
|
statusLine += ` | ${jobStatus.provider}/${jobStatus.model}`;
|
|
185
202
|
} else if (jobStatus.provider) {
|
|
186
203
|
statusLine += ` | ${jobStatus.provider}`;
|
|
204
|
+
} else if (jobStatus.models_list) {
|
|
205
|
+
statusLine += ` | ${jobStatus.models_list}`;
|
|
187
206
|
}
|
|
188
207
|
}
|
|
189
208
|
|
|
@@ -332,10 +351,16 @@ export function formatJobStatus(job, options = {}) {
|
|
|
332
351
|
const elapsedMs = now - startTime;
|
|
333
352
|
const elapsedSeconds = elapsedMs / 1000;
|
|
334
353
|
|
|
354
|
+
// `mode` is the unified chat tool's execution mode; `tool` is kept as a
|
|
355
|
+
// fallback so any durable pre-v3 job snapshot on disk still renders. The old
|
|
356
|
+
// `conversation` tool maps to `roundtable`.
|
|
357
|
+
const mode = normalizeMode(job.mode || job.tool);
|
|
358
|
+
|
|
335
359
|
const formatted = {
|
|
336
360
|
continuation_id: job.jobId,
|
|
337
361
|
status: job.status,
|
|
338
362
|
tool: job.tool,
|
|
363
|
+
mode,
|
|
339
364
|
created_at: job.createdAt,
|
|
340
365
|
updated_at: job.updatedAt,
|
|
341
366
|
progress: job.overall?.progress || 0,
|
|
@@ -346,7 +371,9 @@ export function formatJobStatus(job, options = {}) {
|
|
|
346
371
|
model: job.model || null,
|
|
347
372
|
models_list: job.models_list || null,
|
|
348
373
|
consensus_progress: job.consensus_progress || null,
|
|
349
|
-
|
|
374
|
+
// `conversation_progress` is the pre-v3 key for what is now roundtable.
|
|
375
|
+
roundtable_progress: job.roundtable_progress || job.conversation_progress || null,
|
|
376
|
+
chat_progress: job.chat_progress || null,
|
|
350
377
|
// Include new metadata fields if present
|
|
351
378
|
accumulated_content: job.accumulated_content || null,
|
|
352
379
|
title: job.title || null,
|
|
@@ -354,8 +381,8 @@ export function formatJobStatus(job, options = {}) {
|
|
|
354
381
|
reasoning_summary: job.reasoning_summary || null,
|
|
355
382
|
};
|
|
356
383
|
|
|
357
|
-
// For consensus, gather provider previews
|
|
358
|
-
if (
|
|
384
|
+
// For the parallel modes (chat, consensus), gather per-provider previews.
|
|
385
|
+
if (mode === 'consensus' || mode === 'chat') {
|
|
359
386
|
const providerPreviews = {};
|
|
360
387
|
for (let i = 0; i < 10; i++) {
|
|
361
388
|
// Check up to 10 providers
|
|
@@ -7,6 +7,26 @@
|
|
|
7
7
|
* importing from another tool module (which would risk circular dependencies).
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Provider auto-selection priority order. Subscription-based CLI/SDK providers
|
|
12
|
+
* (codex, gemini-cli, claude, copilot) come before API-key providers so "auto"
|
|
13
|
+
* routing prefers them. Shared by every mode's model-resolution path.
|
|
14
|
+
* @type {string[]}
|
|
15
|
+
*/
|
|
16
|
+
export const PROVIDER_PRIORITY = [
|
|
17
|
+
'codex',
|
|
18
|
+
'gemini-cli',
|
|
19
|
+
'claude',
|
|
20
|
+
'copilot',
|
|
21
|
+
'openai',
|
|
22
|
+
'google',
|
|
23
|
+
'xai',
|
|
24
|
+
'anthropic',
|
|
25
|
+
'mistral',
|
|
26
|
+
'deepseek',
|
|
27
|
+
'openrouter',
|
|
28
|
+
];
|
|
29
|
+
|
|
10
30
|
/**
|
|
11
31
|
* Get default model for a provider
|
|
12
32
|
* @param {string} providerName - Provider name
|
|
@@ -19,17 +39,149 @@ export function getDefaultModelForProvider(providerName) {
|
|
|
19
39
|
claude: 'claude',
|
|
20
40
|
copilot: 'copilot',
|
|
21
41
|
openai: 'gpt-5.6',
|
|
22
|
-
xai: 'grok-4
|
|
42
|
+
xai: 'grok-4.5',
|
|
23
43
|
google: 'gemini-pro',
|
|
24
44
|
anthropic: 'claude-sonnet-4-20250514',
|
|
25
|
-
mistral: '
|
|
26
|
-
deepseek: 'deepseek-
|
|
27
|
-
openrouter: '
|
|
45
|
+
mistral: 'mistral-medium-3-5',
|
|
46
|
+
deepseek: 'deepseek-v4-pro',
|
|
47
|
+
openrouter: 'z-ai/glm-5.2',
|
|
28
48
|
};
|
|
29
49
|
|
|
30
50
|
return defaults[providerName] || 'gpt-5.6';
|
|
31
51
|
}
|
|
32
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Curated friendly-alias → { provider, canonicalModel } table. Single source of
|
|
55
|
+
* truth for routing the bare friendly aliases of the API-key providers (xAI,
|
|
56
|
+
* Mistral, DeepSeek) to their canonical current-generation model IDs, so the
|
|
57
|
+
* routing-parity checks are enumerable and capability-gating gets a deterministic
|
|
58
|
+
* canonical ID regardless of provider-level alias quirks. Copilot's aliases stay
|
|
59
|
+
* inside copilot.js because they are reached only via the `copilot:` namespace
|
|
60
|
+
* (bare `gpt-*`/`claude-*`/`gemini-*` keyword-route to their native API providers).
|
|
61
|
+
* OpenRouter models are reached by full slug or the `openrouter:` namespace, not
|
|
62
|
+
* by friendly aliases. Keys are lowercase.
|
|
63
|
+
* @type {Object<string, {provider: string, canonicalModel: string}>}
|
|
64
|
+
*/
|
|
65
|
+
export const CURATED_MODEL_ALIASES = {
|
|
66
|
+
// xAI
|
|
67
|
+
grok: { provider: 'xai', canonicalModel: 'grok-4.5' },
|
|
68
|
+
'grok-4.5': { provider: 'xai', canonicalModel: 'grok-4.5' },
|
|
69
|
+
'grok-4.5-latest': { provider: 'xai', canonicalModel: 'grok-4.5' },
|
|
70
|
+
'grok-build-latest': { provider: 'xai', canonicalModel: 'grok-4.5' },
|
|
71
|
+
// Mistral
|
|
72
|
+
mistral: { provider: 'mistral', canonicalModel: 'mistral-medium-3-5' },
|
|
73
|
+
'mistral-medium': { provider: 'mistral', canonicalModel: 'mistral-medium-3-5' },
|
|
74
|
+
'mistral-medium-3-5': { provider: 'mistral', canonicalModel: 'mistral-medium-3-5' },
|
|
75
|
+
'mistral-small': { provider: 'mistral', canonicalModel: 'mistral-small-2603' },
|
|
76
|
+
'mistral-small-2603': { provider: 'mistral', canonicalModel: 'mistral-small-2603' },
|
|
77
|
+
'mistral-large': { provider: 'mistral', canonicalModel: 'mistral-large-2512' },
|
|
78
|
+
'mistral-large-2512': { provider: 'mistral', canonicalModel: 'mistral-large-2512' },
|
|
79
|
+
// DeepSeek (native — OpenRouter DeepSeek models use their full slug instead)
|
|
80
|
+
deepseek: { provider: 'deepseek', canonicalModel: 'deepseek-v4-pro' },
|
|
81
|
+
'deepseek-pro': { provider: 'deepseek', canonicalModel: 'deepseek-v4-pro' },
|
|
82
|
+
'deepseek-v4-pro': { provider: 'deepseek', canonicalModel: 'deepseek-v4-pro' },
|
|
83
|
+
'deepseek-flash': { provider: 'deepseek', canonicalModel: 'deepseek-v4-flash' },
|
|
84
|
+
'deepseek-v4-flash': { provider: 'deepseek', canonicalModel: 'deepseek-v4-flash' },
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse OpenRouter model decorations off a slug. `:online` is consumed into a
|
|
89
|
+
* `webSearch` flag (the provider attaches the web plugin from the flag) and is
|
|
90
|
+
* never carried on the request/lookup ID; other suffixes such as `:free` are
|
|
91
|
+
* preserved on the request model but stripped from the bare lookup base.
|
|
92
|
+
* @param {string} slug - Slug with the `openrouter:` namespace already removed
|
|
93
|
+
* @returns {{ base: string, modelForRequest: string, webSearch: boolean }}
|
|
94
|
+
*/
|
|
95
|
+
function parseOpenRouterDecorations(slug) {
|
|
96
|
+
const segments = String(slug).split(':');
|
|
97
|
+
const base = segments[0];
|
|
98
|
+
const decorations = segments.slice(1);
|
|
99
|
+
const webSearch = decorations.includes('online');
|
|
100
|
+
const kept = decorations.filter((d) => d !== 'online');
|
|
101
|
+
const modelForRequest = kept.length ? `${base}:${kept.join(':')}` : base;
|
|
102
|
+
return { base, modelForRequest, webSearch };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Classify a model spec into { providerName, canonicalModel, options } by the
|
|
107
|
+
* design resolution order: explicit namespace prefix, curated friendly alias,
|
|
108
|
+
* full OpenRouter slug / `openrouter:` prefix (no env gate), then keyword/
|
|
109
|
+
* passthrough. `options` carries flags derived from decorations (e.g.
|
|
110
|
+
* `web_search` from an OpenRouter `:online`). Unknown explicit IDs pass through
|
|
111
|
+
* unchanged (never silently substituted).
|
|
112
|
+
* @param {string} spec - Model specification
|
|
113
|
+
* @param {object} providers - Provider instances
|
|
114
|
+
* @returns {{ providerName: string, canonicalModel: string, options: object }}
|
|
115
|
+
*/
|
|
116
|
+
function classifyModelSpec(spec, providers) {
|
|
117
|
+
const raw = String(spec);
|
|
118
|
+
const lower = raw.toLowerCase();
|
|
119
|
+
const options = {};
|
|
120
|
+
|
|
121
|
+
if (lower === 'auto') {
|
|
122
|
+
const providerName = mapModelToProvider('auto', providers);
|
|
123
|
+
return {
|
|
124
|
+
providerName,
|
|
125
|
+
canonicalModel: getDefaultModelForProvider(providerName),
|
|
126
|
+
options,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Explicit OpenRouter namespace: route without OPENROUTER_DYNAMIC_MODELS and
|
|
131
|
+
// parse `:online`/`:free` decorations before any lookup.
|
|
132
|
+
if (lower.startsWith('openrouter:')) {
|
|
133
|
+
const { modelForRequest, webSearch } = parseOpenRouterDecorations(
|
|
134
|
+
raw.slice('openrouter:'.length),
|
|
135
|
+
);
|
|
136
|
+
if (webSearch) options.web_search = true;
|
|
137
|
+
return { providerName: 'openrouter', canonicalModel: modelForRequest, options };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Other explicit namespaces pass the spec through unchanged; the target
|
|
141
|
+
// provider strips its own prefix (preserves current copilot/claude/gemini-cli
|
|
142
|
+
// behavior).
|
|
143
|
+
if (
|
|
144
|
+
lower.startsWith('copilot:') ||
|
|
145
|
+
lower.startsWith('claude:') ||
|
|
146
|
+
lower.startsWith('gemini:')
|
|
147
|
+
) {
|
|
148
|
+
return {
|
|
149
|
+
providerName: mapModelToProvider(raw, providers),
|
|
150
|
+
canonicalModel: raw,
|
|
151
|
+
options,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Curated friendly alias → provider + canonical ID.
|
|
156
|
+
const curated = CURATED_MODEL_ALIASES[lower];
|
|
157
|
+
if (curated) {
|
|
158
|
+
return {
|
|
159
|
+
providerName: curated.provider,
|
|
160
|
+
canonicalModel: curated.canonicalModel,
|
|
161
|
+
options,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Full provider/model slug: a native provider that statically owns the bare
|
|
166
|
+
// model wins; otherwise it is an OpenRouter slug (decorations parsed).
|
|
167
|
+
if (raw.includes('/')) {
|
|
168
|
+
const { base, modelForRequest, webSearch } = parseOpenRouterDecorations(raw);
|
|
169
|
+
const providerName = mapModelToProvider(base, providers);
|
|
170
|
+
if (providerName === 'openrouter' && webSearch) {
|
|
171
|
+
options.web_search = true;
|
|
172
|
+
}
|
|
173
|
+
return { providerName, canonicalModel: modelForRequest, options };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Keyword routing / unknown-ID passthrough (unchanged model string).
|
|
177
|
+
const providerName = mapModelToProvider(raw, providers);
|
|
178
|
+
return {
|
|
179
|
+
providerName,
|
|
180
|
+
canonicalModel: resolveAutoModel(raw, providerName),
|
|
181
|
+
options,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
33
185
|
/**
|
|
34
186
|
* Resolve "auto" model to default model for the provider
|
|
35
187
|
* @param {string} model - Model name (may be "auto")
|
|
@@ -87,6 +239,49 @@ export function providerSupportsImages(providerInstance, providerName) {
|
|
|
87
239
|
}
|
|
88
240
|
}
|
|
89
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Return the available provider names in PROVIDER_PRIORITY order, optionally
|
|
244
|
+
* skipping text-only providers when the request has images and capping the
|
|
245
|
+
* count. Shared by every mode's "auto" expansion path.
|
|
246
|
+
* @param {object} providers - Provider instances
|
|
247
|
+
* @param {object} config - Configuration
|
|
248
|
+
* @param {object} [options]
|
|
249
|
+
* @param {boolean} [options.hasImages=false] - Skip text-only providers when true
|
|
250
|
+
* @param {number} [options.limit=Infinity] - Max number of providers to return
|
|
251
|
+
* @returns {string[]} Ordered available provider names
|
|
252
|
+
*/
|
|
253
|
+
export function getAvailableProviders(providers, config, { hasImages = false, limit = Infinity } = {}) {
|
|
254
|
+
const names = [];
|
|
255
|
+
for (const name of PROVIDER_PRIORITY) {
|
|
256
|
+
if (names.length >= limit) break;
|
|
257
|
+
const provider = providers[name];
|
|
258
|
+
if (!provider || !provider.isAvailable(config)) continue;
|
|
259
|
+
if (hasImages && !providerSupportsImages(provider, name)) continue;
|
|
260
|
+
names.push(name);
|
|
261
|
+
}
|
|
262
|
+
return names;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Resolve a single model spec into routing facts: its provider name, the
|
|
267
|
+
* provider instance, the concrete model, and an availability status. Callers
|
|
268
|
+
* own their error wording and structural handling by switching on `status`
|
|
269
|
+
* ('ok' | 'not_found' | 'unavailable').
|
|
270
|
+
* @param {string} spec - Model specification
|
|
271
|
+
* @param {object} providers - Provider instances
|
|
272
|
+
* @param {object} config - Configuration
|
|
273
|
+
* @returns {{ providerName: string, provider: object, resolvedModel: string, status: string, options: object }}
|
|
274
|
+
*/
|
|
275
|
+
export function resolveModelSpec(spec, providers, config) {
|
|
276
|
+
const { providerName, canonicalModel, options } = classifyModelSpec(
|
|
277
|
+
spec,
|
|
278
|
+
providers,
|
|
279
|
+
);
|
|
280
|
+
const provider = providers[providerName];
|
|
281
|
+
const status = !provider ? 'not_found' : !provider.isAvailable(config) ? 'unavailable' : 'ok';
|
|
282
|
+
return { providerName, provider, resolvedModel: canonicalModel, status, options };
|
|
283
|
+
}
|
|
284
|
+
|
|
90
285
|
/**
|
|
91
286
|
* Map model name to provider name
|
|
92
287
|
* @param {string} model - Model name
|
|
@@ -160,6 +355,14 @@ export function mapModelToProvider(model, providers) {
|
|
|
160
355
|
return 'copilot';
|
|
161
356
|
}
|
|
162
357
|
|
|
358
|
+
// Check openrouter: prefix (e.g., openrouter:z-ai/glm-5.2). Routes to
|
|
359
|
+
// OpenRouter without the OPENROUTER_DYNAMIC_MODELS gate. Must be before the
|
|
360
|
+
// slash-format check so the namespaced slug is not probed against native
|
|
361
|
+
// providers.
|
|
362
|
+
if (modelLower.startsWith('openrouter:')) {
|
|
363
|
+
return 'openrouter';
|
|
364
|
+
}
|
|
365
|
+
|
|
163
366
|
// Check OpenRouter-specific patterns first
|
|
164
367
|
if (
|
|
165
368
|
modelLower === 'openrouter auto' ||
|
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenRouter Endpoints API Client
|
|
3
|
-
*
|
|
4
|
-
* Handles fetching model capabilities from OpenRouter's endpoints API.
|
|
5
|
-
* Provides caching and error handling for dynamic model discovery.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { debugLog, debugError } from '../utils/console.js';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Parse an OpenRouter model ID into author and slug components
|
|
12
|
-
* @param {string} modelId - Model ID in format "author/slug"
|
|
13
|
-
* @returns {{author: string, slug: string} | null} Parsed components or null if invalid
|
|
14
|
-
*/
|
|
15
|
-
function parseModelId(modelId) {
|
|
16
|
-
if (!modelId || typeof modelId !== 'string') {
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const parts = modelId.split('/');
|
|
21
|
-
if (parts.length !== 2) {
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const [author, slug] = parts;
|
|
26
|
-
if (!author || !slug) {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return { author, slug };
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Convert endpoint data to model configuration format
|
|
35
|
-
* @param {Object} endpointData - Raw endpoint data from API
|
|
36
|
-
* @returns {Object} Model configuration object
|
|
37
|
-
*/
|
|
38
|
-
function convertEndpointToModelConfig(endpointData) {
|
|
39
|
-
const data = endpointData.data;
|
|
40
|
-
const modelId = data.id;
|
|
41
|
-
|
|
42
|
-
// Find the best endpoint (prefer primary providers)
|
|
43
|
-
const preferredProviders = ['Anthropic', 'OpenAI', 'Google', 'XAI'];
|
|
44
|
-
let selectedEndpoint = data.endpoints[0]; // Default to first
|
|
45
|
-
|
|
46
|
-
for (const endpoint of data.endpoints) {
|
|
47
|
-
if (preferredProviders.includes(endpoint.provider_name)) {
|
|
48
|
-
selectedEndpoint = endpoint;
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// Extract supported parameters
|
|
54
|
-
const supportedParams = selectedEndpoint.supported_parameters || [];
|
|
55
|
-
|
|
56
|
-
return {
|
|
57
|
-
modelName: modelId,
|
|
58
|
-
friendlyName: data.name || `${modelId} (via OpenRouter)`,
|
|
59
|
-
description: data.description || `Dynamic model: ${modelId}`,
|
|
60
|
-
contextWindow: selectedEndpoint.context_length || 8192,
|
|
61
|
-
maxOutputTokens: selectedEndpoint.max_completion_tokens || 4096,
|
|
62
|
-
supportsStreaming: true, // Most models support streaming
|
|
63
|
-
supportsImages:
|
|
64
|
-
data.architecture?.input_modalities?.includes('image') || false,
|
|
65
|
-
supportsTemperature: supportedParams.includes('temperature'),
|
|
66
|
-
supportsWebSearch: false, // Not in API response, conservative default
|
|
67
|
-
supportsThinking: supportedParams.includes('reasoning'),
|
|
68
|
-
supportsTools: supportedParams.includes('tools'),
|
|
69
|
-
timeout: 300000, // 5 minutes default
|
|
70
|
-
isDynamic: true,
|
|
71
|
-
// Store additional metadata
|
|
72
|
-
metadata: {
|
|
73
|
-
architecture: data.architecture,
|
|
74
|
-
endpoints: data.endpoints,
|
|
75
|
-
pricing: selectedEndpoint.pricing,
|
|
76
|
-
selectedProvider: selectedEndpoint.provider_name,
|
|
77
|
-
maxPromptTokens: selectedEndpoint.max_prompt_tokens,
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Fetch model endpoints from OpenRouter API
|
|
84
|
-
* @param {string} modelId - Model ID in format "author/slug"
|
|
85
|
-
* @returns {Promise<Object|null>} Model configuration or null if not found
|
|
86
|
-
*/
|
|
87
|
-
export async function fetchModelEndpoints(modelId) {
|
|
88
|
-
const parsed = parseModelId(modelId);
|
|
89
|
-
if (!parsed) {
|
|
90
|
-
debugLog(`[OpenRouter Endpoints] Invalid model ID format: ${modelId}`);
|
|
91
|
-
return null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const { author, slug } = parsed;
|
|
95
|
-
const url = `https://openrouter.ai/api/v1/models/${author}/${slug}/endpoints`;
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
debugLog(`[OpenRouter Endpoints] Fetching endpoints for ${modelId}`);
|
|
99
|
-
|
|
100
|
-
const response = await globalThis.fetch(url, {
|
|
101
|
-
method: 'GET',
|
|
102
|
-
headers: {
|
|
103
|
-
Accept: 'application/json',
|
|
104
|
-
},
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
if (response.status === 404) {
|
|
108
|
-
debugLog(`[OpenRouter Endpoints] Model not found: ${modelId}`);
|
|
109
|
-
return null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (!response.ok) {
|
|
113
|
-
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const data = await response.json();
|
|
117
|
-
|
|
118
|
-
// Validate response structure
|
|
119
|
-
if (!data?.data?.id || !data?.data?.endpoints?.length) {
|
|
120
|
-
debugLog(
|
|
121
|
-
`[OpenRouter Endpoints] Invalid response structure for ${modelId}`,
|
|
122
|
-
);
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const modelConfig = convertEndpointToModelConfig(data);
|
|
127
|
-
debugLog(
|
|
128
|
-
`[OpenRouter Endpoints] Successfully fetched config for ${modelId}`,
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
return modelConfig;
|
|
132
|
-
} catch (error) {
|
|
133
|
-
debugError(`[OpenRouter Endpoints] Error fetching ${modelId}:`, error);
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Create a simple in-memory cache for model endpoints
|
|
140
|
-
*/
|
|
141
|
-
export function createEndpointsCache() {
|
|
142
|
-
const cache = new Map();
|
|
143
|
-
const DEFAULT_TTL = 24 * 60 * 60 * 1000; // 24 hours
|
|
144
|
-
const FAILED_TTL = 5 * 60 * 1000; // 5 minutes for failed requests
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
/**
|
|
148
|
-
* Get a cached value
|
|
149
|
-
* @param {string} key - Cache key
|
|
150
|
-
* @returns {{found: boolean, value: any}} Cache result
|
|
151
|
-
*/
|
|
152
|
-
get(key) {
|
|
153
|
-
const entry = cache.get(key);
|
|
154
|
-
if (!entry) {
|
|
155
|
-
return { found: false, value: null };
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
if (Date.now() > entry.expiry) {
|
|
159
|
-
cache.delete(key);
|
|
160
|
-
return { found: false, value: null };
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return { found: true, value: entry.value };
|
|
164
|
-
},
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Set a cached value
|
|
168
|
-
* @param {string} key - Cache key
|
|
169
|
-
* @param {Object|null} value - Value to cache
|
|
170
|
-
* @param {boolean} isFailure - Whether this is a failed request
|
|
171
|
-
*/
|
|
172
|
-
set(key, value, isFailure = false) {
|
|
173
|
-
const ttl = isFailure ? FAILED_TTL : DEFAULT_TTL;
|
|
174
|
-
cache.set(key, {
|
|
175
|
-
value,
|
|
176
|
-
expiry: Date.now() + ttl,
|
|
177
|
-
});
|
|
178
|
-
},
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Clear the entire cache
|
|
182
|
-
*/
|
|
183
|
-
clear() {
|
|
184
|
-
cache.clear();
|
|
185
|
-
},
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Get cache size
|
|
189
|
-
* @returns {number} Number of cached entries
|
|
190
|
-
*/
|
|
191
|
-
size() {
|
|
192
|
-
return cache.size;
|
|
193
|
-
},
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Create a singleton cache instance
|
|
198
|
-
export const endpointsCache = createEndpointsCache();
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Fetch model endpoints with caching
|
|
202
|
-
* @param {string} modelId - Model ID in format "author/slug"
|
|
203
|
-
* @returns {Promise<Object|null>} Model configuration or null if not found
|
|
204
|
-
*/
|
|
205
|
-
export async function fetchModelEndpointsWithCache(modelId) {
|
|
206
|
-
// Check cache first
|
|
207
|
-
const cached = endpointsCache.get(modelId);
|
|
208
|
-
if (cached.found) {
|
|
209
|
-
debugLog(`[OpenRouter Endpoints] Using cached config for ${modelId}`);
|
|
210
|
-
return cached.value;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Fetch from API
|
|
214
|
-
const config = await fetchModelEndpoints(modelId);
|
|
215
|
-
|
|
216
|
-
// Cache the result (including null for not found)
|
|
217
|
-
endpointsCache.set(modelId, config, config === null);
|
|
218
|
-
|
|
219
|
-
return config;
|
|
220
|
-
}
|