@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
|
|
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
|
-
|
|
143
|
+
registerFromBuilt(pi, baseUrl, built);
|
|
77
144
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
models,
|
|
84
|
-
|
|
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);
|