@aliou/pi-ts-aperture 0.3.1 → 0.4.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.
package/src/index.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  *
4
4
  * Keeps the entry point focused on orchestration:
5
5
  * - load config
6
- * - bootstrap provider/model visibility
7
6
  * - register lifecycle hooks
8
7
  * - register user commands
9
8
  */
@@ -15,40 +14,114 @@ import type {
15
14
  import { registerApertureSettings } from "./commands/settings";
16
15
  import { registerSetupCommand } from "./commands/setup";
17
16
  import { configLoader } from "./config";
17
+ import { planConfigChange, resolveProviderBaseUrl } from "./core";
18
18
  import {
19
19
  applyAperture,
20
- bootstrapProvidersFromAperture,
20
+ checkGatewayModels,
21
21
  refreshActiveModel,
22
- resetApertureModelsCache,
23
22
  } from "./providers/aperture";
24
23
 
25
- function registerApertureLifecycleHook(pi: ExtensionAPI): void {
24
+ function notifyMissingModelsOnce(
25
+ ctx: ExtensionContext,
26
+ missingModels: string[],
27
+ warnedModels: Set<string>,
28
+ ): void {
29
+ const newMissing = missingModels.filter((id) => !warnedModels.has(id));
30
+ if (newMissing.length > 0) {
31
+ for (const id of newMissing) warnedModels.add(id);
32
+ ctx.ui.notify(
33
+ `[aperture] models not available on gateway: ${newMissing.join(", ")}. Add them to the gateway configuration.`,
34
+ "warning",
35
+ );
36
+ }
37
+ }
38
+
39
+ function registerApertureLifecycleHook(
40
+ pi: ExtensionAPI,
41
+ warnedModels: Set<string>,
42
+ ): void {
26
43
  pi.on("before_agent_start", async (_event, ctx) => {
27
44
  if (!ctx?.modelRegistry) return;
28
45
 
29
- const overriddenProviders = await applyAperture(pi, ctx.modelRegistry);
46
+ const { providers: overriddenProviders, gatewayUrl } = await applyAperture(
47
+ pi,
48
+ ctx.modelRegistry,
49
+ );
50
+
51
+ if (
52
+ ctx.model &&
53
+ overriddenProviders.includes(ctx.model.provider) &&
54
+ gatewayUrl !== null &&
55
+ configLoader.getConfig().checkGatewayModels.includes(ctx.model.provider)
56
+ ) {
57
+ const { missingModels } = await checkGatewayModels(
58
+ gatewayUrl,
59
+ ctx.modelRegistry,
60
+ );
61
+ notifyMissingModelsOnce(ctx, missingModels, warnedModels);
62
+ }
63
+
30
64
  if (!ctx.model || !overriddenProviders.includes(ctx.model.provider)) return;
31
65
 
32
66
  await refreshActiveModel(pi, ctx);
33
67
  });
68
+
69
+ // Also check when user switches to a model whose provider uses aperture
70
+ pi.on("model_select", async (_event, ctx) => {
71
+ if (!ctx?.model) return;
72
+
73
+ const config = configLoader.getConfig();
74
+ if (!config.providers.includes(ctx.model.provider)) return;
75
+
76
+ const gatewayUrl = resolveProviderBaseUrl(config)?.replace("/v1", "");
77
+ if (!gatewayUrl) return;
78
+
79
+ if (config.checkGatewayModels.includes(ctx.model.provider)) {
80
+ const { missingModels } = await checkGatewayModels(
81
+ gatewayUrl,
82
+ ctx.modelRegistry,
83
+ );
84
+ notifyMissingModelsOnce(ctx, missingModels, warnedModels);
85
+ }
86
+ });
34
87
  }
35
88
 
36
89
  function createConfigChangeHandler(
37
90
  pi: ExtensionAPI,
91
+ warnedModels: Set<string>,
38
92
  ): (ctx: ExtensionContext) => void {
39
93
  let lastRegisteredProviders = [...configLoader.getConfig().providers];
40
94
 
41
95
  return (ctx: ExtensionContext) => {
42
96
  const { providers } = configLoader.getConfig();
43
- const removedProviders = lastRegisteredProviders.filter(
44
- (provider) => !providers.includes(provider),
97
+
98
+ const plan = planConfigChange(
99
+ lastRegisteredProviders,
100
+ providers,
101
+ ctx.model?.provider,
45
102
  );
46
103
 
47
- resetApertureModelsCache();
48
- void applyAperture(pi, ctx.modelRegistry);
104
+ void applyAperture(pi, ctx.modelRegistry).then(
105
+ async ({ providers, gatewayUrl }) => {
106
+ if (
107
+ ctx.model &&
108
+ providers.includes(ctx.model.provider) &&
109
+ gatewayUrl !== null &&
110
+ configLoader
111
+ .getConfig()
112
+ .checkGatewayModels.includes(ctx.model.provider)
113
+ ) {
114
+ const { missingModels } = await checkGatewayModels(
115
+ gatewayUrl,
116
+ ctx.modelRegistry,
117
+ );
118
+ notifyMissingModelsOnce(ctx, missingModels, warnedModels);
119
+ }
120
+ },
121
+ );
49
122
  lastRegisteredProviders = [...providers];
50
123
 
51
- if (ctx.model && providers.includes(ctx.model.provider)) {
124
+ if (plan.shouldRefreshModel) {
52
125
  void refreshActiveModel(pi, ctx).then((updated) => {
53
126
  if (!updated) return;
54
127
  ctx.ui.notify(
@@ -58,7 +131,7 @@ function createConfigChangeHandler(
58
131
  });
59
132
  }
60
133
 
61
- for (const provider of removedProviders) {
134
+ for (const provider of plan.removedProviders) {
62
135
  pi.unregisterProvider(provider);
63
136
  }
64
137
  };
@@ -66,11 +139,12 @@ function createConfigChangeHandler(
66
139
 
67
140
  export default async function (pi: ExtensionAPI): Promise<void> {
68
141
  await configLoader.load();
69
- await bootstrapProvidersFromAperture(pi);
70
142
 
71
- registerApertureLifecycleHook(pi);
143
+ const warnedModels = new Set<string>();
144
+
145
+ registerApertureLifecycleHook(pi, warnedModels);
72
146
 
73
- const onConfigChange = createConfigChangeHandler(pi);
147
+ const onConfigChange = createConfigChangeHandler(pi, warnedModels);
74
148
  registerSetupCommand(pi, onConfigChange);
75
149
  registerApertureSettings(pi, onConfigChange);
76
150
  }
package/src/lib/health.ts CHANGED
@@ -28,3 +28,18 @@ export async function checkApertureHealth(
28
28
  return { ok: false, error: msg };
29
29
  }
30
30
  }
31
+
32
+ export async function fetchGatewayModelIds(baseUrl: string): Promise<string[]> {
33
+ const url = `${baseUrl.replace(/\/+$/, "")}/v1/models`;
34
+ try {
35
+ const res = await fetch(url, {
36
+ method: "GET",
37
+ signal: AbortSignal.timeout(5000),
38
+ });
39
+ if (!res.ok) return [];
40
+ const body = (await res.json()) as { data?: { id: string }[] };
41
+ return body.data?.map((m) => m.id) ?? [];
42
+ } catch {
43
+ return [];
44
+ }
45
+ }
@@ -1,156 +1,68 @@
1
1
  import type {
2
2
  ExtensionAPI,
3
3
  ExtensionContext,
4
- ProviderModelConfig,
5
4
  } from "@mariozechner/pi-coding-agent";
6
5
  import { configLoader } from "../config";
7
- import { fetchApertureProviderModels } from "../lib/aperture-api";
8
6
  import {
9
- clearProviderModelsCache,
10
- getProviderModelsCache,
11
- setProviderModelsCache,
12
- } from "../state/provider-model-cache";
13
- import { mergeModels, toModelConfig } from "./model-config";
7
+ buildApplyPlan,
8
+ resolveGatewayUrl,
9
+ resolveProviderBaseUrl,
10
+ } from "../core";
11
+ import { fetchGatewayModelIds } from "../lib/health";
14
12
 
15
- /**
16
- * Preserve provenance similarly to pi-synthetic so downstream providers can
17
- * attribute traffic to Pi / this extension.
18
- */
19
- const APERTURE_PROVENANCE_HEADERS = {
20
- Referer: "https://pi.dev",
21
- "X-Title": "npm:@aliou/pi-ts-aperture",
22
- };
13
+ export { resolveGatewayUrl } from "../core";
23
14
 
24
15
  /**
25
- * Providers for which we bootstrap models at startup (before first turn)
26
- * to make CLI model selection deterministic.
27
- */
28
- const BOOTSTRAP_DISCOVERY_PROVIDERS = new Set(["openrouter"]);
29
-
30
- /** Returns configured gateway URL without trailing slash. */
31
- export function resolveGatewayUrl(): string | null {
32
- const { baseUrl, providers } = configLoader.getConfig();
33
- if (!baseUrl || providers.length === 0) return null;
34
- return baseUrl.replace(/\/+$/, "");
35
- }
36
-
37
- /**
38
- * Returns the Aperture provider base URL used for provider registration.
16
+ * Apply Aperture override to configured providers.
39
17
  *
40
- * Aperture exposes multiple protocol paths (OpenAI, Anthropic, Gemini, ...).
41
- * For this extension we route through the OpenAI-compatible `/v1` surface that
42
- * Pi providers use (`openai-completions` API).
43
- */
44
- export function resolveApertureProviderBaseUrl(): string | null {
45
- const gateway = resolveGatewayUrl();
46
- if (!gateway) return null;
47
- return `${gateway}/v1`;
48
- }
49
-
50
- function resolveProviderHeaders(
51
- models: ProviderModelConfig[],
52
- ): Record<string, string> {
53
- const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
54
- return {
55
- ...APERTURE_PROVENANCE_HEADERS,
56
- ...modelHeaders,
57
- };
58
- }
59
-
60
- async function getOrLoadProviderModelsCache(
61
- gatewayUrl: string,
62
- providers: string[],
63
- ): Promise<Map<string, string[]>> {
64
- const current = getProviderModelsCache();
65
- if (current) return current;
66
-
67
- const loaded = await fetchApertureProviderModels(gatewayUrl, providers);
68
- setProviderModelsCache(loaded);
69
- return loaded;
70
- }
71
-
72
- export function resetApertureModelsCache(): void {
73
- clearProviderModelsCache();
74
- }
75
-
76
- /**
77
- * Apply Aperture override to configured providers:
78
- * - provider baseUrl -> aperture /v1 endpoint
79
- * - apiKey -> dummy token (Aperture injects real key server-side)
80
- * - headers -> provenance + provider/model headers
18
+ * Only patches baseUrl, apiKey, and headers. Models are left exactly as
19
+ * registered by Pi built-ins or other extensions -- Aperture never touches
20
+ * model definitions.
21
+ *
22
+ * Providers with no models in the registry are skipped (nothing to reroute).
81
23
  */
82
24
  export async function applyAperture(
83
25
  pi: ExtensionAPI,
84
26
  registry: ExtensionContext["modelRegistry"],
85
- ): Promise<string[]> {
86
- const baseUrl = resolveApertureProviderBaseUrl();
87
- const gatewayUrl = resolveGatewayUrl();
88
- if (!baseUrl || !gatewayUrl) return [];
27
+ ): Promise<{ providers: string[]; gatewayUrl: string | null }> {
28
+ const config = configLoader.getConfig();
29
+ const baseUrl = resolveProviderBaseUrl(config);
30
+ if (!baseUrl) return { providers: [], gatewayUrl: null };
89
31
 
90
- const { providers } = configLoader.getConfig();
91
-
92
- let modelCache: Map<string, string[]>;
93
- try {
94
- modelCache = await getOrLoadProviderModelsCache(gatewayUrl, providers);
95
- } catch {
96
- modelCache = new Map();
97
- }
32
+ const gatewayUrl = resolveGatewayUrl(config);
98
33
 
99
- for (const provider of providers) {
100
- const existingModels = registry
101
- .getAll()
102
- .filter((m) => m.provider === provider) as ProviderModelConfig[];
34
+ const registryModels = registry.getAll();
103
35
 
104
- const models = mergeModels(existingModels, modelCache.get(provider));
36
+ const plan = buildApplyPlan(config, registryModels, baseUrl, []);
105
37
 
106
- pi.registerProvider(provider, {
107
- baseUrl,
108
- apiKey: "-",
109
- headers: resolveProviderHeaders(models),
110
- ...(models.length > 0 && { api: models[0].api, models }),
38
+ for (const reg of plan.registrations) {
39
+ pi.registerProvider(reg.provider, {
40
+ baseUrl: reg.baseUrl,
41
+ apiKey: reg.apiKey,
42
+ headers: reg.headers,
43
+ api: reg.api,
44
+ models: reg.models,
111
45
  });
112
46
  }
113
47
 
114
- return providers;
48
+ return { providers: config.providers, gatewayUrl };
115
49
  }
116
50
 
117
51
  /**
118
- * Pre-register selected providers from Aperture model discovery so CLI model
119
- * resolution works even when a model is not present in Pi built-ins.
52
+ * Fetch gateway models and return missing ones relative to the plan.
120
53
  */
121
- export async function bootstrapProvidersFromAperture(
122
- pi: ExtensionAPI,
123
- ): Promise<void> {
124
- const baseUrl = resolveApertureProviderBaseUrl();
125
- const gatewayUrl = resolveGatewayUrl();
126
- if (!baseUrl || !gatewayUrl) return;
127
-
128
- const { providers } = configLoader.getConfig();
129
-
130
- let modelCache: Map<string, string[]>;
131
- try {
132
- modelCache = await fetchApertureProviderModels(gatewayUrl, providers);
133
- setProviderModelsCache(modelCache);
134
- } catch {
135
- return;
136
- }
137
-
138
- for (const provider of providers) {
139
- if (!BOOTSTRAP_DISCOVERY_PROVIDERS.has(provider)) continue;
140
-
141
- const modelIds = modelCache.get(provider) ?? [];
142
- if (modelIds.length === 0) continue;
143
-
144
- const models = modelIds.map((id) => toModelConfig(id));
145
-
146
- pi.registerProvider(provider, {
147
- baseUrl,
148
- apiKey: "-",
149
- api: "openai-completions",
150
- headers: resolveProviderHeaders(models),
151
- models,
152
- });
153
- }
54
+ export async function checkGatewayModels(
55
+ gatewayUrl: string,
56
+ registry: ExtensionContext["modelRegistry"],
57
+ ): Promise<{ missingModels: string[] }> {
58
+ const config = configLoader.getConfig();
59
+ const baseUrl = resolveProviderBaseUrl(config);
60
+ if (!baseUrl) return { missingModels: [] };
61
+
62
+ const gatewayModelIds = await fetchGatewayModelIds(gatewayUrl);
63
+ const registryModels = registry.getAll();
64
+ const plan = buildApplyPlan(config, registryModels, baseUrl, gatewayModelIds);
65
+ return { missingModels: plan.missingModels };
154
66
  }
155
67
 
156
68
  /** Re-resolve and set current model after provider registry updates. */
@@ -1,32 +0,0 @@
1
- export interface ApertureProviderInfo {
2
- id?: string;
3
- models?: string[];
4
- }
5
-
6
- /**
7
- * Fetch provider -> model list mapping from Aperture and keep only selected
8
- * providers configured by the user.
9
- */
10
- export async function fetchApertureProviderModels(
11
- gatewayUrl: string,
12
- providers: string[],
13
- ): Promise<Map<string, string[]>> {
14
- const response = await fetch(`${gatewayUrl}/api/providers`, {
15
- signal: AbortSignal.timeout(4000),
16
- });
17
-
18
- if (!response.ok) {
19
- return new Map();
20
- }
21
-
22
- const data = (await response.json()) as ApertureProviderInfo[];
23
- const selectedProviders = new Set(providers);
24
- const modelsByProvider = new Map<string, string[]>();
25
-
26
- for (const provider of data) {
27
- if (!provider.id || !selectedProviders.has(provider.id)) continue;
28
- modelsByProvider.set(provider.id, provider.models ?? []);
29
- }
30
-
31
- return modelsByProvider;
32
- }
@@ -1,54 +0,0 @@
1
- import type { ProviderModelConfig } from "@mariozechner/pi-coding-agent";
2
-
3
- const DEFAULT_COST = {
4
- input: 0,
5
- output: 0,
6
- cacheRead: 0,
7
- cacheWrite: 0,
8
- };
9
-
10
- /**
11
- * Build a ProviderModelConfig for Aperture-discovered model IDs.
12
- *
13
- * When a template model is available (same provider), preserve its API/compat
14
- * shape so behavior stays consistent after rerouting.
15
- */
16
- export function toModelConfig(
17
- id: string,
18
- template?: ProviderModelConfig,
19
- ): ProviderModelConfig {
20
- return {
21
- id,
22
- name: template?.name ?? id,
23
- api: template?.api ?? "openai-completions",
24
- reasoning: template?.reasoning ?? false,
25
- input: template?.input ?? ["text"],
26
- cost: template?.cost ?? DEFAULT_COST,
27
- contextWindow: template?.contextWindow ?? 128000,
28
- maxTokens: template?.maxTokens ?? 16384,
29
- headers: template?.headers,
30
- compat: template?.compat,
31
- };
32
- }
33
-
34
- /**
35
- * Merge known provider models with Aperture-discovered model IDs.
36
- * Existing models win; missing IDs are synthesized from template defaults.
37
- */
38
- export function mergeModels(
39
- existingModels: ProviderModelConfig[],
40
- apertureModelIds: string[] | undefined,
41
- ): ProviderModelConfig[] {
42
- if (!apertureModelIds || apertureModelIds.length === 0) return existingModels;
43
-
44
- const modelsById = new Map(existingModels.map((m) => [m.id, m]));
45
- const template = existingModels[0];
46
-
47
- for (const modelId of apertureModelIds) {
48
- if (!modelsById.has(modelId)) {
49
- modelsById.set(modelId, toModelConfig(modelId, template));
50
- }
51
- }
52
-
53
- return [...modelsById.values()];
54
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * In-memory cache for Aperture provider model discovery.
3
- *
4
- * Ephemeral by design: reset on config changes and process restart.
5
- */
6
- let providerModelsCache: Map<string, string[]> | null = null;
7
-
8
- export function getProviderModelsCache(): Map<string, string[]> | null {
9
- return providerModelsCache;
10
- }
11
-
12
- export function setProviderModelsCache(models: Map<string, string[]>): void {
13
- providerModelsCache = models;
14
- }
15
-
16
- export function clearProviderModelsCache(): void {
17
- providerModelsCache = null;
18
- }