@aliou/pi-ts-aperture 0.5.1 → 0.6.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.
Files changed (33) hide show
  1. package/README.md +119 -21
  2. package/extensions/aperture/dedicated/api-routing.ts +66 -0
  3. package/extensions/aperture/dedicated/model-defaults.ts +48 -0
  4. package/extensions/aperture/dedicated/runtime.ts +87 -0
  5. package/extensions/aperture/index.ts +78 -0
  6. package/extensions/aperture/onboarding/index.ts +25 -0
  7. package/extensions/aperture/onboarding/onboarding.ts +892 -0
  8. package/extensions/aperture/onboarding/setup-command.ts +53 -0
  9. package/extensions/aperture/onboarding/setup-wizard.ts +134 -0
  10. package/extensions/aperture/proxy/runtime.ts +160 -0
  11. package/extensions/aperture/settings-command.ts +369 -0
  12. package/extensions/aperture/shared/config/defaults.ts +17 -0
  13. package/extensions/aperture/shared/config/loader.ts +21 -0
  14. package/extensions/aperture/shared/config/migration/001-legacy-to-v0-6.ts +45 -0
  15. package/extensions/aperture/shared/config/migration/002-mode-to-capabilities.ts +20 -0
  16. package/extensions/aperture/shared/config/migration/003-normalize-capabilities.ts +26 -0
  17. package/extensions/aperture/shared/config/migration/index.ts +15 -0
  18. package/extensions/aperture/shared/config/types.ts +57 -0
  19. package/extensions/aperture/shared/sync-bus.ts +12 -0
  20. package/{src/lib → extensions/aperture/shared}/types.ts +1 -1
  21. package/package.json +37 -27
  22. package/src/api/client.ts +139 -0
  23. package/src/api/types.ts +26 -0
  24. package/src/provider-mapping.ts +91 -0
  25. package/src/url.ts +52 -0
  26. package/src/commands/settings.ts +0 -135
  27. package/src/commands/setup.ts +0 -232
  28. package/src/extension/runtime.test.ts +0 -121
  29. package/src/extension/runtime.ts +0 -144
  30. package/src/index.ts +0 -97
  31. package/src/lib/config.ts +0 -32
  32. package/src/lib/gateway.ts +0 -61
  33. package/src/lib/url.ts +0 -42
