@stabgan/openrouter-mcp-multimodal 4.6.2 → 4.8.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 +108 -37
- package/dist/errors.d.ts +5 -20
- package/dist/errors.js +1 -10
- package/dist/index.js +7 -1
- package/dist/logger.js +54 -24
- package/dist/model-cache.d.ts +13 -0
- package/dist/model-cache.js +61 -5
- package/dist/openrouter-api.d.ts +14 -15
- package/dist/openrouter-api.js +68 -22
- package/dist/openrouter-openai-client.d.ts +9 -0
- package/dist/openrouter-openai-client.js +15 -0
- package/dist/tool-definitions.d.ts +24 -0
- package/dist/tool-definitions.js +283 -170
- package/dist/tool-descriptions.js +23 -15
- package/dist/tool-handlers/analyze-audio.js +4 -1
- package/dist/tool-handlers/analyze-image.js +10 -5
- 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 +112 -31
- 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 +3 -0
- package/dist/tool-handlers/chat-request.js +28 -0
- 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 +331 -68
- package/dist/tool-handlers/generate-audio.d.ts +6 -51
- package/dist/tool-handlers/generate-audio.js +30 -76
- package/dist/tool-handlers/generate-image-dedicated.d.ts +2 -12
- package/dist/tool-handlers/generate-image-dedicated.js +79 -28
- package/dist/tool-handlers/generate-image.d.ts +3 -46
- package/dist/tool-handlers/generate-image.js +26 -43
- package/dist/tool-handlers/generate-video.d.ts +4 -3
- package/dist/tool-handlers/generate-video.js +51 -50
- 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.d.ts +6 -0
- package/dist/tool-handlers/path-safety.js +77 -14
- package/dist/tool-handlers/path-utils.d.ts +4 -0
- package/dist/tool-handlers/path-utils.js +20 -0
- package/dist/tool-handlers/provider-routing.d.ts +2 -0
- package/dist/tool-handlers/provider-routing.js +10 -0
- package/dist/tool-handlers/rerank.d.ts +1 -4
- package/dist/tool-handlers/rerank.js +43 -14
- 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 +2 -12
- package/dist/tool-handlers/text-to-speech.js +29 -22
- package/dist/tool-handlers/tool-result-payload.d.ts +47 -0
- package/dist/tool-handlers/tool-result-payload.js +98 -0
- package/dist/tool-handlers/validate-model.js +1 -1
- package/dist/tool-handlers.d.ts +9 -0
- package/dist/tool-handlers.js +19 -10
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -3
|
@@ -55,8 +55,8 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
55
55
|
'You already know the model id (or rely on the server default)',
|
|
56
56
|
],
|
|
57
57
|
notWhen: [
|
|
58
|
-
'Input is
|
|
59
|
-
'You need to create images, audio, or video → use generate_* tools',
|
|
58
|
+
'Input is a single image/audio/video file → use analyze_image / analyze_audio / analyze_video (dedicated wrappers)',
|
|
59
|
+
'You need to create images, audio, or video → use generate_* / text_to_speech tools',
|
|
60
60
|
'You only need to check if a model exists → use validate_model',
|
|
61
61
|
],
|
|
62
62
|
goodExamples: [
|
|
@@ -136,14 +136,14 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
136
136
|
'The image is a local file under the input sandbox, a public https URL, or a data URL',
|
|
137
137
|
],
|
|
138
138
|
notWhen: [
|
|
139
|
-
'You want to generate a new image → use generate_image',
|
|
139
|
+
'You want to generate a new image → use generate_image or generate_image_dedicated',
|
|
140
140
|
'You need multi-file batch analysis in one call → not supported; call once per image',
|
|
141
|
-
'
|
|
141
|
+
'Multi-turn vision chat with several images → use chat_completion with a vision model (analyze_image is single-image only)',
|
|
142
142
|
],
|
|
143
143
|
goodExamples: [
|
|
144
144
|
'`{ "image_path": "diagram.png", "question": "List every label in this diagram." }`',
|
|
145
145
|
'`{ "image_path": "https://example.com/photo.jpg", "question": "Describe the scene." }`',
|
|
146
|
-
'`{ "
|
|
146
|
+
'`{ "image_path": "scan.jpg", "question": "Extract text", "model": "google/gemini-2.5-flash" }` (optional model override)',
|
|
147
147
|
],
|
|
148
148
|
badExamples: [
|
|
149
149
|
'`{ "url": "photo.jpg" }` → wrong key; use `image_path`',
|
|
@@ -166,9 +166,9 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
166
166
|
'Format is a common audio container the decoder recognizes',
|
|
167
167
|
],
|
|
168
168
|
notWhen: [
|
|
169
|
-
'You want text-to-speech → use generate_audio',
|
|
169
|
+
'You want text-to-speech → use text_to_speech (dedicated, faster) or generate_audio (chat route, music/SFX)',
|
|
170
|
+
'You want pure transcription without Q&A → use speech_to_text',
|
|
170
171
|
'Input is video → use analyze_video (or extract audio first)',
|
|
171
|
-
'Pure text chat → use chat_completion',
|
|
172
172
|
],
|
|
173
173
|
goodExamples: [
|
|
174
174
|
'`{ "audio_path": "meeting.wav", "question": "Transcribe verbatim." }`',
|
|
@@ -290,13 +290,15 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
290
290
|
worksWith: ['get_model_info', 'chat_completion'],
|
|
291
291
|
}),
|
|
292
292
|
generate_image: buildToolDescription({
|
|
293
|
-
summary: 'Generate an image
|
|
294
|
-
'Default model: google/gemini-2.5-flash-image.',
|
|
293
|
+
summary: 'Generate an image via chat completions (modalities route). Optional `input_images` for style/identity. ' +
|
|
294
|
+
'Default model: google/gemini-2.5-flash-image. Use generate_image_dedicated for resolution/quality/format control or newer Image API models.',
|
|
295
295
|
useWhen: [
|
|
296
|
-
'You need a new image from a text prompt',
|
|
297
|
-
'You have reference images for style or subject consistency',
|
|
296
|
+
'You need a new image from a text prompt via the chat-completions route',
|
|
297
|
+
'You have reference images for style or subject consistency (`input_images`)',
|
|
298
|
+
'Simple text-to-image without dedicated API knobs',
|
|
298
299
|
],
|
|
299
300
|
notWhen: [
|
|
301
|
+
'You need resolution tiers, quality, output_format, or Image API-only models → generate_image_dedicated',
|
|
300
302
|
'You want to analyze an existing image → analyze_image',
|
|
301
303
|
'You want video → generate_video or generate_video_from_image',
|
|
302
304
|
'Prompt is empty or only whitespace',
|
|
@@ -329,6 +331,7 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
329
331
|
'You need image-to-image with `input_references`',
|
|
330
332
|
],
|
|
331
333
|
notWhen: [
|
|
334
|
+
'Simple text-to-image without format/resolution control → generate_image (fewer params)',
|
|
332
335
|
'You want to analyze an existing image → analyze_image',
|
|
333
336
|
'You want video → generate_video or generate_video_from_image',
|
|
334
337
|
'Prompt is empty or only whitespace',
|
|
@@ -352,12 +355,17 @@ export const TOOL_DESCRIPTIONS = {
|
|
|
352
355
|
worksWith: ['analyze_image', 'generate_video_from_image'],
|
|
353
356
|
}),
|
|
354
357
|
generate_audio: buildToolDescription({
|
|
355
|
-
summary: 'Generate speech or music
|
|
358
|
+
summary: 'Generate speech or music via chat completions (modalities route). Formats: wav, mp3, flac, opus, pcm16. ' +
|
|
359
|
+
'Default model: openai/gpt-audio, voice alloy. Use text_to_speech for dedicated TTS with speed/instructions.',
|
|
356
360
|
useWhen: [
|
|
357
|
-
'You need
|
|
358
|
-
'
|
|
361
|
+
'You need music, sound effects, or expressive speech via the chat route',
|
|
362
|
+
'You want pcm16/wav output from the gpt-audio family',
|
|
363
|
+
],
|
|
364
|
+
notWhen: [
|
|
365
|
+
'You want fast dedicated TTS with speed/instructions → text_to_speech',
|
|
366
|
+
'You want to transcribe existing audio → speech_to_text or analyze_audio',
|
|
367
|
+
'Prompt is empty',
|
|
359
368
|
],
|
|
360
|
-
notWhen: ['You want to transcribe existing audio → analyze_audio', 'Prompt is empty'],
|
|
361
369
|
goodExamples: [
|
|
362
370
|
'`{ "prompt": "Say hello world in a calm voice." }`',
|
|
363
371
|
'`{ "prompt": "Upbeat jingle", "save_path": "out/jingle.mp3" }`',
|
|
@@ -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,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 = 'nvidia/nemotron-nano-12b-v2-vl:free';
|
|
10
10
|
export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
@@ -13,6 +13,9 @@ export async function handleAnalyzeImage(request, openai, defaultModel) {
|
|
|
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,18 +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';
|
|
9
|
+
import { resolveSafeJobStatusPath, isValidJobId } from './path-safety.js';
|
|
8
10
|
import { classifyUpstreamError } from './openrouter-errors.js';
|
|
9
|
-
import {
|
|
11
|
+
import { validateCacheOptions } from './cache.js';
|
|
12
|
+
import { DEFAULT_CHAT_MODEL, buildChatCompletionBody, buildChatCompletionRequestOpts, asOpenAIChatBody, readIncludeReasoningDefault, validateChatMessages, validateMaxTokens, } from './chat-request.js';
|
|
10
13
|
const jobs = new Map();
|
|
11
14
|
let jobCounter = 0;
|
|
12
|
-
|
|
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() {
|
|
13
48
|
jobCounter += 1;
|
|
14
49
|
const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
15
|
-
|
|
50
|
+
const entropy = randomBytes(4).toString('hex');
|
|
51
|
+
return `chat_${ts}_${String(jobCounter).padStart(3, '0')}_${entropy}`;
|
|
16
52
|
}
|
|
17
53
|
function getJobsDir() {
|
|
18
54
|
const outputDir = process.env.OPENROUTER_OUTPUT_DIR;
|
|
@@ -44,8 +80,11 @@ export async function loadJobFromDisk(jobId) {
|
|
|
44
80
|
const dir = getJobsDir();
|
|
45
81
|
if (!dir)
|
|
46
82
|
return null;
|
|
83
|
+
const statusPath = await resolveSafeJobStatusPath(dir, jobId);
|
|
84
|
+
if (!statusPath)
|
|
85
|
+
return null;
|
|
47
86
|
try {
|
|
48
|
-
const raw = await fs.readFile(
|
|
87
|
+
const raw = await fs.readFile(statusPath, 'utf8');
|
|
49
88
|
return JSON.parse(raw);
|
|
50
89
|
}
|
|
51
90
|
catch {
|
|
@@ -58,7 +97,7 @@ async function resolveJob(jobId) {
|
|
|
58
97
|
return inMemory;
|
|
59
98
|
const fromDisk = await loadJobFromDisk(jobId);
|
|
60
99
|
if (fromDisk) {
|
|
61
|
-
|
|
100
|
+
rememberJob(fromDisk);
|
|
62
101
|
return fromDisk;
|
|
63
102
|
}
|
|
64
103
|
return undefined;
|
|
@@ -66,9 +105,15 @@ async function resolveJob(jobId) {
|
|
|
66
105
|
export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
67
106
|
const args = request.params.arguments ?? { messages: [] };
|
|
68
107
|
const { messages, model, temperature, max_tokens, provider, include_reasoning, online, web_max_results, cache, cache_ttl, cache_clear, } = args;
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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;
|
|
72
117
|
const effectiveModel = model || defaultModel || DEFAULT_CHAT_MODEL;
|
|
73
118
|
const jobId = generateJobId();
|
|
74
119
|
const job = {
|
|
@@ -77,7 +122,7 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
77
122
|
createdAt: new Date().toISOString(),
|
|
78
123
|
model: effectiveModel,
|
|
79
124
|
};
|
|
80
|
-
|
|
125
|
+
rememberJob(job);
|
|
81
126
|
logger.audit('async_chat.start', {
|
|
82
127
|
job_id: jobId,
|
|
83
128
|
model: effectiveModel,
|
|
@@ -95,6 +140,16 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
95
140
|
cache,
|
|
96
141
|
cache_ttl,
|
|
97
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
|
+
}
|
|
98
153
|
});
|
|
99
154
|
return {
|
|
100
155
|
content: [
|
|
@@ -112,35 +167,56 @@ export async function handleStartChatCompletion(request, openai, defaultModel) {
|
|
|
112
167
|
};
|
|
113
168
|
}
|
|
114
169
|
async function runCompletionInBackground(job, openai, opts) {
|
|
115
|
-
const wantsReasoning = opts.include_reasoning ?? readIncludeReasoningDefault();
|
|
116
|
-
const body = buildChatCompletionBody(opts);
|
|
117
|
-
const requestOpts = buildChatCompletionRequestOpts(opts);
|
|
118
170
|
try {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
if (!extracted.text) {
|
|
171
|
+
const cacheError = validateCacheOptions(opts);
|
|
172
|
+
if (cacheError) {
|
|
122
173
|
job.status = 'failed';
|
|
123
|
-
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;
|
|
124
179
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
200
|
+
}
|
|
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 });
|
|
134
207
|
}
|
|
135
208
|
}
|
|
136
209
|
catch (err) {
|
|
137
210
|
job.status = 'failed';
|
|
138
|
-
|
|
139
|
-
job.
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
});
|
|
142
217
|
}
|
|
143
218
|
await persistJob(job);
|
|
219
|
+
evictTerminalJobsIfNeeded();
|
|
144
220
|
}
|
|
145
221
|
export async function handleGetChatCompletionStatus(request) {
|
|
146
222
|
const args = request.params.arguments ?? {};
|
|
@@ -148,6 +224,9 @@ export async function handleGetChatCompletionStatus(request) {
|
|
|
148
224
|
if (!jobId) {
|
|
149
225
|
return toolError(ErrorCode.INVALID_INPUT, 'job_id is required.');
|
|
150
226
|
}
|
|
227
|
+
if (!isValidJobId(jobId)) {
|
|
228
|
+
return toolError(ErrorCode.INVALID_INPUT, `Invalid job_id "${jobId}". Must start with chat_ and must not contain path separators.`);
|
|
229
|
+
}
|
|
151
230
|
const job = await resolveJob(jobId);
|
|
152
231
|
if (!job) {
|
|
153
232
|
const hint = getJobsDir()
|
|
@@ -156,14 +235,16 @@ export async function handleGetChatCompletionStatus(request) {
|
|
|
156
235
|
return toolError(ErrorCode.INVALID_INPUT, `No job found with id "${jobId}".${hint}`);
|
|
157
236
|
}
|
|
158
237
|
if (job.status === 'completed' && job.result) {
|
|
238
|
+
const capped = capResultText(job.result.text);
|
|
159
239
|
return {
|
|
160
|
-
content: [{ type: 'text', text:
|
|
240
|
+
content: [{ type: 'text', text: capped.text }],
|
|
161
241
|
_meta: {
|
|
162
242
|
server_version: SERVER_VERSION,
|
|
163
243
|
job_id: jobId,
|
|
164
244
|
status: 'completed',
|
|
165
245
|
model: job.model,
|
|
166
246
|
created_at: job.createdAt,
|
|
247
|
+
...(capped.truncated ? { result_truncated: true } : {}),
|
|
167
248
|
...job.result.meta,
|
|
168
249
|
},
|
|
169
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
|
+
}>;
|