Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions extensions/nan-provider.ts
Original file line number Diff line number Diff line change
@@ -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());
}
76 changes: 61 additions & 15 deletions lib/nan-provider.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 } };
Expand Down Expand Up @@ -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<void>;
} {
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) {
Expand All @@ -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({
Expand All @@ -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);
} });
},
};
}
Loading
Loading