From 282cce6801885db543d894cf7737ab1c99e4baad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20D=2E?= <9351115+decode2@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:42:50 +0000 Subject: [PATCH] feat(provider): add first-party NaN model connection --- README.md | 4 + extensions/nan-provider.ts | 6 + lib/nan-provider.ts | 147 ++++++++++++++++++++++ tests/nan-provider.test.ts | 245 +++++++++++++++++++++++++++++++++++++ tests/runtime-harness.mjs | 14 ++- 5 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 extensions/nan-provider.ts create mode 100644 lib/nan-provider.ts create mode 100644 tests/nan-provider.test.ts diff --git a/README.md b/README.md index ee0f06282..a5737b20e 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,10 @@ pi See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-shell/releases/tag/v3.5.1) for version-specific changes. +### NaN model provider + +The first-party `nan` provider is included; no third-party provider package is required. Set `NAN_API_KEY` before starting Pi, or authenticate with `/login nan`, then use `/model` to select a model. Pi streams chat completions through its OpenAI-compatible provider. Model discovery intersects NaN's authenticated `/v1/models` response with a maintained subset of known chat IDs from the [official model documentation](https://nan.builders/docs/models); unknown and non-chat IDs are omitted. A successful response with no known chat IDs stays empty. Documented context, reasoning, and text/image capabilities are preserved with conservative numeric bounds for abbreviated limits; audio input is not advertised by Pi. Where NaN does not publish an output maximum, the provider configures a conservative 1,024-token cap rather than claiming the model's true limit. When discovery is unavailable, the offline baseline is only `deepseek-v4-flash` (or the last successful catalog for the same key); the baseline may not be available to every key. NaN MCP search and media bridges are not included. + ```text /gentle:status /gentle:doctor diff --git a/extensions/nan-provider.ts b/extensions/nan-provider.ts new file mode 100644 index 000000000..171ddcca3 --- /dev/null +++ b/extensions/nan-provider.ts @@ -0,0 +1,6 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { createNanProviderConfig, NAN_PROVIDER_ID } from "../lib/nan-provider.ts"; + +export default function registerNanProvider(pi: ExtensionAPI): void { + pi.registerProvider(NAN_PROVIDER_ID, createNanProviderConfig()); +} diff --git a/lib/nan-provider.ts b/lib/nan-provider.ts new file mode 100644 index 000000000..167db9a24 --- /dev/null +++ b/lib/nan-provider.ts @@ -0,0 +1,147 @@ +import type { RefreshModelsContext } from "@earendil-works/pi-ai"; +import type { ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; + +export const NAN_PROVIDER_ID = "nan"; +export const NAN_PROVIDER_BASE_URL = "https://api.nan.builders/v1"; +export const NAN_MODELS_TIMEOUT_MS = 3_000; + +export interface NanProviderOptions { + fetchImpl?: typeof fetch; + timeoutMs?: number; +} + +// Pi requires numeric rates; NaN access is quota-based, so zero avoids inventing per-token pricing. +const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +// Maintained chat subset from https://nan.builders/docs/models. +// Decimal bounds conservatively interpret the documented 1M/262K/131K labels. +// Pi models text/image inputs only; MiMo's documented audio input is not advertised. +// Where no output maximum is published, 8,192 is our conservative configured cap for coding with reasoning, not NaN's limit. +const CHAT_MODELS: ProviderModelConfig[] = [ + { id: "glm5.3", name: "GLM 5.3", input: ["text"], contextWindow: 1_000_000 }, + { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", input: ["text", "image"], contextWindow: 1_000_000 }, + { id: "glm5.3-flash", name: "GLM 5.3 Flash", input: ["text", "image"], contextWindow: 1_000_000 }, + { id: "qwen3.8-flash", name: "Qwen 3.8 Flash", input: ["text", "image"], contextWindow: 1_048_576, maxTokens: 131_000 }, + { id: "mimo-v2.6-flash", name: "MiMo V2.6 Flash", input: ["text", "image"], contextWindow: 1_000_000 }, + { id: "gemma4", name: "Gemma 4", input: ["text", "image"], contextWindow: 262_000 }, + { id: "qwen3.6", name: "Qwen 3.6", input: ["text", "image"], contextWindow: 262_000 }, +].map((model) => ({ + ...model, + input: model.input as ProviderModelConfig["input"], + api: "openai-completions", + reasoning: true, + cost: ZERO_COST, + maxTokens: model.maxTokens ?? 8_192, +})); + +// Offline discovery advertises only this known chat model, not the entire allowlist. +const OFFLINE_MODELS = CHAT_MODELS.filter((model) => model.id === "deepseek-v4-flash"); + +function cloneModel(model: ProviderModelConfig): ProviderModelConfig { + return { ...model, input: [...model.input], cost: { ...model.cost } }; +} + +function knownChatModels(ids: readonly string[]): ProviderModelConfig[] { + return ids.flatMap((id) => { + const known = CHAT_MODELS.find((model) => model.id === id); + return known ? [cloneModel(known)] : []; + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Returns undefined on an unusable response; an empty data array is authoritative. */ +async function fetchLiveModelIds(options: { + apiKey?: string; + signal: AbortSignal; + fetchImpl: typeof fetch; + timeoutMs: number; +}): Promise { + const { apiKey, signal, fetchImpl, timeoutMs } = options; + if (signal.aborted) return undefined; + + const controller = new AbortController(); + const abort = () => controller.abort(signal.reason); + if (signal.aborted) abort(); + else signal.addEventListener("abort", abort, { once: true }); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const headers: Record = { Accept: "application/json" }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + const response = await fetchImpl(`${NAN_PROVIDER_BASE_URL}/models`, { + method: "GET", + headers, + signal: controller.signal, + redirect: "error", + cache: "no-store", + }); + if (!response.ok) return undefined; + + const payload: unknown = await response.json(); + if (!isRecord(payload) || !Array.isArray(payload.data)) return undefined; + if (payload.data.length === 0) return []; + + const ids = new Set(); + for (const row of payload.data) { + if (!isRecord(row) || typeof row.id !== "string") continue; + const id = row.id.trim(); + if (id) ids.add(id); + } + return ids.size > 0 ? [...ids] : undefined; + } catch { + // Discovery is best-effort. Never log request or response data: it may contain credentials. + return undefined; + } finally { + clearTimeout(timeout); + signal.removeEventListener("abort", abort); + } +} + +function cloneCatalog(models: readonly ProviderModelConfig[]): ProviderModelConfig[] { + return models.map(cloneModel); +} + +export function createNanProviderConfig(options: NanProviderOptions = {}): ProviderConfig { + let catalog = cloneCatalog(OFFLINE_MODELS); + let catalogKey: string | undefined; + let credentialRevision = 0; + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + + return { + name: "NaN", + baseUrl: NAN_PROVIDER_BASE_URL, + api: "openai-completions", + apiKey: "$NAN_API_KEY", + authHeader: true, + models: cloneCatalog(catalog), + refreshModels: async (context: RefreshModelsContext) => { + const apiKey = context.credential?.type === "api_key" ? context.credential.key : undefined; + if (apiKey !== catalogKey) { + // A live catalog is authoritative only for the credential that discovered it. + catalogKey = apiKey; + credentialRevision++; + catalog = cloneCatalog(OFFLINE_MODELS); + } + const revision = credentialRevision; + if (!context.allowNetwork || context.signal.aborted || typeof fetchImpl !== "function") { + return cloneCatalog(catalog); + } + + const ids = await fetchLiveModelIds({ + apiKey, + signal: context.signal, + fetchImpl, + timeoutMs: options.timeoutMs ?? NAN_MODELS_TIMEOUT_MS, + }); + if (revision !== credentialRevision || ids === undefined || context.signal.aborted) { + return cloneCatalog(catalog); + } + + catalog = knownChatModels(ids); + return cloneCatalog(catalog); + }, + }; +} diff --git a/tests/nan-provider.test.ts b/tests/nan-provider.test.ts new file mode 100644 index 000000000..7127de3ba --- /dev/null +++ b/tests/nan-provider.test.ts @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { RefreshModelsContext } from "@earendil-works/pi-ai"; +import type { ProviderConfig } from "@earendil-works/pi-coding-agent"; +import nanProviderExtension from "../extensions/nan-provider.ts"; +import { createNanProviderConfig, NAN_PROVIDER_BASE_URL, NAN_PROVIDER_ID } from "../lib/nan-provider.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function refreshContext(credential?: RefreshModelsContext["credential"]): RefreshModelsContext { + return { + credential, + stored: undefined, + allowNetwork: true, + signal: new AbortController().signal, + async publish() { + return true; + }, + }; +} + +test("extension registers NaN with Pi's OpenAI-compatible and native API-key configuration", () => { + let registeredName: string | undefined; + let registeredConfig: ProviderConfig | undefined; + nanProviderExtension({ + registerProvider(name: string, config: ProviderConfig) { + registeredName = name; + registeredConfig = config; + }, + } as never); + + assert.equal(registeredName, NAN_PROVIDER_ID); + assert.equal(registeredConfig?.name, "NaN"); + assert.equal(registeredConfig?.baseUrl, NAN_PROVIDER_BASE_URL); + assert.equal(registeredConfig?.api, "openai-completions"); + assert.equal(registeredConfig?.apiKey, "$NAN_API_KEY"); + assert.equal(registeredConfig?.authHeader, true); + assert.equal(typeof registeredConfig?.refreshModels, "function"); +}); + +test("offline baseline is one documented chat model with a configured output cap", () => { + const model = createNanProviderConfig().models?.[0]; + assert.equal(model?.id, "deepseek-v4-flash"); + assert.equal(model?.api, "openai-completions"); + assert.equal(model?.reasoning, true); + assert.deepEqual(model?.input, ["text", "image"]); + assert.equal(model?.contextWindow, 1_000_000); + assert.equal(createNanProviderConfig().models?.length, 1); + assert.equal(model?.maxTokens, 8_192); + assert.deepEqual(model?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); +}); + +test("live discovery uses the key-scoped endpoint and replaces the fallback with listed models", async () => { + let request: { url: string; init?: RequestInit } | undefined; + const config = createNanProviderConfig({ + fetchImpl: async (input, init) => { + request = { url: String(input), init }; + return jsonResponse({ data: [{ id: " glm5.3 " }, { id: "glm5.3" }, { id: "unknown-chat" }, { id: "embedding" }, { id: "image" }, { id: "speech" }, { id: "rerank" }] }); + }, + }); + const fallbackId = config.models?.[0]?.id; + assert.ok(fallbackId); + + const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })); + + assert.equal(request?.url, `${NAN_PROVIDER_BASE_URL}/models`); + assert.equal(request?.init?.method, "GET"); + assert.equal(new Headers(request?.init?.headers).get("authorization"), "Bearer test-secret"); + assert.equal(request?.init?.redirect, "error"); + assert.deepEqual(models?.map((model) => model.id), ["glm5.3"]); + assert.ok(!models?.some((model) => model.id === fallbackId)); + + const known = models?.[0]; + assert.equal(known?.api, "openai-completions"); + assert.equal(known?.reasoning, true); + assert.deepEqual(known?.input, ["text"]); + assert.equal(known?.contextWindow, 1_000_000); + assert.equal(known?.maxTokens, 8_192); + assert.deepEqual(known?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); +}); + +test("known chat models retain documented capabilities without advertising audio", async () => { + const expected = [ + ["glm5.3", 1_000_000, ["text"], 8_192], + ["deepseek-v4-flash", 1_000_000, ["text", "image"], 8_192], + ["glm5.3-flash", 1_000_000, ["text", "image"], 8_192], + ["qwen3.8-flash", 1_048_576, ["text", "image"], 131_000], + ["mimo-v2.6-flash", 1_000_000, ["text", "image"], 8_192], + ["gemma4", 262_000, ["text", "image"], 8_192], + ["qwen3.6", 262_000, ["text", "image"], 8_192], + ] as const; + const config = createNanProviderConfig({ + fetchImpl: async () => jsonResponse({ data: expected.map(([id]) => ({ id })) }), + }); + const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-key" })); + assert.equal(models?.length, expected.length); + for (const [index, [id, contextWindow, input, maxTokens]] of expected.entries()) { + const model = models?.[index]; + assert.equal(model?.id, id); + assert.equal(model?.reasoning, true); + assert.equal(model?.contextWindow, contextWindow); + assert.deepEqual(model?.input, input); + assert.equal(model?.maxTokens, maxTokens); + } +}); + +test("a successful list containing only unknown or non-chat IDs stays empty", async () => { + const config = createNanProviderConfig({ + fetchImpl: async () => jsonResponse({ data: ["unknown", "embedding", "image", "speech", "rerank"].map((id) => ({ id })) }), + }); + const context = refreshContext({ type: "api_key", key: "test-key" }); + assert.deepEqual(await config.refreshModels?.(context), []); + assert.deepEqual(await config.refreshModels?.({ ...context, allowNetwork: false }), []); +}); + +test("a successful empty key-scoped catalog does not restore offline fallback models", async () => { + const config = createNanProviderConfig({ fetchImpl: async () => jsonResponse({ data: [] }) }); + assert.ok(config.models && config.models.length > 0); + + const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })); + + assert.deepEqual(models, []); +}); + +test("failed or malformed discovery preserves the conservative baseline or last successful catalog", async () => { + const failingFetches: Array = [ + async () => jsonResponse({ error: "unavailable" }, 503), + async () => new Response("not-json", { status: 200 }), + async () => jsonResponse({ models: [{ id: "not-the-supported-shape" }] }), + async () => { + throw new Error("network unavailable"); + }, + ]; + + for (const fetchImpl of failingFetches) { + const config = createNanProviderConfig({ fetchImpl }); + const baseline = config.models; + const afterFailure = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })); + assert.deepEqual(afterFailure, baseline); + } + + let fail = false; + const config = createNanProviderConfig({ + fetchImpl: async () => fail ? jsonResponse({ error: "unavailable" }, 503) : jsonResponse({ data: [{ id: "glm5.3" }] }), + }); + const live = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })); + fail = true; + assert.deepEqual(await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })), live); +}); + +test("offline model refresh does not make a network request", async () => { + let calls = 0; + const config = createNanProviderConfig({ + fetchImpl: async () => { + calls++; + return jsonResponse({ data: [] }); + }, + }); + const context = refreshContext({ type: "api_key", key: "test-secret" }); + const models = await config.refreshModels?.({ ...context, allowNetwork: false }); + assert.equal(calls, 0); + assert.deepEqual(models, config.models); +}); + +test("credential changes discard previous live models before failed, offline, or cancelled discovery", async () => { + for (const mode of ["failure", "offline", "cancelled", "removed"] as const) { + let calls = 0; + const config = createNanProviderConfig({ + fetchImpl: async () => ++calls === 1 + ? jsonResponse({ data: [{ id: "glm5.3" }] }) + : jsonResponse({ error: "denied" }, 401), + }); + await config.refreshModels?.(refreshContext({ type: "api_key", key: "first-key" })); + const controller = new AbortController(); + if (mode === "cancelled") controller.abort(); + const context = refreshContext(mode === "removed" ? undefined : { type: "api_key", key: "second-key" }); + const models = await config.refreshModels?.({ + ...context, + allowNetwork: mode !== "offline", + signal: controller.signal, + }); + assert.deepEqual(models, config.models, mode); + assert.equal(calls, mode === "offline" || mode === "cancelled" ? 1 : 2); + } +}); + +test("same-key failure retains an authoritative empty catalog", async () => { + let calls = 0; + const config = createNanProviderConfig({ + fetchImpl: async () => ++calls === 1 ? jsonResponse({ data: [] }) : jsonResponse({}, 503), + }); + const context = refreshContext({ type: "api_key", key: "empty-key" }); + assert.deepEqual(await config.refreshModels?.(context), []); + assert.deepEqual(await config.refreshModels?.(context), []); +}); + +test("cancelling discovery aborts fetch and does not restore the previous key's catalog", async () => { + let calls = 0; + let requestSignal: AbortSignal | undefined; + let started!: () => void; + const pending = new Promise((resolve) => { started = resolve; }); + const config = createNanProviderConfig({ + fetchImpl: async (_input, init) => { + if (++calls === 1) return jsonResponse({ data: [{ id: "glm5.3" }] }); + requestSignal = init?.signal as AbortSignal; + return new Promise((_resolve, reject) => { + requestSignal!.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + started(); + }); + }, + }); + await config.refreshModels?.(refreshContext({ type: "api_key", key: "first-key" })); + const controller = new AbortController(); + const result = config.refreshModels?.({ + ...refreshContext({ type: "api_key", key: "second-key" }), signal: controller.signal, + }); + await pending; + controller.abort(); + assert.deepEqual(await result, config.models); + assert.equal(requestSignal?.aborted, true); +}); + +test("an old in-flight discovery cannot overwrite a changed credential's catalog", async () => { + let resolveOld!: (response: Response) => void; + const config = createNanProviderConfig({ + fetchImpl: async (_input, init) => { + if (new Headers(init?.headers).get("authorization") === "Bearer first-key") { + return new Promise((resolve) => { resolveOld = resolve; }); + } + return jsonResponse({ data: [] }); + }, + }); + const old = config.refreshModels?.(refreshContext({ type: "api_key", key: "first-key" })); + assert.deepEqual(await config.refreshModels?.(refreshContext({ type: "api_key", key: "second-key" })), []); + resolveOld(jsonResponse({ data: [{ id: "glm5.3" }] })); + assert.deepEqual(await old, []); + assert.deepEqual(await config.refreshModels?.({ + ...refreshContext({ type: "api_key", key: "second-key" }), allowNetwork: false, + }), []); +}); diff --git a/tests/runtime-harness.mjs b/tests/runtime-harness.mjs index 93ebacca2..66aefc38a 100644 --- a/tests/runtime-harness.mjs +++ b/tests/runtime-harness.mjs @@ -17,6 +17,7 @@ const ROOT = dirname(dirname(fileURLToPath(import.meta.url))); const { createGentleAiExtension } = await import(pathToFileURL(join(ROOT, "extensions/gentle-ai.ts")).href); const EXTENSIONS = [ "extensions/gentle-ai.ts", + "extensions/nan-provider.ts", "extensions/quiet-tools.ts", "extensions/skill-registry.ts", "extensions/startup-banner.ts", @@ -57,6 +58,7 @@ const FORBIDDEN_COMPAT_COMMANDS = [ function createPi() { const hooks = new Map(); const commands = new Map(); + const providers = new Map(); const flags = new Map(); const tools = new Map(); const eventHandlers = new Map(); @@ -89,6 +91,9 @@ function createPi() { registerCommand(name, definition) { commands.set(name, definition); }, + registerProvider(name, config) { + providers.set(name, config); + }, registerFlag(name, definition) { flags.set(name, definition); }, @@ -121,7 +126,7 @@ function createPi() { }, }; - return { pi, hooks, commands, flags, tools, emittedEvents }; + return { pi, hooks, commands, providers, flags, tools, emittedEvents }; } function createUi() { @@ -219,8 +224,9 @@ async function run() { process.env.GENTLE_PI_TEST_ASSETS_DIR = ambientTestAssetsDir; const globalModelsPath = join(globalConfigHome, "models.json"); const globalSubagentsPath = join(globalAgentHome, "subagents.json"); - const { pi, hooks, commands, flags, tools, emittedEvents } = createPi(); + const { pi, hooks, commands, providers, flags, tools, emittedEvents } = createPi(); await loadExtensions(pi); + assert.equal(providers.get("nan")?.api, "openai-completions", "runtime extension loading registers the NaN provider"); // gentle-pi#404: a collect binding that returns the native last-event // closure must terminate after one capture. It must not re-enter a public @@ -383,6 +389,10 @@ async function run() { [], "declared extension directory must load without invalid helper modules", ); + assert.ok( + discovered.extensions.some((extension) => extension.resolvedPath.endsWith(join("extensions", "nan-provider.ts"))), + "declared extension directory must discover the NaN provider", + ); // orchestrator-lazy-diet: Pi Subagent Model Routing detail (the "do not // pass the `model` parameter by default" / SDD-model-assignment-scoping