@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.8.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/README.md +108 -37
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +7 -1
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +61 -5
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +283 -170
- package/dist/tool-descriptions.js +23 -15
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +10 -5
- package/dist/tool-handlers/analyze-video.js +9 -5
- package/dist/tool-handlers/async-chat.d.ts +17 -0
- package/dist/tool-handlers/async-chat.js +112 -31
- package/dist/tool-handlers/audio-utils.d.ts +19 -4
- package/dist/tool-handlers/audio-utils.js +170 -16
- package/dist/tool-handlers/cache.d.ts +3 -3
- package/dist/tool-handlers/cache.js +56 -4
- package/dist/tool-handlers/chat-completion.js +16 -7
- package/dist/tool-handlers/chat-request.d.ts +3 -0
- package/dist/tool-handlers/chat-request.js +28 -0
- package/dist/tool-handlers/completion-utils.d.ts +5 -11
- package/dist/tool-handlers/completion-utils.js +76 -47
- package/dist/tool-handlers/fetch-utils.d.ts +14 -0
- package/dist/tool-handlers/fetch-utils.js +331 -68
- package/dist/tool-handlers/generate-audio.d.ts +6 -51
- package/dist/tool-handlers/generate-audio.js +30 -76
- package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
- package/dist/tool-handlers/generate-image-dedicated.js +79 -28
- package/dist/tool-handlers/generate-image.d.ts +3 -46
- package/dist/tool-handlers/generate-image.js +26 -43
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +51 -50
- package/dist/tool-handlers/get-model-info.js +1 -1
- package/dist/tool-handlers/health-check.js +39 -15
- package/dist/tool-handlers/image-utils.js +2 -2
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
- package/dist/tool-handlers/openrouter-errors.js +138 -31
- package/dist/tool-handlers/path-safety.d.ts +6 -0
- package/dist/tool-handlers/path-safety.js +77 -14
- package/dist/tool-handlers/path-utils.d.ts +4 -0
- package/dist/tool-handlers/path-utils.js +20 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +10 -0
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +43 -14
- package/dist/tool-handlers/search-models.js +3 -3
- package/dist/tool-handlers/speech-to-text.d.ts +1 -0
- package/dist/tool-handlers/speech-to-text.js +23 -56
- package/dist/tool-handlers/text-to-speech.d.ts +2 -12
- package/dist/tool-handlers/text-to-speech.js +29 -22
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +98 -0
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +19 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -3
|
@@ -5,6 +5,8 @@ export interface ProviderRoutingOptions {
|
|
|
5
5
|
quantizations?: string[];
|
|
6
6
|
/** Exclude these provider slugs (e.g. `['openai', 'anthropic']`). */
|
|
7
7
|
ignore?: string[];
|
|
8
|
+
/** Allow only these provider slugs for the request. */
|
|
9
|
+
only?: string[];
|
|
8
10
|
/** Sort providers by this criterion. */
|
|
9
11
|
sort?: ProviderSort;
|
|
10
12
|
/** Prioritized provider list (e.g. `['openai/gpt-4o', 'anthropic/claude-3-opus']`). */
|
|
@@ -66,6 +66,16 @@ export function readProviderDefaults() {
|
|
|
66
66
|
const ignore = parseCsv(env.OPENROUTER_PROVIDER_IGNORE);
|
|
67
67
|
if (ignore)
|
|
68
68
|
out.ignore = ignore;
|
|
69
|
+
try {
|
|
70
|
+
const only = parseJsonArray(env.OPENROUTER_PROVIDER_ONLY, 'OPENROUTER_PROVIDER_ONLY');
|
|
71
|
+
if (only)
|
|
72
|
+
out.only = only;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
logger.warn('OPENROUTER_PROVIDER_ONLY ignored', {
|
|
76
|
+
err: err instanceof Error ? err.message : String(err),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
69
79
|
const sort = parseSort(env.OPENROUTER_PROVIDER_SORT);
|
|
70
80
|
if (sort)
|
|
71
81
|
out.sort = sort;
|
|
@@ -11,7 +11,4 @@ export declare function handleRerankDocuments(request: {
|
|
|
11
11
|
params: {
|
|
12
12
|
arguments: RerankDocumentsRequest;
|
|
13
13
|
};
|
|
14
|
-
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<
|
|
15
|
-
model: string;
|
|
16
|
-
results: Record<string, unknown>[];
|
|
17
|
-
}>>;
|
|
14
|
+
}, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | import("./structured-output.js").StructuredResult<unknown>>;
|
|
@@ -1,7 +1,39 @@
|
|
|
1
1
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
2
2
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
3
|
import { buildStructuredResult } from './structured-output.js';
|
|
4
|
+
import { capResultText } from './completion-utils.js';
|
|
4
5
|
const DEFAULT_MODEL = 'cohere/rerank-english-v3.0';
|
|
6
|
+
function isValidDocumentIndex(index, documentCount) {
|
|
7
|
+
return (typeof index === 'number' && Number.isInteger(index) && index >= 0 && index < documentCount);
|
|
8
|
+
}
|
|
9
|
+
function normalizeRerankResults(response, documents, returnDocuments, modelFallback) {
|
|
10
|
+
const invalid = (response.results ?? []).find((r) => !isValidDocumentIndex(r.index, documents.length));
|
|
11
|
+
if (invalid) {
|
|
12
|
+
return toolError(ErrorCode.INTERNAL, `Rerank API returned invalid document index ${String(invalid.index)} (expected 0–${documents.length - 1}).`);
|
|
13
|
+
}
|
|
14
|
+
const normalized = (response.results ?? []).map((r) => {
|
|
15
|
+
const score = typeof r.score === 'number' ? r.score : r.relevance_score;
|
|
16
|
+
const out = { index: r.index, score };
|
|
17
|
+
if (returnDocuments) {
|
|
18
|
+
const rawDoc = typeof r.document === 'string' ? r.document : (r.document?.text ?? documents[r.index]);
|
|
19
|
+
const capped = capResultText(rawDoc);
|
|
20
|
+
out.document = capped.text;
|
|
21
|
+
if (capped.truncated)
|
|
22
|
+
out.document_truncated = true;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
});
|
|
26
|
+
const payload = {
|
|
27
|
+
model: response.model ?? modelFallback,
|
|
28
|
+
results: normalized,
|
|
29
|
+
};
|
|
30
|
+
const jsonText = JSON.stringify(payload, null, 2);
|
|
31
|
+
const cappedJson = capResultText(jsonText);
|
|
32
|
+
if (cappedJson.truncated) {
|
|
33
|
+
return toolError(ErrorCode.RESOURCE_TOO_LARGE, 'Rerank result exceeds OPENROUTER_MAX_RESULT_TEXT_CHARS. Set it to 0 to disable or raise the limit.', { result_truncated: true });
|
|
34
|
+
}
|
|
35
|
+
return buildStructuredResult(payload, response.usage ? { usage: response.usage } : {});
|
|
36
|
+
}
|
|
5
37
|
export async function handleRerankDocuments(request, apiClient) {
|
|
6
38
|
const args = request.params.arguments ?? { query: '', documents: [] };
|
|
7
39
|
const { query, documents, model, top_n, return_documents } = args;
|
|
@@ -14,10 +46,19 @@ export async function handleRerankDocuments(request, apiClient) {
|
|
|
14
46
|
if (documents.some((d) => typeof d !== 'string')) {
|
|
15
47
|
return toolError(ErrorCode.INVALID_INPUT, 'every document must be a string.');
|
|
16
48
|
}
|
|
49
|
+
if (top_n !== undefined) {
|
|
50
|
+
if (typeof top_n !== 'number' || !Number.isFinite(top_n)) {
|
|
51
|
+
return toolError(ErrorCode.INVALID_INPUT, 'top_n must be a finite number.');
|
|
52
|
+
}
|
|
53
|
+
if (top_n < 1) {
|
|
54
|
+
return toolError(ErrorCode.INVALID_INPUT, 'top_n must be at least 1 when specified.');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const effectiveModel = model || DEFAULT_MODEL;
|
|
17
58
|
let response;
|
|
18
59
|
try {
|
|
19
60
|
response = await apiClient.rerank({
|
|
20
|
-
model:
|
|
61
|
+
model: effectiveModel,
|
|
21
62
|
query,
|
|
22
63
|
documents,
|
|
23
64
|
top_n,
|
|
@@ -26,20 +67,8 @@ export async function handleRerankDocuments(request, apiClient) {
|
|
|
26
67
|
catch (err) {
|
|
27
68
|
return classifyUpstreamError(err, 'rerank');
|
|
28
69
|
}
|
|
29
|
-
const normalized = (response.results ?? []).map((r) => {
|
|
30
|
-
const score = typeof r.score === 'number' ? r.score : r.relevance_score;
|
|
31
|
-
const out = { index: r.index, score };
|
|
32
|
-
if (return_documents) {
|
|
33
|
-
const doc = typeof r.document === 'string' ? r.document : (r.document?.text ?? documents[r.index]);
|
|
34
|
-
out.document = doc;
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
});
|
|
38
70
|
try {
|
|
39
|
-
return
|
|
40
|
-
model: response.model ?? model ?? DEFAULT_MODEL,
|
|
41
|
-
results: normalized,
|
|
42
|
-
}, response.usage ? { usage: response.usage } : {});
|
|
71
|
+
return normalizeRerankResults(response, documents, return_documents === true, effectiveModel);
|
|
43
72
|
}
|
|
44
73
|
catch (err) {
|
|
45
74
|
return toolErrorFrom(ErrorCode.INTERNAL, err, 'rerank');
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { clampLimit, clampOffset } from '../model-cache.js';
|
|
1
2
|
import { ErrorCode, toolErrorFrom } from '../errors.js';
|
|
2
3
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
4
|
import { buildStructuredResult } from './structured-output.js';
|
|
4
5
|
const DEFAULT_LIMIT = 20;
|
|
5
|
-
const MAX_LIMIT = 50;
|
|
6
6
|
export async function handleSearchModels(request, apiClient, modelCache) {
|
|
7
7
|
try {
|
|
8
8
|
await modelCache.ensureFresh(() => apiClient.getModels());
|
|
@@ -12,8 +12,8 @@ export async function handleSearchModels(request, apiClient, modelCache) {
|
|
|
12
12
|
}
|
|
13
13
|
try {
|
|
14
14
|
const args = request.params.arguments ?? {};
|
|
15
|
-
const limit =
|
|
16
|
-
const offset =
|
|
15
|
+
const limit = clampLimit(args.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT);
|
|
16
|
+
const offset = clampOffset(args.offset ?? 0);
|
|
17
17
|
const { page, total } = modelCache.searchPaginated({
|
|
18
18
|
query: args.query,
|
|
19
19
|
provider: args.provider,
|
|
@@ -1,63 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
-
import { resolveSafeInputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
5
|
-
import { fetchHttpResource } from './fetch-utils.js';
|
|
1
|
+
import { STT_RESPONSE_FORMATS } from '../tool-definitions.js';
|
|
2
|
+
import { UnsafeOutputPathError } from './path-safety.js';
|
|
3
|
+
import { resolveSpeechToTextAudio } from './audio-utils.js';
|
|
6
4
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
7
5
|
import { SERVER_VERSION } from '../version.js';
|
|
8
6
|
import { logger } from '../logger.js';
|
|
9
7
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
-
import { buildCacheHeaders } from './cache.js';
|
|
8
|
+
import { buildCacheHeaders, validateCacheOptions } from './cache.js';
|
|
11
9
|
const DEFAULT_MODEL = 'openai/whisper-1';
|
|
12
|
-
const VALID_RESPONSE_FORMATS = new Set(
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
switch (normalized) {
|
|
17
|
-
case 'mp3':
|
|
18
|
-
return 'mp3';
|
|
19
|
-
case 'mp4':
|
|
20
|
-
case 'm4a':
|
|
21
|
-
return 'mp4';
|
|
22
|
-
case 'wav':
|
|
23
|
-
return 'wav';
|
|
24
|
-
case 'flac':
|
|
25
|
-
return 'flac';
|
|
26
|
-
case 'ogg':
|
|
27
|
-
case 'oga':
|
|
28
|
-
return 'ogg';
|
|
29
|
-
case 'webm':
|
|
30
|
-
return 'webm';
|
|
31
|
-
case 'opus':
|
|
32
|
-
return 'opus';
|
|
33
|
-
default:
|
|
34
|
-
return 'mp3';
|
|
10
|
+
const VALID_RESPONSE_FORMATS = new Set(STT_RESPONSE_FORMATS);
|
|
11
|
+
function formatTranscriptionContent(response, responseFormat) {
|
|
12
|
+
if (responseFormat === 'verbose_json') {
|
|
13
|
+
return JSON.stringify(response, null, 2);
|
|
35
14
|
}
|
|
36
|
-
|
|
37
|
-
async function resolveAudioInput(audioPath) {
|
|
38
|
-
const trimmed = audioPath.trim();
|
|
39
|
-
if (!trimmed)
|
|
40
|
-
throw new Error('audio_path is empty');
|
|
41
|
-
if (trimmed.startsWith('data:')) {
|
|
42
|
-
const match = trimmed.match(/^data:audio\/([^;,]+)(?:;[^,]*)*;base64,(.+)$/);
|
|
43
|
-
if (!match)
|
|
44
|
-
throw new Error('Invalid audio data URL format');
|
|
45
|
-
return { data: match[2], format: match[1] };
|
|
46
|
-
}
|
|
47
|
-
if (/^https?:\/\//i.test(trimmed)) {
|
|
48
|
-
const { buffer, contentType } = await fetchHttpResource(trimmed, {
|
|
49
|
-
timeoutMs: 60_000,
|
|
50
|
-
maxBytes: 100 * 1024 * 1024,
|
|
51
|
-
maxRedirects: 8,
|
|
52
|
-
});
|
|
53
|
-
const format = contentType?.match(/audio\/(\w+)/)?.[1] || 'mp3';
|
|
54
|
-
return { data: buffer.toString('base64'), format };
|
|
55
|
-
}
|
|
56
|
-
const abs = await resolveSafeInputPath(trimmed);
|
|
57
|
-
const buf = await fs.readFile(abs);
|
|
58
|
-
const ext = path.extname(abs);
|
|
59
|
-
const format = audioFormatFromExt(ext);
|
|
60
|
-
return { data: buf.toString('base64'), format };
|
|
15
|
+
return response.text ?? null;
|
|
61
16
|
}
|
|
62
17
|
export async function handleSpeechToText(request, apiClient) {
|
|
63
18
|
const args = request.params.arguments ?? {};
|
|
@@ -68,6 +23,12 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
68
23
|
if (response_format && !VALID_RESPONSE_FORMATS.has(response_format)) {
|
|
69
24
|
return toolError(ErrorCode.INVALID_INPUT, `response_format '${response_format}' is not supported. Valid: ${[...VALID_RESPONSE_FORMATS].join(', ')}.`);
|
|
70
25
|
}
|
|
26
|
+
if (typeof temperature === 'number' && (temperature < 0 || temperature > 1)) {
|
|
27
|
+
return toolError(ErrorCode.INVALID_INPUT, 'temperature must be between 0 and 1 (inclusive).');
|
|
28
|
+
}
|
|
29
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
30
|
+
if (cacheError)
|
|
31
|
+
return cacheError;
|
|
71
32
|
logger.audit('speech_to_text.start', {
|
|
72
33
|
model: model || DEFAULT_MODEL,
|
|
73
34
|
audio_path: audio_path.startsWith('data:') ? 'data_url' : audio_path.slice(0, 80),
|
|
@@ -76,7 +37,7 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
76
37
|
});
|
|
77
38
|
let audioInput;
|
|
78
39
|
try {
|
|
79
|
-
audioInput = await
|
|
40
|
+
audioInput = await resolveSpeechToTextAudio(audio_path);
|
|
80
41
|
}
|
|
81
42
|
catch (err) {
|
|
82
43
|
if (err instanceof UnsafeOutputPathError)
|
|
@@ -84,6 +45,12 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
84
45
|
const msg = err instanceof Error ? err.message : String(err);
|
|
85
46
|
if (msg.includes('Blocked host'))
|
|
86
47
|
return toolErrorFrom(ErrorCode.UPSTREAM_REFUSED, err);
|
|
48
|
+
if (msg.toLowerCase().includes('too large')) {
|
|
49
|
+
return toolErrorFrom(ErrorCode.RESOURCE_TOO_LARGE, err);
|
|
50
|
+
}
|
|
51
|
+
if (msg.toLowerCase().includes('unsupported')) {
|
|
52
|
+
return toolErrorFrom(ErrorCode.UNSUPPORTED_FORMAT, err);
|
|
53
|
+
}
|
|
87
54
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
88
55
|
}
|
|
89
56
|
const body = {
|
|
@@ -107,7 +74,7 @@ export async function handleSpeechToText(request, apiClient) {
|
|
|
107
74
|
catch (err) {
|
|
108
75
|
return classifyUpstreamError(err, 'speech_to_text');
|
|
109
76
|
}
|
|
110
|
-
const text = response
|
|
77
|
+
const text = formatTranscriptionContent(response, response_format);
|
|
111
78
|
if (!text) {
|
|
112
79
|
return toolError(ErrorCode.INTERNAL, 'Transcription returned no text.', {
|
|
113
80
|
response_keys: Object.keys(response),
|
|
@@ -13,17 +13,7 @@ export declare function handleTextToSpeech(request: {
|
|
|
13
13
|
params: {
|
|
14
14
|
arguments: TextToSpeechRequest;
|
|
15
15
|
};
|
|
16
|
-
}, apiClient: OpenRouterAPIClient): Promise<
|
|
17
|
-
content: (
|
|
18
|
-
type: "text";
|
|
19
|
-
text: string;
|
|
20
|
-
mimeType?: undefined;
|
|
21
|
-
data?: undefined;
|
|
22
|
-
} | {
|
|
23
|
-
type: "audio";
|
|
24
|
-
mimeType: string;
|
|
25
|
-
data: string;
|
|
26
|
-
text?: undefined;
|
|
27
|
-
})[];
|
|
16
|
+
}, apiClient: OpenRouterAPIClient): Promise<{
|
|
17
|
+
content: import("./tool-result-payload.js").BinaryToolContent[];
|
|
28
18
|
_meta: Record<string, unknown>;
|
|
29
19
|
}>;
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/** Dedicated POST /api/v1/audio/speech — OpenAI, Gemini Flash TTS, Voxtral. */
|
|
2
|
-
import { promises as fs } from 'node:fs';
|
|
3
2
|
import { extname } from 'node:path';
|
|
3
|
+
import { TTS_RESPONSE_FORMATS } from '../tool-definitions.js';
|
|
4
4
|
import { resolveOptionalOutputPath, isToolErrorResult } from './path-safety.js';
|
|
5
5
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
6
6
|
import { SERVER_VERSION } from '../version.js';
|
|
7
7
|
import { logger } from '../logger.js';
|
|
8
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
-
import {
|
|
9
|
+
import { buildBinaryToolResult } from './tool-result-payload.js';
|
|
10
|
+
import { replaceExtension, writeOutputFile } from './path-utils.js';
|
|
11
|
+
import { buildCacheHeaders, validateCacheOptions } from './cache.js';
|
|
12
|
+
import { detectAudioFormat } from './audio-utils.js';
|
|
10
13
|
const DEFAULT_MODEL = 'openai/gpt-4o-mini-tts-2025-12-15';
|
|
11
14
|
const DEFAULT_VOICE = 'alloy';
|
|
12
|
-
const
|
|
15
|
+
const MIN_SPEED = 0.25;
|
|
16
|
+
const MAX_SPEED = 4.0;
|
|
17
|
+
const VALID_FORMATS = new Set(TTS_RESPONSE_FORMATS);
|
|
13
18
|
export async function handleTextToSpeech(request, apiClient) {
|
|
14
19
|
const args = request.params.arguments ?? {};
|
|
15
20
|
const { input, model, voice, response_format, speed, instructions, save_path, cache, cache_ttl, cache_clear, } = args;
|
|
@@ -19,6 +24,12 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
19
24
|
if (response_format && !VALID_FORMATS.has(response_format)) {
|
|
20
25
|
return toolError(ErrorCode.INVALID_INPUT, `response_format '${response_format}' is not supported. Valid: ${[...VALID_FORMATS].join(', ')}.`);
|
|
21
26
|
}
|
|
27
|
+
if (typeof speed === 'number' && (speed < MIN_SPEED || speed > MAX_SPEED)) {
|
|
28
|
+
return toolError(ErrorCode.INVALID_INPUT, `speed must be between ${MIN_SPEED} and ${MAX_SPEED} (inclusive).`);
|
|
29
|
+
}
|
|
30
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
31
|
+
if (cacheError)
|
|
32
|
+
return cacheError;
|
|
22
33
|
logger.audit('text_to_speech.start', {
|
|
23
34
|
model: model || DEFAULT_MODEL,
|
|
24
35
|
voice: voice || DEFAULT_VOICE,
|
|
@@ -37,7 +48,7 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
37
48
|
};
|
|
38
49
|
if (response_format)
|
|
39
50
|
body.response_format = response_format;
|
|
40
|
-
if (typeof speed === 'number'
|
|
51
|
+
if (typeof speed === 'number')
|
|
41
52
|
body.speed = speed;
|
|
42
53
|
if (instructions)
|
|
43
54
|
body.instructions = instructions;
|
|
@@ -50,8 +61,9 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
50
61
|
return classifyUpstreamError(err, 'text_to_speech');
|
|
51
62
|
}
|
|
52
63
|
const { buffer, contentType } = result;
|
|
53
|
-
const
|
|
54
|
-
const
|
|
64
|
+
const detected = detectAudioFormat(buffer);
|
|
65
|
+
const mimeType = detected.mimeType || contentType.split(';')[0]?.trim() || 'audio/mpeg';
|
|
66
|
+
const ext = detected.ext;
|
|
55
67
|
const baseMeta = {
|
|
56
68
|
server_version: SERVER_VERSION,
|
|
57
69
|
model: model || DEFAULT_MODEL,
|
|
@@ -61,27 +73,22 @@ export async function handleTextToSpeech(request, apiClient) {
|
|
|
61
73
|
};
|
|
62
74
|
if (safeSavePath) {
|
|
63
75
|
const currentExt = extname(safeSavePath).toLowerCase().slice(1);
|
|
64
|
-
const actualPath = currentExt === ext ? safeSavePath :
|
|
76
|
+
const actualPath = currentExt === ext ? safeSavePath : replaceExtension(safeSavePath, ext);
|
|
65
77
|
try {
|
|
66
|
-
await
|
|
78
|
+
await writeOutputFile(actualPath, buffer);
|
|
67
79
|
}
|
|
68
80
|
catch (err) {
|
|
69
81
|
return toolErrorFrom(ErrorCode.INTERNAL, err, 'Write');
|
|
70
82
|
}
|
|
71
83
|
baseMeta.save_path = actualPath;
|
|
72
|
-
return {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
_meta: baseMeta,
|
|
78
|
-
};
|
|
84
|
+
return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
|
|
85
|
+
savedPath: actualPath,
|
|
86
|
+
summaryText: `Speech saved to: ${actualPath}`,
|
|
87
|
+
meta: baseMeta,
|
|
88
|
+
});
|
|
79
89
|
}
|
|
80
|
-
return {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
],
|
|
85
|
-
_meta: baseMeta,
|
|
86
|
-
};
|
|
90
|
+
return buildBinaryToolResult({ kind: 'audio', buffer, mimeType }, {
|
|
91
|
+
prefixText: `Speech generated (${buffer.length} bytes, ${mimeType}).`,
|
|
92
|
+
meta: baseMeta,
|
|
93
|
+
});
|
|
87
94
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type InlineMediaKind = 'image' | 'audio' | 'video';
|
|
2
|
+
type TextContent = {
|
|
3
|
+
type: 'text';
|
|
4
|
+
text: string;
|
|
5
|
+
};
|
|
6
|
+
type ImageContent = {
|
|
7
|
+
type: 'image';
|
|
8
|
+
mimeType: string;
|
|
9
|
+
data: string;
|
|
10
|
+
};
|
|
11
|
+
type AudioContent = {
|
|
12
|
+
type: 'audio';
|
|
13
|
+
mimeType: string;
|
|
14
|
+
data: string;
|
|
15
|
+
};
|
|
16
|
+
type ResourceContent = {
|
|
17
|
+
type: 'resource';
|
|
18
|
+
resource: {
|
|
19
|
+
uri: string;
|
|
20
|
+
mimeType?: string;
|
|
21
|
+
blob: string;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export type BinaryToolContent = TextContent | ImageContent | AudioContent | ResourceContent;
|
|
25
|
+
export interface BinaryArtifact {
|
|
26
|
+
kind: InlineMediaKind;
|
|
27
|
+
buffer: Buffer;
|
|
28
|
+
mimeType: string;
|
|
29
|
+
}
|
|
30
|
+
export interface BuildBinaryToolResultOptions {
|
|
31
|
+
savedPath?: string | null;
|
|
32
|
+
/** Overrides default saved/too-large message */
|
|
33
|
+
summaryText?: string;
|
|
34
|
+
/** Shown alongside inline media when not using inlineOnly */
|
|
35
|
+
prefixText?: string;
|
|
36
|
+
/** When inline fits and no save_path: return media block only (image UX) */
|
|
37
|
+
inlineOnly?: boolean;
|
|
38
|
+
remoteUrl?: string;
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
40
|
+
maxInlineBytes?: number;
|
|
41
|
+
}
|
|
42
|
+
export declare function getMaxInlineBytes(kind: InlineMediaKind): number;
|
|
43
|
+
export declare function buildBinaryToolResult(artifact: BinaryArtifact, opts?: BuildBinaryToolResultOptions): {
|
|
44
|
+
content: BinaryToolContent[];
|
|
45
|
+
_meta: Record<string, unknown>;
|
|
46
|
+
};
|
|
47
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
const DEFAULT_INLINE_MAX_BYTES = 1024 * 1024;
|
|
2
|
+
const DEFAULT_VIDEO_INLINE_MAX_BYTES = 10 * 1024 * 1024;
|
|
3
|
+
const INLINE_VIDEO_URI = 'inline://openrouter-mcp-multimodal/video';
|
|
4
|
+
const KIND_ENV_KEYS = {
|
|
5
|
+
image: 'OPENROUTER_IMAGE_INLINE_MAX_BYTES',
|
|
6
|
+
audio: 'OPENROUTER_AUDIO_INLINE_MAX_BYTES',
|
|
7
|
+
video: 'OPENROUTER_VIDEO_INLINE_MAX_BYTES',
|
|
8
|
+
};
|
|
9
|
+
const KIND_DEFAULT_BYTES = {
|
|
10
|
+
image: DEFAULT_INLINE_MAX_BYTES,
|
|
11
|
+
audio: DEFAULT_INLINE_MAX_BYTES,
|
|
12
|
+
video: DEFAULT_VIDEO_INLINE_MAX_BYTES,
|
|
13
|
+
};
|
|
14
|
+
function readEnvInlineMaxBytes(name, fallback) {
|
|
15
|
+
const raw = process.env[name];
|
|
16
|
+
if (raw === undefined || raw === '')
|
|
17
|
+
return fallback;
|
|
18
|
+
if (!/^\d+$/.test(raw))
|
|
19
|
+
return fallback;
|
|
20
|
+
const n = Number(raw);
|
|
21
|
+
return Number.isFinite(n) && n >= 0 ? n : fallback;
|
|
22
|
+
}
|
|
23
|
+
export function getMaxInlineBytes(kind) {
|
|
24
|
+
const globalFallback = readEnvInlineMaxBytes('OPENROUTER_INLINE_MAX_BYTES', KIND_DEFAULT_BYTES[kind]);
|
|
25
|
+
return readEnvInlineMaxBytes(KIND_ENV_KEYS[kind], globalFallback);
|
|
26
|
+
}
|
|
27
|
+
function kindLabel(kind) {
|
|
28
|
+
switch (kind) {
|
|
29
|
+
case 'image':
|
|
30
|
+
return 'Image';
|
|
31
|
+
case 'audio':
|
|
32
|
+
return 'Audio';
|
|
33
|
+
case 'video':
|
|
34
|
+
return 'Video';
|
|
35
|
+
default: {
|
|
36
|
+
const _exhaustive = kind;
|
|
37
|
+
return _exhaustive;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function buildInlineBlock(kind, mimeType, data, remoteUrl) {
|
|
42
|
+
if (kind === 'video') {
|
|
43
|
+
return {
|
|
44
|
+
type: 'resource',
|
|
45
|
+
resource: {
|
|
46
|
+
uri: remoteUrl ?? INLINE_VIDEO_URI,
|
|
47
|
+
mimeType,
|
|
48
|
+
blob: data,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return { type: kind, mimeType, data };
|
|
53
|
+
}
|
|
54
|
+
export function buildBinaryToolResult(artifact, opts = {}) {
|
|
55
|
+
const { kind, buffer, mimeType } = artifact;
|
|
56
|
+
const maxInline = opts.maxInlineBytes ?? getMaxInlineBytes(kind);
|
|
57
|
+
const meta = {
|
|
58
|
+
...opts.meta,
|
|
59
|
+
mime: mimeType,
|
|
60
|
+
size_bytes: buffer.length,
|
|
61
|
+
};
|
|
62
|
+
if (opts.savedPath) {
|
|
63
|
+
const text = opts.summaryText ??
|
|
64
|
+
`${kindLabel(kind)} saved to: ${opts.savedPath} (${buffer.length} bytes, ${mimeType})`;
|
|
65
|
+
return {
|
|
66
|
+
content: [{ type: 'text', text }],
|
|
67
|
+
_meta: { ...meta, save_path: opts.savedPath },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (buffer.length <= maxInline) {
|
|
71
|
+
const data = buffer.toString('base64');
|
|
72
|
+
if (opts.inlineOnly) {
|
|
73
|
+
return {
|
|
74
|
+
content: [buildInlineBlock(kind, mimeType, data, opts.remoteUrl)],
|
|
75
|
+
_meta: meta,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const text = opts.prefixText ?? `${kindLabel(kind)} generated (${buffer.length} bytes, ${mimeType}).`;
|
|
79
|
+
return {
|
|
80
|
+
content: [textBlock(text), buildInlineBlock(kind, mimeType, data, opts.remoteUrl)],
|
|
81
|
+
_meta: meta,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const urlHint = opts.remoteUrl ? ` URL: ${opts.remoteUrl}` : '';
|
|
85
|
+
return {
|
|
86
|
+
content: [
|
|
87
|
+
{
|
|
88
|
+
type: 'text',
|
|
89
|
+
text: opts.summaryText ??
|
|
90
|
+
`${kindLabel(kind)} generated (${buffer.length} bytes, ${mimeType}). Too large to inline; pass save_path to persist.${urlHint}`,
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
_meta: meta,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function textBlock(text) {
|
|
97
|
+
return { type: 'text', text };
|
|
98
|
+
}
|
|
@@ -17,6 +17,6 @@ export async function handleValidateModel(request, modelCache, apiClient) {
|
|
|
17
17
|
if (!modelCache.isValid()) {
|
|
18
18
|
return toolError(ErrorCode.INTERNAL, 'No model data available.');
|
|
19
19
|
}
|
|
20
|
-
const valid = modelCache.
|
|
20
|
+
const valid = modelCache.catalogHas(model);
|
|
21
21
|
return buildStructuredResult({ valid, model });
|
|
22
22
|
}
|
package/dist/tool-handlers.d.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
type McpProgressHook = (update: {
|
|
3
|
+
status: string;
|
|
4
|
+
progress?: number;
|
|
5
|
+
attempt: number;
|
|
6
|
+
video_id: string;
|
|
7
|
+
}) => void;
|
|
8
|
+
/** MCP `notifications/progress` hook. */
|
|
9
|
+
export declare function buildProgressHook(server: Server, progressToken: string | number | undefined): McpProgressHook | undefined;
|
|
2
10
|
export declare class ToolHandlers {
|
|
3
11
|
private openai;
|
|
4
12
|
private modelCache;
|
|
@@ -8,3 +16,4 @@ export declare class ToolHandlers {
|
|
|
8
16
|
constructor(server: Server, apiKey: string, defaultModel?: string);
|
|
9
17
|
private register;
|
|
10
18
|
}
|
|
19
|
+
export {};
|
package/dist/tool-handlers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CallToolRequestSchema, ErrorCode as McpErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
import
|
|
2
|
+
import { createOpenRouterOpenAIClient } from './openrouter-openai-client.js';
|
|
3
3
|
import { ModelCache } from './model-cache.js';
|
|
4
4
|
import { OpenRouterAPIClient } from './openrouter-api.js';
|
|
5
5
|
import { handleChatCompletion } from './tool-handlers/chat-completion.js';
|
|
@@ -20,25 +20,31 @@ import { handleSpeechToText } from './tool-handlers/speech-to-text.js';
|
|
|
20
20
|
import { handleStartChatCompletion, handleGetChatCompletionStatus, } from './tool-handlers/async-chat.js';
|
|
21
21
|
import { TOOL_DEFINITIONS } from './tool-definitions.js';
|
|
22
22
|
import { TOOL_ICONS } from './tool-icons.js';
|
|
23
|
+
import { ErrorCode, toolErrorFrom } from './errors.js';
|
|
24
|
+
import { logger } from './logger.js';
|
|
23
25
|
function wrapToolArgs(a) {
|
|
24
26
|
return { params: { arguments: a ?? {} } };
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
/** MCP `notifications/progress` hook. */
|
|
29
|
+
export function buildProgressHook(server, progressToken) {
|
|
27
30
|
if (progressToken === undefined)
|
|
28
31
|
return undefined;
|
|
29
|
-
// MCP progress must monotonically increase; upstream values can drop or be omitted.
|
|
30
32
|
let lastSent = -1;
|
|
31
33
|
return ({ status, progress, attempt, video_id }) => {
|
|
32
34
|
const candidate = typeof progress === 'number' ? Math.max(attempt, progress) : attempt;
|
|
33
35
|
const next = Math.max(lastSent + 1, candidate);
|
|
34
36
|
lastSent = next;
|
|
35
|
-
void server
|
|
37
|
+
void server
|
|
38
|
+
.notification({
|
|
36
39
|
method: 'notifications/progress',
|
|
37
40
|
params: {
|
|
38
41
|
progressToken,
|
|
39
42
|
progress: next,
|
|
40
43
|
message: `video ${video_id} — ${status}${typeof progress === 'number' ? ` (${progress}%)` : ''}`,
|
|
41
44
|
},
|
|
45
|
+
})
|
|
46
|
+
.catch((err) => {
|
|
47
|
+
logger.warn('progress notification failed', { err: String(err) });
|
|
42
48
|
});
|
|
43
49
|
};
|
|
44
50
|
}
|
|
@@ -55,10 +61,7 @@ export class ToolHandlers {
|
|
|
55
61
|
constructor(server, apiKey, defaultModel) {
|
|
56
62
|
this.defaultModel = defaultModel;
|
|
57
63
|
this.apiClient = new OpenRouterAPIClient(apiKey);
|
|
58
|
-
this.openai =
|
|
59
|
-
apiKey,
|
|
60
|
-
baseURL: 'https://openrouter.ai/api/v1',
|
|
61
|
-
});
|
|
64
|
+
this.openai = createOpenRouterOpenAIClient(apiKey);
|
|
62
65
|
this.server = server;
|
|
63
66
|
this.register(server);
|
|
64
67
|
}
|
|
@@ -71,7 +74,6 @@ export class ToolHandlers {
|
|
|
71
74
|
}));
|
|
72
75
|
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
73
76
|
const { name, arguments: args } = request.params;
|
|
74
|
-
// Handlers return extra _meta keys not in the SDK type.
|
|
75
77
|
const dispatch = async () => {
|
|
76
78
|
switch (name) {
|
|
77
79
|
case 'chat_completion':
|
|
@@ -116,7 +118,14 @@ export class ToolHandlers {
|
|
|
116
118
|
throw new McpError(McpErrorCode.MethodNotFound, `Unknown tool: ${name}`);
|
|
117
119
|
}
|
|
118
120
|
};
|
|
119
|
-
|
|
121
|
+
try {
|
|
122
|
+
return (await dispatch());
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
if (err instanceof McpError)
|
|
126
|
+
throw err;
|
|
127
|
+
return toolErrorFrom(ErrorCode.INTERNAL, err);
|
|
128
|
+
}
|
|
120
129
|
});
|
|
121
130
|
}
|
|
122
131
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const SERVER_VERSION = "4.
|
|
1
|
+
export declare const SERVER_VERSION = "4.8.0";
|
|
2
2
|
export declare const MCP_PROTOCOL_VERSION = "2025-06-18";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export const SERVER_VERSION = '4.
|
|
1
|
+
export const SERVER_VERSION = '4.8.0';
|
|
2
2
|
export const MCP_PROTOCOL_VERSION = '2025-06-18';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stabgan/openrouter-mcp-multimodal",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.8.0",
|
|
4
4
|
"mcpName": "io.github.stabgan/openrouter-multimodal",
|
|
5
5
|
"description": "MCP server for OpenRouter — chat with 300+ LLMs, analyze images/audio/video, generate images (dedicated API), TTS/STT, video generation (Veo 3.1 / Seedance / Wan), async completions, response caching",
|
|
6
6
|
"type": "module",
|
|
@@ -68,8 +68,6 @@
|
|
|
68
68
|
"@types/node": "^22.20.0",
|
|
69
69
|
"eslint": "^9.39.2",
|
|
70
70
|
"eslint-config-prettier": "^10.1.8",
|
|
71
|
-
"form-data": "^4.0.6",
|
|
72
|
-
"js-yaml": "^4.3.0",
|
|
73
71
|
"prettier": "^3.9.4",
|
|
74
72
|
"shx": "^0.4.0",
|
|
75
73
|
"typescript": "^5.9.3",
|