@stabgan/openrouter-mcp-multimodal 4.6.1 → 4.6.2

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