@@ -0,0 +1,369 @@
1
+ import {
2
+ registerSettingsCommand,
3
+ SettingsDetailEditor,
4
+ type SettingsDetailField,
5
+ type SettingsSection,
6
+ } from "@aliou/pi-utils-settings";
7
+ import type { Api, Model } from "@earendil-works/pi-ai";
8
+ import type {
9
+ ExtensionAPI,
10
+ ExtensionContext,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
13
+ import type { Component } from "@earendil-works/pi-tui";
14
+ import { ApertureClient } from "../../src/api/client";
15
+ import {
16
+ mapDedicatedProviders,
17
+ mapProxyProviders,
18
+ } from "../../src/provider-mapping";
19
+ import { normalizeInputUrl } from "../../src/url";
20
+ import type {
21
+ ApertureConfig,
22
+ DedicatedProviderConfig,
23
+ ResolvedConfig,
24
+ } from "./shared/config/loader";
25
+ import { configLoader } from "./shared/config/loader";
26
+
27
+ function boolLabel(value: boolean): string {
28
+ return value ? "enabled" : "disabled";
29
+ }
30
+
31
+ class AsyncEditor implements Component {
32
+ private editor: Component | null = null;
33
+ private error = "";
34
+
35
+ constructor(loader: () => Promise<Component>) {
36
+ void loader()
37
+ .then((editor) => {
38
+ this.editor = editor;
39
+ })
40
+ .catch((error: unknown) => {
41
+ this.error = error instanceof Error ? error.message : String(error);
42
+ });
43
+ }
44
+
45
+ render(width: number): string[] {
46
+ if (this.editor) return this.editor.render(width);
47
+ if (this.error) return [`Failed to refresh providers: ${this.error}`];
48
+ return ["Refreshing providers from Aperture..."];
49
+ }
50
+
51
+ invalidate(): void {
52
+ this.editor?.invalidate?.();
53
+ }
54
+
55
+ handleInput(data: string): void {
56
+ this.editor?.handleInput?.(data);
57
+ }
58
+ }
59
+
60
+ export function registerApertureSettings(
61
+ pi: ExtensionAPI,
62
+ onSync: (ctx: ExtensionContext) => void,
63
+ getKnownModels: () => Model<Api>[],
64
+ ): void {
65
+ registerSettingsCommand<ApertureConfig, ResolvedConfig>(pi, {
66
+ commandName: "aperture:settings",
67
+ title: "Aperture Settings",
68
+ configStore: configLoader,
69
+ buildSections: (tabConfig, resolved, { setDraft }): SettingsSection[] => {
70
+ const draft = tabConfig ?? {};
71
+ const baseUrl = draft.baseUrl ?? resolved.baseUrl;
72
+ const proxyEnabled = draft.proxy?.enabled ?? resolved.proxy.enabled;
73
+ const dedicatedEnabled =
74
+ draft.dedicated?.enabled ?? resolved.dedicated.enabled;
75
+ const upstreamProviders =
76
+ draft.proxy?.upstreamProviders ?? resolved.proxy.upstreamProviders;
77
+ const dedicatedProviders =
78
+ draft.dedicated?.providers ?? resolved.dedicated.providers;
79
+ const onboardingDone = draft.onboardingDone ?? resolved.onboardingDone;
80
+ const onboardingEnabled =
81
+ draft.onboarding?.enabled ?? resolved.onboarding.enabled;
82
+
83
+ return [
84
+ {
85
+ label: "Connection",
86
+ items: [
87
+ {
88
+ id: "baseUrl",
89
+ label: "Base URL",
90
+ description: "Aperture gateway URL on your tailnet",
91
+ currentValue: baseUrl || "(not set)",
92
+ submenu: (_val, submenuDone) => {
93
+ let currentUrl = baseUrl;
94
+ const fields: SettingsDetailField[] = [
95
+ {
96
+ type: "text",
97
+ id: "baseUrl",
98
+ label: "Base URL",
99
+ getValue: () => currentUrl,
100
+ setValue: (value) => {
101
+ currentUrl = value;
102
+ const updated = structuredClone(draft) as ApertureConfig;
103
+ updated.baseUrl = normalizeInputUrl(value);
104
+ setDraft(updated);
105
+ },
106
+ validate: (value) =>
107
+ value.trim() ? null : "URL cannot be empty",
108
+ displayValue: (value) => value || "(not set)",
109
+ emptyValueText: "(not set)",
110
+ },
111
+ ];
112
+ return new SettingsDetailEditor({
113
+ title: "Base URL",
114
+ fields,
115
+ theme: getSettingsListTheme(),
116
+ onDone: (summary) =>
117
+ submenuDone(summary ?? (currentUrl || "(not set)")),
118
+ getDoneSummary: () => currentUrl || "(not set)",
119
+ });
120
+ },
121
+ },
122
+ ],
123
+ },
124
+ {
125
+ label: "Capabilities",
126
+ items: [
127
+ {
128
+ id: "proxy.enabled",
129
+ label: "Proxy existing providers",
130
+ description: "Route selected Pi providers through Aperture",
131
+ currentValue: boolLabel(proxyEnabled),
132
+ values: ["enabled", "disabled"],
133
+ },
134
+ {
135
+ id: "dedicated.enabled",
136
+ label: "Dedicated Aperture provider",
137
+ description: "Register a standalone aperture provider",
138
+ currentValue: boolLabel(dedicatedEnabled),
139
+ values: ["enabled", "disabled"],
140
+ },
141
+ ],
142
+ },
143
+ {
144
+ label: "Proxy",
145
+ items: [
146
+ {
147
+ id: "proxy.upstreamProviders",
148
+ label: "Upstream providers",
149
+ description: "Configured proxy providers and gateway checks",
150
+ currentValue:
151
+ upstreamProviders.length > 0
152
+ ? `${upstreamProviders.length} provider(s)`
153
+ : "none",
154
+ submenu: (_val, submenuDone) =>
155
+ new AsyncEditor(async () => {
156
+ const client = new ApertureClient(baseUrl);
157
+ const [providerInfos, gatewayProviders] = await Promise.all([
158
+ client.providerConfigInfos(),
159
+ client.providers(),
160
+ ]);
161
+ const providers = mapProxyProviders(
162
+ getKnownModels(),
163
+ providerInfos,
164
+ gatewayProviders,
165
+ upstreamProviders,
166
+ );
167
+ const enabled = new Set(
168
+ upstreamProviders.map((provider) => provider.id),
169
+ );
170
+ {
171
+ const updated = structuredClone(draft) as ApertureConfig;
172
+ updated.proxy = {
173
+ ...updated.proxy,
174
+ upstreamProviders: providers.filter((provider) =>
175
+ enabled.has(provider.id),
176
+ ),
177
+ };
178
+ setDraft(updated);
179
+ }
180
+ const fields: SettingsDetailField[] = providers.flatMap(
181
+ (p, i) => [
182
+ {
183
+ type: "boolean" as const,
184
+ id: `provider.${p.id}.enabled`,
185
+ label: p.name ?? p.id,
186
+ getValue: () => enabled.has(p.id),
187
+ setValue: (value: boolean) => {
188
+ if (value) enabled.add(p.id);
189
+ else enabled.delete(p.id);
190
+ const updated = structuredClone(
191
+ draft,
192
+ ) as ApertureConfig;
193
+ updated.proxy = {
194
+ ...updated.proxy,
195
+ upstreamProviders: providers.filter((provider) =>
196
+ enabled.has(provider.id),
197
+ ),
198
+ };
199
+ setDraft(updated);
200
+ },
201
+ trueLabel: "enabled",
202
+ falseLabel: "disabled",
203
+ },
204
+ {
205
+ type: "boolean" as const,
206
+ id: `provider.${p.id}.shouldCheckGatewayModels`,
207
+ label: `${p.name ?? p.id} — gateway model check`,
208
+ getValue: () => p.shouldCheckGatewayModels as boolean,
209
+ setValue: (value: boolean) => {
210
+ const provider = providers[i];
211
+ if (provider)
212
+ provider.shouldCheckGatewayModels = value;
213
+ const updated = structuredClone(
214
+ draft,
215
+ ) as ApertureConfig;
216
+ updated.proxy = {
217
+ ...updated.proxy,
218
+ upstreamProviders: providers.filter((provider) =>
219
+ enabled.has(provider.id),
220
+ ),
221
+ };
222
+ setDraft(updated);
223
+ },
224
+ trueLabel: "on",
225
+ falseLabel: "off",
226
+ },
227
+ ],
228
+ );
229
+ return new SettingsDetailEditor({
230
+ title: () =>
231
+ `Upstream Providers (${enabled.size}/${providers.length})`,
232
+ fields,
233
+ theme: getSettingsListTheme(),
234
+ onDone: () =>
235
+ submenuDone(
236
+ providers.length > 0
237
+ ? `${enabled.size}/${providers.length} enabled`
238
+ : "none",
239
+ ),
240
+ getDoneSummary: () =>
241
+ providers.length > 0
242
+ ? `${enabled.size}/${providers.length} enabled`
243
+ : "none",
244
+ emptyStateText:
245
+ "No local providers match the Aperture gateway provider base URLs.",
246
+ });
247
+ }),
248
+ },
249
+ ],
250
+ },
251
+ {
252
+ label: "Dedicated",
253
+ items: [
254
+ {
255
+ id: "dedicated.providers",
256
+ label: "Aperture providers",
257
+ description:
258
+ "Gateway providers included in the aperture provider",
259
+ currentValue:
260
+ dedicatedProviders.length > 0
261
+ ? `${dedicatedProviders.filter((p) => p.enabled).length}/${dedicatedProviders.length} enabled`
262
+ : "all (no filter)",
263
+ submenu: (_val, submenuDone) =>
264
+ new AsyncEditor(async () => {
265
+ const gatewayProviders = await new ApertureClient(
266
+ baseUrl,
267
+ ).providers();
268
+ const providers: DedicatedProviderConfig[] =
269
+ mapDedicatedProviders(gatewayProviders, dedicatedProviders);
270
+ {
271
+ const updated = structuredClone(draft) as ApertureConfig;
272
+ updated.dedicated = {
273
+ ...updated.dedicated,
274
+ providers,
275
+ };
276
+ setDraft(updated);
277
+ }
278
+ const fields: SettingsDetailField[] = providers.map(
279
+ (p: DedicatedProviderConfig, i: number) => ({
280
+ type: "boolean" as const,
281
+ id: `dedicated.provider.${p.id}.enabled`,
282
+ label: p.name ?? p.id,
283
+ getValue: () => p.enabled,
284
+ setValue: (value: boolean) => {
285
+ const provider = providers[i];
286
+ if (provider) provider.enabled = value;
287
+ const updated = structuredClone(
288
+ draft,
289
+ ) as ApertureConfig;
290
+ updated.dedicated = {
291
+ ...updated.dedicated,
292
+ providers,
293
+ };
294
+ setDraft(updated);
295
+ },
296
+ trueLabel: "enabled",
297
+ falseLabel: "disabled",
298
+ }),
299
+ );
300
+ return new SettingsDetailEditor({
301
+ title: () =>
302
+ `Dedicated Providers (${providers.filter((p) => p.enabled).length}/${providers.length})`,
303
+ fields,
304
+ theme: getSettingsListTheme(),
305
+ onDone: () =>
306
+ submenuDone(
307
+ providers.length > 0
308
+ ? `${providers.filter((p) => p.enabled).length}/${providers.length} enabled`
309
+ : "none",
310
+ ),
311
+ getDoneSummary: () =>
312
+ providers.length > 0
313
+ ? `${providers.filter((p) => p.enabled).length}/${providers.length} enabled`
314
+ : "none",
315
+ emptyStateText:
316
+ "No providers found on the Aperture gateway.",
317
+ });
318
+ }),
319
+ },
320
+ ],
321
+ },
322
+ {
323
+ label: "Setup",
324
+ items: [
325
+ {
326
+ id: "onboardingDone",
327
+ label: "Onboarding",
328
+ description: onboardingDone ? "Setup completed" : "Setup pending",
329
+ currentValue: onboardingDone ? "completed" : "pending",
330
+ values: ["completed", "pending"],
331
+ },
332
+ {
333
+ id: "onboardingEnabled",
334
+ label: "Onboarding extension",
335
+ description: "Controls first-run onboarding command",
336
+ currentValue: onboardingEnabled ? "enabled" : "disabled",
337
+ values: ["enabled", "disabled"],
338
+ },
339
+ ],
340
+ },
341
+ ];
342
+ },
343
+ onSettingChange: (id, newValue, config) => {
344
+ const updated = structuredClone(config);
345
+ if (id === "baseUrl") updated.baseUrl = normalizeInputUrl(newValue);
346
+ if (id === "proxy.enabled")
347
+ updated.proxy = { ...updated.proxy, enabled: newValue === "enabled" };
348
+ if (id === "dedicated.enabled")
349
+ updated.dedicated = {
350
+ ...updated.dedicated,
351
+ enabled: newValue === "enabled",
352
+ };
353
+ if (id === "onboardingDone") {
354
+ updated.onboardingDone = newValue === "completed";
355
+ updated.onboarding = {
356
+ ...updated.onboarding,
357
+ enabled: !updated.onboardingDone,
358
+ };
359
+ }
360
+ if (id === "onboardingEnabled")
361
+ updated.onboarding = {
362
+ ...updated.onboarding,
363
+ enabled: newValue === "enabled",
364
+ };
365
+ return updated;
366
+ },
367
+ onSave: (ctx) => onSync(ctx),
368
+ });
369
+ }
@@ -0,0 +1,17 @@
1
+ import type { ResolvedConfig } from "./types";
2
+
3
+ export const DEFAULT_CONFIG: ResolvedConfig = {
4
+ baseUrl: "",
5
+ onboardingDone: false,
6
+ onboarding: {
7
+ enabled: true,
8
+ },
9
+ proxy: {
10
+ enabled: false,
11
+ upstreamProviders: [],
12
+ },
13
+ dedicated: {
14
+ enabled: true,
15
+ providers: [],
16
+ },
17
+ };
@@ -0,0 +1,21 @@
1
+ import { ConfigLoader } from "@aliou/pi-utils-settings";
2
+ import { DEFAULT_CONFIG } from "./defaults";
3
+ import { migrations } from "./migration";
4
+ import type { ApertureConfig, ResolvedConfig } from "./types";
5
+
6
+ export const configLoader = new ConfigLoader<ApertureConfig, ResolvedConfig>(
7
+ "aperture",
8
+ DEFAULT_CONFIG,
9
+ {
10
+ scopes: ["global"],
11
+ migrations,
12
+ },
13
+ );
14
+
15
+ export type {
16
+ ApertureConfig,
17
+ ApertureMode,
18
+ DedicatedProviderConfig,
19
+ ProxiedProviderConfig,
20
+ ResolvedConfig,
21
+ } from "./types";
@@ -0,0 +1,45 @@
1
+ import type { ApertureConfig, Migration } from "../types";
2
+
3
+ export const legacyToV06Migration: Migration<ApertureConfig> = {
4
+ name: "001-legacy-to-v0-6",
5
+ shouldRun: (config) =>
6
+ config.providers !== undefined ||
7
+ config.checkGatewayModels !== undefined ||
8
+ config.apertureProvider !== undefined ||
9
+ (config.onboardingDone === undefined && config.baseUrl !== undefined),
10
+ run: (config) => {
11
+ const migrated: ApertureConfig = { ...config };
12
+ const hadProviders =
13
+ migrated.providers !== undefined ||
14
+ migrated.checkGatewayModels !== undefined;
15
+
16
+ if (hadProviders) {
17
+ const providers = migrated.providers ?? [];
18
+ const checked = migrated.checkGatewayModels ?? [];
19
+ migrated.proxy = {
20
+ ...migrated.proxy,
21
+ enabled: true,
22
+ upstreamProviders: providers.map((id) => ({
23
+ id,
24
+ shouldCheckGatewayModels: checked.includes(id),
25
+ })),
26
+ };
27
+ delete migrated.providers;
28
+ delete migrated.checkGatewayModels;
29
+ }
30
+
31
+ if (migrated.apertureProvider !== undefined) {
32
+ migrated.dedicated = {
33
+ ...migrated.dedicated,
34
+ enabled: migrated.apertureProvider,
35
+ };
36
+ delete migrated.apertureProvider;
37
+ }
38
+
39
+ if (migrated.onboardingDone === undefined && migrated.baseUrl) {
40
+ migrated.onboardingDone = true;
41
+ }
42
+
43
+ return migrated;
44
+ },
45
+ };
@@ -0,0 +1,20 @@
1
+ import type { ApertureConfig, Migration } from "../types";
2
+
3
+ export const modeToCapabilitiesMigration: Migration<ApertureConfig> = {
4
+ name: "002-mode-to-capabilities",
5
+ shouldRun: (config) => config.mode !== undefined,
6
+ run: (config) => {
7
+ const migrated: ApertureConfig = { ...config };
8
+
9
+ if (migrated.mode === "proxy") {
10
+ migrated.proxy = { ...migrated.proxy, enabled: true };
11
+ migrated.dedicated = { ...migrated.dedicated, enabled: false };
12
+ } else if (migrated.mode === "dedicated") {
13
+ migrated.dedicated = { ...migrated.dedicated, enabled: true };
14
+ migrated.proxy = { ...migrated.proxy, enabled: false };
15
+ }
16
+
17
+ delete migrated.mode;
18
+ return migrated;
19
+ },
20
+ };
@@ -0,0 +1,26 @@
1
+ import type { ApertureConfig, Migration } from "../types";
2
+
3
+ export const normalizeCapabilitiesMigration: Migration<ApertureConfig> = {
4
+ name: "003-normalize-capabilities",
5
+ shouldRun: (config) =>
6
+ config.proxy?.enabled === undefined ||
7
+ config.proxy?.upstreamProviders === undefined ||
8
+ config.dedicated?.enabled === undefined ||
9
+ config.dedicated?.providers === undefined ||
10
+ config.dedicated?.cachedModels !== undefined,
11
+ run: (config) => {
12
+ const migrated: ApertureConfig = { ...config };
13
+ migrated.proxy = {
14
+ ...migrated.proxy,
15
+ enabled: migrated.proxy?.enabled ?? false,
16
+ upstreamProviders: migrated.proxy?.upstreamProviders ?? [],
17
+ };
18
+ migrated.dedicated = {
19
+ ...migrated.dedicated,
20
+ enabled: migrated.dedicated?.enabled ?? true,
21
+ providers: migrated.dedicated?.providers ?? [],
22
+ };
23
+ delete migrated.dedicated.cachedModels;
24
+ return migrated;
25
+ },
26
+ };
@@ -0,0 +1,15 @@
1
+ import { legacyToV06Migration } from "./001-legacy-to-v0-6";
2
+ import { modeToCapabilitiesMigration } from "./002-mode-to-capabilities";
3
+ import { normalizeCapabilitiesMigration } from "./003-normalize-capabilities";
4
+
5
+ export const migrations = [
6
+ legacyToV06Migration,
7
+ modeToCapabilitiesMigration,
8
+ normalizeCapabilitiesMigration,
9
+ ];
10
+
11
+ export {
12
+ legacyToV06Migration,
13
+ modeToCapabilitiesMigration,
14
+ normalizeCapabilitiesMigration,
15
+ };
@@ -0,0 +1,57 @@
1
+ export interface ProxiedProviderConfig {
2
+ id: string;
3
+ shouldCheckGatewayModels?: boolean;
4
+ }
5
+
6
+ export interface DedicatedProviderConfig {
7
+ id: string;
8
+ name?: string;
9
+ enabled: boolean;
10
+ }
11
+
12
+ export type ApertureMode = "proxy" | "dedicated";
13
+
14
+ export interface ApertureConfig {
15
+ baseUrl?: string;
16
+ onboardingDone?: boolean;
17
+ onboarding?: {
18
+ enabled?: boolean;
19
+ };
20
+ proxy?: {
21
+ enabled?: boolean;
22
+ upstreamProviders?: ProxiedProviderConfig[];
23
+ };
24
+ dedicated?: {
25
+ enabled?: boolean;
26
+ providers?: DedicatedProviderConfig[];
27
+ cachedModels?: unknown[];
28
+ };
29
+
30
+ // Legacy-only migration inputs.
31
+ mode?: ApertureMode;
32
+ providers?: string[];
33
+ checkGatewayModels?: string[];
34
+ apertureProvider?: boolean;
35
+ }
36
+
37
+ export interface ResolvedConfig {
38
+ baseUrl: string;
39
+ onboardingDone: boolean;
40
+ onboarding: {
41
+ enabled: boolean;
42
+ };
43
+ proxy: {
44
+ enabled: boolean;
45
+ upstreamProviders: Required<ProxiedProviderConfig>[];
46
+ };
47
+ dedicated: {
48
+ enabled: boolean;
49
+ providers: DedicatedProviderConfig[];
50
+ };
51
+ }
52
+
53
+ export interface Migration<TConfig> {
54
+ name: string;
55
+ shouldRun: (config: TConfig) => boolean;
56
+ run: (config: TConfig, filePath: string) => TConfig;
57
+ }
@@ -0,0 +1,12 @@
1
+ type SyncCallback = () => void;
2
+
3
+ const listeners = new Set<SyncCallback>();
4
+
5
+ export function onConfigSync(cb: SyncCallback): () => void {
6
+ listeners.add(cb);
7
+ return () => listeners.delete(cb);
8
+ }
9
+
10
+ export function emitConfigSync(): void {
11
+ for (const listener of listeners) listener();
12
+ }
@@ -8,7 +8,7 @@ import type {
8
8
  Context,
9
9
  Model,
10
10
  SimpleStreamOptions,
11
- } from "@mariozechner/pi-ai";
11
+ } from "@earendil-works/pi-ai";
12
12
 
13
13
  export type {
14
14
  Api,