@aliou/pi-ts-aperture 0.3.2 → 0.5.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
@@ -1,3 +1,5 @@
1
+ ![banner](https://assets.aliou.me/pi-extensions/banners/pi-ts-aperture.png)
2
+
1
3
  # pi-ts-aperture
2
4
 
3
5
  Route Pi LLM providers through [Tailscale Aperture](https://tailscale.com/docs/features/aperture), a managed AI gateway on your tailnet.
@@ -45,4 +47,4 @@ Additionally, the extension can bootstrap model IDs discovered from Aperture (`/
45
47
 
46
48
  - A Tailscale tailnet with Aperture configured
47
49
  - 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://`)
50
+ - 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.3.2",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -29,12 +29,12 @@
29
29
  "video": "https://assets.aliou.me/pi-extensions/demos/pi-ts-aperture.mp4"
30
30
  },
31
31
  "dependencies": {
32
- "@aliou/pi-utils-settings": "^0.10.0"
32
+ "@aliou/pi-utils-settings": "^0.12.0"
33
33
  },
34
34
  "peerDependencies": {
35
- "@mariozechner/pi-ai": "0.61.0",
36
- "@mariozechner/pi-coding-agent": "0.61.0",
37
- "@mariozechner/pi-tui": "0.61.0"
35
+ "@mariozechner/pi-ai": "0.64.0",
36
+ "@mariozechner/pi-coding-agent": "0.64.0",
37
+ "@mariozechner/pi-tui": "0.64.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@mariozechner/pi-coding-agent": {
@@ -51,7 +51,7 @@
51
51
  "@aliou/biome-plugins": "^0.3.2",
52
52
  "@biomejs/biome": "^2.3.13",
53
53
  "@changesets/cli": "^2.27.11",
54
- "@mariozechner/pi-coding-agent": "0.61.0",
54
+ "@mariozechner/pi-coding-agent": "0.64.0",
55
55
  "@sinclair/typebox": "^0.34.48",
56
56
  "@types/node": "^25.0.10",
57
57
  "@vitest/coverage-v8": "^4.0.18",
@@ -67,7 +67,8 @@
67
67
  "changeset": "changeset",
68
68
  "version": "changeset version",
69
69
  "release": "pnpm changeset publish",
70
- "test": "vitest run tests/e2e.test.ts",
71
- "test:watch": "vitest watch tests/"
70
+ "test": "vitest run",
71
+ "test:watch": "vitest",
72
+ "test:e2e": "vitest run tests/e2e.test.ts"
72
73
  }
73
74
  }
@@ -17,12 +17,12 @@ import type {
17
17
  ExtensionContext,
18
18
  } from "@mariozechner/pi-coding-agent";
19
19
  import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
20
- import type { ApertureConfig, ResolvedConfig } from "../config";
21
- import { configLoader } from "../config";
20
+ import type { ApertureConfig, ResolvedConfig } from "../lib/config";
21
+ import { configLoader } from "../lib/config";
22
22
 
23
23
  export function registerApertureSettings(
24
24
  pi: ExtensionAPI,
25
- onConfigChange: (ctx: ExtensionContext) => void,
25
+ onSync: (ctx: ExtensionContext) => void,
26
26
  ): void {
27
27
  registerSettingsCommand<ApertureConfig, ResolvedConfig>(pi, {
28
28
  commandName: "aperture:settings",
@@ -37,6 +37,9 @@ export function registerApertureSettings(
37
37
 
38
38
  const providers = tabConfig?.providers ?? resolved.providers;
39
39
 
40
+ const checkGatewayModels: string[] =
41
+ tabConfig?.checkGatewayModels ?? resolved.checkGatewayModels;
42
+
40
43
  return [
41
44
  {
42
45
  label: "Connection",
@@ -51,6 +54,39 @@ export function registerApertureSettings(
51
54
  values: undefined,
52
55
  submenu: undefined,
53
56
  },
57
+ {
58
+ id: "checkGatewayModels",
59
+ label: "Gateway model checking",
60
+ description:
61
+ "Providers for which gateway model availability is checked",
62
+ currentValue:
63
+ checkGatewayModels.length > 0
64
+ ? `${checkGatewayModels.length} provider(s)`
65
+ : "disabled",
66
+ values: undefined,
67
+ submenu: (_val, submenuDone) => {
68
+ let latest = [...checkGatewayModels];
69
+ return new ArrayEditor({
70
+ label: "Gateway-checked providers",
71
+ items: [...checkGatewayModels],
72
+ theme: settingsTheme,
73
+ onSave: (items) => {
74
+ latest = items;
75
+ const updated = structuredClone(
76
+ tabConfig ?? {},
77
+ ) as ApertureConfig;
78
+ setNestedValue(updated, "checkGatewayModels", items);
79
+ setDraft(updated);
80
+ },
81
+ onDone: () =>
82
+ submenuDone(
83
+ latest.length > 0
84
+ ? `${latest.length} provider(s)`
85
+ : "disabled",
86
+ ),
87
+ });
88
+ },
89
+ },
54
90
  ],
55
91
  },
56
92
  {
@@ -93,7 +129,7 @@ export function registerApertureSettings(
93
129
  return updated;
94
130
  },
95
131
  onSave: (ctx) => {
96
- onConfigChange(ctx);
132
+ onSync(ctx);
97
133
  },
98
134
  });
99
135
  }
@@ -2,256 +2,138 @@
2
2
  * aperture:setup -- interactive wizard for configuring Aperture.
3
3
  *
4
4
  * Steps:
5
- * 1. Ask for Aperture base URL (Input)
6
- * 2. Select providers to route through Aperture (FuzzySelector, multi-select loop)
7
- * 3. Save config and register providers
5
+ * 1. URL input (health check runs inline on Enter, auto-advances on success)
6
+ * 2. Provider selection with per-provider "verify models" sub-option
8
7
  */
9
8
 
10
- import { FuzzySelector } from "@aliou/pi-utils-settings";
9
+ import {
10
+ FuzzyMultiSelector,
11
+ type FuzzyMultiSelectorItem,
12
+ getSettingsTheme,
13
+ type SettingsTheme,
14
+ Wizard,
15
+ type WizardStepContext,
16
+ } from "@aliou/pi-utils-settings";
11
17
  import type {
12
18
  ExtensionAPI,
13
19
  ExtensionContext,
14
20
  } from "@mariozechner/pi-coding-agent";
15
- import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
16
21
  import type { Component, TUI } from "@mariozechner/pi-tui";
17
- import { Input, Key, matchesKey } from "@mariozechner/pi-tui";
18
- import { configLoader } from "../config";
19
- import { checkApertureHealth } from "../lib/health";
20
-
21
- function normalizeUrl(url: string): string {
22
- let result = url.trim();
23
- if (!result) return result;
24
- if (!result.startsWith("http://") && !result.startsWith("https://")) {
25
- result = `http://${result}`;
26
- }
27
- return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
28
- }
22
+ import { Input } from "@mariozechner/pi-tui";
23
+ import { configLoader } from "../lib/config";
24
+ import { checkApertureHealth } from "../lib/gateway";
25
+ import { normalizeInputUrl } from "../lib/url";
29
26
 
30
27
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
31
28
 
32
- /**
33
- * Shows a spinner while verifying the Aperture URL is reachable.
34
- * Kicks off the health check on construction and resolves done(boolean)
35
- * when the check completes.
36
- */
37
- class HealthCheckSpinner implements Component {
38
- private theme: ReturnType<typeof getSettingsListTheme>;
39
- private url: string;
29
+ // ---------------------------------------------------------------------------
30
+ // Step 1: URL input with inline health check
31
+ // ---------------------------------------------------------------------------
32
+
33
+ class UrlStep implements Component {
34
+ private input: Input;
35
+ private theme: SettingsTheme;
40
36
  private tui: TUI;
41
- // true = healthy, false = failed (retry), undefined = cancelled
42
- private done: (result: boolean | undefined) => void;
37
+ private wizCtx: WizardStepContext;
38
+ private onUrl: (url: string) => void;
39
+ private readonly placeholder = "ai.pango-lin.ts.net";
40
+
41
+ private state: "idle" | "checking" | "ok" | "error" = "idle";
42
+ private errorMessage = "";
43
43
  private frame = 0;
44
- private timer: ReturnType<typeof setInterval>;
45
- private result: { ok: boolean; error?: string } | null = null;
44
+ private timer: ReturnType<typeof setInterval> | null = null;
46
45
 
47
46
  constructor(
48
- theme: ReturnType<typeof getSettingsListTheme>,
49
- url: string,
47
+ theme: SettingsTheme,
50
48
  tui: TUI,
51
- done: (result: boolean | undefined) => void,
49
+ currentValue: string,
50
+ wizCtx: WizardStepContext,
51
+ onUrl: (url: string) => void,
52
52
  ) {
53
53
  this.theme = theme;
54
- this.url = url;
55
54
  this.tui = tui;
56
- this.done = done;
55
+ this.wizCtx = wizCtx;
56
+ this.onUrl = onUrl;
57
+ this.input = new Input();
58
+ if (currentValue) {
59
+ this.input.setValue(currentValue);
60
+ }
61
+ this.input.onSubmit = () => this.submit();
62
+ }
63
+
64
+ private submit(): void {
65
+ const value = this.input.getValue().trim();
66
+ if (!value || this.state === "checking") return;
67
+
68
+ const url = normalizeInputUrl(value);
69
+ this.state = "checking";
70
+ this.frame = 0;
57
71
 
58
- // Animate spinner at ~80ms per frame.
59
72
  this.timer = setInterval(() => {
60
73
  this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
61
74
  this.tui.requestRender();
62
75
  }, 80);
63
76
 
64
- // Fire the check.
65
77
  checkApertureHealth(url).then((res) => {
66
- clearInterval(this.timer);
67
- this.result = res;
68
- this.tui.requestRender();
78
+ if (this.timer) clearInterval(this.timer);
79
+ this.timer = null;
69
80
 
70
- // On success, auto-advance after a brief pause.
71
- // On failure, wait for user input (see handleInput).
72
81
  if (res.ok) {
73
- setTimeout(() => this.done(true), 600);
82
+ this.state = "ok";
83
+ this.onUrl(url);
84
+ this.wizCtx.markComplete();
85
+ this.tui.requestRender();
86
+ setTimeout(() => this.wizCtx.goNext(), 400);
87
+ } else {
88
+ this.state = "error";
89
+ this.errorMessage = res.error ?? "unknown error";
90
+ this.tui.requestRender();
74
91
  }
75
92
  });
76
93
  }
77
94
 
78
- render(_width: number): string[] {
79
- const lines: string[] = [];
80
- lines.push(this.theme.label(" Aperture Setup", true));
81
- lines.push("");
82
-
83
- if (!this.result) {
84
- const spinner = SPINNER_FRAMES[this.frame];
85
- lines.push(
86
- this.theme.hint(` ${spinner} Checking connection to ${this.url}...`),
87
- );
88
- } else if (this.result.ok) {
89
- lines.push(this.theme.hint(` Connected to ${this.url}`));
90
- } else {
91
- lines.push(
92
- this.theme.hint(` Could not reach ${this.url}: ${this.result.error}`),
93
- );
94
- lines.push("");
95
- lines.push(
96
- this.theme.hint(
97
- " Make sure the URL is correct and you are connected to the tailnet.",
98
- ),
99
- );
100
- lines.push("");
101
- lines.push(this.theme.hint(" Enter: try another URL · Esc: cancel"));
102
- }
103
-
104
- lines.push("");
105
- return lines;
106
- }
107
-
108
- invalidate() {}
109
-
110
- handleInput(data: string) {
111
- // Only handle input after a failed check.
112
- if (!this.result || this.result.ok) return;
113
-
114
- if (matchesKey(data, Key.enter)) {
115
- this.done(false); // retry
116
- } else if (matchesKey(data, Key.escape)) {
117
- this.done(undefined); // cancel wizard
118
- }
119
- }
120
-
121
- dispose() {
122
- clearInterval(this.timer);
123
- }
124
- }
125
-
126
- /**
127
- * Simple input prompt component for the base URL step.
128
- */
129
- class UrlPrompt implements Component {
130
- private input: Input;
131
- private done: (value: string | undefined) => void;
132
- private theme: ReturnType<typeof getSettingsListTheme>;
133
- private placeholder = "ai.pango-lin.ts.net";
134
-
135
- constructor(
136
- theme: ReturnType<typeof getSettingsListTheme>,
137
- currentValue: string,
138
- done: (value: string | undefined) => void,
139
- ) {
140
- this.theme = theme;
141
- this.done = done;
142
- this.input = new Input();
143
- if (currentValue) {
144
- this.input.setValue(currentValue);
145
- }
146
-
147
- this.input.onSubmit = () => {
148
- const value = this.input.getValue().trim();
149
- if (!value) return;
150
- this.done(normalizeUrl(value));
151
- };
152
- this.input.onEscape = () => {
153
- this.done(undefined);
154
- };
155
- }
156
-
157
95
  render(width: number): string[] {
158
96
  const lines: string[] = [];
159
- lines.push(this.theme.label(" Aperture Setup", true));
160
- lines.push("");
97
+
161
98
  lines.push(
162
99
  this.theme.hint(` Aperture base URL (e.g. ${this.placeholder}):`),
163
100
  );
164
101
  lines.push(` ${this.input.render(width - 4).join("")}`);
165
102
  lines.push("");
166
- lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
167
- return lines;
168
- }
169
-
170
- invalidate() {}
171
-
172
- handleInput(data: string) {
173
- this.input.handleInput(data);
174
- }
175
- }
176
-
177
- /**
178
- * Provider multi-select component. Uses FuzzySelector for search,
179
- * tracks selected providers, and lets the user confirm with Ctrl+S.
180
- */
181
- class ProviderMultiSelect implements Component {
182
- private allProviders: string[];
183
- private selected: Set<string>;
184
- private theme: ReturnType<typeof getSettingsListTheme>;
185
- private done: (value: string[] | undefined) => void;
186
- private fuzzy: FuzzySelector;
187
103
 
188
- constructor(
189
- theme: ReturnType<typeof getSettingsListTheme>,
190
- providers: string[],
191
- preselected: string[],
192
- done: (value: string[] | undefined) => void,
193
- ) {
194
- this.allProviders = providers;
195
- this.selected = new Set(preselected);
196
- this.theme = theme;
197
- this.done = done;
198
-
199
- this.fuzzy = new FuzzySelector({
200
- label: "Select providers (Enter to toggle, Ctrl+S to confirm)",
201
- items: this.allProviders,
202
- theme,
203
- onSelect: (value) => {
204
- if (this.selected.has(value)) {
205
- this.selected.delete(value);
206
- } else {
207
- this.selected.add(value);
208
- }
209
- },
210
- onDone: () => {
211
- this.done(undefined);
212
- },
213
- });
214
- }
215
-
216
- render(width: number): string[] {
217
- const lines = this.fuzzy.render(width);
218
-
219
- // Append selected providers summary
220
- if (this.selected.size > 0) {
221
- lines.push("");
222
- lines.push(this.theme.hint(` Selected (${this.selected.size}):`));
223
- for (const p of this.selected) {
224
- lines.push(` ${this.theme.value(p, false)}`);
225
- }
226
- }
227
-
228
- // Replace the hint line from FuzzySelector
229
- const hintIndex = lines.findIndex((l) => l.includes("Type to search"));
230
- if (hintIndex !== -1) {
231
- lines[hintIndex] = this.theme.hint(
232
- " Type to search · Enter: toggle · Ctrl+S: confirm · Esc: cancel",
233
- );
104
+ if (this.state === "checking") {
105
+ const spinner = SPINNER_FRAMES[this.frame];
106
+ lines.push(this.theme.hint(` ${spinner} Checking connection...`));
107
+ } else if (this.state === "ok") {
108
+ lines.push(this.theme.hint(" Connected."));
109
+ } else if (this.state === "error") {
110
+ lines.push(this.theme.hint(` Could not connect: ${this.errorMessage}`));
111
+ lines.push(this.theme.hint(" Fix the URL and press Enter to retry."));
234
112
  }
235
113
 
236
114
  return lines;
237
115
  }
238
116
 
239
- invalidate() {
240
- this.fuzzy.invalidate();
117
+ invalidate(): void {}
118
+
119
+ handleInput(data: string): void {
120
+ if (this.state === "checking") return;
121
+ this.state = "idle";
122
+ this.input.handleInput(data);
241
123
  }
242
124
 
243
- handleInput(data: string) {
244
- if (matchesKey(data, Key.ctrl("s"))) {
245
- this.done([...this.selected]);
246
- return;
247
- }
248
- this.fuzzy.handleInput(data);
125
+ dispose(): void {
126
+ if (this.timer) clearInterval(this.timer);
249
127
  }
250
128
  }
251
129
 
130
+ // ---------------------------------------------------------------------------
131
+ // Command registration
132
+ // ---------------------------------------------------------------------------
133
+
252
134
  export function registerSetupCommand(
253
135
  pi: ExtensionAPI,
254
- onConfigChange: (ctx: ExtensionContext) => void,
136
+ onSync: (ctx: ExtensionContext) => void,
255
137
  ): void {
256
138
  pi.registerCommand("aperture:setup", {
257
139
  description: "Configure Tailscale Aperture integration",
@@ -265,57 +147,82 @@ export function registerSetupCommand(
265
147
  }
266
148
 
267
149
  const config = configLoader.getConfig();
268
- const settingsTheme = getSettingsListTheme();
150
+ const checkGatewayProviders = config.checkGatewayModels ?? [];
269
151
 
270
- // Step 1: base URL + health check loop.
271
- // On failure, loop back to the URL prompt so the user can retry.
272
- let baseUrl: string | undefined;
273
- while (true) {
274
- baseUrl = await ctx.ui.custom<string | undefined>(
275
- (_tui, _theme, _kb, done) => {
276
- return new UrlPrompt(
277
- settingsTheme,
278
- baseUrl ?? config.baseUrl,
279
- done,
280
- );
281
- },
282
- );
283
-
284
- if (!baseUrl) return;
285
- const urlToCheck = baseUrl;
286
-
287
- const result = await ctx.ui.custom<boolean | undefined>(
288
- (tui, _theme, _kb, done) => {
289
- return new HealthCheckSpinner(settingsTheme, urlToCheck, tui, done);
290
- },
291
- );
292
-
293
- if (result === true) break; // healthy, proceed
294
- if (result === undefined) return; // cancelled
295
- }
296
-
297
- // Step 2: select providers
298
- // Use model registry so custom/extension providers are included.
299
152
  const knownProviders = Array.from(
300
153
  new Set(ctx.modelRegistry.getAll().map((model) => model.provider)),
301
154
  ).sort((a, b) => a.localeCompare(b));
302
155
 
303
- const providers = await ctx.ui.custom<string[] | undefined>(
304
- (_tui, _theme, _kb, done) => {
305
- return new ProviderMultiSelect(
306
- settingsTheme,
307
- knownProviders,
308
- config.providers,
309
- done,
310
- );
156
+ let baseUrl = config.baseUrl;
157
+
158
+ const providerItems: FuzzyMultiSelectorItem[] = knownProviders.map(
159
+ (p) => ({
160
+ label: p,
161
+ checked: config.providers.includes(p),
162
+ subOptions: [
163
+ {
164
+ label: "verify models on gateway",
165
+ description:
166
+ "Warn at startup if this provider's models are missing from the Aperture gateway",
167
+ checked: checkGatewayProviders.includes(p),
168
+ },
169
+ ],
170
+ }),
171
+ );
172
+
173
+ const confirmed = await ctx.ui.custom<boolean | undefined>(
174
+ (tui, theme, _kb, done) => {
175
+ const settingsTheme = getSettingsTheme(theme);
176
+
177
+ return new Wizard({
178
+ title: "Aperture Setup",
179
+ theme: settingsTheme,
180
+ minContentHeight: 16,
181
+ steps: [
182
+ {
183
+ label: "URL",
184
+ build: (wCtx: WizardStepContext) =>
185
+ new UrlStep(settingsTheme, tui, baseUrl, wCtx, (url) => {
186
+ baseUrl = url;
187
+ }),
188
+ },
189
+ {
190
+ label: "Providers",
191
+ build: (wCtx: WizardStepContext) => {
192
+ wCtx.markComplete();
193
+ return new FuzzyMultiSelector({
194
+ label: "Providers to route through Aperture",
195
+ items: providerItems,
196
+ theme: settingsTheme,
197
+ showHints: false,
198
+ showCount: false,
199
+ maxVisible: 7,
200
+ });
201
+ },
202
+ },
203
+ ],
204
+ onComplete: () => done(true),
205
+ onCancel: () => done(undefined),
206
+ });
311
207
  },
312
208
  );
313
209
 
314
- if (!providers) return;
210
+ if (!confirmed) return;
211
+
212
+ const providers = providerItems
213
+ .filter((i) => i.checked)
214
+ .map((i) => i.label);
215
+
216
+ const checkGatewayModels = providerItems
217
+ .filter((i) => i.checked && i.subOptions?.[0]?.checked)
218
+ .map((i) => i.label);
315
219
 
316
- // Step 3: save and register
317
- await configLoader.save("global", { baseUrl, providers });
318
- onConfigChange(ctx);
220
+ await configLoader.save("global", {
221
+ baseUrl,
222
+ providers,
223
+ checkGatewayModels,
224
+ });
225
+ onSync(ctx);
319
226
  ctx.ui.notify(
320
227
  `Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
321
228
  "info",
@@ -0,0 +1,122 @@
1
+ /**
2
+ * ApertureRuntime -- core extension runtime logic.
3
+ *
4
+ * Handles provider registration, unregistration, and gateway model checking.
5
+ */
6
+
7
+ import { getApiProvider } from "@mariozechner/pi-ai";
8
+ import { configLoader } from "../lib/config";
9
+ import { fetchGatewayModelIds } from "../lib/gateway";
10
+ import type {
11
+ Api,
12
+ AssistantMessageEventStream,
13
+ CheckDeps,
14
+ Context,
15
+ Model,
16
+ SimpleStreamOptions,
17
+ SyncDeps,
18
+ } from "../lib/types";
19
+ import { resolveProviderBaseUrl } from "../lib/url";
20
+
21
+ /**
22
+ * Preserve provenance similarly to pi-synthetic so downstream providers can
23
+ * attribute traffic to Pi / this extension.
24
+ */
25
+ const APERTURE_PROVENANCE_HEADERS = {
26
+ Referer: "https://pi.dev",
27
+ "X-Title": "npm:@aliou/pi-ts-aperture",
28
+ };
29
+
30
+ function resolveProviderHeaders(models: Model<Api>[]): Record<string, string> {
31
+ const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
32
+ return {
33
+ ...APERTURE_PROVENANCE_HEADERS,
34
+ ...modelHeaders,
35
+ };
36
+ }
37
+
38
+ export class ApertureRuntime {
39
+ private registeredProviders = new Set<string>();
40
+
41
+ async sync(deps: SyncDeps): Promise<void> {
42
+ const config = configLoader.getConfig();
43
+ if (!config.baseUrl || config.providers.length === 0) {
44
+ return;
45
+ }
46
+
47
+ const baseUrl = resolveProviderBaseUrl(config);
48
+ if (!baseUrl) return;
49
+
50
+ const allModels = deps.getModels();
51
+
52
+ for (const providerName of config.providers) {
53
+ const providerModels = allModels.filter(
54
+ (m) => m.provider === providerName,
55
+ );
56
+ if (providerModels.length === 0) continue;
57
+
58
+ const api = providerModels[0].api ?? "openai-completions";
59
+ const builtIn = getApiProvider(api);
60
+
61
+ deps.registerProvider(providerName, {
62
+ baseUrl,
63
+ apiKey: "-",
64
+ headers: resolveProviderHeaders(providerModels),
65
+ api,
66
+ streamSimple: builtIn
67
+ ? (
68
+ model: Model<Api>,
69
+ context: Context,
70
+ options?: SimpleStreamOptions,
71
+ ): AssistantMessageEventStream =>
72
+ builtIn.streamSimple(model, context, {
73
+ ...options,
74
+ headers: {
75
+ ...options?.headers,
76
+ "x-session-id": options?.sessionId ?? "",
77
+ },
78
+ })
79
+ : undefined,
80
+ });
81
+
82
+ this.registeredProviders.add(providerName);
83
+ }
84
+ }
85
+
86
+ async checkMissingModels(deps: CheckDeps, gatewayUrl: string): Promise<void> {
87
+ const config = configLoader.getConfig();
88
+ if (config.checkGatewayModels.length === 0) return;
89
+
90
+ const gatewayModelIds = await fetchGatewayModelIds(gatewayUrl);
91
+ if (gatewayModelIds.length === 0) return;
92
+
93
+ const allModels = deps.getModels();
94
+ const checkedProviders = new Set(config.checkGatewayModels);
95
+
96
+ const routedModels = allModels.filter((m) =>
97
+ checkedProviders.has(m.provider),
98
+ );
99
+ const missingModels = routedModels.filter(
100
+ (m) => !gatewayModelIds.includes(m.id),
101
+ );
102
+
103
+ if (missingModels.length > 0) {
104
+ const ids = missingModels.map((m) => m.id).join(", ");
105
+ deps.notify(
106
+ `[aperture] models not available on gateway: ${ids}. Add them to the gateway configuration.`,
107
+ "warning",
108
+ );
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Returns providers that should be unregistered based on config changes.
114
+ * Compares previous providers with new ones.
115
+ */
116
+ getProvidersToUnregister(
117
+ prevProviders: string[],
118
+ nextProviders: string[],
119
+ ): string[] {
120
+ return prevProviders.filter((p) => !nextProviders.includes(p));
121
+ }
122
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Pi extension for Tailscale Aperture integration.
3
3
  *
4
- * Keeps the entry point focused on orchestration:
5
- * - load config
6
- * - register lifecycle hooks
7
- * - register user commands
4
+ * Entry point orchestration:
5
+ * - Load config
6
+ * - Register session_start hook for provider registration
7
+ * - Register user commands
8
8
  */
9
9
 
10
10
  import type {
@@ -13,76 +13,85 @@ import type {
13
13
  } from "@mariozechner/pi-coding-agent";
14
14
  import { registerApertureSettings } from "./commands/settings";
15
15
  import { registerSetupCommand } from "./commands/setup";
16
- import { configLoader } from "./config";
17
- import { applyAperture, refreshActiveModel } from "./providers/aperture";
16
+ import { ApertureRuntime } from "./extension/runtime";
17
+ import { configLoader } from "./lib/config";
18
+ import { resolveGatewayUrl } from "./lib/url";
18
19
 
19
- function registerApertureLifecycleHook(pi: ExtensionAPI): void {
20
- const warnedModels = new Set<string>();
20
+ export default async function (pi: ExtensionAPI): Promise<void> {
21
+ await configLoader.load();
21
22
 
22
- pi.on("before_agent_start", async (_event, ctx) => {
23
- if (!ctx?.modelRegistry) return;
23
+ const runtime = new ApertureRuntime();
24
+ let lastRegisteredProviders: string[] = [
25
+ ...configLoader.getConfig().providers,
26
+ ];
24
27
 
25
- const { providers: overriddenProviders, missingModels } =
26
- await applyAperture(pi, ctx.modelRegistry);
28
+ // Sync function used by commands after config changes
29
+ const onSync = (ctx: ExtensionContext): void => {
30
+ const config = configLoader.getConfig();
27
31
 
28
- const newMissing = missingModels.filter((id) => !warnedModels.has(id));
29
- if (newMissing.length > 0) {
30
- for (const id of newMissing) warnedModels.add(id);
32
+ // Unregister providers that were removed from config
33
+ const prevProviders = lastRegisteredProviders;
34
+ const nextProviders = config.providers;
35
+ const toRemove = runtime.getProvidersToUnregister(
36
+ prevProviders,
37
+ nextProviders,
38
+ );
39
+ for (const provider of toRemove) {
40
+ pi.unregisterProvider(provider);
31
41
  ctx.ui.notify(
32
- `[aperture] models not available on gateway: ${newMissing.join(", ")}. Add them to the gateway configuration.`,
33
- "warning",
42
+ `[aperture] unregistered ${provider}. Run /reload to use the native provider.`,
43
+ "info",
34
44
  );
35
45
  }
36
46
 
37
- if (!ctx.model || !overriddenProviders.includes(ctx.model.provider)) return;
38
-
39
- await refreshActiveModel(pi, ctx);
40
- });
41
- }
42
-
43
- function createConfigChangeHandler(
44
- pi: ExtensionAPI,
45
- ): (ctx: ExtensionContext) => void {
46
- let lastRegisteredProviders = [...configLoader.getConfig().providers];
47
-
48
- return (ctx: ExtensionContext) => {
49
- const { providers } = configLoader.getConfig();
50
- const removedProviders = lastRegisteredProviders.filter(
51
- (provider) => !providers.includes(provider),
52
- );
47
+ // Re-register providers
48
+ void runtime
49
+ .sync({
50
+ registerProvider: pi.registerProvider.bind(pi),
51
+ getModels: () => ctx.modelRegistry.getAll(),
52
+ })
53
+ .then(() => {
54
+ // Refresh active model if it's from a registered provider
55
+ if (
56
+ ctx.model &&
57
+ ctx.modelRegistry.find(ctx.model.provider, ctx.model.id)
58
+ ) {
59
+ const updated = ctx.modelRegistry.find(
60
+ ctx.model.provider,
61
+ ctx.model.id,
62
+ );
63
+ if (updated && config.providers.includes(ctx.model.provider)) {
64
+ void pi.setModel(updated);
65
+ }
66
+ }
67
+ });
53
68
 
54
- void applyAperture(pi, ctx.modelRegistry).then(({ missingModels }) => {
55
- if (missingModels.length > 0) {
56
- ctx.ui.notify(
57
- `[aperture] models not available on gateway: ${missingModels.join(", ")}. Add them to the gateway configuration.`,
58
- "warning",
69
+ // Check for missing models on gateway if configured
70
+ if (config.checkGatewayModels.length > 0) {
71
+ const gatewayUrl = resolveGatewayUrl(config);
72
+ if (gatewayUrl) {
73
+ void runtime.checkMissingModels(
74
+ {
75
+ getModels: () => ctx.modelRegistry.getAll(),
76
+ notify: (msg, type) => ctx.ui.notify(msg, type),
77
+ },
78
+ gatewayUrl,
59
79
  );
60
80
  }
61
- });
62
- lastRegisteredProviders = [...providers];
63
-
64
- if (ctx.model && providers.includes(ctx.model.provider)) {
65
- void refreshActiveModel(pi, ctx).then((updated) => {
66
- if (!updated) return;
67
- ctx.ui.notify(
68
- `[aperture] re-routing ${ctx.model?.id ?? "model"} through ${ctx.model?.baseUrl ?? "aperture"}`,
69
- "info",
70
- );
71
- });
72
81
  }
73
82
 
74
- for (const provider of removedProviders) {
75
- pi.unregisterProvider(provider);
76
- }
83
+ lastRegisteredProviders = [...nextProviders];
77
84
  };
78
- }
79
85
 
80
- export default async function (pi: ExtensionAPI): Promise<void> {
81
- await configLoader.load();
82
-
83
- registerApertureLifecycleHook(pi);
86
+ // Register providers at session start (for new sessions)
87
+ pi.on("session_start", (_event, ctx) => {
88
+ lastRegisteredProviders = [...configLoader.getConfig().providers];
89
+ void runtime.sync({
90
+ registerProvider: pi.registerProvider.bind(pi),
91
+ getModels: () => ctx.modelRegistry.getAll(),
92
+ });
93
+ });
84
94
 
85
- const onConfigChange = createConfigChangeHandler(pi);
86
- registerSetupCommand(pi, onConfigChange);
87
- registerApertureSettings(pi, onConfigChange);
95
+ registerSetupCommand(pi, onSync);
96
+ registerApertureSettings(pi, onSync);
88
97
  }
@@ -10,16 +10,19 @@ import { ConfigLoader } from "@aliou/pi-utils-settings";
10
10
  export interface ApertureConfig {
11
11
  baseUrl?: string;
12
12
  providers?: string[];
13
+ checkGatewayModels?: string[];
13
14
  }
14
15
 
15
16
  export interface ResolvedConfig {
16
17
  baseUrl: string;
17
18
  providers: string[];
19
+ checkGatewayModels: string[];
18
20
  }
19
21
 
20
22
  const DEFAULT_CONFIG: ResolvedConfig = {
21
23
  baseUrl: "",
22
24
  providers: [],
25
+ checkGatewayModels: [],
23
26
  };
24
27
 
25
28
  export const configLoader = new ConfigLoader<ApertureConfig, ResolvedConfig>(
@@ -1,8 +1,5 @@
1
1
  /**
2
- * Health check for the Aperture gateway.
3
- *
4
- * Hits GET <baseUrl>/v1/models to verify the gateway is reachable.
5
- * Uses native fetch (no extra dependencies).
2
+ * Gateway health and model checking.
6
3
  */
7
4
 
8
5
  export interface HealthCheckResult {
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Internal types for Aperture extension.
3
+ */
4
+
5
+ import type {
6
+ Api,
7
+ AssistantMessageEventStream,
8
+ Context,
9
+ Model,
10
+ SimpleStreamOptions,
11
+ } from "@mariozechner/pi-ai";
12
+
13
+ export type {
14
+ Api,
15
+ AssistantMessageEventStream,
16
+ Context,
17
+ Model,
18
+ SimpleStreamOptions,
19
+ };
20
+
21
+ /**
22
+ * Dependencies for ApertureRuntime.sync()
23
+ */
24
+ export interface SyncDeps {
25
+ registerProvider: (
26
+ name: string,
27
+ config: {
28
+ baseUrl: string;
29
+ apiKey: string;
30
+ headers: Record<string, string>;
31
+ api: string;
32
+ streamSimple?: (
33
+ model: Model<Api>,
34
+ context: Context,
35
+ options?: SimpleStreamOptions,
36
+ ) => AssistantMessageEventStream;
37
+ },
38
+ ) => void;
39
+ getModels: () => Model<Api>[];
40
+ }
41
+
42
+ /**
43
+ * Dependencies for ApertureRuntime.checkMissingModels()
44
+ */
45
+ export interface CheckDeps {
46
+ getModels: () => Model<Api>[];
47
+ notify: (msg: string, type: "warning" | "info") => void;
48
+ }
49
+
50
+ /**
51
+ * Headers for provider registration.
52
+ */
53
+ export interface ProviderHeaders {
54
+ Referer: string;
55
+ "X-Title": string;
56
+ }
package/src/lib/url.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Pure URL helpers.
3
+ */
4
+
5
+ import type { ApertureConfig } from "./config";
6
+
7
+ /**
8
+ * Normalizes a user-input URL:
9
+ * - Trims whitespace
10
+ * - Adds http:// scheme if missing
11
+ * - Strips trailing /v1 or /v1/
12
+ * - Strips trailing slashes
13
+ */
14
+ export function normalizeInputUrl(raw: string): string {
15
+ let result = raw.trim();
16
+ if (!result) return result;
17
+ if (!result.startsWith("http://") && !result.startsWith("https://")) {
18
+ result = `http://${result}`;
19
+ }
20
+ return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
21
+ }
22
+
23
+ /**
24
+ * Returns configured gateway URL without trailing slash.
25
+ * Returns null when baseUrl is empty or providers list is empty.
26
+ */
27
+ export function resolveGatewayUrl(config: ApertureConfig): string | null {
28
+ const { baseUrl, providers } = config;
29
+ if (!baseUrl || providers?.length === 0) return null;
30
+ return baseUrl.replace(/\/+$/, "");
31
+ }
32
+
33
+ /**
34
+ * Returns the Aperture provider base URL used for provider registration.
35
+ * Appends /v1 to the gateway URL.
36
+ * Returns null when gateway URL cannot be resolved.
37
+ */
38
+ export function resolveProviderBaseUrl(config: ApertureConfig): string | null {
39
+ const gateway = resolveGatewayUrl(config);
40
+ if (!gateway) return null;
41
+ return `${gateway}/v1`;
42
+ }
@@ -1,112 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- ProviderModelConfig,
5
- } from "@mariozechner/pi-coding-agent";
6
- import { configLoader } from "../config";
7
- import { fetchGatewayModelIds } from "../lib/health";
8
-
9
- /**
10
- * Preserve provenance similarly to pi-synthetic so downstream providers can
11
- * attribute traffic to Pi / this extension.
12
- */
13
- const APERTURE_PROVENANCE_HEADERS = {
14
- Referer: "https://pi.dev",
15
- "X-Title": "npm:@aliou/pi-ts-aperture",
16
- };
17
-
18
- /** Returns configured gateway URL without trailing slash. */
19
- export function resolveGatewayUrl(): string | null {
20
- const { baseUrl, providers } = configLoader.getConfig();
21
- if (!baseUrl || providers.length === 0) return null;
22
- return baseUrl.replace(/\/+$/, "");
23
- }
24
-
25
- /**
26
- * Returns the Aperture provider base URL used for provider registration.
27
- *
28
- * Aperture exposes multiple protocol paths (OpenAI, Anthropic, Gemini, ...).
29
- * For this extension we route through the OpenAI-compatible `/v1` surface that
30
- * Pi providers use (`openai-completions` API).
31
- */
32
- export function resolveApertureProviderBaseUrl(): string | null {
33
- const gateway = resolveGatewayUrl();
34
- if (!gateway) return null;
35
- return `${gateway}/v1`;
36
- }
37
-
38
- function resolveProviderHeaders(
39
- models: ProviderModelConfig[],
40
- ): Record<string, string> {
41
- const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
42
- return {
43
- ...APERTURE_PROVENANCE_HEADERS,
44
- ...modelHeaders,
45
- };
46
- }
47
-
48
- /**
49
- * Apply Aperture override to configured providers.
50
- *
51
- * Only patches baseUrl, apiKey, and headers. Models are left exactly as
52
- * registered by Pi built-ins or other extensions -- Aperture never touches
53
- * model definitions.
54
- *
55
- * Providers with no models in the registry are skipped (nothing to reroute).
56
- */
57
- export async function applyAperture(
58
- pi: ExtensionAPI,
59
- registry: ExtensionContext["modelRegistry"],
60
- ): Promise<{ providers: string[]; missingModels: string[] }> {
61
- const baseUrl = resolveApertureProviderBaseUrl();
62
- if (!baseUrl) return { providers: [], missingModels: [] };
63
-
64
- const { providers } = configLoader.getConfig();
65
-
66
- for (const provider of providers) {
67
- const existingModels = registry
68
- .getAll()
69
- .filter((m) => m.provider === provider) as ProviderModelConfig[];
70
-
71
- if (existingModels.length === 0) continue;
72
-
73
- pi.registerProvider(provider, {
74
- baseUrl,
75
- apiKey: "-",
76
- headers: resolveProviderHeaders(existingModels),
77
- api: existingModels[0].api,
78
- models: existingModels,
79
- });
80
- }
81
-
82
- const gatewayUrl = resolveGatewayUrl();
83
- const gatewayModelIds = gatewayUrl
84
- ? await fetchGatewayModelIds(gatewayUrl)
85
- : [];
86
-
87
- let missingModels: string[] = [];
88
- if (gatewayModelIds.length > 0) {
89
- const routedModelIds = registry
90
- .getAll()
91
- .filter((m) => providers.includes(m.provider))
92
- .map((m) => m.id);
93
- missingModels = routedModelIds.filter(
94
- (id) => !gatewayModelIds.includes(id),
95
- );
96
- }
97
-
98
- return { providers, missingModels };
99
- }
100
-
101
- /** Re-resolve and set current model after provider registry updates. */
102
- export async function refreshActiveModel(
103
- pi: ExtensionAPI,
104
- ctx: ExtensionContext,
105
- ): Promise<boolean> {
106
- if (!ctx.model) return false;
107
-
108
- const updated = ctx.modelRegistry.find(ctx.model.provider, ctx.model.id);
109
- if (!updated) return false;
110
-
111
- return pi.setModel(updated);
112
- }