@gtrabanco/pi-nan-provider 0.10.3 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,12 @@
22
22
  * Streaming API resolution: bare root under pi's compat alias is the instance
23
23
  * anchor; otherwise a file URL from import.meta.resolve; never a bare subpath
24
24
  * (loud failure — issue #8). Full logic in src/pi-ai-loader.ts.
25
+ *
26
+ * The provider also exposes NaN image models (flux-2-klein, qwen-image-2.1)
27
+ * through an image API ("nan-images"): `baselineImageModels` are added to the
28
+ * model set and a `ProviderImages` implementation is wired via
29
+ * `createNanImagesApi`, so pi's model registry can route
30
+ * `generateImages()` calls through it.
25
31
  */
26
32
 
27
33
  import * as piAi from "@earendil-works/pi-ai";
@@ -31,11 +37,17 @@ import type {
31
37
  RefreshModelsContext,
32
38
  } from "@earendil-works/pi-ai";
33
39
  import { withContextOverflowClassification } from "./context-overflow-classifier.ts";
40
+ import { NAN_IMAGE_API, baselineImageModels, createNanImagesApi } from "./images.ts";
34
41
  import {
35
42
  baselineModels,
36
43
  DEFAULT_MODELS_TIMEOUT_MS,
37
44
  resolveCatalog,
38
45
  type CatalogSource,
46
+ overlayGeneratedCapabilities,
47
+ buildOverlayMap,
48
+ applyOverlay,
49
+ type OverlayEntry,
50
+ NAN_GENERATED_MODELS,
39
51
  } from "./fetch-models.ts";
40
52
  import { resolveOpenAICompletionsApi } from "./pi-ai-loader.ts";
41
53
  import { sanitizeOpenAICompatPayload } from "./openai-compat-sanitizer.ts";
@@ -126,6 +138,55 @@ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStre
126
138
  };
127
139
  }
128
140
 
