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 1/3] 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 From 25bdff2e9834c4ce86cd286537b8ed0b179cac91 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 22:28:59 +0000 Subject: [PATCH 2/3] fix(provider): validate native NaN API-key login --- README.md | 2 +- extensions/nan-provider.ts | 4 +- lib/nan-provider.ts | 71 +++++++++++++--- tests/nan-provider.test.ts | 161 +++++++++++++++++++++++++++++++++---- tests/runtime-harness.mjs | 17 +++- 5 files changed, 223 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index a5737b20e..07e449b8b 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. 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 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..757190fe3 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"; @@ -104,19 +105,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 +171,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 +181,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..a7b49333a 100644 --- a/tests/nan-provider.test.ts +++ b/tests/nan-provider.test.ts @@ -1,9 +1,23 @@ 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(); + }, + }; +} function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { @@ -18,31 +32,150 @@ 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("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), ["deepseek-v4-flash"]); + return false; + }, + }); + assert.deepEqual(provider.getModels().map((model) => model.id), ["deepseek-v4-flash"]); + } +}); + +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), ["deepseek-v4-flash"]); + } +}); + 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"); 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 From f40d176b94bb1a5226af4bc2b21f58ea35e80545 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 23:48:06 +0000 Subject: [PATCH 3/3] fix(provider): retain complete NaN chat catalog offline --- README.md | 2 +- lib/nan-provider.ts | 5 ++-- tests/nan-provider.test.ts | 53 ++++++++++++++++++++++++++++++++------ 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 07e449b8b..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 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. 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/lib/nan-provider.ts b/lib/nan-provider.ts index 757190fe3..6654efd06 100644 --- a/lib/nan-provider.ts +++ b/lib/nan-provider.ts @@ -35,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 } }; diff --git a/tests/nan-provider.test.ts b/tests/nan-provider.test.ts index a7b49333a..daa58f1d3 100644 --- a/tests/nan-provider.test.ts +++ b/tests/nan-provider.test.ts @@ -19,6 +19,11 @@ function createNanProviderConfig(options: Parameters model.id), ["deepseek-v4-flash"]); + assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS); return false; }, }); - assert.deepEqual(provider.getModels().map((model) => model.id), ["deepseek-v4-flash"]); + assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS); } }); @@ -172,22 +177,42 @@ test("offline and already-aborted refreshes invalidate keys without publication" allowNetwork: mode !== "offline", signal: controller.signal, publish: async () => { assert.fail("refresh must not publish"); }, }); - assert.deepEqual(provider.getModels().map((model) => model.id), ["deepseek-v4-flash"]); + assert.deepEqual(provider.getModels().map((model) => model.id), DOCUMENTED_CHAT_IDS); } }); -test("offline baseline is one documented chat model with a configured output cap", () => { - const model = createNanProviderConfig().models?.[0]; +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({ @@ -196,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" })); @@ -258,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 }), @@ -284,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 () => { @@ -298,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 () => {