@stabgan/openrouter-mcp-multimodal 4.0.1 → 4.5.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.
Files changed (49) hide show
  1. package/README.md +95 -8
  2. package/dist/errors.d.ts +26 -8
  3. package/dist/errors.js +17 -12
  4. package/dist/index.js +21 -5
  5. package/dist/logger.d.ts +11 -0
  6. package/dist/logger.js +26 -0
  7. package/dist/model-cache.d.ts +17 -0
  8. package/dist/model-cache.js +27 -1
  9. package/dist/openrouter-api.d.ts +22 -2
  10. package/dist/openrouter-api.js +21 -2
  11. package/dist/tool-handlers/analyze-audio.d.ts +9 -4
  12. package/dist/tool-handlers/analyze-audio.js +29 -15
  13. package/dist/tool-handlers/analyze-image.d.ts +10 -4
  14. package/dist/tool-handlers/analyze-image.js +37 -9
  15. package/dist/tool-handlers/analyze-video.d.ts +9 -4
  16. package/dist/tool-handlers/analyze-video.js +31 -19
  17. package/dist/tool-handlers/cache.d.ts +33 -0
  18. package/dist/tool-handlers/cache.js +54 -0
  19. package/dist/tool-handlers/chat-completion.d.ts +18 -4
  20. package/dist/tool-handlers/chat-completion.js +39 -11
  21. package/dist/tool-handlers/completion-utils.d.ts +29 -0
  22. package/dist/tool-handlers/completion-utils.js +76 -17
  23. package/dist/tool-handlers/generate-audio.d.ts +2 -0
  24. package/dist/tool-handlers/generate-audio.js +18 -1
  25. package/dist/tool-handlers/generate-image.d.ts +2 -0
  26. package/dist/tool-handlers/generate-image.js +15 -0
  27. package/dist/tool-handlers/generate-video.d.ts +43 -0
  28. package/dist/tool-handlers/generate-video.js +46 -3
  29. package/dist/tool-handlers/get-model-info.d.ts +1 -6
  30. package/dist/tool-handlers/get-model-info.js +2 -1
  31. package/dist/tool-handlers/health-check.d.ts +23 -0
  32. package/dist/tool-handlers/health-check.js +35 -0
  33. package/dist/tool-handlers/openai-withresponse.d.ts +16 -0
  34. package/dist/tool-handlers/openai-withresponse.js +16 -0
  35. package/dist/tool-handlers/openrouter-errors.d.ts +5 -1
  36. package/dist/tool-handlers/openrouter-errors.js +72 -11
  37. package/dist/tool-handlers/rerank.d.ts +17 -0
  38. package/dist/tool-handlers/rerank.js +52 -0
  39. package/dist/tool-handlers/search-models.d.ts +18 -7
  40. package/dist/tool-handlers/search-models.js +25 -2
  41. package/dist/tool-handlers/structured-output.d.ts +13 -0
  42. package/dist/tool-handlers/structured-output.js +24 -0
  43. package/dist/tool-handlers/validate-model.d.ts +4 -6
  44. package/dist/tool-handlers/validate-model.js +3 -8
  45. package/dist/tool-handlers.d.ts +1 -0
  46. package/dist/tool-handlers.js +435 -165
  47. package/dist/version.d.ts +16 -0
  48. package/dist/version.js +16 -0
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -92,6 +92,12 @@ Install-link audit (2026-04-20, round 4 — HTTPS redirectors only):
92
92
  | Image generation | ✅ Path-sandboxed disk output |
93
93
  | **Video understanding** | ✅ **v3** — mp4, mpeg, mov, webm from files, URLs, or data URLs |
94
94
  | **Video generation** | ✅ **v3** — Veo 3.1 / Sora 2 Pro / Seedance / Wan via async API with progress notifications |
95
+ | **Response caching** | ✅ **v4.5** — `X-OpenRouter-Cache` passthrough, zero tokens billed on hit, 80–300ms latency |
96
+ | **Web search plugin** | ✅ **v4.5** — `online: true` on `chat_completion` injects OpenRouter's Exa-backed plugin |
97
+ | **Rerank** | ✅ **v4.5** — `rerank_documents` tool against `/rerank` (Cohere, Fireworks) |
98
+ | **Health check** | ✅ **v4.5** — `health_check` verifies API key + OpenRouter reachability |
99
+ | **Reasoning tokens** | ✅ **v4.5** — passthrough of DeepSeek R1 / Gemini Thinking / Opus 4.7 traces on `_meta.reasoning` |
100
+ | **MCP 2025-06-18 spec** | ✅ **v4.5** — structured outputs (`outputSchema`), progress notifications, `title` + `openWorldHint` |
95
101
  | Auto image resize + compress | ✅ Configurable (defaults 800px max, JPEG 80%) |
96
102
  | Model search + validation | ✅ Filter by vision / audio / video modality |
97
103
  | Free model support | ✅ Default: free Nemotron VL |
@@ -106,17 +112,20 @@ Install-link audit (2026-04-20, round 4 — HTTPS redirectors only):
106
112
 
107
113
  | Tool | Description |
108
114
  | :--- | :--- |
