@aliou/pi-ts-aperture 0.4.0 → 0.5.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.
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/commands/settings.ts +4 -4
- package/src/commands/setup.ts +5 -5
- package/src/extension/runtime.test.ts +121 -0
- package/src/extension/runtime.ts +144 -0
- package/src/index.ts +65 -118
- package/src/lib/{health.ts → gateway.ts} +23 -7
- package/src/lib/types.ts +56 -0
- package/src/{core → lib}/url.ts +2 -2
- package/src/core/index.ts +0 -7
- package/src/core/plan.test.ts +0 -253
- package/src/core/plan.ts +0 -107
- package/src/core/types.ts +0 -33
- package/src/core/url.test.ts +0 -130
- package/src/providers/aperture.ts +0 -79
- /package/src/{config.ts → lib/config.ts} +0 -0
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
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
package/src/commands/settings.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
132
|
+
onSync(ctx);
|
|
133
133
|
},
|
|
134
134
|
});
|
|
135
135
|
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -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 {
|
|
25
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
225
|
+
onSync(ctx);
|
|
226
226
|
ctx.ui.notify(
|
|
227
227
|
`Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
|
|
228
228
|
"info",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { configLoader } from "../lib/config";
|
|
3
|
+
import { fetchGatewayModels } from "../lib/gateway";
|
|
4
|
+
import type { Api, Model } from "../lib/types";
|
|
5
|
+
import { ApertureRuntime } from "./runtime";
|
|
6
|
+
|
|
7
|
+
vi.mock("../lib/config", () => ({
|
|
8
|
+
configLoader: {
|
|
9
|
+
getConfig: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock("../lib/gateway", () => ({
|
|
14
|
+
fetchGatewayModels: vi.fn(),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const getConfig = vi.mocked(configLoader.getConfig);
|
|
18
|
+
const fetchModels = vi.mocked(fetchGatewayModels);
|
|
19
|
+
|
|
20
|
+
function model(provider: string, id: string): Model<Api> {
|
|
21
|
+
return { provider, id } as Model<Api>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function check(models: Model<Api>[]) {
|
|
25
|
+
const notify = vi.fn();
|
|
26
|
+
const runtime = new ApertureRuntime();
|
|
27
|
+
|
|
28
|
+
await runtime.checkMissingModels(
|
|
29
|
+
{
|
|
30
|
+
getModels: () => models,
|
|
31
|
+
notify,
|
|
32
|
+
},
|
|
33
|
+
"http://gateway.test",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
return notify;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("ApertureRuntime.checkMissingModels", () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
getConfig.mockReturnValue({
|
|
42
|
+
baseUrl: "http://gateway.test",
|
|
43
|
+
providers: [],
|
|
44
|
+
checkGatewayModels: ["synthetic"],
|
|
45
|
+
});
|
|
46
|
+
fetchModels.mockResolvedValue([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("matches gateway models by provider and id", async () => {
|
|
50
|
+
fetchModels.mockResolvedValue([{ providerId: "openrouter", id: "foo" }]);
|
|
51
|
+
|
|
52
|
+
const notify = await check([
|
|
53
|
+
model("synthetic", "foo"),
|
|
54
|
+
model("openrouter", "foo"),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
expect(notify).toHaveBeenCalledOnce();
|
|
58
|
+
expect(notify.mock.calls[0][0]).toContain("synthetic: foo");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("only checks configured providers", async () => {
|
|
62
|
+
getConfig.mockReturnValue({
|
|
63
|
+
baseUrl: "http://gateway.test",
|
|
64
|
+
providers: [],
|
|
65
|
+
checkGatewayModels: ["synthetic"],
|
|
66
|
+
});
|
|
67
|
+
fetchModels.mockResolvedValue([{ providerId: "synthetic", id: "foo" }]);
|
|
68
|
+
|
|
69
|
+
const notify = await check([
|
|
70
|
+
model("synthetic", "foo"),
|
|
71
|
+
model("openrouter", "missing-openrouter"),
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
expect(notify).not.toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("truncates missing models per provider", async () => {
|
|
78
|
+
getConfig.mockReturnValue({
|
|
79
|
+
baseUrl: "http://gateway.test",
|
|
80
|
+
providers: [],
|
|
81
|
+
checkGatewayModels: ["openrouter", "synthetic"],
|
|
82
|
+
});
|
|
83
|
+
fetchModels.mockResolvedValue([{ providerId: "synthetic", id: "syn-1" }]);
|
|
84
|
+
|
|
85
|
+
const notify = await check([
|
|
86
|
+
model("openrouter", "or-1"),
|
|
87
|
+
model("openrouter", "or-2"),
|
|
88
|
+
model("openrouter", "or-3"),
|
|
89
|
+
model("openrouter", "or-4"),
|
|
90
|
+
model("openrouter", "or-5"),
|
|
91
|
+
model("openrouter", "or-6"),
|
|
92
|
+
model("openrouter", "or-7"),
|
|
93
|
+
model("synthetic", "syn-1"),
|
|
94
|
+
model("synthetic", "syn-2"),
|
|
95
|
+
model("synthetic", "syn-3"),
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
expect(notify).toHaveBeenCalledOnce();
|
|
99
|
+
const message = notify.mock.calls[0][0];
|
|
100
|
+
expect(message).toContain(
|
|
101
|
+
"openrouter: or-1, or-2, or-3, or-4, or-5, 2 more",
|
|
102
|
+
);
|
|
103
|
+
expect(message).not.toContain("or-6");
|
|
104
|
+
expect(message).not.toContain("or-7");
|
|
105
|
+
expect(message).toContain("synthetic: syn-2, syn-3");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("does not warn when all checked provider models exist", async () => {
|
|
109
|
+
fetchModels.mockResolvedValue([
|
|
110
|
+
{ providerId: "synthetic", id: "foo" },
|
|
111
|
+
{ providerId: "synthetic", id: "bar" },
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
const notify = await check([
|
|
115
|
+
model("synthetic", "foo"),
|
|
116
|
+
model("synthetic", "bar"),
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
expect(notify).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
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 { fetchGatewayModels } 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
|
+
const MAX_MISSING_MODELS_PER_PROVIDER = 5;
|
|
31
|
+
|
|
32
|
+
function resolveProviderHeaders(models: Model<Api>[]): Record<string, string> {
|
|
33
|
+
const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
|
|
34
|
+
return {
|
|
35
|
+
...APERTURE_PROVENANCE_HEADERS,
|
|
36
|
+
...modelHeaders,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class ApertureRuntime {
|
|
41
|
+
private registeredProviders = new Set<string>();
|
|
42
|
+
|
|
43
|
+
async sync(deps: SyncDeps): Promise<void> {
|
|
44
|
+
const config = configLoader.getConfig();
|
|
45
|
+
if (!config.baseUrl || config.providers.length === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const baseUrl = resolveProviderBaseUrl(config);
|
|
50
|
+
if (!baseUrl) return;
|
|
51
|
+
|
|
52
|
+
const allModels = deps.getModels();
|
|
53
|
+
|
|
54
|
+
for (const providerName of config.providers) {
|
|
55
|
+
const providerModels = allModels.filter(
|
|
56
|
+
(m) => m.provider === providerName,
|
|
57
|
+
);
|
|
58
|
+
if (providerModels.length === 0) continue;
|
|
59
|
+
|
|
60
|
+
const api = providerModels[0].api ?? "openai-completions";
|
|
61
|
+
const builtIn = getApiProvider(api);
|
|
62
|
+
|
|
63
|
+
deps.registerProvider(providerName, {
|
|
64
|
+
baseUrl,
|
|
65
|
+
apiKey: "-",
|
|
66
|
+
headers: resolveProviderHeaders(providerModels),
|
|
67
|
+
api,
|
|
68
|
+
streamSimple: builtIn
|
|
69
|
+
? (
|
|
70
|
+
model: Model<Api>,
|
|
71
|
+
context: Context,
|
|
72
|
+
options?: SimpleStreamOptions,
|
|
73
|
+
): AssistantMessageEventStream =>
|
|
74
|
+
builtIn.streamSimple(model, context, {
|
|
75
|
+
...options,
|
|
76
|
+
headers: {
|
|
77
|
+
...options?.headers,
|
|
78
|
+
"x-session-id": options?.sessionId ?? "",
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
: undefined,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
this.registeredProviders.add(providerName);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async checkMissingModels(deps: CheckDeps, gatewayUrl: string): Promise<void> {
|
|
89
|
+
const config = configLoader.getConfig();
|
|
90
|
+
if (config.checkGatewayModels.length === 0) return;
|
|
91
|
+
|
|
92
|
+
const gatewayModels = await fetchGatewayModels(gatewayUrl);
|
|
93
|
+
if (gatewayModels.length === 0) return;
|
|
94
|
+
|
|
95
|
+
const allModels = deps.getModels();
|
|
96
|
+
const checkedProviders = new Set(config.checkGatewayModels);
|
|
97
|
+
const gatewayModelKeys = new Set(
|
|
98
|
+
gatewayModels.map((m) => `${m.providerId}:${m.id}`),
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const routedModels = allModels.filter((m) =>
|
|
102
|
+
checkedProviders.has(m.provider),
|
|
103
|
+
);
|
|
104
|
+
const missingModels = routedModels.filter(
|
|
105
|
+
(m) => !gatewayModelKeys.has(`${m.provider}:${m.id}`),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (missingModels.length > 0) {
|
|
109
|
+
const missingByProvider = new Map<string, Model<Api>[]>();
|
|
110
|
+
for (const model of missingModels) {
|
|
111
|
+
const providerModels = missingByProvider.get(model.provider) ?? [];
|
|
112
|
+
providerModels.push(model);
|
|
113
|
+
missingByProvider.set(model.provider, providerModels);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const summary = Array.from(missingByProvider.entries())
|
|
117
|
+
.map(([provider, models]) => {
|
|
118
|
+
const shownModels = models
|
|
119
|
+
.slice(0, MAX_MISSING_MODELS_PER_PROVIDER)
|
|
120
|
+
.map((m) => m.id);
|
|
121
|
+
const remainingCount = models.length - shownModels.length;
|
|
122
|
+
const more = remainingCount > 0 ? `, ${remainingCount} more` : "";
|
|
123
|
+
return `${provider}: ${shownModels.join(", ")}${more}`;
|
|
124
|
+
})
|
|
125
|
+
.join("; ");
|
|
126
|
+
|
|
127
|
+
deps.notify(
|
|
128
|
+
`[aperture] models not available on gateway: ${summary}. Add them to the gateway configuration.`,
|
|
129
|
+
"warning",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Returns providers that should be unregistered based on config changes.
|
|
136
|
+
* Compares previous providers with new ones.
|
|
137
|
+
*/
|
|
138
|
+
getProvidersToUnregister(
|
|
139
|
+
prevProviders: string[],
|
|
140
|
+
nextProviders: string[],
|
|
141
|
+
): string[] {
|
|
142
|
+
return prevProviders.filter((p) => !nextProviders.includes(p));
|
|
143
|
+
}
|
|
144
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Pi extension for Tailscale Aperture integration.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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 {
|
|
17
|
-
import {
|
|
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
|
|
25
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
115
|
-
|
|
116
|
-
ctx.
|
|
59
|
+
const updated = ctx.modelRegistry.find(
|
|
60
|
+
ctx.model.provider,
|
|
61
|
+
ctx.model.id,
|
|
117
62
|
);
|
|
118
|
-
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
registerApertureSettings(pi, onConfigChange);
|
|
95
|
+
registerSetupCommand(pi, onSync);
|
|
96
|
+
registerApertureSettings(pi, onSync);
|
|
150
97
|
}
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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 {
|
|
@@ -29,7 +26,14 @@ export async function checkApertureHealth(
|
|
|
29
26
|
}
|
|
30
27
|
}
|
|
31
28
|
|
|
32
|
-
export
|
|
29
|
+
export interface GatewayModel {
|
|
30
|
+
id: string;
|
|
31
|
+
providerId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function fetchGatewayModels(
|
|
35
|
+
baseUrl: string,
|
|
36
|
+
): Promise<GatewayModel[]> {
|
|
33
37
|
const url = `${baseUrl.replace(/\/+$/, "")}/v1/models`;
|
|
34
38
|
try {
|
|
35
39
|
const res = await fetch(url, {
|
|
@@ -37,8 +41,20 @@ export async function fetchGatewayModelIds(baseUrl: string): Promise<string[]> {
|
|
|
37
41
|
signal: AbortSignal.timeout(5000),
|
|
38
42
|
});
|
|
39
43
|
if (!res.ok) return [];
|
|
40
|
-
const body = (await res.json()) as {
|
|
41
|
-
|
|
44
|
+
const body = (await res.json()) as {
|
|
45
|
+
data?: {
|
|
46
|
+
id: string;
|
|
47
|
+
metadata?: { provider?: { id?: string } };
|
|
48
|
+
}[];
|
|
49
|
+
};
|
|
50
|
+
return (
|
|
51
|
+
body.data
|
|
52
|
+
?.map((m) => ({
|
|
53
|
+
id: m.id,
|
|
54
|
+
providerId: m.metadata?.provider?.id ?? "",
|
|
55
|
+
}))
|
|
56
|
+
.filter((m) => m.providerId.length > 0) ?? []
|
|
57
|
+
);
|
|
42
58
|
} catch {
|
|
43
59
|
return [];
|
|
44
60
|
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal types for Aperture extension.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
Api,
|
|
7
|
+
AssistantMessageEventStream,
|
|
8
|
+
Context,
|
|
9
|
+
Model,
|
|
10
|
+
SimpleStreamOptions,
|
|
11
|
+
} from "@mariozechner/pi-ai";
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
Api,
|
|
15
|
+
AssistantMessageEventStream,
|
|
16
|
+
Context,
|
|
17
|
+
Model,
|
|
18
|
+
SimpleStreamOptions,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Dependencies for ApertureRuntime.sync()
|
|
23
|
+
*/
|
|
24
|
+
export interface SyncDeps {
|
|
25
|
+
registerProvider: (
|
|
26
|
+
name: string,
|
|
27
|
+
config: {
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
apiKey: string;
|
|
30
|
+
headers: Record<string, string>;
|
|
31
|
+
api: string;
|
|
32
|
+
streamSimple?: (
|
|
33
|
+
model: Model<Api>,
|
|
34
|
+
context: Context,
|
|
35
|
+
options?: SimpleStreamOptions,
|
|
36
|
+
) => AssistantMessageEventStream;
|
|
37
|
+
},
|
|
38
|
+
) => void;
|
|
39
|
+
getModels: () => Model<Api>[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dependencies for ApertureRuntime.checkMissingModels()
|
|
44
|
+
*/
|
|
45
|
+
export interface CheckDeps {
|
|
46
|
+
getModels: () => Model<Api>[];
|
|
47
|
+
notify: (msg: string, type: "warning" | "info") => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Headers for provider registration.
|
|
52
|
+
*/
|
|
53
|
+
export interface ProviderHeaders {
|
|
54
|
+
Referer: string;
|
|
55
|
+
"X-Title": string;
|
|
56
|
+
}
|
package/src/{core → lib}/url.ts
RENAMED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Pure URL helpers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import type { ApertureConfig } from "./
|
|
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
|
|
29
|
+
if (!baseUrl || providers?.length === 0) return null;
|
|
30
30
|
return baseUrl.replace(/\/+$/, "");
|
|
31
31
|
}
|
|
32
32
|
|
package/src/core/index.ts
DELETED
package/src/core/plan.test.ts
DELETED
|
@@ -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
|
-
}
|
package/src/core/url.test.ts
DELETED
|
@@ -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
|