@gtrabanco/pi-nan-provider 0.2.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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Runtime model catalog for NaN-compatible providers.
3
+ *
4
+ * Two layers, never either alone:
5
+ *
6
+ * 1. Build-time generated fallback — `scripts/models.generated.ts`, committed
7
+ * to the repo and regenerated pre-publish by `scripts/generate-models.ts`
8
+ * from models.dev. Every capability number in it traces to its source.
9
+ *
10
+ * 2. Runtime fetch of the provider's own `/models` endpoint. NaN runs
11
+ * LiteLLM behind an OpenAI-compatible facade, so the response carries
12
+ * only model `id`s — no capability fields. It is used solely to confirm
13
+ * which model IDs are currently live.
14
+ *
15
+ * Merge: live IDs × generated capability data. A live ID with no generated
16
+ * match is kept with conservative placeholder limits (the same defaults used
17
+ * in custom-provider.md's dynamic-discovery example) and no reasoning
18
+ * support — capabilities stay "unknown", nothing is fabricated. On fetch
19
+ * failure, timeout, or an unusable response, callers fall back to the
20
+ * generated catalog so startup is never blocked.
21
+ */
22
+
23
+ import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
24
+ import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
25
+
26
+ export type { Model };
27
+
28
+ /**
29
+ * Serializable model definition in the generated fallback catalog
30
+ * (scripts/models.generated.ts). Lives here so the generator (scripts/) and
31
+ * the runtime share one definition without a circular type alias.
32
+ */
33
+ export interface GeneratedModelEntry {
34
+ id: string;
35
+ name: string;
36
+ reasoning: boolean;
37
+ input: ("text" | "image")[];
38
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
39
+ contextWindow: number;
40
+ maxTokens: number;
41
+ /** Compat applied to every NaN-compatible model (LiteLLM-confirmed, see scripts/generate-models.ts). */
42
+ compat?: OpenAICompletionsCompat;
43
+ /** Provenance notes for values overriding models.dev or needing manual confirmation. */
44
+ notes?: string[];
45
+ /**
46
+ * Raw models.dev model object (provider `nan`) preserved verbatim so every
47
+ * documented property survives generation — quotas, tiers, release dates,
48
+ * reasoning options, attachment flags, etc. Informational only.
49
+ */
50
+ extras?: Record<string, unknown>;
51
+ }
52
+
53
+ /** pi-ai streaming API used for every NaN-compatible provider. */
54
+ export const NAN_COMPAT_API = "openai-completions" as const;
55
+
56
+ /**
57
+ * Conservative limits for live IDs with no generated capability match.
58
+ * Mirrors the `?? 128000` / `?? 4096` defaults in custom-provider.md's
59
+ * dynamic-discovery example: a safe request envelope, not a capability claim.
60
+ */
61
+ export const UNKNOWN_MODEL_LIMITS = { contextWindow: 128_000, maxTokens: 4_096 } as const;
62
+
63
+ /** Timeout for the live /models fetch; matches the pi-synthetic-provider precedent (~3s). */
64
+ export const DEFAULT_MODELS_TIMEOUT_MS = 3_000;
65
+
66
+ export interface CatalogSource {
67
+ /** Provider id as registered in pi, e.g. "nan". */
68
+ providerId: string;
69
+ /** OpenAI-compatible base URL including version path, e.g. "https://api.nan.builders/v1". */
70
+ baseUrl: string;
71
+ }
72
+
73
+ /** Convert a generated catalog entry into a pi-ai Model for the given provider. */
74
+ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Model<"openai-completions"> {
75
+ return {
76
+ id: entry.id,
77
+ name: entry.name,
78
+ api: NAN_COMPAT_API,
79
+ provider: source.providerId,
80
+ baseUrl: source.baseUrl,
81
+ reasoning: entry.reasoning,
82
+ input: [...entry.input],
83
+ cost: { ...entry.cost },
84
+ contextWindow: entry.contextWindow,
85
+ maxTokens: entry.maxTokens,
86
+ ...(entry.compat ? { compat: { ...entry.compat } } : {}),
87
+ };
88
+ }
89
+
90
+ /** The generated fallback catalog as pi-ai Models for the given provider. */
91
+ export function baselineModels(source: CatalogSource): Model<"openai-completions">[] {
92
+ return NAN_GENERATED_MODELS.map((entry) => toModel(entry, source));
93
+ }
94
+
95
+ export interface LiveModelListOptions {
96
+ baseUrl: string;
97
+ /** Optional bearer key. NaN's /models returns 401 without one. */
98
+ apiKey?: string;
99
+ timeoutMs?: number;
100
+ /** Injectable for tests; defaults to global fetch. */
101
+ fetchImpl?: typeof fetch;
102
+ }
103
+
104
+ /**
105
+ * Fetch live model IDs from `{baseUrl}/models`.
106
+ *
107
+ * Returns `undefined` on any failure — non-OK status, timeout, malformed or
108
+ * empty body — so callers fall back to the generated catalog instead of
109
+ * failing startup. IDs are trimmed, de-duplicated, and order-preserving.
110
+ */
111
+ export async function listLiveModelIds(options: LiveModelListOptions): Promise<string[] | undefined> {
112
+ const { baseUrl, apiKey, timeoutMs = DEFAULT_MODELS_TIMEOUT_MS, fetchImpl = fetch } = options;
113
+
114
+ const url = `${baseUrl.replace(/\/+$/, "")}/models`;
115
+ const headers: Record<string, string> = { Accept: "application/json" };
116
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
117
+
118
+ const controller = new AbortController();
119
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
120
+ try {
121
+ const response = await fetchImpl(url, { headers, signal: controller.signal });
122
+ if (!response.ok) return undefined;
123
+
124
+ const payload = (await response.json()) as { data?: Array<{ id?: unknown }> };
125
+ const rows = payload?.data;
126
+ if (!Array.isArray(rows)) return undefined;
127
+
128
+ const ids = rows
129
+ .map((row) => (typeof row?.id === "string" ? row.id.trim() : ""))
130
+ .filter((id) => id.length > 0);
131
+ // An empty live list is indistinguishable from "endpoint unusable":
132
+ // prefer the generated catalog over an empty registration.
133
+ return ids.length > 0 ? [...new Set(ids)] : undefined;
134
+ } catch {
135
+ return undefined;
136
+ } finally {
137
+ clearTimeout(timeoutId);
138
+ }
139
+ }
140
+
141
+ export interface MergedCatalog {
142
+ models: Model<"openai-completions">[];
143
+ /** Live IDs resolved against generated capability data. */
144
+ matched: string[];
145
+ /** Live IDs kept with unknown capabilities (conservative limits). */
146
+ unknown: string[];
147
+ }
148
+
149
+ /**
150
+ * Merge live model IDs with the generated capability catalog.
151
+ * Known IDs get generated data; unknown IDs get conservative placeholder
152
+ * limits, `reasoning: false`, and zero cost — documented defaults, not
153
+ * invented capabilities.
154
+ */
155
+ export function mergeLiveWithGenerated(
156
+ liveIds: readonly string[],
157
+ source: CatalogSource,
158
+ generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
159
+ ): MergedCatalog {
160
+ const byId = new Map(generated.map((entry) => [entry.id, entry]));
161
+ const models: Model<"openai-completions">[] = [];
162
+ const matched: string[] = [];
163
+ const unknown: string[] = [];
164
+
165
+ for (const id of liveIds) {
166
+ const entry = byId.get(id);
167
+ if (entry) {
168
+ models.push(toModel(entry, source));
169
+ matched.push(id);
170
+ } else {
171
+ models.push({
172
+ id,
173
+ name: id,
174
+ api: NAN_COMPAT_API,
175
+ provider: source.providerId,
176
+ baseUrl: source.baseUrl,
177
+ reasoning: false,
178
+ input: ["text"],
179
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
180
+ contextWindow: UNKNOWN_MODEL_LIMITS.contextWindow,
181
+ maxTokens: UNKNOWN_MODEL_LIMITS.maxTokens,
182
+ });
183
+ unknown.push(id);
184
+ }
185
+ }
186
+
187
+ return { models, matched, unknown };
188
+ }
189
+
190
+ export interface FetchModelsOptions {
191
+ /** Bearer key for the live fetch, when one is already resolved. */
192
+ apiKey?: string;
193
+ timeoutMs?: number;
194
+ /** Injectable for tests; defaults to global fetch. */
195
+ fetchImpl?: typeof fetch;
196
+ }
197
+
198
+ export interface ResolvedCatalog {
199
+ /** Live IDs × generated capability data; the generated catalog when live fetch fails. */
200
+ models: Model<"openai-completions">[];
201
+ /**
202
+ * Live model IDs from the provider's /models endpoint when the fetch
203
+ * succeeded — the authoritative per-key list (what your NaN membership can
204
+ * actually use, e.g. premium-tier models included/excluded). `undefined`
205
+ * when the live fetch failed and the generated fallback was used.
206
+ */
207
+ liveIds: Set<string> | undefined;
208
+ /** Live IDs kept with unknown capabilities (present only when liveIds is set). */
209
+ unknownIds: string[];
210
+ }
211
+
212
+ /**
213
+ * Resolve the effective catalog: live `/models` IDs × generated capability
214
+ * data. A successful live fetch is authoritative (tier-aware: it reflects
215
+ * exactly what your NaN key can use); failure degrades to the generated
216
+ * fallback so startup is never blocked.
217
+ */
218
+ export async function resolveCatalog(
219
+ source: CatalogSource,
220
+ options: FetchModelsOptions = {},
221
+ ): Promise<ResolvedCatalog> {
222
+ const baseline = baselineModels(source);
223
+ try {
224
+ const liveIds = await listLiveModelIds({
225
+ baseUrl: source.baseUrl,
226
+ apiKey: options.apiKey,
227
+ timeoutMs: options.timeoutMs,
228
+ fetchImpl: options.fetchImpl,
229
+ });
230
+ if (!liveIds) return { models: baseline, liveIds: undefined, unknownIds: [] };
231
+ const merged = mergeLiveWithGenerated(liveIds, source);
232
+ return { models: merged.models, liveIds: new Set(liveIds), unknownIds: merged.unknown };
233
+ } catch {
234
+ return { models: baseline, liveIds: undefined, unknownIds: [] };
235
+ }
236
+ }
237
+
238
+ /**
239
+ * `fetchModels` implementation handed to pi-ai's `createProvider`: live IDs ×
240
+ * generated capability data, falling back to the generated catalog whenever
241
+ * the live endpoint is unreachable, slow, non-OK, malformed, or empty —
242
+ * startup is never blocked by the catalog fetch.
243
+ */
244
+ export async function fetchNanCompatibleModels(
245
+ source: CatalogSource,
246
+ options: FetchModelsOptions = {},
247
+ ): Promise<readonly Model<"openai-completions">[]> {
248
+ return (await resolveCatalog(source, options)).models;
249
+ }
250
+
251
+ /** Metadata of the generated fallback catalog, for diagnostics and docs. */
252
+ export function generatedCatalogMeta() {
253
+ return GENERATED_CATALOG_META;
254
+ }
package/src/index.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * @gtrabanco/pi-nan-provider — NaN Builders provider + MCP bridges for pi.
3
+ *
4
+ * What this extension registers:
5
+ *
6
+ * 1. Providers: every entry in PROVIDERS via the shared OpenAI-compatible
7
+ * factory. The generated fallback catalog is available immediately at
8
+ * startup; pi's Models runtime calls fetchModels (live /models ×
9
+ * generated catalog) on network refreshes, and filterModels prunes the
10
+ * models your key cannot actually use (tier detection). On pi versions
11
+ * without the native Provider overload, registration falls back to the
12
+ * legacy (name, config) form with the same baseline catalog.
13
+ *
14
+ * 2. MCP tools over pi's registerTool (pi intentionally has no MCP client):
15
+ * - `nan_web_search` via NaN's official remote MCP server
16
+ * (https://api.nan.builders/mcp) — on by default, NAN_MCP_TOOLS=0 to
17
+ * disable.
18
+ * - Media tools bridging the optional community `nan-mcp-server`
19
+ * (stdio, spawned per call) — off by default, NAN_MEDIA_MCP=1 to
20
+ * enable, so nothing runs unless audio/image/transcription is invoked.
21
+ */
22
+
23
+ import type { ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
24
+ import { baselineModels } from "./fetch-models.ts";
25
+ import { createNanWebSearchTool, mcpToolsDisabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
26
+ import { createNanMediaTools, mediaMcpEnabled } from "./mcp/nan-media.ts";
27
+ import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
28
+ import { PROVIDERS } from "./providers.ts";
29
+
30
+ /**
31
+ * Register a provider on any pi version: the native full-Provider overload
32
+ * where supported, else the documented legacy (name, config) form with
33
+ * env-var auth. The fallback loses stored-credential auth (env only) — a
34
+ * documented limitation of the legacy path, never a silent auth invention.
35
+ */
36
+ function registerProviderCompat(pi: ExtensionAPI, config: OpenAICompatibleProviderConfig): void {
37
+ const native = createNanCompatibleProvider(config);
38
+ try {
39
+ pi.registerProvider(native);
40
+ return;
41
+ } catch (error) {
42
+ console.warn(
43
+ `[pi-nan-provider] native provider registration rejected (${error instanceof Error ? error.message : String(error)}); ` +
44
+ "falling back to legacy config form (env-var auth only).",
45
+ );
46
+ }
47
+ const legacy: ProviderConfig = {
48
+ name: config.name,
49
+ baseUrl: config.baseUrl,
50
+ // Legacy config syntax: one env-var reference; first configured var wins.
51
+ ...(config.envVars.length > 0 ? { apiKey: `$${config.envVars[0]}` } : {}),
52
+ api: "openai-completions",
53
+ models: baselineModels({ providerId: config.id, baseUrl: config.baseUrl }).map((model) => ({
54
+ id: model.id,
55
+ name: model.name,
56
+ reasoning: model.reasoning,
57
+ input: [...model.input],
58
+ cost: { ...model.cost },
59
+ contextWindow: model.contextWindow,
60
+ maxTokens: model.maxTokens,
61
+ ...(model.compat ? { compat: { ...model.compat } } : {}),
62
+ })),
63
+ };
64
+ pi.registerProvider(config.id, legacy);
65
+ }
66
+
67
+ /**
68
+ * Register MCP-bridged tools when the runtime supports them. Old pi versions
69
+ * without registerTool simply skip this block — provider registration is
70
+ * unaffected.
71
+ */
72
+ function registerMcpToolsCompat(pi: ExtensionAPI): void {
73
+ if (typeof pi.registerTool !== "function") return;
74
+ if (!mcpToolsDisabled()) {
75
+ pi.registerTool(createNanWebSearchTool());
76
+ }
77
+ if (mediaMcpEnabled()) {
78
+ for (const tool of createNanMediaTools()) {
79
+ pi.registerTool(tool);
80
+ }
81
+ }
82
+ }
83
+
84
+ export default function nanProviderExtension(pi: ExtensionAPI): void {
85
+ for (const config of PROVIDERS) {
86
+ registerProviderCompat(pi, config);
87
+ }
88
+ registerMcpToolsCompat(pi);
89
+ }
90
+
91
+ /** Exposed for tests: the env var this package uses for every NaN surface. */
92
+ export { NAN_API_KEY_ENV };
93
+
94
+ /** Exposed for advanced consumers that want the typed provider factory directly. */
95
+ export { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
96
+ export { PROVIDERS, NAN_PROVIDER } from "./providers.ts";
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Optional bridge to the community stdio MCP server
3
+ * `nan-mcp-server` (https://github.com/luciferfran/nan-mcp-server), which
4
+ * exposes NaN's media tools: image generation/editing (flux-2-klein), TTS
5
+ * (kokoro), STT (whisper), plus voice listing.
6
+ *
7
+ * Opt-in and fully lazy:
8
+ * - OFF by default. Enable with NAN_MEDIA_MCP=1.
9
+ * - The server process is spawned per tool call and terminated right after —
10
+ * zero startup cost, nothing runs unless audio/image/transcription is
11
+ * actually invoked.
12
+ * - NAN_API_KEY is forwarded to the child; NAN_OUTPUT_DIR and the server's
13
+ * other env vars inherit from your environment (generated files land in
14
+ * ~/nan-mcp-output/ by default).
15
+ * - Version pinning follows the upstream server's own supply-chain guidance:
16
+ * NAN_MEDIA_MCP_VERSION (default "1.0.7"), or pass a custom command with
17
+ * NAN_MEDIA_MCP_COMMAND (space-separated, e.g. "bunx nan-mcp-server@1.0.7").
18
+ */
19
+
20
+ import { Type, type TSchema } from "@earendil-works/pi-ai";
21
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
22
+ import { callStdioMcpTool } from "./stdio-client.ts";
23
+ import { NAN_API_KEY_ENV, resolveNanApiKey, type NapiKeyContext } from "./nan-search.ts";
24
+
25
+ export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
26
+ export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
27
+ export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
28
+ export const NAN_MEDIA_MCP_TIMEOUT_ENV = "NAN_MEDIA_MCP_TIMEOUT_MS";
29
+ export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.0.7";
30
+ export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
31
+
32
+ /** Media tools bridged from the stdio MCP server (audio/image/transcription scope). */
33
+ export const NAN_MEDIA_TOOLS = [
34
+ "nan_generate_image",
35
+ "nan_edit_image",
36
+ "nan_text_to_speech",
37
+ "nan_list_voices",
38
+ "nan_speech_to_text",
39
+ ] as const;
40
+
41
+ /** Truthy env parse: 1/true/on (case-insensitive). */
42
+ function envEnabled(name: string): boolean {
43
+ const value = process.env[name]?.trim().toLowerCase();
44
+ return value === "1" || value === "true" || value === "on";
45
+ }
46
+
47
+ /** Whether the optional nan-mcp-server bridge is enabled (NAN_MEDIA_MCP=1). */
48
+ export function mediaMcpEnabled(): boolean {
49
+ return envEnabled(NAN_MEDIA_MCP_ENV);
50
+ }
51
+
52
+ /** Default spawn: `npx -y nan-mcp-server@<pinned version>` (npx caches after first use). */
53
+ export function mediaMcpCommand(version = process.env[NAN_MEDIA_MCP_VERSION_ENV] || DEFAULT_NAN_MEDIA_MCP_VERSION): string[] {
54
+ const custom = process.env[NAN_MEDIA_MCP_COMMAND_ENV]?.trim();
55
+ if (custom) return custom.split(/\s+/);
56
+ return ["npx", "-y", `nan-mcp-server@${version}`];
57
+ }
58
+
59
+ function mediaMcpTimeoutMs(): number {
60
+ const parsed = Number.parseInt(process.env[NAN_MEDIA_MCP_TIMEOUT_ENV] ?? "", 10);
61
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MEDIA_MCP_TIMEOUT_MS;
62
+ }
63
+
64
+ interface MediaToolSpec<TParams extends TSchema = TSchema> {
65
+ name: (typeof NAN_MEDIA_TOOLS)[number];
66
+ mcpTool: string;
67
+ label: string;
68
+ description: string;
69
+ promptSnippet: string;
70
+ parameters: TParams;
71
+ }
72
+
73
+ const sizeProperty = () =>
74
+ Type.Optional(
75
+ Type.String({
76
+ description: 'Image size "WxH" divisible by 16, e.g. 1024x1024, 1536x1024, 1024x1536. Default 1024x1024',
77
+ }),
78
+ );
79
+
80
+ function defineMediaTool<TParams extends TSchema>(spec: MediaToolSpec<TParams>): ToolDefinition<TParams> {
81
+ return {
82
+ name: spec.name,
83
+ label: spec.label,
84
+ description: spec.description,
85
+ promptSnippet: spec.promptSnippet,
86
+ parameters: spec.parameters,
87
+ execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
88
+ const apiKey = await resolveNanApiKey(ctx as unknown as NapiKeyContext);
89
+ if (!apiKey) {
90
+ throw new Error(`${NAN_API_KEY_ENV} is not set. Export it or run /login nan to use NaN media tools.`);
91
+ }
92
+ const result = await callStdioMcpTool(spec.mcpTool, params as Record<string, unknown>, {
93
+ command: mediaMcpCommand(),
94
+ env: { [NAN_API_KEY_ENV]: apiKey },
95
+ timeoutMs: mediaMcpTimeoutMs(),
96
+ ...(signal ? { signal } : {}),
97
+ });
98
+ if (!result.ok) {
99
+ throw new Error(
100
+ `${result.error ?? "nan-mcp-server call failed"}${result.stderrTail ? ` (server stderr: ${result.stderrTail})` : ""}`,
101
+ );
102
+ }
103
+ return { content: [{ type: "text", text: result.text }], details: undefined } as const;
104
+ },
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Build the media tool set. Registered only when NAN_MEDIA_MCP=1 and the
110
+ * runtime supports registerTool; execution spawns the MCP server per call.
111
+ * Schemas mirror nan-mcp-server's zod input schemas (v1.0.7).
112
+ */
113
+ export function createNanMediaTools(): ToolDefinition[] {
114
+ return [
115
+ defineMediaTool({
116
+ name: "nan_generate_image",
117
+ mcpTool: "generate_image",
118
+ label: "NaN Generate Image",
119
+ description:
120
+ "Generate an image with flux-2-klein (NaN API). Returns the saved image file path (under ~/nan-mcp-output/ by default) and its URL.",
121
+ promptSnippet: "nan_generate_image(prompt, size?, n?, seed?, guidance?, outputName?): generate an image via NaN (flux-2-klein)",
122
+ parameters: Type.Object({
123
+ prompt: Type.String({ description: "Textual description of the image to generate" }),
124
+ size: sizeProperty(),
125
+ n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
126
+ seed: Type.Optional(Type.Number({ description: "Base seed for reproducibility" })),
127
+ guidance: Type.Optional(Type.Number({ description: "FLUX guidance scale" })),
128
+ outputName: Type.Optional(Type.String({ description: "Optional base name for the output file(s)" })),
129
+ }),
130
+ }),
131
+ defineMediaTool({
132
+ name: "nan_edit_image",
133
+ mcpTool: "edit_image",
134
+ label: "NaN Edit Image",
135
+ description:
136
+ "Edit an image with flux-2-klein image-to-image (NaN API). Takes reference image files and applies a transformation. Returns the saved output image path.",
137
+ promptSnippet: "nan_edit_image(prompt, images, size?, n?, seed?, guidance?, outputName?): edit images via NaN (flux-2-klein)",
138
+ parameters: Type.Object({
139
+ prompt: Type.String({ description: "Description of the edit or transformation to apply" }),
140
+ images: Type.Array(Type.String(), {
141
+ description: "Absolute paths to reference image files (PNG, JPEG, WebP; up to 4, each < 25MB)",
142
+ }),
143
+ size: sizeProperty(),
144
+ n: Type.Optional(Type.Integer({ description: "Number of images to generate (1-4). Default 1", minimum: 1, maximum: 4 })),
145
+ seed: Type.Optional(Type.Number({ description: "Base seed for reproducibility" })),
146
+ guidance: Type.Optional(Type.Number({ description: "FLUX guidance scale" })),
147
+ outputName: Type.Optional(Type.String({ description: "Optional base name for the output file(s)" })),
148
+ }),
149
+ }),
150
+ defineMediaTool({
151
+ name: "nan_text_to_speech",
152
+ mcpTool: "text_to_speech",
153
+ label: "NaN Text To Speech",
154
+ description:
155
+ "Synthesize audio from text with kokoro (NaN API TTS). Returns the saved audio file path. Use nan_list_voices to see all available voices per language.",
156
+ promptSnippet: "nan_text_to_speech(text, voice?, format?, speed?, outputName?): synthesize speech via NaN (kokoro)",
157
+ parameters: Type.Object({
158
+ text: Type.String({ description: "Text to synthesize" }),
159
+ voice: Type.Optional(
160
+ Type.String({
161
+ description:
162
+ 'Voice to use, e.g. "af_heart" (American English female), "ef_dora" (Spanish female), "em_alex" (Spanish male). Use nan_list_voices for the full catalog',
163
+ }),
164
+ ),
165
+ format: Type.Optional(
166
+ Type.Unsafe<"mp3" | "wav" | "flac" | "aac" | "pcm" | "opus">({
167
+ type: "string",
168
+ enum: ["mp3", "wav", "flac", "aac", "pcm", "opus"],
169
+ description: "Audio format. Default mp3",
170
+ }),
171
+ ),
172
+ speed: Type.Optional(Type.Number({ description: "Speech speed. Default 1.0" })),
173
+ outputName: Type.Optional(Type.String({ description: "Optional base name for the output file" })),
174
+ }),
175
+ }),
176
+ defineMediaTool({
177
+ name: "nan_list_voices",
178
+ mcpTool: "list_voices",
179
+ label: "NaN List Voices",
180
+ description: "List all available kokoro TTS voices grouped by language.",
181
+ promptSnippet: "nan_list_voices(): list available kokoro TTS voices",
182
+ parameters: Type.Object({}),
183
+ }),
184
+ defineMediaTool({
185
+ name: "nan_speech_to_text",
186
+ mcpTool: "speech_to_text",
187
+ label: "NaN Speech To Text",
188
+ description: "Transcribe an audio file with whisper (NaN API STT). Returns the transcript.",
189
+ promptSnippet: "nan_speech_to_text(file, language?, verbose?): transcribe an audio file via NaN (whisper)",
190
+ parameters: Type.Object({
191
+ file: Type.String({ description: "Absolute path to the audio file to transcribe" }),
192
+ language: Type.Optional(
193
+ Type.String({ description: 'ISO-639-1 language code, e.g. "es", "en". Auto-detected if omitted' }),
194
+ ),
195
+ verbose: Type.Optional(Type.Boolean({ description: "Return verbose JSON with segments instead of plain text" })),
196
+ }),
197
+ }),
198
+ ];
199
+ }