109
- | `chat_completion` | Send messages to any OpenRouter model. Detects reasoning-model cutoffs. Supports **provider routing** (`quantizations`, `ignore`, `sort`, `order`, `require_parameters`, `data_collection`, `allow_fallbacks`) and **model suffixes** (`:nitro` for fastest, `:floor` for cheapest). |
110
- | `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. |
111
- | `analyze_audio` | Analyze/transcribe audio (WAV, MP3, FLAC, OGG, etc.) from files, URLs, or data URIs. |
112
- | `analyze_video` | Analyze/transcribe video (mp4, mpeg, mov, webm) from files, URLs, or data URIs. |
115
+ | `chat_completion` | Send messages to any OpenRouter model. Detects reasoning-model cutoffs. Supports **provider routing** (`quantizations`, `ignore`, `sort`, `order`, `require_parameters`, `data_collection`, `allow_fallbacks`), **model suffixes** (`:nitro` for fastest, `:floor` for cheapest, `:exacto` for Auto Exacto tool-calling), **response caching** (`cache`, `cache_ttl`, `cache_clear`), **reasoning passthrough** (`include_reasoning`), and **web search** (`online`, `web_max_results`). |
116
+ | `analyze_image` | Analyze images from local files, URLs, or data URIs. Auto-optimized with sharp. Optional `cache_input: true` attaches `cache_control: ephemeral` for Anthropic / Gemini 2.5+ prompt caching. |
117
+ | `analyze_audio` | Analyze/transcribe audio (WAV, MP3, FLAC, OGG, etc.) from files, URLs, or data URIs. Optional `cache_input: true` for prompt caching. |
118
+ | `analyze_video` | Analyze/transcribe video (mp4, mpeg, mov, webm) from files, URLs, or data URIs. Optional `cache_input: true` for prompt caching. |
113
119
  | `generate_image` | Generate images from text prompts. Supports `aspect_ratio` (14 values), `image_size` (0.5K–4K), and `max_tokens`. Optional path-sandboxed disk save. |
114
120
  | `generate_audio` | Generate audio from text. Auto-detects format, wraps raw PCM in WAV. |
115
- | `generate_video` | Generate video via OpenRouter's async API (Veo 3.1 / Sora 2 Pro / Seedance / Wan). Submits, polls, downloads, saves. |
121
+ | `generate_video` | Generate video via OpenRouter's async API (Veo 3.1 / Sora 2 Pro / Seedance / Wan). Submits, polls, downloads, saves. Emits MCP `notifications/progress` when the client sends a `progressToken`. |
122
+ | `generate_video_from_image` | Image-to-video wrapper around `generate_video`. Narrower schema, higher tool-call hit rate. |
116
123
  | `get_video_status` | Resume polling a `generate_video` job by id. Download + save when complete. |
117
- | `search_models` | Search/filter models by name, provider, or capabilities (vision / audio / video). |
124
+ | `rerank_documents` | Rerank candidate documents against a query via OpenRouter's `/rerank` endpoint. Supports Cohere and Fireworks rerankers. |
125
+ | `search_models` | Search/filter models by name, provider, or capabilities (vision / audio / video). Paginated via `offset` / `next_offset` / `has_more` / `total`. |
118
126
  | `get_model_info` | Get pricing, context length, and capabilities for any model. |
119
127
  | `validate_model` | Check if a model ID exists on OpenRouter. |
128
+ | `health_check` | Verify API-key validity, OpenRouter reachability, and return server + protocol versions. |
120
129
 
121
130
  > All error responses carry `_meta.code` from a closed taxonomy: `INVALID_INPUT` · `UNSAFE_PATH` · `UPSTREAM_HTTP` · `UPSTREAM_TIMEOUT` · `UPSTREAM_REFUSED` · `UNSUPPORTED_FORMAT` · `RESOURCE_TOO_LARGE` · `ZDR_INCOMPATIBLE` · `MODEL_NOT_FOUND` · `JOB_FAILED` · `JOB_STILL_RUNNING` · `INTERNAL`
122
131
 
@@ -200,6 +209,8 @@ npx -y @smithery/cli install @stabgan/openrouter-mcp-multimodal --client claude
200
209
  | `OPENROUTER_PROVIDER_REQUIRE_PARAMETERS` | No | — | `true` / `false`. Only use providers supporting every request parameter. |
201
210
  | `OPENROUTER_PROVIDER_DATA_COLLECTION` | No | — | `allow` / `deny`. Opt out of providers that log request data. |
202
211
  | `OPENROUTER_PROVIDER_ALLOW_FALLBACKS` | No | — | `true` / `false`. |
212
+ | `OPENROUTER_CACHE_RESPONSES` | No | — | `1` / `true`. Enable response caching server-wide. Sends `X-OpenRouter-Cache: true` on chat + analyze_* calls unless overridden per-request with `cache: false`. Zero tokens billed on hits. |
213
+ | `OPENROUTER_INCLUDE_REASONING` | No | — | `1` / `true`. Enable reasoning tokens passthrough server-wide for DeepSeek R1 / Gemini Thinking / Opus 4.7. Adds `_meta.reasoning` to `chat_completion` responses. |
203
214
  | `OPENROUTER_MODEL_CACHE_TTL_MS` | No | `3600000` | Model cache TTL (ms) |
204
215
  | `OPENROUTER_IMAGE_MAX_DIMENSION` | No | `800` | Longest edge for resize (px) |
205
216
  | `OPENROUTER_IMAGE_JPEG_QUALITY` | No | `80` | JPEG quality (1–100) |
@@ -249,6 +260,28 @@ Use chat_completion with model "openai/gpt-4o:nitro", prompt "Reason step-by-ste
249
260
  # Chat with :floor variant for cheapest provider of the requested model
250
261
  Use chat_completion with model "mistralai/mistral-7b-instruct:floor", prompt "Quick check"
251
262
 
263
+ # Chat with response caching + reasoning passthrough (v4.5)
264
+ Use chat_completion with model "deepseek/deepseek-r1", prompt "Prove sqrt(2) is irrational",
265
+ cache: true, cache_ttl: 3600, include_reasoning: true
266
+ # → response.meta.cache = { status: "hit" | "miss", age, ttl }
267
+ # → response.meta.reasoning = "<upstream reasoning trace>"
268
+
269
+ # Chat with web search plugin (v4.5)
270
+ Use chat_completion with model "openai/gpt-4o", prompt "What shipped in OpenRouter last week?",
271
+ online: true, web_max_results: 5
272
+
273
+ # Rerank documents against a query (v4.5)
274
+ Use rerank_documents with query "best practices for MCP server auth",
275
+ documents: ["doc A text...", "doc B text...", "doc C text..."], top_n: 3
276
+
277
+ # Generate video from an image (v4.5)
278
+ Use generate_video_from_image with image "./frame.png", prompt "zoom out slowly",
279
+ model "google/veo-3.1", save to ./clip.mp4
280
+
281
+ # Health check (v4.5)
282
+ Use health_check
283
+ # → { ok: true, server_version: "4.5.0", protocol_version: "2025-06-18", api_key_valid: true, models_cached: 312 }
284
+
252
285
  # Vision
253
286
  Use analyze_image on /path/to/photo.jpg and tell me what you see.
254
287
 
@@ -280,7 +313,7 @@ Use get_video_status with video_id "vid_abc123" and save_path "./river.mp4"
280
313
  ```
