@stabgan/openrouter-mcp-multimodal 4.7.0 → 5.0.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 +95 -41
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +8 -2
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +62 -8
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +280 -177
- package/dist/tool-descriptions.d.ts +0 -4
- package/dist/tool-descriptions.js +30 -21
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +11 -6
- package/dist/tool-handlers/analyze-video.js +9 -5
- package/dist/tool-handlers/async-chat.d.ts +17 -0
- package/dist/tool-handlers/async-chat.js +104 -30
- package/dist/tool-handlers/audio-utils.d.ts +19 -4
- package/dist/tool-handlers/audio-utils.js +170 -16
- package/dist/tool-handlers/cache.d.ts +3 -3
- package/dist/tool-handlers/cache.js +56 -4
- package/dist/tool-handlers/chat-completion.js +16 -7
- package/dist/tool-handlers/chat-request.d.ts +4 -1
- package/dist/tool-handlers/chat-request.js +29 -1
- package/dist/tool-handlers/completion-utils.d.ts +5 -11
- package/dist/tool-handlers/completion-utils.js +76 -47
- package/dist/tool-handlers/fetch-utils.d.ts +14 -0
- package/dist/tool-handlers/fetch-utils.js +329 -77
- package/dist/tool-handlers/generate-audio.d.ts +4 -15
- package/dist/tool-handlers/generate-audio.js +21 -53
- package/dist/tool-handlers/generate-image-dedicated.d.ts +1 -1
- package/dist/tool-handlers/generate-image-dedicated.js +50 -31
- package/dist/tool-handlers/generate-image.d.ts +1 -1
- package/dist/tool-handlers/generate-image.js +19 -22
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +42 -18
- package/dist/tool-handlers/get-model-info.js +1 -1
- package/dist/tool-handlers/health-check.js +39 -15
- package/dist/tool-handlers/image-utils.js +2 -2
- package/dist/tool-handlers/openrouter-errors.d.ts +2 -0
- package/dist/tool-handlers/openrouter-errors.js +138 -31
- package/dist/tool-handlers/path-safety.js +49 -17
- package/dist/tool-handlers/path-utils.d.ts +2 -0
- package/dist/tool-handlers/path-utils.js +13 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +11 -1
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +44 -15
- package/dist/tool-handlers/search-models.js +3 -3
- package/dist/tool-handlers/speech-to-text.d.ts +1 -0
- package/dist/tool-handlers/speech-to-text.js +23 -56
- package/dist/tool-handlers/text-to-speech.d.ts +1 -1
- package/dist/tool-handlers/text-to-speech.js +33 -20
- package/dist/tool-handlers/tool-result-payload.js +11 -9
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +17 -5
- package/dist/tts-defaults.d.ts +4 -0
- package/dist/tts-defaults.js +4 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +4 -2
- package/package.json +11 -13
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* MCP tool descriptions with explicit routing, examples, and failure modes.
|
|
3
3
|
* See docs/plans/tool-description-improvement.md for the authoring guide.
|
|
4
4
|
*/
|
|
5
|
+
import { DEFAULT_TTS_MODEL, DEFAULT_TTS_RESPONSE_FORMAT, DEFAULT_TTS_VOICE, } from './tts-defaults.js';
|
|
5
6
|
function formatBullets(items) {
|
|
6
7
|
return items.map((item) => `- ${item}`).join('\n');
|
|
7
8
|
}
|
|
@@ -55,8 +56,8 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
55
56
|
'You already know the model id (or rely on the server default)',
|
|
56
57
|
],
|
|
57
58
|
notWhen: [
|
|
58
|
-
'Input is
|
|
59
|
-
'You need to create images, audio, or video → use generate_* tools',
|
|
59
|
+
'Input is a single image/audio/video file → use analyze_image / analyze_audio / analyze_video (dedicated wrappers)',
|
|
60
|
+
'You need to create images, audio, or video → use generate_* / text_to_speech tools',
|
|
60
61
|
'You only need to check if a model exists → use validate_model',
|
|
61
62
|
],
|
|
62
63
|
goodExamples: [
|
|
@@ -136,14 +137,14 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
136
137
|
'The image is a local file under the input sandbox, a public https URL, or a data URL',
|
|
137
138
|
],
|
|
138
139
|
notWhen: [
|
|
139
|
-
'You want to generate a new image → use generate_image',
|
|
140
|
+
'You want to generate a new image → use generate_image or generate_image_dedicated',
|
|
140
141
|
'You need multi-file batch analysis in one call → not supported; call once per image',
|
|
141
|
-
'
|
|
142
|
+
'Multi-turn vision chat with several images → use chat_completion with a vision model (analyze_image is single-image only)',
|
|
142
143
|
],
|
|
143
144
|
goodExamples: [
|
|
144
145
|
'`{ "image_path": "diagram.png", "question": "List every label in this diagram." }`',
|
|
145
146
|
'`{ "image_path": "https://example.com/photo.jpg", "question": "Describe the scene." }`',
|
|
146
|
-
'`{ "
|
|
147
|
+
'`{ "image_path": "scan.jpg", "question": "Extract text", "model": "google/gemini-2.5-flash" }` (optional model override)',
|
|
147
148
|
],
|
|
148
149
|
badExamples: [
|
|
149
150
|
'`{ "url": "photo.jpg" }` → wrong key; use `image_path`',
|
|
@@ -166,9 +167,9 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
166
167
|
'Format is a common audio container the decoder recognizes',
|
|
167
168
|
],
|
|
168
169
|
notWhen: [
|
|
169
|
-
'You want text-to-speech → use generate_audio',
|
|
170
|
+
'You want text-to-speech → use text_to_speech (dedicated, faster) or generate_audio (chat route, music/SFX)',
|
|
171
|
+
'You want pure transcription without Q&A → use speech_to_text',
|
|
170
172
|
'Input is video → use analyze_video (or extract audio first)',
|
|
171
|
-
'Pure text chat → use chat_completion',
|
|
172
173
|
],
|
|
173
174
|
goodExamples: [
|
|
174
175
|
'`{ "audio_path": "meeting.wav", "question": "Transcribe verbatim." }`',
|
|
@@ -290,13 +291,15 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
290
291
|
worksWith: ['get_model_info', 'chat_completion'],
|
|
291
292
|
}),
|
|
292
293
|
generate_image: buildToolDescription({
|
|
293
|
-
summary: 'Generate an image
|
|
294
|
-
'Default model: google/gemini-2.5-flash-image.',
|
|
294
|
+
summary: 'Generate an image via chat completions (modalities route). Optional `input_images` for style/identity. ' +
|
|
295
|
+
'Default model: google/gemini-2.5-flash-image. Use generate_image_dedicated for resolution/quality/format control or newer Image API models.',
|
|
295
296
|
useWhen: [
|
|
296
|
-
'You need a new image from a text prompt',
|
|
297
|
-
'You have reference images for style or subject consistency',
|
|
297
|
+
'You need a new image from a text prompt via the chat-completions route',
|
|
298
|
+
'You have reference images for style or subject consistency (`input_images`)',
|
|
299
|
+
'Simple text-to-image without dedicated API knobs',
|
|
298
300
|
],
|
|
299
301
|
notWhen: [
|
|
302
|
+
'You need resolution tiers, quality, output_format, or Image API-only models → generate_image_dedicated',
|
|
300
303
|
'You want to analyze an existing image → analyze_image',
|
|
301
304
|
'You want video → generate_video or generate_video_from_image',
|
|
302
305
|
'Prompt is empty or only whitespace',
|
|
@@ -329,6 +332,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
329
332
|
'You need image-to-image with `input_references`',
|
|
330
333
|
],
|
|
331
334
|
notWhen: [
|
|
335
|
+
'Simple text-to-image without format/resolution control → generate_image (fewer params)',
|
|
332
336
|
'You want to analyze an existing image → analyze_image',
|
|
333
337
|
'You want video → generate_video or generate_video_from_image',
|
|
334
338
|
'Prompt is empty or only whitespace',
|
|
@@ -352,12 +356,17 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
352
356
|
worksWith: ['analyze_image', 'generate_video_from_image'],
|
|
353
357
|
}),
|
|
354
358
|
generate_audio: buildToolDescription({
|
|
355
|
-
summary: 'Generate speech or music
|
|
359
|
+
summary: 'Generate speech or music via chat completions (modalities route). Formats: wav, mp3, flac, opus, pcm16. ' +
|
|
360
|
+
'Default model: openai/gpt-audio, voice alloy. Use text_to_speech for dedicated TTS with speed/instructions.',
|
|
356
361
|
useWhen: [
|
|
357
|
-
'You need
|
|
358
|
-
'
|
|
362
|
+
'You need music, sound effects, or expressive speech via the chat route',
|
|
363
|
+
'You want pcm16/wav output from the gpt-audio family',
|
|
364
|
+
],
|
|
365
|
+
notWhen: [
|
|
366
|
+
'You want fast dedicated TTS with speed/instructions → text_to_speech',
|
|
367
|
+
'You want to transcribe existing audio → speech_to_text or analyze_audio',
|
|
368
|
+
'Prompt is empty',
|
|
359
369
|
],
|
|
360
|
-
notWhen: ['You want to transcribe existing audio → analyze_audio', 'Prompt is empty'],
|
|
361
370
|
goodExamples: [
|
|
362
371
|
'`{ "prompt": "Say hello world in a calm voice." }`',
|
|
363
372
|
'`{ "prompt": "Upbeat jingle", "save_path": "out/jingle.mp3" }`',
|
|
@@ -375,11 +384,11 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
375
384
|
}),
|
|
376
385
|
text_to_speech: buildToolDescription({
|
|
377
386
|
summary: "Convert text to speech via OpenRouter's dedicated TTS endpoint (POST /api/v1/audio/speech). " +
|
|
378
|
-
|
|
387
|
+
`Default: ${DEFAULT_TTS_MODEL} with ${DEFAULT_TTS_VOICE}; discover current models with GET /api/v1/models?output_modalities=speech. Output formats: mp3 or pcm (default: ${DEFAULT_TTS_RESPONSE_FORMAT}).`,
|
|
379
388
|
useWhen: [
|
|
380
389
|
'You need text-to-speech with specific voice control',
|
|
381
390
|
'You want fast, dedicated TTS without chat overhead',
|
|
382
|
-
'You need
|
|
391
|
+
'You need mp3 or pcm audio output',
|
|
383
392
|
],
|
|
384
393
|
notWhen: [
|
|
385
394
|
'You want to generate music or sound effects → generate_audio',
|
|
@@ -388,13 +397,13 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
388
397
|
],
|
|
389
398
|
goodExamples: [
|
|
390
399
|
'`{ "input": "Hello, welcome to our app!" }`',
|
|
391
|
-
'`{ "input": "...", "voice": "
|
|
400
|
+
'`{ "input": "...", "voice": "flux-alexis-en", "response_format": "mp3", "save_path": "out/welcome.mp3" }`',
|
|
392
401
|
'`{ "input": "...", "instructions": "speak slowly and clearly", "speed": 0.8 }`',
|
|
393
402
|
],
|
|
394
403
|
badExamples: [
|
|
395
404
|
'`{ "input": "" }` → INVALID_INPUT',
|
|
396
405
|
'`{ "prompt": "text" }` → wrong key; use `input`',
|
|
397
|
-
'`{ "response_format": "
|
|
406
|
+
'`{ "response_format": "wav" }` → only mp3 and pcm are supported',
|
|
398
407
|
],
|
|
399
408
|
failsWhen: [
|
|
400
409
|
'INVALID_INPUT: empty input, invalid response_format',
|
|
@@ -520,7 +529,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
520
529
|
worksWith: ['generate_video', 'generate_video_from_image'],
|
|
521
530
|
}),
|
|
522
531
|
rerank_documents: buildToolDescription({
|
|
523
|
-
summary: 'Re-order documents by relevance to a query using an OpenRouter reranker. Default: cohere/rerank-
|
|
532
|
+
summary: 'Re-order documents by relevance to a query using an OpenRouter reranker. Default: cohere/rerank-v3.5.',
|
|
524
533
|
useWhen: [
|
|
525
534
|
'You have a query and a list of text snippets to sort by relevance',
|
|
526
535
|
'You will feed top results into chat_completion for grounded answers',
|
|
@@ -531,7 +540,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
531
540
|
],
|
|
532
541
|
goodExamples: [
|
|
533
542
|
'`{ "query": "battery life", "documents": ["Doc A text...", "Doc B text..."] }`',
|
|
534
|
-
'`{ "query": "...", "documents": [...], "model": "cohere/rerank-
|
|
543
|
+
'`{ "query": "...", "documents": [...], "model": "cohere/rerank-v3.5" }`',
|
|
535
544
|
],
|
|
536
545
|
badExamples: [
|
|
537
546
|
'`{ "documents": [] }` → INVALID_INPUT',
|
|
@@ -4,7 +4,7 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
|
4
4
|
import { SERVER_VERSION } from '../version.js';
|
|
5
5
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
6
6
|
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
7
|
-
import { buildCacheHeaders, extractCacheMeta } from './cache.js';
|
|
7
|
+
import { buildCacheHeaders, extractCacheMeta, validateCacheOptions, } from './cache.js';
|
|
8
8
|
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
9
9
|
const DEFAULT_MODEL = 'google/gemini-2.5-flash';
|
|
10
10
|
export async function handleAnalyzeAudio(request, openai, defaultModel) {
|
|
@@ -13,6 +13,9 @@ export async function handleAnalyzeAudio(request, openai, defaultModel) {
|
|
|
13
13
|
if (!audio_path) {
|
|
14
14
|
return toolError(ErrorCode.INVALID_INPUT, 'audio_path is required.');
|
|
15
15
|
}
|
|
16
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
17
|
+
if (cacheError)
|
|
18
|
+
return cacheError;
|
|
16
19
|
let audioData;
|
|
17
20
|
try {
|
|
18
21
|
audioData = await prepareAudioData(audio_path);
|
|
@@ -4,15 +4,18 @@ import { ErrorCode, toolError, toolErrorFrom } from '../errors.js';
|
|
|
4
4
|
import { SERVER_VERSION } from '../version.js';
|
|
5
5
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
6
6
|
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
7
|
-
import { buildCacheHeaders, extractCacheMeta } from './cache.js';
|
|
7
|
+
import { buildCacheHeaders, extractCacheMeta, validateCacheOptions, } from './cache.js';
|
|
8
8
|
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
9
|
-
const DEFAULT_MODEL = '
|
|
9
|
+
const DEFAULT_MODEL = 'google/gemma-4-26b-a4b-it:free';
|
|
10
10
|
export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
11
11
|
const args = request.params.arguments ?? { image_path: '' };
|
|
12
12
|
const { image_path, question, model, cache_input, cache, cache_ttl, cache_clear } = args;
|
|
13
13
|
if (!image_path) {
|
|
14
14
|
return toolError(ErrorCode.INVALID_INPUT, 'image_path is required.');
|
|
15
15
|
}
|
|
16
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
17
|
+
if (cacheError)
|
|
18
|
+
return cacheError;
|
|
16
19
|
let imageUrl;
|
|
17
20
|
try {
|
|
18
21
|
imageUrl = await prepareImageUrl(image_path);
|
|
@@ -22,12 +25,14 @@ export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
|
22
25
|
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
23
26
|
}
|
|
24
27
|
const msg = err instanceof Error ? err.message : String(err);
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const detail = `image_path "${image_path}": ${msg}`;
|
|
29
|
+
if (msg.includes('Blocked host')) {
|
|
30
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, detail);
|
|
31
|
+
}
|
|
27
32
|
if (msg.toLowerCase().includes('too large')) {
|
|
28
|
-
return
|
|
33
|
+
return toolError(ErrorCode.RESOURCE_TOO_LARGE, detail);
|
|
29
34
|
}
|
|
30
|
-
return
|
|
35
|
+
return toolError(ErrorCode.INVALID_INPUT, detail);
|
|
31
36
|
}
|
|
32
37
|
const imageBlock = {
|
|
33
38
|
type: 'image_url',
|
|
@@ -5,7 +5,7 @@ import { SERVER_VERSION } from '../version.js';
|
|
|
5
5
|
import { logger } from '../logger.js';
|
|
6
6
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
7
7
|
import { extractCompletionText, detectReasoningCutoff, buildCompletionMeta, } from './completion-utils.js';
|
|
8
|
-
import { buildCacheHeaders, extractCacheMeta } from './cache.js';
|
|
8
|
+
import { buildCacheHeaders, extractCacheMeta, validateCacheOptions, } from './cache.js';
|
|
9
9
|
import { awaitCompletionWithHeaders } from './openai-withresponse.js';
|
|
10
10
|
const FALLBACK_DEFAULT_MODEL = 'google/gemini-2.5-flash';
|
|
11
11
|
export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
@@ -14,6 +14,9 @@ export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
|
14
14
|
if (!video_path) {
|
|
15
15
|
return toolError(ErrorCode.INVALID_INPUT, 'video_path is required.');
|
|
16
16
|
}
|
|
17
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
18
|
+
if (cacheError)
|
|
19
|
+
return cacheError;
|
|
17
20
|
const pickedModel = model || process.env.OPENROUTER_DEFAULT_VIDEO_MODEL || defaultModel || FALLBACK_DEFAULT_MODEL;
|
|
18
21
|
let videoData;
|
|
19
22
|
try {
|
|
@@ -24,16 +27,17 @@ export async function handleAnalyzeVideo(request, openai, defaultModel) {
|
|
|
24
27
|
return toolErrorFrom(ErrorCode.UNSAFE_PATH, err);
|
|
25
28
|
}
|
|
26
29
|
const msg = err instanceof Error ? err.message : String(err);
|
|
30
|
+
const detail = `video_path "${video_path}": ${msg}`;
|
|
27
31
|
if (msg.includes('Blocked host')) {
|
|
28
|
-
return
|
|
32
|
+
return toolError(ErrorCode.UPSTREAM_REFUSED, detail);
|
|
29
33
|
}
|
|
30
34
|
if (msg.includes('too large')) {
|
|
31
|
-
return
|
|
35
|
+
return toolError(ErrorCode.RESOURCE_TOO_LARGE, detail);
|
|
32
36
|
}
|
|
33
37
|
if (msg.includes('Unsupported') || msg.includes('not a video')) {
|
|
34
|
-
return
|
|
38
|
+
return toolError(ErrorCode.UNSUPPORTED_FORMAT, detail);
|
|
35
39
|
}
|
|
36
|
-
return
|
|
40
|
+
return toolError(ErrorCode.INVALID_INPUT, detail);
|
|
37
41
|
}
|
|
38
42
|
const videoBlock = {
|
|
39
43
|
type: 'video_url',
|
|
@@ -18,6 +18,10 @@ export interface AsyncJob {
|
|
|
18
18
|
error?: string;
|
|
19
19
|
error_code?: ErrorCode;
|
|
20
20
|
}
|
|
21
|
+
/** Test-only reset for module-level job state. */
|
|
22
|
+
export declare function resetAsyncJobStateForTests(): void;
|
|
23
|
+
/** Exported for tests — produces ids accepted by `isValidJobId`. */
|
|
24
|
+
export declare function generateJobId(): string;
|
|
21
25
|
/** Load a persisted job from disk (exported for tests). */
|
|
22
26
|
export declare function loadJobFromDisk(jobId: string): Promise<AsyncJob | null>;
|
|
23
27
|
export declare function handleStartChatCompletion(request: {
|
|
@@ -41,6 +45,19 @@ export declare function handleGetChatCompletionStatus(request: {
|
|
|
41
45
|
arguments: GetChatCompletionStatusRequest;
|
|
42
46
|
};
|
|
43
47
|
}): Promise<import("../errors.js").ToolErrorResult | {
|
|
48
|
+
content: {
|
|
49
|
+
type: "text";
|
|
50
|
+
text: string;
|
|
51
|
+
}[];
|
|
52
|
+
_meta: {
|
|
53
|
+
result_truncated?: boolean | undefined;
|
|
54
|
+
server_version: string;
|
|
55
|
+
job_id: string;
|
|
56
|
+
status: "completed";
|
|
57
|
+
model: string;
|
|
58
|
+
created_at: string;
|
|
59
|
+
};
|
|
60
|
+
} | {
|
|
44
61
|
content: {
|
|
45
62
|
type: "text";
|
|
46
63
|
text: string;
|
|
@@ -1,19 +1,54 @@
|
|
|
1
1
|
/** Async chat completions — in-memory jobs, optionally persisted under OPENROUTER_OUTPUT_DIR/openrouter-jobs/. */
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
2
3
|
import { promises as fs } from 'node:fs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import { ErrorCode, toolError } from '../errors.js';
|
|
5
6
|
import { SERVER_VERSION } from '../version.js';
|
|
6
7
|
import { logger } from '../logger.js';
|
|
7
|
-
import { extractCompletionText, buildCompletionMeta } from './completion-utils.js';
|
|
8
|
+
import { extractCompletionText, buildCompletionMeta, capResultText } from './completion-utils.js';
|
|
8
9
|
import { resolveSafeJobStatusPath, isValidJobId } from './path-safety.js';
|
|
9
10
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
10
|
-
import {
|
|
11
|
+
import { validateCacheOptions } from './cache.js';
|
|
12
|
+
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, validateChatMessages, validateMaxTokens, } from './chat-request.js';
|
|
11
13
|
const jobs = new Map();
|
|
12
14
|
let jobCounter = 0;
|
|
13
|
-
|
|
15
|
+
const DEFAULT_ASYNC_JOBS_MEMORY_MAX = 200;
|
|
16
|
+
function readAsyncJobsMemoryMax() {
|
|
17
|
+
const raw = process.env.OPENROUTER_ASYNC_JOBS_MEMORY_MAX;
|
|
18
|
+
if (raw === undefined || raw === '')
|
|
19
|
+
return DEFAULT_ASYNC_JOBS_MEMORY_MAX;
|
|
20
|
+
if (raw === '0')
|
|
21
|
+
return 0;
|
|
22
|
+
const n = parseInt(raw, 10);
|
|
23
|
+
return Number.isFinite(n) && n > 0 ? n : DEFAULT_ASYNC_JOBS_MEMORY_MAX;
|
|
24
|
+
}
|
|
25
|
+
function evictTerminalJobsIfNeeded() {
|
|
26
|
+
const max = readAsyncJobsMemoryMax();
|
|
27
|
+
if (max <= 0 || jobs.size < max)
|
|
28
|
+
return;
|
|
29
|
+
const terminal = [...jobs.entries()]
|
|
30
|
+
.filter(([, job]) => job.status === 'completed' || job.status === 'failed')
|
|
31
|
+
.sort((a, b) => a[1].createdAt.localeCompare(b[1].createdAt));
|
|
32
|
+
while (jobs.size > max && terminal.length > 0) {
|
|
33
|
+
const [id] = terminal.shift();
|
|
34
|
+
jobs.delete(id);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function rememberJob(job) {
|
|
38
|
+
evictTerminalJobsIfNeeded();
|
|
39
|
+
jobs.set(job.id, job);
|
|
40
|
+
}
|
|
41
|
+
/** Test-only reset for module-level job state. */
|
|
42
|
+
export function resetAsyncJobStateForTests() {
|
|
43
|
+
jobs.clear();
|
|
44
|
+
jobCounter = 0;
|
|
45
|
+
}
|
|
46
|
+
/** Exported for tests — produces ids accepted by `isValidJobId`. */
|
|
47
|
+
export function generateJobId() {
|
|
14
48
|
jobCounter += 1;
|
|
15
49
|
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
16
|
-
|
|
50
|
+
const entropy = randomBytes(4).toString('hex');
|
|
51
|
+
return `chat_${ts}_${String(jobCounter).padStart(3, '0')}_${entropy}`;
|
|
17
52
|
}
|
|
18
53
|
function getJobsDir() {
|
|
19
54
|
const outputDir = process.env.OPENROUTER_OUTPUT_DIR;
|
|
@@ -62,7 +97,7 @@ async function resolveJob(jobId) {
|
|
|
62
97
|
return inMemory;
|
|
63
98
|
const fromDisk = await loadJobFromDisk(jobId);
|
|
64
99
|
if (fromDisk) {
|
|
65
|
-
|
|
100
|
+
rememberJob(fromDisk);
|
|
66
101
|
return fromDisk;
|
|
67
102
|
}
|
|
68
103
|
return undefined;
|
|
@@ -70,9 +105,15 @@ async function resolveJob(jobId) {
|
|
|
70
105
|
export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
71
106
|
const args = request.params.arguments ?? { messages: [] };
|
|
72
107
|
const { messages, model, temperature, max_tokens, provider, include_reasoning, online, web_max_results, cache, cache_ttl, cache_clear, } = args;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
const messagesError = validateChatMessages(messages);
|
|
109
|
+
if (messagesError)
|
|
110
|
+
return messagesError;
|
|
111
|
+
const cacheError = validateCacheOptions({ cache, cache_ttl, cache_clear });
|
|
112
|
+
if (cacheError)
|
|
113
|
+
return cacheError;
|
|
114
|
+
const maxTokensError = validateMaxTokens(max_tokens);
|
|
115
|
+
if (maxTokensError)
|
|
116
|
+
return maxTokensError;
|
|
76
117
|
const effectiveModel = model || defaultModel || DEFAULT_CHAT_MODEL;
|
|
77
118
|
const jobId = generateJobId();
|
|
78
119
|
const job = {
|
|
@@ -81,7 +122,7 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
81
122
|
createdAt: new Date().toISOString(),
|
|
82
123
|
model: effectiveModel,
|
|
83
124
|
};
|
|
84
|
-
|
|
125
|
+
rememberJob(job);
|
|
85
126
|
logger.audit('async_chat.start', {
|
|
86
127
|
job_id: jobId,
|
|
87
128
|
model: effectiveModel,
|
|
@@ -99,6 +140,16 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
99
140
|
cache,
|
|
100
141
|
cache_ttl,
|
|
101
142
|
cache_clear,
|
|
143
|
+
}).catch((err) => {
|
|
144
|
+
logger.error('async_chat.unhandled', {
|
|
145
|
+
job_id: job.id,
|
|
146
|
+
err: err instanceof Error ? err.message : String(err),
|
|
147
|
+
});
|
|
148
|
+
if (job.status === 'running') {
|
|
149
|
+
job.status = 'failed';
|
|
150
|
+
job.error = 'Unexpected error during background completion.';
|
|
151
|
+
job.error_code = ErrorCode.INTERNAL;
|
|
152
|
+
}
|
|
102
153
|
});
|
|
103
154
|
return {
|
|
104
155
|
content: [
|
|
@@ -116,35 +167,56 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
116
167
|
};
|
|
117
168
|
}
|
|
118
169
|
async function runCompletionInBackground(job, openai, opts) {
|
|
119
|
-
const wantsReasoning = opts.include_reasoning ?? readIncludeReasoningDefault();
|
|
120
|
-
const body = buildChatCompletionBody(opts);
|
|
121
|
-
const requestOpts = buildChatCompletionRequestOpts(opts);
|
|
122
170
|
try {
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
if (!extracted.text) {
|
|
171
|
+
const cacheError = validateCacheOptions(opts);
|
|
172
|
+
if (cacheError) {
|
|
126
173
|
job.status = 'failed';
|
|
127
|
-
job.error =
|
|
174
|
+
job.error = cacheError.content[0]?.text ?? 'Invalid cache options.';
|
|
175
|
+
job.error_code = cacheError._meta.code;
|
|
176
|
+
await persistJob(job);
|
|
177
|
+
evictTerminalJobsIfNeeded();
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const wantsReasoning = opts.include_reasoning ?? readIncludeReasoningDefault();
|
|
181
|
+
const body = buildChatCompletionBody(opts);
|
|
182
|
+
const requestOpts = buildChatCompletionRequestOpts(opts);
|
|
183
|
+
try {
|
|
184
|
+
const completion = (await openai.chat.completions.create(asOpenAIChatBody(body), requestOpts));
|
|
185
|
+
const extracted = extractCompletionText(completion);
|
|
186
|
+
if (!extracted.text) {
|
|
187
|
+
job.status = 'failed';
|
|
188
|
+
job.error = 'Model returned no textual content.';
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
job.status = 'completed';
|
|
192
|
+
job.result = {
|
|
193
|
+
text: extracted.text,
|
|
194
|
+
meta: buildCompletionMeta(extracted, {
|
|
195
|
+
includeReasoning: wantsReasoning,
|
|
196
|
+
extra: { server_version: SERVER_VERSION },
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
128
200
|
}
|
|
129
|
-
|
|
130
|
-
job.status = '
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
extra: { server_version: SERVER_VERSION },
|
|
136
|
-
}),
|
|
137
|
-
};
|
|
201
|
+
catch (err) {
|
|
202
|
+
job.status = 'failed';
|
|
203
|
+
const classified = classifyUpstreamError(err);
|
|
204
|
+
job.error = classified.content[0]?.text ?? 'Job failed.';
|
|
205
|
+
job.error_code = classified._meta.code;
|
|
206
|
+
logger.warn('async_chat.failed', { job_id: job.id, error: job.error, code: job.error_code });
|
|
138
207
|
}
|
|
139
208
|
}
|
|
140
209
|
catch (err) {
|
|
141
210
|
job.status = 'failed';
|
|
142
|
-
|
|
143
|
-
job.
|
|
144
|
-
|
|
145
|
-
|
|
211
|
+
job.error = 'Unexpected error during background completion.';
|
|
212
|
+
job.error_code = ErrorCode.INTERNAL;
|
|
213
|
+
logger.error('async_chat.background_error', {
|
|
214
|
+
job_id: job.id,
|
|
215
|
+
err: err instanceof Error ? err.message : String(err),
|
|
216
|
+
});
|
|
146
217
|
}
|
|
147
218
|
await persistJob(job);
|
|
219
|
+
evictTerminalJobsIfNeeded();
|
|
148
220
|
}
|
|
149
221
|
export async function handleGetChatCompletionStatus(request) {
|
|
150
222
|
const args = request.params.arguments ?? {};
|
|
@@ -163,14 +235,16 @@ export async function handleGetChatCompletionStatus(request) {
|
|
|
163
235
|
return toolError(ErrorCode.INVALID_INPUT, `No job found with id "${jobId}".${hint}`);
|
|
164
236
|
}
|
|
165
237
|
if (job.status === 'completed' && job.result) {
|
|
238
|
+
const capped = capResultText(job.result.text);
|
|
166
239
|
return {
|
|
167
|
-
content: [{ type: 'text', text:
|
|
240
|
+
content: [{ type: 'text', text: capped.text }],
|
|
168
241
|
_meta: {
|
|
169
242
|
server_version: SERVER_VERSION,
|
|
170
243
|
job_id: jobId,
|
|
171
244
|
status: 'completed',
|
|
172
245
|
model: job.model,
|
|
173
246
|
created_at: job.createdAt,
|
|
247
|
+
...(capped.truncated ? { result_truncated: true } : {}),
|
|
174
248
|
...job.result.meta,
|
|
175
249
|
},
|
|
176
250
|
};
|
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
export { isBlockedIPv4, assertUrlSafeForFetch } from './fetch-utils.js';
|
|
2
|
-
/**
|
|
2
|
+
/** Shared ceiling for speech_to_text local, HTTP, and data-URL inputs. */
|
|
3
|
+
export declare function getMaxAudioInputBytes(): number;
|
|
3
4
|
declare const FILE_AUDIO_FORMATS: readonly ["wav", "mp3", "aiff", "aac", "ogg", "flac", "m4a"];
|
|
4
5
|
export declare const SUPPORTED_AUDIO_FORMATS: readonly ["wav", "mp3", "aiff", "aac", "ogg", "flac", "m4a", "pcm16", "pcm24"];
|
|
5
6
|
export type AudioFormat = (typeof SUPPORTED_AUDIO_FORMATS)[number];
|
|
6
7
|
type FileAudioFormat = (typeof FILE_AUDIO_FORMATS)[number];
|
|
7
|
-
|
|
8
|
+
export declare const STT_FILE_EXTENSIONS: readonly ["mp3", "mp4", "m4a", "wav", "flac", "ogg", "oga", "webm", "opus"];
|
|
9
|
+
export type SttFileExtension = (typeof STT_FILE_EXTENSIONS)[number];
|
|
10
|
+
/**
|
|
11
|
+
* Detect audio container format from magic bytes. MP3 detection is strict:
|
|
12
|
+
* ID3 tags or frame sync with valid MPEG header fields.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detectAudioFormat(data: Buffer): {
|
|
15
|
+
ext: string;
|
|
16
|
+
mimeType: string;
|
|
17
|
+
};
|
|
8
18
|
export declare function getAudioFormat(filePath: string): FileAudioFormat | undefined;
|
|
9
|
-
/** Get MIME type for an audio format. */
|
|
10
19
|
export declare function getAudioMimeType(format: AudioFormat): string;
|
|
11
20
|
export interface AudioData {
|
|
12
21
|
data: string;
|
|
13
22
|
format: AudioFormat;
|
|
14
23
|
}
|
|
15
|
-
/** Prepare audio from data URL, HTTP URL, or sandboxed local file. */
|
|
16
24
|
export declare function prepareAudioData(source: string): Promise<AudioData>;
|
|
25
|
+
/** Map a speech_to_text file extension to the upstream format slug. */
|
|
26
|
+
export declare function sttFormatFromExtension(ext: string): string;
|
|
27
|
+
/** Resolve speech_to_text audio from data URL, HTTP URL, or sandboxed local file. */
|
|
28
|
+
export declare function resolveSpeechToTextAudio(audioPath: string): Promise<{
|
|
29
|
+
data: string;
|
|
30
|
+
format: string;
|
|
31
|
+
}>;
|