@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.
Files changed (64) hide show
  1. package/README.md +108 -37
  2. package/dist/errors.d.ts +5 -20
  3. package/dist/errors.js +1 -10
  4. package/dist/index.js +7 -1
  5. package/dist/logger.js +54 -24
  6. package/dist/model-cache.d.ts +13 -0
  7. package/dist/model-cache.js +61 -5
  8. package/dist/openrouter-api.d.ts +14 -15
  9. package/dist/openrouter-api.js +68 -22
  10. package/dist/openrouter-openai-client.d.ts +9 -0
  11. package/dist/openrouter-openai-client.js +15 -0
  12. package/dist/tool-definitions.d.ts +24 -0
  13. package/dist/tool-definitions.js +283 -170
  14. package/dist/tool-descriptions.js +23 -15
  15. package/dist/tool-handlers/analyze-audio.js +4 -1
  16. package/dist/tool-handlers/analyze-image.js +10 -5
  17. package/dist/tool-handlers/analyze-video.js +9 -5
  18. package/dist/tool-handlers/async-chat.d.ts +17 -0
  19. package/dist/tool-handlers/async-chat.js +112 -31
  20. package/dist/tool-handlers/audio-utils.d.ts +19 -4
  21. package/dist/tool-handlers/audio-utils.js +170 -16
  22. package/dist/tool-handlers/cache.d.ts +3 -3
  23. package/dist/tool-handlers/cache.js +56 -4
  24. package/dist/tool-handlers/chat-completion.js +16 -7
  25. package/dist/tool-handlers/chat-request.d.ts +3 -0
  26. package/dist/tool-handlers/chat-request.js +28 -0
  27. package/dist/tool-handlers/completion-utils.d.ts +5 -11
  28. package/dist/tool-handlers/completion-utils.js +76 -47
  29. package/dist/tool-handlers/fetch-utils.d.ts +14 -0
  30. package/dist/tool-handlers/fetch-utils.js +331 -68
  31. package/dist/tool-handlers/generate-audio.d.ts +6 -51
  32. package/dist/tool-handlers/generate-audio.js +30 -76
  33. package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
  34. package/dist/tool-handlers/generate-image-dedicated.js +79 -28
  35. package/dist/tool-handlers/generate-image.d.ts +3 -46
  36. package/dist/tool-handlers/generate-image.js +26 -43
  37. package/dist/tool-handlers/generate-video.d.ts +4 -3
  38. package/dist/tool-handlers/generate-video.js +51 -50
  39. package/dist/tool-handlers/get-model-info.js +1 -1
  40. package/dist/tool-handlers/health-check.js +39 -15
  41. package/dist/tool-handlers/image-utils.js +2 -2
  42. package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
  43. package/dist/tool-handlers/openrouter-errors.js +138 -31
  44. package/dist/tool-handlers/path-safety.d.ts +6 -0
  45. package/dist/tool-handlers/path-safety.js +77 -14
  46. package/dist/tool-handlers/path-utils.d.ts +4 -0
  47. package/dist/tool-handlers/path-utils.js +20 -0
  48. package/dist/tool-handlers/provider-routing.d.ts +2 -0
  49. package/dist/tool-handlers/provider-routing.js +10 -0
  50. package/dist/tool-handlers/rerank.d.ts +1 -4
  51. package/dist/tool-handlers/rerank.js +43 -14
  52. package/dist/tool-handlers/search-models.js +3 -3
  53. package/dist/tool-handlers/speech-to-text.d.ts +1 -0
  54. package/dist/tool-handlers/speech-to-text.js +23 -56
  55. package/dist/tool-handlers/text-to-speech.d.ts +2 -12
  56. package/dist/tool-handlers/text-to-speech.js +29 -22
  57. package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
  58. package/dist/tool-handlers/tool-result-payload.js +98 -0
  59. package/dist/tool-handlers/validate-model.js +1 -1
  60. package/dist/tool-handlers.d.ts +9 -0
  61. package/dist/tool-handlers.js +19 -10
  62. package/dist/version.d.ts +1 -1
  63. package/dist/version.js +1 -1
  64. 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: model || DEFAULT_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 buildStructuredResult({
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 = Math.min(Math.max(1, args.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
16
- const offset = Math.max(0, args.offset ?? 0);
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,3 +1,4 @@
1
+ /** Dedicated POST /api/v1/audio/transcriptions — Whisper, GPT-4o Transcribe, Voxtral. */
1
2
  import type { OpenRouterAPIClient } from '../openrouter-api.js';
2
3
  import { type CacheOptions } from './cache.js';
3
4
  export interface SpeechToTextRequest extends CacheOptions {
@@ -1,63 +1,18 @@
1
- /** Dedicated POST /api/v1/audio/transcriptions — Whisper, GPT-4o Transcribe, Voxtral. */
2
- import { promises as fs } from 'node:fs';
3
- import path from 'node:path';
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(['json', 'text', 'srt', 'verbose_json', 'vtt']);
13
- /** Infer audio format from file extension. */
14
- function audioFormatFromExt(ext) {
15
- const normalized = ext.toLowerCase().replace('.', '');
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 resolveAudioInput(audio_path);
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.text;
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<import("../errors.js").ToolErrorResult | {
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 { buildCacheHeaders } from './cache.js';
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 VALID_FORMATS = new Set(['mp3', 'opus', 'aac', 'flac', 'wav', 'pcm']);
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' && speed > 0)
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 mimeType = contentType.split(';')[0]?.trim() || 'audio/mpeg';
54
- const ext = response_format || 'mp3';
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 : `${safeSavePath}.${ext}`;
76
+ const actualPath = currentExt === ext ? safeSavePath : replaceExtension(safeSavePath, ext);
65
77
  try {
66
- await fs.writeFile(actualPath, buffer);
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
- content: [
74
- { type: 'text', text: `Speech saved to: ${actualPath}` },
75
- { type: 'audio', mimeType, data: buffer.toString('base64') },
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
- content: [
82
- { type: 'text', text: `Speech generated (${buffer.length} bytes, ${mimeType}).` },
83
- { type: 'audio', mimeType, data: buffer.toString('base64') },
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.has(model);
20
+ const valid = modelCache.catalogHas(model);
21
21
  return buildStructuredResult({ valid, model });
22
22
  }
@@ -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 {};
@@ -1,5 +1,5 @@
1
1
  import { CallToolRequestSchema, ErrorCode as McpErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js';
2
- import OpenAI from 'openai';
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
- function buildProgressHook(server, progressToken) {
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.notification({
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 = new 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
- return (await dispatch());
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.6.2";
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.6.2';
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.6.2",
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",