281
314
  src/
282
315
  ├── index.ts # Entry, env validation, graceful shutdown
283
- ├── tool-handlers.ts # 11 tools (annotated) + dispatch
316
+ ├── tool-handlers.ts # 14 tools (annotated) + dispatch
284
317
  ├── model-cache.ts # TTL + in-flight coalescing
285
318
  ├── openrouter-api.ts # REST client (chat + /videos)
286
319
  ├── errors.ts # Closed ErrorCode enum
@@ -305,6 +338,60 @@ src/
305
338
  └── validate-model.ts # Model existence check
306
339
  ```
307
340
 
341
+ ## Design Principles & Research
342
+
343
+ v4.5.0's design draws from three threads of research and industry guidance. Rather than building in isolation, every feature ties to a cited source so decisions can be re-examined later.
344
+
345
+ ### MCP-first design principles
346
+
347
+ We follow [Phil Schmid's production guide for MCP servers](https://www.philschmid.de/mcp-best-practices) (Jan 2026), which argues that an MCP server is "a user interface for AI agents, not a REST API wrapper":
348
+
349
+ - **Outcomes, not operations.** Our tools like `analyze_image` and `generate_video` encapsulate a whole workflow (fetch, validate, invoke, save) rather than exposing raw OpenRouter primitives.
350
+ - **Flattened arguments.** Top-level primitives with enums (`aspect_ratio`, `image_size`), no deeply nested configuration blobs. The one nested object (`provider`) is required by OpenRouter's routing schema.
351
+ - **Descriptions are context.** Every tool description includes "Fails when:" and "Works with:" sections (see next section for the research backing).
352
+ - **Curated surface.** 14 tools total. Each is a distinct outcome; no "helper" tools that exist only for internal composition.
353
+
354
+ Apigene's ["12 Rules for Production MCP Deployment"](https://apigene.ai/blog/mcp-best-practices) (March 2026) guided the error-handling posture: structured errors with `suggestions` and `retry_after_seconds` on `_meta` beat raw error strings the agent has to interpret.
355
+
356
+ ### MCP 2025-06-18 spec compliance
357
+
358
+ - **Structured outputs.** `validate_model`, `get_model_info`, `search_models`, `rerank_documents`, and `health_check` emit [`structuredContent` with `outputSchema`](https://modelcontextprotocol.io/specification/2025-06-18/server/tools#output-schema), per §5.2.6-7. Agents can validate responses typefully.
359
+ - **Progress notifications.** `generate_video` emits [`notifications/progress`](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress) on every poll tick when the client passes a `progressToken` in `_meta`. Progress values are guaranteed strictly monotonic per spec.
360
+ - **Tool annotations.** Every tool carries `title` + `readOnlyHint` + `destructiveHint` + `idempotentHint` + `openWorldHint` so clients can render appropriate UI affordances.
361
+
362
+ ### Research-backed tool-design decisions
363
+
364
+ These papers shaped specific v4.5.0 choices:
365
+
366
+ | Finding | Source | How it shaped v4.5.0 |
367
+ | :--- | :--- | :--- |
368
+ | Failure-mode docs and inter-tool relationships measurably improve tool-selection accuracy | [Schlapbach, *Convergence of SGD & MCP*](https://arxiv.org/abs/2602.18764) (Feb 2026) | Every tool description has explicit "Fails when:" (ErrorCode triggers) and "Works with:" (related tools). |
369
+ | Tool-call success drops with parameter count and schema complexity | [Fu et al., *ROSBag MCP Server*](https://arxiv.org/abs/2511.03497) (Nov 2025) | `generate_video_from_image` is a narrower image-to-video wrapper around `generate_video` — fewer params, higher hit rate. |
370
+ | Indirect prompt injection via tool-returned content is a real attack vector | [Zhao et al., *ClawGuard*](https://arxiv.org/abs/2604.11790) (Apr 2026) · [Yu et al., *Defense via Tool Result Parsing*](https://arxiv.org/abs/2601.04795) (Jan 2026) | `analyze_image` / `analyze_audio` / `analyze_video` tag their output `_meta.content_is_untrusted: true`. Downstream agents know to treat that text as data, not instructions. |
371
+ | Provider-level tool-calling variance is large and persists across providers for the same model | [OpenRouter Auto Exacto announcement](https://openrouter.ai/announcements/auto-exacto) (Mar 2026) | `chat_completion` documents the `:exacto` model suffix alongside `:nitro` / `:floor`. 80-88% error reduction on top tool-calling models. |
372
+ | LLM JSON defects compound at scale | [OpenRouter Response Healing](https://openrouter.ai/announcements/response-healing-reduce-json-defects-by-80percent) (Dec 2025) | Structured outputs + outputSchema declarations give clients a parseable contract. (Response-healing plugin itself is opt-in on OpenRouter's side.) |
373
+ | MCP servers are vulnerable to preference-manipulation and tool-poisoning attacks | [Wang et al., *MPMA*](https://arxiv.org/abs/2505.11154) (May 2025) · [Turgut & Gümüş, *CASCADE*](https://arxiv.org/abs/2604.17125) (Apr 2026) | Tool descriptions audited for injection surface; audit logging (`logger.audit()`) captures every paid-op invocation with a prompt preview for forensics. |
374
+
375
+ ### OpenRouter platform parity
376
+
377
+ v4.5.0 surfaces platform features shipped between Q4 2025 and Q2 2026:
378
+
379
+ - [Response caching via `X-OpenRouter-Cache`](https://openrouter.ai/announcements/response-caching) (Apr 2026): zero tokens billed on identical request cache hits, 80-300ms latency.
380
+ - [Web search plugin](https://openrouter.ai/announcements/introducing-web-search-via-the-api) (Jan 2025): Exa-backed, enabled via `online: true`.
381
+ - [Reasoning tokens](https://openrouter.ai/announcements/reasoning-tokens-for-thinking-models) (Jan 2025): DeepSeek R1 / Gemini Thinking / Opus 4.7 chain-of-thought via `include_reasoning: true`.
382
+ - [Auto Exacto](https://openrouter.ai/announcements/auto-exacto) (Mar 2026): on-by-default for tool-calling; `:exacto` suffix for all other requests.
383
+ - [Rerank endpoint](https://openrouter.ai/announcements/april-release-spotlight) (Apr 2026): Cohere + Fireworks via the new `rerank_documents` tool.
384
+ - [Prompt caching with `cache_control`](https://openrouter.ai/docs/guides/best-practices/prompt-caching): Anthropic Claude 10x / Gemini 2.5+ 4x savings on repeated input media via `cache_input: true` on analyze_* tools.
385
+ - [Zero completion token insurance](https://openrouter.ai/announcements/never-pay-for-empty-ai-responses-again) (Mar 2025): automatic, no opt-in needed.
386
+
387
+ ### Security posture
388
+
389
+ - **Path sandbox.** All file writes (`save_path`) and reads (`input_images`, frame images) go through `resolveSafeOutputPath` / `resolveSafeInputPath`, which reject traversal escapes. Legacy bypass: `OPENROUTER_ALLOW_UNSAFE_PATHS=1`.
390
+ - **SSRF blocklist.** Loopback, private, link-local, multicast, 6to4, Teredo, ORCHID, and IPv4-mapped IPv6 all rejected at the fetch layer.
391
+ - **Audit logging.** `logger.audit()` emits a JSON line at level=audit for every `generate_video`, `generate_audio`, and `generate_image` call. Bypasses `OPENROUTER_LOG_LEVEL` so unintended spend is always traceable. 80-char prompt preview is the hard PII boundary.
392
+ - **Structured errors.** Closed `_meta.code` taxonomy means agents switch on failure modes without regex-parsing free text. Rate-limit errors include `retry_after_seconds` derived from `Retry-After` headers.
393
+ - **No credential leakage.** `OPENROUTER_API_KEY` is read once at startup, passed to the SDK, and never echoed in logs, tool responses, or error messages. Fatal-error logging whitelists fields explicitly (name / message / trimmed stack) — no raw error objects. Verified by an independent bug-hunter audit (Apr 2026).
394
+
308
395
  ## Development
309
396
 
310
397
  ```bash
