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.
- package/README.md +315 -182
- package/dist/create-config.js +14 -14
- package/dist/index.js +3 -3
- package/dist/ninerouter-client.js +38 -36
- package/dist/server.js +9 -9
- package/dist/tools/common.js +1 -8
- package/dist/tools/embeddings.js +17 -12
- package/dist/tools/media.js +159 -86
- package/dist/tools/models.js +12 -9
- package/dist/tools/web.js +28 -22
- package/package.json +37 -8
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import { access, readFile } from
|
|
2
|
-
import os from
|
|
3
|
-
import path from
|
|
4
|
-
import { parse as parseToml } from
|
|
5
|
-
const DEFAULT_CONFIG_PATH = path.join(os.homedir(),
|
|
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 ===
|
|
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(
|
|
20
|
-
return argument.slice(argument.indexOf(
|
|
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(
|
|
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 ===
|
|
30
|
+
if (typeof nestedValue === 'string' && nestedValue.trim()) {
|
|
31
31
|
return nestedValue.trim();
|
|
32
32
|
}
|
|
33
33
|
const topLevelValue = configFile[key];
|
|
34
|
-
if (typeof topLevelValue ===
|
|
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,
|
|
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,
|
|
53
|
-
const apiKeyFromFile = configFile ? pickConfigValue(configFile,
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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(/^\//,
|
|
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 ===
|
|
101
|
+
if (typeof parsed.message === 'string') {
|
|
100
102
|
return parsed.message;
|
|
101
103
|
}
|
|
102
|
-
if (typeof parsed.error ===
|
|
104
|
+
if (typeof parsed.error === 'string') {
|
|
103
105
|
return parsed.error;
|
|
104
106
|
}
|
|
105
|
-
if (parsed.error && typeof 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 ===
|
|
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:
|
|
129
|
+
method: 'POST',
|
|
128
130
|
headers: {
|
|
129
|
-
|
|
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:
|
|
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:
|
|
151
|
+
method: 'POST',
|
|
150
152
|
headers: {
|
|
151
|
-
|
|
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(
|
|
160
|
-
contentType: response.headers.get(
|
|
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:
|
|
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(
|
|
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
|
|
2
|
-
import { StdioServerTransport } from
|
|
3
|
-
import { getConfig, resolveConfigPath } from
|
|
4
|
-
import { registerEmbeddingTools } from
|
|
5
|
-
import { registerMediaTools } from
|
|
6
|
-
import { registerModelTools } from
|
|
7
|
-
import { registerWebTools } from
|
|
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:
|
|
10
|
-
version:
|
|
9
|
+
name: 'ninerouter-mcp',
|
|
10
|
+
version: '1.0.0',
|
|
11
11
|
}, {
|
|
12
12
|
capabilities: {
|
|
13
13
|
tools: {},
|
package/dist/tools/common.js
CHANGED
|
@@ -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
|
}
|
package/dist/tools/embeddings.js
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import { requestJson } from
|
|
3
|
-
import {
|
|
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(
|
|
6
|
-
description:
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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(
|
|
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,
|
|
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:
|
|
35
|
+
content: [{ type: 'text', text: toPrettyJson(payload) }],
|
|
31
36
|
};
|
|
32
37
|
});
|
|
33
38
|
}
|
package/dist/tools/media.js
CHANGED
|
@@ -1,143 +1,216 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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(
|
|
6
|
-
description:
|
|
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(
|
|
9
|
-
model: z
|
|
10
|
-
|
|
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(
|
|
13
|
-
quality: z.enum([
|
|
14
|
-
|
|
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,
|
|
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(
|
|
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
|
|
40
|
-
|
|
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:
|
|
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: [
|
|
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(
|
|
54
|
-
description:
|
|
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(
|
|
57
|
-
model: z
|
|
58
|
-
|
|
59
|
-
|
|
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,
|
|
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(
|
|
120
|
+
throw new Error('No model specified and no default_models.text_to_speech configured.');
|
|
67
121
|
}
|
|
68
|
-
|
|
69
|
-
const binary = await
|
|
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
|
-
|
|
125
|
+
bytes: Buffer.from(binary.base64, 'base64'),
|
|
126
|
+
contentType: binary.contentType ?? 'audio/mpeg',
|
|
127
|
+
b64: binary.base64,
|
|
79
128
|
};
|
|
80
|
-
}
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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: [
|
|
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(
|
|
94
|
-
description:
|
|
95
|
-
inputSchema: z
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
})
|
|
106
|
-
|
|
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(
|
|
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(
|
|
190
|
+
formData.append('model', selectedModel);
|
|
118
191
|
if (audioPath) {
|
|
119
192
|
const blob = await createFileBlob(audioPath);
|
|
120
|
-
formData.append(
|
|
193
|
+
formData.append('file', blob, fileName ?? fileNameFromPath(audioPath));
|
|
121
194
|
}
|
|
122
195
|
else if (audioBase64) {
|
|
123
|
-
const blob = new Blob([Buffer.from(audioBase64,
|
|
124
|
-
formData.append(
|
|
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(
|
|
200
|
+
formData.append('language', language);
|
|
128
201
|
}
|
|
129
202
|
if (prompt) {
|
|
130
|
-
formData.append(
|
|
203
|
+
formData.append('prompt', prompt);
|
|
131
204
|
}
|
|
132
205
|
if (temperature !== undefined) {
|
|
133
|
-
formData.append(
|
|
206
|
+
formData.append('temperature', String(temperature));
|
|
134
207
|
}
|
|
135
|
-
return await requestMultipartText(config,
|
|
208
|
+
return await requestMultipartText(config, '/v1/audio/transcriptions', formData, {
|
|
136
209
|
response_format: responseFormat,
|
|
137
210
|
});
|
|
138
211
|
});
|
|
139
212
|
return {
|
|
140
|
-
content: [{ type:
|
|
213
|
+
content: [{ type: 'text', text: payload.text }],
|
|
141
214
|
};
|
|
142
215
|
});
|
|
143
216
|
}
|
package/dist/tools/models.js
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
|
-
import { z } from
|
|
2
|
-
import { requestGetJson } from
|
|
3
|
-
import { toPrettyJson } from
|
|
4
|
-
const MODEL_KINDS = [
|
|
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(
|
|
7
|
-
description:
|
|
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
|
|
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,
|
|
17
|
+
: await requestGetJson(config, '/v1/models');
|
|
15
18
|
return {
|
|
16
|
-
content: [{ type:
|
|
19
|
+
content: [{ type: 'text', text: toPrettyJson(payload) }],
|
|
17
20
|
};
|
|
18
21
|
});
|
|
19
22
|
}
|