@aliou/pi-ts-aperture 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -21
- package/extensions/aperture/dedicated/api-routing.ts +66 -0
- package/extensions/aperture/dedicated/model-defaults.ts +48 -0
- package/extensions/aperture/dedicated/runtime.ts +87 -0
- package/extensions/aperture/index.ts +79 -0
- package/extensions/aperture/onboarding/index.ts +25 -0
- package/extensions/aperture/onboarding/onboarding.ts +892 -0
- package/extensions/aperture/onboarding/setup-command.ts +53 -0
- package/extensions/aperture/onboarding/setup-wizard.ts +134 -0
- package/extensions/aperture/proxy/runtime.ts +164 -0
- package/extensions/aperture/settings-command.ts +369 -0
- package/extensions/aperture/shared/config/defaults.ts +17 -0
- package/extensions/aperture/shared/config/loader.ts +21 -0
- package/extensions/aperture/shared/config/migration/001-legacy-to-v0-6.ts +45 -0
- package/extensions/aperture/shared/config/migration/002-mode-to-capabilities.ts +20 -0
- package/extensions/aperture/shared/config/migration/003-normalize-capabilities.ts +26 -0
- package/extensions/aperture/shared/config/migration/index.ts +15 -0
- package/extensions/aperture/shared/config/types.ts +57 -0
- package/extensions/aperture/shared/sync-bus.ts +12 -0
- package/{src/lib → extensions/aperture/shared}/types.ts +2 -1
- package/package.json +37 -27
- package/src/api/client.ts +139 -0
- package/src/api/types.ts +26 -0
- package/src/provider-mapping.ts +91 -0
- package/src/url.ts +52 -0
- package/src/commands/settings.ts +0 -135
- package/src/commands/setup.ts +0 -232
- package/src/extension/runtime.test.ts +0 -121
- package/src/extension/runtime.ts +0 -144
- package/src/index.ts +0 -97
- package/src/lib/config.ts +0 -32
- package/src/lib/gateway.ts +0 -61
- package/src/lib/url.ts +0 -42
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { configLoader } from "../shared/config/loader";
|
|
6
|
+
import {
|
|
7
|
+
buildOnboardedConfig,
|
|
8
|
+
createOnboardingWizard,
|
|
9
|
+
type OnboardingResult,
|
|
10
|
+
} from "./onboarding";
|
|
11
|
+
|
|
12
|
+
export function registerOnboardingCommand(pi: ExtensionAPI): void {
|
|
13
|
+
pi.registerCommand("aperture:onboarding", {
|
|
14
|
+
description: "First-time setup for Tailscale Aperture integration",
|
|
15
|
+
handler: async (_args, ctx: ExtensionCommandContext) => {
|
|
16
|
+
if (!ctx.hasUI) {
|
|
17
|
+
ctx.ui.notify(
|
|
18
|
+
"[aperture] onboarding requires an interactive terminal",
|
|
19
|
+
"error",
|
|
20
|
+
);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const globalConfig = configLoader.getRawConfig("global");
|
|
25
|
+
const knownModels = ctx.modelRegistry.getAll();
|
|
26
|
+
|
|
27
|
+
const result = await ctx.ui.custom<OnboardingResult>(
|
|
28
|
+
(tui, theme, _kb, done) =>
|
|
29
|
+
createOnboardingWizard(theme, tui, done, knownModels, globalConfig),
|
|
30
|
+
{ overlay: true },
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (!result.completed) {
|
|
34
|
+
ctx.ui.notify("[aperture] onboarding cancelled.", "warning");
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await configLoader.save(
|
|
39
|
+
"global",
|
|
40
|
+
buildOnboardedConfig(
|
|
41
|
+
result.baseUrl,
|
|
42
|
+
result.proxyEnabled,
|
|
43
|
+
result.dedicatedEnabled,
|
|
44
|
+
result.upstreamProviders,
|
|
45
|
+
result.dedicatedProviders,
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
await configLoader.load();
|
|
49
|
+
ctx.ui.notify("[aperture] onboarding completed. Reloading...", "info");
|
|
50
|
+
await ctx.reload();
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UrlStep -- TUI component for the Aperture URL input with inline health check.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
SettingsTheme,
|
|
7
|
+
WizardStepContext,
|
|
8
|
+
} from "@aliou/pi-utils-settings";
|
|
9
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import { Input, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
11
|
+
import { ApertureClient } from "../../../src/api/client";
|
|
12
|
+
import { normalizeInputUrl } from "../../../src/url";
|
|
13
|
+
|
|
14
|
+
export const SPINNER_FRAMES = [
|
|
15
|
+
"⠋",
|
|
16
|
+
"⠙",
|
|
17
|
+
"⠹",
|
|
18
|
+
"⠸",
|
|
19
|
+
"⠼",
|
|
20
|
+
"⠴",
|
|
21
|
+
"⠦",
|
|
22
|
+
"⠧",
|
|
23
|
+
"⠇",
|
|
24
|
+
"⠏",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export class UrlStep implements Component {
|
|
28
|
+
private input: Input;
|
|
29
|
+
private theme: SettingsTheme;
|
|
30
|
+
private tui: TUI;
|
|
31
|
+
private wizCtx: WizardStepContext;
|
|
32
|
+
private onUrl: (url: string) => void;
|
|
33
|
+
private readonly placeholder = "ai.pango-lin.ts.net";
|
|
34
|
+
|
|
35
|
+
private state: "idle" | "checking" | "ok" | "error" = "idle";
|
|
36
|
+
private errorMessage = "";
|
|
37
|
+
private frame = 0;
|
|
38
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
theme: SettingsTheme,
|
|
42
|
+
tui: TUI,
|
|
43
|
+
currentValue: string,
|
|
44
|
+
wizCtx: WizardStepContext,
|
|
45
|
+
onUrl: (url: string) => void,
|
|
46
|
+
) {
|
|
47
|
+
this.theme = theme;
|
|
48
|
+
this.tui = tui;
|
|
49
|
+
this.wizCtx = wizCtx;
|
|
50
|
+
this.onUrl = onUrl;
|
|
51
|
+
this.input = new Input();
|
|
52
|
+
if (currentValue) {
|
|
53
|
+
this.input.setValue(currentValue);
|
|
54
|
+
}
|
|
55
|
+
this.input.onSubmit = () => this.submit();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private submit(): void {
|
|
59
|
+
const value = this.input.getValue().trim();
|
|
60
|
+
if (!value || this.state === "checking" || this.state === "ok") return;
|
|
61
|
+
|
|
62
|
+
const url = normalizeInputUrl(value);
|
|
63
|
+
this.state = "checking";
|
|
64
|
+
this.frame = 0;
|
|
65
|
+
|
|
66
|
+
this.timer = setInterval(() => {
|
|
67
|
+
this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
|
|
68
|
+
this.tui.requestRender();
|
|
69
|
+
}, 80);
|
|
70
|
+
|
|
71
|
+
new ApertureClient(url)
|
|
72
|
+
.health()
|
|
73
|
+
.then(() => {
|
|
74
|
+
if (this.timer) clearInterval(this.timer);
|
|
75
|
+
this.timer = null;
|
|
76
|
+
this.state = "ok";
|
|
77
|
+
this.onUrl(url);
|
|
78
|
+
this.wizCtx.markComplete();
|
|
79
|
+
this.wizCtx.goNext();
|
|
80
|
+
this.tui.requestRender();
|
|
81
|
+
})
|
|
82
|
+
.catch((error: unknown) => {
|
|
83
|
+
if (this.timer) clearInterval(this.timer);
|
|
84
|
+
this.timer = null;
|
|
85
|
+
this.state = "error";
|
|
86
|
+
this.errorMessage =
|
|
87
|
+
error instanceof Error ? error.message : String(error);
|
|
88
|
+
this.tui.requestRender();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render(width: number): string[] {
|
|
93
|
+
const lines: string[] = [];
|
|
94
|
+
|
|
95
|
+
lines.push(
|
|
96
|
+
this.theme.hint(` Aperture base URL (e.g. ${this.placeholder}):`),
|
|
97
|
+
);
|
|
98
|
+
lines.push(` ${this.input.render(width - 4).join("")}`);
|
|
99
|
+
lines.push("");
|
|
100
|
+
|
|
101
|
+
if (this.state === "checking") {
|
|
102
|
+
const spinner = SPINNER_FRAMES[this.frame];
|
|
103
|
+
lines.push(this.theme.hint(` ${spinner} Checking connection...`));
|
|
104
|
+
} else if (this.state === "ok") {
|
|
105
|
+
lines.push(this.theme.hint(" Connected."));
|
|
106
|
+
} else if (this.state === "error") {
|
|
107
|
+
lines.push(this.theme.hint(` Could not connect: ${this.errorMessage}`));
|
|
108
|
+
lines.push(this.theme.hint(" Fix the URL and press Enter to retry."));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
invalidate(): void {}
|
|
115
|
+
|
|
116
|
+
handleInput(data: string): void {
|
|
117
|
+
if (this.state === "checking") return;
|
|
118
|
+
|
|
119
|
+
if (this.state === "ok") {
|
|
120
|
+
// Only Enter advances from the "ok" state. No re-submission.
|
|
121
|
+
if (matchesKey(data, Key.enter)) {
|
|
122
|
+
this.wizCtx.goNext();
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.state = "idle";
|
|
128
|
+
this.input.handleInput(data);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
dispose(): void {
|
|
132
|
+
if (this.timer) clearInterval(this.timer);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { getApiProvider } from "@earendil-works/pi-ai";
|
|
2
|
+
import { ApertureClient } from "../../../src/api/client";
|
|
3
|
+
import type { ApertureProvider } from "../../../src/api/types";
|
|
4
|
+
import { resolveGatewayUrl, resolveProviderBaseUrl } from "../../../src/url";
|
|
5
|
+
import { configLoader } from "../shared/config/loader";
|
|
6
|
+
import type {
|
|
7
|
+
Api,
|
|
8
|
+
AssistantMessageEventStream,
|
|
9
|
+
CheckDeps,
|
|
10
|
+
Context,
|
|
11
|
+
Model,
|
|
12
|
+
SimpleStreamOptions,
|
|
13
|
+
SyncDeps,
|
|
14
|
+
} from "../shared/types";
|
|
15
|
+
|
|
16
|
+
const APERTURE_PROVENANCE_HEADERS = {
|
|
17
|
+
Referer: "https://pi.dev",
|
|
18
|
+
"X-Title": "npm:@aliou/pi-ts-aperture",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const MAX_MISSING_MODELS_PER_PROVIDER = 5;
|
|
22
|
+
|
|
23
|
+
const ROOT_BASE_URL_APIS = new Set<Api>([
|
|
24
|
+
// Pi's Codex adapter appends /codex/responses itself. Registering /v1
|
|
25
|
+
// would produce /v1/codex/responses, which Aperture does not expose.
|
|
26
|
+
"openai-codex-responses",
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
export function shouldUseGatewayRootForProxy(api: Api): boolean {
|
|
30
|
+
return ROOT_BASE_URL_APIS.has(api);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveProviderHeaders(
|
|
34
|
+
models: Model<Api>[],
|
|
35
|
+
sessionId: string,
|
|
36
|
+
): Record<string, string> {
|
|
37
|
+
const modelHeaders = models.find((m) => m.headers)?.headers ?? {};
|
|
38
|
+
return {
|
|
39
|
+
...APERTURE_PROVENANCE_HEADERS,
|
|
40
|
+
...modelHeaders,
|
|
41
|
+
"x-session-id": sessionId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class ApertureRuntime {
|
|
46
|
+
async sync(deps: SyncDeps): Promise<void> {
|
|
47
|
+
const config = configLoader.getConfig();
|
|
48
|
+
if (!config.proxy.enabled) return;
|
|
49
|
+
if (!config.baseUrl || config.proxy.upstreamProviders.length === 0) return;
|
|
50
|
+
|
|
51
|
+
const baseUrl = resolveProviderBaseUrl(config);
|
|
52
|
+
if (!baseUrl) return;
|
|
53
|
+
|
|
54
|
+
const allModels = deps.getModels();
|
|
55
|
+
const providerIds = config.proxy.upstreamProviders
|
|
56
|
+
.map((p) => p.id)
|
|
57
|
+
.filter((id) => id !== "aperture");
|
|
58
|
+
|
|
59
|
+
for (const providerName of providerIds) {
|
|
60
|
+
const providerModels = allModels.filter(
|
|
61
|
+
(m) => m.provider === providerName,
|
|
62
|
+
);
|
|
63
|
+
if (providerModels.length === 0) continue;
|
|
64
|
+
|
|
65
|
+
const api = providerModels[0].api ?? "openai-completions";
|
|
66
|
+
const builtIn = getApiProvider(api);
|
|
67
|
+
|
|
68
|
+
const providerBaseUrl = shouldUseGatewayRootForProxy(api)
|
|
69
|
+
? resolveGatewayUrl(config)
|
|
70
|
+
: baseUrl;
|
|
71
|
+
if (!providerBaseUrl) continue;
|
|
72
|
+
|
|
73
|
+
deps.registerProvider(providerName, {
|
|
74
|
+
baseUrl: providerBaseUrl,
|
|
75
|
+
apiKey: "-",
|
|
76
|
+
headers: resolveProviderHeaders(providerModels, deps.getSessionId()),
|
|
77
|
+
api,
|
|
78
|
+
streamSimple: builtIn
|
|
79
|
+
? (
|
|
80
|
+
model: Model<Api>,
|
|
81
|
+
context: Context,
|
|
82
|
+
options?: SimpleStreamOptions,
|
|
83
|
+
): AssistantMessageEventStream => {
|
|
84
|
+
return builtIn.streamSimple(model, context, {
|
|
85
|
+
...options,
|
|
86
|
+
headers: {
|
|
87
|
+
...options?.headers,
|
|
88
|
+
"x-session-id": options?.sessionId ?? "",
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
: undefined,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async checkMissingModels(
|
|
98
|
+
deps: CheckDeps,
|
|
99
|
+
providers?: ApertureProvider[],
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
const config = configLoader.getConfig();
|
|
102
|
+
if (!config.proxy.enabled) return;
|
|
103
|
+
|
|
104
|
+
const checkedProviderIds = config.proxy.upstreamProviders
|
|
105
|
+
.filter((p) => p.shouldCheckGatewayModels)
|
|
106
|
+
.map((p) => p.id);
|
|
107
|
+
if (checkedProviderIds.length === 0) return;
|
|
108
|
+
|
|
109
|
+
const gatewayUrl = resolveGatewayUrl(config);
|
|
110
|
+
if (!gatewayUrl && !providers) return;
|
|
111
|
+
|
|
112
|
+
const gatewayProviders =
|
|
113
|
+
providers ?? (await new ApertureClient(gatewayUrl as string).providers());
|
|
114
|
+
if (gatewayProviders.length === 0) return;
|
|
115
|
+
|
|
116
|
+
const modelIdsByProvider = new Map(
|
|
117
|
+
gatewayProviders.map((provider) => [
|
|
118
|
+
provider.id,
|
|
119
|
+
new Set(provider.models),
|
|
120
|
+
]),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const allModels = deps.getModels();
|
|
124
|
+
const checkedProviders = new Set(checkedProviderIds);
|
|
125
|
+
const routedModels = allModels.filter((m) =>
|
|
126
|
+
checkedProviders.has(m.provider),
|
|
127
|
+
);
|
|
128
|
+
const missingModels = routedModels.filter(
|
|
129
|
+
(m) => !modelIdsByProvider.get(m.provider)?.has(m.id),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
if (missingModels.length === 0) return;
|
|
133
|
+
|
|
134
|
+
const missingByProvider = new Map<string, Model<Api>[]>();
|
|
135
|
+
for (const model of missingModels) {
|
|
136
|
+
const providerModels = missingByProvider.get(model.provider) ?? [];
|
|
137
|
+
providerModels.push(model);
|
|
138
|
+
missingByProvider.set(model.provider, providerModels);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const summary = Array.from(missingByProvider.entries())
|
|
142
|
+
.map(([provider, models]) => {
|
|
143
|
+
const shownModels = models
|
|
144
|
+
.slice(0, MAX_MISSING_MODELS_PER_PROVIDER)
|
|
145
|
+
.map((m) => m.id);
|
|
146
|
+
const remainingCount = models.length - shownModels.length;
|
|
147
|
+
const more = remainingCount > 0 ? `, ${remainingCount} more` : "";
|
|
148
|
+
return `${provider}: ${shownModels.join(", ")}${more}`;
|
|
149
|
+
})
|
|
150
|
+
.join("; ");
|
|
151
|
+
|
|
152
|
+
deps.notify(
|
|
153
|
+
`[aperture] models not available on gateway: ${summary}. Add them to the gateway configuration.`,
|
|
154
|
+
"warning",
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getProvidersToUnregister(
|
|
159
|
+
prevProviders: string[],
|
|
160
|
+
nextProviders: string[],
|
|
161
|
+
): string[] {
|
|
162
|
+
return prevProviders.filter((p) => !nextProviders.includes(p));
|
|
163
|
+
}
|
|
164
|
+
}
|