@aliou/pi-ts-aperture 0.5.1 → 0.6.1

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 +79 -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 +164 -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 +2 -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
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.5.1",
4
+ "version": "0.6.1",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -20,55 +20,65 @@
20
20
  },
21
21
  "files": [
22
22
  "src",
23
- "README.md"
23
+ "extensions",
24
+ "README.md",
25
+ "!**/*.test.ts"
24
26
  ],
25
27
  "pi": {
26
28
  "extensions": [
27
- "./src/index.ts"
29
+ "./extensions/aperture/index.ts"
28
30
  ],
29
- "video": "https://assets.aliou.me/pi-extensions/demos/pi-ts-aperture.mp4"
31
+ "video": "https://assets.aliou.me/github/aliou/pi-ts-aperture/demo.mp4"
30
32
  },
31
33
  "dependencies": {
32
- "@aliou/pi-utils-settings": "^0.12.0"
34
+ "@aliou/pi-utils-settings": "^0.17.0"
33
35
  },
34
36
  "peerDependencies": {
35
- "@mariozechner/pi-ai": "0.64.0",
36
- "@mariozechner/pi-coding-agent": "0.64.0",
37
- "@mariozechner/pi-tui": "0.64.0"
37
+ "@earendil-works/pi-ai": "0.74.0",
38
+ "@earendil-works/pi-coding-agent": "0.74.0",
39
+ "@earendil-works/pi-tui": "0.74.0"
38
40
  },
