@aliou/pi-ts-aperture 0.5.0 → 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 (32) 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.ts +0 -122
  29. package/src/index.ts +0 -97
  30. package/src/lib/config.ts +0 -32
  31. package/src/lib/gateway.ts +0 -42
  32. package/src/lib/url.ts +0 -42
package/README.md CHANGED
@@ -1,50 +1,148 @@
1
- ![banner](https://assets.aliou.me/pi-extensions/banners/pi-ts-aperture.png)
1
+ ![banner](https://assets.aliou.me/github/aliou/pi-ts-aperture/banner.png)
2
2
 
3
3
  # pi-ts-aperture
4
4
 
5
5
  Route Pi LLM providers through [Tailscale Aperture](https://tailscale.com/docs/features/aperture), a managed AI gateway on your tailnet.
6
6
 
7
- 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.
7
+ Aperture handles API key injection and request routing server-side. This extension integrates Pi with Aperture using two independent capabilities: **dedicated** (a standalone `aperture` provider) and **proxy** (reroute existing Pi providers). Dedicated is enabled by default, and you can enable proxy at the same time.
8
8
 
9
- ## Setup
9
+ ## Install
10
10
 
11
11
  ```bash
12
12
  pi install npm:@aliou/pi-ts-aperture
13
13
  ```
14
14
 
15
- Then run the setup wizard:
15
+ ## First run
16
+
17
+ After installing, run the onboarding wizard:
16
18
 
17
19
  ```
18
- /aperture:setup
20
+ /aperture:onboarding
19
21
  ```
20
22
 
21
- This prompts for:
22
- 1. Aperture base URL (for example `ai.your-tailnet.ts.net`)
23
- 2. Providers to route through Aperture (fuzzy searchable, multi-select)
23
+ [![Onboarding walkthrough](https://assets.aliou.me/pi-extensions/demos/aperture/v0.6.0/onboarding.gif)](https://assets.aliou.me/pi-extensions/demos/aperture/v0.6.0/onboarding.mp4)
24
24
 
25
- Configuration is saved globally to `~/.pi/agent/extensions/aperture.json`.
25
+ The wizard walks you through:
26
+
27
+ 1. Aperture base URL, with a `/v1/models` health check (e.g. `ai.your-tailnet.ts.net`).
28
+ 2. Capability selection: dedicated, proxy, or both.
29
+ 3. Provider selection:
30
+ - Dedicated: choose which Aperture gateway providers to include.
31
+ - Proxy: choose matching local Pi providers to route through Aperture, with optional gateway model verification.
32
+ 4. Recap, save, and reload Pi so the selected capabilities are registered cleanly.
33
+
34
+ You can change everything later with:
35
+
36
+ ```
37
+ /aperture:settings
38
+ ```
39
+
40
+ ## Capabilities
41
+
42
+ ### Dedicated provider (default)
43
+
44
+ Registers a standalone `aperture` provider whose model list comes from the Aperture gateway. You can include all gateway providers or filter to specific gateway providers during onboarding or in settings.
45
+
46
+ Dedicated model IDs are the model IDs reported by Aperture. They are not prefixed with the upstream provider ID. The extension keeps an internal route map so each model uses the Pi API that matches its Aperture provider compatibility.
47
+
48
+ Because Aperture does not expose every Pi model capability yet, models use safe defaults on first load: 128k context, 8k max output, text input, and no reasoning. Gateway pricing is mapped to Pi costs when Aperture returns pricing data.
49
+
50
+ #### Sync model capabilities
51
+
52
+ In dedicated mode, the onboarding extension can stay enabled until model metadata is synced and validated. It exposes:
53
+
54
+ - `sync-aperture-models` skill: looks up real capabilities such as context window, max tokens, reasoning, and input modalities, then updates `~/.pi/agent/models.json`.
55
+ - `aperture_validate_models_json` tool: validates Pi's `models.json` schema and checks that Aperture models include capability fields.
56
+ - `aperture_complete_onboarding` tool: marks onboarding complete and disables the temporary onboarding tools and skill after validation passes.
57
+
58
+ User-defined model entries in `models.json` take precedence over gateway defaults and persist across restarts. The extension still owns routing details and cost data from Aperture gateway pricing.
59
+
60
+ ### Proxy existing providers
61
+
62
+ Reroutes existing Pi providers (anthropic, openai, openai-codex, etc.) through Aperture. Each provider keeps its own model definitions and settings. Only the base URL, API key, and headers are overridden.
63
+
64
+ Proxy provider selection maps Aperture providers to local Pi registry providers by base URL. It supports child path matching, so an Aperture provider under `https://chatgpt.com/backend-api/codex` can match Pi's local `https://chatgpt.com/backend-api` provider. If base URLs are unavailable, matching also falls back to provider IDs.
65
+
66
+ Proxy mode is useful when you want Pi's native per-provider model configuration but want requests to go through Aperture for server-side credentials and routing.
26
67
 
27
68
  ## Commands
28
69
 
29
70
  | Command | Description |
30
71
  |---|---|
31
- | `/aperture:setup` | Interactive wizard to configure Aperture URL and routed providers |
32
- | `/aperture:settings` | Settings UI to update URL and routed provider list |
72
+ | `/aperture:onboarding` | Onboarding wizard. Only available while onboarding is enabled. |
73
+ | `/aperture:settings` | Settings UI to update connection, capabilities, proxy providers, dedicated provider filters, onboarding status, and the onboarding extension toggle. |
33
74
 
34
75
  ## How it works
35
76
 
36
- For each configured provider, the extension calls `registerProvider` with:
77
+ ### Aperture API usage
78
+
79
+ The extension reads provider data from Aperture using:
80
+
81
+ - `GET /api/providers` for gateway providers and models.
82
+ - `GET /aperture/config` for provider compatibility, names, and base URLs.
83
+
84
+ ### Request routing
85
+
86
+ Requests sent through Aperture include provenance headers:
87
+
88
+ - `Referer: https://pi.dev`
89
+ - `X-Title: npm:@aliou/pi-ts-aperture`
90
+ - `x-session-id` for grouping requests in the Aperture dashboard
91
+
92
+ ### Proxy routing
93
+
94
+ For each configured upstream provider, the extension calls `registerProvider` with:
95
+
96
+ - `baseUrl` set to your Aperture URL + `/v1` for most Pi APIs.
97
+ - `baseUrl` set to the Aperture root for APIs where Pi appends its own path, such as `openai-codex-responses`.
98
+ - `apiKey` set to `"-"` because Aperture injects upstream credentials server-side. OAuth credentials still take precedence when Pi has them.
99
+
100
+ Optional gateway model verification can warn when configured Pi models are missing from the Aperture gateway.
101
+
102
+ ### Dedicated routing
103
+
104
+ Dedicated mode fetches models from Aperture, maps provider compatibility to Pi APIs, merges gateway defaults with user-defined `providers.aperture.models` from `~/.pi/agent/models.json`, and registers an `aperture` provider.
105
+
106
+ Compatibility controls the Pi API and base URL used for each upstream provider at runtime. For example, OpenAI-compatible providers use `/v1`, Anthropic-compatible providers use the gateway root, Gemini-compatible providers use `/v1beta`, and Vertex-compatible providers use `/v1`.
107
+
108
+ User-defined models from `models.json` take precedence over gateway defaults, so custom capabilities such as reasoning, context window, max output, and input modalities are preserved across restarts. If a user model does not define cost, the extension keeps the cost derived from Aperture gateway pricing.
109
+
110
+ ## Configuration
111
+
112
+ Configuration is saved globally to `~/.pi/agent/extensions/aperture.json`.
113
+
114
+ ```json
115
+ {
116
+ "baseUrl": "http://ai.your-tailnet.ts.net",
117
+ "onboardingDone": true,
118
+ "onboarding": {
119
+ "enabled": false
120
+ },
121
+ "proxy": {
122
+ "enabled": true,
123
+ "upstreamProviders": [
124
+ { "id": "anthropic", "shouldCheckGatewayModels": true }
125
+ ]
126
+ },
127
+ "dedicated": {
128
+ "enabled": true,
129
+ "providers": [
130
+ { "id": "anthropic", "name": "Anthropic", "enabled": true },
131
+ { "id": "openai", "name": "OpenAI", "enabled": true },
132
+ { "id": "google", "name": "Google", "enabled": false }
133
+ ]
134
+ }
135
+ }
136
+ ```
37
137
 
38
- - `baseUrl` set to your Aperture URL + `/v1` (OpenAI-compatible surface used by Pi provider configs)
39
- - `apiKey` set to `"-"` (Aperture injects upstream credentials server-side)
40
- - provenance headers:
41
- - `Referer: https://pi.dev`
42
- - `X-Title: npm:@aliou/pi-ts-aperture`
138
+ Notes:
43
139
 
44
- 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.
140
+ - There is no `mode` setting. Use `proxy.enabled` and `dedicated.enabled` independently.
141
+ - An empty `dedicated.providers` list means all Aperture gateway providers are included.
142
+ - Model metadata belongs in `~/.pi/agent/models.json`, not in the extension config.
45
143
 
46
144
  ## Requirements
47
145
 
48
- - A Tailscale tailnet with Aperture configured
49
- - The device running Pi must be on the tailnet (or otherwise able to reach your Aperture endpoint)
50
- - Use the URL/scheme that matches your deployment (`http://` or `https://`)
146
+ - A Tailscale tailnet with Aperture configured.
147
+ - The device running Pi must be on the tailnet, or otherwise able to reach your Aperture endpoint.
148
+ - Use the URL/scheme that matches your deployment (`http://` or `https://`).
@@ -0,0 +1,66 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import { getApiProvider } from "@earendil-works/pi-ai";
3
+ import type { ProviderCompatibility } from "../../../src/api/types";
4
+ import type {
5
+ AssistantMessageEventStream,
6
+ Context,
7
+ SimpleStreamOptions,
8
+ } from "../shared/types";
9
+
10
+ interface ModelRoute {
11
+ api: Api;
12
+ }
13
+
14
+ export function getApiForCompatibility(
15
+ compatibility: ProviderCompatibility | undefined,
16
+ ): Api {
17
+ // Prefer chat completions when available: it is Aperture's default and the
18
+ // broadest compatibility mode for Pi's tool-calling path.
19
+ if (!compatibility || compatibility.openai_chat) return "openai-completions";
20
+ if (compatibility.anthropic_messages) return "anthropic-messages";
21
+ if (compatibility.openai_responses) return "openai-responses";
22
+ if (compatibility.gemini_generate_content) return "google-generative-ai";
23
+ if (compatibility.google_generate_content) return "google-vertex";
24
+ if (compatibility.bedrock_converse) return "bedrock-converse-stream";
25
+ return "openai-completions";
26
+ }
27
+
28
+ export function getBaseUrlForApi(
29
+ api: Api,
30
+ gatewayUrl: string,
31
+ baseUrl: string,
32
+ ): string {
33
+ switch (api) {
34
+ case "anthropic-messages":
35
+ return gatewayUrl;
36
+ case "google-generative-ai":
37
+ return `${gatewayUrl}/v1beta`;
38
+ case "google-vertex":
39
+ return `${gatewayUrl}/v1`;
40
+ default:
41
+ return baseUrl;
42
+ }
43
+ }
44
+
45
+ export function buildStreamSimple(routeByModelId: Map<string, ModelRoute>) {
46
+ return (
47
+ model: Model<Api>,
48
+ context: Context,
49
+ options?: SimpleStreamOptions,
50
+ ): AssistantMessageEventStream => {
51
+ const route = routeByModelId.get(model.id);
52
+ const api = route?.api ?? "openai-completions";
53
+ const provider = getApiProvider(api);
54
+ if (!provider) {
55
+ throw new Error(`Unsupported Aperture provider API: ${api}`);
56
+ }
57
+
58
+ return provider.streamSimple({ ...model, api }, context, {
59
+ ...options,
60
+ headers: {
61
+ ...options?.headers,
62
+ "x-session-id": options?.sessionId ?? "",
63
+ },
64
+ });
65
+ };
66
+ }
@@ -0,0 +1,48 @@
1
+ import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
2
+
3
+ export interface ApertureModelDefaultsInput {
4
+ id: string;
5
+ providerId: string;
6
+ provider?: {
7
+ id: string;
8
+ name?: string;
9
+ };
10
+ pricing?: {
11
+ input?: string;
12
+ input_cache_read?: string;
13
+ input_cache_write?: string;
14
+ output?: string;
15
+ };
16
+ }
17
+
18
+ const TOKENS_PER_MILLION = 1_000_000;
19
+
20
+ function parsePrice(value: string | undefined): number {
21
+ if (!value) return 0;
22
+ const n = Number(value);
23
+ return Number.isFinite(n) ? n * TOKENS_PER_MILLION : 0;
24
+ }
25
+
26
+ export function buildDefaultModelConfig(
27
+ model: ApertureModelDefaultsInput,
28
+ ): ProviderModelConfig {
29
+ const id = model.id;
30
+ const cost = model.pricing
31
+ ? {
32
+ input: parsePrice(model.pricing.input),
33
+ output: parsePrice(model.pricing.output),
34
+ cacheRead: parsePrice(model.pricing.input_cache_read),
35
+ cacheWrite: parsePrice(model.pricing.input_cache_write),
36
+ }
37
+ : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
38
+
39
+ return {
40
+ id,
41
+ name: id,
42
+ reasoning: false,
43
+ input: ["text"],
44
+ cost,
45
+ contextWindow: 128_000,
46
+ maxTokens: 8_192,
47
+ };
48
+ }
@@ -0,0 +1,87 @@
1
+ import type { Api } from "@earendil-works/pi-ai";
2
+ import type {
3
+ ExtensionAPI,
4
+ ProviderModelConfig,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { ApertureClient } from "../../../src/api/client";
7
+ import type { ApertureProvider } from "../../../src/api/types";
8
+ import { resolveGatewayUrl, resolveProviderBaseUrl } from "../../../src/url";
9
+ import { configLoader, type ResolvedConfig } from "../shared/config/loader";
10
+ import {
11
+ buildStreamSimple,
12
+ getApiForCompatibility,
13
+ getBaseUrlForApi,
14
+ } from "./api-routing";
15
+ import { buildDefaultModelConfig } from "./model-defaults";
16
+
17
+ const PROVIDER_NAME = "aperture";
18
+ const APERTURE_API = "aperture";
19
+
20
+ const HEADERS = {
21
+ Referer: "https://pi.dev",
22
+ "X-Title": "npm:@aliou/pi-ts-aperture",
23
+ };
24
+
25
+ function filterProviders(
26
+ providers: ApertureProvider[],
27
+ config: ResolvedConfig,
28
+ ): ApertureProvider[] {
29
+ const selected = new Set(
30
+ config.dedicated.providers.filter((p) => p.enabled).map((p) => p.id),
31
+ );
32
+ return config.dedicated.providers.length > 0
33
+ ? providers.filter((provider) => selected.has(provider.id))
34
+ : providers;
35
+ }
36
+
37
+ export class DedicatedRuntime {
38
+ async sync(pi: Pick<ExtensionAPI, "registerProvider">): Promise<void> {
39
+ const config = configLoader.getConfig();
40
+ await this.syncConfig(pi, config);
41
+ }
42
+
43
+ async syncConfig(
44
+ pi: Pick<ExtensionAPI, "registerProvider">,
45
+ config: ResolvedConfig,
46
+ ): Promise<void> {
47
+ if (!config.dedicated.enabled) return;
48
+
49
+ const gatewayUrl = resolveGatewayUrl(config);
50
+ const baseUrl = resolveProviderBaseUrl(config);
51
+ if (!gatewayUrl || !baseUrl) return;
52
+
53
+ const providers = filterProviders(
54
+ await new ApertureClient(gatewayUrl).providers(),
55
+ config,
56
+ );
57
+ const routeByModelId = new Map<string, { api: Api }>();
58
+ const models: ProviderModelConfig[] = [];
59
+
60
+ for (const provider of providers) {
61
+ const api = getApiForCompatibility(provider.compatibility);
62
+ for (const modelId of provider.models) {
63
+ routeByModelId.set(modelId, { api });
64
+ models.push({
65
+ ...buildDefaultModelConfig({
66
+ id: modelId,
67
+ providerId: provider.id,
68
+ provider: { id: provider.id, name: provider.name },
69
+ }),
70
+ api: APERTURE_API,
71
+ baseUrl: getBaseUrlForApi(api, gatewayUrl, baseUrl),
72
+ });
73
+ }
74
+ }
75
+
76
+ if (models.length === 0) return;
77
+
78
+ pi.registerProvider(PROVIDER_NAME, {
79
+ baseUrl,
80
+ apiKey: "-",
81
+ api: APERTURE_API,
82
+ headers: HEADERS,
83
+ models,
84
+ streamSimple: buildStreamSimple(routeByModelId),
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,78 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { DedicatedRuntime } from "./dedicated/runtime";
6
+ import { registerOnboarding } from "./onboarding";
7
+ import { ApertureRuntime } from "./proxy/runtime";
8
+ import { registerApertureSettings } from "./settings-command";
9
+ import { configLoader } from "./shared/config/loader";
10
+ import { emitConfigSync } from "./shared/sync-bus";
11
+
12
+ export default async function (pi: ExtensionAPI): Promise<void> {
13
+ await configLoader.load();
14
+
15
+ const proxyRuntime = new ApertureRuntime();
16
+ const dedicatedRuntime = new DedicatedRuntime();
17
+ let lastProxyProviders = configLoader
18
+ .getConfig()
19
+ .proxy.upstreamProviders.map((p) => p.id);
20
+ let knownModels = [] as ReturnType<
21
+ ExtensionContext["modelRegistry"]["getAll"]
22
+ >;
23
+
24
+ const updateKnownModels = (ctx: ExtensionContext): void => {
25
+ knownModels = ctx.modelRegistry.getAll();
26
+ };
27
+
28
+ const onSync = (ctx: ExtensionContext): void => {
29
+ updateKnownModels(ctx);
30
+ emitConfigSync();
31
+ const config = configLoader.getConfig();
32
+
33
+ const nextProxyProviders = config.proxy.enabled
34
+ ? config.proxy.upstreamProviders.map((p) => p.id)
35
+ : [];
36
+ for (const provider of proxyRuntime.getProvidersToUnregister(
37
+ lastProxyProviders,
38
+ nextProxyProviders,
39
+ )) {
40
+ pi.unregisterProvider(provider);
41
+ ctx.ui.notify(`[aperture] unregistered ${provider}.`, "info");
42
+ }
43
+ lastProxyProviders = nextProxyProviders;
44
+
45
+ void proxyRuntime
46
+ .sync({
47
+ registerProvider: pi.registerProvider.bind(pi),
48
+ getModels: () => ctx.modelRegistry.getAll(),
49
+ })
50
+ .then(() => {
51
+ const active = ctx.model;
52
+ if (!active) return;
53
+ const updated = ctx.modelRegistry.find(active.provider, active.id);
54
+ if (updated && nextProxyProviders.includes(active.provider)) {
55
+ void pi.setModel(updated);
56
+ }
57
+ });
58
+
59
+ void proxyRuntime.checkMissingModels({
60
+ getModels: () => ctx.modelRegistry.getAll(),
61
+ notify: (msg, type) => ctx.ui.notify(msg, type),
62
+ });
63
+
64
+ void dedicatedRuntime.sync(pi).catch((error: unknown) => {
65
+ ctx.ui.notify(
66
+ `[aperture] dedicated sync failed: ${error instanceof Error ? error.message : String(error)}`,
67
+ "warning",
68
+ );
69
+ });
70
+ };
71
+
72
+ pi.on("session_start", (_event, ctx) => {
73
+ onSync(ctx);
74
+ });
75
+
76
+ registerApertureSettings(pi, onSync, () => knownModels);
77
+ registerOnboarding(pi);
78
+ }
@@ -0,0 +1,25 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { configLoader } from "../shared/config/loader";
3
+ import {
4
+ isOnboardingExtensionEnabled,
5
+ isOnboardingPending,
6
+ } from "./onboarding";
7
+ import { registerOnboardingCommand } from "./setup-command";
8
+
9
+ export function registerOnboarding(pi: ExtensionAPI): void {
10
+ const globalConfig = configLoader.getRawConfig("global");
11
+ if (!isOnboardingExtensionEnabled(globalConfig)) return;
12
+
13
+ pi.on("session_start", (_event, ctx) => {
14
+ if (isOnboardingPending(configLoader.getRawConfig("global"))) {
15
+ ctx.ui.notify(
16
+ "[aperture] extension installed. Run /aperture:onboarding to configure.",
17
+ "info",
18
+ );
19
+ }
20
+ });
21
+
22
+ if (isOnboardingPending(globalConfig)) {
23
+ registerOnboardingCommand(pi);
24
+ }
25
+ }