@aliou/pi-ts-aperture 0.4.0 → 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.4.0",
4
+ "version": "0.5.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -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",
@@ -129,7 +129,7 @@ export function registerApertureSettings(
129
129
  return updated;
130
130
  },
131
131
  onSave: (ctx) => {
132
- onConfigChange(ctx);
132
+ onSync(ctx);
133
133
  },
134
134
  });
135
135
  }
@@ -20,9 +20,9 @@ import type {
20
20
  } from "@mariozechner/pi-coding-agent";
21
21
  import type { Component, TUI } from "@mariozechner/pi-tui";
22
22
  import { Input } from "@mariozechner/pi-tui";
23
- import { configLoader } from "../config";
24
- import { normalizeInputUrl } from "../core";
25
- import { checkApertureHealth } from "../lib/health";
23
+ import { configLoader } from "../lib/config";
24
+ import { checkApertureHealth } from "../lib/gateway";
25
+ import { normalizeInputUrl } from "../lib/url";
26
26
 
27
27
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
28
 
@@ -133,7 +133,7 @@ class UrlStep implements Component {
133
133
 
134
134
  export function registerSetupCommand(
135
135
  pi: ExtensionAPI,
136
- onConfigChange: (ctx: ExtensionContext) => void,
136
+ onSync: (ctx: ExtensionContext) => void,
137
137
  ): void {
138
138
  pi.registerCommand("aperture:setup", {
139
139
  description: "Configure Tailscale Aperture integration",
@@ -222,7 +222,7 @@ export function registerSetupCommand(
222
222
  providers,
223
223
  checkGatewayModels,
224
224
  });
225
- onConfigChange(ctx);
225
+ onSync(ctx);
226
226
  ctx.ui.notify(
227
227
  `Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
228
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,138 +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 { planConfigChange, resolveProviderBaseUrl } from "./core";
18
- import {
19
- applyAperture,
20
- checkGatewayModels,
21
- refreshActiveModel,
22
- } from "./providers/aperture";
16
+ import { ApertureRuntime } from "./extension/runtime";
17
+ import { configLoader } from "./lib/config";
18
+ import { resolveGatewayUrl } from "./lib/url";
23
19
 
24
- function notifyMissingModelsOnce(
25
- ctx: ExtensionContext,
26
- missingModels: string[],
27
- warnedModels: Set<string>,
28
- ): void {
29
- const newMissing = missingModels.filter((id) => !warnedModels.has(id));
30
- if (newMissing.length > 0) {
31
- for (const id of newMissing) warnedModels.add(id);
32
- ctx.ui.notify(
33
- `[aperture] models not available on gateway: ${newMissing.join(", ")}. Add them to the gateway configuration.`,
34
- "warning",
35
- );
36
- }
37
- }
38
-
39
- function registerApertureLifecycleHook(
40
- pi: ExtensionAPI,
41
- warnedModels: Set<string>,
42
- ): void {
43
- pi.on("before_agent_start", async (_event, ctx) => {
44
- if (!ctx?.modelRegistry) return;
45
-
46
- const { providers: overriddenProviders, gatewayUrl } = await applyAperture(
47
- pi,
48
- ctx.modelRegistry,
49
- );
50
-
51
- if (
52
- ctx.model &&
53
- overriddenProviders.includes(ctx.model.provider) &&
54
- gatewayUrl !== null &&
55
- configLoader.getConfig().checkGatewayModels.includes(ctx.model.provider)
56
- ) {
57
- const { missingModels } = await checkGatewayModels(
58
- gatewayUrl,
59
- ctx.modelRegistry,
60
- );
61
- notifyMissingModelsOnce(ctx, missingModels, warnedModels);
62
- }
63
-
64
- if (!ctx.model || !overriddenProviders.includes(ctx.model.provider)) return;
65
-
66
- await refreshActiveModel(pi, ctx);
67
- });
20
+ export default async function (pi: ExtensionAPI): Promise<void> {
21
+ await configLoader.load();
68
22
 
69
- // Also check when user switches to a model whose provider uses aperture
70
- pi.on("model_select", async (_event, ctx) => {
71
- if (!ctx?.model) return;
23
+ const runtime = new ApertureRuntime();
24
+ let lastRegisteredProviders: string[] = [
25
+ ...configLoader.getConfig().providers,
26
+ ];
72
27
 
28
+ // Sync function used by commands after config changes
29
+ const onSync = (ctx: ExtensionContext): void => {
73
30
  const config = configLoader.getConfig();
74
- if (!config.providers.includes(ctx.model.provider)) return;
75
31
 
76
- const gatewayUrl = resolveProviderBaseUrl(config)?.replace("/v1", "");
77
- if (!gatewayUrl) return;
78
-
79
- if (config.checkGatewayModels.includes(ctx.model.provider)) {
80
- const { missingModels } = await checkGatewayModels(
81
- gatewayUrl,
82
- ctx.modelRegistry,
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);
41
+ ctx.ui.notify(
42
+ `[aperture] unregistered ${provider}. Run /reload to use the native provider.`,
43
+ "info",
83
44
  );
84
- notifyMissingModelsOnce(ctx, missingModels, warnedModels);
85
45
  }
86
- });
87
- }
88
-
89
- function createConfigChangeHandler(
90
- pi: ExtensionAPI,
91
- warnedModels: Set<string>,
92
- ): (ctx: ExtensionContext) => void {
93
- let lastRegisteredProviders = [...configLoader.getConfig().providers];
94
-
95
- return (ctx: ExtensionContext) => {
96
- const { providers } = configLoader.getConfig();
97
-
98
- const plan = planConfigChange(
99
- lastRegisteredProviders,
100
- providers,
101
- ctx.model?.provider,
102
- );
103
46
 
104
- void applyAperture(pi, ctx.modelRegistry).then(
105
- async ({ providers, gatewayUrl }) => {
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
106
55
  if (
107
56
  ctx.model &&
108
- providers.includes(ctx.model.provider) &&
109
- gatewayUrl !== null &&
110
- configLoader
111
- .getConfig()
112
- .checkGatewayModels.includes(ctx.model.provider)
57
+ ctx.modelRegistry.find(ctx.model.provider, ctx.model.id)
113
58
  ) {
114
- const { missingModels } = await checkGatewayModels(
115
- gatewayUrl,
116
- ctx.modelRegistry,
59
+ const updated = ctx.modelRegistry.find(
60
+ ctx.model.provider,
61
+ ctx.model.id,
117
62
  );
118
- notifyMissingModelsOnce(ctx, missingModels, warnedModels);
63
+ if (updated && config.providers.includes(ctx.model.provider)) {
64
+ void pi.setModel(updated);
65
+ }
119
66
  }
120
- },
121
- );
122
- lastRegisteredProviders = [...providers];
67
+ });
123
68
 
124
- if (plan.shouldRefreshModel) {
125
- void refreshActiveModel(pi, ctx).then((updated) => {
126
- if (!updated) return;
127
- ctx.ui.notify(
128
- `[aperture] re-routing ${ctx.model?.id ?? "model"} through ${ctx.model?.baseUrl ?? "aperture"}`,
129
- "info",
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,
130
79
  );
131
- });
80
+ }
132
81
  }
133
82
 
134
- for (const provider of plan.removedProviders) {
135
- pi.unregisterProvider(provider);
136
- }
83
+ lastRegisteredProviders = [...nextProviders];
137
84
  };
138
- }
139
-
140
- export default async function (pi: ExtensionAPI): Promise<void> {
141
- await configLoader.load();
142
85
 
143
- const warnedModels = new Set<string>();
144
-
145
- registerApertureLifecycleHook(pi, warnedModels);
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
+ });
146
94
 
147
- const onConfigChange = createConfigChangeHandler(pi, warnedModels);
148
- registerSetupCommand(pi, onConfigChange);
149
- registerApertureSettings(pi, onConfigChange);
95
+ registerSetupCommand(pi, onSync);
96
+ registerApertureSettings(pi, onSync);
150
97
  }
@@ -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
+ }
@@ -2,7 +2,7 @@
2
2
  * Pure URL helpers.
3
3
  */
4
4
 
5
- import type { ApertureConfig } from "./types";
5
+ import type { ApertureConfig } from "./config";
6
6
 
7
7
  /**
8
8
  * Normalizes a user-input URL:
@@ -26,7 +26,7 @@ export function normalizeInputUrl(raw: string): string {
26
26
  */
27
27
  export function resolveGatewayUrl(config: ApertureConfig): string | null {
28
28
  const { baseUrl, providers } = config;
29
- if (!baseUrl || providers.length === 0) return null;
29
+ if (!baseUrl || providers?.length === 0) return null;
30
30
  return baseUrl.replace(/\/+$/, "");
31
31
  }
32
32
 
package/src/core/index.ts DELETED
@@ -1,7 +0,0 @@
1
- /**
2
- * Core module exports -- pure functions and data types.
3
- */
4
-
5
- export * from "./plan";
6
- export * from "./types";
7
- export * from "./url";
@@ -1,253 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- APERTURE_PROVENANCE_HEADERS,
4
- buildApplyPlan,
5
- planConfigChange,
6
- resolveProviderHeaders,
7
- } from "./plan";
8
- import type { ApertureConfig, Api, Model } from "./types";
9
-
10
- describe("resolveProviderHeaders", () => {
11
- it("includes provenance headers", () => {
12
- const models: Model<Api>[] = [{ id: "gpt-4", provider: "openai" }];
13
- const headers = resolveProviderHeaders(models);
14
- expect(headers).toMatchObject(APERTURE_PROVENANCE_HEADERS);
15
- });
16
-
17
- it("merges model headers when present", () => {
18
- const models: Model<Api>[] = [
19
- { id: "gpt-4", provider: "openai", headers: { "X-Custom": "value" } },
20
- ];
21
- const headers = resolveProviderHeaders(models);
22
- expect(headers).toEqual({
23
- ...APERTURE_PROVENANCE_HEADERS,
24
- "X-Custom": "value",
25
- });
26
- });
27
-
28
- it("model headers take precedence over provenance headers", () => {
29
- const models: Model<Api>[] = [
30
- { id: "gpt-4", provider: "openai", headers: { Referer: "custom" } },
31
- ];
32
- const headers = resolveProviderHeaders(models);
33
- expect(headers.Referer).toBe("custom");
34
- });
35
-
36
- it("uses first model with headers", () => {
37
- const models: Model<Api>[] = [
38
- { id: "gpt-4", provider: "openai" },
39
- { id: "gpt-3", provider: "openai", headers: { "X-Auth": "token" } },
40
- { id: "gpt-4o", provider: "openai", headers: { "X-Other": "other" } },
41
- ];
42
- const headers = resolveProviderHeaders(models);
43
- expect(headers["X-Auth"]).toBe("token");
44
- expect(headers["X-Other"]).toBeUndefined();
45
- });
46
-
47
- it("returns only provenance headers when no model has headers", () => {
48
- const models: Model<Api>[] = [
49
- { id: "gpt-4", provider: "openai" },
50
- { id: "gpt-3", provider: "openai" },
51
- ];
52
- const headers = resolveProviderHeaders(models);
53
- expect(headers).toEqual(APERTURE_PROVENANCE_HEADERS);
54
- });
55
- });
56
-
57
- describe("buildApplyPlan", () => {
58
- const baseConfig: ApertureConfig = {
59
- baseUrl: "https://aperture.example.com",
60
- providers: ["openai", "anthropic"],
61
- };
62
-
63
- const baseUrl = "https://aperture.example.com/v1";
64
-
65
- it("returns empty registrations for empty config", () => {
66
- const config: ApertureConfig = { baseUrl: "", providers: [] };
67
- const plan = buildApplyPlan(config, [], baseUrl, []);
68
- expect(plan.registrations).toEqual([]);
69
- expect(plan.missingModels).toEqual([]);
70
- });
71
-
72
- it("skips providers with no models in registry", () => {
73
- const registryModels: Model<Api>[] = [{ id: "gpt-4", provider: "openai" }];
74
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
75
- expect(plan.registrations).toHaveLength(1);
76
- expect(plan.registrations[0].provider).toBe("openai");
77
- });
78
-
79
- it("creates registrations for configured providers with models", () => {
80
- const registryModels: Model<Api>[] = [
81
- { id: "gpt-4", provider: "openai", api: "openai-completions" },
82
- { id: "claude-3", provider: "anthropic", api: "anthropic-messages" },
83
- ];
84
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
85
- expect(plan.registrations).toHaveLength(2);
86
- expect(plan.registrations.map((r) => r.provider)).toContain("openai");
87
- expect(plan.registrations.map((r) => r.provider)).toContain("anthropic");
88
- });
89
-
90
- it("registration has correct baseUrl", () => {
91
- const registryModels: Model<Api>[] = [{ id: "gpt-4", provider: "openai" }];
92
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
93
- expect(plan.registrations[0].baseUrl).toBe(baseUrl);
94
- });
95
-
96
- it("registration has apiKey set to dash", () => {
97
- const registryModels: Model<Api>[] = [{ id: "gpt-4", provider: "openai" }];
98
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
99
- expect(plan.registrations[0].apiKey).toBe("-");
100
- });
101
-
102
- it("registration includes merged headers", () => {
103
- const registryModels: Model<Api>[] = [
104
- { id: "gpt-4", provider: "openai", headers: { "X-Custom": "value" } },
105
- ];
106
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
107
- expect(plan.registrations[0].headers).toEqual({
108
- ...APERTURE_PROVENANCE_HEADERS,
109
- "X-Custom": "value",
110
- });
111
- });
112
-
113
- it("registration uses first model's api", () => {
114
- const registryModels: Model<Api>[] = [
115
- { id: "gpt-4", provider: "openai", api: "openai-completions" },
116
- { id: "gpt-3", provider: "openai", api: "openai-chat" },
117
- ];
118
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
119
- expect(plan.registrations[0].api).toBe("openai-completions");
120
- });
121
-
122
- it("registration defaults api when not specified", () => {
123
- const registryModels: Model<Api>[] = [{ id: "gpt-4", provider: "openai" }];
124
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
125
- expect(plan.registrations[0].api).toBe("openai-completions");
126
- });
127
-
128
- it("registration includes all models for provider", () => {
129
- const registryModels: Model<Api>[] = [
130
- { id: "gpt-4", provider: "openai" },
131
- { id: "gpt-3", provider: "openai" },
132
- { id: "gpt-4o", provider: "openai" },
133
- ];
134
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
135
- expect(plan.registrations[0].models).toHaveLength(3);
136
- });
137
-
138
- it("computes missing models when gateway IDs provided", () => {
139
- const registryModels: Model<Api>[] = [
140
- { id: "gpt-4", provider: "openai" },
141
- { id: "gpt-3", provider: "openai" },
142
- ];
143
- const gatewayIds = ["gpt-4"];
144
- const plan = buildApplyPlan(
145
- baseConfig,
146
- registryModels,
147
- baseUrl,
148
- gatewayIds,
149
- );
150
- expect(plan.missingModels).toEqual(["gpt-3"]);
151
- });
152
-
153
- it("missingModels is empty when gateway IDs empty", () => {
154
- const registryModels: Model<Api>[] = [{ id: "gpt-4", provider: "openai" }];
155
- const plan = buildApplyPlan(baseConfig, registryModels, baseUrl, []);
156
- expect(plan.missingModels).toEqual([]);
157
- });
158
-
159
- it("missingModels is empty when all models present on gateway", () => {
160
- const registryModels: Model<Api>[] = [
161
- { id: "gpt-4", provider: "openai" },
162
- { id: "gpt-3", provider: "openai" },
163
- ];
164
- const gatewayIds = ["gpt-4", "gpt-3"];
165
- const plan = buildApplyPlan(
166
- baseConfig,
167
- registryModels,
168
- baseUrl,
169
- gatewayIds,
170
- );
171
- expect(plan.missingModels).toEqual([]);
172
- });
173
-
174
- it("only checks missing models for configured providers", () => {
175
- const config: ApertureConfig = {
176
- baseUrl: "https://aperture.example.com",
177
- providers: ["openai"],
178
- };
179
- const registryModels: Model<Api>[] = [
180
- { id: "gpt-4", provider: "openai" },
181
- { id: "claude-3", provider: "anthropic" },
182
- ];
183
- const gatewayIds: string[] = [];
184
- const plan = buildApplyPlan(config, registryModels, baseUrl, gatewayIds);
185
- // Only openai models are considered, but since gatewayIds is empty, missingModels is empty
186
- expect(plan.missingModels).toEqual([]);
187
- });
188
- });
189
-
190
- describe("planConfigChange", () => {
191
- it("detects removed providers", () => {
192
- const prev = ["openai", "anthropic"];
193
- const next = ["openai"];
194
- const plan = planConfigChange(prev, next);
195
- expect(plan.removedProviders).toEqual(["anthropic"]);
196
- });
197
-
198
- it("returns empty removedProviders when no providers removed", () => {
199
- const prev = ["openai", "anthropic"];
200
- const next = ["openai", "anthropic", "google"];
201
- const plan = planConfigChange(prev, next);
202
- expect(plan.removedProviders).toEqual([]);
203
- });
204
-
205
- it("returns empty removedProviders when providers unchanged", () => {
206
- const prev = ["openai", "anthropic"];
207
- const next = ["openai", "anthropic"];
208
- const plan = planConfigChange(prev, next);
209
- expect(plan.removedProviders).toEqual([]);
210
- });
211
-
212
- it("detects all providers removed", () => {
213
- const prev = ["openai", "anthropic"];
214
- const next: string[] = [];
215
- const plan = planConfigChange(prev, next);
216
- expect(plan.removedProviders).toEqual(["openai", "anthropic"]);
217
- });
218
-
219
- it("shouldRefreshModel is true when active model provider is in next providers", () => {
220
- const prev = ["openai"];
221
- const next = ["openai", "anthropic"];
222
- const plan = planConfigChange(prev, next, "openai");
223
- expect(plan.shouldRefreshModel).toBe(true);
224
- });
225
-
226
- it("shouldRefreshModel is false when active model provider was removed", () => {
227
- const prev = ["openai", "anthropic"];
228
- const next = ["openai"];
229
- const plan = planConfigChange(prev, next, "anthropic");
230
- expect(plan.shouldRefreshModel).toBe(false);
231
- });
232
-
233
- it("shouldRefreshModel is false when no active model", () => {
234
- const prev = ["openai"];
235
- const next = ["openai", "anthropic"];
236
- const plan = planConfigChange(prev, next);
237
- expect(plan.shouldRefreshModel).toBe(false);
238
- });
239
-
240
- it("shouldRefreshModel is true when adding provider that matches active model", () => {
241
- const prev: string[] = [];
242
- const next = ["openai"];
243
- const plan = planConfigChange(prev, next, "openai");
244
- expect(plan.shouldRefreshModel).toBe(true);
245
- });
246
-
247
- it("shouldRefreshModel is false when active model provider not in next", () => {
248
- const prev = ["openai"];
249
- const next = ["anthropic"];
250
- const plan = planConfigChange(prev, next, "openai");
251
- expect(plan.shouldRefreshModel).toBe(false);
252
- });
253
- });
package/src/core/plan.ts DELETED
@@ -1,107 +0,0 @@
1
- /**
2
- * Core decision logic -- all pure functions.
3
- */
4
-
5
- import type {
6
- ApertureConfig,
7
- Api,
8
- ApplyPlan,
9
- ConfigChangePlan,
10
- Model,
11
- ProviderRegistration,
12
- } from "./types";
13
-
14
- /**
15
- * Preserve provenance similarly to pi-synthetic so downstream providers can
16
- * attribute traffic to Pi / this extension.
17
- */
18
- export const APERTURE_PROVENANCE_HEADERS = {
19
- Referer: "https://pi.dev",
20
- "X-Title": "npm:@aliou/pi-ts-aperture",
21
- };
22
-
23
- /**
24
- * Resolves headers for a provider registration.
25
- * Merges provenance headers with the first model's headers (if any).
26
- */
27
- export function resolveProviderHeaders(
28
- models: Model<Api>[],
29
- ): Record<string, string> {
30
- const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
31
- return {
32
- ...APERTURE_PROVENANCE_HEADERS,
33
- ...modelHeaders,
34
- };
35
- }
36
-
37
- /**
38
- * Builds a plan for applying Aperture configuration.
39
- *
40
- * Groups registry models by configured provider, builds registrations,
41
- * and computes missing models (if gateway model IDs are provided).
42
- *
43
- * Providers with no models in the registry are skipped (nothing to reroute).
44
- */
45
- export function buildApplyPlan(
46
- config: ApertureConfig,
47
- registryModels: Model<Api>[],
48
- providerBaseUrl: string,
49
- gatewayModelIds: string[],
50
- ): ApplyPlan {
51
- const { providers } = config;
52
-
53
- const registrations: ProviderRegistration[] = [];
54
-
55
- for (const provider of providers) {
56
- const existingModels = registryModels.filter(
57
- (m) => m.provider === provider,
58
- );
59
-
60
- if (existingModels.length === 0) continue;
61
-
62
- registrations.push({
63
- provider,
64
- baseUrl: providerBaseUrl,
65
- apiKey: "-",
66
- headers: resolveProviderHeaders(existingModels),
67
- api: existingModels[0].api ?? "openai-completions",
68
- models: existingModels,
69
- });
70
- }
71
-
72
- let missingModels: string[] = [];
73
- if (gatewayModelIds.length > 0) {
74
- const routedModelIds = registryModels
75
- .filter((m) => providers.includes(m.provider))
76
- .map((m) => m.id);
77
- missingModels = routedModelIds.filter(
78
- (id) => !gatewayModelIds.includes(id),
79
- );
80
- }
81
-
82
- return { registrations, missingModels };
83
- }
84
-
85
- /**
86
- * Plans the effects of a configuration change.
87
- *
88
- * @param prevProviders - Providers that were previously configured
89
- * @param nextProviders - Providers that are now configured
90
- * @param activeModelProvider - Provider of the currently active model (if any)
91
- * @returns ConfigChangePlan with removed providers and refresh decision
92
- */
93
- export function planConfigChange(
94
- prevProviders: string[],
95
- nextProviders: string[],
96
- activeModelProvider?: string,
97
- ): ConfigChangePlan {
98
- const removedProviders = prevProviders.filter(
99
- (provider) => !nextProviders.includes(provider),
100
- );
101
-
102
- const shouldRefreshModel =
103
- activeModelProvider !== undefined &&
104
- nextProviders.includes(activeModelProvider);
105
-
106
- return { removedProviders, shouldRefreshModel };
107
- }
package/src/core/types.ts DELETED
@@ -1,33 +0,0 @@
1
- /**
2
- * Plain data types used by core functions.
3
- * Model is re-exported from @mariozechner/pi-ai for internal use.
4
- */
5
- import type { Api, Model } from "@mariozechner/pi-ai";
6
-
7
- export interface ApertureConfig {
8
- baseUrl: string;
9
- providers: string[];
10
- checkGatewayModels: string[];
11
- }
12
-
13
- export interface ProviderRegistration {
14
- provider: string;
15
- baseUrl: string;
16
- apiKey: string;
17
- headers: Record<string, string>;
18
- api: string;
19
- models: Model<Api>[];
20
- }
21
-
22
- // Re-export Model for use in other core files
23
- export type { Model, Api };
24
-
25
- export interface ApplyPlan {
26
- registrations: ProviderRegistration[];
27
- missingModels: string[];
28
- }
29
-
30
- export interface ConfigChangePlan {
31
- removedProviders: string[];
32
- shouldRefreshModel: boolean;
33
- }
@@ -1,130 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import type { ApertureConfig } from "./types";
3
- import {
4
- normalizeInputUrl,
5
- resolveGatewayUrl,
6
- resolveProviderBaseUrl,
7
- } from "./url";
8
-
9
- describe("normalizeInputUrl", () => {
10
- it("returns empty string for empty input", () => {
11
- expect(normalizeInputUrl("")).toBe("");
12
- });
13
-
14
- it("trims whitespace", () => {
15
- expect(normalizeInputUrl(" https://example.com ")).toBe(
16
- "https://example.com",
17
- );
18
- });
19
-
20
- it("adds http:// scheme when missing", () => {
21
- expect(normalizeInputUrl("example.com")).toBe("http://example.com");
22
- });
23
-
24
- it("preserves https:// scheme", () => {
25
- expect(normalizeInputUrl("https://example.com")).toBe(
26
- "https://example.com",
27
- );
28
- });
29
-
30
- it("preserves http:// scheme", () => {
31
- expect(normalizeInputUrl("http://example.com")).toBe("http://example.com");
32
- });
33
-
34
- it("strips trailing /v1", () => {
35
- expect(normalizeInputUrl("https://example.com/v1")).toBe(
36
- "https://example.com",
37
- );
38
- });
39
-
40
- it("strips trailing /v1/", () => {
41
- expect(normalizeInputUrl("https://example.com/v1/")).toBe(
42
- "https://example.com",
43
- );
44
- });
45
-
46
- it("strips trailing slashes", () => {
47
- expect(normalizeInputUrl("https://example.com/")).toBe(
48
- "https://example.com",
49
- );
50
- });
51
-
52
- it("strips multiple trailing slashes", () => {
53
- expect(normalizeInputUrl("https://example.com///")).toBe(
54
- "https://example.com",
55
- );
56
- });
57
-
58
- it("handles already-normalized URL", () => {
59
- expect(normalizeInputUrl("https://example.com")).toBe(
60
- "https://example.com",
61
- );
62
- });
63
- });
64
-
65
- describe("resolveGatewayUrl", () => {
66
- it("returns null when baseUrl is empty", () => {
67
- const config: ApertureConfig = { baseUrl: "", providers: ["openai"] };
68
- expect(resolveGatewayUrl(config)).toBeNull();
69
- });
70
-
71
- it("returns null when providers is empty", () => {
72
- const config: ApertureConfig = {
73
- baseUrl: "https://example.com",
74
- providers: [],
75
- };
76
- expect(resolveGatewayUrl(config)).toBeNull();
77
- });
78
-
79
- it("returns null when both baseUrl and providers are empty", () => {
80
- const config: ApertureConfig = { baseUrl: "", providers: [] };
81
- expect(resolveGatewayUrl(config)).toBeNull();
82
- });
83
-
84
- it("returns URL without trailing slash", () => {
85
- const config: ApertureConfig = {
86
- baseUrl: "https://example.com/",
87
- providers: ["openai"],
88
- };
89
- expect(resolveGatewayUrl(config)).toBe("https://example.com");
90
- });
91
-
92
- it("returns URL as-is when no trailing slash", () => {
93
- const config: ApertureConfig = {
94
- baseUrl: "https://example.com",
95
- providers: ["openai"],
96
- };
97
- expect(resolveGatewayUrl(config)).toBe("https://example.com");
98
- });
99
-
100
- it("strips multiple trailing slashes", () => {
101
- const config: ApertureConfig = {
102
- baseUrl: "https://example.com///",
103
- providers: ["openai"],
104
- };
105
- expect(resolveGatewayUrl(config)).toBe("https://example.com");
106
- });
107
- });
108
-
109
- describe("resolveProviderBaseUrl", () => {
110
- it("returns null when gateway URL cannot be resolved", () => {
111
- const config: ApertureConfig = { baseUrl: "", providers: ["openai"] };
112
- expect(resolveProviderBaseUrl(config)).toBeNull();
113
- });
114
-
115
- it("appends /v1 to gateway URL", () => {
116
- const config: ApertureConfig = {
117
- baseUrl: "https://example.com",
118
- providers: ["openai"],
119
- };
120
- expect(resolveProviderBaseUrl(config)).toBe("https://example.com/v1");
121
- });
122
-
123
- it("handles trailing slashes correctly", () => {
124
- const config: ApertureConfig = {
125
- baseUrl: "https://example.com/",
126
- providers: ["openai"],
127
- };
128
- expect(resolveProviderBaseUrl(config)).toBe("https://example.com/v1");
129
- });
130
- });
@@ -1,79 +0,0 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionContext,
4
- } from "@mariozechner/pi-coding-agent";
5
- import { configLoader } from "../config";
6
- import {
7
- buildApplyPlan,
8
- resolveGatewayUrl,
9
- resolveProviderBaseUrl,
10
- } from "../core";
11
- import { fetchGatewayModelIds } from "../lib/health";
12
-
13
- export { resolveGatewayUrl } from "../core";
14
-
15
- /**
16
- * Apply Aperture override to configured providers.
17
- *
18
- * Only patches baseUrl, apiKey, and headers. Models are left exactly as
19
- * registered by Pi built-ins or other extensions -- Aperture never touches
20
- * model definitions.
21
- *
22
- * Providers with no models in the registry are skipped (nothing to reroute).
23
- */
24
- export async function applyAperture(
25
- pi: ExtensionAPI,
26
- registry: ExtensionContext["modelRegistry"],
27
- ): Promise<{ providers: string[]; gatewayUrl: string | null }> {
28
- const config = configLoader.getConfig();
29
- const baseUrl = resolveProviderBaseUrl(config);
30
- if (!baseUrl) return { providers: [], gatewayUrl: null };
31
-
32
- const gatewayUrl = resolveGatewayUrl(config);
33
-
34
- const registryModels = registry.getAll();
35
-
36
- const plan = buildApplyPlan(config, registryModels, baseUrl, []);
37
-
38
- for (const reg of plan.registrations) {
39
- pi.registerProvider(reg.provider, {
40
- baseUrl: reg.baseUrl,
41
- apiKey: reg.apiKey,
42
- headers: reg.headers,
43
- api: reg.api,
44
- models: reg.models,
45
- });
46
- }
47
-
48
- return { providers: config.providers, gatewayUrl };
49
- }
50
-
51
- /**
52
- * Fetch gateway models and return missing ones relative to the plan.
53
- */
54
- export async function checkGatewayModels(
55
- gatewayUrl: string,
56
- registry: ExtensionContext["modelRegistry"],
57
- ): Promise<{ missingModels: string[] }> {
58
- const config = configLoader.getConfig();
59
- const baseUrl = resolveProviderBaseUrl(config);
60
- if (!baseUrl) return { missingModels: [] };
61
-
62
- const gatewayModelIds = await fetchGatewayModelIds(gatewayUrl);
63
- const registryModels = registry.getAll();
64
- const plan = buildApplyPlan(config, registryModels, baseUrl, gatewayModelIds);
65
- return { missingModels: plan.missingModels };
66
- }
67
-
68
- /** Re-resolve and set current model after provider registry updates. */
69
- export async function refreshActiveModel(
70
- pi: ExtensionAPI,
71
- ctx: ExtensionContext,
72
- ): Promise<boolean> {
73
- if (!ctx.model) return false;
74
-
75
- const updated = ctx.modelRegistry.find(ctx.model.provider, ctx.model.id);
76
- if (!updated) return false;
77
-
78
- return pi.setModel(updated);
79
- }
File without changes