ninerouter-mcp 1.0.1 → 1.1.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.
@@ -1,37 +1,37 @@
1
- import { access, readFile } from "node:fs/promises";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { parse as parseToml } from "@iarna/toml";
5
- const DEFAULT_CONFIG_PATH = path.join(os.homedir(), ".config", "ninerouter-mcp", "config.toml");
1
+ import { access, readFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { parse as parseToml } from 'toml';
5
+ const DEFAULT_CONFIG_PATH = path.join(os.homedir(), '.config', 'ninerouter-mcp', 'config.toml');
6
6
  export function getDefaultConfigPath() {
7
7
  return DEFAULT_CONFIG_PATH;
8
8
  }
9
9
  export function resolveConfigPath(argv) {
10
10
  for (let index = 2; index < argv.length; index += 1) {
11
11
  const argument = argv[index];
12
- if (argument === "--config" || argument === "--config-file" || argument === "-c") {
12
+ if (argument === '--config' || argument === '--config-file' || argument === '-c') {
13
13
  const nextValue = argv[index + 1];
14
14
  if (!nextValue) {
15
15
  throw new Error(`Missing value for ${argument}.`);
16
16
  }
17
17
  return nextValue;
18
18
  }
19
- if (argument.startsWith("--config=") || argument.startsWith("--config-file=")) {
20
- return argument.slice(argument.indexOf("=") + 1);
19
+ if (argument.startsWith('--config=') || argument.startsWith('--config-file=')) {
20
+ return argument.slice(argument.indexOf('=') + 1);
21
21
  }
22
22
  }
23
23
  return process.env.NINEROUTER_CONFIG?.trim() || undefined;
24
24
  }
25
25
  function normalizeBaseUrl(baseUrl) {
26
- return baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
26
+ return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
27
27
  }
28
28
  function pickConfigValue(configFile, key) {
29
29
  const nestedValue = configFile.ninerouter?.[key];
30
- if (typeof nestedValue === "string" && nestedValue.trim()) {
30
+ if (typeof nestedValue === 'string' && nestedValue.trim()) {
31
31
  return nestedValue.trim();
32
32
  }
33
33
  const topLevelValue = configFile[key];
34
- if (typeof topLevelValue === "string" && topLevelValue.trim()) {
34
+ if (typeof topLevelValue === 'string' && topLevelValue.trim()) {
35
35
  return topLevelValue.trim();
36
36
  }
37
37
  return undefined;
@@ -43,14 +43,14 @@ async function loadConfigFile(configPath) {
43
43
  catch {
44
44
  return null;
45
45
  }
46
- const raw = await readFile(configPath, "utf8");
46
+ const raw = await readFile(configPath, 'utf8');
47
47
  const parsed = parseToml(raw);
48
48
  return parsed;
49
49
  }
50
50
  export async function getConfig(options = {}) {
51
51
  const configFile = await loadConfigFile(options.configPath ?? DEFAULT_CONFIG_PATH);
52
- const baseUrlFromFile = configFile ? pickConfigValue(configFile, "base_url") : undefined;
53
- const apiKeyFromFile = configFile ? pickConfigValue(configFile, "api_key") : undefined;
52
+ const baseUrlFromFile = configFile ? pickConfigValue(configFile, 'base_url') : undefined;
53
+ const apiKeyFromFile = configFile ? pickConfigValue(configFile, 'api_key') : undefined;
54
54
  const baseUrl = baseUrlFromFile ?? process.env.NINEROUTER_URL;
55
55
  if (!baseUrl) {
56
56
  throw new Error(`NINEROUTER_URL is required. Set it in ${options.configPath ?? DEFAULT_CONFIG_PATH} or as an environment variable.`);
@@ -62,14 +62,16 @@ export async function getConfig(options = {}) {
62
62
  return undefined;
63
63
  return Array.isArray(value) ? value : [value];
64
64
  };
65
- const defaultModels = configFile?.default_models ? {
66
- webSearch: normalizeToArray(configFile.default_models.web_search),
67
- webFetch: normalizeToArray(configFile.default_models.web_fetch),
68
- generateImage: normalizeToArray(configFile.default_models.generate_image),
69
- textToSpeech: normalizeToArray(configFile.default_models.text_to_speech),
70
- speechToText: normalizeToArray(configFile.default_models.speech_to_text),
71
- embeddings: normalizeToArray(configFile.default_models.embeddings),
72
- } : undefined;
65
+ const defaultModels = configFile?.default_models
66
+ ? {
67
+ webSearch: normalizeToArray(configFile.default_models.web_search),
68
+ webFetch: normalizeToArray(configFile.default_models.web_fetch),
69
+ generateImage: normalizeToArray(configFile.default_models.generate_image),
70
+ textToSpeech: normalizeToArray(configFile.default_models.text_to_speech),
71
+ speechToText: normalizeToArray(configFile.default_models.speech_to_text),
72
+ embeddings: normalizeToArray(configFile.default_models.embeddings),
73
+ }
74
+ : undefined;
73
75
  return {
74
76
  baseUrl: normalizeBaseUrl(baseUrl),
75
77
  apiKey: apiKey ? apiKey : undefined,
@@ -77,7 +79,7 @@ export async function getConfig(options = {}) {
77
79
  };
78
80
  }
79
81
  function buildUrl(baseUrl, pathname, query) {
80
- const url = new URL(pathname.replace(/^\//, ""), baseUrl);
82
+ const url = new URL(pathname.replace(/^\//, ''), baseUrl);
81
83
  for (const [key, value] of Object.entries(query ?? {})) {
82
84
  if (value === undefined) {
83
85
  continue;
@@ -96,15 +98,15 @@ async function readErrorMessage(response) {
96
98
  }
97
99
  try {
98
100
  const parsed = JSON.parse(text);
99
- if (typeof parsed.message === "string") {
101
+ if (typeof parsed.message === 'string') {
100
102
  return parsed.message;
101
103
  }
102
- if (typeof parsed.error === "string") {
104
+ if (typeof parsed.error === 'string') {
103
105
  return parsed.error;
104
106
  }
105
- if (parsed.error && typeof parsed.error === "object" && "message" in parsed.error) {
107
+ if (parsed.error && typeof parsed.error === 'object' && 'message' in parsed.error) {
106
108
  const message = parsed.error.message;
107
- if (typeof message === "string") {
109
+ if (typeof message === 'string') {
108
110
  return message;
109
111
  }
110
112
  }
@@ -124,9 +126,9 @@ function ensureOk(response, action) {
124
126
  }
125
127
  export async function requestJson(config, pathname, body, query) {
126
128
  const response = await fetch(buildUrl(config.baseUrl, pathname, query), {
127
- method: "POST",
129
+ method: 'POST',
128
130
  headers: {
129
- "Content-Type": "application/json",
131
+ 'Content-Type': 'application/json',
130
132
  ...authHeaders(config.apiKey),
131
133
  },
132
134
  body: JSON.stringify(body),
@@ -136,7 +138,7 @@ export async function requestJson(config, pathname, body, query) {
136
138
  }
137
139
  export async function requestGetJson(config, pathname, query) {
138
140
  const response = await fetch(buildUrl(config.baseUrl, pathname, query), {
139
- method: "GET",
141
+ method: 'GET',
140
142
  headers: {
141
143
  ...authHeaders(config.apiKey),
142
144
  },
@@ -146,9 +148,9 @@ export async function requestGetJson(config, pathname, query) {
146
148
  }
147
149
  export async function requestBinary(config, pathname, body, query) {
148
150
  const response = await fetch(buildUrl(config.baseUrl, pathname, query), {
149
- method: "POST",
151
+ method: 'POST',
150
152
  headers: {
151
- "Content-Type": "application/json",
153
+ 'Content-Type': 'application/json',
152
154
  ...authHeaders(config.apiKey),
153
155
  },
154
156
  body: JSON.stringify(body),
@@ -156,13 +158,13 @@ export async function requestBinary(config, pathname, body, query) {
156
158
  await ensureOk(response, pathname);
157
159
  const buffer = Buffer.from(await response.arrayBuffer());
158
160
  return {
159
- base64: buffer.toString("base64"),
160
- contentType: response.headers.get("content-type"),
161
+ base64: buffer.toString('base64'),
162
+ contentType: response.headers.get('content-type'),
161
163
  };
162
164
  }
163
165
  export async function requestMultipartText(config, pathname, formData, query) {
164
166
  const response = await fetch(buildUrl(config.baseUrl, pathname, query), {
165
- method: "POST",
167
+ method: 'POST',
166
168
  headers: {
167
169
  ...authHeaders(config.apiKey),
168
170
  },
@@ -171,7 +173,7 @@ export async function requestMultipartText(config, pathname, formData, query) {
171
173
  await ensureOk(response, pathname);
172
174
  return {
173
175
  text: await response.text(),
174
- contentType: response.headers.get("content-type"),
176
+ contentType: response.headers.get('content-type'),
175
177
  };
176
178
  }
177
179
  export async function createFileBlob(filePath) {
package/dist/server.js CHANGED
@@ -1,13 +1,13 @@
1
- import { McpServer } from "@modelcontextprotocol/server";
2
- import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
3
- import { getConfig, resolveConfigPath } from "./ninerouter-client.js";
4
- import { registerEmbeddingTools } from "./tools/embeddings.js";
5
- import { registerMediaTools } from "./tools/media.js";
6
- import { registerModelTools } from "./tools/models.js";
7
- import { registerWebTools } from "./tools/web.js";
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
3
+ import { getConfig, resolveConfigPath } from './ninerouter-client.js';
4
+ import { registerEmbeddingTools } from './tools/embeddings.js';
5
+ import { registerMediaTools } from './tools/media.js';
6
+ import { registerModelTools } from './tools/models.js';
7
+ import { registerWebTools } from './tools/web.js';
8
8
  const server = new McpServer({
9
- name: "ninerouter-mcp",
10
- version: "1.0.0",
9
+ name: 'ninerouter-mcp',
10
+ version: '1.0.0',
11
11
  }, {
12
12
  capabilities: {
13
13
  tools: {},
@@ -1,10 +1,3 @@
1
- export function pickModel(model, provider) {
2
- const selected = model ?? provider;
3
- if (!selected) {
4
- throw new Error("Provide either model or provider.");
5
- }
6
- return selected;
7
- }
8
1
  export function toPrettyJson(value) {
9
2
  return JSON.stringify(value, null, 2);
10
3
  }
@@ -21,6 +14,6 @@ export async function tryModelsWithFallback(models, operation) {
21
14
  });
22
15
  }
23
16
  }
24
- const errorSummary = errors.map((e) => `${e.model}: ${e.error}`).join("; ");
17
+ const errorSummary = errors.map((e) => `${e.model}: ${e.error}`).join('; ');
25
18
  throw new Error(`All models failed. Errors: ${errorSummary}`);
26
19
  }
@@ -1,14 +1,19 @@
1
- import { z } from "zod/v4";
2
- import { requestJson } from "../ninerouter-client.js";
3
- import { toPrettyJson, tryModelsWithFallback } from "./common.js";
1
+ import { z } from 'zod/v4';
2
+ import { requestJson } from '../ninerouter-client.js';
3
+ import { tryModelsWithFallback, toPrettyJson } from './common.js';
4
4
  export function registerEmbeddingTools(server, config) {
5
- server.registerTool("embeddings", {
6
- description: "Generate embeddings through 9Router and return the raw embedding payload.",
5
+ server.registerTool('embeddings', {
6
+ description: 'Generate vector embeddings for a single string or a batch of strings. Returns the raw upstream payload (vectors, usage). Use for retrieval, semantic search, clustering, or similarity. Pass `model` (alias `provider`) to pick an embedding model (e.g. `openai/text-embedding-3-small`); otherwise the configured `default_models.embeddings` fallback chain is tried in order.',
7
7
  inputSchema: z.object({
8
- model: z.string().optional().describe("9Router embedding model id, for example openai/text-embedding-3-small."),
9
- provider: z.string().optional().describe("Alias for model; accepted for compatibility with 9Router docs."),
10
- input: z.union([z.string(), z.array(z.string().min(1))]).describe("Text input or a batch of text inputs."),
11
- encodingFormat: z.enum(["float", "base64"]).optional().default("float"),
8
+ model: z
9
+ .string()
10
+ .optional()
11
+ .describe('Embedding model id, for example openai/text-embedding-3-small.'),
12
+ provider: z.string().optional().describe('Alias for model.'),
13
+ input: z
14
+ .union([z.string(), z.array(z.string().min(1))])
15
+ .describe('Text input or a batch of text inputs.'),
16
+ encodingFormat: z.enum(['float', 'base64']).optional().default('float'),
12
17
  dimensions: z.number().int().positive().optional(),
13
18
  }),
14
19
  }, async ({ model, provider, input, encodingFormat, dimensions }) => {
@@ -16,10 +21,10 @@ export function registerEmbeddingTools(server, config) {
16
21
  const defaultModels = config.defaultModels?.embeddings ?? [];
17
22
  const modelsToTry = userModel ? [userModel] : defaultModels;
18
23
  if (modelsToTry.length === 0) {
19
- throw new Error("No model specified and no default_models.embeddings configured.");
24
+ throw new Error('No model specified and no default_models.embeddings configured.');
20
25
  }
21
26
  const payload = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
22
- return await requestJson(config, "/v1/embeddings", {
27
+ return await requestJson(config, '/v1/embeddings', {
23
28
  model: selectedModel,
24
29
  input,
25
30
  encoding_format: encodingFormat,
@@ -27,7 +32,7 @@ export function registerEmbeddingTools(server, config) {
27
32
  });
28
33
  });
29
34
  return {
30
- content: [{ type: "text", text: toPrettyJson(payload) }],
35
+ content: [{ type: 'text', text: toPrettyJson(payload) }],
31
36
  };
32
37
  });
33
38
  }
@@ -1,143 +1,216 @@
1
- import { z } from "zod/v4";
2
- import { createFileBlob, fileNameFromPath, requestBinary, requestJson, requestMultipartText } from "../ninerouter-client.js";
3
- import { toPrettyJson, tryModelsWithFallback } from "./common.js";
1
+ import { writeFile } from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { z } from 'zod/v4';
5
+ import { createFileBlob, fileNameFromPath, requestBinary, requestJson, requestMultipartText, } from '../ninerouter-client.js';
6
+ import { tryModelsWithFallback, toPrettyJson } from './common.js';
7
+ function defaultOutputPath(prefix, ext) {
8
+ const safe = prefix
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, '-')
11
+ .replace(/^-+|-+$/g, '')
12
+ .slice(0, 40) || 'output';
13
+ return path.join(os.tmpdir(), `ninerouter-${safe}-${Date.now()}${ext}`);
14
+ }
15
+ function extFromContentType(contentType, fallback) {
16
+ if (!contentType)
17
+ return fallback;
18
+ const map = {
19
+ 'image/png': '.png',
20
+ 'image/jpeg': '.jpg',
21
+ 'image/jpg': '.jpg',
22
+ 'image/webp': '.webp',
23
+ 'image/gif': '.gif',
24
+ 'image/svg+xml': '.svg',
25
+ 'audio/mpeg': '.mp3',
26
+ 'audio/mp3': '.mp3',
27
+ 'audio/wav': '.wav',
28
+ 'audio/x-wav': '.wav',
29
+ 'audio/ogg': '.ogg',
30
+ 'audio/webm': '.webm',
31
+ 'audio/flac': '.flac',
32
+ 'audio/aac': '.aac',
33
+ };
34
+ return map[contentType.split(';')[0].trim().toLowerCase()] ?? fallback;
35
+ }
4
36
  export function registerMediaTools(server, config) {
5
- server.registerTool("generate_image", {
6
- description: "Generate an image through 9Router and return the upstream response as JSON or base64.",
37
+ server.registerTool('generate_image', {
38
+ description: 'Generate an image from a text prompt. Always writes the file and returns the image as a base64 content block plus `{ outputPath, bytes, contentType }`. Omit `outputPath` to write to a temp file with extension auto-derived from upstream content-type (.png default).',
7
39
  inputSchema: z.object({
8
- prompt: z.string().min(1).describe("Image prompt."),
9
- model: z.string().optional().describe("9Router image model id, for example gemini/gemini-3-pro-image-preview or openai/dall-e-3."),
10
- provider: z.string().optional().describe("Alias for model; accepted for compatibility with 9Router docs."),
40
+ prompt: z.string().min(1).describe('Image prompt.'),
41
+ model: z
42
+ .string()
43
+ .optional()
44
+ .describe('Image model id, for example gemini/gemini-3-pro-image-preview or openai/dall-e-3.'),
45
+ provider: z.string().optional().describe('Alias for model.'),
11
46
  n: z.number().int().positive().max(10).optional().default(1),
12
- size: z.string().optional().default("1024x1024"),
13
- quality: z.enum(["standard", "hd"]).optional(),
14
- responseFormat: z.enum(["url", "b64_json", "binary"]).optional().default("url"),
47
+ size: z.string().optional().default('1024x1024'),
48
+ quality: z.enum(['standard', 'hd']).optional(),
49
+ outputPath: z
50
+ .string()
51
+ .optional()
52
+ .describe('Where to write the image. Default: temp dir, extension derived from upstream content-type.'),
15
53
  }),
16
- }, async ({ prompt, model, provider, n, size, quality, responseFormat }) => {
54
+ }, async ({ prompt, model, provider, n, size, quality, outputPath }) => {
17
55
  const userModel = model ?? provider;
18
56
  const defaultModels = config.defaultModels?.generateImage ?? [];
19
57
  const modelsToTry = userModel ? [userModel] : defaultModels;
20
58
  if (modelsToTry.length === 0) {
21
- throw new Error("No model specified and no default_models.generate_image configured.");
22
- }
23
- if (responseFormat === "binary") {
24
- const binary = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
25
- return await requestBinary(config, "/v1/images/generations", {
26
- model: selectedModel,
27
- prompt,
28
- n,
29
- size,
30
- quality,
31
- }, {
32
- response_format: "binary",
33
- });
34
- });
35
- return {
36
- content: [{ type: "text", text: toPrettyJson({ contentType: binary.contentType, base64: binary.base64 }) }],
37
- };
59
+ throw new Error('No model specified and no default_models.generate_image configured.');
38
60
  }
39
- const payload = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
40
- return await requestJson(config, "/v1/images/generations", {
61
+ const fetchImage = async (selectedModel) => {
62
+ const payload = (await requestJson(config, '/v1/images/generations', {
41
63
  model: selectedModel,
42
64
  prompt,
43
65
  n,
44
66
  size,
45
67
  quality,
46
- response_format: responseFormat,
47
- });
48
- });
68
+ response_format: 'b64_json',
69
+ }));
70
+ const first = payload.data?.[0];
71
+ const b64 = first?.b64_json;
72
+ if (!b64) {
73
+ throw new Error('Image response contained no b64_json data.');
74
+ }
75
+ const bytes = Buffer.from(b64, 'base64');
76
+ const contentType = 'image/png';
77
+ return { bytes, contentType, b64 };
78
+ };
79
+ const result = await tryModelsWithFallback(modelsToTry, fetchImage);
80
+ const ext = extFromContentType(result.contentType, '.png');
81
+ const resolvedPath = outputPath ?? defaultOutputPath(prompt, ext);
82
+ await writeFile(resolvedPath, result.bytes);
49
83
  return {
50
- content: [{ type: "text", text: toPrettyJson(payload) }],
84
+ content: [
85
+ {
86
+ type: 'image',
87
+ data: result.b64,
88
+ mimeType: result.contentType ?? 'image/png',
89
+ },
90
+ {
91
+ type: 'text',
92
+ text: toPrettyJson({
93
+ outputPath: resolvedPath,
94
+ bytes: result.bytes.length,
95
+ contentType: result.contentType,
96
+ }),
97
+ },
98
+ ],
51
99
  };
52
100
  });
53
- server.registerTool("text_to_speech", {
54
- description: "Convert text to speech through 9Router and return base64-encoded audio.",
101
+ server.registerTool('text_to_speech', {
102
+ description: 'Convert text to speech. Always writes the audio file and returns `{ outputPath, bytes, contentType }`. Omit `outputPath` to write to a temp file as .mp3; pass a path to control location.',
55
103
  inputSchema: z.object({
56
- input: z.string().min(1).describe("Text to synthesize."),
57
- model: z.string().optional().describe("9Router TTS model or voice id, for example openai/tts-1 or edge-tts/vi-VN-HoaiMyNeural."),
58
- provider: z.string().optional().describe("Alias for model; accepted for compatibility with 9Router docs."),
59
- responseFormat: z.enum(["json", "mp3"]).optional().default("json"),
104
+ input: z.string().min(1).describe('Text to synthesize.'),
105
+ model: z
106
+ .string()
107
+ .optional()
108
+ .describe('TTS model or voice id, for example openai/tts-1 or edge-tts/vi-VN-HoaiMyNeural.'),
109
+ provider: z.string().optional().describe('Alias for model.'),
110
+ outputPath: z
111
+ .string()
112
+ .optional()
113
+ .describe('Where to write the audio. Default: temp dir, extension derived from upstream content-type (.mp3 default).'),
60
114
  }),
61
- }, async ({ input, model, provider, responseFormat }) => {
115
+ }, async ({ input, model, provider, outputPath }) => {
62
116
  const userModel = model ?? provider;
63
117
  const defaultModels = config.defaultModels?.textToSpeech ?? [];
64
118
  const modelsToTry = userModel ? [userModel] : defaultModels;
65
119
  if (modelsToTry.length === 0) {
66
- throw new Error("No model specified and no default_models.text_to_speech configured.");
120
+ throw new Error('No model specified and no default_models.text_to_speech configured.');
67
121
  }
68
- if (responseFormat === "mp3") {
69
- const binary = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
70
- return await requestBinary(config, "/v1/audio/speech", {
71
- model: selectedModel,
72
- input,
73
- }, {
74
- response_format: "mp3",
75
- });
76
- });
122
+ const fetchAudio = async (selectedModel) => {
123
+ const binary = await requestBinary(config, '/v1/audio/speech', { model: selectedModel, input }, { response_format: 'mp3' });
77
124
  return {
78
- content: [{ type: "text", text: toPrettyJson({ contentType: binary.contentType, audioBase64: binary.base64, format: "mp3" }) }],
125
+ bytes: Buffer.from(binary.base64, 'base64'),
126
+ contentType: binary.contentType ?? 'audio/mpeg',
127
+ b64: binary.base64,
79
128
  };
80
- }
81
- const payload = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
82
- return await requestJson(config, "/v1/audio/speech", {
83
- model: selectedModel,
84
- input,
85
- }, {
86
- response_format: "json",
87
- });
88
- });
129
+ };
130
+ const result = await tryModelsWithFallback(modelsToTry, fetchAudio);
131
+ const ext = extFromContentType(result.contentType, '.mp3');
132
+ const resolvedPath = outputPath ?? defaultOutputPath(input, ext);
133
+ await writeFile(resolvedPath, result.bytes);
89
134
  return {
90
- content: [{ type: "text", text: toPrettyJson(payload) }],
135
+ content: [
136
+ {
137
+ type: 'audio',
138
+ data: result.b64,
139
+ mimeType: result.contentType,
140
+ },
141
+ {
142
+ type: 'text',
143
+ text: toPrettyJson({
144
+ outputPath: resolvedPath,
145
+ bytes: result.bytes.length,
146
+ contentType: result.contentType,
147
+ }),
148
+ },
149
+ ],
91
150
  };
92
151
  });
93
- server.registerTool("speech_to_text", {
94
- description: "Transcribe audio through 9Router using a local file path or a base64 payload.",
95
- inputSchema: z.object({
96
- model: z.string().optional().describe("9Router STT model id, for example openai/whisper-1 or groq/whisper-large-v3-turbo."),
97
- provider: z.string().optional().describe("Alias for model; accepted for compatibility with 9Router docs."),
98
- audioPath: z.string().optional().describe("Local path to an audio file."),
99
- audioBase64: z.string().optional().describe("Base64-encoded audio payload."),
100
- fileName: z.string().optional().describe("File name to use when uploading a base64 payload."),
101
- language: z.string().optional().describe("Optional ISO-639-1 language code, for example en or vi."),
152
+ server.registerTool('speech_to_text', {
153
+ description: 'Transcribe audio. Provide exactly one of `audioPath` (local file) or `audioBase64` (base64 payload); both are sent as multipart upload. `responseFormat` selects the output shape (`json`, `text`, `verbose_json`, `srt`, `vtt`). Pass `model` (alias `provider`) to pick a transcriber (e.g. `openai/whisper-1`, `groq/whisper-large-v3-turbo`); otherwise the configured `default_models.speech_to_text` fallback chain is tried in order.',
154
+ inputSchema: z
155
+ .object({
156
+ model: z
157
+ .string()
158
+ .optional()
159
+ .describe('STT model id, for example openai/whisper-1 or groq/whisper-large-v3-turbo.'),
160
+ provider: z.string().optional().describe('Alias for model.'),
161
+ audioPath: z.string().optional().describe('Local path to an audio file.'),
162
+ audioBase64: z.string().optional().describe('Base64-encoded audio payload.'),
163
+ fileName: z
164
+ .string()
165
+ .optional()
166
+ .describe('File name to use when uploading a base64 payload.'),
167
+ language: z
168
+ .string()
169
+ .optional()
170
+ .describe('Optional ISO-639-1 language code, for example en or vi.'),
102
171
  prompt: z.string().optional(),
103
- responseFormat: z.enum(["json", "text", "verbose_json", "srt", "vtt"]).optional().default("json"),
172
+ responseFormat: z
173
+ .enum(['json', 'text', 'verbose_json', 'srt', 'vtt'])
174
+ .optional()
175
+ .default('json'),
104
176
  temperature: z.number().min(0).max(1).optional(),
105
- }).refine((value) => Boolean(value.audioPath || value.audioBase64), {
106
- message: "Provide audioPath or audioBase64.",
177
+ })
178
+ .refine((value) => Boolean(value.audioPath || value.audioBase64), {
179
+ message: 'Provide audioPath or audioBase64.',
107
180
  }),
108
- }, async ({ model, provider, audioPath, audioBase64, fileName, language, prompt, responseFormat, temperature }) => {
181
+ }, async ({ model, provider, audioPath, audioBase64, fileName, language, prompt, responseFormat, temperature, }) => {
109
182
  const userModel = model ?? provider;
110
183
  const defaultModels = config.defaultModels?.speechToText ?? [];
111
184
  const modelsToTry = userModel ? [userModel] : defaultModels;
112
185
  if (modelsToTry.length === 0) {
113
- throw new Error("No model specified and no default_models.speech_to_text configured.");
186
+ throw new Error('No model specified and no default_models.speech_to_text configured.');
114
187
  }
115
188
  const payload = await tryModelsWithFallback(modelsToTry, async (selectedModel) => {
116
189
  const formData = new FormData();
117
- formData.append("model", selectedModel);
190
+ formData.append('model', selectedModel);
118
191
  if (audioPath) {
119
192
  const blob = await createFileBlob(audioPath);
120
- formData.append("file", blob, fileName ?? fileNameFromPath(audioPath));
193
+ formData.append('file', blob, fileName ?? fileNameFromPath(audioPath));
121
194
  }
122
195
  else if (audioBase64) {
123
- const blob = new Blob([Buffer.from(audioBase64, "base64")]);
124
- formData.append("file", blob, fileName ?? "audio.bin");
196
+ const blob = new Blob([Buffer.from(audioBase64, 'base64')]);
197
+ formData.append('file', blob, fileName ?? 'audio.bin');
125
198
  }
126
199
  if (language) {
127
- formData.append("language", language);
200
+ formData.append('language', language);
128
201
  }
129
202
  if (prompt) {
130
- formData.append("prompt", prompt);
203
+ formData.append('prompt', prompt);
131
204
  }
132
205
  if (temperature !== undefined) {
133
- formData.append("temperature", String(temperature));
206
+ formData.append('temperature', String(temperature));
134
207
  }
135
- return await requestMultipartText(config, "/v1/audio/transcriptions", formData, {
208
+ return await requestMultipartText(config, '/v1/audio/transcriptions', formData, {
136
209
  response_format: responseFormat,
137
210
  });
138
211
  });
139
212
  return {
140
- content: [{ type: "text", text: payload.text }],
213
+ content: [{ type: 'text', text: payload.text }],
141
214
  };
142
215
  });
143
216
  }
@@ -1,19 +1,22 @@
1
- import { z } from "zod/v4";
2
- import { requestGetJson } from "../ninerouter-client.js";
3
- import { toPrettyJson } from "./common.js";
4
- const MODEL_KINDS = ["chat", "image", "tts", "embedding", "web", "stt", "image-to-text"];
1
+ import { z } from 'zod/v4';
2
+ import { requestGetJson } from '../ninerouter-client.js';
3
+ import { toPrettyJson } from './common.js';
4
+ const MODEL_KINDS = ['chat', 'image', 'tts', 'embedding', 'web', 'stt', 'image-to-text'];
5
5
  export function registerModelTools(server, config) {
6
- server.registerTool("list_models", {
7
- description: "List available 9Router models. Call this first to choose a valid model id before using any other 9Router tool.",
6
+ server.registerTool('list_models', {
7
+ description: 'List available model ids. Call this first to discover a valid `model` value before using web_search, web_fetch, generate_image, text_to_speech, speech_to_text, or embeddings. Pass `kind` to filter by capability.',
8
8
  inputSchema: z.object({
9
- kind: z.enum(MODEL_KINDS).optional().describe("Optional model category. Omit to list default chat models."),
9
+ kind: z
10
+ .enum(MODEL_KINDS)
11
+ .optional()
12
+ .describe('Optional model category. Omit to list default chat models.'),
10
13
  }),
11
14
  }, async ({ kind }) => {
12
15
  const payload = kind
13
16
  ? await requestGetJson(config, `/v1/models/${kind}`)
14
- : await requestGetJson(config, "/v1/models");
17
+ : await requestGetJson(config, '/v1/models');
15
18
  return {
16
- content: [{ type: "text", text: toPrettyJson(payload) }],
19
+ content: [{ type: 'text', text: toPrettyJson(payload) }],
17
20
  };
18
21
  });
19
22
  }