diff --git a/README.md b/README.md index a5737b20e..b0ef65e47 100644 --- a/README.md +++ b/README.md @@ -270,7 +270,7 @@ See the [v3.5.1 release notes](https://github.com/Gentleman-Programming/gentle-s ### 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. +The first-party `nan` provider is included; no third-party provider package is required. Set `NAN_API_KEY` before starting Pi, or use native `/login` → NaN (also `/login nan`), then use `/model` to select a model. Both login routes await explicit API-key input; blank or whitespace-only entries fail without saving a credential, and surrounding whitespace is trimmed. Cancellation leaves the stored key unchanged. Stored keys take precedence over `NAN_API_KEY`. 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 8,192-token cap rather than claiming the model's true limit. Before a successful refresh, all seven documented chat models are available as the offline fallback in `/gentle:models`: `glm5.3`, `deepseek-v4-flash`, `glm5.3-flash`, `qwen3.8-flash`, `mimo-v2.6-flash`, `gemma4`, and `qwen3.6`. This fallback declares documented support, not proof of access for your key. Once refreshed, the successful live key-scoped list remains authoritative (including an empty list), even offline or after a failed refresh. Changing credentials resets the catalog to the full documented fallback until discovery succeeds for the new key. NaN MCP search and media bridges are not included. ```text /gentle:status diff --git a/extensions/nan-provider.ts b/extensions/nan-provider.ts index 171ddcca3..ced5b53a7 100644 --- a/extensions/nan-provider.ts +++ b/extensions/nan-provider.ts @@ -1,6 +1,6 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { createNanProviderConfig, NAN_PROVIDER_ID } from "../lib/nan-provider.ts"; +import { createNanProviderConfig } from "../lib/nan-provider.ts"; export default function registerNanProvider(pi: ExtensionAPI): void { - pi.registerProvider(NAN_PROVIDER_ID, createNanProviderConfig()); + pi.registerProvider(createNanProviderConfig()); } diff --git a/lib/nan-provider.ts b/lib/nan-provider.ts index 167db9a24..6654efd06 100644 --- a/lib/nan-provider.ts +++ b/lib/nan-provider.ts @@ -1,5 +1,6 @@ -import type { RefreshModelsContext } from "@earendil-works/pi-ai"; -import type { ProviderConfig, ProviderModelConfig } from "@earendil-works/pi-coding-agent"; +import * as piAi from "@earendil-works/pi-ai"; +import type { Provider, ProviderStreams, RefreshModelsContext } from "@earendil-works/pi-ai"; +import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent"; export const NAN_PROVIDER_ID = "nan"; export const NAN_PROVIDER_BASE_URL = "https://api.nan.builders/v1"; @@ -34,8 +35,9 @@ const CHAT_MODELS: ProviderModelConfig[] = [ 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"); +// The cold/offline baseline declares documented chat support, not key entitlement. +// A successful live catalog remains authoritative for the credential that fetched it. +const OFFLINE_MODELS = CHAT_MODELS; function cloneModel(model: ProviderModelConfig): ProviderModelConfig { return { ...model, input: [...model.input], cost: { ...model.cost } }; @@ -104,19 +106,62 @@ function cloneCatalog(models: readonly ProviderModelConfig[]): ProviderModelConf return models.map(cloneModel); } -export function createNanProviderConfig(options: NanProviderOptions = {}): ProviderConfig { +/** Native auth belongs to the provider; Pi persists only a successful login result. */ +export function createNanProviderConfig(options: NanProviderOptions = {}): Provider<"openai-completions"> { + const config = createCatalogConfig(options); + // Pi's extension loader aliases the bare root to compat, which exposes this + // host-owned lazy API factory. Do not import an SDK implementation subpath. + const api = piAi.lazyApi(async () => { + const runtime = piAi as typeof piAi & { openAICompletionsApi?: () => ProviderStreams }; + if (!runtime.openAICompletionsApi) throw new Error("NaN requires Pi's OpenAI completions API factory"); + return runtime.openAICompletionsApi(); + }); + return { + id: NAN_PROVIDER_ID, + name: "NaN", + baseUrl: NAN_PROVIDER_BASE_URL, + auth: { apiKey: { + name: "NaN API key", + async login(interaction) { + interaction.signal.throwIfAborted(); + const entered = await interaction.prompt({ + type: "secret", message: "Enter API key", signal: interaction.signal, + }); + interaction.signal.throwIfAborted(); + const key = entered.trim(); + if (!key) throw new Error("NaN requires a non-empty API key"); + return { type: "api_key", key }; + }, + async resolve({ ctx, credential, signal }) { + signal.throwIfAborted(); + const stored = credential?.key?.trim(); + const key = stored || (await ctx.env("NAN_API_KEY"))?.trim(); + signal.throwIfAborted(); + return key ? { auth: { apiKey: key }, source: stored ? "API key" : "NAN_API_KEY" } : undefined; + }, + } }, + getModels: () => config.getModels().map((model) => ({ + ...cloneModel(model), provider: NAN_PROVIDER_ID, + baseUrl: NAN_PROVIDER_BASE_URL, api: "openai-completions" as const, + })), + refreshModels: (context) => config.refreshModels(context), + stream: api.stream, + streamSimple: api.streamSimple, + }; +} + +function createCatalogConfig(options: NanProviderOptions = {}): { + getModels(): ProviderModelConfig[]; + refreshModels(context: RefreshModelsContext): Promise; +} { 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), + // One source of truth lets Pi snapshot the new catalog inside publish.update. + getModels: () => cloneCatalog(catalog), refreshModels: async (context: RefreshModelsContext) => { const apiKey = context.credential?.type === "api_key" ? context.credential.key : undefined; if (apiKey !== catalogKey) { @@ -127,7 +172,7 @@ export function createNanProviderConfig(options: NanProviderOptions = {}): Provi } const revision = credentialRevision; if (!context.allowNetwork || context.signal.aborted || typeof fetchImpl !== "function") { - return cloneCatalog(catalog); + return; } const ids = await fetchLiveModelIds({ @@ -137,11 +182,12 @@ export function createNanProviderConfig(options: NanProviderOptions = {}): Provi timeoutMs: options.timeoutMs ?? NAN_MODELS_TIMEOUT_MS, }); if (revision !== credentialRevision || ids === undefined || context.signal.aborted) { - return cloneCatalog(catalog); + return; } - catalog = knownChatModels(ids); - return cloneCatalog(catalog); + await context.publish({ update: () => { + if (revision === credentialRevision && !context.signal.aborted) catalog = knownChatModels(ids); + } }); }, }; } diff --git a/tests/nan-provider.test.ts b/tests/nan-provider.test.ts index 7127de3ba..daa58f1d3 100644 --- a/tests/nan-provider.test.ts +++ b/tests/nan-provider.test.ts @@ -1,9 +1,28 @@ 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 { createModels, InMemoryCredentialStore } from "@earendil-works/pi-ai"; +import type { Provider } from "@earendil-works/pi-ai"; import nanProviderExtension from "../extensions/nan-provider.ts"; -import { createNanProviderConfig, NAN_PROVIDER_BASE_URL, NAN_PROVIDER_ID } from "../lib/nan-provider.ts"; +import { createNanProviderConfig as createNativeProvider, NAN_PROVIDER_BASE_URL, NAN_PROVIDER_ID } from "../lib/nan-provider.ts"; + +// Keep catalog assertions independent of the native refresh's void return contract. +function createNanProviderConfig(options: Parameters[0] = {}) { + const provider = createNativeProvider(options); + return { + ...provider, + models: provider.getModels(), + async refreshModels(context: RefreshModelsContext) { + await provider.refreshModels!(context); + return provider.getModels(); + }, + }; +} + +const DOCUMENTED_CHAT_IDS = [ + "glm5.3", "deepseek-v4-flash", "glm5.3-flash", "qwen3.8-flash", + "mimo-v2.6-flash", "gemma4", "qwen3.6", +]; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -18,43 +37,182 @@ function refreshContext(credential?: RefreshModelsContext["credential"]): Refres stored: undefined, allowNetwork: true, signal: new AbortController().signal, - async publish() { + async publish(publication) { + publication.update?.(); return true; }, }; } +test("native login rejects an explicitly submitted empty key", async () => { + const provider = createNativeProvider(); + await assert.rejects(async () => provider.auth.apiKey!.login!({ + signal: new AbortController().signal, + prompt: async () => "", + notify() {}, + }), /non-empty/); +}); + test("extension registers NaN with Pi's OpenAI-compatible and native API-key configuration", () => { - let registeredName: string | undefined; - let registeredConfig: ProviderConfig | undefined; + let registeredConfig: Provider | undefined; nanProviderExtension({ - registerProvider(name: string, config: ProviderConfig) { - registeredName = name; - registeredConfig = config; - }, + registerProvider(provider: Provider) { registeredConfig = provider; }, } as never); - - assert.equal(registeredName, NAN_PROVIDER_ID); + assert.equal(registeredConfig?.id, 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(registeredConfig?.getModels()[0]?.api, "openai-completions"); + assert.equal(typeof registeredConfig?.auth.apiKey?.login, "function"); + assert.equal(typeof registeredConfig?.streamSimple, "function"); assert.equal(typeof registeredConfig?.refreshModels, "function"); }); -test("offline baseline is one documented chat model with a configured output cap", () => { - const model = createNanProviderConfig().models?.[0]; +test("native login awaits input, trims keys, and rejects whitespace or cancellation", async () => { + const login = createNativeProvider().auth.apiKey!.login!; + const controller = new AbortController(); + let submit!: (key: string) => void; + let prompted = false; + let finished = false; + const pending = login({ signal: controller.signal, notify() {}, prompt: async (prompt) => { + prompted = true; + assert.equal(prompt.type, "secret"); + assert.equal(prompt.signal, controller.signal); + return new Promise((resolve) => { submit = resolve; }); + } }).then((result) => { finished = true; return result; }); + assert.equal(prompted, true); + assert.equal(finished, false); + submit(" synthetic-key \t"); + assert.deepEqual(await pending, { type: "api_key", key: "synthetic-key" }); + await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => " \t " }), /non-empty/); + await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => { throw new Error("cancelled"); } }), /cancelled/); + await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => { + controller.abort(); return "synthetic-key"; + } }), { name: "AbortError" }); + let calls = 0; + await assert.rejects(login({ signal: controller.signal, notify() {}, prompt: async () => { calls++; return "key"; } }), { name: "AbortError" }); + assert.equal(calls, 0); +}); + +test("native Models login never persists blank credentials and uses saved auth for requests", async () => { + const credentials = new InMemoryCredentialStore(); + const models = createModels({ credentials, authContext: { + env: async () => "env-key", fileExists: async () => false, + } }); + models.setProvider(createNativeProvider()); + const interaction = { prompt: async () => "", notify() {} }; + await assert.rejects(models.login("nan", "api_key", interaction), /non-empty/); + assert.equal(await credentials.read("nan"), undefined); + await models.login("nan", "api_key", { ...interaction, prompt: async () => " saved-key " }); + assert.deepEqual((await models.getAuth("nan"))?.auth, { apiKey: "saved-key" }); + await assert.rejects(models.login("nan", "api_key", interaction), /non-empty/); + assert.deepEqual(await credentials.read("nan"), { type: "api_key", key: "saved-key" }); + await models.logout("nan"); + assert.deepEqual((await models.getAuth("nan"))?.auth, { apiKey: "env-key" }); +}); + +test("native auth resolves stored keys before environment and rejects empty configuration", async () => { + const resolve = createNativeProvider().auth.apiKey!.resolve; + let envCalls = 0; + const input = { + ctx: { async env(name: string) { envCalls++; assert.equal(name, "NAN_API_KEY"); return " env-key "; }, async fileExists() { return false; } }, + signal: new AbortController().signal, + }; + assert.deepEqual(await resolve({ ...input, credential: { type: "api_key", key: " stored-key " } }), { auth: { apiKey: "stored-key" }, source: "API key" }); + assert.equal(envCalls, 0); + assert.deepEqual(await resolve({ ...input, credential: { type: "api_key", key: " " } }), { auth: { apiKey: "env-key" }, source: "NAN_API_KEY" }); + assert.equal(await resolve({ ...input, ctx: { ...input.ctx, env: async () => " " } }), undefined); +}); + +test("native publication exposes the new catalog synchronously, including an empty catalog", async () => { + for (const ids of [["glm5.3"], []]) { + const provider = createNativeProvider({ + fetchImpl: async () => jsonResponse({ data: ids.map((id) => ({ id })) }), + }); + let publications = 0; + await provider.refreshModels!({ + ...refreshContext({ type: "api_key", key: "snapshot-key" }), + async publish(publication) { + publications++; + publication.update?.(); + assert.deepEqual(provider.getModels().map((model) => model.id), ids); + return true; + }, + }); + assert.equal(publications, 1); + } +}); + +test("stale and aborted publication updates cannot replace the current key's catalog", async () => { + for (const mode of ["changed-key", "aborted"] as const) { + const provider = createNativeProvider({ + fetchImpl: async () => jsonResponse({ data: [{ id: "glm5.3" }] }), + }); + const controller = new AbortController(); + await provider.refreshModels!({ + ...refreshContext({ type: "api_key", key: "old-key" }), signal: controller.signal, + async publish(publication) { + if (mode === "changed-key") { + await provider.refreshModels!({ + ...refreshContext({ type: "api_key", key: "new-key" }), allowNetwork: false, + publish: async () => { assert.fail("offline refresh must not publish"); }, + }); + } else controller.abort(); + publication.update?.(); + assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS); + return false; + }, + }); + assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS); + } +}); + +test("offline and already-aborted refreshes invalidate keys without publication", async () => { + const provider = createNativeProvider({ fetchImpl: async () => jsonResponse({ data: [{ id: "glm5.3" }] }) }); + await provider.refreshModels!(refreshContext({ type: "api_key", key: "first" })); + for (const mode of ["offline", "aborted"] as const) { + const controller = new AbortController(); + if (mode === "aborted") controller.abort(); + await provider.refreshModels!({ + ...refreshContext({ type: "api_key", key: mode }), + allowNetwork: mode !== "offline", signal: controller.signal, + publish: async () => { assert.fail("refresh must not publish"); }, + }); + assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS); + } +}); + +test("initial catalog contains all seven documented chat models with configured output caps", () => { + const models = createNanProviderConfig().models; + assert.deepEqual(models.map((model) => model.id), DOCUMENTED_CHAT_IDS); + const model = models.find((model) => model.id === "deepseek-v4-flash"); 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(models.length, 7); assert.equal(model?.maxTokens, 8_192); assert.deepEqual(model?.cost, { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }); }); +test("catalog snapshots cannot mutate the offline baseline or another provider", async () => { + const provider = createNativeProvider(); + const snapshot = [...provider.getModels()]; + snapshot[0].id = "mutated"; + snapshot[0].input.push("image"); + snapshot[0].cost.input = 99; + snapshot.pop(); + const fresh = provider.getModels(); + assert.deepEqual(fresh.map((model) => model.id), DOCUMENTED_CHAT_IDS); + assert.deepEqual(fresh[0].input, ["text"]); + assert.equal(fresh[0].cost.input, 0); + assert.deepEqual(createNativeProvider().getModels(), fresh); + await provider.refreshModels!({ + ...refreshContext({ type: "api_key", key: "changed-key" }), allowNetwork: false, + }); + assert.deepEqual(provider.getModels(), fresh); +}); + 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({ @@ -63,7 +221,7 @@ test("live discovery uses the key-scoped endpoint and replaces the fallback with 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; + const fallbackId = "deepseek-v4-flash"; assert.ok(fallbackId); const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })); @@ -125,9 +283,12 @@ test("a successful empty key-scoped catalog does not restore offline fallback mo const models = await config.refreshModels?.(refreshContext({ type: "api_key", key: "test-secret" })); assert.deepEqual(models, []); + assert.deepEqual(await config.refreshModels({ + ...refreshContext({ type: "api_key", key: "test-secret" }), allowNetwork: false, + }), []); }); -test("failed or malformed discovery preserves the conservative baseline or last successful catalog", async () => { +test("failed or malformed discovery preserves the documented baseline or last successful catalog", async () => { const failingFetches: Array = [ async () => jsonResponse({ error: "unavailable" }, 503), async () => new Response("not-json", { status: 200 }), @@ -151,6 +312,9 @@ test("failed or malformed discovery preserves the conservative baseline or last 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); + assert.deepEqual(await config.refreshModels({ + ...refreshContext({ type: "api_key", key: "test-secret" }), allowNetwork: false, + }), live); }); test("offline model refresh does not make a network request", async () => { @@ -165,6 +329,12 @@ test("offline model refresh does not make a network request", async () => { const models = await config.refreshModels?.({ ...context, allowNetwork: false }); assert.equal(calls, 0); assert.deepEqual(models, config.models); + assert.deepEqual(models.map((model) => model.id), DOCUMENTED_CHAT_IDS); + const changed = await config.refreshModels({ + ...refreshContext({ type: "api_key", key: "different-key" }), allowNetwork: false, + }); + assert.deepEqual(changed.map((model) => model.id), DOCUMENTED_CHAT_IDS); + assert.equal(calls, 0); }); test("credential changes discard previous live models before failed, offline, or cancelled discovery", async () => { diff --git a/tests/runtime-harness.mjs b/tests/runtime-harness.mjs index 66aefc38a..bbbc2bba2 100644 --- a/tests/runtime-harness.mjs +++ b/tests/runtime-harness.mjs @@ -92,7 +92,8 @@ function createPi() { commands.set(name, definition); }, registerProvider(name, config) { - providers.set(name, config); + if (typeof name === "object") providers.set(name.id, name); + else providers.set(name, config); }, registerFlag(name, definition) { flags.set(name, definition); @@ -226,7 +227,7 @@ async function run() { const globalSubagentsPath = join(globalAgentHome, "subagents.json"); 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"); + assert.equal(providers.get("nan")?.getModels()[0]?.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 @@ -394,6 +395,18 @@ async function run() { "declared extension directory must discover the NaN provider", ); + const nativeNan = discovered.runtime.pendingNativeProviderRegistrations + .find((entry) => entry.provider.id === "nan")?.provider; + assert.ok(nativeNan, "actual Pi loader must queue native NaN registration"); + assert.ok(!discovered.runtime.pendingProviderRegistrations.some((entry) => entry.name === "nan"), + "NaN must not fall back to the legacy empty-key login route"); + await assert.rejects(nativeNan.auth.apiKey.login({ + signal: new AbortController().signal, prompt: async () => "", notify() {}, + }), /non-empty/); + assert.deepEqual(await nativeNan.auth.apiKey.login({ + signal: new AbortController().signal, prompt: async () => " synthetic-loader-key ", notify() {}, + }), { type: "api_key", key: "synthetic-loader-key" }); + // orchestrator-lazy-diet: Pi Subagent Model Routing detail (the "do not // pass the `model` parameter by default" / SDD-model-assignment-scoping // rules) moved verbatim to assets/orchestrator-delegation.md; the