@aliou/pi-ts-aperture 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/README.md +119 -21
  2. package/extensions/aperture/dedicated/api-routing.ts +66 -0
  3. package/extensions/aperture/dedicated/model-defaults.ts +48 -0
  4. package/extensions/aperture/dedicated/runtime.ts +87 -0
  5. package/extensions/aperture/index.ts +78 -0
  6. package/extensions/aperture/onboarding/index.ts +25 -0
  7. package/extensions/aperture/onboarding/onboarding.ts +892 -0
  8. package/extensions/aperture/onboarding/setup-command.ts +53 -0
  9. package/extensions/aperture/onboarding/setup-wizard.ts +134 -0
  10. package/extensions/aperture/proxy/runtime.ts +160 -0
  11. package/extensions/aperture/settings-command.ts +369 -0
  12. package/extensions/aperture/shared/config/defaults.ts +17 -0
  13. package/extensions/aperture/shared/config/loader.ts +21 -0
  14. package/extensions/aperture/shared/config/migration/001-legacy-to-v0-6.ts +45 -0
  15. package/extensions/aperture/shared/config/migration/002-mode-to-capabilities.ts +20 -0
  16. package/extensions/aperture/shared/config/migration/003-normalize-capabilities.ts +26 -0
  17. package/extensions/aperture/shared/config/migration/index.ts +15 -0
  18. package/extensions/aperture/shared/config/types.ts +57 -0
  19. package/extensions/aperture/shared/sync-bus.ts +12 -0
  20. package/{src/lib → extensions/aperture/shared}/types.ts +1 -1
  21. package/package.json +37 -27
  22. package/src/api/client.ts +139 -0
  23. package/src/api/types.ts +26 -0
  24. package/src/provider-mapping.ts +91 -0
  25. package/src/url.ts +52 -0
  26. package/src/commands/settings.ts +0 -135
  27. package/src/commands/setup.ts +0 -232
  28. package/src/extension/runtime.test.ts +0 -121
  29. package/src/extension/runtime.ts +0 -144
  30. package/src/index.ts +0 -97
  31. package/src/lib/config.ts +0 -32
  32. package/src/lib/gateway.ts +0 -61
  33. package/src/lib/url.ts +0 -42
