@stabgan/openrouter-mcp-multimodal 4.5.1 → 4.6.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 (44) hide show
  1. package/README.md +367 -283
  2. package/dist/index.js +1 -1
  3. package/dist/model-cache.d.ts +22 -12
  4. package/dist/model-cache.js +58 -21
  5. package/dist/openrouter-api.d.ts +45 -0
  6. package/dist/openrouter-api.js +50 -0
  7. package/dist/tool-descriptions.d.ts +19 -0
  8. package/dist/tool-descriptions.js +573 -0
  9. package/dist/tool-handlers/analyze-audio.js +5 -1
  10. package/dist/tool-handlers/analyze-image.js +6 -5
  11. package/dist/tool-handlers/analyze-video.js +6 -5
  12. package/dist/tool-handlers/async-chat.d.ts +51 -0
  13. package/dist/tool-handlers/async-chat.js +216 -0
  14. package/dist/tool-handlers/audio-utils.js +4 -2
  15. package/dist/tool-handlers/chat-completion.js +1 -1
  16. package/dist/tool-handlers/fetch-utils.js +16 -2
  17. package/dist/tool-handlers/generate-audio.js +2 -4
  18. package/dist/tool-handlers/generate-image-dedicated.d.ts +32 -0
  19. package/dist/tool-handlers/generate-image-dedicated.js +176 -0
  20. package/dist/tool-handlers/generate-image-input.d.ts +3 -0
  21. package/dist/tool-handlers/generate-image-input.js +38 -0
  22. package/dist/tool-handlers/generate-image.d.ts +13 -51
  23. package/dist/tool-handlers/generate-image.js +32 -119
  24. package/dist/tool-handlers/generate-video.d.ts +2 -2
  25. package/dist/tool-handlers/generate-video.js +78 -30
  26. package/dist/tool-handlers/image-utils.d.ts +1 -0
  27. package/dist/tool-handlers/image-utils.js +26 -16
  28. package/dist/tool-handlers/openrouter-errors.js +6 -2
  29. package/dist/tool-handlers/path-safety.js +32 -5
  30. package/dist/tool-handlers/provider-routing.js +7 -2
  31. package/dist/tool-handlers/rerank.js +2 -5
  32. package/dist/tool-handlers/search-models.d.ts +2 -2
  33. package/dist/tool-handlers/search-models.js +2 -6
  34. package/dist/tool-handlers/speech-to-text.d.ts +20 -0
  35. package/dist/tool-handlers/speech-to-text.js +140 -0
  36. package/dist/tool-handlers/structured-output.d.ts +8 -0
  37. package/dist/tool-handlers/structured-output.js +11 -0
  38. package/dist/tool-handlers/text-to-speech.d.ts +29 -0
  39. package/dist/tool-handlers/text-to-speech.js +105 -0
  40. package/dist/tool-handlers/video-utils.js +6 -9
  41. package/dist/tool-handlers.js +253 -125
  42. package/dist/version.d.ts +1 -1
  43. package/dist/version.js +1 -1
  44. package/package.json +27 -15
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Readable } from 'node:stream';
3
3
  import { config } from 'dotenv';
4
- config(); // Load .env file if present
4
+ config({ quiet: true }); // Load .env file if present (quiet — stdio transport owns stdout)
5
5
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
7
  import { ToolHandlers } from './tool-handlers.js';
@@ -8,6 +8,19 @@ export interface OpenRouterModelRecord {
8
8
  context_length?: number;
9
9
  [key: string]: unknown;
10
10
  }
