@aliou/pi-ts-aperture 0.3.2 → 0.4.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/package.json +9 -8
- package/src/commands/settings.ts +36 -0
- package/src/commands/setup.ts +144 -237
- package/src/config.ts +3 -0
- package/src/core/index.ts +7 -0
- package/src/core/plan.test.ts +253 -0
- package/src/core/plan.ts +107 -0
- package/src/core/types.ts +33 -0
- package/src/core/url.test.ts +130 -0
- package/src/core/url.ts +42 -0
- package/src/index.ts +87 -25
- package/src/providers/aperture.ts +36 -69
|
@@ -0,0 +1,253 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
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
|
+
});
|
package/src/core/url.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure URL helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ApertureConfig } from "./types";
|
|
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
|
+
}
|