@gtrabanco/pi-nan-provider 0.8.0 → 0.10.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.
- package/AGENTS.md +104 -23
- package/README.es.md +68 -15
- package/README.md +54 -20
- package/package.json +5 -5
- package/scripts/check-nan-mcp-server.ts +11 -3
- package/scripts/generate-models.ts +66 -12
- package/scripts/manual-overrides.ts +78 -14
- package/scripts/models.generated.ts +82 -58
- package/src/commands.ts +181 -55
- package/src/context-overflow-classifier.ts +1 -1
- package/src/cross-model-thinking-guard.ts +19 -1
- package/src/fetch-models.ts +11 -0
- package/src/index.ts +99 -41
- package/src/mcp/api-key.ts +109 -0
- package/src/mcp/host-support.ts +94 -0
- package/src/mcp/media-server.ts +56 -0
- package/src/mcp/state.ts +6 -0
- package/src/provider-factory.ts +7 -2
- package/src/usage.ts +10 -5
- package/src/mcp/nan-media.ts +0 -236
- package/src/mcp/nan-search.ts +0 -241
- package/src/mcp/stdio-client.ts +0 -222
package/src/commands.ts
CHANGED
|
@@ -1,45 +1,41 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `/nan-mcp` — slash command configuring
|
|
3
|
-
* registers
|
|
2
|
+
* `/nan-mcp` — slash command configuring both native MCP servers this
|
|
3
|
+
* package registers:
|
|
4
4
|
*
|
|
5
|
-
* - `
|
|
6
|
-
* exposing `
|
|
7
|
-
* - `nan-
|
|
8
|
-
*
|
|
5
|
+
* - `nan-search` — the official NaN remote MCP server (api.nan.builders/mcp),
|
|
6
|
+
* exposing `mcp__nan-search__web_search`. Default: enabled.
|
|
7
|
+
* - `nan-media` — the community stdio media server (flux-2-klein / kokoro /
|
|
8
|
+
* whisper), exposing `mcp__nan-media__generate_image` etc. Default: enabled.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* - `/nan-mcp` or `/nan-mcp status` — state of both bridges and their source.
|
|
14
|
-
* - `/nan-mcp enable [target]` — enable a bridge (or both, when no target is
|
|
15
|
-
* given) and persist it in `<agentDir>/nan-provider.json`; enabled bridges
|
|
16
|
-
* register their tools immediately for the current session.
|
|
17
|
-
* - `/nan-mcp disable [target]` — disable persistently. pi has no
|
|
18
|
-
* unregisterTool, so already-registered tools remain until the next
|
|
19
|
-
* session/restart; the persisted toggle governs future sessions.
|
|
10
|
+
* Both are session-scoped native servers (visible in /mcp, source "extension").
|
|
11
|
+
* /nan-mcp enables/disables them via pi.registerMcpServer / pi.unregisterMcpServer,
|
|
12
|
+
* persisting the toggle in nan-provider.json.
|
|
20
13
|
*
|
|
21
14
|
* Targets accept aliases: `web-search` (`search`, `web_search`, `official`,
|
|
22
15
|
* `nan-web-search`) and `nan-mcp-server` (`media`, `media-mcp`, `nan-media`).
|
|
23
16
|
* Explicit env vars (NAN_MCP_TOOLS, NAN_MEDIA_MCP) override the persisted
|
|
24
17
|
* toggles for the session.
|
|
18
|
+
*
|
|
19
|
+
* Native behavior changes from the old bridge:
|
|
20
|
+
* - `disable` IMMEDIATELY hides tools (unregisterMcpServer disconnects the
|
|
21
|
+
* server; the MCP extension hides tools when disconnected).
|
|
22
|
+
* - `enable` IMMEDIATELY registers the server for the current session.
|
|
23
|
+
* - `status` reads the server state from pi.getMcpServers().
|
|
24
|
+
* - Missing API key at load: server is NOT registered; guidance notification
|
|
25
|
+
* replaces the old "errors at call time" behavior.
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
-
import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource } from "./mcp/
|
|
29
|
-
import { webSearchBridgeEnabled, webSearchBridgeSource } from "./mcp/
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/** Register web-search bridge tools now; must skip already-registered tools. */
|
|
34
|
-
registerWebSearchTools: () => void;
|
|
35
|
-
/** Register media bridge tools now; must skip already-registered tools. */
|
|
36
|
-
registerMediaTools: () => void;
|
|
37
|
-
}
|
|
29
|
+
import { mediaMcpCommand, mediaMcpEnabled, mediaMcpSource, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
|
|
30
|
+
import { webSearchBridgeEnabled, webSearchBridgeSource, mcpToolsEnvExplicit, mcpToolsDisabled } from "./mcp/api-key.ts";
|
|
31
|
+
import { NAN_API_KEY_ENV, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey } from "./mcp/api-key.ts";
|
|
32
|
+
import { NAN_STATE_FILE, MCP_SERVER_NAMES, readBridgeState, writeBridgeState, type BridgeKey, readState, writeState } from "./mcp/state.ts";
|
|
33
|
+
import { connectorMissingMessage, hasMcpConnector } from "./mcp/host-support.ts";
|
|
38
34
|
|
|
39
35
|
const USAGE =
|
|
40
36
|
"Usage: /nan-mcp [status] · /nan-mcp enable [web-search|nan-mcp-server] · /nan-mcp disable [web-search|nan-mcp-server]";
|
|
41
37
|
|
|
42
|
-
/** Canonical bridge targets with aliases
|
|
38
|
+
/** Canonical bridge targets with aliases. */
|
|
43
39
|
const TARGETS: Record<string, BridgeKey> = {
|
|
44
40
|
"web-search": "webSearch",
|
|
45
41
|
search: "webSearch",
|
|
@@ -67,19 +63,72 @@ function sourceLabel(bridge: BridgeKey, source: "env" | "persisted" | "default")
|
|
|
67
63
|
if (source === "persisted") {
|
|
68
64
|
return `persisted in <agentDir>/${NAN_STATE_FILE} (${targetName(bridge)}: ${readBridgeState(bridge)})`;
|
|
69
65
|
}
|
|
70
|
-
return "default (both bridges are enabled
|
|
66
|
+
return "default (both bridges are enabled by default)";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve a bridge key to its native server name. */
|
|
70
|
+
function serverName(bridge: BridgeKey): string {
|
|
71
|
+
return MCP_SERVER_NAMES[bridge];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Describe the bridge in the status message. */
|
|
75
|
+
function bridgeDescription(bridge: BridgeKey): string {
|
|
76
|
+
return bridge === "webSearch"
|
|
77
|
+
? "web-search bridge (official NaN MCP → mcp__nan-search__web_search)"
|
|
78
|
+
: "nan-mcp-server bridge (community media MCP → mcp__nan-media__generate_image/edit_image/text_to_speech/list_voices/speech_to_text)";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Check if a native server of this name is currently registered. */
|
|
82
|
+
function nativeServerRegistered(pi: ExtensionAPI, name: string): boolean {
|
|
83
|
+
try {
|
|
84
|
+
return pi.getMcpServers().some((s) => s.name === name);
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Status message listing both bridges' gate state and native registration.
|
|
91
|
+
* `ctx` is the command context (provides modelRegistry for key lookup).
|
|
92
|
+
*/
|
|
93
|
+
async function statusMessage(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<string> {
|
|
94
|
+
const lines: string[] = [];
|
|
95
|
+
|
|
96
|
+
if (!hasMcpConnector(pi)) {
|
|
97
|
+
lines.push(connectorMissingMessage([MCP_SERVER_NAMES.webSearch, MCP_SERVER_NAMES.mediaMcp]));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const bridge of ["webSearch" as BridgeKey, "mediaMcp" as BridgeKey]) {
|
|
101
|
+
const enabled = bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
|
|
102
|
+
const source = bridge === "webSearch" ? webSearchBridgeSource() : mediaMcpSource();
|
|
103
|
+
const regStatus = nativeServerRegistered(pi, serverName(bridge)) ? "registered (native)" : "not registered";
|
|
104
|
+
lines.push(`${bridgeDescription(bridge)}: ${enabled ? "enabled" : "disabled"} — ${sourceLabel(bridge, source)}, ${regStatus}.`);
|
|
105
|
+
|
|
106
|
+
if (bridge === "webSearch") {
|
|
107
|
+
// Key status for web-search
|
|
108
|
+
const keyStatus = await resolveKeyStatus(ctx);
|
|
109
|
+
lines.push(` API key: ${keyStatus}.`);
|
|
110
|
+
}
|
|
111
|
+
if (bridge === "mediaMcp") {
|
|
112
|
+
lines.push(` Media spawn command: ${mediaMcpCommand().join(" ")}.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
lines.push("Configure with /nan-mcp enable|disable [web-search|nan-mcp-server]; no target = both.");
|
|
117
|
+
return lines.join("\n");
|
|
71
118
|
}
|
|
72
119
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
]
|
|
120
|
+
/** Check API key resolution status for web-search.
|
|
121
|
+
* `ctx` is the command context (provides modelRegistry).
|
|
122
|
+
*/
|
|
123
|
+
async function resolveKeyStatus(ctx: ExtensionCommandContext): Promise<string> {
|
|
124
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
125
|
+
const storedKey = resolveStoredNanApiKey();
|
|
126
|
+
const envKey = process.env[NAN_API_KEY_ENV];
|
|
127
|
+
if (registryKey || storedKey || envKey) return "resolved";
|
|
128
|
+
return "NOT SET — run /login nan or export NAN_API_KEY";
|
|
80
129
|
}
|
|
81
130
|
|
|
82
|
-
/** Parse an optional target argument; no target = both bridges.
|
|
131
|
+
/** Parse an optional target argument; no target = both bridges. */
|
|
83
132
|
function parseTarget(token: string | undefined): { bridges: BridgeKey[]; target?: string } | undefined {
|
|
84
133
|
if (token === undefined) return { bridges: ["webSearch", "mediaMcp"] };
|
|
85
134
|
const key = TARGETS[token.toLowerCase()];
|
|
@@ -92,18 +141,25 @@ function describeBridges(bridges: BridgeKey[]): string {
|
|
|
92
141
|
return names.length === 2 ? "both bridges" : names.join(" and ");
|
|
93
142
|
}
|
|
94
143
|
|
|
95
|
-
|
|
144
|
+
/**
|
|
145
|
+
* Check if a bridge is effectively enabled (gate resolved).
|
|
146
|
+
*/
|
|
147
|
+
function bridgeEffectivelyEnabled(bridge: BridgeKey): boolean {
|
|
148
|
+
return bridge === "webSearch" ? webSearchBridgeEnabled() : mediaMcpEnabled();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function registerNanMcpCommand(pi: ExtensionAPI): void {
|
|
96
152
|
pi.registerCommand("nan-mcp", {
|
|
97
153
|
description:
|
|
98
|
-
"NaN MCP configuration: enable/disable/status for both
|
|
154
|
+
"NaN MCP configuration: enable/disable/status for both native servers (official web-search + community media)",
|
|
99
155
|
getArgumentCompletions: (argumentPrefix: string) => {
|
|
100
156
|
const prefix = argumentPrefix.trim().toLowerCase();
|
|
101
157
|
const items = [
|
|
102
|
-
{ value: "enable", label: "enable", description: "Enable a
|
|
103
|
-
{ value: "disable", label: "disable", description: "Disable a
|
|
104
|
-
{ value: "status", label: "status", description: "Show current
|
|
105
|
-
{ value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search
|
|
106
|
-
{ value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media
|
|
158
|
+
{ value: "enable", label: "enable", description: "Enable a native server (or both) and persist it" },
|
|
159
|
+
{ value: "disable", label: "disable", description: "Disable a native server (or both) persistently" },
|
|
160
|
+
{ value: "status", label: "status", description: "Show current server status and registration" },
|
|
161
|
+
{ value: "enable web-search", label: "enable web-search", description: "Enable the official NaN web-search server" },
|
|
162
|
+
{ value: "enable nan-mcp-server", label: "enable nan-mcp-server", description: "Enable the community media server" },
|
|
107
163
|
];
|
|
108
164
|
const filtered = items.filter((item) => item.value.startsWith(prefix));
|
|
109
165
|
return filtered.length > 0 ? filtered : null;
|
|
@@ -114,12 +170,11 @@ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOp
|
|
|
114
170
|
const subcommand = rawSubcommand.toLowerCase();
|
|
115
171
|
|
|
116
172
|
if (subcommand === "status") {
|
|
117
|
-
ctx.ui.notify(statusMessage(), "info");
|
|
173
|
+
ctx.ui.notify(await statusMessage(pi, ctx), "info");
|
|
118
174
|
return;
|
|
119
175
|
}
|
|
120
176
|
|
|
121
177
|
if (subcommand === "enable" || subcommand === "disable") {
|
|
122
|
-
// Trailing tokens beyond the target (e.g. duplicate target names) are ignored.
|
|
123
178
|
const parsed = parseTarget(rawTarget);
|
|
124
179
|
if (parsed === undefined || rest.length > 0) {
|
|
125
180
|
ctx.ui.notify(
|
|
@@ -136,23 +191,94 @@ export function registerNanMcpCommand(pi: ExtensionAPI, options: NanMcpCommandOp
|
|
|
136
191
|
for (const bridge of bridges) {
|
|
137
192
|
writeBridgeState(bridge, enabled);
|
|
138
193
|
}
|
|
139
|
-
if (enabled) {
|
|
140
|
-
if (bridges.includes("webSearch")) options.registerWebSearchTools();
|
|
141
|
-
if (bridges.includes("mediaMcp")) options.registerMediaTools();
|
|
142
|
-
}
|
|
143
194
|
|
|
144
195
|
const where = target ? `for "${target}"` : "for both bridges";
|
|
145
196
|
const persistence = `persisted in <agentDir>/${NAN_STATE_FILE}`;
|
|
146
|
-
|
|
197
|
+
|
|
198
|
+
// Without an extension that handles `mcp_servers_change` pi cannot connect the
|
|
199
|
+
// servers; registering would only trigger its "no loaded extension connects MCP
|
|
200
|
+
// servers" error (see src/mcp/host-support.ts). The toggle above still persists,
|
|
201
|
+
// so it applies as soon as a connector (built-in `mcp`) is available.
|
|
202
|
+
if (enabled && !hasMcpConnector(pi)) {
|
|
147
203
|
ctx.ui.notify(
|
|
148
|
-
`Enabled ${describeBridges(bridges)} ${where}, ${persistence}
|
|
149
|
-
|
|
150
|
-
"
|
|
204
|
+
`Enabled ${describeBridges(bridges)} ${where}, ${persistence} — NOT registered. ` +
|
|
205
|
+
connectorMissingMessage(bridges.map(serverName)),
|
|
206
|
+
"warning",
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Immediate native registration/unregistration.
|
|
212
|
+
for (const bridge of bridges) {
|
|
213
|
+
const name = serverName(bridge);
|
|
214
|
+
if (enabled) {
|
|
215
|
+
// Only register if gate allows it.
|
|
216
|
+
if (bridgeEffectivelyEnabled(bridge)) {
|
|
217
|
+
if (bridge === "webSearch") {
|
|
218
|
+
// Resolve key: stored credential → registry → env.
|
|
219
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
220
|
+
const storedKey = resolveStoredNanApiKey();
|
|
221
|
+
const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
|
|
222
|
+
if (apiKey) {
|
|
223
|
+
pi.registerMcpServer(name, {
|
|
224
|
+
type: "http",
|
|
225
|
+
url: "https://api.nan.builders/mcp",
|
|
226
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
227
|
+
exposure: "direct",
|
|
228
|
+
});
|
|
229
|
+
} else {
|
|
230
|
+
ctx.ui.notify(
|
|
231
|
+
`${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
|
|
232
|
+
"warning",
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
// Resolve key for media: stored credential → registry → env.
|
|
237
|
+
const registryKey = await tryResolveNanApiKeyViaRegistry(ctx.modelRegistry);
|
|
238
|
+
const storedKey = resolveStoredNanApiKey();
|
|
239
|
+
const apiKey = storedKey ?? registryKey ?? process.env[NAN_API_KEY_ENV];
|
|
240
|
+
if (apiKey) {
|
|
241
|
+
const mediaArgs = mediaMcpCommand();
|
|
242
|
+
pi.registerMcpServer(name, {
|
|
243
|
+
type: "stdio",
|
|
244
|
+
command: mediaArgs[0]!,
|
|
245
|
+
args: mediaArgs.slice(1),
|
|
246
|
+
env: { [NAN_API_KEY_ENV]: apiKey },
|
|
247
|
+
exposure: "direct",
|
|
248
|
+
timeout: mediaMcpTimeoutSec(),
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
ctx.ui.notify(
|
|
252
|
+
`${NAN_API_KEY_ENV} is not set. Export it or run /login nan.`,
|
|
253
|
+
"warning",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
pi.unregisterMcpServer(name);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (enabled) {
|
|
264
|
+
// Only claim success if servers were actually registered.
|
|
265
|
+
const allRegistered = bridges.every((b: BridgeKey) =>
|
|
266
|
+
nativeServerRegistered(pi, serverName(b)),
|
|
151
267
|
);
|
|
268
|
+
if (allRegistered) {
|
|
269
|
+
ctx.ui.notify(
|
|
270
|
+
`Enabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers registered for this session.`,
|
|
271
|
+
"info",
|
|
272
|
+
);
|
|
273
|
+
} else {
|
|
274
|
+
ctx.ui.notify(
|
|
275
|
+
`Enabled ${describeBridges(bridges)} ${where}, ${persistence} — API key missing; servers not registered.`,
|
|
276
|
+
"warning",
|
|
277
|
+
);
|
|
278
|
+
}
|
|
152
279
|
} else {
|
|
153
280
|
ctx.ui.notify(
|
|
154
|
-
`Disabled ${describeBridges(bridges)} ${where}, ${persistence}.
|
|
155
|
-
" An explicit NAN_MCP_TOOLS / NAN_MEDIA_MCP env var overrides this toggle.",
|
|
281
|
+
`Disabled ${describeBridges(bridges)} ${where}, ${persistence}. Native servers unregistered — tools hidden immediately.`,
|
|
156
282
|
"warning",
|
|
157
283
|
);
|
|
158
284
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* NaN-aware context-overflow classification.
|
|
3
3
|
*
|
|
4
|
-
* Root cause (pi-ai,
|
|
4
|
+
* Root cause (pi-ai, verified on 0.87.1): when history is replayed
|
|
5
5
|
* into a DIFFERENT model, `transformMessages` downgrades every non-redacted
|
|
6
6
|
* `thinking` block to plain `text` verbatim, with no size bound — and nothing
|
|
7
7
|
* bounds the SUM across messages. Switching from a 1M-context model
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-model thinking guard.
|
|
3
3
|
*
|
|
4
|
-
* Root cause this guards against (pi-ai,
|
|
4
|
+
* Root cause this guards against (pi-ai, verified on 0.87.1): when
|
|
5
5
|
* history is replayed into a DIFFERENT model, `transformMessages` downgrades
|
|
6
6
|
* every non-redacted `thinking` block to a plain `text` block verbatim
|
|
7
7
|
* (`packages/ai/src/api/transform-messages.ts`), and `openai-completions`
|
|
@@ -65,6 +65,14 @@ export function crossModelThinkingGuardEnabled(env: NodeJS.ProcessEnv = process.
|
|
|
65
65
|
return value !== "0" && value !== "false" && value !== "no" && value !== "off";
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* API id pi 0.99 assigns to virtual catalog entries (`core/virtual-models.js`,
|
|
70
|
+
* `VIRTUAL_MODEL_API`). A virtual selection routes each request to a physical
|
|
71
|
+
* model; the physical target is not exposed to the `context` hook, so the guard
|
|
72
|
+
* cannot tell which physical model will answer.
|
|
73
|
+
*/
|
|
74
|
+
const VIRTUAL_MODEL_API = "pi-virtual";
|
|
75
|
+
|
|
68
76
|
function isGuardMessage(value: unknown): value is GuardMessage {
|
|
69
77
|
return typeof value === "object" && value !== null;
|
|
70
78
|
}
|
|
@@ -85,6 +93,16 @@ export function stripCrossModelThinking<T>(
|
|
|
85
93
|
): readonly T[] {
|
|
86
94
|
if (!target?.provider || !options.providerIds.has(target.provider)) return messages;
|
|
87
95
|
|
|
96
|
+
// Virtual selection (pi 0.99): `ctx.model` is the selectable virtual entry
|
|
97
|
+
// (api "pi-virtual"), not the physical model the router picks for this
|
|
98
|
+
// request. Its api/id never match a physical assistant message, so the
|
|
99
|
+
// same-model check below would strip reasoning on EVERY continuation —
|
|
100
|
+
// including when the router keeps the same physical model — losing the
|
|
101
|
+
// prompt cache and thinking continuity the guard promises to preserve.
|
|
102
|
+
// The routed physical model is not available here, so skip virtual
|
|
103
|
+
// selections entirely; cross-model trimming is the router's concern there.
|
|
104
|
+
if (target.api === VIRTUAL_MODEL_API) return messages;
|
|
105
|
+
|
|
88
106
|
let changed = false;
|
|
89
107
|
|
|
90
108
|
const next = messages.map((raw) => {
|
package/src/fetch-models.ts
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import type { Model, OpenAICompletionsCompat } from "@earendil-works/pi-ai";
|
|
25
|
+
import type { ThinkingLevelMap } from "@earendil-works/pi-ai";
|
|
25
26
|
import { GENERATED_CATALOG_META, NAN_GENERATED_MODELS } from "../scripts/models.generated.ts";
|
|
26
27
|
|
|
27
28
|
export type { Model };
|
|
@@ -46,6 +47,15 @@ export interface GeneratedModelEntry {
|
|
|
46
47
|
* An undefined array means the field was not set (legacy / uncatalogued).
|
|
47
48
|
*/
|
|
48
49
|
reasoningEffortValues?: string[];
|
|
50
|
+
/**
|
|
51
|
+
* Mapping from pi-ai's user-facing thinking levels to model-specific effort
|
|
52
|
+
* strings. When `reasoning: "off"` maps to undefined in pi-ai,
|
|
53
|
+
* thinkingLevelMap.off supplies the effort value that NaN understands
|
|
54
|
+
* (e.g. "none" for deepseek-v4-flash, "minimal" for glm5.3-flash).
|
|
55
|
+
* Without this, no reasoning_effort is sent and NaN does NOT disable
|
|
56
|
+
* reasoning, causing runaway reasoning (issue #16).
|
|
57
|
+
*/
|
|
58
|
+
thinkingLevelMap?: ThinkingLevelMap;
|
|
49
59
|
/** Compat applied to every NaN-compatible model (LiteLLM-confirmed, see scripts/generate-models.ts). */
|
|
50
60
|
compat?: OpenAICompletionsCompat;
|
|
51
61
|
/** Provenance notes for values overriding models.dev or needing manual confirmation. */
|
|
@@ -92,6 +102,7 @@ export function toModel(entry: GeneratedModelEntry, source: CatalogSource): Mode
|
|
|
92
102
|
contextWindow: entry.contextWindow,
|
|
93
103
|
maxTokens: entry.maxTokens,
|
|
94
104
|
...(entry.compat ? { compat: { ...entry.compat } } : {}),
|
|
105
|
+
...(entry.thinkingLevelMap ? { thinkingLevelMap: { ...entry.thinkingLevelMap } } : {}),
|
|
95
106
|
};
|
|
96
107
|
}
|
|
97
108
|
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @gtrabanco/pi-nan-provider — NaN Builders provider + MCP
|
|
2
|
+
* @gtrabanco/pi-nan-provider — NaN Builders provider + native MCP servers for pi.
|
|
3
3
|
*
|
|
4
4
|
* What this extension registers:
|
|
5
5
|
*
|
|
@@ -15,14 +15,17 @@
|
|
|
15
15
|
* 0.84 alike), and the openai-completions streaming implementation is
|
|
16
16
|
* resolved dynamically — see provider-factory.ts for why.
|
|
17
17
|
*
|
|
18
|
-
* 2. MCP
|
|
19
|
-
* - `
|
|
20
|
-
* (https://api.nan.builders/mcp)
|
|
21
|
-
* disable.
|
|
22
|
-
* -
|
|
23
|
-
* (
|
|
24
|
-
*
|
|
25
|
-
*
|
|
18
|
+
* 2. MCP servers via pi's native registerMcpServer (pi >=0.99):
|
|
19
|
+
* - `nan-search` — the official NaN remote MCP server
|
|
20
|
+
* (https://api.nan.builders/mcp), exposing `mcp__nan-search__web_search`.
|
|
21
|
+
* Default: enabled. NAN_MCP_TOOLS=0 to disable.
|
|
22
|
+
* - `nan-media` — the community stdio media server
|
|
23
|
+
* (flux-2-klein / kokoro / whisper), exposing
|
|
24
|
+
* `mcp__nan-media__generate_image` etc. Default: enabled,
|
|
25
|
+
* NAN_MEDIA_MCP=0 to disable.
|
|
26
|
+
*
|
|
27
|
+
* Both are session-scoped (visible in /mcp with source "extension").
|
|
28
|
+
* Exposure: `direct` — tools are declared to the model like built-ins.
|
|
26
29
|
*/
|
|
27
30
|
|
|
28
31
|
import type { ContextEvent, ExtensionAPI, ProviderConfig } from "@earendil-works/pi-coding-agent";
|
|
@@ -34,10 +37,11 @@ import {
|
|
|
34
37
|
stripCrossModelThinking,
|
|
35
38
|
} from "./cross-model-thinking-guard.ts";
|
|
36
39
|
import { baselineModels } from "./fetch-models.ts";
|
|
37
|
-
import { createNanWebSearchTool, webSearchBridgeEnabled, NAN_API_KEY_ENV } from "./mcp/nan-search.ts";
|
|
38
|
-
import { createNanMediaTools, mediaMcpEnabled } from "./mcp/nan-media.ts";
|
|
39
40
|
import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
|
|
40
41
|
import { PROVIDERS } from "./providers.ts";
|
|
42
|
+
import { NAN_API_KEY_ENV, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
|
|
43
|
+
import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
|
|
44
|
+
import { registerMcpHostGuard } from "./mcp/host-support.ts";
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
47
|
* Register a provider on any pi version: the native full-Provider overload
|
|
@@ -75,50 +79,97 @@ async function registerProviderCompat(
|
|
|
75
79
|
contextWindow: model.contextWindow,
|
|
76
80
|
maxTokens: model.maxTokens,
|
|
77
81
|
...(model.compat ? { compat: { ...model.compat } } : {}),
|
|
82
|
+
...(model.thinkingLevelMap ? { thinkingLevelMap: { ...model.thinkingLevelMap } } : {}),
|
|
78
83
|
})),
|
|
79
84
|
};
|
|
80
85
|
pi.registerProvider(config.id, legacy);
|
|
81
86
|
}
|
|
82
87
|
|
|
88
|
+
// ── Native MCP registration ──────────────────────────────────────────────
|
|
89
|
+
|
|
83
90
|
/**
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* `/nan-mcp` (and the NAN_MCP_TOOLS / NAN_MEDIA_MCP env vars) toggle them.
|
|
87
|
-
* Old pi versions without registerTool/registerCommand skip gracefully.
|
|
88
|
-
*
|
|
89
|
-
* Registration is tracked in `registeredToolNames` so `/nan-mcp enable` can
|
|
90
|
-
* add tools mid-session without double-registering.
|
|
91
|
+
* Guard message shown when pi lacks native MCP support (pi <0.99 despite peers).
|
|
92
|
+
* Matches the loud-skip style of existing guards (pi-ai, registerTool).
|
|
91
93
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const registeredToolNames = new Set<string>();
|
|
95
|
-
const registerSearchTool = () => {
|
|
96
|
-
const search = createNanWebSearchTool();
|
|
97
|
-
if (registeredToolNames.has(search.name)) return;
|
|
98
|
-
registeredToolNames.add(search.name);
|
|
99
|
-
pi.registerTool(search);
|
|
100
|
-
};
|
|
101
|
-
const registerMediaTools = () => {
|
|
102
|
-
for (const tool of createNanMediaTools()) {
|
|
103
|
-
if (registeredToolNames.has(tool.name)) continue;
|
|
104
|
-
registeredToolNames.add(tool.name);
|
|
105
|
-
pi.registerTool(tool);
|
|
106
|
-
}
|
|
107
|
-
};
|
|
94
|
+
const NO_NATIVE_MCP_GUARD_MSG =
|
|
95
|
+
"pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
|
|
108
96
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
|
|
97
|
+
/** Register the official NaN web-search MCP server (session-scoped). */
|
|
98
|
+
async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | undefined): Promise<boolean> {
|
|
99
|
+
if (!webSearchBridgeEnabled()) return false;
|
|
100
|
+
if (!apiKey) {
|
|
101
|
+
console.warn(
|
|
102
|
+
"[pi-nan-provider] NAN_API_KEY is not set — web-search server not registered. " +
|
|
103
|
+
"Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
|
|
104
|
+
);
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
pi.registerMcpServer("nan-search", {
|
|
108
|
+
type: "http",
|
|
109
|
+
url: "https://api.nan.builders/mcp",
|
|
110
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
111
|
+
exposure: "direct",
|
|
112
|
+
});
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Register the community media stdio MCP server (session-scoped). */
|
|
117
|
+
function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): boolean {
|
|
118
|
+
if (!mediaMcpEnabled()) return false;
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
console.warn(
|
|
121
|
+
"[pi-nan-provider] NAN_API_KEY is not set — media server not registered. " +
|
|
122
|
+
"Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
|
|
123
|
+
);
|
|
124
|
+
return false;
|
|
114
125
|
}
|
|
126
|
+
const mediaArgs = mediaMcpCommand();
|
|
127
|
+
pi.registerMcpServer("nan-media", {
|
|
128
|
+
type: "stdio",
|
|
129
|
+
command: mediaArgs[0]!,
|
|
130
|
+
args: mediaArgs.slice(1),
|
|
131
|
+
env: { [NAN_API_KEY_ENV]: apiKey },
|
|
132
|
+
exposure: "direct",
|
|
133
|
+
timeout: mediaMcpTimeoutSec(),
|
|
134
|
+
});
|
|
135
|
+
return true;
|
|
115
136
|
}
|
|
116
137
|
|
|
138
|
+
/**
|
|
139
|
+
* Register MCP servers via pi's native registerMcpServer.
|
|
140
|
+
*
|
|
141
|
+
* Session-scoped: servers appear in /mcp with source "extension", are
|
|
142
|
+
* visible to the model (exposure: "direct"), and user mcp.json entries
|
|
143
|
+
* with the same name take precedence.
|
|
144
|
+
*
|
|
145
|
+
* On pi <0.99 (registerMcpServer absent) this does NOT bridge — it
|
|
146
|
+
* notifies once and skips (same loud-skip style as existing guards).
|
|
147
|
+
* Hard cutover: users on older pi stay on package 0.9.x.
|
|
148
|
+
*/
|
|
149
|
+
async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
|
|
150
|
+
// Guard: pi >= 0.99 required for native MCP.
|
|
151
|
+
if (typeof pi.registerMcpServer !== "function") {
|
|
152
|
+
console.warn(NO_NATIVE_MCP_GUARD_MSG);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Resolve the API key ONCE: stored credential (auth.json) → env fallback.
|
|
157
|
+
// The factory-time ExtensionAPI has NO modelRegistry; stored credentials
|
|
158
|
+
// must be read through the coding-agent's readStoredCredential API.
|
|
159
|
+
const storedKey = resolveStoredNanApiKey();
|
|
160
|
+
const apiKey = storedKey ?? process.env[NAN_API_KEY_ENV];
|
|
161
|
+
|
|
162
|
+
await registerWebSearchMcpServer(pi, apiKey);
|
|
163
|
+
registerMediaMcpServer(pi, apiKey);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Extension entrypoint ─────────────────────────────────────────────────
|
|
167
|
+
|
|
117
168
|
/**
|
|
118
169
|
* Drop the reasoning pi-ai replays across a model switch.
|
|
119
170
|
*
|
|
120
171
|
* pi-ai's `transformMessages` downgrades a previous model's `thinking` blocks
|
|
121
|
-
* to plain text with no size bound (
|
|
172
|
+
* to plain text with no size bound (verified on 0.87.1), and nothing
|
|
122
173
|
* bounds the sum across messages — measured at 30–60% of the whole context on
|
|
123
174
|
* real sessions. Switching from a 1M-context model to a 262K one (`qwen3.6`)
|
|
124
175
|
* then overflows the window, and NaN's gateway answers a generic
|
|
@@ -143,10 +194,17 @@ export function registerCrossModelThinkingGuard(pi: ExtensionAPI): void {
|
|
|
143
194
|
|
|
144
195
|
export default async function nanProviderExtension(pi: ExtensionAPI): Promise<void> {
|
|
145
196
|
registerCrossModelThinkingGuard(pi);
|
|
197
|
+
// Replaces pi's cryptic "no loaded extension connects MCP servers" error with
|
|
198
|
+
// actionable guidance when the host loads no built-in `mcp` extension (PI WEB).
|
|
199
|
+
registerMcpHostGuard(pi);
|
|
146
200
|
for (const config of PROVIDERS) {
|
|
147
201
|
await registerProviderCompat(pi, config);
|
|
148
202
|
}
|
|
149
|
-
|
|
203
|
+
await registerMcpServersNative(pi);
|
|
204
|
+
if (typeof pi.registerCommand === "function") {
|
|
205
|
+
registerNanMcpCommand(pi);
|
|
206
|
+
registerNanUsageCommand(pi);
|
|
207
|
+
}
|
|
150
208
|
}
|
|
151
209
|
|
|
152
210
|
/** Exposed for tests: the env var this package uses for every NaN surface. */
|