@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.
@@ -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 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
- * (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
- * Both are session-scoped (visible in /mcp with source "extension").
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, mcpToolsDisabled, mcpToolsEnvExplicit, resolveNanApiKey, tryResolveNanApiKeyViaRegistry, resolveStoredNanApiKey, NAN_MCP_TOOLS_ENV, webSearchBridgeEnabled } from "./mcp/api-key.ts";
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 servers via pi's native registerMcpServer.
118
+ * Register the MCP server via pi's native registerMcpServer.
143
119
  *
144
- * Session-scoped: servers appear in /mcp with source "extension", are
145
- * visible to the model (exposure: "direct"), and user mcp.json entries
146
- * with the same name take precedence.
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
 
@@ -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 (web search, media, usage).
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
- }
@@ -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.1.2";
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) at 60,000 reasoning chars or 420 seconds:
6
- * `finish_reason: "length"` on an empty delta, followed by a final usage chunk
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 / 60,000-character limit; the turn was not billed). Original finish_reason: length.",
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
  /**