@stabgan/openrouter-mcp-multimodal 4.0.1 → 4.5.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 +40 -7
- package/dist/errors.d.ts +26 -8
- package/dist/errors.js +17 -12
- package/dist/logger.d.ts +11 -0
- package/dist/logger.js +26 -0
- package/dist/model-cache.d.ts +4 -0
- package/dist/model-cache.js +6 -0
- package/dist/openrouter-api.d.ts +22 -2
- package/dist/openrouter-api.js +21 -2
- package/dist/tool-handlers/analyze-audio.d.ts +9 -4
- package/dist/tool-handlers/analyze-audio.js +29 -15
- package/dist/tool-handlers/analyze-image.d.ts +10 -4
- package/dist/tool-handlers/analyze-image.js +37 -9
- package/dist/tool-handlers/analyze-video.d.ts +9 -4
- package/dist/tool-handlers/analyze-video.js +31 -19
- package/dist/tool-handlers/cache.d.ts +33 -0
- package/dist/tool-handlers/cache.js +54 -0
- package/dist/tool-handlers/chat-completion.d.ts +18 -4
- package/dist/tool-handlers/chat-completion.js +39 -11
- package/dist/tool-handlers/completion-utils.d.ts +29 -0
- package/dist/tool-handlers/completion-utils.js +76 -17
- package/dist/tool-handlers/generate-audio.d.ts +2 -0
- package/dist/tool-handlers/generate-audio.js +18 -1
- package/dist/tool-handlers/generate-image.d.ts +2 -0
- package/dist/tool-handlers/generate-image.js +15 -0
- package/dist/tool-handlers/generate-video.d.ts +43 -0
- package/dist/tool-handlers/generate-video.js +46 -3
- package/dist/tool-handlers/get-model-info.d.ts +1 -6
- package/dist/tool-handlers/get-model-info.js +2 -1
- package/dist/tool-handlers/health-check.d.ts +23 -0
- package/dist/tool-handlers/health-check.js +32 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +16 -0
- package/dist/tool-handlers/openai-withresponse.js +16 -0
- package/dist/tool-handlers/rerank.d.ts +17 -0
- package/dist/tool-handlers/rerank.js +52 -0
- package/dist/tool-handlers/search-models.d.ts +18 -7
- package/dist/tool-handlers/search-models.js +25 -2
- package/dist/tool-handlers/structured-output.d.ts +13 -0
- package/dist/tool-handlers/structured-output.js +24 -0
- package/dist/tool-handlers/validate-model.d.ts +4 -6
- package/dist/tool-handlers/validate-model.js +3 -8
- package/dist/tool-handlers.d.ts +1 -0
- package/dist/tool-handlers.js +417 -165
- package/dist/version.d.ts +16 -0
- package/dist/version.js +16 -0
- 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`)
|
|
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
|
-
| `
|
|
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
|
|
|
@@ -339,7 +372,7 @@ Works with any MCP client: [Kiro](https://kiro.dev) · [Claude Desktop](https://
|
|
|
339
372
|
|
|
340
373
|
## License
|
|
341
374
|
|
|
342
|
-
Apache 2.0. See [LICENSE](./LICENSE)
|
|
375
|
+
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
376
|
|
|
344
377
|
## Contributing
|
|
345
378
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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/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
|
};
|
package/dist/model-cache.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export declare class ModelCache {
|
|
|
23
23
|
*/
|
|
24
24
|
ensureFresh(fetcher: () => Promise<OpenRouterModelRecord[]>): Promise<void>;
|
|
25
25
|
getAll(): OpenRouterModelRecord[];
|
|
26
|
+
/** Number of models currently cached. Used by health_check. */
|
|
27
|
+
size(): number;
|
|
26
28
|
get(id: string): OpenRouterModelRecord | null;
|
|
27
29
|
has(id: string): boolean;
|
|
28
30
|
search(params: {
|
|
@@ -34,5 +36,7 @@ export declare class ModelCache {
|
|
|
34
36
|
video?: boolean;
|
|
35
37
|
};
|
|
36
38
|
limit?: number;
|
|
39
|
+
/** When true, return the full filtered set and ignore `limit`. Used by pagination. */
|
|
40
|
+
all?: boolean;
|
|
37
41
|
}): OpenRouterModelRecord[];
|
|
38
42
|
}
|
package/dist/model-cache.js
CHANGED
|
@@ -45,6 +45,10 @@ export class ModelCache {
|
|
|
45
45
|
getAll() {
|
|
46
46
|
return Object.values(this.models);
|
|
47
47
|
}
|
|
48
|
+
/** Number of models currently cached. Used by health_check. */
|
|
49
|
+
size() {
|
|
50
|
+
return Object.keys(this.models).length;
|
|
51
|
+
}
|
|
48
52
|
get(id) {
|
|
49
53
|
return this.models[id] ?? null;
|
|
50
54
|
}
|
|
@@ -70,6 +74,8 @@ export class ModelCache {
|
|
|
70
74
|
if (params.capabilities?.video) {
|
|
71
75
|
results = results.filter((m) => m.architecture?.input_modalities?.includes('video'));
|
|
72
76
|
}
|
|
77
|
+
if (params.all)
|
|
78
|
+
return results;
|
|
73
79
|
const limit = Math.min(Math.max(1, params.limit ?? 10), MAX_SEARCH_LIMIT);
|
|
74
80
|
return results.slice(0, limit);
|
|
75
81
|
}
|
package/dist/openrouter-api.d.ts
CHANGED
|
@@ -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;
|
package/dist/openrouter-api.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
|
|
2
|
+
import { type CacheOptions } from './cache.js';
|
|
3
|
+
export interface AnalyzeImageToolRequest extends CacheOptions {
|
|
3
4
|
image_path: string;
|
|
4
5
|
question?: string;
|
|
5
6
|
model?: string;
|
|
7
|
+
/**
|
|
8
|
+
* When true, attach Anthropic-style `cache_control: {type: 'ephemeral'}`
|
|
9
|
+
* to the image block so Claude / Gemini 2.5+ prompt-caches it. Repeat
|
|
10
|
+
* questions about the same image then cost ~0.1x on Anthropic and
|
|
11
|
+
* ~0.25x on Gemini for the image input.
|
|
12
|
+
*/
|
|
13
|
+
cache_input?: boolean;
|
|
6
14
|
}
|
|
7
15
|
export declare function handleAnalyzeImage(request: {
|
|
8
16
|
params: {
|
|
@@ -13,7 +21,5 @@ export declare function handleAnalyzeImage(request: {
|
|
|
13
21
|
type: "text";
|
|
14
22
|
text: string;
|
|
15
23
|
}[];
|
|
16
|
-
_meta:
|
|
17
|
-
finish_reason: "length" | "stop" | "tool_calls" | "content_filter" | "function_call" | undefined;
|
|
18
|
-
};
|
|
24
|
+
_meta: Record<string, unknown>;
|
|
19
25
|
}>;
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { prepareImageUrl } from './image-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,
|
|
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 = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
6
9
|
export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
7
|
-
const
|
|
10
|
+
const args = request.params.arguments ?? { image_path: '' };
|
|
11
|
+
const { image_path, question, model, cache_input, cache, cache_ttl, cache_clear } = args;
|
|
8
12
|
if (!image_path) {
|
|
9
13
|
return toolError(ErrorCode.INVALID_INPUT, 'image_path is required.');
|
|
10
14
|
}
|
|
@@ -21,20 +25,35 @@ export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
|
21
25
|
}
|
|
22
26
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
23
27
|
}
|
|
28
|
+
// Attach `cache_control` to the image block when requested. The openai
|
|
29
|
+
// SDK doesn't type this field but passes it through to the server,
|
|
30
|
+
// which forwards it to providers that support prompt caching.
|
|
31
|
+
const imageBlock = {
|
|
32
|
+
type: 'image_url',
|
|
33
|
+
image_url: { url: imageUrl },
|
|
34
|
+
};
|
|
35
|
+
if (cache_input)
|
|
36
|
+
imageBlock.cache_control = { type: 'ephemeral' };
|
|
37
|
+
const headers = buildCacheHeaders({ cache, cache_ttl, cache_clear });
|
|
38
|
+
const requestOpts = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
24
39
|
let completion;
|
|
40
|
+
let responseHeaders;
|
|
25
41
|
try {
|
|
26
|
-
|
|
42
|
+
const call = openai.chat.completions.create({
|
|
27
43
|
model: model || defaultModel || DEFAULT_MODEL,
|
|
28
44
|
messages: [
|
|
29
45
|
{
|
|
30
46
|
role: 'user',
|
|
31
47
|
content: [
|
|
32
48
|
{ type: 'text', text: question || "What's in this image?" },
|
|
33
|
-
|
|
49
|
+
imageBlock,
|
|
34
50
|
],
|
|
35
51
|
},
|
|
36
52
|
],
|
|
37
|
-
});
|
|
53
|
+
}, requestOpts);
|
|
54
|
+
const { data, response } = await awaitCompletionWithHeaders(call);
|
|
55
|
+
completion = data;
|
|
56
|
+
responseHeaders = response?.headers;
|
|
38
57
|
}
|
|
39
58
|
catch (err) {
|
|
40
59
|
return classifyUpstreamError(err);
|
|
@@ -48,11 +67,20 @@ export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
|
48
67
|
finish_reason: extracted.finishReason,
|
|
49
68
|
});
|
|
50
69
|
}
|
|
70
|
+
const cacheMeta = extractCacheMeta(responseHeaders);
|
|
71
|
+
// Output originates from model interpretation of potentially
|
|
72
|
+
// attacker-controlled image content (typography attacks, QR codes,
|
|
73
|
+
// adversarial watermarks). Flag it so downstream agents know to treat
|
|
74
|
+
// this text as data, not instructions. Inspired by ClawGuard (arxiv
|
|
75
|
+
// 2604.11790) and tool-result-parsing defenses (2601.04795).
|
|
76
|
+
const extra = {
|
|
77
|
+
server_version: SERVER_VERSION,
|
|
78
|
+
content_is_untrusted: true,
|
|
79
|
+
};
|
|
80
|
+
if (cacheMeta)
|
|
81
|
+
extra.cache = cacheMeta;
|
|
51
82
|
return {
|
|
52
83
|
content: [{ type: 'text', text: extracted.text }],
|
|
53
|
-
_meta: {
|
|
54
|
-
finish_reason: extracted.finishReason,
|
|
55
|
-
...(toUsageMeta(extracted.usage) ?? {}),
|
|
56
|
-
},
|
|
84
|
+
_meta: buildCompletionMeta(extracted, { extra }),
|
|
57
85
|
};
|
|
58
86
|
}
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
|
|
2
|
+
import { type CacheOptions } from './cache.js';
|
|
3
|
+
export interface AnalyzeVideoToolRequest extends CacheOptions {
|
|
3
4
|
video_path: string;
|
|
4
5
|
question?: string;
|
|
5
6
|
model?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Attach `cache_control: {type: 'ephemeral'}` to the video block so
|
|
9
|
+
* Claude / Gemini 2.5+ prompt-caches it. Very valuable for large
|
|
10
|
+
* videos where repeat questions save 10x on Anthropic pricing.
|
|
11
|
+
*/
|
|
12
|
+
cache_input?: boolean;
|
|
6
13
|
}
|
|
7
14
|
export declare function handleAnalyzeVideo(request: {
|
|
8
15
|
params: {
|
|
@@ -13,7 +20,5 @@ export declare function handleAnalyzeVideo(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
|
}>;
|