@stabgan/openrouter-mcp-multimodal 4.0.0 → 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/index.js +1 -1
- 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 +5 -3
- package/dist/tool-handlers/generate-image.js +20 -34
- package/dist/tool-handlers/generate-video.d.ts +43 -0
- package/dist/tool-handlers/generate-video.js +61 -6
- 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/path-safety.d.ts +11 -0
- package/dist/tool-handlers/path-safety.js +54 -0
- package/dist/tool-handlers/provider-routing.js +6 -2
- 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
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { prepareVideoData } from './video-utils.js';
|
|
2
2
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
3
|
+
import { SERVER_VERSION } from '../version.js';
|
|
3
4
|
import { logger } from '../logger.js';
|
|
4
5
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
5
|
-
import { extractCompletionText, detectReasoningCutoff,
|
|
6
|
+
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
7
|
+
import { buildCacheHeaders, extractCacheMeta, } from './cache.js';
|
|
8
|
+
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
6
9
|
/**
|
|
7
10
|
* Default model — `google/gemini-2.5-flash` has the widest video-input
|
|
8
11
|
* support on OpenRouter at time of writing. Override via env
|
|
@@ -10,9 +13,8 @@ import { extractCompletionText, detectReasoningCutoff, toUsageMeta, } from './co
|
|
|
10
13
|
*/
|
|
11
14
|
const FALLBACK_DEFAULT_MODEL = 'google/gemini-2.5-flash';
|
|
12
15
|
export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
};
|
|
16
|
+
const args = request.params.arguments ?? { video_path: '' };
|
|
17
|
+
const { video_path, question, model, cache_input, cache, cache_ttl, cache_clear } = args;
|
|
16
18
|
if (!video_path) {
|
|
17
19
|
return toolError(ErrorCode.INVALID_INPUT, 'video_path is required.');
|
|
18
20
|
}
|
|
@@ -37,14 +39,25 @@ export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
|
37
39
|
}
|
|
38
40
|
return toolErrorFrom(ErrorCode.INVALID_INPUT, err);
|
|
39
41
|
}
|
|
42
|
+
const videoBlock = {
|
|
43
|
+
// The `video_url` content type is an OpenRouter extension; the OpenAI
|
|
44
|
+
// SDK's typings don't know about it yet.
|
|
45
|
+
type: 'video_url',
|
|
46
|
+
video_url: { url: `data:${videoData.mediaType};base64,${videoData.data}` },
|
|
47
|
+
};
|
|
48
|
+
if (cache_input)
|
|
49
|
+
videoBlock.cache_control = { type: 'ephemeral' };
|
|
50
|
+
const headers = buildCacheHeaders({ cache, cache_ttl, cache_clear });
|
|
51
|
+
const requestOpts = Object.keys(headers).length > 0 ? { headers } : undefined;
|
|
40
52
|
let completion;
|
|
53
|
+
let responseHeaders;
|
|
41
54
|
try {
|
|
42
55
|
logger.debug('analyze_video.submit', {
|
|
43
56
|
model: pickedModel,
|
|
44
57
|
format: videoData.format,
|
|
45
58
|
size_bytes: videoData.sizeBytes,
|
|
46
59
|
});
|
|
47
|
-
|
|
60
|
+
const call = openai.chat.completions.create({
|
|
48
61
|
model: pickedModel,
|
|
49
62
|
messages: [
|
|
50
63
|
{
|
|
@@ -54,19 +67,14 @@ export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
|
54
67
|
type: 'text',
|
|
55
68
|
text: question || 'Describe what happens in this video, step by step.',
|
|
56
69
|
},
|
|
57
|
-
|
|
58
|
-
// The `video_url` content type is an OpenRouter extension; the
|
|
59
|
-
// OpenAI SDK's typings don't know about it yet. See:
|
|
60
|
-
// https://openrouter.ai/docs/guides/overview/multimodal/videos
|
|
61
|
-
type: 'video_url',
|
|
62
|
-
video_url: {
|
|
63
|
-
url: `data:${videoData.mediaType};base64,${videoData.data}`,
|
|
64
|
-
},
|
|
65
|
-
},
|
|
70
|
+
videoBlock,
|
|
66
71
|
],
|
|
67
72
|
},
|
|
68
73
|
],
|
|
69
|
-
});
|
|
74
|
+
}, requestOpts);
|
|
75
|
+
const { data, response } = await awaitCompletionWithHeaders(call);
|
|
76
|
+
completion = data;
|
|
77
|
+
responseHeaders = response?.headers;
|
|
70
78
|
}
|
|
71
79
|
catch (err) {
|
|
72
80
|
logger.warn('analyze_video.error', {
|
|
@@ -83,11 +91,15 @@ export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
|
83
91
|
finish_reason: extracted.finishReason,
|
|
84
92
|
});
|
|
85
93
|
}
|
|
94
|
+
const cacheMeta = extractCacheMeta(responseHeaders);
|
|
95
|
+
const extra = {
|
|
96
|
+
server_version: SERVER_VERSION,
|
|
97
|
+
content_is_untrusted: true,
|
|
98
|
+
};
|
|
99
|
+
if (cacheMeta)
|
|
100
|
+
extra.cache = cacheMeta;
|
|
86
101
|
return {
|
|
87
102
|
content: [{ type: 'text', text: extracted.text }],
|
|
88
|
-
_meta: {
|
|
89
|
-
finish_reason: extracted.finishReason,
|
|
90
|
-
...(toUsageMeta(extracted.usage) ?? {}),
|
|
91
|
-
},
|
|
103
|
+
_meta: buildCompletionMeta(extracted, { extra }),
|
|
92
104
|
};
|
|
93
105
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
export interface CacheOptions {
|
|
14
|
+
cache?: boolean;
|
|
15
|
+
cache_ttl?: string;
|
|
16
|
+
cache_clear?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/** Parse the env-default and return `true` when caching should be on by default. */
|
|
19
|
+
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
|
+
*/
|
|
26
|
+
export declare function buildCacheHeaders(opts: CacheOptions | undefined): Record<string, string>;
|
|
27
|
+
/** Extract cache metadata from response headers, null when not present. */
|
|
28
|
+
export interface CacheMeta {
|
|
29
|
+
status: 'HIT' | 'MISS' | string;
|
|
30
|
+
age?: number;
|
|
31
|
+
ttl?: string;
|
|
32
|
+
}
|
|
33
|
+
export declare function extractCacheMeta(headers: Headers | undefined): CacheMeta | null;
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
/** Parse the env-default and return `true` when caching should be on by default. */
|
|
14
|
+
export function readCacheDefault() {
|
|
15
|
+
const raw = (process.env.OPENROUTER_CACHE_RESPONSES ?? '').trim().toLowerCase();
|
|
16
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
17
|
+
}
|
|
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
|
+
*/
|
|
24
|
+
export function buildCacheHeaders(opts) {
|
|
25
|
+
const headers = {};
|
|
26
|
+
const defaultOn = readCacheDefault();
|
|
27
|
+
// Caller-explicit `cache` wins. If unset, fall back to env default.
|
|
28
|
+
const enabled = opts?.cache ?? defaultOn;
|
|
29
|
+
if (enabled)
|
|
30
|
+
headers['X-OpenRouter-Cache'] = 'true';
|
|
31
|
+
if (opts?.cache_ttl)
|
|
32
|
+
headers['X-OpenRouter-Cache-TTL'] = opts.cache_ttl;
|
|
33
|
+
if (opts?.cache_clear)
|
|
34
|
+
headers['X-OpenRouter-Cache-Clear'] = 'true';
|
|
35
|
+
return headers;
|
|
36
|
+
}
|
|
37
|
+
export function extractCacheMeta(headers) {
|
|
38
|
+
if (!headers)
|
|
39
|
+
return null;
|
|
40
|
+
const status = headers.get('x-openrouter-cache-status');
|
|
41
|
+
if (!status)
|
|
42
|
+
return null;
|
|
43
|
+
const ageStr = headers.get('x-openrouter-cache-age');
|
|
44
|
+
const ttl = headers.get('x-openrouter-cache-ttl') ?? undefined;
|
|
45
|
+
const meta = { status };
|
|
46
|
+
if (ageStr) {
|
|
47
|
+
const n = Number(ageStr);
|
|
48
|
+
if (Number.isFinite(n))
|
|
49
|
+
meta.age = n;
|
|
50
|
+
}
|
|
51
|
+
if (ttl)
|
|
52
|
+
meta.ttl = ttl;
|
|
53
|
+
return meta;
|
|
54
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import OpenAI from 'openai';
|
|
2
2
|
import type { ChatCompletionMessageParam } from 'openai/resources/chat/completions.js';
|
|
3
3
|
import { type ProviderRoutingOptions } from './provider-routing.js';
|
|
4
|
-
|
|
4
|
+
import { type CacheOptions } from './cache.js';
|
|
5
|
+
export interface ChatCompletionToolRequest extends CacheOptions {
|
|
5
6
|
model?: string;
|
|
6
7
|
messages: ChatCompletionMessageParam[];
|
|
7
8
|
temperature?: number;
|
|
@@ -12,6 +13,21 @@ export interface ChatCompletionToolRequest {
|
|
|
12
13
|
* https://openrouter.ai/docs/features/provider-routing
|
|
13
14
|
*/
|
|
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;
|
|
15
31
|
}
|
|
16
32
|
export declare function handleChatCompletion(request: {
|
|
17
33
|
params: {
|
|
@@ -22,7 +38,5 @@ export declare function handleChatCompletion(request: {
|
|
|
22
38
|
type: "text";
|
|
23
39
|
text: string;
|
|
24
40
|
}[];
|
|
25
|
-
_meta:
|
|
26
|
-
finish_reason: "length" | "stop" | "tool_calls" | "content_filter" | "function_call" | undefined;
|
|
27
|
-
};
|
|
41
|
+
_meta: Record<string, unknown>;
|
|
28
42
|
}>;
|
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
import { SERVER_VERSION } from '../version.js';
|
|
2
3
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
3
|
-
import { extractCompletionText, detectReasoningCutoff,
|
|
4
|
+
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
4
5
|
import { readProviderDefaults, mergeProviderOptions, buildProviderBody, resolveMaxTokens, } from './provider-routing.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';
|
|
9
|
+
function readIncludeReasoningDefault() {
|
|
10
|
+
const raw = (process.env.OPENROUTER_INCLUDE_REASONING ?? '').trim().toLowerCase();
|
|
11
|
+
return raw === '1' || raw === 'true' || raw === 'yes';
|
|
12
|
+
}
|
|
6
13
|
export async function handleChatCompletion(request, openai, defaultModel) {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
};
|
|
14
|
+
const args = request.params.arguments ?? { messages: [] };
|
|
15
|
+
const { messages, model, temperature, max_tokens, provider, include_reasoning, online, web_max_results, cache, cache_ttl, cache_clear, } = args;
|
|
10
16
|
if (!messages?.length) {
|
|
11
17
|
return toolError(ErrorCode.INVALID_INPUT, 'Messages array cannot be empty.');
|
|
12
18
|
}
|
|
13
19
|
const providerOptions = mergeProviderOptions(readProviderDefaults(), provider);
|
|
14
20
|
const providerBody = buildProviderBody(providerOptions);
|
|
15
21
|
const effectiveMaxTokens = resolveMaxTokens(max_tokens);
|
|
16
|
-
|
|
17
|
-
// the
|
|
22
|
+
const wantsReasoning = include_reasoning ?? readIncludeReasoningDefault();
|
|
23
|
+
// Build the request body. Several OpenRouter extensions aren't in the
|
|
24
|
+
// OpenAI SDK types, so we assemble as `Record<string, unknown>` and cast
|
|
25
|
+
// at the call site.
|
|
18
26
|
const body = {
|
|
19
27
|
model: model || defaultModel || DEFAULT_MODEL,
|
|
20
28
|
messages,
|
|
@@ -24,9 +32,24 @@ export async function handleChatCompletion(request, openai, defaultModel) {
|
|
|
24
32
|
body.max_tokens = effectiveMaxTokens;
|
|
25
33
|
if (providerBody)
|
|
26
34
|
body.provider = providerBody;
|
|
35
|
+
if (wantsReasoning)
|
|
36
|
+
body.include_reasoning = true;
|
|
37
|
+
if (online) {
|
|
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;
|
|
27
46
|
let completion;
|
|
47
|
+
let responseHeaders;
|
|
28
48
|
try {
|
|
29
|
-
|
|
49
|
+
const call = openai.chat.completions.create(body, requestOpts);
|
|
50
|
+
const { data, response } = await awaitCompletionWithHeaders(call);
|
|
51
|
+
completion = data;
|
|
52
|
+
responseHeaders = response?.headers;
|
|
30
53
|
}
|
|
31
54
|
catch (err) {
|
|
32
55
|
return classifyUpstreamError(err);
|
|
@@ -38,13 +61,18 @@ export async function handleChatCompletion(request, openai, defaultModel) {
|
|
|
38
61
|
if (!extracted.text) {
|
|
39
62
|
return toolError(ErrorCode.INTERNAL, 'Model returned no textual content.', {
|
|
40
63
|
finish_reason: extracted.finishReason,
|
|
64
|
+
native_finish_reason: extracted.nativeFinishReason,
|
|
41
65
|
});
|
|
42
66
|
}
|
|
67
|
+
const cacheMeta = extractCacheMeta(responseHeaders);
|
|
68
|
+
const extra = { server_version: SERVER_VERSION };
|
|
69
|
+
if (cacheMeta)
|
|
70
|
+
extra.cache = cacheMeta;
|
|
43
71
|
return {
|
|
44
72
|
content: [{ type: 'text', text: extracted.text }],
|
|
45
|
-
_meta: {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
},
|
|
73
|
+
_meta: buildCompletionMeta(extracted, {
|
|
74
|
+
includeReasoning: wantsReasoning,
|
|
75
|
+
extra,
|
|
76
|
+
}),
|
|
49
77
|
};
|
|
50
78
|
}
|
|
@@ -14,6 +14,19 @@ export interface ExtractedText {
|
|
|
14
14
|
/** True when `text` came from the reasoning trace (not a final answer). */
|
|
15
15
|
reasonedOnly: boolean;
|
|
16
16
|
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
|
+
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
|
+
reasoning?: string;
|
|
17
30
|
usage?: ChatCompletion['usage'];
|
|
18
31
|
}
|
|
19
32
|
export declare function extractCompletionText(completion: ChatCompletion): ExtractedText;
|
|
@@ -25,3 +38,19 @@ export declare function extractCompletionText(completion: ChatCompletion): Extra
|
|
|
25
38
|
*/
|
|
26
39
|
export declare function detectReasoningCutoff(extracted: ExtractedText): ToolErrorResult | null;
|
|
27
40
|
export declare function toUsageMeta(usage: ChatCompletion['usage'] | undefined): Record<string, unknown> | undefined;
|
|
41
|
+
/**
|
|
42
|
+
* Build the common `_meta` shape for chat-completion-derived tools.
|
|
43
|
+
* Folds in:
|
|
44
|
+
* - normalized and native finish reasons (from the choice)
|
|
45
|
+
* - optional `reasoning` trace (when the caller opted in)
|
|
46
|
+
* - token usage (prompt / completion / total)
|
|
47
|
+
* - server version stamp
|
|
48
|
+
*
|
|
49
|
+
* Caller can pass `extra` to merge additional keys (cache metadata,
|
|
50
|
+
* content_is_untrusted, etc.) without repeating this boilerplate.
|
|
51
|
+
*/
|
|
52
|
+
export interface BuildMetaOptions {
|
|
53
|
+
includeReasoning?: boolean;
|
|
54
|
+
extra?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
export declare function buildCompletionMeta(extracted: ExtractedText, opts?: BuildMetaOptions): Record<string, unknown>;
|
|
@@ -1,14 +1,45 @@
|
|
|
1
1
|
import { ErrorCode, toolError } from '../errors.js';
|
|
2
|
+
function extractReasoning(msg) {
|
|
3
|
+
if (typeof msg.reasoning === 'string' && msg.reasoning.length > 0)
|
|
4
|
+
return msg.reasoning;
|
|
5
|
+
if (Array.isArray(msg.reasoning_details) && msg.reasoning_details.length > 0) {
|
|
6
|
+
const joined = msg.reasoning_details
|
|
7
|
+
.filter((d) => typeof d.text === 'string')
|
|
8
|
+
.map((d) => d.text)
|
|
9
|
+
.join('\n');
|
|
10
|
+
if (joined.length > 0)
|
|
11
|
+
return joined;
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
2
15
|
export function extractCompletionText(completion) {
|
|
3
16
|
const choice = completion.choices?.[0];
|
|
4
17
|
const msg = choice?.message;
|
|
5
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
|
+
const nativeFinishReason = choice?.native_finish_reason ?? undefined;
|
|
6
22
|
const usage = completion.usage ?? undefined;
|
|
7
|
-
if (!msg)
|
|
8
|
-
return {
|
|
9
|
-
|
|
23
|
+
if (!msg) {
|
|
24
|
+
return {
|
|
25
|
+
text: '',
|
|
26
|
+
reasonedOnly: false,
|
|
27
|
+
finishReason,
|
|
28
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
29
|
+
usage,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const { content } = msg;
|
|
33
|
+
const reasoning = extractReasoning(msg);
|
|
10
34
|
if (typeof content === 'string' && content.length > 0) {
|
|
11
|
-
return {
|
|
35
|
+
return {
|
|
36
|
+
text: content,
|
|
37
|
+
reasonedOnly: false,
|
|
38
|
+
finishReason,
|
|
39
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
40
|
+
reasoning,
|
|
41
|
+
usage,
|
|
42
|
+
};
|
|
12
43
|
}
|
|
13
44
|
if (Array.isArray(content)) {
|
|
14
45
|
const parts = content
|
|
@@ -16,22 +47,33 @@ export function extractCompletionText(completion) {
|
|
|
16
47
|
.map((p) => p.text ?? '');
|
|
17
48
|
const joined = parts.join('');
|
|
18
49
|
if (joined.length > 0) {
|
|
19
|
-
return {
|
|
50
|
+
return {
|
|
51
|
+
text: joined,
|
|
52
|
+
reasonedOnly: false,
|
|
53
|
+
finishReason,
|
|
54
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
55
|
+
reasoning,
|
|
56
|
+
usage,
|
|
57
|
+
};
|
|
20
58
|
}
|
|
21
59
|
}
|
|
22
|
-
if (
|
|
23
|
-
return {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return { text: joined, reasonedOnly: true, finishReason, usage };
|
|
32
|
-
}
|
|
60
|
+
if (reasoning && reasoning.length > 0) {
|
|
61
|
+
return {
|
|
62
|
+
text: reasoning,
|
|
63
|
+
reasonedOnly: true,
|
|
64
|
+
finishReason,
|
|
65
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
66
|
+
reasoning,
|
|
67
|
+
usage,
|
|
68
|
+
};
|
|
33
69
|
}
|
|
34
|
-
return {
|
|
70
|
+
return {
|
|
71
|
+
text: '',
|
|
72
|
+
reasonedOnly: false,
|
|
73
|
+
finishReason,
|
|
74
|
+
nativeFinishReason: nativeFinishReason ?? undefined,
|
|
75
|
+
usage,
|
|
76
|
+
};
|
|
35
77
|
}
|
|
36
78
|
/**
|
|
37
79
|
* If the extracted response is reasoning-only and was cut off by
|
|
@@ -67,3 +109,20 @@ export function toUsageMeta(usage) {
|
|
|
67
109
|
},
|
|
68
110
|
};
|
|
69
111
|
}
|
|
112
|
+
export function buildCompletionMeta(extracted, opts = {}) {
|
|
113
|
+
const meta = {
|
|
114
|
+
finish_reason: extracted.finishReason,
|
|
115
|
+
};
|
|
116
|
+
if (extracted.nativeFinishReason) {
|
|
117
|
+
meta.native_finish_reason = extracted.nativeFinishReason;
|
|
118
|
+
}
|
|
119
|
+
if (opts.includeReasoning && extracted.reasoning && !extracted.reasonedOnly) {
|
|
120
|
+
meta.reasoning = extracted.reasoning;
|
|
121
|
+
}
|
|
122
|
+
const usageMeta = toUsageMeta(extracted.usage);
|
|
123
|
+
if (usageMeta)
|
|
124
|
+
Object.assign(meta, usageMeta);
|
|
125
|
+
if (opts.extra)
|
|
126
|
+
Object.assign(meta, opts.extra);
|
|
127
|
+
return meta;
|
|
128
|
+
}
|
|
@@ -41,6 +41,7 @@ export declare function handleGenerateAudio(request: {
|
|
|
41
41
|
text?: undefined;
|
|
42
42
|
})[];
|
|
43
43
|
_meta: {
|
|
44
|
+
server_version: string;
|
|
44
45
|
save_path: string;
|
|
45
46
|
mime: string;
|
|
46
47
|
size_bytes: number;
|
|
@@ -58,6 +59,7 @@ export declare function handleGenerateAudio(request: {
|
|
|
58
59
|
text?: undefined;
|
|
59
60
|
})[];
|
|
60
61
|
_meta: {
|
|
62
|
+
server_version: string;
|
|
61
63
|
mime: string;
|
|
62
64
|
size_bytes: number;
|
|
63
65
|
save_path?: undefined;
|
|
@@ -2,6 +2,8 @@ import { promises as fs } from 'fs';
|
|
|
2
2
|
import { extname } from 'path';
|
|
3
3
|
import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
4
4
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
5
|
+
import { SERVER_VERSION } from '../version.js';
|
|
6
|
+
import { logger } from '../logger.js';
|
|
5
7
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
6
8
|
const DEFAULT_MODEL = 'openai/gpt-audio';
|
|
7
9
|
const DEFAULT_VOICE = 'alloy';
|
|
@@ -89,6 +91,16 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
89
91
|
if (!prompt?.trim()) {
|
|
90
92
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
91
93
|
}
|
|
94
|
+
// Audit entry. See generate_image for rationale.
|
|
95
|
+
logger.audit('generate_audio.start', {
|
|
96
|
+
model: model || DEFAULT_MODEL,
|
|
97
|
+
voice: voice?.trim() || DEFAULT_VOICE,
|
|
98
|
+
format: VALID_FORMATS.includes(format ?? '')
|
|
99
|
+
? format
|
|
100
|
+
: DEFAULT_FORMAT,
|
|
101
|
+
prompt_preview: prompt.slice(0, 80),
|
|
102
|
+
save_path: save_path ? 'provided' : 'none',
|
|
103
|
+
});
|
|
92
104
|
// Fail-fast on unsafe paths BEFORE spending tokens.
|
|
93
105
|
let safeBase = null;
|
|
94
106
|
if (save_path) {
|
|
@@ -165,6 +177,7 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
165
177
|
{ type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
|
|
166
178
|
],
|
|
167
179
|
_meta: {
|
|
180
|
+
server_version: SERVER_VERSION,
|
|
168
181
|
save_path: actualSavePath,
|
|
169
182
|
mime: detected.mimeType,
|
|
170
183
|
size_bytes: audioBuffer.length,
|
|
@@ -176,7 +189,11 @@ export async function handleGenerateAudio(request, openai) {
|
|
|
176
189
|
{ type: 'text', text: transcript || 'Audio generated successfully.' },
|
|
177
190
|
{ type: 'audio', mimeType: detected.mimeType, data: returnBase64 },
|
|
178
191
|
],
|
|
179
|
-
_meta: {
|
|
192
|
+
_meta: {
|
|
193
|
+
server_version: SERVER_VERSION,
|
|
194
|
+
mime: detected.mimeType,
|
|
195
|
+
size_bytes: audioBuffer.length,
|
|
196
|
+
},
|
|
180
197
|
};
|
|
181
198
|
}
|
|
182
199
|
catch (err) {
|
|
@@ -69,6 +69,7 @@ export declare function handleGenerateImage(request: {
|
|
|
69
69
|
completion_tokens: number;
|
|
70
70
|
total_tokens: number;
|
|
71
71
|
} | undefined;
|
|
72
|
+
server_version: string;
|
|
72
73
|
save_path: string;
|
|
73
74
|
mime: string;
|
|
74
75
|
};
|
|
@@ -84,14 +85,15 @@ export declare function handleGenerateImage(request: {
|
|
|
84
85
|
completion_tokens: number;
|
|
85
86
|
total_tokens: number;
|
|
86
87
|
} | undefined;
|
|
88
|
+
server_version: string;
|
|
87
89
|
mime: string;
|
|
88
90
|
};
|
|
89
91
|
}>;
|
|
90
92
|
/**
|
|
91
93
|
* Resolve a caller-supplied input image into a URL the chat-completions
|
|
92
|
-
* API accepts. Local file paths are sandboxed
|
|
93
|
-
*
|
|
94
|
-
* as base64 data URLs.
|
|
94
|
+
* API accepts. Local file paths are sandboxed via
|
|
95
|
+
* `resolveSafeInputPath` (`OPENROUTER_INPUT_DIR` /
|
|
96
|
+
* `OPENROUTER_OUTPUT_DIR` / cwd) and inlined as base64 data URLs.
|
|
95
97
|
*/
|
|
96
98
|
export declare function resolveInputImage(ref: string): Promise<string>;
|
|
97
99
|
export declare function mimeFromExt(ext: string): string | null;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { promises as fs } from 'fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { resolveSafeOutputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
3
|
+
import { resolveSafeOutputPath, resolveSafeInputPath, UnsafeOutputPathError } from './path-safety.js';
|
|
4
4
|
import { parseBase64DataUrl } from './fetch-utils.js';
|
|
5
5
|
import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
6
|
+
import { SERVER_VERSION } from '../version.js';
|
|
7
|
+
import { logger } from '../logger.js';
|
|
6
8
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
7
9
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash-image';
|
|
8
10
|
// OpenRouter-documented aspect ratios (standard + extended). Extended are
|
|
@@ -30,6 +32,17 @@ export async function handleGenerateImage(request, openai) {
|
|
|
30
32
|
if (!prompt?.trim()) {
|
|
31
33
|
return toolError(ErrorCode.INVALID_INPUT, 'prompt is required.');
|
|
32
34
|
}
|
|
35
|
+
// Audit entry. Bypasses the normal log level so operators always see a
|
|
36
|
+
// record of cost-incurring operations. Prompt preview is hard-capped
|
|
37
|
+
// at 80 chars to avoid PII spillage in log aggregators.
|
|
38
|
+
logger.audit('generate_image.start', {
|
|
39
|
+
model: model || DEFAULT_MODEL,
|
|
40
|
+
prompt_preview: prompt.slice(0, 80),
|
|
41
|
+
aspect_ratio,
|
|
42
|
+
image_size,
|
|
43
|
+
save_path: save_path ? 'provided' : 'none',
|
|
44
|
+
input_images_count: input_images?.length ?? 0,
|
|
45
|
+
});
|
|
33
46
|
// Validate optional shape fields early so callers get a clear error
|
|
34
47
|
// instead of a cryptic upstream 400.
|
|
35
48
|
if (aspect_ratio !== undefined && !VALID_ASPECT_RATIOS.has(aspect_ratio)) {
|
|
@@ -126,6 +139,7 @@ export async function handleGenerateImage(request, openai) {
|
|
|
126
139
|
{ type: 'image', mimeType: base64.mime, data: base64.data },
|
|
127
140
|
],
|
|
128
141
|
_meta: {
|
|
142
|
+
server_version: SERVER_VERSION,
|
|
129
143
|
save_path: safePathResolved,
|
|
130
144
|
mime: base64.mime,
|
|
131
145
|
...(usage
|
|
@@ -144,6 +158,7 @@ export async function handleGenerateImage(request, openai) {
|
|
|
144
158
|
return {
|
|
145
159
|
content: [{ type: 'image', mimeType: base64.mime, data: base64.data }],
|
|
146
160
|
_meta: {
|
|
161
|
+
server_version: SERVER_VERSION,
|
|
147
162
|
mime: base64.mime,
|
|
148
163
|
...(usage
|
|
149
164
|
? {
|
|
@@ -159,9 +174,9 @@ export async function handleGenerateImage(request, openai) {
|
|
|
159
174
|
}
|
|
160
175
|
/**
|
|
161
176
|
* Resolve a caller-supplied input image into a URL the chat-completions
|
|
162
|
-
* API accepts. Local file paths are sandboxed
|
|
163
|
-
*
|
|
164
|
-
* as base64 data URLs.
|
|
177
|
+
* API accepts. Local file paths are sandboxed via
|
|
178
|
+
* `resolveSafeInputPath` (`OPENROUTER_INPUT_DIR` /
|
|
179
|
+
* `OPENROUTER_OUTPUT_DIR` / cwd) and inlined as base64 data URLs.
|
|
165
180
|
*/
|
|
166
181
|
export async function resolveInputImage(ref) {
|
|
167
182
|
const trimmed = ref.trim();
|
|
@@ -171,36 +186,7 @@ export async function resolveInputImage(ref) {
|
|
|
171
186
|
return trimmed;
|
|
172
187
|
if (/^https?:\/\//i.test(trimmed))
|
|
173
188
|
return trimmed;
|
|
174
|
-
const
|
|
175
|
-
const unsafe = process.env.OPENROUTER_ALLOW_UNSAFE_PATHS === '1' ||
|
|
176
|
-
process.env.OPENROUTER_ALLOW_UNSAFE_PATHS?.toLowerCase() === 'true';
|
|
177
|
-
// Realpath the root first so absolute paths the caller already gave in
|
|
178
|
-
// canonical form (e.g. /private/var/...) and paths we resolve against
|
|
179
|
-
// the root (which may go through /var/... symlinks on macOS) live in
|
|
180
|
-
// the same namespace for the prefix check below.
|
|
181
|
-
const rootReal = await fs.realpath(root).catch(() => root);
|
|
182
|
-
const abs = path.isAbsolute(trimmed)
|
|
183
|
-
? path.resolve(trimmed)
|
|
184
|
-
: path.resolve(rootReal, trimmed);
|
|
185
|
-
if (!unsafe) {
|
|
186
|
-
const withSep = rootReal.endsWith(path.sep) ? rootReal : rootReal + path.sep;
|
|
187
|
-
// Prefer realpath for the prefix check so callers can pass paths
|
|
188
|
-
// through symlinks (e.g. macOS `/var/...` → `/private/var/...`)
|
|
189
|
-
// without us rejecting them. If the file doesn't exist yet, fall
|
|
190
|
-
// back to a textual check on the resolved path so traversal
|
|
191
|
-
// (`../escape.png`) is still rejected with the right error type
|
|
192
|
-
// instead of leaking an ENOENT to the caller.
|
|
193
|
-
let canonical;
|
|
194
|
-
try {
|
|
195
|
-
canonical = await fs.realpath(abs);
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
canonical = abs;
|
|
199
|
-
}
|
|
200
|
-
if (!(canonical === rootReal || canonical.startsWith(withSep))) {
|
|
201
|
-
throw new UnsafeOutputPathError(`input_images entry resolves outside workspace root (${rootReal}): ${ref}`);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
189
|
+
const abs = await resolveSafeInputPath(trimmed);
|
|
204
190
|
const buf = await fs.readFile(abs);
|
|
205
191
|
const mime = mimeFromExt(path.extname(abs)) || 'image/png';
|
|
206
192
|
return `data:${mime};base64,${buf.toString('base64')}`;
|