39
41
  "peerDependenciesMeta": {
40
- "@mariozechner/pi-coding-agent": {
42
+ "@earendil-works/pi-coding-agent": {
41
43
  "optional": true
42
44
  },
43
- "@mariozechner/pi-ai": {
45
+ "@earendil-works/pi-ai": {
44
46
  "optional": true
45
47
  },
46
- "@mariozechner/pi-tui": {
48
+ "@earendil-works/pi-tui": {
47
49
  "optional": true
48
50
  }
49
51
  },
50
- "devDependencies": {
51
- "@aliou/biome-plugins": "^0.3.2",
52
- "@biomejs/biome": "^2.3.13",
53
- "@changesets/cli": "^2.27.11",
54
- "@mariozechner/pi-coding-agent": "0.64.0",
55
- "@sinclair/typebox": "^0.34.48",
56
- "@types/node": "^25.0.10",
57
- "@vitest/coverage-v8": "^4.0.18",
58
- "husky": "^9.1.7",
59
- "typescript": "^5.9.3",
60
- "vitest": "^4.0.18"
61
- },
62
52
  "scripts": {
63
53
  "typecheck": "tsc --noEmit",
64
54
  "lint": "biome check",
65
55
  "format": "biome check --write",
66
56
  "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
57
+ "prepare": "[ -d .git ] && husky || true",
67
58
  "changeset": "changeset",
68
59
  "version": "changeset version",
69
60
  "release": "pnpm changeset publish",
70
61
  "test": "vitest run",
71
- "test:watch": "vitest",
72
- "test:e2e": "vitest run tests/e2e.test.ts"
73
- }
74
- }
62
+ "test:watch": "vitest"
63
+ },
64
+ "devDependencies": {
65
+ "@aliou/biome-plugins": "^0.8.1",
66
+ "@aliou/pi-utils-ui": "^0.4.1",
67
+ "@biomejs/biome": "^2.4.15",
68
+ "@changesets/cli": "^2.27.11",
69
+ "@earendil-works/pi-coding-agent": "0.74.0",
70
+ "typebox": "^1.0.0",
71
+ "@types/node": "^25.0.10",
72
+ "@vitest/coverage-v8": "^4.0.18",
73
+ "husky": "^9.1.7",
74
+ "typescript": "^5.9.3",
75
+ "vitest": "^4.0.18"
76
+ },
77
+ "pnpm": {
78
+ "overrides": {
79
+ "@earendil-works/pi-ai": "$@earendil-works/pi-coding-agent",
80
+ "@earendil-works/pi-tui": "$@earendil-works/pi-coding-agent"
81
+ }
82
+ },
83
+ "packageManager": "pnpm@10.26.1"
84
+ }
@@ -0,0 +1,139 @@
1
+ import type { ApertureProvider, ApertureProviderConfigInfo } from "./types";
2
+
3
+ interface ProvidersResponse {
4
+ providers?: unknown;
5
+ }
6
+
7
+ interface ConfigResponse {
8
+ config?: unknown;
9
+ }
10
+
11
+ function parseProvider(
12
+ value: unknown,
13
+ fallbackId?: string,
14
+ ): ApertureProvider | null {
15
+ if (!value || typeof value !== "object") return null;
16
+ const record = value as Record<string, unknown>;
17
+ const id = typeof record.id === "string" ? record.id : fallbackId;
18
+ if (!id) return null;
19
+ return {
20
+ id,
21
+ name: typeof record.name === "string" ? record.name : id,
22
+ description:
23
+ typeof record.description === "string" ? record.description : undefined,
24
+ baseUrl:
25
+ typeof record.baseUrl === "string"
26
+ ? record.baseUrl
27
+ : typeof record.base_url === "string"
28
+ ? record.base_url
29
+ : typeof record.baseurl === "string"
30
+ ? record.baseurl
31
+ : undefined,
32
+ models: Array.isArray(record.models)
33
+ ? record.models.filter(
34
+ (model): model is string => typeof model === "string",
35
+ )
36
+ : [],
37
+ compatibility:
38
+ record.compatibility && typeof record.compatibility === "object"
39
+ ? (record.compatibility as ApertureProvider["compatibility"])
40
+ : {},
41
+ };
42
+ }
43
+
44
+ export class ApertureClient {
45
+ constructor(private readonly baseUrl: string) {}
46
+
47
+ async providers(signal?: AbortSignal): Promise<ApertureProvider[]> {
48
+ const res = await fetch(
49
+ `${this.baseUrl.replace(/\/+$/, "")}/api/providers`,
50
+ {
51
+ method: "GET",
52
+ signal: signal ?? AbortSignal.timeout(5000),
53
+ },
54
+ );
55
+ if (!res.ok) {
56
+ throw new Error(
57
+ `Aperture providers request failed: HTTP ${res.status} ${res.statusText}`,
58
+ );
59
+ }
60
+
61
+ const body = (await res.json()) as ProvidersResponse | unknown[];
62
+ if (Array.isArray(body)) {
63
+ return body
64
+ .map((provider) => parseProvider(provider))
65
+ .filter((p): p is ApertureProvider => p !== null);
66
+ }
67
+
68
+ const providers = (body as ProvidersResponse).providers;
69
+ if (Array.isArray(providers)) {
70
+ return providers
71
+ .map((provider) => parseProvider(provider))
72
+ .filter((p): p is ApertureProvider => p !== null);
73
+ }
74
+ if (providers && typeof providers === "object") {
75
+ return Object.entries(providers).flatMap(([id, provider]) => {
76
+ const parsed = parseProvider(provider, id);
77
+ return parsed ? [parsed] : [];
78
+ });
79
+ }
80
+
81
+ return [];
82
+ }
83
+
84
+ async providerConfigInfos(
85
+ signal?: AbortSignal,
86
+ ): Promise<Map<string, ApertureProviderConfigInfo>> {
87
+ const res = await fetch(
88
+ `${this.baseUrl.replace(/\/+$/, "")}/aperture/config`,
89
+ {
90
+ method: "GET",
91
+ signal: signal ?? AbortSignal.timeout(5000),
92
+ },
93
+ );
94
+ if (!res.ok) {
95
+ throw new Error(
96
+ `Aperture config request failed: HTTP ${res.status} ${res.statusText}`,
97
+ );
98
+ }
99
+
100
+ const body = (await res.json()) as ConfigResponse;
101
+ const config =
102
+ typeof body.config === "string" ? JSON.parse(body.config) : body.config;
103
+ if (!config || typeof config !== "object") return new Map();
104
+
105
+ const providers = (config as { providers?: unknown }).providers;
106
+ if (!providers || typeof providers !== "object") return new Map();
107
+
108
+ const result = new Map<string, ApertureProviderConfigInfo>();
109
+ for (const [id, provider] of Object.entries(providers)) {
110
+ if (!provider || typeof provider !== "object") continue;
111
+ const record = provider as Record<string, unknown>;
112
+ const baseUrl =
113
+ typeof record.baseurl === "string"
114
+ ? record.baseurl
115
+ : typeof record.baseUrl === "string"
116
+ ? record.baseUrl
117
+ : typeof record.base_url === "string"
118
+ ? record.base_url
119
+ : undefined;
120
+ if (baseUrl) {
121
+ result.set(id, {
122
+ id,
123
+ baseUrl,
124
+ name: typeof record.name === "string" ? record.name : undefined,
125
+ });
126
+ }
127
+ }
128
+ return result;
129
+ }
130
+
131
+ async providerBaseUrls(signal?: AbortSignal): Promise<Map<string, string>> {
132
+ const infos = await this.providerConfigInfos(signal);
133
+ return new Map([...infos].map(([id, info]) => [id, info.baseUrl]));
134
+ }
135
+
136
+ async health(signal?: AbortSignal): Promise<void> {
137
+ await this.providers(signal);
138
+ }
139
+ }
@@ -0,0 +1,26 @@
1
+ export interface ProviderCompatibility {
2
+ openai_chat?: boolean;
3
+ openai_responses?: boolean;
4
+ anthropic_messages?: boolean;
5
+ gemini_generate_content?: boolean;
6
+ google_generate_content?: boolean;
7
+ google_raw_predict?: boolean;
8
+ bedrock_model_invoke?: boolean;
9
+ bedrock_converse?: boolean;
10
+ experimental_gemini_cli_vertex_compat?: boolean;
11
+ }
12
+
13
+ export interface ApertureProviderConfigInfo {
14
+ id: string;
15
+ name?: string;
16
+ baseUrl: string;
17
+ }
18
+
19
+ export interface ApertureProvider {
20
+ id: string;
21
+ name: string;
22
+ description?: string;
23
+ baseUrl?: string;
24
+ models: string[];
25
+ compatibility: ProviderCompatibility;
26
+ }
@@ -0,0 +1,91 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { DedicatedProviderConfig } from "../extensions/aperture/shared/config/loader";
3
+ import type { ApertureProvider, ApertureProviderConfigInfo } from "./api/types";
4
+
5
+ function normalizeProviderBaseUrl(url: string): string[] {
6
+ const normalized = url.replace(/\/+$/, "");
7
+ return [normalized, normalized.replace(/\/v1\/?$/, "")];
8
+ }
9
+
10
+ function isSameOrChildUrl(parent: string, candidate: string): boolean {
11
+ return candidate === parent || candidate.startsWith(`${parent}/`);
12
+ }
13
+
14
+ function hasMatchingBaseUrl(
15
+ baseUrls: string[],
16
+ apertureBaseUrls: Set<string>,
17
+ ): boolean {
18
+ const apertureUrls = [...apertureBaseUrls];
19
+ return baseUrls.some((baseUrl) =>
20
+ normalizeProviderBaseUrl(baseUrl).some((localUrl) =>
21
+ apertureUrls.some((apertureUrl) =>
22
+ isSameOrChildUrl(localUrl, apertureUrl),
23
+ ),
24
+ ),
25
+ );
26
+ }
27
+
28
+ function collectLocalProviders(models: readonly Model<Api>[]) {
29
+ return Array.from(
30
+ models.reduce((providers, model) => {
31
+ if (model.provider === "aperture") return providers;
32
+ const baseUrls = providers.get(model.provider) ?? new Set<string>();
33
+ if (model.baseUrl) baseUrls.add(model.baseUrl);
34
+ providers.set(model.provider, baseUrls);
35
+ return providers;
36
+ }, new Map<string, Set<string>>()),
37
+ )
38
+ .map(([id, baseUrls]) => ({ id, baseUrls: [...baseUrls] }))
39
+ .sort((a, b) => a.id.localeCompare(b.id));
40
+ }
41
+
42
+ export function mapProxyProviders(
43
+ localModels: readonly Model<Api>[],
44
+ providerInfos: Map<string, ApertureProviderConfigInfo>,
45
+ gatewayProviders: ApertureProvider[],
46
+ existingProviders: { id: string; shouldCheckGatewayModels?: boolean }[],
47
+ ) {
48
+ const names = new Map(
49
+ gatewayProviders.map((provider) => [provider.id, provider.name]),
50
+ );
51
+ const apertureBaseUrls = new Set(
52
+ [...providerInfos.values()].flatMap((provider) =>
53
+ normalizeProviderBaseUrl(provider.baseUrl),
54
+ ),
55
+ );
56
+ const existing = new Map(
57
+ existingProviders.map((provider) => [provider.id, provider]),
58
+ );
59
+ const gatewayProviderIds = new Set([
60
+ ...providerInfos.keys(),
61
+ ...gatewayProviders.map((provider) => provider.id),
62
+ ]);
63
+
64
+ return collectLocalProviders(localModels)
65
+ .filter(
66
+ (provider) =>
67
+ hasMatchingBaseUrl(provider.baseUrls, apertureBaseUrls) ||
68
+ gatewayProviderIds.has(provider.id),
69
+ )
70
+ .map((provider) => ({
71
+ ...provider,
72
+ name: names.get(provider.id) ?? providerInfos.get(provider.id)?.name,
73
+ shouldCheckGatewayModels:
74
+ existing.get(provider.id)?.shouldCheckGatewayModels ?? true,
75
+ }));
76
+ }
77
+
78
+ export function mapDedicatedProviders(
79
+ gatewayProviders: ApertureProvider[],
80
+ existingProviders: DedicatedProviderConfig[],
81
+ ): DedicatedProviderConfig[] {
82
+ const existing = new Map(
83
+ existingProviders.map((provider) => [provider.id, provider]),
84
+ );
85
+
86
+ return gatewayProviders.map((provider) => ({
87
+ id: provider.id,
88
+ name: provider.name,
89
+ enabled: existing.get(provider.id)?.enabled ?? true,
90
+ }));
91
+ }
package/src/url.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Pure URL helpers.
3
+ */
4
+
5
+ interface UrlConfig {
6
+ baseUrl?: string;
7
+ }
8
+
9
+ /**
10
+ * Normalizes a user-input URL:
11
+ * - Trims whitespace
12
+ * - Adds http:// scheme if missing
13
+ * - Parses with URL constructor and extracts origin (scheme + host + port)
14
+ * - This handles full URLs like "http://ai.host.ts.net/v1/models" -> "http://ai.host.ts.net"
15
+ * - Also handles bare hosts like "ai.host.ts.net" -> "http://ai.host.ts.net"
16
+ */
17
+ export function normalizeInputUrl(raw: string): string {
18
+ let result = raw.trim();
19
+ if (!result) return result;
20
+ if (!result.startsWith("http://") && !result.startsWith("https://")) {
21
+ result = `http://${result}`;
22
+ }
23
+ try {
24
+ const parsed = new URL(result);
25
+ // Return just the origin (scheme + host + port), discarding path/query/fragment
26
+ return parsed.origin;
27
+ } catch {
28
+ // Fallback for unparseable input: strip /v1 and trailing slashes
29
+ return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Returns configured gateway URL without trailing slash.
35
+ * Returns null when baseUrl is empty.
36
+ */
37
+ export function resolveGatewayUrl(config: UrlConfig): string | null {
38
+ const { baseUrl } = config;
39
+ if (!baseUrl) return null;
40
+ return baseUrl.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
41
+ }
42
+
43
+ /**
44
+ * Returns the Aperture provider base URL used for provider registration.
45
+ * Appends /v1 to the gateway URL.
46
+ * Returns null when gateway URL cannot be resolved.
47
+ */
48
+ export function resolveProviderBaseUrl(config: UrlConfig): string | null {
49
+ const gateway = resolveGatewayUrl(config);
50
+ if (!gateway) return null;
51
+ return `${gateway}/v1`;
52
+ }
@@ -1,135 +0,0 @@
1
- /**
2
- * aperture:settings -- settings UI for Aperture configuration.
3
- *
4
- * Sections:
5
- * - Connection: base URL
6
- * - Providers: list of providers routed through Aperture
7
- */
8
-
9
- import {
10
- ArrayEditor,
11
- registerSettingsCommand,
12
- type SettingsSection,
13
- setNestedValue,
14
- } from "@aliou/pi-utils-settings";
15
- import type {
16
- ExtensionAPI,
17
- ExtensionContext,
18
- } from "@mariozechner/pi-coding-agent";
19
- import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
20
- import type { ApertureConfig, ResolvedConfig } from "../lib/config";
21
- import { configLoader } from "../lib/config";
22
-
23
- export function registerApertureSettings(
24
- pi: ExtensionAPI,
25
- onSync: (ctx: ExtensionContext) => void,
26
- ): void {
27
- registerSettingsCommand<ApertureConfig, ResolvedConfig>(pi, {
28
- commandName: "aperture:settings",
29
- title: "Aperture Settings",
30
- configStore: configLoader,
31
- buildSections: (
32
- tabConfig: ApertureConfig | null,
33
- resolved: ResolvedConfig,
34
- { setDraft },
35
- ): SettingsSection[] => {
36
- const settingsTheme = getSettingsListTheme();
37
-
38
- const providers = tabConfig?.providers ?? resolved.providers;
39
-
40
- const checkGatewayModels: string[] =
41
- tabConfig?.checkGatewayModels ?? resolved.checkGatewayModels;
42
-
43
- return [
44
- {
45
- label: "Connection",
46
- items: [
47
- {
48
- id: "baseUrl",
49
- label: "Base URL",
50
- description:
51
- "Aperture gateway URL on your tailnet (e.g. http://ai.pango-lin.ts.net)",
52
- currentValue:
53
- (tabConfig?.baseUrl ?? resolved.baseUrl) || "(not set)",
54
- values: undefined,
55
- submenu: undefined,
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
- },
90
- ],
91
- },
92
- {
93
- label: "Providers",
94
- items: [
95
- {
96
- id: "providers",
97
- label: "Routed providers",
98
- description: "LLM providers routed through Aperture",
99
- currentValue: `${providers.length} provider(s)`,
100
- submenu: (_val, submenuDone) => {
101
- let latest = [...providers];
102
- return new ArrayEditor({
103
- label: "Providers",
104
- items: [...providers],
105
- theme: settingsTheme,
106
- onSave: (items) => {
107
- latest = items;
108
- const updated = structuredClone(
109
- tabConfig ?? {},
110
- ) as ApertureConfig;
111
- setNestedValue(updated, "providers", items);
112
- setDraft(updated);
113
- },
114
- onDone: () => submenuDone(`${latest.length} provider(s)`),
115
- });
116
- },
117
- },
118
- ],
119
- },
120
- ];
121
- },
122
- onSettingChange: (id, newValue, config) => {
123
- const updated = structuredClone(config);
124
- if (id === "baseUrl") {
125
- updated.baseUrl = newValue;
126
- } else {
127
- setNestedValue(updated, id, newValue);
128
- }
129
- return updated;
130
- },
131
- onSave: (ctx) => {
132
- onSync(ctx);
133
- },
134
- });
135
- }