@aliou/pi-ts-aperture 0.2.4 → 0.3.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Route Pi LLM providers through [Tailscale Aperture](https://tailscale.com/docs/features/aperture), a managed AI gateway on your tailnet.
4
4
 
5
- Aperture handles API key injection and request routing server-side. This extension overrides the base URL for selected providers so all LLM requests go through your Aperture instance instead of directly to provider APIs.
5
+ Aperture handles API key injection and request routing server-side. This extension overrides selected providers so requests go through your Aperture gateway instead of directly to upstream provider APIs.
6
6
 
7
7
  ## Setup
8
8
 
@@ -16,9 +16,9 @@ Then run the setup wizard:
16
16
  /aperture:setup
17
17
  ```
18
18
 
19
- This will prompt you for:
20
- 1. Your Aperture base URL (e.g. `ai.your-tailnet.ts.net`)
21
- 2. Which providers to route through Aperture (fuzzy searchable, multi-select)
19
+ This prompts for:
20
+ 1. Aperture base URL (for example `ai.your-tailnet.ts.net`)
21
+ 2. Providers to route through Aperture (fuzzy searchable, multi-select)
22
22
 
23
23
  Configuration is saved globally to `~/.pi/agent/extensions/aperture.json`.
24
24
 
@@ -26,19 +26,23 @@ Configuration is saved globally to `~/.pi/agent/extensions/aperture.json`.
26
26
 
27
27
  | Command | Description |
28
28
  |---|---|
29
- | `/aperture:setup` | Interactive wizard to configure Aperture URL and providers |
30
- | `/aperture:settings` | Settings UI to update base URL and provider list |
29
+ | `/aperture:setup` | Interactive wizard to configure Aperture URL and routed providers |
30
+ | `/aperture:settings` | Settings UI to update URL and routed provider list |
31
31
 
32
32
  ## How it works
33
33
 
34
34
  For each configured provider, the extension calls `registerProvider` with:
35
- - `baseUrl` set to your Aperture URL + `/v1`
36
- - `apiKey` set to `"-"` (Aperture ignores client keys, it injects its own)
37
35
 
38
- This means all requests for those providers are routed through Aperture, which handles authentication, logging, and cost tracking on its end.
36
+ - `baseUrl` set to your Aperture URL + `/v1` (OpenAI-compatible surface used by Pi provider configs)
37
+ - `apiKey` set to `"-"` (Aperture injects upstream credentials server-side)
38
+ - provenance headers:
39
+ - `Referer: https://pi.dev`
40
+ - `X-Title: npm:@aliou/pi-ts-aperture`
41
+
42
+ Additionally, the extension can bootstrap model IDs discovered from Aperture (`/api/providers`) for providers like OpenRouter so CLI model selection can resolve Aperture-exposed model IDs before the first prompt.
39
43
 
40
44
  ## Requirements
41
45
 
42
46
  - A Tailscale tailnet with Aperture configured
43
- - The device running Pi must be on the tailnet
44
- - Use HTTP, not HTTPS, for the Aperture URL (WireGuard handles encryption)
47
+ - The device running Pi must be on the tailnet (or otherwise able to reach your Aperture endpoint)
48
+ - Use the URL/scheme that matches your deployment (`http://` or `https://`)
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.2.4",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
package/src/index.ts CHANGED
@@ -1,105 +1,76 @@
1
1
  /**
2
2
  * Pi extension for Tailscale Aperture integration.
3
3
  *
4
- * Routes selected LLM providers through an Aperture gateway on your tailnet.
5
- * Aperture handles API key injection and request routing, so this extension
6
- * overrides each provider's baseUrl and sets a dummy apiKey.
4
+ * Keeps the entry point focused on orchestration:
5
+ * - load config
6
+ * - bootstrap provider/model visibility
7
+ * - register lifecycle hooks
8
+ * - register user commands
7
9
  */
8
10
 
9
11
  import type {
10
12
  ExtensionAPI,
11
13
  ExtensionContext,
12
14
  } from "@mariozechner/pi-coding-agent";
13
- import { VERSION } from "@mariozechner/pi-coding-agent";
14
15
  import { registerApertureSettings } from "./commands/settings";
15
16
  import { registerSetupCommand } from "./commands/setup";
16
17
  import { configLoader } from "./config";
18
+ import {
19
+ applyAperture,
20
+ bootstrapProvidersFromAperture,
21
+ refreshActiveModel,
22
+ resetApertureModelsCache,
23
+ } from "./providers/aperture";
17
24
 
18
- /**
19
- * Compute the full Aperture base URL from config, or null if not configured.
20
- */
21
- function resolveBaseUrl(): string | null {
22
- const { baseUrl, providers } = configLoader.getConfig();
23
- if (!baseUrl || providers.length === 0) return null;
24
- return `${baseUrl.replace(/\/+$/, "")}/v1`;
25
- }
25
+ function registerApertureLifecycleHook(pi: ExtensionAPI): void {
26
+ pi.on("before_agent_start", async (_event, ctx) => {
27
+ if (!ctx?.modelRegistry) return;
26
28
 
27
- /**
28
- * Override provider registrations to route through Aperture.
29
- * Preserves existing models so extensions that registered custom models
30
- * before this runs don't lose them.
31
- */
32
- function overrideProviders(
33
- pi: ExtensionAPI,
34
- registry: ExtensionContext["modelRegistry"],
35
- providers: string[],
36
- baseUrl: string,
37
- ): void {
38
- for (const provider of providers) {
39
- const models = registry.getAll().filter((m) => m.provider === provider);
29
+ const overriddenProviders = await applyAperture(pi, ctx.modelRegistry);
30
+ if (!ctx.model || !overriddenProviders.includes(ctx.model.provider)) return;
40
31
 
41
- pi.registerProvider(provider, {
42
- baseUrl,
43
- apiKey: "-",
44
- ...(models.length > 0 && { api: models[0].api, models }),
45
- });
46
- }
32
+ await refreshActiveModel(pi, ctx);
33
+ });
47
34
  }
48
35
 
49
- /**
50
- * Apply Aperture configuration to the model registry.
51
- * Returns the list of providers that were overridden, or empty if no-op.
52
- */
53
- function applyAperture(
36
+ function createConfigChangeHandler(
54
37
  pi: ExtensionAPI,
55
- registry: ExtensionContext["modelRegistry"],
56
- ): string[] {
57
- const url = resolveBaseUrl();
58
- if (!url) return [];
59
-
60
- const { providers } = configLoader.getConfig();
61
- overrideProviders(pi, registry, providers, url);
62
- return providers;
63
- }
64
-
65
- export default async function (pi: ExtensionAPI): Promise<void> {
66
- await configLoader.load();
67
-
38
+ ): (ctx: ExtensionContext) => void {
68
39
  let lastRegisteredProviders = [...configLoader.getConfig().providers];
69
40
 
70
- // Apply after all extensions have registered their providers and models.
71
- pi.events.on("before_agent_start", async (data) => {
72
- const ctx = data as ExtensionContext;
73
- if (!ctx?.modelRegistry) return;
74
- applyAperture(pi, ctx.modelRegistry);
75
- });
76
-
77
- const onSetupComplete = (ctx: ExtensionContext) => {
41
+ return (ctx: ExtensionContext) => {
78
42
  const { providers } = configLoader.getConfig();
79
- const removed = lastRegisteredProviders.filter(
80
- (p) => !providers.includes(p),
43
+ const removedProviders = lastRegisteredProviders.filter(
44
+ (provider) => !providers.includes(provider),
81
45
  );
82
46
 
83
- applyAperture(pi, ctx.modelRegistry);
47
+ resetApertureModelsCache();
48
+ void applyAperture(pi, ctx.modelRegistry);
84
49
  lastRegisteredProviders = [...providers];
85
50
 
86
- // Re-resolve active model if it belongs to a reconfigured provider.
87
51
  if (ctx.model && providers.includes(ctx.model.provider)) {
88
- const updated = ctx.modelRegistry.find(ctx.model.provider, ctx.model.id);
89
- if (updated) {
52
+ void refreshActiveModel(pi, ctx).then((updated) => {
53
+ if (!updated) return;
90
54
  ctx.ui.notify(
91
- `[aperture] re-routing ${ctx.model.id} through ${updated.baseUrl}`,
55
+ `[aperture] re-routing ${ctx.model?.id ?? "model"} through ${ctx.model?.baseUrl ?? "aperture"}`,
92
56
  "info",
93
57
  );
94
- pi.setModel(updated);
95
- }
58
+ });
96
59
  }
97
60
 
98
- for (const p of removed) {
99
- pi.unregisterProvider(p);
61
+ for (const provider of removedProviders) {
62
+ pi.unregisterProvider(provider);
100
63
  }
101
64
  };
65
+ }
66
+
67
+ export default async function (pi: ExtensionAPI): Promise<void> {
68
+ await configLoader.load();
69
+ await bootstrapProvidersFromAperture(pi);
70
+
71
+ registerApertureLifecycleHook(pi);
102
72
 
103
- registerSetupCommand(pi, onSetupComplete);
104
- registerApertureSettings(pi, onSetupComplete);
73
+ const onConfigChange = createConfigChangeHandler(pi);
74
+ registerSetupCommand(pi, onConfigChange);
75
+ registerApertureSettings(pi, onConfigChange);
105
76
  }
@@ -0,0 +1,32 @@
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
+ }
@@ -0,0 +1,167 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ ProviderModelConfig,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import { configLoader } from "../config";
7
+ import { fetchApertureProviderModels } from "../lib/aperture-api";
8
+ import {
9
+ clearProviderModelsCache,
10
+ getProviderModelsCache,
11
+ setProviderModelsCache,
12
+ } from "../state/provider-model-cache";
13
+ import { mergeModels, toModelConfig } from "./model-config";
14
+
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
+ };
23
+
24
+ /**
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.
39
+ *
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
81
+ */
82
+ export async function applyAperture(
83
+ pi: ExtensionAPI,
84
+ registry: ExtensionContext["modelRegistry"],
85
+ ): Promise<string[]> {
86
+ const baseUrl = resolveApertureProviderBaseUrl();
87
+ const gatewayUrl = resolveGatewayUrl();
88
+ if (!baseUrl || !gatewayUrl) return [];
89
+
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
+ }
98
+
99
+ for (const provider of providers) {
100
+ const existingModels = registry
101
+ .getAll()
102
+ .filter((m) => m.provider === provider) as ProviderModelConfig[];
103
+
104
+ const models = mergeModels(existingModels, modelCache.get(provider));
105
+
106
+ pi.registerProvider(provider, {
107
+ baseUrl,
108
+ apiKey: "-",
109
+ headers: resolveProviderHeaders(models),
110
+ ...(models.length > 0 && { api: models[0].api, models }),
111
+ });
112
+ }
113
+
114
+ return providers;
115
+ }
116
+
117
+ /**
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.
120
+ */
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
+ }
154
+ }
155
+
156
+ /** Re-resolve and set current model after provider registry updates. */
157
+ export async function refreshActiveModel(
158
+ pi: ExtensionAPI,
159
+ ctx: ExtensionContext,
160
+ ): Promise<boolean> {
161
+ if (!ctx.model) return false;
162
+
163
+ const updated = ctx.modelRegistry.find(ctx.model.provider, ctx.model.id);
164
+ if (!updated) return false;
165
+
166
+ return pi.setModel(updated);
167
+ }
@@ -0,0 +1,54 @@
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
+ }
@@ -0,0 +1,18 @@
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
+ }