@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.6.2
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 +60 -52
- package/dist/errors.d.ts +3 -12
- package/dist/errors.js +2 -6
- package/dist/index.js +2 -10
- package/dist/logger.d.ts +1 -18
- package/dist/logger.js +0 -7
- package/dist/model-cache.d.ts +1 -20
- package/dist/model-cache.js +1 -20
- package/dist/openrouter-api.d.ts +4 -18
- package/dist/openrouter-api.js +4 -20
- package/dist/tool-definitions.d.ts +1 -0
- package/dist/tool-definitions.js +708 -0
- package/dist/tool-handlers/analyze-audio.d.ts +0 -5
- package/dist/tool-handlers/analyze-image.d.ts +0 -6
- package/dist/tool-handlers/analyze-image.js +1 -8
- package/dist/tool-handlers/analyze-video.d.ts +0 -5
- package/dist/tool-handlers/analyze-video.js +0 -7
- package/dist/tool-handlers/async-chat.d.ts +17 -13
- package/dist/tool-handlers/async-chat.js +42 -65
- package/dist/tool-handlers/audio-utils.d.ts +1 -4
- package/dist/tool-handlers/audio-utils.js +4 -14
- package/dist/tool-handlers/cache.d.ts +2 -18
- package/dist/tool-handlers/cache.js +1 -19
- package/dist/tool-handlers/chat-completion.d.ts +2 -30
- package/dist/tool-handlers/chat-completion.js +13 -33
- package/dist/tool-handlers/chat-request.d.ts +25 -0
- package/dist/tool-handlers/chat-request.js +38 -0
- package/dist/tool-handlers/completion-utils.d.ts +1 -18
- package/dist/tool-handlers/completion-utils.js +0 -2
- package/dist/tool-handlers/fetch-utils.d.ts +2 -18
- package/dist/tool-handlers/fetch-utils.js +3 -51
- package/dist/tool-handlers/generate-audio.js +11 -21
- package/dist/tool-handlers/generate-image-dedicated.js +16 -70
- package/dist/tool-handlers/generate-image-input.d.ts +0 -1
- package/dist/tool-handlers/generate-image-input.js +2 -18
- package/dist/tool-handlers/generate-image.js +8 -15
- package/dist/tool-handlers/generate-video.d.ts +1 -7
- package/dist/tool-handlers/generate-video.js +24 -109
- package/dist/tool-handlers/health-check.d.ts +1 -9
- package/dist/tool-handlers/health-check.js +1 -12
- package/dist/tool-handlers/image-source.d.ts +14 -0
- package/dist/tool-handlers/image-source.js +23 -0
- package/dist/tool-handlers/image-utils.d.ts +5 -0
- package/dist/tool-handlers/image-utils.js +23 -0
- package/dist/tool-handlers/openai-withresponse.d.ts +1 -10
- package/dist/tool-handlers/openai-withresponse.js +0 -7
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -18
- package/dist/tool-handlers/openrouter-errors.js +2 -34
- package/dist/tool-handlers/path-safety.d.ts +10 -15
- package/dist/tool-handlers/path-safety.js +30 -55
- package/dist/tool-handlers/provider-routing.d.ts +0 -9
- package/dist/tool-handlers/provider-routing.js +1 -14
- package/dist/tool-handlers/rerank.js +0 -2
- package/dist/tool-handlers/search-models.d.ts +0 -6
- package/dist/tool-handlers/speech-to-text.js +3 -21
- package/dist/tool-handlers/structured-output.d.ts +1 -4
- package/dist/tool-handlers/structured-output.js +2 -16
- package/dist/tool-handlers/text-to-speech.js +8 -26
- package/dist/tool-handlers/video-utils.d.ts +1 -6
- package/dist/tool-handlers/video-utils.js +2 -17
- package/dist/tool-handlers.js +4 -734
- package/dist/tool-icons.d.ts +0 -6
- package/dist/tool-icons.js +1 -8
- package/dist/version.d.ts +1 -15
- package/dist/version.js +1 -15
- package/package.json +3 -2
|
@@ -4,11 +4,6 @@ export interface AnalyzeAudioToolRequest extends CacheOptions {
|
|
|
4
4
|
audio_path: string;
|
|
5
5
|
question?: string;
|
|
6
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
7
|
cache_input?: boolean;
|
|
13
8
|
}
|
|
14
9
|
export declare function handleAnalyzeAudio(request: {
|
|
@@ -4,12 +4,6 @@ export interface AnalyzeImageToolRequest extends CacheOptions {
|
|
|
4
4
|
image_path: string;
|
|
5
5
|
question?: string;
|
|
6
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
7
|
cache_input?: boolean;
|
|
14
8
|
}
|
|
15
9
|
export declare function handleAnalyzeImage(request: {
|
|
@@ -29,9 +29,6 @@ export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
|
29
29
|
}
|
|
30
30
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
31
31
|
}
|
|
32
|
-
// Attach `cache_control` to the image block when requested. The openai
|
|
33
|
-
// SDK doesn't type this field but passes it through to the server,
|
|
34
|
-
// which forwards it to providers that support prompt caching.
|
|
35
32
|
const imageBlock = {
|
|
36
33
|
type: 'image_url',
|
|
37
34
|
image_url: { url: imageUrl },
|
|
@@ -69,11 +66,7 @@ export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
|
69
66
|
});
|
|
70
67
|
}
|
|
71
68
|
const cacheMeta = extractCacheMeta(responseHeaders);
|
|
72
|
-
//
|
|
73
|
-
// attacker-controlled image content (typography attacks, QR codes,
|
|
74
|
-
// adversarial watermarks). Flag it so downstream agents know to treat
|
|
75
|
-
// this text as data, not instructions. Inspired by ClawGuard (arxiv
|
|
76
|
-
// 2604.11790) and tool-result-parsing defenses (2601.04795).
|
|
69
|
+
// Vision output may reflect untrusted image content — flag for downstream agents.
|
|
77
70
|
const extra = {
|
|
78
71
|
server_version: SERVER_VERSION,
|
|
79
72
|
content_is_untrusted: true,
|
|
@@ -4,11 +4,6 @@ export interface AnalyzeVideoToolRequest extends CacheOptions {
|
|
|
4
4
|
video_path: string;
|
|
5
5
|
question?: string;
|
|
6
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
7
|
cache_input?: boolean;
|
|
13
8
|
}
|
|
14
9
|
export declare function handleAnalyzeVideo(request: {
|
|
@@ -7,11 +7,6 @@ import { classifyUpstreamError } from './openrouter-errors.js';
|
|
|
7
7
|
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
8
8
|
import { buildCacheHeaders, extractCacheMeta } from './cache.js';
|
|
9
9
|
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
10
|
-
/**
|
|
11
|
-
* Default model — `google/gemini-2.5-flash` has the widest video-input
|
|
12
|
-
* support on OpenRouter at time of writing. Override via env
|
|
13
|
-
* `OPENROUTER_DEFAULT_VIDEO_MODEL` or per-call `model`.
|
|
14
|
-
*/
|
|
15
10
|
const FALLBACK_DEFAULT_MODEL = 'google/gemini-2.5-flash';
|
|
16
11
|
export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
17
12
|
const args = request.params.arguments ?? { video_path: '' };
|
|
@@ -41,8 +36,6 @@ export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
|
41
36
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
42
37
|
}
|
|
43
38
|
const videoBlock = {
|
|
44
|
-
// The `video_url` content type is an OpenRouter extension; the OpenAI
|
|
45
|
-
// SDK's typings don't know about it yet.
|
|
46
39
|
type: 'video_url',
|
|
47
40
|
video_url: { url: `data:${videoData.mediaType};base64,${videoData.data}` },
|
|
48
41
|
};
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import
|
|
3
|
-
import { type
|
|
4
|
-
|
|
5
|
-
export interface StartChatCompletionRequest extends CacheOptions {
|
|
6
|
-
messages: ChatCompletionMessageParam[];
|
|
7
|
-
model?: string;
|
|
8
|
-
temperature?: number;
|
|
9
|
-
max_tokens?: number;
|
|
10
|
-
provider?: ProviderRoutingOptions;
|
|
11
|
-
include_reasoning?: boolean;
|
|
12
|
-
online?: boolean;
|
|
13
|
-
web_max_results?: number;
|
|
14
|
-
}
|
|
2
|
+
import { ErrorCode } from '../errors.js';
|
|
3
|
+
import { type ChatToolRequest } from './chat-request.js';
|
|
4
|
+
export type StartChatCompletionRequest = ChatToolRequest;
|
|
15
5
|
export interface GetChatCompletionStatusRequest {
|
|
16
6
|
job_id: string;
|
|
17
7
|
}
|
|
18
8
|
export type AsyncJobStatus = 'queued' | 'running' | 'completed' | 'failed';
|
|
9
|
+
export interface AsyncJob {
|
|
10
|
+
id: string;
|
|
11
|
+
status: AsyncJobStatus;
|
|
12
|
+
createdAt: string;
|
|
13
|
+
model: string;
|
|
14
|
+
result?: {
|
|
15
|
+
text: string;
|
|
16
|
+
meta: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
error?: string;
|
|
19
|
+
error_code?: ErrorCode;
|
|
20
|
+
}
|
|
21
|
+
/** Load a persisted job from disk (exported for tests). */
|
|
22
|
+
export declare function loadJobFromDisk(jobId: string): Promise<AsyncJob | null>;
|
|
19
23
|
export declare function handleStartChatCompletion(request: {
|
|
20
24
|
params: {
|
|
21
25
|
arguments: StartChatCompletionRequest;
|
|
@@ -1,29 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Problem: Remote MCP bridges (Cowork, etc.) kill tool calls after ~60s.
|
|
5
|
-
* Reasoning models can take much longer. Unlike video, `chat_completion`
|
|
6
|
-
* currently has no background job mechanism.
|
|
7
|
-
*
|
|
8
|
-
* Solution: Two tools that mirror the video pattern:
|
|
9
|
-
* - `start_chat_completion` — fires off the request in the background,
|
|
10
|
-
* returns a `job_id` immediately.
|
|
11
|
-
* - `get_chat_completion_status` — returns queued/running/completed/failed,
|
|
12
|
-
* with the final response on completion.
|
|
13
|
-
*
|
|
14
|
-
* Job state is held in memory (survives within a single MCP session).
|
|
15
|
-
* Optionally persisted to OPENROUTER_OUTPUT_DIR/openrouter-jobs/ for
|
|
16
|
-
* crash recovery.
|
|
17
|
-
*/
|
|
18
|
-
import { promises as fs } from 'fs';
|
|
1
|
+
/** Async chat completions — in-memory jobs, optionally persisted under OPENROUTER_OUTPUT_DIR/openrouter-jobs/. */
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
19
3
|
import path from 'node:path';
|
|
20
4
|
import { ErrorCode, toolError } from '../errors.js';
|
|
21
5
|
import { SERVER_VERSION } from '../version.js';
|
|
22
6
|
import { logger } from '../logger.js';
|
|
23
7
|
import { extractCompletionText, buildCompletionMeta } from './completion-utils.js';
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
26
|
-
// ─── Job Store ───────────────────────────────────────────────────────────────
|
|
8
|
+
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
+
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, } from './chat-request.js';
|
|
27
10
|
const jobs = new Map();
|
|
28
11
|
let jobCounter = 0;
|
|
29
12
|
function generateJobId() {
|
|
@@ -56,11 +39,29 @@ async function persistJob(job) {
|
|
|
56
39
|
});
|
|
57
40
|
}
|
|
58
41
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
42
|
+
/** Load a persisted job from disk (exported for tests). */
|
|
43
|
+
export async function loadJobFromDisk(jobId) {
|
|
44
|
+
const dir = getJobsDir();
|
|
45
|
+
if (!dir)
|
|
46
|
+
return null;
|
|
47
|
+
try {
|
|
48
|
+
const raw = await fs.readFile(path.join(dir, jobId, 'status.json'), 'utf8');
|
|
49
|
+
return JSON.parse(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async function resolveJob(jobId) {
|
|
56
|
+
const inMemory = jobs.get(jobId);
|
|
57
|
+
if (inMemory)
|
|
58
|
+
return inMemory;
|
|
59
|
+
const fromDisk = await loadJobFromDisk(jobId);
|
|
60
|
+
if (fromDisk) {
|
|
61
|
+
jobs.set(jobId, fromDisk);
|
|
62
|
+
return fromDisk;
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
64
65
|
}
|
|
65
66
|
export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
66
67
|
const args = request.params.arguments ?? { messages: [] };
|
|
@@ -68,9 +69,8 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
68
69
|
if (!messages?.length) {
|
|
69
70
|
return toolError(ErrorCode.INVALID_INPUT, 'Messages array cannot be empty.');
|
|
70
71
|
}
|
|
71
|
-
const effectiveModel = model || defaultModel ||
|
|
72
|
+
const effectiveModel = model || defaultModel || DEFAULT_CHAT_MODEL;
|
|
72
73
|
const jobId = generateJobId();
|
|
73
|
-
// Create the job immediately
|
|
74
74
|
const job = {
|
|
75
75
|
id: jobId,
|
|
76
76
|
status: 'running',
|
|
@@ -83,8 +83,7 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
83
83
|
model: effectiveModel,
|
|
84
84
|
message_count: messages.length,
|
|
85
85
|
});
|
|
86
|
-
|
|
87
|
-
runCompletionInBackground(job, openai, {
|
|
86
|
+
void runCompletionInBackground(job, openai, {
|
|
88
87
|
messages,
|
|
89
88
|
model: effectiveModel,
|
|
90
89
|
temperature,
|
|
@@ -97,7 +96,6 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
97
96
|
cache_ttl,
|
|
98
97
|
cache_clear,
|
|
99
98
|
});
|
|
100
|
-
// Return immediately with the job ID
|
|
101
99
|
return {
|
|
102
100
|
content: [
|
|
103
101
|
{
|
|
@@ -114,36 +112,11 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
114
112
|
};
|
|
115
113
|
}
|
|
116
114
|
async function runCompletionInBackground(job, openai, opts) {
|
|
117
|
-
const providerOptions = mergeProviderOptions(readProviderDefaults(), opts.provider);
|
|
118
|
-
const providerBody = buildProviderBody(providerOptions);
|
|
119
|
-
const effectiveMaxTokens = resolveMaxTokens(opts.max_tokens);
|
|
120
115
|
const wantsReasoning = opts.include_reasoning ?? readIncludeReasoningDefault();
|
|
121
|
-
const body =
|
|
122
|
-
|
|
123
|
-
messages: opts.messages,
|
|
124
|
-
temperature: opts.temperature ?? 1,
|
|
125
|
-
};
|
|
126
|
-
if (typeof effectiveMaxTokens === 'number')
|
|
127
|
-
body.max_tokens = effectiveMaxTokens;
|
|
128
|
-
if (providerBody)
|
|
129
|
-
body.provider = providerBody;
|
|
130
|
-
if (wantsReasoning)
|
|
131
|
-
body.include_reasoning = true;
|
|
132
|
-
if (opts.online) {
|
|
133
|
-
const plugin = { id: 'web' };
|
|
134
|
-
if (typeof opts.web_max_results === 'number' && opts.web_max_results > 0) {
|
|
135
|
-
plugin.max_results = opts.web_max_results;
|
|
136
|
-
}
|
|
137
|
-
body.plugins = [plugin];
|
|
138
|
-
}
|
|
139
|
-
const headers = buildCacheHeaders({
|
|
140
|
-
cache: opts.cache,
|
|
141
|
-
cache_ttl: opts.cache_ttl,
|
|
142
|
-
cache_clear: opts.cache_clear,
|
|
143
|
-
});
|
|
144
|
-
const requestOpts = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
116
|
+
const body = buildChatCompletionBody(opts);
|
|
117
|
+
const requestOpts = buildChatCompletionRequestOpts(opts);
|
|
145
118
|
try {
|
|
146
|
-
const completion = (await openai.chat.completions.create(body, requestOpts));
|
|
119
|
+
const completion = (await openai.chat.completions.create(asOpenAIChatBody(body), requestOpts));
|
|
147
120
|
const extracted = extractCompletionText(completion);
|
|
148
121
|
if (!extracted.text) {
|
|
149
122
|
job.status = 'failed';
|
|
@@ -162,8 +135,10 @@ async function runCompletionInBackground(job, openai, opts) {
|
|
|
162
135
|
}
|
|
163
136
|
catch (err) {
|
|
164
137
|
job.status = 'failed';
|
|
165
|
-
|
|
166
|
-
|
|
138
|
+
const classified = classifyUpstreamError(err);
|
|
139
|
+
job.error = classified.content[0]?.text ?? 'Job failed.';
|
|
140
|
+
job.error_code = classified._meta.code;
|
|
141
|
+
logger.warn('async_chat.failed', { job_id: job.id, error: job.error, code: job.error_code });
|
|
167
142
|
}
|
|
168
143
|
await persistJob(job);
|
|
169
144
|
}
|
|
@@ -173,9 +148,12 @@ export async function handleGetChatCompletionStatus(request) {
|
|
|
173
148
|
if (!jobId) {
|
|
174
149
|
return toolError(ErrorCode.INVALID_INPUT, 'job_id is required.');
|
|
175
150
|
}
|
|
176
|
-
const job =
|
|
151
|
+
const job = await resolveJob(jobId);
|
|
177
152
|
if (!job) {
|
|
178
|
-
|
|
153
|
+
const hint = getJobsDir()
|
|
154
|
+
? ' Jobs persist under OPENROUTER_OUTPUT_DIR/openrouter-jobs/ when that env var is set.'
|
|
155
|
+
: ' Jobs are stored in memory for the current session only.';
|
|
156
|
+
return toolError(ErrorCode.INVALID_INPUT, `No job found with id "${jobId}".${hint}`);
|
|
179
157
|
}
|
|
180
158
|
if (job.status === 'completed' && job.result) {
|
|
181
159
|
return {
|
|
@@ -191,13 +169,12 @@ export async function handleGetChatCompletionStatus(request) {
|
|
|
191
169
|
};
|
|
192
170
|
}
|
|
193
171
|
if (job.status === 'failed') {
|
|
194
|
-
return toolError(ErrorCode.JOB_FAILED, job.error || 'Job failed.', {
|
|
172
|
+
return toolError(job.error_code ?? ErrorCode.JOB_FAILED, job.error || 'Job failed.', {
|
|
195
173
|
job_id: jobId,
|
|
196
174
|
model: job.model,
|
|
197
175
|
created_at: job.createdAt,
|
|
198
176
|
});
|
|
199
177
|
}
|
|
200
|
-
// Still running
|
|
201
178
|
return {
|
|
202
179
|
content: [
|
|
203
180
|
{
|
|
@@ -12,8 +12,5 @@ export interface AudioData {
|
|
|
12
12
|
data: string;
|
|
13
13
|
format: AudioFormat;
|
|
14
14
|
}
|
|
15
|
-
/**
|
|
16
|
-
* Prepare audio from any source (data URL, HTTP URL, local file) as base64 + format.
|
|
17
|
-
* OpenRouter requires audio to be base64-encoded; direct URLs are NOT supported.
|
|
18
|
-
*/
|
|
15
|
+
/** Prepare audio from data URL, HTTP URL, or sandboxed local file. */
|
|
19
16
|
export declare function prepareAudioData(source: string): Promise<AudioData>;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*/
|
|
5
|
-
import path from 'path';
|
|
6
|
-
import { promises as fs } from 'fs';
|
|
1
|
+
/** Audio format detection and fetch utilities. */
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { promises as fs } from 'node:fs';
|
|
7
4
|
import { readEnvInt, fetchHttpResource, parseBase64DataUrl } from './fetch-utils.js';
|
|
8
5
|
import { resolveSafeInputPath } from './path-safety.js';
|
|
9
6
|
// Re-export for tests
|
|
@@ -83,12 +80,8 @@ function formatFromContentType(ct) {
|
|
|
83
80
|
return undefined;
|
|
84
81
|
return mimeSubtypeToFormat(mime.slice(6));
|
|
85
82
|
}
|
|
86
|
-
/**
|
|
87
|
-
* Prepare audio from any source (data URL, HTTP URL, local file) as base64 + format.
|
|
88
|
-
* OpenRouter requires audio to be base64-encoded; direct URLs are NOT supported.
|
|
89
|
-
*/
|
|
83
|
+
/** Prepare audio from data URL, HTTP URL, or sandboxed local file. */
|
|
90
84
|
export async function prepareAudioData(source) {
|
|
91
|
-
// --- data URL ---
|
|
92
85
|
if (source.startsWith('data:')) {
|
|
93
86
|
const parsed = parseBase64DataUrl(source);
|
|
94
87
|
if (!parsed)
|
|
@@ -102,14 +95,12 @@ export async function prepareAudioData(source) {
|
|
|
102
95
|
throw new Error('Data URL too large');
|
|
103
96
|
return { data: parsed.base64, format };
|
|
104
97
|
}
|
|
105
|
-
// --- HTTP(S) URL ---
|
|
106
98
|
if (source.startsWith('http://') || source.startsWith('https://')) {
|
|
107
99
|
const { buffer, contentType } = await fetchHttpResource(source, {
|
|
108
100
|
timeoutMs: getFetchTimeoutMs(),
|
|
109
101
|
maxBytes: getMaxDownloadBytes(),
|
|
110
102
|
maxRedirects: getMaxRedirects(),
|
|
111
103
|
});
|
|
112
|
-
// Try URL path extension first, fall back to Content-Type header
|
|
113
104
|
const urlPath = new URL(source).pathname;
|
|
114
105
|
const format = getAudioFormat(urlPath) ?? formatFromContentType(contentType);
|
|
115
106
|
if (!format) {
|
|
@@ -117,7 +108,6 @@ export async function prepareAudioData(source) {
|
|
|
117
108
|
}
|
|
118
109
|
return { data: buffer.toString('base64'), format };
|
|
119
110
|
}
|
|
120
|
-
// --- local file ---
|
|
121
111
|
const safe = await resolveSafeInputPath(source);
|
|
122
112
|
const format = getAudioFormat(safe);
|
|
123
113
|
if (!format) {
|
|
@@ -1,15 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Response caching helpers for OpenRouter's X-OpenRouter-Cache header family.
|
|
3
|
-
* See https://openrouter.ai/docs/guides/features/response-caching
|
|
4
|
-
*
|
|
5
|
-
* Three caller inputs:
|
|
6
|
-
* - cache: enable caching for this request
|
|
7
|
-
* - cache_ttl: TTL string (1s .. 24h), e.g. "5m", "1h"; pass-through
|
|
8
|
-
* - cache_clear: bust the cache entry for this request
|
|
9
|
-
*
|
|
10
|
-
* Server-wide default: OPENROUTER_CACHE_RESPONSES=1 enables cache on every
|
|
11
|
-
* request unless the caller explicitly passes cache=false.
|
|
12
|
-
*/
|
|
1
|
+
/** OpenRouter response caching via X-OpenRouter-Cache headers. */
|
|
13
2
|
export interface CacheOptions {
|
|
14
3
|
cache?: boolean;
|
|
15
4
|
cache_ttl?: string;
|
|
@@ -17,12 +6,7 @@ export interface CacheOptions {
|
|
|
17
6
|
}
|
|
18
7
|
/** Parse the env-default and return `true` when caching should be on by default. */
|
|
19
8
|
export declare function readCacheDefault(): boolean;
|
|
20
|
-
/**
|
|
21
|
-
* Build the headers object to pass as the second argument to
|
|
22
|
-
* `openai.chat.completions.create(body, { headers })`. Returns an empty
|
|
23
|
-
* object when nothing should be sent, so the caller can always spread the
|
|
24
|
-
* result without a conditional.
|
|
25
|
-
*/
|
|
9
|
+
/** Build X-OpenRouter-Cache headers for chat/analyze requests. */
|
|
26
10
|
export declare function buildCacheHeaders(opts: CacheOptions | undefined): Record<string, string>;
|
|
27
11
|
/** Extract cache metadata from response headers, null when not present. */
|
|
28
12
|
export interface CacheMeta {
|
|
@@ -1,30 +1,12 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Response caching helpers for OpenRouter's X-OpenRouter-Cache header family.
|
|
3
|
-
* See https://openrouter.ai/docs/guides/features/response-caching
|
|
4
|
-
*
|
|
5
|
-
* Three caller inputs:
|
|
6
|
-
* - cache: enable caching for this request
|
|
7
|
-
* - cache_ttl: TTL string (1s .. 24h), e.g. "5m", "1h"; pass-through
|
|
8
|
-
* - cache_clear: bust the cache entry for this request
|
|
9
|
-
*
|
|
10
|
-
* Server-wide default: OPENROUTER_CACHE_RESPONSES=1 enables cache on every
|
|
11
|
-
* request unless the caller explicitly passes cache=false.
|
|
12
|
-
*/
|
|
13
1
|
/** Parse the env-default and return `true` when caching should be on by default. */
|
|
14
2
|
export function readCacheDefault() {
|
|
15
3
|
const raw = (process.env.OPENROUTER_CACHE_RESPONSES ?? '').trim().toLowerCase();
|
|
16
4
|
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
17
5
|
}
|
|
18
|
-
/**
|
|
19
|
-
* Build the headers object to pass as the second argument to
|
|
20
|
-
* `openai.chat.completions.create(body, { headers })`. Returns an empty
|
|
21
|
-
* object when nothing should be sent, so the caller can always spread the
|
|
22
|
-
* result without a conditional.
|
|
23
|
-
*/
|
|
6
|
+
/** Build X-OpenRouter-Cache headers for chat/analyze requests. */
|
|
24
7
|
export function buildCacheHeaders(opts) {
|
|
25
8
|
const headers = {};
|
|
26
9
|
const defaultOn = readCacheDefault();
|
|
27
|
-
// Caller-explicit `cache` wins. If unset, fall back to env default.
|
|
28
10
|
const enabled = opts?.cache ?? defaultOn;
|
|
29
11
|
if (enabled)
|
|
30
12
|
headers['X-OpenRouter-Cache'] = 'true';
|
|
@@ -1,34 +1,6 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
|
-
import type
|
|
3
|
-
|
|
4
|
-
import { type CacheOptions } from './cache.js';
|
|
5
|
-
export interface ChatCompletionToolRequest extends CacheOptions {
|
|
6
|
-
model?: string;
|
|
7
|
-
messages: ChatCompletionMessageParam[];
|
|
8
|
-
temperature?: number;
|
|
9
|
-
max_tokens?: number;
|
|
10
|
-
/**
|
|
11
|
-
* OpenRouter provider routing overrides. Merges on top of the
|
|
12
|
-
* `OPENROUTER_PROVIDER_*` env-var defaults. See
|
|
13
|
-
* https://openrouter.ai/docs/features/provider-routing
|
|
14
|
-
*/
|
|
15
|
-
provider?: ProviderRoutingOptions;
|
|
16
|
-
/**
|
|
17
|
-
* Surface the model's chain-of-thought trace on `_meta.reasoning` when
|
|
18
|
-
* the upstream response carries one (DeepSeek R1, Gemini Thinking,
|
|
19
|
-
* Claude Opus 4.7). Defaults to `false` or the value of
|
|
20
|
-
* `OPENROUTER_INCLUDE_REASONING`.
|
|
21
|
-
*/
|
|
22
|
-
include_reasoning?: boolean;
|
|
23
|
-
/**
|
|
24
|
-
* Enable OpenRouter's web-search plugin (Exa-backed). When true, the
|
|
25
|
-
* plugin fetches current web results and merges them into the prompt.
|
|
26
|
-
* Billed at $4 / 1000 results.
|
|
27
|
-
*/
|
|
28
|
-
online?: boolean;
|
|
29
|
-
/** Max web-search results when `online: true`. Default 5. */
|
|
30
|
-
web_max_results?: number;
|
|
31
|
-
}
|
|
2
|
+
import { type ChatToolRequest } from './chat-request.js';
|
|
3
|
+
export type ChatCompletionToolRequest = ChatToolRequest;
|
|
32
4
|
export declare function handleChatCompletion(request: {
|
|
33
5
|
params: {
|
|
34
6
|
arguments: ChatCompletionToolRequest;
|
|
@@ -2,51 +2,31 @@ import { ErrorCode, toolError } from '../errors.js';
|
|
|
2
2
|
import { SERVER_VERSION } from '../version.js';
|
|
3
3
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
4
4
|
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
5
|
-
import {
|
|
6
|
-
import { buildCacheHeaders, extractCacheMeta } from './cache.js';
|
|
5
|
+
import { extractCacheMeta } from './cache.js';
|
|
7
6
|
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
8
|
-
|
|
9
|
-
function readIncludeReasoningDefault() {
|
|
10
|
-
const raw = (process.env.OPENROUTER_INCLUDE_REASONING ?? '').trim().toLowerCase();
|
|
11
|
-
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
12
|
-
}
|
|
7
|
+
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, } from './chat-request.js';
|
|
13
8
|
export async function handleChatCompletion(request, openai, defaultModel) {
|
|
14
9
|
const args = request.params.arguments ?? { messages: [] };
|
|
15
10
|
const { messages, model, temperature, max_tokens, provider, include_reasoning, online, web_max_results, cache, cache_ttl, cache_clear, } = args;
|
|
16
11
|
if (!messages?.length) {
|
|
17
12
|
return toolError(ErrorCode.INVALID_INPUT, 'Messages array cannot be empty.');
|
|
18
13
|
}
|
|
19
|
-
const providerOptions = mergeProviderOptions(readProviderDefaults(), provider);
|
|
20
|
-
const providerBody = buildProviderBody(providerOptions);
|
|
21
|
-
const effectiveMaxTokens = resolveMaxTokens(max_tokens);
|
|
22
14
|
const wantsReasoning = include_reasoning ?? readIncludeReasoningDefault();
|
|
23
|
-
|
|
24
|
-
// OpenAI SDK types, so we assemble as `Record<string, unknown>` and cast
|
|
25
|
-
// at the call site.
|
|
26
|
-
const body = {
|
|
27
|
-
model: model || defaultModel || DEFAULT_MODEL,
|
|
15
|
+
const body = buildChatCompletionBody({
|
|
28
16
|
messages,
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const plugin = { id: 'web' };
|
|
39
|
-
if (typeof web_max_results === 'number' && web_max_results > 0) {
|
|
40
|
-
plugin.max_results = web_max_results;
|
|
41
|
-
}
|
|
42
|
-
body.plugins = [plugin];
|
|
43
|
-
}
|
|
44
|
-
const headers = buildCacheHeaders({ cache, cache_ttl, cache_clear });
|
|
45
|
-
const requestOpts = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
17
|
+
model: model || defaultModel || DEFAULT_CHAT_MODEL,
|
|
18
|
+
temperature,
|
|
19
|
+
max_tokens,
|
|
20
|
+
provider,
|
|
21
|
+
include_reasoning,
|
|
22
|
+
online,
|
|
23
|
+
web_max_results,
|
|
24
|
+
});
|
|
25
|
+
const requestOpts = buildChatCompletionRequestOpts({ cache, cache_ttl, cache_clear });
|
|
46
26
|
let completion;
|
|
47
27
|
let responseHeaders;
|
|
48
28
|
try {
|
|
49
|
-
const call = openai.chat.completions.create(body, requestOpts);
|
|
29
|
+
const call = openai.chat.completions.create(asOpenAIChatBody(body), requestOpts);
|
|
50
30
|
const { data, response } = await awaitCompletionWithHeaders(call);
|
|
51
31
|
completion = data;
|
|
52
32
|
responseHeaders = response?.headers;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type OpenAI from 'openai';
|
|
2
|
+
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js';
|
|
3
|
+
import { type ProviderRoutingOptions } from './provider-routing.js';
|
|
4
|
+
import { type CacheOptions } from './cache.js';
|
|
5
|
+
export declare const DEFAULT_CHAT_MODEL = "nvidia/nemotron-nano-12b-v2-vl:free";
|
|
6
|
+
/** Shared request shape for sync and async chat completion tools. */
|
|
7
|
+
export interface ChatToolRequest extends CacheOptions {
|
|
8
|
+
model?: string;
|
|
9
|
+
messages: ChatCompletionMessageParam[];
|
|
10
|
+
temperature?: number;
|
|
11
|
+
max_tokens?: number;
|
|
12
|
+
provider?: ProviderRoutingOptions;
|
|
13
|
+
include_reasoning?: boolean;
|
|
14
|
+
online?: boolean;
|
|
15
|
+
web_max_results?: number;
|
|
16
|
+
}
|
|
17
|
+
export declare function readIncludeReasoningDefault(): boolean;
|
|
18
|
+
export declare function buildChatCompletionBody(input: ChatToolRequest & {
|
|
19
|
+
model: string;
|
|
20
|
+
}): Record<string, unknown>;
|
|
21
|
+
export declare function buildChatCompletionRequestOpts(cache: CacheOptions): {
|
|
22
|
+
headers: Record<string, string>;
|
|
23
|
+
} | undefined;
|
|
24
|
+
export type OpenAIChatCreateBody = Parameters<OpenAI['chat']['completions']['create']>[0];
|
|
25
|
+
export declare function asOpenAIChatBody(body: Record<string, unknown>): OpenAIChatCreateBody;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readProviderDefaults, mergeProviderOptions, buildProviderBody, resolveMaxTokens, } from './provider-routing.js';
|
|
2
|
+
import { buildCacheHeaders } from './cache.js';
|
|
3
|
+
export const DEFAULT_CHAT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
4
|
+
export function readIncludeReasoningDefault() {
|
|
5
|
+
const raw = (process.env.OPENROUTER_INCLUDE_REASONING ?? '').trim().toLowerCase();
|
|
6
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
7
|
+
}
|
|
8
|
+
export function buildChatCompletionBody(input) {
|
|
9
|
+
const providerBody = buildProviderBody(mergeProviderOptions(readProviderDefaults(), input.provider));
|
|
10
|
+
const effectiveMaxTokens = resolveMaxTokens(input.max_tokens);
|
|
11
|
+
const wantsReasoning = input.include_reasoning ?? readIncludeReasoningDefault();
|
|
12
|
+
const body = {
|
|
13
|
+
model: input.model,
|
|
14
|
+
messages: input.messages,
|
|
15
|
+
temperature: input.temperature ?? 1,
|
|
16
|
+
};
|
|
17
|
+
if (typeof effectiveMaxTokens === 'number')
|
|
18
|
+
body.max_tokens = effectiveMaxTokens;
|
|
19
|
+
if (providerBody)
|
|
20
|
+
body.provider = providerBody;
|
|
21
|
+
if (wantsReasoning)
|
|
22
|
+
body.include_reasoning = true;
|
|
23
|
+
if (input.online) {
|
|
24
|
+
const plugin = { id: 'web' };
|
|
25
|
+
if (typeof input.web_max_results === 'number' && input.web_max_results > 0) {
|
|
26
|
+
plugin.max_results = input.web_max_results;
|
|
27
|
+
}
|
|
28
|
+
body.plugins = [plugin];
|
|
29
|
+
}
|
|
30
|
+
return body;
|
|
31
|
+
}
|
|
32
|
+
export function buildChatCompletionRequestOpts(cache) {
|
|
33
|
+
const headers = buildCacheHeaders(cache);
|
|
34
|
+
return Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
35
|
+
}
|
|
36
|
+
export function asOpenAIChatBody(body) {
|
|
37
|
+
return body;
|
|
38
|
+
}
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* return the assistant's message as text. Handles:
|
|
4
|
-
* - plain string content (the common case)
|
|
5
|
-
* - multimodal array content (concatenate text parts)
|
|
6
|
-
* - reasoning-only responses (`content: null` + `reasoning`/`reasoning_details`)
|
|
7
|
-
* - `finish_reason === 'length'` — warn the caller so they know to raise
|
|
8
|
-
* `max_tokens` instead of silently getting nothing back.
|
|
2
|
+
* Helpers for chat completion responses — text extraction, reasoning, finish reasons.
|
|
9
3
|
*/
|
|
10
4
|
import type { ChatCompletion } from 'openai/resources/chat/completions.js';
|
|
11
5
|
import { type ToolErrorResult } from '../errors.js';
|
|
@@ -14,18 +8,7 @@ export interface ExtractedText {
|
|
|
14
8
|
/** True when `text` came from the reasoning trace (not a final answer). */
|
|
15
9
|
reasonedOnly: boolean;
|
|
16
10
|
finishReason: ChatCompletion.Choice['finish_reason'] | undefined;
|
|
17
|
-
/**
|
|
18
|
-
* OpenRouter's `native_finish_reason`, when present. Carries the
|
|
19
|
-
* provider-native value before OpenRouter normalizes it. Surfaced in
|
|
20
|
-
* `_meta.native_finish_reason` for debuggability.
|
|
21
|
-
*/
|
|
22
11
|
nativeFinishReason: string | undefined;
|
|
23
|
-
/**
|
|
24
|
-
* Raw reasoning trace (content of `reasoning` or joined `reasoning_details`).
|
|
25
|
-
* Populated whenever the upstream response carried one, even when the
|
|
26
|
-
* assistant also produced a final `content` answer. Surfaced to callers
|
|
27
|
-
* via `_meta.reasoning` when they opt in with `include_reasoning: true`.
|
|
28
|
-
*/
|
|
29
12
|
reasoning?: string;
|
|
30
13
|
usage?: ChatCompletion['usage'];
|
|
31
14
|
}
|
|
@@ -16,8 +16,6 @@ export function extractCompletionText(completion) {
|
|
|
16
16
|
const choice = completion.choices?.[0];
|
|
17
17
|
const msg = choice?.message;
|
|
18
18
|
const finishReason = choice?.finish_reason;
|
|
19
|
-
// `native_finish_reason` is an OpenRouter extension, not in the OpenAI
|
|
20
|
-
// SDK types — read it via an unknown-cast.
|
|
21
19
|
const nativeFinishReason = choice?.native_finish_reason ?? undefined;
|
|
22
20
|
const usage = completion.usage ?? undefined;
|
|
23
21
|
if (!msg) {
|