@aliou/pi-ts-aperture 0.6.1 → 0.6.2

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.
@@ -0,0 +1,121 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import type { Api } from "@earendil-works/pi-ai";
5
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
6
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
7
+
8
+ /**
9
+ * Stale-while-revalidate disk cache for dedicated Aperture models.
10
+ *
11
+ * Dedicated Aperture models are only discoverable by hitting the authenticated
12
+ * Aperture `/api/providers` endpoint, which we can only do inside
13
+ * `session_start` / `onSync` (Pi does not expose the gateway to the extension
14
+ * factory). However, Pi validates scoped models (e.g.
15
+ * `aperture/<model-id>`) during startup, *before* `session_start` fires. To
16
+ * avoid "No models match pattern" warnings on saved scoped models, we persist
17
+ * the last fetch to disk so the provider can be registered with cached models
18
+ * instantly on the next launch. The first run with no cache still warns once;
19
+ * subsequent runs resolve cleanly.
20
+ *
21
+ * Unlike a plain `ProviderModelConfig[]` cache, dedicated mode uses a custom
22
+ * `"aperture"` API with a `streamSimple` that routes each request to the
23
+ * upstream Aperture API (openai-completions, anthropic-messages, ...). That
24
+ * upstream `api` and per-model `baseUrl` are derived from compatibility at
25
+ * fetch time and are *not* stored on the model config (model.api is the
26
+ * `"aperture"` string), so the cache also persists the modelId -> upstream Api
27
+ * route map. The gateway URL is stored so a stale cache for a different
28
+ * gateway is ignored until revalidation rewrites it.
29
+ *
30
+ * File shape: `{ version: 1, gatewayUrl: string, models: ProviderModelConfig[], routes: Record<string, Api> }`.
31
+ */
32
+
33
+ const CACHE_VERSION = 1;
34
+ const CACHE_FILENAME = "aperture-dedicated-models.json";
35
+
36
+ function cachePath(): string {
37
+ return join(getAgentDir(), "cache", CACHE_FILENAME);
38
+ }
39
+
40
+ export interface DedicatedModelsCacheFile {
41
+ version?: unknown;
42
+ gatewayUrl?: unknown;
43
+ models?: unknown;
44
+ routes?: unknown;
45
+ }
46
+
47
+ export interface DedicatedModelsCache {
48
+ gatewayUrl: string;
49
+ models: ProviderModelConfig[];
50
+ routes: Record<string, Api>;
51
+ }
52
+
53
+ /**
54
+ * Read cached dedicated models synchronously.
55
+ *
56
+ * Designed to be called from the provider extension factory body, where Pi
57
+ * has not entered the event loop yet. Returns `null` if the cache is missing,
58
+ * unreadable, malformed, or for a different gateway URL.
59
+ */
60
+ export function loadCachedDedicatedModels(
61
+ expectedGatewayUrl: string,
62
+ ): DedicatedModelsCache | null {
63
+ try {
64
+ const path = cachePath();
65
+ if (!existsSync(path)) return null;
66
+
67
+ const parsed: DedicatedModelsCacheFile = JSON.parse(
68
+ readFileSync(path, "utf8"),
69
+ );
70
+ if (parsed.version !== CACHE_VERSION) return null;
71
+ if (typeof parsed.gatewayUrl !== "string") return null;
72
+ if (parsed.gatewayUrl !== expectedGatewayUrl) return null;
73
+ if (!Array.isArray(parsed?.models)) return null;
74
+ if (
75
+ parsed.routes === null ||
76
+ typeof parsed.routes !== "object" ||
77
+ Array.isArray(parsed.routes)
78
+ ) {
79
+ return null;
80
+ }
81
+
82
+ return {
83
+ gatewayUrl: parsed.gatewayUrl,
84
+ models: parsed.models as ProviderModelConfig[],
85
+ routes: parsed.routes as Record<string, Api>,
86
+ };
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Persist dedicated models to disk for the next startup.
94
+ *
95
+ * Called after a successful `/api/providers` fetch. Failures are swallowed
96
+ * since a missing cache only degrades to first-run behavior.
97
+ */
98
+ export async function writeCachedDedicatedModels(
99
+ gatewayUrl: string,
100
+ models: ProviderModelConfig[],
101
+ routes: Map<string, Api>,
102
+ ): Promise<void> {
103
+ try {
104
+ const path = cachePath();
105
+ await mkdir(dirname(path), { recursive: true });
106
+ const routesRecord: Record<string, Api> = {};
107
+ for (const [modelId, api] of routes) routesRecord[modelId] = api;
108
+ await writeFile(
109
+ path,
110
+ `${JSON.stringify(
111
+ { version: CACHE_VERSION, gatewayUrl, models, routes: routesRecord },
112
+ null,
113
+ 2,
114
+ )}\n`,
115
+ "utf8",
116
+ );
117
+ } catch {
118
+ // Cache writes are best-effort. A missing cache only falls back to the
119
+ // first-run path (next session revalidates and writes again).
120
+ }
121
+ }
@@ -13,6 +13,11 @@ import {
13
13
  getBaseUrlForApi,
14
14
  } from "./api-routing";
15
15
  import { buildDefaultModelConfig } from "./model-defaults";
16
+ import {
17
+ type DedicatedModelsCache,
18
+ loadCachedDedicatedModels,
19
+ writeCachedDedicatedModels,
20
+ } from "./models-cache";
16
21
 
17
22
  const PROVIDER_NAME = "aperture";
18
23
  const APERTURE_API = "aperture";
@@ -22,6 +27,11 @@ const HEADERS = {
22
27
  "X-Title": "npm:@aliou/pi-ts-aperture",
23
28
  };
24
29
 
30
+ interface BuiltModels {
31
+ models: ProviderModelConfig[];
32
+ routeByModelId: Map<string, { api: Api }>;
33
+ }
34
+
25
35
  function filterProviders(
26
36
  providers: ApertureProvider[],
27
37
  config: ResolvedConfig,
@@ -34,7 +44,81 @@ function filterProviders(
34
44
  : providers;
35
45
  }
36
46
 
47
+ function buildModels(
48
+ providers: ApertureProvider[],
49
+ gatewayUrl: string,
50
+ baseUrl: string,
51
+ ): BuiltModels {
52
+ const routeByModelId = new Map<string, { api: Api }>();
53
+ const models: ProviderModelConfig[] = [];
54
+
55
+ for (const provider of providers) {
56
+ const api = getApiForCompatibility(provider.compatibility);
57
+ for (const modelId of provider.models) {
58
+ routeByModelId.set(modelId, { api });
59
+ models.push({
60
+ ...buildDefaultModelConfig({
61
+ id: modelId,
62
+ providerId: provider.id,
63
+ provider: { id: provider.id, name: provider.name },
64
+ }),
65
+ api: APERTURE_API,
66
+ baseUrl: getBaseUrlForApi(api, gatewayUrl, baseUrl),
67
+ });
68
+ }
69
+ }
70
+
71
+ return { models, routeByModelId };
72
+ }
73
+
74
+ function registerFromBuilt(
75
+ pi: Pick<ExtensionAPI, "registerProvider">,
76
+ baseUrl: string,
77
+ built: BuiltModels,
78
+ ): void {
79
+ if (built.models.length === 0) return;
80
+ pi.registerProvider(PROVIDER_NAME, {
81
+ baseUrl,
82
+ apiKey: "-",
83
+ api: APERTURE_API,
84
+ headers: HEADERS,
85
+ models: built.models,
86
+ streamSimple: buildStreamSimple(built.routeByModelId),
87
+ });
88
+ }
89
+
37
90
  export class DedicatedRuntime {
91
+ /**
92
+ * Register the aperture provider synchronously from the on-disk cache so Pi
93
+ * can validate scoped models during startup, before `session_start`
94
+ * revalidates from the live gateway.
95
+ *
96
+ * No-ops when dedicated is disabled, the gateway URL is unset, or there is
97
+ * no usable cache (first run, or cache for a different gateway URL). The
98
+ * subsequent revalidation in {@link syncConfig} writes a fresh cache.
99
+ */
100
+ registerCached(pi: Pick<ExtensionAPI, "registerProvider">): void {
101
+ const config = configLoader.getConfig();
102
+ if (!config.dedicated.enabled) return;
103
+
104
+ const gatewayUrl = resolveGatewayUrl(config);
105
+ const baseUrl = resolveProviderBaseUrl(config);
106
+ if (!gatewayUrl || !baseUrl) return;
107
+
108
+ const cache = loadCachedDedicatedModels(gatewayUrl);
109
+ if (!cache) return;
110
+
111
+ const routeByModelId = new Map<string, { api: Api }>();
112
+ for (const [modelId, api] of Object.entries(cache.routes)) {
113
+ routeByModelId.set(modelId, { api });
114
+ }
115
+
116
+ registerFromBuilt(pi, baseUrl, {
117
+ models: cache.models,
118
+ routeByModelId,
119
+ });
120
+ }
121
+
38
122
  async sync(pi: Pick<ExtensionAPI, "registerProvider">): Promise<void> {
39
123
  const config = configLoader.getConfig();
40
124
  await this.syncConfig(pi, config);
@@ -54,34 +138,18 @@ export class DedicatedRuntime {
54
138
  await new ApertureClient(gatewayUrl).providers(),
55
139
  config,
56
140
  );
57
- const routeByModelId = new Map<string, { api: Api }>();
58
- const models: ProviderModelConfig[] = [];
59
-
60
- for (const provider of providers) {
61
- const api = getApiForCompatibility(provider.compatibility);
62
- for (const modelId of provider.models) {
63
- routeByModelId.set(modelId, { api });
64
- models.push({
65
- ...buildDefaultModelConfig({
66
- id: modelId,
67
- providerId: provider.id,
68
- provider: { id: provider.id, name: provider.name },
69
- }),
70
- api: APERTURE_API,
71
- baseUrl: getBaseUrlForApi(api, gatewayUrl, baseUrl),
72
- });
73
- }
74
- }
141
+ const built = buildModels(providers, gatewayUrl, baseUrl);
75
142
 
76
- if (models.length === 0) return;
143
+ registerFromBuilt(pi, baseUrl, built);
77
144
 
78
- pi.registerProvider(PROVIDER_NAME, {
79
- baseUrl,
80
- apiKey: "-",
81
- api: APERTURE_API,
82
- headers: HEADERS,
83
- models,
84
- streamSimple: buildStreamSimple(routeByModelId),
85
- });
145
+ if (built.models.length > 0) {
146
+ const routes = new Map<string, Api>();
147
+ for (const [modelId, route] of built.routeByModelId) {
148
+ routes.set(modelId, route.api);
149
+ }
150
+ await writeCachedDedicatedModels(gatewayUrl, built.models, routes);
151
+ }
86
152
  }
87
153
  }
154
+
155
+ export type { DedicatedModelsCache };
@@ -14,6 +14,18 @@ export default async function (pi: ExtensionAPI): Promise<void> {
14
14
 
15
15
  const proxyRuntime = new ApertureRuntime();
16
16
  const dedicatedRuntime = new DedicatedRuntime();
17
+
18
+ // Stale-while-revalidate seed for dedicated Aperture models.
19
+ //
20
+ // Dedicated models are only discoverable by hitting the Aperture
21
+ // `/api/providers` endpoint, which we can do inside `session_start`. Pi
22
+ // validates scoped models during startup, *before* `session_start` fires,
23
+ // so we synchronously restore the previous session's fetch from the on-disk
24
+ // cache so the provider is registered with cached models at load time.
25
+ // `session_start` then revalidates from the live gateway, writes the cache
26
+ // back, and re-registers with fresh models. First run with no cache still
27
+ // warns once until the first revalidation persists a cache.
28
+ dedicatedRuntime.registerCached(pi);
17
29
  let lastProxyProviders = configLoader
18
30
  .getConfig()
19
31
  .proxy.upstreamProviders.map((p) => p.id);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@aliou/pi-ts-aperture",
3
3
  "description": "Route Pi LLM providers through Tailscale Aperture",
4
- "version": "0.6.1",
4
+ "version": "0.6.2",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,