@@ -339,7 +426,7 @@ Works with any MCP client: [Kiro](https://kiro.dev) · [Claude Desktop](https://
339
426
 
340
427
  ## License
341
428
 
342
- Apache 2.0. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). v1.0.0 through v3.2.0 were released under MIT; v4.0.0 relicensed to Apache 2.0 (Apache 2.0 is a permissive superset of MIT with explicit patent grant).
429
+ Apache 2.0. See [LICENSE](./LICENSE). v1.0.0 through v3.2.0 were released under MIT; v4.0.0 relicensed to Apache 2.0 (Apache 2.0 is a permissive superset of MIT with explicit patent grant).
343
430
 
344
431
  ## Contributing
345
432
 
package/dist/errors.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Closed error-code taxonomy for MCP tool responses. Every handler uses
3
- * `toolError(code, message, details?)` instead of ad-hoc text so clients
4
- * can switch on `_meta.code` without regex-parsing free text.
3
+ * `toolError(code, message, details?, opts?)` instead of ad-hoc text so
4
+ * clients can switch on `_meta.code` without regex-parsing free text.
5
5
  *
6
6
  * Adding a new code requires a design.md note — this set is intentionally
7
7
  * small and stable.
@@ -21,22 +21,40 @@ export declare const ErrorCode: {
21
21
  readonly INTERNAL: "INTERNAL";
22
22
  };