@@ -1,232 +0,0 @@
1
- /**
2
- * aperture:setup -- interactive wizard for configuring Aperture.
3
- *
4
- * Steps:
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
7
- */
8
-
9
- import {
10
- FuzzyMultiSelector,
11
- type FuzzyMultiSelectorItem,
12
- getSettingsTheme,
13
- type SettingsTheme,
14
- Wizard,
15
- type WizardStepContext,
16
- } from "@aliou/pi-utils-settings";
17
- import type {
18
- ExtensionAPI,
19
- ExtensionContext,
20
- } from "@mariozechner/pi-coding-agent";
21
- import type { Component, TUI } from "@mariozechner/pi-tui";
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";
26
-
27
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
28
-
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;
36
- private tui: TUI;
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
- private frame = 0;
44
- private timer: ReturnType<typeof setInterval> | null = null;
45
-
46
- constructor(
47
- theme: SettingsTheme,
48
- tui: TUI,
49
- currentValue: string,
50
- wizCtx: WizardStepContext,
51
- onUrl: (url: string) => void,
52
- ) {
53
- this.theme = theme;
54
- this.tui = tui;
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;
71
-
72
- this.timer = setInterval(() => {
73
- this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
74
- this.tui.requestRender();
75
- }, 80);
76
-
77
- checkApertureHealth(url).then((res) => {
78
- if (this.timer) clearInterval(this.timer);
79
- this.timer = null;
80
-
81
- if (res.ok) {
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();
91
- }
92
- });
93
- }
94
-
95
- render(width: number): string[] {
96
- const lines: string[] = [];
97
-
98
- lines.push(
99
- this.theme.hint(` Aperture base URL (e.g. ${this.placeholder}):`),
100
- );
101
- lines.push(` ${this.input.render(width - 4).join("")}`);
102
- lines.push("");
103
-
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."));
112
- }
113
-
114
- return lines;
115
- }
116
-
117
- invalidate(): void {}
118
-
119
- handleInput(data: string): void {
120
- if (this.state === "checking") return;
121
- this.state = "idle";
122
- this.input.handleInput(data);
123
- }
124
-
125
- dispose(): void {
126
- if (this.timer) clearInterval(this.timer);
127
- }
128
- }
129
-
130
- // ---------------------------------------------------------------------------
131
- // Command registration
132
- // ---------------------------------------------------------------------------
133
-
134
- export function registerSetupCommand(
135
- pi: ExtensionAPI,
136
- onSync: (ctx: ExtensionContext) => void,
137
- ): void {
138
- pi.registerCommand("aperture:setup", {
139
- description: "Configure Tailscale Aperture integration",
140
- handler: async (_args, ctx) => {
141
- if (!ctx.hasUI) {
142
- ctx.ui.notify(
143
- "aperture:setup requires an interactive terminal",
144
- "error",
145
- );
146
- return;
147
- }
148
-
149
- const config = configLoader.getConfig();
150
- const checkGatewayProviders = config.checkGatewayModels ?? [];
151
-
152
- const knownProviders = Array.from(
153
- new Set(ctx.modelRegistry.getAll().map((model) => model.provider)),
154
- ).sort((a, b) => a.localeCompare(b));
155
-
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
- });
207
- },
208
- );
209
-
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);
219
-
220
- await configLoader.save("global", {
221
- baseUrl,
222
- providers,
223
- checkGatewayModels,
224
- });
225
- onSync(ctx);
226
- ctx.ui.notify(
227
- `Aperture configured: ${providers.length} provider(s) via ${baseUrl}`,
228
- "info",
229
- );
230
- },
231
- });
232
- }
@@ -1,121 +0,0 @@
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
- });
@@ -1,144 +0,0 @@
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 DELETED
@@ -1,97 +0,0 @@
1
- /**
2
- * Pi extension for Tailscale Aperture integration.
3
- *
4
- * Entry point orchestration:
5
- * - Load config
6
- * - Register session_start hook for provider registration
7
- * - Register user commands
8
- */
9
-
10
- import type {
11
- ExtensionAPI,
12
- ExtensionContext,
13
- } from "@mariozechner/pi-coding-agent";
14
- import { registerApertureSettings } from "./commands/settings";
15
- import { registerSetupCommand } from "./commands/setup";
16
- import { ApertureRuntime } from "./extension/runtime";
17
- import { configLoader } from "./lib/config";
18
- import { resolveGatewayUrl } from "./lib/url";
19
-
20
- export default async function (pi: ExtensionAPI): Promise<void> {
21
- await configLoader.load();
22
-
23
- const runtime = new ApertureRuntime();
24
- let lastRegisteredProviders: string[] = [
25
- ...configLoader.getConfig().providers,
26
- ];
27
-
28
- // Sync function used by commands after config changes
29
- const onSync = (ctx: ExtensionContext): void => {
30
- const config = configLoader.getConfig();
31
-
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",
44
- );
45
- }
46
-
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
- });
68
-
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,
79
- );
80
- }
81
- }
82
-
83
- lastRegisteredProviders = [...nextProviders];
84
- };
85
-
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
- });
94
-
95
- registerSetupCommand(pi, onSync);
96
- registerApertureSettings(pi, onSync);
97
- }
package/src/lib/config.ts DELETED
@@ -1,32 +0,0 @@
1
- /**
2
- * Configuration schema and loader for the Aperture extension.
3
- *
4
- * ApertureConfig is the user-facing schema (all fields optional).
5
- * ResolvedConfig is the internal schema (all fields required, defaults applied).
6
- */
7
-
8
- import { ConfigLoader } from "@aliou/pi-utils-settings";
9
-
10
- export interface ApertureConfig {
11
- baseUrl?: string;
12
- providers?: string[];
13
- checkGatewayModels?: string[];
14
- }
15
-
16
- export interface ResolvedConfig {
17
- baseUrl: string;
18
- providers: string[];
19
- checkGatewayModels: string[];
20
- }
21
-
22
- const DEFAULT_CONFIG: ResolvedConfig = {
23
- baseUrl: "",
24
- providers: [],
25
- checkGatewayModels: [],
26
- };
27
-
28
- export const configLoader = new ConfigLoader<ApertureConfig, ResolvedConfig>(
29
- "aperture",
30
- DEFAULT_CONFIG,
31
- { scopes: ["global"] },
32
- );
@@ -1,61 +0,0 @@
1
- /**
2
- * Gateway health and model checking.
3
- */
4
-
5
- export interface HealthCheckResult {
6
- ok: boolean;
7
- error?: string;
8
- }
9
-
10
- export async function checkApertureHealth(
11
- baseUrl: string,
12
- ): Promise<HealthCheckResult> {
13
- const url = `${baseUrl.replace(/\/+$/, "")}/v1/models`;
14
- try {
15
- const res = await fetch(url, {
16
- method: "GET",
17
- signal: AbortSignal.timeout(5000),
18
- });
19
- if (!res.ok) {
20
- return { ok: false, error: `HTTP ${res.status} ${res.statusText}` };
21
- }
22
- return { ok: true };
23
- } catch (e: unknown) {
24
- const msg = e instanceof Error ? e.message : String(e);
25
- return { ok: false, error: msg };
26
- }
27
- }
28
-
29
- export interface GatewayModel {
30
- id: string;
31
- providerId: string;
32
- }
33
-
34
- export async function fetchGatewayModels(
35
- baseUrl: string,
36
- ): Promise<GatewayModel[]> {
37
- const url = `${baseUrl.replace(/\/+$/, "")}/v1/models`;
38
- try {
39
- const res = await fetch(url, {
40
- method: "GET",
41
- signal: AbortSignal.timeout(5000),
42
- });
43
- if (!res.ok) return [];
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
- );
58
- } catch {
59
- return [];
60
- }
61
- }