@stabgan/openrouter-mcp-multimodal 4.5.3 → 4.6.1

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/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { ToolHandlers } from './tool-handlers.js';
8
8
  import { logger } from './logger.js';
9
9
  import { SERVER_VERSION } from './version.js';
10
+ import { SERVER_ICON } from './tool-icons.js';
10
11
  const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
11
12
  // Exit on fatal errors to prevent silent zombie processes (issue #5).
12
13
  // We log an explicit whitelist of fields rather than the raw error object
@@ -37,7 +38,14 @@ if (!apiKey) {
37
38
  process.exit(1);
38
39
  }
39
40
  const defaultModel = process.env.OPENROUTER_DEFAULT_MODEL || process.env.DEFAULT_MODEL || DEFAULT_MODEL;
40
- const server = new Server({ name: 'openrouter-multimodal-server', version: SERVER_VERSION }, { capabilities: { tools: {} } });
41
+ const server = new Server({
42
+ name: 'openrouter-multimodal-server',
43
+ version: SERVER_VERSION,
44
+ title: 'OpenRouter MCP Multimodal',
45
+ description: 'MCP server for OpenRouter — chat with 300+ LLMs, analyze/generate images, audio, and video.',
46
+ websiteUrl: 'https://github.com/stabgan/openrouter-mcp-multimodal',
47
+ icons: SERVER_ICON,
48
+ }, { capabilities: { tools: {} } });
41
49
  server.onerror = (error) => logFatal('mcpError', error);
42
50
  new ToolHandlers(server, apiKey, defaultModel);