23
23
  export type ErrorCode = (typeof ErrorCode)[keyof typeof ErrorCode];
24
+ export interface ToolErrorMeta {
25
+ code: ErrorCode;
26
+ details?: Record<string, unknown>;
27
+ /**
28
+ * Optional human-readable next steps the agent can take. Inspired by
29
+ * the Apigene "structured errors" best practice: rather than a raw
30
+ * string for the agent to interpret, list concrete options like
31
+ * "Wait and retry" or "Lower resolution to 480p".
32
+ */
33
+ suggestions?: string[];
34
+ /**
35
+ * For rate-limit / backoff errors, the number of seconds the caller
36
+ * should wait before retrying. Derived from `Retry-After` headers when
37
+ * available.
38
+ */
39
+ retry_after_seconds?: number;
40
+ }
24
41
  export interface ToolErrorResult {
25
42
  content: Array<{
26
43
  type: 'text';
27
44
  text: string;
28
45
  }>;
29
46
  isError: true;
30
- _meta: {
31
- code: ErrorCode;
32
- details?: Record<string, unknown>;
33
- };
47
+ _meta: ToolErrorMeta;
48
+ }
49
+ export interface ToolErrorOptions {
50
+ suggestions?: string[];
51
+ retry_after_seconds?: number;
34
52
  }
35
53
  /** Build a structured MCP error result. */
36
- export declare function toolError(code: ErrorCode, message: string, details?: Record<string, unknown>): ToolErrorResult;
54
+ export declare function toolError(code: ErrorCode, message: string, details?: Record<string, unknown>, opts?: ToolErrorOptions): ToolErrorResult;
37
55
  /**
38
56
  * Convert a caught `unknown` error into a structured tool result. Preserves
39
57
  * user-visible messages for known `Error` types and refuses to leak stack
40
58
  * traces or raw objects.
41
59
  */
42
- export declare function toolErrorFrom(code: ErrorCode, err: unknown, prefix?: string): ToolErrorResult;
60
+ export declare function toolErrorFrom(code: ErrorCode, err: unknown, prefix?: string, opts?: ToolErrorOptions): ToolErrorResult;
package/dist/errors.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Closed error-code taxonomy for MCP tool responses. Every handler uses
3
- * `toolError(code, message, details?)` instead of ad-hoc text so clients
4
- * can switch on `_meta.code` without regex-parsing free text.
3
+ * `toolError(code, message, details?, opts?)` instead of ad-hoc text so
4
+ * clients can switch on `_meta.code` without regex-parsing free text.
5
5
  *
6
6
  * Adding a new code requires a design.md note — this set is intentionally
7
7
  * small and stable.
@@ -21,26 +21,31 @@ export const ErrorCode = {
21
21
  INTERNAL: 'INTERNAL',
22
22
  };
23
23
  /** Build a structured MCP error result. */
