pi-rozalia 0.1.4 → 0.2.1

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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-rozalia
2
2
 
3
- An extension for the [Pi coding agent](https://github.com/microsoft/pi-coding-agent) that connects to a [Rozalia AI](https://ai.zygoon.pl) server via the OpenAI-compatible Chat Completions API.
3
+ An extension for the [Pi coding agent](https://github.com/earendil-works/pi) that connects to a [Rozalia AI](https://ai.zygoon.pl) server via the OpenAI-compatible Chat Completions API.
4
4
 
5
5
  ## Prerequisites
6
6
 
@@ -34,7 +34,6 @@ The server URL and API key are stored in `~/.pi/agent/auth.json` and reused on s
34
34
  | Variable | Description | Default |
35
35
  |----------|-------------|---------|
36
36
  | `ROZALIA_BASE_URL` | Rozalia AI server base URL | `https://ai.zygoon.pl/v1` |
37
- | `ROZALIA_BASE_URL` | Rozalia AI server base URL | `https://ai.zygoon.pl/v1` |
38
37
  | `ROZALIA_API_KEY` | API key for authentication | *(none)* |
39
38
  | `ROZALIA_TIMEOUT` | Model discovery timeout in ms | `5000` |
40
39
 
@@ -29,6 +29,7 @@
29
29
 
30
30
  import type { ExtensionAPI, ProviderModelConfig } from "@earendil-works/pi-coding-agent";
31
31
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
32
+ import type { RefreshModelsContext } from "@earendil-works/pi-ai";
32
33
 
33
34
  // ---------------------------------------------------------------------------
34
35
  // Credential payload (stored in OAuth refresh field).
@@ -198,6 +199,78 @@ function logDiscoveryError(baseUrl: string, error: unknown): void {
198
199
  );
199
200
  }
200
201
 
202
+ // ---------------------------------------------------------------------------
203
+ // TTL cache for model discovery
204
+ // ---------------------------------------------------------------------------
205
+
206
+ let cachedModels: ProviderModelConfig[] = [];
207
+ let lastFetchedAt = 0;
208
+ const MODEL_CACHE_TTL_MS = 5 * 1000; // 5 seconds — debounce only; /v1/models is cheap on a self-hosted server and the coordinator already dedupes concurrent calls
209
+
210
+ /**
211
+ * Resolve baseUrl and apiKey from Pi's credential context.
212
+ *
213
+ * Priority: oauth credential > env vars.
214
+ * This is called by Pi during its refresh cycle — the credential is the
215
+ * effective configured auth (OAuth stored in ~/.pi/agent/auth.json or
216
+ * env-based API key).
217
+ */
218
+ function resolveAuthFromContext(context: RefreshModelsContext): { baseUrl: string; apiKey: string } {
219
+ let baseUrl = getEnvBaseUrl();
220
+ let apiKey = getEnvApiKey();
221
+
222
+ const cred = context.credential;
223
+ if (cred?.type === "oauth") {
224
+ const p = decodeCreds(cred as OAuthCredentials);
225
+ if (p.baseUrl) baseUrl = p.baseUrl;
226
+ if (p.apiKey) apiKey = p.apiKey;
227
+ } else if (cred?.type === "api_key" && typeof cred.key === "string") {
228
+ apiKey = apiKey || cred.key;
229
+ }
230
+
231
+ return { baseUrl, apiKey };
232
+ }
233
+
234
+ /**
235
+ * The `refreshModels` hook — called by Pi on every refresh cycle (startup,
236
+ * explicit model refresh, etc.). Implements a TTL cache to avoid hammering
237
+ * the server while still responding to real changes within 5 minutes.
238
+ */
239
+ async function refreshRozaliaModels(context: RefreshModelsContext): Promise<ProviderModelConfig[]> {
240
+ // TTL cache: skip fetch if we have a fresh list and caller didn't force
241
+ if (context.allowNetwork && !context.force && cachedModels.length > 0) {
242
+ const age = Date.now() - lastFetchedAt;
243
+ if (age < MODEL_CACHE_TTL_MS) {
244
+ return cachedModels;
245
+ }
246
+ }
247
+
248
+ const { baseUrl, apiKey } = resolveAuthFromContext(context);
249
+ if (!baseUrl) {
250
+ return cachedModels.length > 0 ? cachedModels : getFallbackModels();
251
+ }
252
+
253
+ // No API key means the user is logged out — skip fetch, return stub.
254
+ if (!apiKey) {
255
+ return cachedModels.length > 0 ? cachedModels : getFallbackModels();
256
+ }
257
+
258
+ try {
259
+ const controller = new AbortController();
260
+ const fetched = await fetchModels(baseUrl, apiKey, controller.signal);
261
+ if (fetched.length > 0) {
262
+ cachedModels = fetched;
263
+ lastFetchedAt = Date.now();
264
+ return fetched;
265
+ }
266
+ return getFallbackModels();
267
+ } catch (error) {
268
+ logDiscoveryError(baseUrl, error);
269
+ // Return cached models if available (don't replace with fallback on transient failure)
270
+ return cachedModels.length > 0 ? cachedModels : getFallbackModels();
271
+ }
272
+ }
273
+
201
274
  // ---------------------------------------------------------------------------
202
275
  // Provider registration helper
203
276
  // ---------------------------------------------------------------------------
@@ -215,6 +288,9 @@ async function registerRozaliaProvider(
215
288
  const { baseUrl, apiKey } = creds;
216
289
  if (!baseUrl) return;
217
290
 
291
+ // No API key — user is logged out, keep the stub.
292
+ if (!apiKey) return;
293
+
218
294
  let models: ProviderModelConfig[];
219
295
  try {
220
296
  const controller = new AbortController();
@@ -313,7 +389,7 @@ function buildOauthBlock(
313
389
  refreshToken: async (creds: OAuthCredentials) => {
314
390
  const payload = decodeCreds(creds);
315
391
  if (!payload.baseUrl) return creds;
316
- // Re-register with fresh models so the picker updates without restart.
392
+ // Refresh the model list so the picker updates without restart.
317
393
  // Reuse this same oauthBlock instead of reconstructing it — only the
318
394
  // model list changes, not the auth callbacks.
319
395
  try {
@@ -340,25 +416,27 @@ export default async function (pi: ExtensionAPI) {
340
416
  // (Pi only passes callbacks to login, not pi).
341
417
  const oauthBlock = buildOauthBlock(envBaseUrl, envApiKey, pi);
342
418
 
343
- // Initial stub registration so "Rozalia" appears in /login selector.
344
- // Credentials are resolved by Pi's OAuth machinery at request time — we no
345
- // longer read ~/.pi/agent/auth.json directly (see commit history for why).
419
+ // Register the provider with refreshModels so Pi populates models on startup.
420
+ // The refreshModels hook is called by Pi during its refresh cycle (startup,
421
+ // explicit model refresh, etc.) with the resolved credential — this is the
422
+ // canonical Pi mechanism for dynamic model discovery.
346
423
  pi.registerProvider("rozalia", {
347
424
  name: "Rozalia",
348
425
  baseUrl: envBaseUrl,
349
426
  api: "openai-completions",
350
427
  authHeader: true,
351
- models: [],
428
+ models: [], // Stub — refreshModels will populate the real list
429
+ refreshModels: refreshRozaliaModels,
352
430
  oauth: oauthBlock,
353
431
  });
354
432
 
355
433
  // Best-effort: if the API key is provided via env var, register eagerly so
356
- // models appear without waiting for /login.
434
+ // models appear immediately without waiting for Pi's refresh cycle.
357
435
  if (envApiKey) {
358
436
  try {
359
437
  await registerRozaliaProvider(pi, { baseUrl: envBaseUrl, apiKey: envApiKey }, oauthBlock);
360
438
  } catch {
361
- // ignore — will retry on next call
439
+ // ignore — will retry on next Pi refresh cycle
362
440
  }
363
441
  }
364
442
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-rozalia",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "OpenAI-compatible provider extension for the Pi coding agent — connects to Rozalia AI",
5
5
  "keywords": [
6
6
  "pi-package",