141
+ /**
142
+ * Stale-catalog overlay (issue #19): pi-ai's `createProvider` builds an
143
+ * internal `refreshModels` where phase 1 restores `context.stored.models`
144
+ * verbatim and phase 2 calls the input `fetchModels` + persists. The
145
+ * `dynamicModels` list is a closure variable — external code cannot assign
146
+ * it. When pi restores a persisted store entry written by an older version
147
+ * of this package (e.g. without `thinkingLevelMap`), that stale copy is
148
+ * used as-is and never refreshed (because `checkedAt` is recent).
149
+ *
150
+ * `currentModels()` merges static `input.models` with `dynamicModels` and a
151
+ * dynamic entry REPLACES the baseline entry with the same id+type (the
152
+ * shadowing mechanism). The `ModelsImpl` in pi resolves models exclusively
153
+ * via `getModels()` / `getAllModels()` on the provider object — it never
154
+ * inspects the internal dynamicModels list.
155
+ *
156
+ * The fix: wrap `getModels` and `getAllModels` on the returned provider so
157
+ * every call re-checks the generated catalog and applies capability data
158
+ * when the id exists there. The store decides which ids exist; the
159
+ * generated catalog decides capability data. Read-time overlay is the only
160
+ * provider-side hook that wins over the stale restore.
161
+ *
162
+ * `overlayMap` is prebuilt once at factory time from BOTH the generated
163
+ * chat catalog AND `baselineImageModels(source)`. Both `getModels`
164
+ * and `getAllModels` use `applyOverlay` for O(1) per-model lookup (GAP 2).
165
+ * This also covers image models: pi-ai's image models are only visible
166
+ * through `getAllModels()`, so a stale stored IMAGE entry would otherwise
167
+ * replace the baseline image entry with no overlay (GAP 1).
168
+ *
169
+ * We do NOT override `refreshModels` — the built-in phases stay intact. The
170
+ * overlay only affects read paths.
171
+ */
172
+ function withStaleCatalogOverlay(
173
+ provider: Provider<"openai-completions">,
174
+ overlayMap: Map<string, OverlayEntry>,
175
+ ): Provider<"openai-completions"> {
176
+ return {
177
+ ...provider,
178
+ getModels: () => {
179
+ const models = provider.getModels();
180
+ return models.map((m) => applyOverlay(m, overlayMap)) as typeof models;
181
+ },
182
+ getAllModels: () => {
183
+ const all = provider.getAllModels?.();
184
+ if (!all) return [];
185
+ return all.map((m) => applyOverlay(m, overlayMap));
186
+ },
187
+ };
188
+ }
189
+
129
190
  /**
130
191
  * Build a complete pi-ai Provider for an OpenAI-compatible endpoint:
131
192
  *
@@ -133,11 +194,15 @@ export function wrapApiForStrictSanitization(api: ProviderStreams): ProviderStre
133
194
  * `/login <id>` prompts for the key (pi's `envApiKeyAuth` semantics — the
134
195
  * same precedence the built-in providers use). No prompt is needed when the
135
196
  * env var is set.
136
- * - models: the generated fallback catalog as static baseline, so models are
197
+ * - models: the generated chat-model fallback catalog plus NaN image models
198
+ * (flux-2-klein, qwen-image-2.1) as static baseline, so all models are
137
199
  * available with zero network.
138
200
  * - fetchModels: live `/models` IDs × generated capability data; falls back
139
201
  * to the baseline when the endpoint is unreachable. pi's Models runtime
140
202
  * drives refreshes (startup/periodic) and persists the overlay.
203
+ * - images: a `ProviderImages` implementation for NaN's OpenAI-compatible
204
+ * image endpoints, so `generateImages()` calls are routed through pi's
205
+ * model registry.
141
206
  * - api: the openai-completions streaming implementation resolved via
142
207
  * `resolveOpenAICompletionsApi` (bare root under pi's compat alias as
143
208
  * the instance anchor; otherwise a file URL from import.meta.resolve;
@@ -157,33 +222,40 @@ export async function createNanCompatibleProvider(
157
222
  // `available` (e.g. premium-tier models you are not subscribed to).
158
223
  let liveIds: Set<string> | undefined;
159
224
 
160
- return piAi.createProvider({
161
- id: config.id,
162
- name: config.name,
163
- baseUrl: config.baseUrl,
164
- auth: { apiKey: piAi.envApiKeyAuth(`${config.name} API key`, config.envVars) },
165
- models: baselineModels(source),
166
- fetchModels: async (context: RefreshModelsContext) => {
167
- const credential = context.credential;
168
- const resolved = await resolveCatalog(source, {
169
- apiKey: credential?.type === "api_key" ? credential.key : undefined,
170
- timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS,
171
- fetchImpl: options.fetchImpl,
172
- });
173
- liveIds = resolved.liveIds;
174
- return resolved.models;
175
- },
176
- filterModels: (models) => {
177
- // Snapshot: TS can't prove `liveIds` unchanged across the closure boundary.
178
- const current = liveIds;
179
- return current ? models.filter((model) => current.has(model.id)) : models;
180
- },
181
- // Sanitize the payload for NaN's strict schema, then classify an opaque
182
- // generic 400 as a context overflow when the request we just sent was over
183
- // the model's window. The second layer keeps a replayed cross-model
184
- // reasoning trace (or any other over-window request the context-hook guard
185
- // cannot reach, e.g. NAN_THINKING_GUARD=0) recoverable instead of wedging
186
- // the session — see src/context-overflow-classifier.ts.
187
- api: withContextOverflowClassification(wrapApiForStrictSanitization(apiFactory())),
188
- });
225
+ const imageModels = baselineImageModels(source);
226
+ const overlayMap = buildOverlayMap(source, NAN_GENERATED_MODELS, imageModels);
227
+
228
+ return withStaleCatalogOverlay(
229
+ piAi.createProvider({
230
+ id: config.id,
231
+ name: config.name,
232
+ baseUrl: config.baseUrl,
233
+ auth: { apiKey: piAi.envApiKeyAuth(`${config.name} API key`, config.envVars) },
234
+ models: [...baselineModels(source), ...baselineImageModels(source)],
235
+ images: { [NAN_IMAGE_API]: createNanImagesApi({ fetchImpl: options.fetchImpl, timeoutMs: options.timeoutMs }) },
236
+ fetchModels: async (context: RefreshModelsContext) => {
237
+ const credential = context.credential;
238
+ const resolved = await resolveCatalog(source, {
239
+ apiKey: credential?.type === "api_key" ? credential.key : undefined,
240
+ timeoutMs: options.timeoutMs ?? DEFAULT_MODELS_TIMEOUT_MS,
241
+ fetchImpl: options.fetchImpl,
242
+ });
243
+ liveIds = resolved.liveIds;
244
+ return resolved.models;
245
+ },
246
+ filterModels: (models) => {
247
+ // Snapshot: TS can't prove `liveIds` unchanged across the closure boundary.
248
+ const current = liveIds;
249
+ return current ? models.filter((model) => current.has(model.id)) : models;
250
+ },
251
+ // Sanitize the payload for NaN's strict schema, then classify an opaque
252
+ // generic 400 as a context overflow when the request we just sent was over
253
+ // the model's window. The second layer keeps a replayed cross-model
254
+ // reasoning trace (or any other over-window request the context-hook guard
255
+ // cannot reach, e.g. NAN_THINKING_GUARD=0) recoverable instead of wedging
256
+ // the session — see src/context-overflow-classifier.ts.
257
+ api: withContextOverflowClassification(wrapApiForStrictSanitization(apiFactory())),
258
+ }),
259
+ overlayMap,
260
+ );
189
261
  }