11
+ export interface ModelSearchParams {
12
+ query?: string;
13
+ provider?: string;
14
+ capabilities?: {
15
+ vision?: boolean;
16
+ audio?: boolean;
17
+ video?: boolean;
18
+ };
19
+ limit?: number;
20
+ /** When true, return the full filtered set and ignore `limit`. Used by pagination. */
21
+ all?: boolean;
22
+ }
23
+ export declare const MAX_SEARCH_LIMIT = 50;
11
24
  export declare class ModelCache {
12
25
  private static instance;
13
26
  private models;
@@ -40,16 +53,13 @@ export declare class ModelCache {
40
53
  size(): number;
41
54
  get(id: string): OpenRouterModelRecord | null;
42
55
  has(id: string): boolean;
43
- search(params: {
44
- query?: string;
45
- provider?: string;
46
- capabilities?: {
47
- vision?: boolean;
48
- audio?: boolean;
49
- video?: boolean;
50
- };
51
- limit?: number;
52
- /** When true, return the full filtered set and ignore `limit`. Used by pagination. */
53
- all?: boolean;
54
- }): OpenRouterModelRecord[];
56
+ /**
57
+ * Single-pass paginated search: O(n) time, O(limit) extra space for the page.
58
+ * Avoids materializing the full filtered array when only one page is needed.
59
+ */
60
+ searchPaginated(params: ModelSearchParams, offset: number, limit: number): {
61
+ page: OpenRouterModelRecord[];
62
+ total: number;
63
+ };
64
+ search(params: ModelSearchParams): OpenRouterModelRecord[];
55
65
  }
@@ -5,7 +5,33 @@ function getCacheTtlMs() {
5
5
  const n = parseInt(raw, 10);
6
6
  return Number.isFinite(n) && n > 0 ? n : 3600000;
7
7
  }
8
- const MAX_SEARCH_LIMIT = 50;
8
+ export const MAX_SEARCH_LIMIT = 50;
9
+ function buildMatcher(params) {
10
+ const q = params.query?.toLowerCase();
11
+ const providerPrefix = params.provider?.toLowerCase();
12
+ const needVision = params.capabilities?.vision === true;
13
+ const needAudio = params.capabilities?.audio === true;
14
+ const needVideo = params.capabilities?.video === true;
15
+ return (m) => {
16
+ if (q) {
17
+ const id = m.id.toLowerCase();
18
+ const name = m.name?.toLowerCase() ?? '';
19
+ if (!id.includes(q) && !name.includes(q))
20
+ return false;
21
+ }
22
+ if (providerPrefix && !m.id.toLowerCase().startsWith(`${providerPrefix}/`)) {
23
+ return false;
24
+ }
25
+ const mods = m.architecture?.input_modalities;
26
+ if (needVision && !mods?.includes('image'))
27
+ return false;
28
+ if (needAudio && !mods?.includes('audio'))
29
+ return false;
30
+ if (needVideo && !mods?.includes('video'))
31
+ return false;
32
+ return true;
33
+ };
34
+ }
9
35
  export class ModelCache {
10
36
  static instance;
11
37
  models = {};
@@ -75,28 +101,39 @@ export class ModelCache {
75
101
  has(id) {
76
102
  return id in this.models;
77
103
  }
78
- search(params) {
79
- let results = this.getAll();
80
- if (params.query) {
81
- const q = params.query.toLowerCase();
82
- results = results.filter((m) => m.id.toLowerCase().includes(q) || m.name?.toLowerCase().includes(q));
83
- }
84
- if (params.provider) {
85
- const p = params.provider.toLowerCase();
86
- results = results.filter((m) => m.id.toLowerCase().startsWith(p + '/'));
87
- }
88
- if (params.capabilities?.vision) {
89
- results = results.filter((m) => m.architecture?.input_modalities?.includes('image'));
90
- }
91
- if (params.capabilities?.audio) {
92
- results = results.filter((m) => m.architecture?.input_modalities?.includes('audio'));
93
- }
94
- if (params.capabilities?.video) {
95
- results = results.filter((m) => m.architecture?.input_modalities?.includes('video'));
104
+ /**
105
+ * Single-pass paginated search: O(n) time, O(limit) extra space for the page.
106
+ * Avoids materializing the full filtered array when only one page is needed.
107
+ */
108
+ searchPaginated(params, offset, limit) {
109
+ const matches = buildMatcher(params);
110
+ const safeOffset = Math.max(0, offset);
111
+ const safeLimit = Math.min(Math.max(1, limit), MAX_SEARCH_LIMIT);
112
+ const page = [];
113
+ let total = 0;
114
+ let matchIndex = 0;
115
+ for (const model of Object.values(this.models)) {
116
+ if (!matches(model))
117
+ continue;
118
+ if (matchIndex >= safeOffset && page.length < safeLimit) {
119
+ page.push(model);
120
+ }
121
+ matchIndex++;
96
122
  }
97
- if (params.all)
123
+ total = matchIndex;
124
+ return { page, total };
125
+ }
126
+ search(params) {
127
+ if (params.all) {
128
+ const matches = buildMatcher(params);
129
+ const results = [];
130
+ for (const model of Object.values(this.models)) {
131
+ if (matches(model))
132
+ results.push(model);
133
+ }
98
134
  return results;
135
+ }
99
136
  const limit = Math.min(Math.max(1, params.limit ?? 10), MAX_SEARCH_LIMIT);
100
- return results.slice(0, limit);
137
+ return this.searchPaginated(params, 0, limit).page;
101
138
  }
102
139
  }
@@ -31,6 +31,24 @@ export declare class OpenRouterAPIClient {
31
31
  buffer: Buffer;
32
32
  contentType: string | null;
33
33
  }>;
34
+ /**
35
+ * POST /images — dedicated image generation endpoint.
36
+ * Returns structured response with base64 image data.
37
+ */
38
+ generateImage(body: Record<string, unknown>, headers?: Record<string, string>): Promise<ImageGenerationResponse>;
39
+ /**
40
+ * POST /audio/speech — dedicated text-to-speech endpoint.
41
+ * Returns raw audio bytes.
42
+ */
43
+ generateSpeech(body: Record<string, unknown>, headers?: Record<string, string>): Promise<{
44
+ buffer: Buffer;
45
+ contentType: string;
46
+ }>;
47
+ /**
48
+ * POST /audio/transcriptions — dedicated speech-to-text endpoint.
49
+ * Accepts base64-encoded audio and returns transcription text.
50
+ */
51
+ transcribeAudio(body: Record<string, unknown>, headers?: Record<string, string>): Promise<TranscriptionResponse>;
34
52
  /** POST /rerank — re-order documents by relevance to a query. */
35
53
  rerank(params: {
36
54
  model: string;
@@ -45,6 +63,33 @@ export interface VideoJobEnvelope {
45
63
  polling_url?: string;
46
64
  [key: string]: unknown;
47
65
  }
66
+ export interface ImageGenerationResponse {
67
+ data?: Array<{
68
+ b64_json?: string;
69
+ url?: string;
70
+ revised_prompt?: string;
71
+ }>;
72
+ usage?: {
73
+ cost?: number;
74
+ [key: string]: unknown;
75
+ };
76
+ [key: string]: unknown;
77
+ }
78
+ export interface TranscriptionResponse {
79
+ text?: string;
80
+ segments?: Array<{
81
+ start: number;
82
+ end: number;
83
+ text: string;
84
+ }>;
85
+ language?: string;
86
+ duration?: number;
87
+ usage?: {
88
+ cost?: number;
89
+ [key: string]: unknown;
90
+ };
91
+ [key: string]: unknown;
92
+ }
48
93
  export interface RerankResultItem {
49
94
  index: number;
50
95
  relevance_score?: number;
@@ -151,6 +151,56 @@ export class OpenRouterAPIClient {
151
151
  }
152
152
  return { buffer: Buffer.concat(chunks), contentType: res.headers.get('content-type') };
153
153
  }
154
+ /**
155
+ * POST /images — dedicated image generation endpoint.
156
+ * Returns structured response with base64 image data.
157
+ */
158
+ async generateImage(body, headers) {
159
+ const res = await fetchWithRetry(`${BASE_URL}/images`, {
160
+ method: 'POST',
161
+ headers: this.authHeaders({ 'Content-Type': 'application/json', ...headers }),
162
+ body: JSON.stringify(body),
163
+ }, { retries: 2, timeoutMs: 120_000 });
164
+ if (!res.ok) {
165
+ const detail = await safeReadText(res);
166
+ throw new Error(`POST /images failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
167
+ }
168
+ return (await res.json());
169
+ }
170
+ /**
171
+ * POST /audio/speech — dedicated text-to-speech endpoint.
172
+ * Returns raw audio bytes.
173
+ */
174
+ async generateSpeech(body, headers) {
175
+ const res = await fetchWithRetry(`${BASE_URL}/audio/speech`, {
176
+ method: 'POST',
177
+ headers: this.authHeaders({ 'Content-Type': 'application/json', ...headers }),
178
+ body: JSON.stringify(body),
179
+ }, { retries: 2, timeoutMs: 60_000 });
180
+ if (!res.ok) {
181
+ const detail = await safeReadText(res);
182
+ throw new Error(`POST /audio/speech failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
183
+ }
184
+ const contentType = res.headers.get('content-type') || 'audio/mpeg';
185
+ const buf = Buffer.from(await res.arrayBuffer());
186
+ return { buffer: buf, contentType };
187
+ }
188
+ /**
189
+ * POST /audio/transcriptions — dedicated speech-to-text endpoint.
190
+ * Accepts base64-encoded audio and returns transcription text.
191
+ */
192
+ async transcribeAudio(body, headers) {
193
+ const res = await fetchWithRetry(`${BASE_URL}/audio/transcriptions`, {
194
+ method: 'POST',
195
+ headers: this.authHeaders({ 'Content-Type': 'application/json', ...headers }),
196
+ body: JSON.stringify(body),
197
+ }, { retries: 2, timeoutMs: 60_000 });
198
+ if (!res.ok) {
199
+ const detail = await safeReadText(res);
200
+ throw new Error(`POST /audio/transcriptions failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
201
+ }
202
+ return (await res.json());
203
+ }
154
204
  /** POST /rerank — re-order documents by relevance to a query. */
155
205
  async rerank(params) {
156
206
  const body = {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * MCP tool descriptions with explicit routing, examples, and failure modes.
3
+ * See docs/plans/tool-description-improvement.md for the authoring guide.
4
+ */
5
+ export interface ToolDescriptionParts {
6
+ summary: string;
7
+ useWhen: string[];
8
+ notWhen: string[];
9
+ goodExamples: string[];
10
+ badExamples: string[];
11
+ failsWhen: string[];
12
+ worksWith: string[];
13
+ }
14
+ export declare function buildToolDescription(parts: ToolDescriptionParts): string;
15
+ /** Required sections every tool description must contain (regression-tested). */
16
+ export declare const REQUIRED_DESCRIPTION_SECTIONS: readonly ["Use when:", "Do NOT use when:", "Good examples:", "Bad examples:", "Fails when:", "Works with:"];
17
+ export declare const TOOL_NAMES: readonly ["chat_completion", "start_chat_completion", "get_chat_completion_status", "analyze_image", "analyze_audio", "analyze_video", "search_models", "get_model_info", "validate_model", "generate_image", "generate_image_dedicated", "generate_audio", "text_to_speech", "speech_to_text", "generate_video", "generate_video_from_image", "get_video_status", "rerank_documents", "health_check"];
18
+ export type ToolName = (typeof TOOL_NAMES)[number];
19
+ export declare const TOOL_DESCRIPTIONS: Record<ToolName, string>;