24
- export function toolError(code, message, details) {
25
- const result = {
24
+ export function toolError(code, message, details, opts) {
25
+ const meta = { code };
26
+ if (details !== undefined)
27
+ meta.details = details;
28
+ if (opts?.suggestions && opts.suggestions.length > 0)
29
+ meta.suggestions = opts.suggestions;
30
+ if (typeof opts?.retry_after_seconds === 'number') {
31
+ meta.retry_after_seconds = opts.retry_after_seconds;
32
+ }
33
+ return {
26
34
  content: [{ type: 'text', text: message }],
27
35
  isError: true,
28
- _meta: { code },
36
+ _meta: meta,
29
37
  };
30
- if (details !== undefined)
31
- result._meta.details = details;
32
- return result;
33
38
  }
34
39
  /**
35
40
  * Convert a caught `unknown` error into a structured tool result. Preserves
36
41
  * user-visible messages for known `Error` types and refuses to leak stack
37
42
  * traces or raw objects.
38
43
  */
39
- export function toolErrorFrom(code, err, prefix) {
44
+ export function toolErrorFrom(code, err, prefix, opts) {
40
45
  const base = prefix ? `${prefix}: ` : '';
41
46
  if (err instanceof Error)
42
- return toolError(code, base + err.message);
47
+ return toolError(code, base + err.message, undefined, opts);
43
48
  if (typeof err === 'string')
44
- return toolError(code, base + err);
45
- return toolError(code, base + 'unknown error');
49
+ return toolError(code, base + err, undefined, opts);
50
+ return toolError(code, base + 'unknown error', undefined, opts);
46
51
  }
package/dist/index.js CHANGED
@@ -5,14 +5,30 @@ config(); // Load .env file if present
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
+ import { logger } from './logger.js';
9
+ import { SERVER_VERSION } from './version.js';
8
10
  const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
9
- // Exit on fatal errors to prevent silent zombie processes (issue #5)
11
+ // Exit on fatal errors to prevent silent zombie processes (issue #5).
12
+ // We log an explicit whitelist of fields rather than the raw error object
13
+ // to avoid ever echoing sensitive SDK internals (request bodies, auth
14
+ // headers) in a future version. Defense-in-depth against a changed
15
+ // APIError.toString() in openai-node.
16
+ function logFatal(kind, err) {
17
+ const e = err;
18
+ logger.error('fatal', {
19
+ kind,
20
+ name: e?.name ?? 'unknown',
21
+ msg: e?.message ?? String(err),
22
+ // Stack traces are developer-only — trim to avoid unbounded log lines.
23
+ stack: e?.stack?.split('\n').slice(0, 10).join('\n'),
24
+ });
25
+ }
10
26
  process.on('uncaughtException', (err) => {
11
- console.error('[Fatal]', err);
27
+ logFatal('uncaughtException', err);
12
28
  process.exit(1);
13
29
  });
14
30
  process.on('unhandledRejection', (err) => {
15
- console.error('[Fatal]', err);
31
+ logFatal('unhandledRejection', err);
16
32
  process.exit(1);
17
33
  });
18
34
  const apiKey = process.env.OPENROUTER_API_KEY;
@@ -21,8 +37,8 @@ if (!apiKey) {
21
37
  process.exit(1);
22
38
  }
23
39
  const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
24
- const server = new Server({ name: 'openrouter-multimodal-server', version: '4.0.1' }, { capabilities: { tools: {} } });
25
- server.onerror = (error) => console.error('[MCP Error]', error);
40
+ const server = new Server({ name: 'openrouter-multimodal-server', version: SERVER_VERSION }, { capabilities: { tools: {} } });
41
+ server.onerror = (error) => logFatal('mcpError', error);
26
42
  new ToolHandlers(server, apiKey, defaultModel);
27
43
  process.on('SIGINT', async () => {
28
44
  await server.close();
package/dist/logger.d.ts CHANGED
@@ -7,6 +7,10 @@
7
7
  *
8
8
  * Level is filtered by OPENROUTER_LOG_LEVEL (error|warn|info|debug,
9
9
  * default info). Unknown values fall through to info.
10
+ *
11
+ * `audit` is a special level that ALWAYS writes (bypasses the level filter),
12
+ * intended for cost-incurring / destructive operations so operators can
13
+ * trace them after the fact.
10
14
  */
11
15
  export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
12
16
  /** Low-level write hook, replaceable in tests. */
@@ -19,4 +23,11 @@ export declare const logger: {
19
23
  warn: (msg: string, ctx?: Record<string, unknown>) => void;
20
24
  info: (msg: string, ctx?: Record<string, unknown>) => void;
21
25
  debug: (msg: string, ctx?: Record<string, unknown>) => void;
26
+ /**
27
+ * Always-on audit line. Bypasses OPENROUTER_LOG_LEVEL. Use for paid or
28
+ * destructive operations (generate_video, generate_audio, generate_image)
29
+ * so operators can trace unintended spend via `docker logs` or a log
30
+ * aggregator.
31
+ */
32
+ audit(msg: string, ctx?: Record<string, unknown>): void;
22
33
  };
package/dist/logger.js CHANGED
@@ -44,4 +44,30 @@ export const logger = {
44
44
  warn: (msg, ctx) => log('warn', msg, ctx),
45
45
  info: (msg, ctx) => log('info', msg, ctx),
46
46
  debug: (msg, ctx) => log('debug', msg, ctx),
47
+ /**
48
+ * Always-on audit line. Bypasses OPENROUTER_LOG_LEVEL. Use for paid or
49
+ * destructive operations (generate_video, generate_audio, generate_image)
50
+ * so operators can trace unintended spend via `docker logs` or a log
51
+ * aggregator.
52
+ */
53
+ audit(msg, ctx) {
54
+ const record = {
55
+ ts: new Date().toISOString(),
56
+ level: 'audit',
57
+ msg,
58
+ };
59
+ if (ctx)
60
+ record.ctx = ctx;
61
+ try {
62
+ _sink.write(JSON.stringify(record));
63
+ }
64
+ catch {
65
+ _sink.write(JSON.stringify({
66
+ ts: new Date().toISOString(),
67
+ level: 'audit',
68
+ msg,
69
+ ctx: { note: 'unserializable' },
70
+ }));
71
+ }
72
+ },
47
73
  };
@@ -12,10 +12,23 @@ export declare class ModelCache {
12
12
  private static instance;
13
13
  private models;
14
14
  private fetchedAt;
15
+ /**
16
+ * Separate from `fetchedAt`: set whenever we successfully CALL the
17
+ * fetcher (even if the response happens to be empty). Used by
18
+ * `isValid()` so a successful-but-empty fetch still counts as "fresh"
19
+ * and we don't hot-loop re-fetching the upstream.
20
+ */
21
+ private populatedAt;
15
22
  private inflight;
16
23
  static getInstance(): ModelCache;
17
24
  isValid(): boolean;
18
25
  setModels(models: OpenRouterModelRecord[]): void;
26
+ /**
27
+ * Force the cache back into an uninitialized state. Used by tests that
28
+ * need to assert `ensureFresh()` actually calls the fetcher. Also useful
29
+ * for ops (`health_check --reset`) if we ever expose such a knob.
30
+ */
31
+ reset(): void;
19
32
  /**
20
33
  * Populate the cache using `fetcher` if stale, coalescing concurrent callers
21
34
  * so only one request hits the upstream API per stale window. Callers that
@@ -23,6 +36,8 @@ export declare class ModelCache {
23
36
  */
24
37
  ensureFresh(fetcher: () => Promise<OpenRouterModelRecord[]>): Promise<void>;
25
38
  getAll(): OpenRouterModelRecord[];
39
+ /** Number of models currently cached. Used by health_check. */
40
+ size(): number;
26
41
  get(id: string): OpenRouterModelRecord | null;
27
42
  has(id: string): boolean;
28
43
  search(params: {
@@ -34,5 +49,7 @@ export declare class ModelCache {
34
49
  video?: boolean;
35
50
  };
36
51
  limit?: number;
52
+ /** When true, return the full filtered set and ignore `limit`. Used by pagination. */
53
+ all?: boolean;
37
54
  }): OpenRouterModelRecord[];
38
55
  }
@@ -10,16 +10,36 @@ export class ModelCache {
10
10
  static instance;
11
11
  models = {};
12
12
  fetchedAt = 0;
13
+ /**
14
+ * Separate from `fetchedAt`: set whenever we successfully CALL the
15
+ * fetcher (even if the response happens to be empty). Used by
16
+ * `isValid()` so a successful-but-empty fetch still counts as "fresh"
17
+ * and we don't hot-loop re-fetching the upstream.
18
+ */
19
+ populatedAt = 0;
13
20
  inflight = null;
14
21
  static getInstance() {
15
22
  return (ModelCache.instance ??= new ModelCache());
16
23
  }
17
24
  isValid() {
18
- return Object.keys(this.models).length > 0 && Date.now() - this.fetchedAt < getCacheTtlMs();
25
+ const fresh = Date.now() - this.populatedAt < getCacheTtlMs();
26
+ return this.populatedAt > 0 && fresh;
19
27
  }
20
28
  setModels(models) {
21
29
  this.models = Object.fromEntries(models.map((m) => [m.id, m]));
22
30
  this.fetchedAt = Date.now();
31
+ this.populatedAt = this.fetchedAt;
32
+ }
33
+ /**
34
+ * Force the cache back into an uninitialized state. Used by tests that
35
+ * need to assert `ensureFresh()` actually calls the fetcher. Also useful
36
+ * for ops (`health_check --reset`) if we ever expose such a knob.
37
+ */
38
+ reset() {
39
+ this.models = {};
40
+ this.fetchedAt = 0;
41
+ this.populatedAt = 0;
42
+ this.inflight = null;
23
43
  }
24
44
  /**
25
45
  * Populate the cache using `fetcher` if stale, coalescing concurrent callers
@@ -45,6 +65,10 @@ export class ModelCache {
45
65
  getAll() {
46
66
  return Object.values(this.models);
47
67
  }
68
+ /** Number of models currently cached. Used by health_check. */
69
+ size() {
70
+ return Object.keys(this.models).length;
71
+ }
48
72
  get(id) {
49
73
  return this.models[id] ?? null;
50
74
  }
@@ -70,6 +94,8 @@ export class ModelCache {
70
94
  if (params.capabilities?.video) {
71
95
  results = results.filter((m) => m.architecture?.input_modalities?.includes('video'));
72
96
  }
97
+ if (params.all)
98
+ return results;
73
99
  const limit = Math.min(Math.max(1, params.limit ?? 10), MAX_SEARCH_LIMIT);
74
100
  return results.slice(0, limit);
75
101
  }
@@ -21,8 +21,7 @@ export declare class OpenRouterAPIClient {
21
21
  submitVideoJob(body: Record<string, unknown>): Promise<VideoJobEnvelope>;
22
22
  /** Poll a submitted video-generation job by id. */
23
23
  pollVideoJob(id: string): Promise<VideoJobStatus>;
24
- /**
25
- * Download the generated video binary. Returns `{ buffer, contentType }`.
24
+ /** Download the generated video binary. Returns `{ buffer, contentType }`.
26
25
  * This intentionally does NOT go through our SSRF-guarded `fetchHttpResource`
27
26
  * because the URL is always OpenRouter itself (trusted origin) — and it can
28
27
  * return arbitrarily large bodies that the caller bounds via
@@ -32,6 +31,13 @@ export declare class OpenRouterAPIClient {
32
31
  buffer: Buffer;
33
32
  contentType: string | null;
34
33
  }>;
34
+ /** POST /rerank — re-order documents by relevance to a query. */
35
+ rerank(params: {
36
+ model: string;
37
+ query: string;
38
+ documents: string[];
39
+ top_n?: number;
40
+ }): Promise<RerankResponse>;
35
41
  }
36
42
  export interface VideoJobEnvelope {
37
43
  id: string;
@@ -39,6 +45,20 @@ export interface VideoJobEnvelope {
39
45
  polling_url?: string;
40
46
  [key: string]: unknown;
41
47
  }
48
+ export interface RerankResultItem {
49
+ index: number;
50
+ relevance_score?: number;
51
+ score?: number;
52
+ document?: {
53
+ text?: string;
54
+ } | string;
55
+ }
56
+ export interface RerankResponse {
57
+ model?: string;
58
+ results: RerankResultItem[];
59
+ usage?: Record<string, unknown>;
60
+ [key: string]: unknown;
61
+ }
42
62
  export type VideoJobStatusName = 'pending' | 'queued' | 'processing' | 'completed' | 'failed';
43
63
  export interface VideoJobStatus {
44
64
  id: string;
@@ -104,8 +104,7 @@ export class OpenRouterAPIClient {
104
104
  }
105
105
  return (await res.json());
106
106
  }
107
- /**
108
- * Download the generated video binary. Returns `{ buffer, contentType }`.
107
+ /** Download the generated video binary. Returns `{ buffer, contentType }`.
109
108
  * This intentionally does NOT go through our SSRF-guarded `fetchHttpResource`
110
109
  * because the URL is always OpenRouter itself (trusted origin) — and it can
111
110
  * return arbitrarily large bodies that the caller bounds via
@@ -152,6 +151,26 @@ export class OpenRouterAPIClient {
152
151
  }
153
152
  return { buffer: Buffer.concat(chunks), contentType: res.headers.get('content-type') };
154
153
  }
154
+ /** POST /rerank — re-order documents by relevance to a query. */
155
+ async rerank(params) {
156
+ const body = {
157
+ model: params.model,
158
+ query: params.query,
159
+ documents: params.documents,
160
+ };
161
+ if (typeof params.top_n === 'number' && params.top_n > 0)
162
+ body.top_n = params.top_n;
163
+ const res = await fetchWithRetry(`${BASE_URL}/rerank`, {
164
+ method: 'POST',
165
+ headers: this.authHeaders({ 'Content-Type': 'application/json' }),
166
+ body: JSON.stringify(body),
167
+ }, { retries: 2, timeoutMs: DEFAULT_TIMEOUT_MS });
168
+ if (!res.ok) {
169
+ const detail = await safeReadText(res);
170
+ throw new Error(`POST /rerank failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
171
+ }
172
+ return (await res.json());
173
+ }
155
174
  }
156
175
  async function safeReadText(res) {
157
176
  try {
@@ -1,8 +1,15 @@
1
1
  import OpenAI from 'openai';
2
- export interface AnalyzeAudioToolRequest {
2
+ import { type CacheOptions } from './cache.js';
3
+ export interface AnalyzeAudioToolRequest extends CacheOptions {
3
4
  audio_path: string;
4
5
  question?: string;
5
6
  model?: string;
7
+ /**
8
+ * Attach `cache_control: {type: 'ephemeral'}` to the audio block so
9
+ * Claude / Gemini 2.5+ prompt-caches it. Repeat questions about the
10
+ * same audio then cost dramatically less for the audio portion.
11
+ */
12
+ cache_input?: boolean;
6
13
  }
7
14
  export declare function handleAnalyzeAudio(request: {
8
15
  params: {
@@ -13,7 +20,5 @@ export declare function handleAnalyzeAudio(request: {
13
20
  type: "text";
14
21
  text: string;
15
22
  }[];
16
- _meta: {
17
- finish_reason: "length" | "stop" | "tool_calls" | "content_filter" | "function_call" | undefined;
18
- };
23
+ _meta: Record<string, unknown>;
19
24
  }>;
@@ -1,10 +1,14 @@
1
1
  import { prepareAudioData } from './audio-utils.js';
2
2
  import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
3
+ import { SERVER_VERSION } from '../version.js';
3
4
  import { classifyUpstreamError } from './openrouter-errors.js';
4
- import { extractCompletionText, detectReasoningCutoff, toUsageMeta, } from './completion-utils.js';
5
+ import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
6
+ import { buildCacheHeaders, extractCacheMeta, } from './cache.js';
7
+ import { awaitCompletionWithHeaders } from './openai-withresponse.js';
5
8
  const DEFAULT_MODEL = 'google/gemini-2.5-flash';
6
9
  export async function handleAnalyzeAudio(request, openai, defaultModel) {
7
- const { audio_path, question, model } = request.params.arguments ?? { audio_path: '' };
10
+ const args = request.params.arguments ?? { audio_path: '' };
11
+ const { audio_path, question, model, cache_input, cache, cache_ttl, cache_clear } = args;
8
12
  if (!audio_path) {
9
13
  return toolError(ErrorCode.INVALID_INPUT, 'audio_path is required.');
10
14
  }
@@ -24,26 +28,32 @@ export async function handleAnalyzeAudio(request, openai, defaultModel) {
24
28
  }
25
29
  return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
26
30
  }
31
+ const audioBlock = {
32
+ type: 'input_audio',
33
+ input_audio: { data: audioData.data, format: audioData.format },
34
+ };
35
+ if (cache_input)
36
+ audioBlock.cache_control = { type: 'ephemeral' };
37
+ const headers = buildCacheHeaders({ cache, cache_ttl, cache_clear });
38
+ const requestOpts = Object.keys(headers).length > 0 ? { headers } : undefined;
27
39
  let completion;
40
+ let responseHeaders;
28
41
  try {
29
- completion = await openai.chat.completions.create({
42
+ const call = openai.chat.completions.create({
30
43
  model: model || defaultModel || DEFAULT_MODEL,
31
44
  messages: [
32
45
  {
33
46
  role: 'user',
34
47
  content: [
35
48
  { type: 'text', text: question || 'Please transcribe and analyze this audio file.' },
36
- {
37
- type: 'input_audio',
38
- input_audio: {
39
- data: audioData.data,
40
- format: audioData.format,
41
- },
42
- },
49
+ audioBlock,
43
50
  ],
44
51
  },
45
52
  ],
46
- });
53
+ }, requestOpts);
54
+ const { data, response } = await awaitCompletionWithHeaders(call);
55
+ completion = data;
56
+ responseHeaders = response?.headers;
47
57
  }
48
58
  catch (err) {
49
59
  return classifyUpstreamError(err);
@@ -57,11 +67,15 @@ export async function handleAnalyzeAudio(request, openai, defaultModel) {
57
67
  finish_reason: extracted.finishReason,
58
68
  });
59
69
  }
70
+ const cacheMeta = extractCacheMeta(responseHeaders);
71
+ const extra = {
72
+ server_version: SERVER_VERSION,
73
+ content_is_untrusted: true,
74
+ };
75
+ if (cacheMeta)
76
+ extra.cache = cacheMeta;
60
77
  return {
61
78
  content: [{ type: 'text', text: extracted.text }],
62
- _meta: {
63
- finish_reason: extracted.finishReason,
64
- ...(toUsageMeta(extracted.usage) ?? {}),
65
- },
79
+ _meta: buildCompletionMeta(extracted, { extra }),
66
80
  };
67
81
  }