43
51
  process.on('SIGINT', async () => {
@@ -31,6 +31,24 @@ export declare class OpenRouterAPIClient {
31
31
  buffer: Buffer;
32
32
  contentType: string | null;
33
33
  }>;
34
+ /**
35
+ * POST /images — dedicated image generation endpoint.
36
+ * Returns structured response with base64 image data.
37
+ */
38
+ generateImage(body: Record<string, unknown>, headers?: Record<string, string>): Promise<ImageGenerationResponse>;
39
+ /**
40
+ * POST /audio/speech — dedicated text-to-speech endpoint.
41
+ * Returns raw audio bytes.
42
+ */
43
+ generateSpeech(body: Record<string, unknown>, headers?: Record<string, string>): Promise<{
44
+ buffer: Buffer;
45
+ contentType: string;
46
+ }>;
47
+ /**
48
+ * POST /audio/transcriptions — dedicated speech-to-text endpoint.
49
+ * Accepts base64-encoded audio and returns transcription text.
50
+ */
51
+ transcribeAudio(body: Record<string, unknown>, headers?: Record<string, string>): Promise<TranscriptionResponse>;
34
52
  /** POST /rerank — re-order documents by relevance to a query. */
35
53
  rerank(params: {
36
54
  model: string;
@@ -45,6 +63,33 @@ export interface VideoJobEnvelope {
45
63
  polling_url?: string;
46
64
  [key: string]: unknown;
47
65
  }
66
+ export interface ImageGenerationResponse {
67
+ data?: Array<{
68
+ b64_json?: string;
69
+ url?: string;
70
+ revised_prompt?: string;
71
+ }>;
72
+ usage?: {
73
+ cost?: number;
74
+ [key: string]: unknown;
75
+ };
76
+ [key: string]: unknown;
77
+ }
78
+ export interface TranscriptionResponse {
79
+ text?: string;
80
+ segments?: Array<{
81
+ start: number;
82
+ end: number;
83
+ text: string;
84
+ }>;
85
+ language?: string;
86
+ duration?: number;
87
+ usage?: {
88
+ cost?: number;
89
+ [key: string]: unknown;
90
+ };
91
+ [key: string]: unknown;
92
+ }
48
93
  export interface RerankResultItem {
49
94
  index: number;
50
95
  relevance_score?: number;
@@ -151,6 +151,56 @@ export class OpenRouterAPIClient {
151
151
  }
152
152
  return { buffer: Buffer.concat(chunks), contentType: res.headers.get('content-type') };
153
153
  }
154
+ /**
155
+ * POST /images — dedicated image generation endpoint.
156
+ * Returns structured response with base64 image data.
157
+ */
158
+ async generateImage(body, headers) {
159
+ const res = await fetchWithRetry(`${BASE_URL}/images`, {
160
+ method: 'POST',
161
+ headers: this.authHeaders({ 'Content-Type': 'application/json', ...headers }),
162
+ body: JSON.stringify(body),
163
+ }, { retries: 2, timeoutMs: 120_000 });
164
+ if (!res.ok) {
165
+ const detail = await safeReadText(res);
166
+ throw new Error(`POST /images failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
167
+ }
168
+ return (await res.json());
169
+ }
170
+ /**
171
+ * POST /audio/speech — dedicated text-to-speech endpoint.
172
+ * Returns raw audio bytes.
173
+ */
174
+ async generateSpeech(body, headers) {
175
+ const res = await fetchWithRetry(`${BASE_URL}/audio/speech`, {
176
+ method: 'POST',
177
+ headers: this.authHeaders({ 'Content-Type': 'application/json', ...headers }),
178
+ body: JSON.stringify(body),
179
+ }, { retries: 2, timeoutMs: 60_000 });
180
+ if (!res.ok) {
181
+ const detail = await safeReadText(res);
182
+ throw new Error(`POST /audio/speech failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
183
+ }
184
+ const contentType = res.headers.get('content-type') || 'audio/mpeg';
185
+ const buf = Buffer.from(await res.arrayBuffer());
186
+ return { buffer: buf, contentType };
187
+ }
188
+ /**
189
+ * POST /audio/transcriptions — dedicated speech-to-text endpoint.
190
+ * Accepts base64-encoded audio and returns transcription text.
191
+ */
192
+ async transcribeAudio(body, headers) {
193
+ const res = await fetchWithRetry(`${BASE_URL}/audio/transcriptions`, {
194
+ method: 'POST',
195
+ headers: this.authHeaders({ 'Content-Type': 'application/json', ...headers }),
196
+ body: JSON.stringify(body),
197
+ }, { retries: 2, timeoutMs: 60_000 });
198
+ if (!res.ok) {
199
+ const detail = await safeReadText(res);
200
+ throw new Error(`POST /audio/transcriptions failed: HTTP ${res.status}${detail ? ` — ${detail}` : ''}`);
201
+ }
202
+ return (await res.json());
203
+ }
154
204
  /** POST /rerank — re-order documents by relevance to a query. */
155
205
  async rerank(params) {
156
206
  const body = {
@@ -14,6 +14,6 @@ export interface ToolDescriptionParts {
14
14
  export declare function buildToolDescription(parts: ToolDescriptionParts): string;
15
15
  /** Required sections every tool description must contain (regression-tested). */
16
16
  export declare const REQUIRED_DESCRIPTION_SECTIONS: readonly ["Use when:", "Do NOT use when:", "Good examples:", "Bad examples:", "Fails when:", "Works with:"];
17
- export declare const TOOL_NAMES: readonly ["chat_completion", "analyze_image", "analyze_audio", "analyze_video", "search_models", "get_model_info", "validate_model", "generate_image", "generate_audio", "generate_video", "generate_video_from_image", "get_video_status", "rerank_documents", "health_check"];
17
+ export declare const TOOL_NAMES: readonly ["chat_completion", "start_chat_completion", "get_chat_completion_status", "analyze_image", "analyze_audio", "analyze_video", "search_models", "get_model_info", "validate_model", "generate_image", "generate_image_dedicated", "generate_audio", "text_to_speech", "speech_to_text", "generate_video", "generate_video_from_image", "get_video_status", "rerank_documents", "health_check"];
18
18
  export type ToolName = (typeof TOOL_NAMES)[number];
19
19
  export declare const TOOL_DESCRIPTIONS: Record<ToolName, string>;
@@ -25,6 +25,8 @@ export const REQUIRED_DESCRIPTION_SECTIONS = [
25
25
  ];
26
26
  export const TOOL_NAMES = [
27
27
  'chat_completion',
28
+ 'start_chat_completion',
29
+ 'get_chat_completion_status',
28
30
  'analyze_image',
29
31
  'analyze_audio',
30
32
  'analyze_video',
@@ -32,7 +34,10 @@ export const TOOL_NAMES = [
32
34
  'get_model_info',
33
35
  'validate_model',
34
36
  'generate_image',
37
+ 'generate_image_dedicated',
35
38
  'generate_audio',
39
+ 'text_to_speech',
40
+ 'speech_to_text',
36
41
  'generate_video',
37
42
  'generate_video_from_image',
38
43
  'get_video_status',
@@ -42,8 +47,8 @@ export const TOOL_NAMES = [
42
47
  export const TOOL_DESCRIPTIONS = {
43
48
  chat_completion: buildToolDescription({
44
49
  summary: 'Send messages to an OpenRouter chat model and get a text reply. Supports provider routing, ' +
45
- 'model suffixes (`:nitro` fastest, `:floor` cheapest, `:exacto` tool accuracy), reasoning ' +
46
- 'tokens, web search (`online: true`), and response caching.',
50
+ 'model suffixes (`:nitro` fastest, `:floor` cheapest, `:free` zero-cost, `:online` web search, ' +
51
+ '`:exacto` tool accuracy), reasoning tokens, web search (`online: true`), and response caching.',
47
52
  useWhen: [
48
53
  'You need text generation, Q&A, summarization, or multi-turn dialogue',
49
54
  'You want web-grounded answers (`online: true`)',
@@ -72,6 +77,57 @@ export const TOOL_DESCRIPTIONS = {
72
77
  ],
73
78
  worksWith: ['validate_model', 'search_models'],
74
79
  }),
80
+ start_chat_completion: buildToolDescription({
81
+ summary: 'Start a chat completion as an async background job. Returns a job_id immediately without waiting ' +
82
+ 'for the model to respond. Use `get_chat_completion_status` to poll for results. Designed for ' +
83
+ 'reasoning models or any request that may exceed MCP timeout limits (~60s).',
84
+ useWhen: [
85
+ 'Using a reasoning model that may take >60 seconds (DeepSeek R1, Claude Opus, etc.)',
86
+ 'Connected through a remote MCP bridge with short timeouts',
87
+ 'You want to fire-and-forget a completion and check back later',
88
+ ],
89
+ notWhen: [
90
+ 'Fast models that respond within seconds → use chat_completion directly',
91
+ 'You need streaming output → use chat_completion',
92
+ 'Messages array is empty',
93
+ ],
94
+ goodExamples: [
95
+ '`{ "messages": [{ "role": "user", "content": "Prove the Riemann hypothesis" }], "model": "deepseek/r1" }`',
96
+ '`{ "messages": [...], "include_reasoning": true }` for long chain-of-thought',
97
+ ],
98
+ badExamples: [
99
+ '`{ "messages": [] }` → INVALID_INPUT',
100
+ 'Using this for simple "hello world" prompts (unnecessary overhead)',
101
+ ],
102
+ failsWhen: [
103
+ 'INVALID_INPUT: empty messages array',
104
+ 'Job may fail in background if model errors or credits exhausted',
105
+ ],
106
+ worksWith: ['get_chat_completion_status', 'chat_completion'],
107
+ }),
108
+ get_chat_completion_status: buildToolDescription({
109
+ summary: 'Check the status of an async chat completion job started with `start_chat_completion`. ' +
110
+ 'Returns the full response when completed, or current status (running/failed) otherwise.',
111
+ useWhen: [
112
+ 'You previously called start_chat_completion and need to check if it finished',
113
+ 'Polling for a long-running reasoning model result',
114
+ ],
115
+ notWhen: [
116
+ "You haven't started a job yet → use start_chat_completion first",
117
+ 'You want to start a new completion',
118
+ ],
119
+ goodExamples: ['`{ "job_id": "chat_20260806_001" }`'],
120
+ badExamples: [
121
+ '`{ "job_id": "" }` → INVALID_INPUT',
122
+ '`{ "job_id": "nonexistent" }` → job not found',
123
+ ],
124
+ failsWhen: [
125
+ 'INVALID_INPUT: empty job_id',
126
+ 'JOB_FAILED: the background completion encountered an error',
127
+ 'Job not found: invalid job_id or job from a previous session',
128
+ ],
129
+ worksWith: ['start_chat_completion', 'chat_completion'],
130
+ }),
75
131
  analyze_image: buildToolDescription({
76
132
  summary: 'Analyze one image with a vision model. Accepts a sandboxed local path, https URL, or base64 data URL. ' +
77
133
  'Output is model-generated and tagged `_meta.content_is_untrusted: true`.',
@@ -263,6 +319,38 @@ export const TOOL_DESCRIPTIONS = {
263
319
  ],
264
320
  worksWith: ['analyze_image', 'generate_video_from_image'],
265
321
  }),
322
+ generate_image_dedicated: buildToolDescription({
323
+ summary: "Generate images via OpenRouter's dedicated Image API (POST /api/v1/images). Supports " +
324
+ 'normalized resolution tiers, quality levels, output format selection, and reference images. ' +
325
+ 'New image models are added exclusively to this endpoint. Default model: google/gemini-2.5-flash-image.',
326
+ useWhen: [
327
+ 'You need image generation with fine control over resolution, quality, and format',
328
+ 'You want to use newer image models only available on the dedicated API',
329
+ 'You need image-to-image with `input_references`',
330
+ ],
331
+ notWhen: [
332
+ 'You want to analyze an existing image → analyze_image',
333
+ 'You want video → generate_video or generate_video_from_image',
334
+ 'Prompt is empty or only whitespace',
335
+ ],
336
+ goodExamples: [
337
+ '`{ "prompt": "A watercolor fox", "resolution": "2K", "quality": "high" }`',
338
+ '`{ "prompt": "Product shot", "input_references": ["product.jpg"], "aspect_ratio": "16:9" }`',
339
+ '`{ "prompt": "Logo", "output_format": "svg", "save_path": "out/logo.svg" }`',
340
+ ],
341
+ badExamples: [
342
+ '`{ "prompt": "" }` → INVALID_INPUT',
343
+ '`{ "resolution": "8K" }` → not in allowed enum',
344
+ '`{ "quality": "ultra" }` → not in allowed enum',
345
+ ],
346
+ failsWhen: [
347
+ 'INVALID_INPUT: empty prompt, invalid resolution/quality/output_format',
348
+ 'UNSAFE_PATH: save_path or input_references escaped sandbox',
349
+ "UPSTREAM_REFUSED: content policy, model doesn't support requested options",
350
+ 'MODEL_NOT_FOUND: invalid model slug',
351
+ ],
352
+ worksWith: ['analyze_image', 'generate_video_from_image'],
353
+ }),
266
354
  generate_audio: buildToolDescription({
267
355
  summary: 'Generate speech or music from a text prompt. Output format is auto-detected; file extension auto-corrected on save.',
268
356
  useWhen: [
@@ -285,6 +373,66 @@ export const TOOL_DESCRIPTIONS = {
285
373
  ],
286
374
  worksWith: ['analyze_audio'],
287
375
  }),
376
+ text_to_speech: buildToolDescription({
377
+ summary: "Convert text to speech via OpenRouter's dedicated TTS endpoint (POST /api/v1/audio/speech). " +
378
+ 'Faster and cheaper than chat completions for pure TTS. Models: OpenAI GPT-4o Mini TTS, Google Gemini Flash TTS, Mistral Voxtral.',
379
+ useWhen: [
380
+ 'You need text-to-speech with specific voice control',
381
+ 'You want fast, dedicated TTS without chat overhead',
382
+ 'You need a specific audio format (mp3, opus, wav, etc.)',
383
+ ],
384
+ notWhen: [
385
+ 'You want to generate music or sound effects → generate_audio',
386
+ 'You want to transcribe audio → speech_to_text or analyze_audio',
387
+ 'Input text is empty',
388
+ ],
389
+ goodExamples: [
390
+ '`{ "input": "Hello, welcome to our app!" }`',
391
+ '`{ "input": "...", "voice": "nova", "response_format": "mp3", "save_path": "out/welcome.mp3" }`',
392
+ '`{ "input": "...", "instructions": "speak slowly and clearly", "speed": 0.8 }`',
393
+ ],
394
+ badExamples: [
395
+ '`{ "input": "" }` → INVALID_INPUT',
396
+ '`{ "prompt": "text" }` → wrong key; use `input`',
397
+ '`{ "response_format": "mp4" }` → not a valid audio format',
398
+ ],
399
+ failsWhen: [
400
+ 'INVALID_INPUT: empty input, invalid response_format',
401
+ 'UNSAFE_PATH: save_path escaped sandbox',
402
+ 'UPSTREAM_REFUSED: content policy or credits',
403
+ ],
404
+ worksWith: ['speech_to_text', 'analyze_audio'],
405
+ }),
406
+ speech_to_text: buildToolDescription({
407
+ summary: "Transcribe audio via OpenRouter's dedicated STT endpoint (POST /api/v1/audio/transcriptions). " +
408
+ 'Faster and cheaper than chat completions for pure transcription. Models: Whisper-1, GPT-4o Transcribe, Voxtral.',
409
+ useWhen: [
410
+ 'You need fast transcription of audio files',
411
+ 'You want pure speech-to-text without analysis or Q&A',
412
+ 'You need structured output (SRT, VTT, verbose JSON)',
413
+ ],
414
+ notWhen: [
415
+ 'You want to ask questions about audio → analyze_audio',
416
+ 'You want music analysis or sound identification → analyze_audio',
417
+ 'You want TTS → text_to_speech or generate_audio',
418
+ ],
419
+ goodExamples: [
420
+ '`{ "audio_path": "recording.mp3" }`',
421
+ '`{ "audio_path": "meeting.wav", "language": "en", "response_format": "srt" }`',
422
+ '`{ "audio_path": "https://example.com/audio.mp3", "model": "openai/gpt-4o-transcribe" }`',
423
+ ],
424
+ badExamples: [
425
+ '`{ "audio_path": "" }` → INVALID_INPUT',
426
+ '`{ "path": "audio.mp3" }` → wrong key; use `audio_path`',
427
+ '`{ "audio_path": "/etc/shadow" }` → UNSAFE_PATH',
428
+ ],
429
+ failsWhen: [
430
+ 'INVALID_INPUT: empty audio_path, invalid response_format, unreadable file',
431
+ 'UNSAFE_PATH: audio_path escaped sandbox',
432
+ 'UPSTREAM_REFUSED: unsupported format or credits exhausted',
433
+ ],
434
+ worksWith: ['text_to_speech', 'analyze_audio'],
435
+ }),
288
436
  generate_video: buildToolDescription({
289
437
  summary: 'Generate video from a text prompt (optional first/last frame or reference images). Submits an async job, ' +
290
438
  'polls until `max_wait_ms`, downloads on completion. Emits MCP progress when client sends `progressToken`. ' +
@@ -0,0 +1,51 @@
1
+ import 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 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
+ }
15
+ export interface GetChatCompletionStatusRequest {
16
+ job_id: string;
17
+ }
18
+ export type AsyncJobStatus = 'queued' | 'running' | 'completed' | 'failed';
19
+ export declare function handleStartChatCompletion(request: {
20
+ params: {
21
+ arguments: StartChatCompletionRequest;
22
+ };
23
+ }, openai: OpenAI, defaultModel?: string): Promise<import("../errors.js").ToolErrorResult | {
24
+ content: {
25
+ type: "text";
26
+ text: string;
27
+ }[];
28
+ _meta: {
29
+ server_version: string;
30
+ job_id: string;
31
+ status: "running";
32
+ model: string;
33
+ };
34
+ }>;
35
+ export declare function handleGetChatCompletionStatus(request: {
36
+ params: {
37
+ arguments: GetChatCompletionStatusRequest;
38
+ };
39
+ }): Promise<import("../errors.js").ToolErrorResult | {
40
+ content: {
41
+ type: "text";
42
+ text: string;
43
+ }[];
44
+ _meta: {
45
+ server_version: string;
46
+ job_id: string;
47
+ status: "queued" | "completed" | "running";
48
+ model: string;
49
+ created_at: string;
50
+ };
51
+ }>;
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Async chat completions — resumable workflow for long-running requests.
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';
19
+ import path from 'node:path';
20
+ import { ErrorCode, toolError } from '../errors.js';
21
+ import { SERVER_VERSION } from '../version.js';
22
+ import { logger } from '../logger.js';
23
+ import { extractCompletionText, buildCompletionMeta } from './completion-utils.js';
24
+ import { readProviderDefaults, mergeProviderOptions, buildProviderBody, resolveMaxTokens, } from './provider-routing.js';
25
+ import { buildCacheHeaders } from './cache.js';
26
+ // ─── Job Store ───────────────────────────────────────────────────────────────
27
+ const jobs = new Map();
28
+ let jobCounter = 0;
29
+ function generateJobId() {
30
+ jobCounter += 1;
31
+ const ts = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
32
+ return `chat_${ts}_${String(jobCounter).padStart(3, '0')}`;
33
+ }
34
+ function getJobsDir() {
35
+ const outputDir = process.env.OPENROUTER_OUTPUT_DIR;
36
+ if (!outputDir)
37
+ return null;
38
+ return path.join(outputDir, 'openrouter-jobs');
39
+ }
40
+ async function persistJob(job) {
41
+ const dir = getJobsDir();
42
+ if (!dir)
43
+ return;
44
+ try {
45
+ const jobDir = path.join(dir, job.id);
46
+ await fs.mkdir(jobDir, { recursive: true });
47
+ await fs.writeFile(path.join(jobDir, 'status.json'), JSON.stringify(job, null, 2));
48
+ if (job.status === 'completed' && job.result?.text) {
49
+ await fs.writeFile(path.join(jobDir, 'response.md'), job.result.text);
50
+ }
51
+ }
52
+ catch (err) {
53
+ logger.warn('async_chat.persist_error', {
54
+ job_id: job.id,
55
+ err: err instanceof Error ? err.message : String(err),
56
+ });
57
+ }
58
+ }
59
+ // ─── Handlers ────────────────────────────────────────────────────────────────
60
+ const DEFAULT_MODEL = 'nvidia/nemotron-nano-12b-v2-vl:free';
61
+ function readIncludeReasoningDefault() {
62
+ const raw = (process.env.OPENROUTER_INCLUDE_REASONING ?? '').trim().toLowerCase();
63
+ return raw === '1' || raw === 'true' || raw === 'yes';
64
+ }
65
+ export async function handleStartChatCompletion(request, openai, defaultModel) {
66
+ const args = request.params.arguments ?? { messages: [] };
67
+ const { messages, model, temperature, max_tokens, provider, include_reasoning, online, web_max_results, cache, cache_ttl, cache_clear, } = args;
68
+ if (!messages?.length) {
69
+ return toolError(ErrorCode.INVALID_INPUT, 'Messages array cannot be empty.');
70
+ }
71
+ const effectiveModel = model || defaultModel || DEFAULT_MODEL;
72
+ const jobId = generateJobId();
73
+ // Create the job immediately
74
+ const job = {
75
+ id: jobId,
76
+ status: 'running',
77
+ createdAt: new Date().toISOString(),
78
+ model: effectiveModel,
79
+ };
80
+ jobs.set(jobId, job);
81
+ logger.audit('async_chat.start', {
82
+ job_id: jobId,
83
+ model: effectiveModel,
84
+ message_count: messages.length,
85
+ });
86
+ // Fire and forget — the completion runs in the background
87
+ runCompletionInBackground(job, openai, {
88
+ messages,
89
+ model: effectiveModel,
90
+ temperature,
91
+ max_tokens,
92
+ provider,
93
+ include_reasoning,
94
+ online,
95
+ web_max_results,
96
+ cache,
97
+ cache_ttl,
98
+ cache_clear,
99
+ });
100
+ // Return immediately with the job ID
101
+ return {
102
+ content: [
103
+ {
104
+ type: 'text',
105
+ text: `Chat completion job started. Use get_chat_completion_status with job_id="${jobId}" to check results.`,
106
+ },
107
+ ],
108
+ _meta: {
109
+ server_version: SERVER_VERSION,
110
+ job_id: jobId,
111
+ status: 'running',
112
+ model: effectiveModel,
113
+ },
114
+ };
115
+ }
116
+ 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
+ const wantsReasoning = opts.include_reasoning ?? readIncludeReasoningDefault();
121
+ const body = {
122
+ model: opts.model,
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;
145
+ try {
146
+ const completion = (await openai.chat.completions.create(body, requestOpts));
147
+ const extracted = extractCompletionText(completion);
148
+ if (!extracted.text) {
149
+ job.status = 'failed';
150
+ job.error = 'Model returned no textual content.';
151
+ }
152
+ else {
153
+ job.status = 'completed';
154
+ job.result = {
155
+ text: extracted.text,
156
+ meta: buildCompletionMeta(extracted, {
157
+ includeReasoning: wantsReasoning,
158
+ extra: { server_version: SERVER_VERSION },
159
+ }),
160
+ };
161
+ }
162
+ }
163
+ catch (err) {
164
+ job.status = 'failed';
165
+ job.error = err instanceof Error ? err.message : String(err);
166
+ logger.warn('async_chat.failed', { job_id: job.id, error: job.error });
167
+ }
168
+ await persistJob(job);
169
+ }
170
+ export async function handleGetChatCompletionStatus(request) {
171
+ const args = request.params.arguments ?? {};
172
+ const jobId = args.job_id?.trim();
173
+ if (!jobId) {
174
+ return toolError(ErrorCode.INVALID_INPUT, 'job_id is required.');
175
+ }
176
+ const job = jobs.get(jobId);
177
+ if (!job) {
178
+ return toolError(ErrorCode.INVALID_INPUT, `No job found with id "${jobId}". Jobs are stored in memory for the current session only.`);
179
+ }
180
+ if (job.status === 'completed' && job.result) {
181
+ return {
182
+ content: [{ type: 'text', text: job.result.text }],
183
+ _meta: {
184
+ server_version: SERVER_VERSION,
185
+ job_id: jobId,
186
+ status: 'completed',
187
+ model: job.model,
188
+ created_at: job.createdAt,
189
+ ...job.result.meta,
190
+ },
191
+ };
192
+ }
193
+ if (job.status === 'failed') {
194
+ return toolError(ErrorCode.JOB_FAILED, job.error || 'Job failed.', {
195
+ job_id: jobId,
196
+ model: job.model,
197
+ created_at: job.createdAt,
198
+ });
199
+ }
200
+ // Still running
201
+ return {
202
+ content: [
203
+ {
204
+ type: 'text',
205
+ text: `Job ${jobId} is still ${job.status}. Try again in a few seconds.`,
206
+ },
207
+ ],
208
+ _meta: {
209
+ server_version: SERVER_VERSION,
210
+ job_id: jobId,
211
+ status: job.status,
212
+ model: job.model,
213
+ created_at: job.createdAt,
214
+ },
215
+ };
216
+ }
@@ -0,0 +1,32 @@
1
+ import type { OpenRouterAPIClient } from '../openrouter-api.js';
2
+ import { type CacheOptions } from './cache.js';
3
+ export interface GenerateImageDedicatedRequest extends CacheOptions {
4
+ prompt: string;
5
+ model?: string;
6
+ resolution?: string;
7
+ aspect_ratio?: string;
8
+ quality?: string;
9
+ output_format?: string;
10
+ n?: number;
11
+ input_references?: string[];
12
+ save_path?: string;
13
+ provider?: Record<string, unknown>;
14
+ }
15
+ export declare function handleGenerateImageDedicated(request: {
16
+ params: {
17
+ arguments: GenerateImageDedicatedRequest;
18
+ };
19
+ }, apiClient: OpenRouterAPIClient): Promise<import("../errors.js").ToolErrorResult | {
20
+ content: ({
21
+ type: "text";
22
+ text: string;
23
+ mimeType?: undefined;
24
+ data?: undefined;
25
+ } | {
26
+ type: "image";
27
+ mimeType: string;
28
+ data: string;
29
+ text?: undefined;
30
+ })[];
31
+ _meta: Record<string, unknown>;
32
+ }>;