@gtrabanco/pi-nan-provider 1.0.1 → 1.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.
- package/AGENTS.md +94 -30
- package/README.es.md +56 -33
- package/README.md +55 -34
- package/package.json +4 -4
- package/scripts/check-nan-mcp-server.ts +1 -1
- package/scripts/generate-models.ts +3 -5
- package/scripts/manual-overrides.ts +35 -11
- package/scripts/models.generated.ts +18 -13
- package/src/commands.ts +62 -149
- package/src/fetch-models.ts +58 -0
- package/src/index.ts +7 -32
- package/src/mcp/api-key.ts +1 -26
- package/src/mcp/media-server.ts +1 -1
- package/src/mcp/state.ts +0 -3
- package/src/reasoning-only-stream-guard.ts +8 -3
package/src/fetch-models.ts
CHANGED
|
@@ -85,6 +85,41 @@ export const UNKNOWN_MODEL_LIMITS = { contextWindow: 128_000, maxTokens: 4_096 }
|
|
|
85
85
|
/** Timeout for the live /models fetch; matches the pi-synthetic-provider precedent (~3s). */
|
|
86
86
|
export const DEFAULT_MODELS_TIMEOUT_MS = 3_000;
|
|
87
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Capability data for models the docs serve but the static baseline must NOT
|
|
90
|
+
* carry (tier-gated), so the live `/models` path registers them with real
|
|
91
|
+
* documented values instead of the blind `UNKNOWN_MODEL_LIMITS` placeholder.
|
|
92
|
+
*
|
|
93
|
+
* `glm5.3` is premium-tier: a non-premium key must never see it in the static
|
|
94
|
+
* baseline (it cannot call it), but a premium key that discovers it live must
|
|
95
|
+
* get its real capabilities. Every value traces to
|
|
96
|
+
* https://nan.builders/docs/models (checked 2026-10-14; docs are the source of
|
|
97
|
+
* truth — models.dev lags):
|
|
98
|
+
* - Context 1M tokens, multimodal input text·image·audio·video (pi-representable
|
|
99
|
+
* subset: text, image; pi's Model.input has no audio/video), output text,
|
|
100
|
+
* tool calling, reasoning_effort low·medium·high·max.
|
|
101
|
+
* - Max output 131,072: models.dev documents that value for glm5.3; the docs
|
|
102
|
+
* give the same 131K figure for the sibling glm5.3-flash, and do not state a
|
|
103
|
+
* lower bound for glm5.3. Kept as the confirmed maximum rather than invented.
|
|
104
|
+
* - thinkingLevelMap { off: "low" }: the docs list low as glm5.3's shallowest
|
|
105
|
+
* level, so `thinking: off` sends the shallowest supported effort. glm5.3 was
|
|
106
|
+
* never measured (issue #16 covered glm5.3-flash, whose shallowest effective
|
|
107
|
+
* value is "minimal"); "low" is the documented floor.
|
|
108
|
+
*/
|
|
109
|
+
export const LIVE_ONLY_MODEL_DATA: Readonly<Record<string, Omit<GeneratedModelEntry, "compat">>> = {
|
|
110
|
+
"glm5.3": {
|
|
111
|
+
id: "glm5.3",
|
|
112
|
+
name: "GLM 5.3",
|
|
113
|
+
reasoning: true,
|
|
114
|
+
input: ["text", "image"],
|
|
115
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
116
|
+
contextWindow: 1_048_576,
|
|
117
|
+
maxTokens: 131_072,
|
|
118
|
+
reasoningEffortValues: ["low", "medium", "high", "max"],
|
|
119
|
+
thinkingLevelMap: { off: "low" },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
88
123
|
/**
|
|
89
124
|
* Live endpoint IDs classified as non-chat and excluded from the registered
|
|
90
125
|
* model set. Keys are model ids; values are provenance reasons citing the
|
|
@@ -218,6 +253,10 @@ export function mergeLiveWithGenerated(
|
|
|
218
253
|
generated: readonly GeneratedModelEntry[] = NAN_GENERATED_MODELS,
|
|
219
254
|
): MergedCatalog {
|
|
220
255
|
const byId = new Map(generated.map((entry) => [entry.id, entry]));
|
|
256
|
+
// Every generated entry shares the same NaN/LiteLLM compat block; borrow it
|
|
257
|
+
// for the live-only documented entries so the gateway-behavior flags never
|
|
258
|
+
// drift between the static catalog and the live overlay.
|
|
259
|
+
const catalogCompat = generated.find((entry) => entry.compat)?.compat;
|
|
221
260
|
const models: Model<"openai-completions">[] = [];
|
|
222
261
|
const matched: string[] = [];
|
|
223
262
|
const unknown: string[] = [];
|
|
@@ -235,6 +274,16 @@ export function mergeLiveWithGenerated(
|
|
|
235
274
|
models.push(toModel(entry, source));
|
|
236
275
|
matched.push(id);
|
|
237
276
|
} else {
|
|
277
|
+
// Documented data for tier-gated live-only ids (glm5.3): the static
|
|
278
|
+
// baseline excludes them so a non-premium key never sees a model it
|
|
279
|
+
// cannot call, but a key that discovers one live gets its real
|
|
280
|
+
// capabilities instead of the blind placeholder below.
|
|
281
|
+
const liveOnlyEntry = LIVE_ONLY_MODEL_DATA[id];
|
|
282
|
+
if (liveOnlyEntry) {
|
|
283
|
+
models.push(toModel({ ...liveOnlyEntry, compat: catalogCompat }, source));
|
|
284
|
+
matched.push(id);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
238
287
|
// Placeholder for uncatalogued live ids (e.g. new or undocumented):
|
|
239
288
|
// limits are the documented safe envelope and capabilities stay
|
|
240
289
|
// "unknown". The compat keys are the same gateway-behavior flags
|
|
@@ -443,6 +492,15 @@ export function buildOverlayMap(
|
|
|
443
492
|
for (const entry of generated) {
|
|
444
493
|
overlay.set(entry.id, { chat: toModel(entry, source) });
|
|
445
494
|
}
|
|
495
|
+
// Tier-gated live-only chat models with documented capabilities (glm5.3):
|
|
496
|
+
// absent from the static baseline so a non-premium key never sees them, but
|
|
497
|
+
// present here so a stored entry for one is corrected to the documented
|
|
498
|
+
// values instead of passing through stale. The store still decides which ids
|
|
499
|
+
// exist (issue #19); this map only decides their capabilities.
|
|
500
|
+
const catalogCompat = generated.find((entry) => entry.compat)?.compat;
|
|
501
|
+
for (const entry of Object.values(LIVE_ONLY_MODEL_DATA)) {
|
|
502
|
+
overlay.set(entry.id, { chat: toModel({ ...entry, compat: catalogCompat }, source) });
|
|
503
|
+
}
|
|
446
504
|
// Image models from the baseline (these live under a different api id
|
|
447
505
|
// so they cannot coexist as chat entries — ids are unique across types).
|
|
448
506
|
if (imageModels) {
|
package/src/index.ts
CHANGED
|
@@ -15,17 +15,13 @@
|
|
|
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
|
-
* - `nan-search` — the official NaN remote MCP server
|
|
20
|
-
* (https://api.nan.builders/mcp), exposing `mcp__nan_search__web_search`
|
|
21
|
-
* (pi >=0.99.2; 0.99.0-0.99.1 kept the hyphen: `mcp__nan-search__web_search`).
|
|
22
|
-
* Default: enabled. NAN_MCP_TOOLS=0 to disable.
|
|
18
|
+
* 2. MCP server via pi's native registerMcpServer (pi >=0.99):
|
|
23
19
|
* - `nan-media` — the community stdio media server
|
|
24
20
|
* (flux-2-klein / kokoro / whisper), exposing
|
|
25
21
|
* `mcp__nan_media__generate_image` etc. Default: enabled,
|
|
26
22
|
* NAN_MEDIA_MCP=0 to disable.
|
|
27
23
|
*
|
|
28
|
-
*
|
|
24
|
+
* It is session-scoped (visible in /mcp with source "extension").
|
|
29
25
|
* Exposure: `direct` — tools are declared to the model like built-ins.
|
|
30
26
|
*/
|
|
31
27
|
|
|
@@ -40,7 +36,7 @@ import {
|
|
|
40
36
|
import { baselineModels } from "./fetch-models.ts";
|
|
41
37
|
import { createNanCompatibleProvider, type OpenAICompatibleProviderConfig } from "./provider-factory.ts";
|
|
42
38
|
import { PROVIDERS } from "./providers.ts";
|
|
43
|
-
import { NAN_API_KEY_ENV,
|
|
39
|
+
import { NAN_API_KEY_ENV, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey } from "./mcp/api-key.ts";
|
|
44
40
|
import { mediaMcpCommand, mediaMcpEnabled, mediaMcpEnvExplicit, mediaMcpEnvTruthy, mediaMcpTimeoutSec } from "./mcp/media-server.ts";
|
|
45
41
|
import { registerMcpHostGuard } from "./mcp/host-support.ts";
|
|
46
42
|
|
|
@@ -95,26 +91,6 @@ async function registerProviderCompat(
|
|
|
95
91
|
const NO_NATIVE_MCP_GUARD_MSG =
|
|
96
92
|
"pi-nan-provider 0.10+ requires pi >= 0.99 for MCP tools (native MCP); upgrade pi or stay on package 0.9.x";
|
|
97
93
|
|
|
98
|
-
/** Register the official NaN web-search MCP server (session-scoped). */
|
|
99
|
-
async function registerWebSearchMcpServer(pi: ExtensionAPI, apiKey: string | undefined): Promise<boolean> {
|
|
100
|
-
if (!webSearchBridgeEnabled()) return false;
|
|
101
|
-
if (!apiKey) {
|
|
102
|
-
console.warn(
|
|
103
|
-
"[pi-nan-provider] NAN_API_KEY is not set — web-search server not registered. " +
|
|
104
|
-
"Run `/login nan` in pi or export `NAN_API_KEY=<your-key>`.",
|
|
105
|
-
);
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
pi.registerMcpServer("nan-search", {
|
|
109
|
-
type: "http",
|
|
110
|
-
url: "https://api.nan.builders/mcp",
|
|
111
|
-
headers: { Authorization: `Bearer ${apiKey}` },
|
|
112
|
-
exposure: "direct",
|
|
113
|
-
description: "Web search through NaN's gateway (official NaN MCP server)",
|
|
114
|
-
});
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
94
|
/** Register the community media stdio MCP server (session-scoped). */
|
|
119
95
|
function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): boolean {
|
|
120
96
|
if (!mediaMcpEnabled()) return false;
|
|
@@ -139,11 +115,11 @@ function registerMediaMcpServer(pi: ExtensionAPI, apiKey: string | undefined): b
|
|
|
139
115
|
}
|
|
140
116
|
|
|
141
117
|
/**
|
|
142
|
-
* Register MCP
|
|
118
|
+
* Register the MCP server via pi's native registerMcpServer.
|
|
143
119
|
*
|
|
144
|
-
* Session-scoped:
|
|
145
|
-
* visible to the model (exposure: "direct"), and user mcp.json
|
|
146
|
-
* with the same name
|
|
120
|
+
* Session-scoped: the server appears in /mcp with source "extension", is
|
|
121
|
+
* visible to the model (exposure: "direct"), and a user mcp.json entry
|
|
122
|
+
* with the same name takes precedence.
|
|
147
123
|
*
|
|
148
124
|
* On pi <0.99 (registerMcpServer absent) this does NOT bridge — it
|
|
149
125
|
* notifies once and skips (same loud-skip style as existing guards).
|
|
@@ -162,7 +138,6 @@ async function registerMcpServersNative(pi: ExtensionAPI): Promise<void> {
|
|
|
162
138
|
const storedKey = resolveStoredNanApiKey();
|
|
163
139
|
const apiKey = storedKey ?? process.env[NAN_API_KEY_ENV];
|
|
164
140
|
|
|
165
|
-
await registerWebSearchMcpServer(pi, apiKey);
|
|
166
141
|
registerMediaMcpServer(pi, apiKey);
|
|
167
142
|
}
|
|
168
143
|
|
package/src/mcp/api-key.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { bridgeSource, resolveBridgeEnabled } from "./state.ts";
|
|
2
1
|
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
|
-
* NaN API key resolution — shared across every surface (
|
|
4
|
+
* NaN API key resolution — shared across every surface (media, usage).
|
|
6
5
|
*
|
|
7
6
|
* Strategy: pi's stored credential (modelRegistry) wins → env var fallback.
|
|
8
7
|
* Never logs or embeds the key beyond the Authorization header.
|
|
@@ -40,8 +39,6 @@ export function resolveStoredNanApiKey(): string | undefined {
|
|
|
40
39
|
}
|
|
41
40
|
}
|
|
42
41
|
|
|
43
|
-
/** Env var that overrides the official web_search bridge. */
|
|
44
|
-
export const NAN_MCP_TOOLS_ENV = "NAN_MCP_TOOLS";
|
|
45
42
|
/** Env var that overrides the media bridge. */
|
|
46
43
|
export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
|
|
47
44
|
/** The NaN API key env var used everywhere. */
|
|
@@ -74,18 +71,6 @@ export async function tryResolveNanApiKeyViaRegistry(
|
|
|
74
71
|
}
|
|
75
72
|
}
|
|
76
73
|
|
|
77
|
-
/** Whether MCP tool registration is disabled via env (NAN_MCP_TOOLS=0|false|off). */
|
|
78
|
-
export function mcpToolsDisabled(): boolean {
|
|
79
|
-
const value = process.env[NAN_MCP_TOOLS_ENV]?.trim().toLowerCase();
|
|
80
|
-
return value === "0" || value === "false" || value === "off";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Whether NAN_MCP_TOOLS is explicitly set (any value). */
|
|
84
|
-
export function mcpToolsEnvExplicit(): boolean {
|
|
85
|
-
const value = process.env[NAN_MCP_TOOLS_ENV];
|
|
86
|
-
return value !== undefined && value.trim() !== "";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
74
|
/** Whether NAN_MEDIA_MCP is explicitly set (any value). */
|
|
90
75
|
export function mediaMcpEnvExplicit(): boolean {
|
|
91
76
|
const value = process.env[NAN_MEDIA_MCP_ENV];
|
|
@@ -97,13 +82,3 @@ export function mediaMcpEnvTruthy(): boolean {
|
|
|
97
82
|
const value = process.env[NAN_MEDIA_MCP_ENV]?.trim().toLowerCase();
|
|
98
83
|
return value === "1" || value === "true" || value === "on";
|
|
99
84
|
}
|
|
100
|
-
|
|
101
|
-
/** Source of the effective web-search enablement (env / persisted / default). */
|
|
102
|
-
export function webSearchBridgeSource(): import("./state.ts").BridgeSource {
|
|
103
|
-
return bridgeSource("webSearch", mcpToolsEnvExplicit());
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Whether the web-search bridge is enabled by gate (env → persisted → default). */
|
|
107
|
-
export function webSearchBridgeEnabled(): boolean {
|
|
108
|
-
return resolveBridgeEnabled("webSearch", mcpToolsEnvExplicit(), !mcpToolsDisabled(), true);
|
|
109
|
-
}
|
package/src/mcp/media-server.ts
CHANGED
|
@@ -27,7 +27,7 @@ export function mediaMcpTimeoutSec(): number {
|
|
|
27
27
|
|
|
28
28
|
export const NAN_MEDIA_MCP_ENV = "NAN_MEDIA_MCP";
|
|
29
29
|
|
|
30
|
-
export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.
|
|
30
|
+
export const DEFAULT_NAN_MEDIA_MCP_VERSION = "1.3.0";
|
|
31
31
|
export const DEFAULT_MEDIA_MCP_TIMEOUT_MS = 120_000;
|
|
32
32
|
export const NAN_MEDIA_MCP_VERSION_ENV = "NAN_MEDIA_MCP_VERSION";
|
|
33
33
|
export const NAN_MEDIA_MCP_COMMAND_ENV = "NAN_MEDIA_MCP_COMMAND";
|
package/src/mcp/state.ts
CHANGED
|
@@ -17,8 +17,6 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
17
17
|
export const NAN_STATE_FILE = "nan-provider.json";
|
|
18
18
|
|
|
19
19
|
export interface NanProviderState {
|
|
20
|
-
/** Persisted enablement of the official web_search MCP bridge. */
|
|
21
|
-
webSearch?: boolean;
|
|
22
20
|
/** Persisted enablement of the community nan-mcp-server media bridge. */
|
|
23
21
|
mediaMcp?: boolean;
|
|
24
22
|
}
|
|
@@ -27,7 +25,6 @@ export type BridgeKey = keyof NanProviderState;
|
|
|
27
25
|
|
|
28
26
|
/** Server name each bridge registers with `pi.registerMcpServer()`. Single source of truth. */
|
|
29
27
|
export const MCP_SERVER_NAMES: Record<BridgeKey, string> = {
|
|
30
|
-
webSearch: "nan-search",
|
|
31
28
|
mediaMcp: "nan-media",
|
|
32
29
|
};
|
|
33
30
|
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
* NaN reasoning-only stream guard (issue #21).
|
|
3
3
|
*
|
|
4
4
|
* Problem: NaN's gateway closes a streaming turn that produced ONLY reasoning
|
|
5
|
-
* (no content, no tool calls)
|
|
6
|
-
*
|
|
5
|
+
* (no content, no tool calls) once it has produced more than 45,000 reasoning
|
|
6
|
+
* characters, or 420 seconds without visible output (600 seconds once the turn
|
|
7
|
+
* has demonstrably exceeded that character count). The reasoning-character
|
|
8
|
+
* budget also scales with prompt size: ~150,000 characters of headroom plus
|
|
9
|
+
* half the prompt's size, capped at 300,000
|
|
10
|
+
* (https://nan.builders/docs/models#reasoning-only-stream-limit,
|
|
11
|
+
* checked 2026-10-14). The cut is:
|
|
7
12
|
* (estimated, unbilled) carrying `nan_truncation: { reason: "reasoning_only_stream" }`.
|
|
8
13
|
* Through pi-ai this maps to `stopReason: "length"` with no text — indistinguishable
|
|
9
14
|
* from a real output-limit cut, never retried, and the turn is silently wasted.
|
|
@@ -72,7 +77,7 @@ export function isReasoningOnlyStreamChunk(chunk: unknown): boolean {
|
|
|
72
77
|
const REASONING_ONLY_REWRITE: Partial<AssistantMessage> = {
|
|
73
78
|
stopReason: "error" as const,
|
|
74
79
|
errorMessage:
|
|
75
|
-
"NaN timed out the stream before any answer content (reasoning-only stream guard: 420 s /
|
|
80
|
+
"NaN timed out the stream before any answer content (reasoning-only stream guard: the 420 s / 45,000-reasoning-character limit; the turn was not billed). Original finish_reason: length.",
|
|
76
81
|
};
|
|
77
82
|
|
|
78
83
|
/**
|