@aliou/pi-ts-aperture 0.3.2 → 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 +3 -1
- package/package.json +9 -8
- package/src/commands/settings.ts +40 -4
- package/src/commands/setup.ts +148 -241
- package/src/extension/runtime.ts +122 -0
- package/src/index.ts +69 -60
- package/src/{config.ts → lib/config.ts} +3 -0
- package/src/lib/{health.ts → gateway.ts} +1 -4
- package/src/lib/types.ts +56 -0
- package/src/lib/url.ts +42 -0
- package/src/providers/aperture.ts +0 -112
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
|
@@ -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
|
+
"version": "0.5.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
"video": "https://assets.aliou.me/pi-extensions/demos/pi-ts-aperture.mp4"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@aliou/pi-utils-settings": "^0.
|
|
32
|
+
"@aliou/pi-utils-settings": "^0.12.0"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@mariozechner/pi-ai": "0.
|
|
36
|
-
"@mariozechner/pi-coding-agent": "0.
|
|
37
|
-
"@mariozechner/pi-tui": "0.
|
|
35
|
+
"@mariozechner/pi-ai": "0.64.0",
|
|
36
|
+
"@mariozechner/pi-coding-agent": "0.64.0",
|
|
37
|
+
"@mariozechner/pi-tui": "0.64.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@mariozechner/pi-coding-agent": {
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
"@aliou/biome-plugins": "^0.3.2",
|
|
52
52
|
"@biomejs/biome": "^2.3.13",
|
|
53
53
|
"@changesets/cli": "^2.27.11",
|
|
54
|
-
"@mariozechner/pi-coding-agent": "0.
|
|
54
|
+
"@mariozechner/pi-coding-agent": "0.64.0",
|
|
55
55
|
"@sinclair/typebox": "^0.34.48",
|
|
56
56
|
"@types/node": "^25.0.10",
|
|
57
57
|
"@vitest/coverage-v8": "^4.0.18",
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"changeset": "changeset",
|
|
68
68
|
"version": "changeset version",
|
|
69
69
|
"release": "pnpm changeset publish",
|
|
70
|
-
"test": "vitest run
|
|
71
|
-
"test:watch": "vitest
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest",
|
|
72
|
+
"test:e2e": "vitest run tests/e2e.test.ts"
|
|
72
73
|
}
|
|
73
74
|
}
|
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",
|
|
@@ -37,6 +37,9 @@ export function registerApertureSettings(
|
|
|
37
37
|
|
|
38
38
|
const providers = tabConfig?.providers ?? resolved.providers;
|
|
39
39
|
|
|
40
|
+
const checkGatewayModels: string[] =
|
|
41
|
+
tabConfig?.checkGatewayModels ?? resolved.checkGatewayModels;
|
|
42
|
+
|
|
40
43
|
return [
|
|
41
44
|
{
|
|
42
45
|
label: "Connection",
|
|
@@ -51,6 +54,39 @@ export function registerApertureSettings(
|
|
|
51
54
|
values: undefined,
|
|
52
55
|
submenu: undefined,
|
|
53
56
|
},
|
|
57
|
+
{
|
|
58
|
+
id: "checkGatewayModels",
|
|
59
|
+
label: "Gateway model checking",
|
|
60
|
+
description:
|
|
61
|
+
"Providers for which gateway model availability is checked",
|
|
62
|
+
currentValue:
|
|
63
|
+
checkGatewayModels.length > 0
|
|
64
|
+
? `${checkGatewayModels.length} provider(s)`
|
|
65
|
+
: "disabled",
|
|
66
|
+
values: undefined,
|
|
67
|
+
submenu: (_val, submenuDone) => {
|
|
68
|
+
let latest = [...checkGatewayModels];
|
|
69
|
+
return new ArrayEditor({
|
|
70
|
+
label: "Gateway-checked providers",
|
|
71
|
+
items: [...checkGatewayModels],
|
|
72
|
+
theme: settingsTheme,
|
|
73
|
+
onSave: (items) => {
|
|
74
|
+
latest = items;
|
|
75
|
+
const updated = structuredClone(
|
|
76
|
+
tabConfig ?? {},
|
|
77
|
+
) as ApertureConfig;
|
|
78
|
+
setNestedValue(updated, "checkGatewayModels", items);
|
|
79
|
+
setDraft(updated);
|
|
80
|
+
},
|
|
81
|
+
onDone: () =>
|
|
82
|
+
submenuDone(
|
|
83
|
+
latest.length > 0
|
|
84
|
+
? `${latest.length} provider(s)`
|
|
85
|
+
: "disabled",
|
|
86
|
+
),
|
|
87
|
+
});
|
|
88
|
+
},
|
|
89
|
+
},
|
|
54
90
|
],
|
|
55
91
|
},
|
|
56
92
|
{
|
|
@@ -93,7 +129,7 @@ export function registerApertureSettings(
|
|
|
93
129
|
return updated;
|
|
94
130
|
},
|
|
95
131
|
onSave: (ctx) => {
|
|
96
|
-
|
|
132
|
+
onSync(ctx);
|
|
97
133
|
},
|
|
98
134
|
});
|
|
99
135
|
}
|
package/src/commands/setup.ts
CHANGED
|
@@ -2,256 +2,138 @@
|
|
|
2
2
|
* aperture:setup -- interactive wizard for configuring Aperture.
|
|
3
3
|
*
|
|
4
4
|
* Steps:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2.
|
|
7
|
-
* 3. Save config and register providers
|
|
5
|
+
* 1. URL input (health check runs inline on Enter, auto-advances on success)
|
|
6
|
+
* 2. Provider selection with per-provider "verify models" sub-option
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
FuzzyMultiSelector,
|
|
11
|
+
type FuzzyMultiSelectorItem,
|
|
12
|
+
getSettingsTheme,
|
|
13
|
+
type SettingsTheme,
|
|
14
|
+
Wizard,
|
|
15
|
+
type WizardStepContext,
|
|
16
|
+
} from "@aliou/pi-utils-settings";
|
|
11
17
|
import type {
|
|
12
18
|
ExtensionAPI,
|
|
13
19
|
ExtensionContext,
|
|
14
20
|
} from "@mariozechner/pi-coding-agent";
|
|
15
|
-
import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
|
|
16
21
|
import type { Component, TUI } from "@mariozechner/pi-tui";
|
|
17
|
-
import { Input
|
|
18
|
-
import { configLoader } from "../config";
|
|
19
|
-
import { checkApertureHealth } from "../lib/
|
|
20
|
-
|
|
21
|
-
function normalizeUrl(url: string): string {
|
|
22
|
-
let result = url.trim();
|
|
23
|
-
if (!result) return result;
|
|
24
|
-
if (!result.startsWith("http://") && !result.startsWith("https://")) {
|
|
25
|
-
result = `http://${result}`;
|
|
26
|
-
}
|
|
27
|
-
return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
|
|
28
|
-
}
|
|
22
|
+
import { Input } from "@mariozechner/pi-tui";
|
|
23
|
+
import { configLoader } from "../lib/config";
|
|
24
|
+
import { checkApertureHealth } from "../lib/gateway";
|
|
25
|
+
import { normalizeInputUrl } from "../lib/url";
|
|
29
26
|
|
|
30
27
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
private theme:
|
|
39
|
-
private url: string;
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Step 1: URL input with inline health check
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
class UrlStep implements Component {
|
|
34
|
+
private input: Input;
|
|
35
|
+
private theme: SettingsTheme;
|
|
40
36
|
private tui: TUI;
|
|
41
|
-
|
|
42
|
-
private
|
|
37
|
+
private wizCtx: WizardStepContext;
|
|
38
|
+
private onUrl: (url: string) => void;
|
|
39
|
+
private readonly placeholder = "ai.pango-lin.ts.net";
|
|
40
|
+
|
|
41
|
+
private state: "idle" | "checking" | "ok" | "error" = "idle";
|
|
42
|
+
private errorMessage = "";
|
|
43
43
|
private frame = 0;
|
|
44
|
-
private timer: ReturnType<typeof setInterval
|
|
45
|
-
private result: { ok: boolean; error?: string } | null = null;
|
|
44
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
46
45
|
|
|
47
46
|
constructor(
|
|
48
|
-
theme:
|
|
49
|
-
url: string,
|
|
47
|
+
theme: SettingsTheme,
|
|
50
48
|
tui: TUI,
|
|
51
|
-
|
|
49
|
+
currentValue: string,
|
|
50
|
+
wizCtx: WizardStepContext,
|
|
51
|
+
onUrl: (url: string) => void,
|
|
52
52
|
) {
|
|
53
53
|
this.theme = theme;
|
|
54
|
-
this.url = url;
|
|
55
54
|
this.tui = tui;
|
|
56
|
-
this.
|
|
55
|
+
this.wizCtx = wizCtx;
|
|
56
|
+
this.onUrl = onUrl;
|
|
57
|
+
this.input = new Input();
|
|
58
|
+
if (currentValue) {
|
|
59
|
+
this.input.setValue(currentValue);
|
|
60
|
+
}
|
|
61
|
+
this.input.onSubmit = () => this.submit();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private submit(): void {
|
|
65
|
+
const value = this.input.getValue().trim();
|
|
66
|
+
if (!value || this.state === "checking") return;
|
|
67
|
+
|
|
68
|
+
const url = normalizeInputUrl(value);
|
|
69
|
+
this.state = "checking";
|
|
70
|
+
this.frame = 0;
|
|
57
71
|
|
|
58
|
-
// Animate spinner at ~80ms per frame.
|
|
59
72
|
this.timer = setInterval(() => {
|
|
60
73
|
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
61
74
|
this.tui.requestRender();
|
|
62
75
|
}, 80);
|
|
63
76
|
|
|
64
|
-
// Fire the check.
|
|
65
77
|
checkApertureHealth(url).then((res) => {
|
|
66
|
-
clearInterval(this.timer);
|
|
67
|
-
this.
|
|
68
|
-
this.tui.requestRender();
|
|
78
|
+
if (this.timer) clearInterval(this.timer);
|
|
79
|
+
this.timer = null;
|
|
69
80
|
|
|
70
|
-
// On success, auto-advance after a brief pause.
|
|
71
|
-
// On failure, wait for user input (see handleInput).
|
|
72
81
|
if (res.ok) {
|
|
73
|
-
|
|
82
|
+
this.state = "ok";
|
|
83
|
+
this.onUrl(url);
|
|
84
|
+
this.wizCtx.markComplete();
|
|
85
|
+
this.tui.requestRender();
|
|
86
|
+
setTimeout(() => this.wizCtx.goNext(), 400);
|
|
87
|
+
} else {
|
|
88
|
+
this.state = "error";
|
|
89
|
+
this.errorMessage = res.error ?? "unknown error";
|
|
90
|
+
this.tui.requestRender();
|
|
74
91
|
}
|
|
75
92
|
});
|
|
76
93
|
}
|
|
77
94
|
|
|
78
|
-
render(_width: number): string[] {
|
|
79
|
-
const lines: string[] = [];
|
|
80
|
-
lines.push(this.theme.label(" Aperture Setup", true));
|
|
81
|
-
lines.push("");
|
|
82
|
-
|
|
83
|
-
if (!this.result) {
|
|
84
|
-
const spinner = SPINNER_FRAMES[this.frame];
|
|
85
|
-
lines.push(
|
|
86
|
-
this.theme.hint(` ${spinner} Checking connection to ${this.url}...`),
|
|
87
|
-
);
|
|
88
|
-
} else if (this.result.ok) {
|
|
89
|
-
lines.push(this.theme.hint(` Connected to ${this.url}`));
|
|
90
|
-
} else {
|
|
91
|
-
lines.push(
|
|
92
|
-
this.theme.hint(` Could not reach ${this.url}: ${this.result.error}`),
|
|
93
|
-
);
|
|
94
|
-
lines.push("");
|
|
95
|
-
lines.push(
|
|
96
|
-
this.theme.hint(
|
|
97
|
-
" Make sure the URL is correct and you are connected to the tailnet.",
|
|
98
|
-
),
|
|
99
|
-
);
|
|
100
|
-
lines.push("");
|
|
101
|
-
lines.push(this.theme.hint(" Enter: try another URL · Esc: cancel"));
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
lines.push("");
|
|
105
|
-
return lines;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
invalidate() {}
|
|
109
|
-
|
|
110
|
-
handleInput(data: string) {
|
|
111
|
-
// Only handle input after a failed check.
|
|
112
|
-
if (!this.result || this.result.ok) return;
|
|
113
|
-
|
|
114
|
-
if (matchesKey(data, Key.enter)) {
|
|
115
|
-
this.done(false); // retry
|
|
116
|
-
} else if (matchesKey(data, Key.escape)) {
|
|
117
|
-
this.done(undefined); // cancel wizard
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
dispose() {
|
|
122
|
-
clearInterval(this.timer);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Simple input prompt component for the base URL step.
|
|
128
|
-
*/
|
|
129
|
-
class UrlPrompt implements Component {
|
|
130
|
-
private input: Input;
|
|
131
|
-
private done: (value: string | undefined) => void;
|
|
132
|
-
private theme: ReturnType<typeof getSettingsListTheme>;
|
|
133
|
-
private placeholder = "ai.pango-lin.ts.net";
|
|
134
|
-
|
|
135
|
-
constructor(
|
|
136
|
-
theme: ReturnType<typeof getSettingsListTheme>,
|
|
137
|
-
currentValue: string,
|
|
138
|
-
done: (value: string | undefined) => void,
|
|
139
|
-
) {
|
|
140
|
-
this.theme = theme;
|
|
141
|
-
this.done = done;
|
|
142
|
-
this.input = new Input();
|
|
143
|
-
if (currentValue) {
|
|
144
|
-
this.input.setValue(currentValue);
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
this.input.onSubmit = () => {
|
|
148
|
-
const value = this.input.getValue().trim();
|
|
149
|
-
if (!value) return;
|
|
150
|
-
this.done(normalizeUrl(value));
|
|
151
|
-
};
|
|
152
|
-
this.input.onEscape = () => {
|
|
153
|
-
this.done(undefined);
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
|
|
157
95
|
render(width: number): string[] {
|
|
158
96
|
const lines: string[] = [];
|
|
159
|
-
|
|
160
|
-
lines.push("");
|
|
97
|
+
|
|
161
98
|
lines.push(
|
|
162
99
|
this.theme.hint(` Aperture base URL (e.g. ${this.placeholder}):`),
|
|
163
100
|
);
|
|
164
101
|
lines.push(` ${this.input.render(width - 4).join("")}`);
|
|
165
102
|
lines.push("");
|
|
166
|
-
lines.push(this.theme.hint(" Enter: confirm · Esc: cancel"));
|
|
167
|
-
return lines;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
invalidate() {}
|
|
171
|
-
|
|
172
|
-
handleInput(data: string) {
|
|
173
|
-
this.input.handleInput(data);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Provider multi-select component. Uses FuzzySelector for search,
|
|
179
|
-
* tracks selected providers, and lets the user confirm with Ctrl+S.
|
|
180
|
-
*/
|
|
181
|
-
class ProviderMultiSelect implements Component {
|
|
182
|
-
private allProviders: string[];
|
|
183
|
-
private selected: Set<string>;
|
|
184
|
-
private theme: ReturnType<typeof getSettingsListTheme>;
|
|
185
|
-
private done: (value: string[] | undefined) => void;
|
|
186
|
-
private fuzzy: FuzzySelector;
|
|
187
103
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.theme = theme;
|
|
197
|
-
this.done = done;
|
|
198
|
-
|
|
199
|
-
this.fuzzy = new FuzzySelector({
|
|
200
|
-
label: "Select providers (Enter to toggle, Ctrl+S to confirm)",
|
|
201
|
-
items: this.allProviders,
|
|
202
|
-
theme,
|
|
203
|
-
onSelect: (value) => {
|
|
204
|
-
if (this.selected.has(value)) {
|
|
205
|
-
this.selected.delete(value);
|
|
206
|
-
} else {
|
|
207
|
-
this.selected.add(value);
|
|
208
|
-
}
|
|
209
|
-
},
|
|
210
|
-
onDone: () => {
|
|
211
|
-
this.done(undefined);
|
|
212
|
-
},
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
render(width: number): string[] {
|
|
217
|
-
const lines = this.fuzzy.render(width);
|
|
218
|
-
|
|
219
|
-
// Append selected providers summary
|
|
220
|
-
if (this.selected.size > 0) {
|
|
221
|
-
lines.push("");
|
|
222
|
-
lines.push(this.theme.hint(` Selected (${this.selected.size}):`));
|
|
223
|
-
for (const p of this.selected) {
|
|
224
|
-
lines.push(` ${this.theme.value(p, false)}`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Replace the hint line from FuzzySelector
|
|
229
|
-
const hintIndex = lines.findIndex((l) => l.includes("Type to search"));
|
|
230
|
-
if (hintIndex !== -1) {
|
|
231
|
-
lines[hintIndex] = this.theme.hint(
|
|
232
|
-
" Type to search · Enter: toggle · Ctrl+S: confirm · Esc: cancel",
|
|
233
|
-
);
|
|
104
|
+
if (this.state === "checking") {
|
|
105
|
+
const spinner = SPINNER_FRAMES[this.frame];
|
|
106
|
+
lines.push(this.theme.hint(` ${spinner} Checking connection...`));
|
|
107
|
+
} else if (this.state === "ok") {
|
|
108
|
+
lines.push(this.theme.hint(" Connected."));
|
|
109
|
+
} else if (this.state === "error") {
|
|
110
|
+
lines.push(this.theme.hint(` Could not connect: ${this.errorMessage}`));
|
|
111
|
+
lines.push(this.theme.hint(" Fix the URL and press Enter to retry."));
|
|
234
112
|
}
|
|
235
113
|
|
|
236
114
|
return lines;
|
|
237
115
|
}
|
|
238
116
|
|
|
239
|
-
invalidate() {
|
|
240
|
-
|
|
117
|
+
invalidate(): void {}
|
|
118
|
+
|
|
119
|
+
handleInput(data: string): void {
|
|
120
|
+
if (this.state === "checking") return;
|
|
121
|
+
this.state = "idle";
|
|
122
|
+
this.input.handleInput(data);
|
|
241
123
|
}
|
|
242
124
|
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
this.done([...this.selected]);
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
this.fuzzy.handleInput(data);
|
|
125
|
+
dispose(): void {
|
|
126
|
+
if (this.timer) clearInterval(this.timer);
|
|
249
127
|
}
|
|
250
128
|
}
|
|
251
129
|
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Command registration
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
252
134
|
export function registerSetupCommand(
|
|
253
135
|
pi: ExtensionAPI,
|
|
254
|
-
|
|
136
|
+
onSync: (ctx: ExtensionContext) => void,
|
|
255
137
|
): void {
|
|
256
138
|
pi.registerCommand("aperture:setup", {
|
|
257
139
|
description: "Configure Tailscale Aperture integration",
|
|
@@ -265,57 +147,82 @@ export function registerSetupCommand(
|
|
|
265
147
|
}
|
|
266
148
|
|
|
267
149
|
const config = configLoader.getConfig();
|
|
268
|
-
const
|
|
150
|
+
const checkGatewayProviders = config.checkGatewayModels ?? [];
|
|
269
151
|
|
|
270
|
-
// Step 1: base URL + health check loop.
|
|
271
|
-
// On failure, loop back to the URL prompt so the user can retry.
|
|
272
|
-
let baseUrl: string | undefined;
|
|
273
|
-
while (true) {
|
|
274
|
-
baseUrl = await ctx.ui.custom<string | undefined>(
|
|
275
|
-
(_tui, _theme, _kb, done) => {
|
|
276
|
-
return new UrlPrompt(
|
|
277
|
-
settingsTheme,
|
|
278
|
-
baseUrl ?? config.baseUrl,
|
|
279
|
-
done,
|
|
280
|
-
);
|
|
281
|
-
},
|
|
282
|
-
);
|
|
283
|
-
|
|
284
|
-
if (!baseUrl) return;
|
|
285
|
-
const urlToCheck = baseUrl;
|
|
286
|
-
|
|
287
|
-
const result = await ctx.ui.custom<boolean | undefined>(
|
|
288
|
-
(tui, _theme, _kb, done) => {
|
|
289
|
-
return new HealthCheckSpinner(settingsTheme, urlToCheck, tui, done);
|
|
290
|
-
},
|
|
291
|
-
);
|
|
292
|
-
|
|
293
|
-
if (result === true) break; // healthy, proceed
|
|
294
|
-
if (result === undefined) return; // cancelled
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// Step 2: select providers
|
|
298
|
-
// Use model registry so custom/extension providers are included.
|
|
299
152
|
const knownProviders = Array.from(
|
|
300
153
|
new Set(ctx.modelRegistry.getAll().map((model) => model.provider)),
|
|
301
154
|
).sort((a, b) => a.localeCompare(b));
|
|
302
155
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
156
|
+
let baseUrl = config.baseUrl;
|
|
157
|
+
|
|
158
|
+
const providerItems: FuzzyMultiSelectorItem[] = knownProviders.map(
|
|
159
|
+
(p) => ({
|
|
160
|
+
label: p,
|
|
161
|
+
checked: config.providers.includes(p),
|
|
162
|
+
subOptions: [
|
|
163
|
+
{
|
|
164
|
+
label: "verify models on gateway",
|
|
165
|
+
description:
|
|
166
|
+
"Warn at startup if this provider's models are missing from the Aperture gateway",
|
|
167
|
+
checked: checkGatewayProviders.includes(p),
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const confirmed = await ctx.ui.custom<boolean | undefined>(
|
|
174
|
+
(tui, theme, _kb, done) => {
|
|
175
|
+
const settingsTheme = getSettingsTheme(theme);
|
|
176
|
+
|
|
177
|
+
return new Wizard({
|
|
178
|
+
title: "Aperture Setup",
|
|
179
|
+
theme: settingsTheme,
|
|
180
|
+
minContentHeight: 16,
|
|
181
|
+
steps: [
|
|
182
|
+
{
|
|
183
|
+
label: "URL",
|
|
184
|
+
build: (wCtx: WizardStepContext) =>
|
|
185
|
+
new UrlStep(settingsTheme, tui, baseUrl, wCtx, (url) => {
|
|
186
|
+
baseUrl = url;
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
label: "Providers",
|
|
191
|
+
build: (wCtx: WizardStepContext) => {
|
|
192
|
+
wCtx.markComplete();
|
|
193
|
+
return new FuzzyMultiSelector({
|
|
194
|
+
label: "Providers to route through Aperture",
|
|
195
|
+
items: providerItems,
|
|
196
|
+
theme: settingsTheme,
|
|
197
|
+
showHints: false,
|
|
198
|
+
showCount: false,
|
|
199
|
+
maxVisible: 7,
|
|
200
|
+
});
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
onComplete: () => done(true),
|
|
205
|
+
onCancel: () => done(undefined),
|
|
206
|
+
});
|
|
311
207
|
},
|
|
312
208
|
);
|
|
313
209
|
|
|
314
|
-
if (!
|
|
210
|
+
if (!confirmed) return;
|
|
211
|
+
|
|
212
|
+
const providers = providerItems
|
|
213
|
+
.filter((i) => i.checked)
|
|
214
|
+
.map((i) => i.label);
|
|
215
|
+
|
|
216
|
+
const checkGatewayModels = providerItems
|
|
217
|
+
.filter((i) => i.checked && i.subOptions?.[0]?.checked)
|
|
218
|
+
.map((i) => i.label);
|
|
315
219
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
220
|
+
await configLoader.save("global", {
|
|
221
|
+
baseUrl,
|
|
222
|
+
providers,
|
|
223
|
+
checkGatewayModels,
|
|
224
|
+
});
|
|
225
|
+
onSync(ctx);
|
|
319
226
|
ctx.ui.notify(
|
|
320
227
|
`Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
|
|
321
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
|
-
*
|
|
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,76 +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 {
|
|
16
|
+
import { ApertureRuntime } from "./extension/runtime";
|
|
17
|
+
import { configLoader } from "./lib/config";
|
|
18
|
+
import { resolveGatewayUrl } from "./lib/url";
|
|
18
19
|
|
|
19
|
-
function
|
|
20
|
-
|
|
20
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
21
|
+
await configLoader.load();
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
const runtime = new ApertureRuntime();
|
|
24
|
+
let lastRegisteredProviders: string[] = [
|
|
25
|
+
...configLoader.getConfig().providers,
|
|
26
|
+
];
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
// Sync function used by commands after config changes
|
|
29
|
+
const onSync = (ctx: ExtensionContext): void => {
|
|
30
|
+
const config = configLoader.getConfig();
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
|
31
41
|
ctx.ui.notify(
|
|
32
|
-
`[aperture]
|
|
33
|
-
"
|
|
42
|
+
`[aperture] unregistered ${provider}. Run /reload to use the native provider.`,
|
|
43
|
+
"info",
|
|
34
44
|
);
|
|
35
45
|
}
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
55
|
+
if (
|
|
56
|
+
ctx.model &&
|
|
57
|
+
ctx.modelRegistry.find(ctx.model.provider, ctx.model.id)
|
|
58
|
+
) {
|
|
59
|
+
const updated = ctx.modelRegistry.find(
|
|
60
|
+
ctx.model.provider,
|
|
61
|
+
ctx.model.id,
|
|
62
|
+
);
|
|
63
|
+
if (updated && config.providers.includes(ctx.model.provider)) {
|
|
64
|
+
void pi.setModel(updated);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
53
68
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
59
79
|
);
|
|
60
80
|
}
|
|
61
|
-
});
|
|
62
|
-
lastRegisteredProviders = [...providers];
|
|
63
|
-
|
|
64
|
-
if (ctx.model && providers.includes(ctx.model.provider)) {
|
|
65
|
-
void refreshActiveModel(pi, ctx).then((updated) => {
|
|
66
|
-
if (!updated) return;
|
|
67
|
-
ctx.ui.notify(
|
|
68
|
-
`[aperture] re-routing ${ctx.model?.id ?? "model"} through ${ctx.model?.baseUrl ?? "aperture"}`,
|
|
69
|
-
"info",
|
|
70
|
-
);
|
|
71
|
-
});
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
|
|
75
|
-
pi.unregisterProvider(provider);
|
|
76
|
-
}
|
|
83
|
+
lastRegisteredProviders = [...nextProviders];
|
|
77
84
|
};
|
|
78
|
-
}
|
|
79
85
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
});
|
|
84
94
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
registerApertureSettings(pi, onConfigChange);
|
|
95
|
+
registerSetupCommand(pi, onSync);
|
|
96
|
+
registerApertureSettings(pi, onSync);
|
|
88
97
|
}
|
|
@@ -10,16 +10,19 @@ import { ConfigLoader } from "@aliou/pi-utils-settings";
|
|
|
10
10
|
export interface ApertureConfig {
|
|
11
11
|
baseUrl?: string;
|
|
12
12
|
providers?: string[];
|
|
13
|
+
checkGatewayModels?: string[];
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
export interface ResolvedConfig {
|
|
16
17
|
baseUrl: string;
|
|
17
18
|
providers: string[];
|
|
19
|
+
checkGatewayModels: string[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
const DEFAULT_CONFIG: ResolvedConfig = {
|
|
21
23
|
baseUrl: "",
|
|
22
24
|
providers: [],
|
|
25
|
+
checkGatewayModels: [],
|
|
23
26
|
};
|
|
24
27
|
|
|
25
28
|
export const configLoader = new ConfigLoader<ApertureConfig, ResolvedConfig>(
|
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/lib/url.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ApertureConfig } from "./config";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalizes a user-input URL:
|
|
9
|
+
* - Trims whitespace
|
|
10
|
+
* - Adds http:// scheme if missing
|
|
11
|
+
* - Strips trailing /v1 or /v1/
|
|
12
|
+
* - Strips trailing slashes
|
|
13
|
+
*/
|
|
14
|
+
export function normalizeInputUrl(raw: string): string {
|
|
15
|
+
let result = raw.trim();
|
|
16
|
+
if (!result) return result;
|
|
17
|
+
if (!result.startsWith("http://") && !result.startsWith("https://")) {
|
|
18
|
+
result = `http://${result}`;
|
|
19
|
+
}
|
|
20
|
+
return result.replace(/\/v1\/?$/, "").replace(/\/+$/, "");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns configured gateway URL without trailing slash.
|
|
25
|
+
* Returns null when baseUrl is empty or providers list is empty.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveGatewayUrl(config: ApertureConfig): string | null {
|
|
28
|
+
const { baseUrl, providers } = config;
|
|
29
|
+
if (!baseUrl || providers?.length === 0) return null;
|
|
30
|
+
return baseUrl.replace(/\/+$/, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns the Aperture provider base URL used for provider registration.
|
|
35
|
+
* Appends /v1 to the gateway URL.
|
|
36
|
+
* Returns null when gateway URL cannot be resolved.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveProviderBaseUrl(config: ApertureConfig): string | null {
|
|
39
|
+
const gateway = resolveGatewayUrl(config);
|
|
40
|
+
if (!gateway) return null;
|
|
41
|
+
return `${gateway}/v1`;
|
|
42
|
+
}
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
ExtensionAPI,
|
|
3
|
-
ExtensionContext,
|
|
4
|
-
ProviderModelConfig,
|
|
5
|
-
} from "@mariozechner/pi-coding-agent";
|
|
6
|
-
import { configLoader } from "../config";
|
|
7
|
-
import { fetchGatewayModelIds } from "../lib/health";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Preserve provenance similarly to pi-synthetic so downstream providers can
|
|
11
|
-
* attribute traffic to Pi / this extension.
|
|
12
|
-
*/
|
|
13
|
-
const APERTURE_PROVENANCE_HEADERS = {
|
|
14
|
-
Referer: "https://pi.dev",
|
|
15
|
-
"X-Title": "npm:@aliou/pi-ts-aperture",
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
/** Returns configured gateway URL without trailing slash. */
|
|
19
|
-
export function resolveGatewayUrl(): string | null {
|
|
20
|
-
const { baseUrl, providers } = configLoader.getConfig();
|
|
21
|
-
if (!baseUrl || providers.length === 0) return null;
|
|
22
|
-
return baseUrl.replace(/\/+$/, "");
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Returns the Aperture provider base URL used for provider registration.
|
|
27
|
-
*
|
|
28
|
-
* Aperture exposes multiple protocol paths (OpenAI, Anthropic, Gemini, ...).
|
|
29
|
-
* For this extension we route through the OpenAI-compatible `/v1` surface that
|
|
30
|
-
* Pi providers use (`openai-completions` API).
|
|
31
|
-
*/
|
|
32
|
-
export function resolveApertureProviderBaseUrl(): string | null {
|
|
33
|
-
const gateway = resolveGatewayUrl();
|
|
34
|
-
if (!gateway) return null;
|
|
35
|
-
return `${gateway}/v1`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function resolveProviderHeaders(
|
|
39
|
-
models: ProviderModelConfig[],
|
|
40
|
-
): Record<string, string> {
|
|
41
|
-
const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
|
|
42
|
-
return {
|
|
43
|
-
...APERTURE_PROVENANCE_HEADERS,
|
|
44
|
-
...modelHeaders,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Apply Aperture override to configured providers.
|
|
50
|
-
*
|
|
51
|
-
* Only patches baseUrl, apiKey, and headers. Models are left exactly as
|
|
52
|
-
* registered by Pi built-ins or other extensions -- Aperture never touches
|
|
53
|
-
* model definitions.
|
|
54
|
-
*
|
|
55
|
-
* Providers with no models in the registry are skipped (nothing to reroute).
|
|
56
|
-
*/
|
|
57
|
-
export async function applyAperture(
|
|
58
|
-
pi: ExtensionAPI,
|
|
59
|
-
registry: ExtensionContext["modelRegistry"],
|
|
60
|
-
): Promise<{ providers: string[]; missingModels: string[] }> {
|
|
61
|
-
const baseUrl = resolveApertureProviderBaseUrl();
|
|
62
|
-
if (!baseUrl) return { providers: [], missingModels: [] };
|
|
63
|
-
|
|
64
|
-
const { providers } = configLoader.getConfig();
|
|
65
|
-
|
|
66
|
-
for (const provider of providers) {
|
|
67
|
-
const existingModels = registry
|
|
68
|
-
.getAll()
|
|
69
|
-
.filter((m) => m.provider === provider) as ProviderModelConfig[];
|
|
70
|
-
|
|
71
|
-
if (existingModels.length === 0) continue;
|
|
72
|
-
|
|
73
|
-
pi.registerProvider(provider, {
|
|
74
|
-
baseUrl,
|
|
75
|
-
apiKey: "-",
|
|
76
|
-
headers: resolveProviderHeaders(existingModels),
|
|
77
|
-
api: existingModels[0].api,
|
|
78
|
-
models: existingModels,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const gatewayUrl = resolveGatewayUrl();
|
|
83
|
-
const gatewayModelIds = gatewayUrl
|
|
84
|
-
? await fetchGatewayModelIds(gatewayUrl)
|
|
85
|
-
: [];
|
|
86
|
-
|
|
87
|
-
let missingModels: string[] = [];
|
|
88
|
-
if (gatewayModelIds.length > 0) {
|
|
89
|
-
const routedModelIds = registry
|
|
90
|
-
.getAll()
|
|
91
|
-
.filter((m) => providers.includes(m.provider))
|
|
92
|
-
.map((m) => m.id);
|
|
93
|
-
missingModels = routedModelIds.filter(
|
|
94
|
-
(id) => !gatewayModelIds.includes(id),
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
return { providers, missingModels };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Re-resolve and set current model after provider registry updates. */
|
|
102
|
-
export async function refreshActiveModel(
|
|
103
|
-
pi: ExtensionAPI,
|
|
104
|
-
ctx: ExtensionContext,
|
|
105
|
-
): Promise<boolean> {
|
|
106
|
-
if (!ctx.model) return false;
|
|
107
|
-
|
|
108
|
-
const updated = ctx.modelRegistry.find(ctx.model.provider, ctx.model.id);
|
|
109
|
-
if (!updated) return false;
|
|
110
|
-
|
|
111
|
-
return pi.setModel(updated);
|
|
112
|
-
}
|