offrouter-core 0.2.0 → 0.2.2

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 (139) hide show
  1. package/dist/audit.d.ts +137 -0
  2. package/dist/audit.d.ts.map +1 -0
  3. package/dist/audit.js +320 -0
  4. package/dist/audit.js.map +1 -0
  5. package/dist/auth/credential-chain.d.ts +84 -0
  6. package/dist/auth/credential-chain.d.ts.map +1 -0
  7. package/dist/auth/credential-chain.js +150 -0
  8. package/dist/auth/credential-chain.js.map +1 -0
  9. package/dist/auth/index.d.ts +10 -0
  10. package/dist/auth/index.d.ts.map +1 -0
  11. package/dist/auth/index.js +7 -0
  12. package/dist/auth/index.js.map +1 -0
  13. package/dist/auth/keychain.d.ts +60 -0
  14. package/dist/auth/keychain.d.ts.map +1 -0
  15. package/dist/auth/keychain.js +104 -0
  16. package/dist/auth/keychain.js.map +1 -0
  17. package/dist/auth/oauth-pkce.d.ts +138 -0
  18. package/dist/auth/oauth-pkce.d.ts.map +1 -0
  19. package/dist/auth/oauth-pkce.js +375 -0
  20. package/dist/auth/oauth-pkce.js.map +1 -0
  21. package/dist/auth/oauth-server.d.ts +36 -0
  22. package/dist/auth/oauth-server.d.ts.map +1 -0
  23. package/dist/auth/oauth-server.js +210 -0
  24. package/dist/auth/oauth-server.js.map +1 -0
  25. package/dist/config.d.ts +76 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +355 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/delegation.d.ts +123 -0
  30. package/dist/delegation.d.ts.map +1 -0
  31. package/dist/delegation.js +455 -0
  32. package/dist/delegation.js.map +1 -0
  33. package/dist/index.d.ts +41 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +26 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/policy.d.ts +45 -0
  38. package/dist/policy.d.ts.map +1 -0
  39. package/dist/policy.js +318 -0
  40. package/dist/policy.js.map +1 -0
  41. package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
  42. package/dist/protocol.d.ts.map +1 -0
  43. package/dist/protocol.js +6 -0
  44. package/dist/protocol.js.map +1 -0
  45. package/dist/providers/adapter.d.ts +137 -0
  46. package/dist/providers/adapter.d.ts.map +1 -0
  47. package/dist/providers/adapter.js +163 -0
  48. package/dist/providers/adapter.js.map +1 -0
  49. package/dist/providers/catalog-data.d.ts +128 -0
  50. package/dist/providers/catalog-data.d.ts.map +1 -0
  51. package/dist/providers/catalog-data.js +397 -0
  52. package/dist/providers/catalog-data.js.map +1 -0
  53. package/dist/providers/catalog.d.ts +63 -0
  54. package/dist/providers/catalog.d.ts.map +1 -0
  55. package/dist/providers/catalog.js +394 -0
  56. package/dist/providers/catalog.js.map +1 -0
  57. package/dist/providers/fake.d.ts +46 -0
  58. package/dist/providers/fake.d.ts.map +1 -0
  59. package/dist/providers/fake.js +234 -0
  60. package/dist/providers/fake.js.map +1 -0
  61. package/dist/providers/openai-compatible.d.ts +65 -0
  62. package/dist/providers/openai-compatible.d.ts.map +1 -0
  63. package/dist/providers/openai-compatible.js +434 -0
  64. package/dist/providers/openai-compatible.js.map +1 -0
  65. package/dist/proxy/responses-mapper.d.ts +366 -0
  66. package/dist/proxy/responses-mapper.d.ts.map +1 -0
  67. package/dist/proxy/responses-mapper.js +517 -0
  68. package/dist/proxy/responses-mapper.js.map +1 -0
  69. package/dist/proxy/responses-server.d.ts +29 -0
  70. package/dist/proxy/responses-server.d.ts.map +1 -0
  71. package/dist/proxy/responses-server.js +360 -0
  72. package/dist/proxy/responses-server.js.map +1 -0
  73. package/dist/router.d.ts +18 -0
  74. package/dist/router.d.ts.map +1 -0
  75. package/dist/router.js +296 -0
  76. package/dist/router.js.map +1 -0
  77. package/dist/schemas.d.ts +560 -0
  78. package/dist/schemas.d.ts.map +1 -0
  79. package/{src/schemas.ts → dist/schemas.js} +83 -103
  80. package/dist/schemas.js.map +1 -0
  81. package/dist/secrets.d.ts +112 -0
  82. package/dist/secrets.d.ts.map +1 -0
  83. package/dist/secrets.js +326 -0
  84. package/dist/secrets.js.map +1 -0
  85. package/dist/types.d.ts +117 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +6 -0
  88. package/dist/types.js.map +1 -0
  89. package/dist/usage-file.d.ts +59 -0
  90. package/dist/usage-file.d.ts.map +1 -0
  91. package/dist/usage-file.js +316 -0
  92. package/dist/usage-file.js.map +1 -0
  93. package/dist/usage.d.ts +235 -0
  94. package/dist/usage.d.ts.map +1 -0
  95. package/dist/usage.js +517 -0
  96. package/dist/usage.js.map +1 -0
  97. package/package.json +9 -4
  98. package/src/audit.test.ts +0 -302
  99. package/src/audit.ts +0 -553
  100. package/src/auth/credential-chain.test.ts +0 -326
  101. package/src/auth/credential-chain.ts +0 -235
  102. package/src/auth/index.ts +0 -45
  103. package/src/auth/keychain.test.ts +0 -265
  104. package/src/auth/keychain.ts +0 -168
  105. package/src/auth/oauth-pkce.test.ts +0 -329
  106. package/src/auth/oauth-pkce.ts +0 -571
  107. package/src/auth/oauth-server.test.ts +0 -83
  108. package/src/auth/oauth-server.ts +0 -296
  109. package/src/config.test.ts +0 -479
  110. package/src/config.ts +0 -505
  111. package/src/delegation.test.ts +0 -368
  112. package/src/delegation.ts +0 -605
  113. package/src/index.ts +0 -280
  114. package/src/policy.test.ts +0 -634
  115. package/src/policy.ts +0 -420
  116. package/src/providers/adapter.test.ts +0 -293
  117. package/src/providers/adapter.ts +0 -328
  118. package/src/providers/catalog-data.test.ts +0 -258
  119. package/src/providers/catalog-data.ts +0 -498
  120. package/src/providers/catalog.test.ts +0 -84
  121. package/src/providers/catalog.ts +0 -483
  122. package/src/providers/fake.ts +0 -312
  123. package/src/providers/openai-compatible.test.ts +0 -366
  124. package/src/providers/openai-compatible.ts +0 -554
  125. package/src/proxy/responses-mapper.test.ts +0 -290
  126. package/src/proxy/responses-mapper.ts +0 -736
  127. package/src/proxy/responses-server.test.ts +0 -322
  128. package/src/proxy/responses-server.ts +0 -469
  129. package/src/router.test.ts +0 -699
  130. package/src/router.ts +0 -352
  131. package/src/schemas.test.ts +0 -291
  132. package/src/secrets.test.ts +0 -271
  133. package/src/secrets.ts +0 -461
  134. package/src/types.ts +0 -173
  135. package/src/usage-file.test.ts +0 -243
  136. package/src/usage-file.ts +0 -435
  137. package/src/usage.test.ts +0 -335
  138. package/src/usage.ts +0 -859
  139. package/tsconfig.json +0 -9
@@ -1,328 +0,0 @@
1
- /**
2
- * Provider adapter contract shared by fake and OpenAI-compatible backends.
3
- * Keeps harness-specific shapes out of the core router.
4
- */
5
-
6
- import type {
7
- AuthScope,
8
- AuthTier,
9
- ProviderCandidate,
10
- ProviderHealth,
11
- RouteRequest,
12
- SubscriptionStatus,
13
- } from "../types.js";
14
- import { redact } from "../secrets.js";
15
-
16
- // ---------------------------------------------------------------------------
17
- // Canonical provider errors
18
- // ---------------------------------------------------------------------------
19
-
20
- export type ProviderErrorCode =
21
- | "auth_failed"
22
- | "rate_limited"
23
- | "quota_exhausted"
24
- | "billing"
25
- | "overload"
26
- | "model_not_found"
27
- | "context_window"
28
- | "safety_refusal"
29
- | "stream_interrupted"
30
- | "cancelled"
31
- | "timeout"
32
- | "network"
33
- | "invalid_request"
34
- | "unknown";
35
-
36
- export interface ProviderErrorOptions {
37
- code: ProviderErrorCode;
38
- message: string;
39
- retryable?: boolean;
40
- httpStatus?: number;
41
- providerCode?: string;
42
- cause?: unknown;
43
- }
44
-
45
- export class ProviderError extends Error {
46
- readonly code: ProviderErrorCode;
47
- readonly retryable: boolean;
48
- readonly httpStatus?: number;
49
- readonly providerCode?: string;
50
-
51
- constructor(options: ProviderErrorOptions) {
52
- super(
53
- options.message,
54
- options.cause !== undefined ? { cause: options.cause } : undefined,
55
- );
56
- this.name = "ProviderError";
57
- this.code = options.code;
58
- this.retryable = options.retryable ?? false;
59
- this.httpStatus = options.httpStatus;
60
- this.providerCode = options.providerCode;
61
- }
62
- }
63
-
64
- export function isProviderError(value: unknown): value is ProviderError {
65
- return value instanceof ProviderError;
66
- }
67
-
68
- /**
69
- * Map HTTP status + optional provider body into a ProviderError.
70
- * Presence-only; never logs credential material.
71
- */
72
- export function mapHttpToProviderError(
73
- status: number,
74
- bodyText?: string,
75
- providerCode?: string,
76
- ): ProviderError {
77
- const snippet =
78
- typeof bodyText === "string" && bodyText.length > 0
79
- ? redact(bodyText.slice(0, 240))
80
- : undefined;
81
- const base = snippet ?? `HTTP ${status}`;
82
-
83
- if (status === 401 || status === 403) {
84
- return new ProviderError({
85
- code: "auth_failed",
86
- message: base,
87
- retryable: false,
88
- httpStatus: status,
89
- providerCode,
90
- });
91
- }
92
- if (status === 404) {
93
- return new ProviderError({
94
- code: "model_not_found",
95
- message: base,
96
- retryable: false,
97
- httpStatus: status,
98
- providerCode,
99
- });
100
- }
101
- if (status === 408 || status === 504) {
102
- return new ProviderError({
103
- code: "timeout",
104
- message: base,
105
- retryable: true,
106
- httpStatus: status,
107
- providerCode,
108
- });
109
- }
110
- if (status === 429) {
111
- const lower = (bodyText ?? "").toLowerCase();
112
- if (
113
- lower.includes("quota") ||
114
- lower.includes("billing") ||
115
- lower.includes("insufficient")
116
- ) {
117
- return new ProviderError({
118
- code: "quota_exhausted",
119
- message: base,
120
- retryable: false,
121
- httpStatus: status,
122
- providerCode,
123
- });
124
- }
125
- return new ProviderError({
126
- code: "rate_limited",
127
- message: base,
128
- retryable: true,
129
- httpStatus: status,
130
- providerCode,
131
- });
132
- }
133
- if (status === 402) {
134
- return new ProviderError({
135
- code: "billing",
136
- message: base,
137
- retryable: false,
138
- httpStatus: status,
139
- providerCode,
140
- });
141
- }
142
- if (status === 413) {
143
- return new ProviderError({
144
- code: "context_window",
145
- message: base,
146
- retryable: false,
147
- httpStatus: status,
148
- providerCode,
149
- });
150
- }
151
- if (status === 503 || status === 529) {
152
- return new ProviderError({
153
- code: "overload",
154
- message: base,
155
- retryable: true,
156
- httpStatus: status,
157
- providerCode,
158
- });
159
- }
160
- if (status >= 500) {
161
- return new ProviderError({
162
- code: "network",
163
- message: base,
164
- retryable: true,
165
- httpStatus: status,
166
- providerCode,
167
- });
168
- }
169
- if (status >= 400) {
170
- return new ProviderError({
171
- code: "invalid_request",
172
- message: base,
173
- retryable: false,
174
- httpStatus: status,
175
- providerCode,
176
- });
177
- }
178
- return new ProviderError({
179
- code: "unknown",
180
- message: base,
181
- retryable: false,
182
- httpStatus: status,
183
- providerCode,
184
- });
185
- }
186
-
187
- // ---------------------------------------------------------------------------
188
- // Supporting types
189
- // ---------------------------------------------------------------------------
190
-
191
- export interface ModelCapability {
192
- modelId: string;
193
- displayName?: string;
194
- supportsTools: boolean;
195
- supportsStreaming: boolean;
196
- supportsJson: boolean;
197
- supportsImages: boolean;
198
- contextWindowTokens?: number;
199
- maxOutputTokens?: number;
200
- estimatedCostUsd?: number;
201
- }
202
-
203
- export interface AuthStatus {
204
- authTier: AuthTier;
205
- authScope: AuthScope;
206
- configured: boolean;
207
- /** Presence-only; never returns key material. */
208
- hasApiKey: boolean;
209
- subscriptionStatus?: SubscriptionStatus;
210
- limitsDiscoverable: boolean;
211
- health: ProviderHealth;
212
- accountId?: string;
213
- detail?: string;
214
- /**
215
- * Which credential source satisfied auth (if any).
216
- * "none" when no credential was found.
217
- */
218
- credentialSource?: "env" | "config" | "keychain" | "oauth" | "none";
219
- /** Whether this auth path uses a subscription-backed credential. */
220
- subscriptionEligible: boolean;
221
- }
222
-
223
- /**
224
- * Limit snapshot. Opaque providers report discoverable=false and omit remaining.
225
- */
226
- export interface LimitSnapshot {
227
- authTier: AuthTier;
228
- /** When false, remaining/reset fields are unknown; rely on errors for fallback. */
229
- discoverable: boolean;
230
- opaque: boolean;
231
- remaining?: number;
232
- limit?: number;
233
- resetAt?: string;
234
- unit?: string;
235
- accountId?: string;
236
- metadata?: Record<string, string | number | boolean | null>;
237
- }
238
-
239
- export interface CostEstimate {
240
- model: string;
241
- authTier: AuthTier;
242
- estimatedCostUsd: number;
243
- currency: "USD";
244
- inputTokensEstimate?: number;
245
- outputTokensEstimate?: number;
246
- note?: string;
247
- }
248
-
249
- export interface ProviderInvokeMessage {
250
- role: "system" | "user" | "assistant" | "tool";
251
- content: string;
252
- }
253
-
254
- export interface ProviderInvokeRequest {
255
- callId: string;
256
- model: string;
257
- messages: ProviderInvokeMessage[];
258
- stream?: boolean;
259
- maxOutputTokens?: number;
260
- temperature?: number;
261
- /** Optional abort signal for cancellation races. */
262
- signal?: AbortSignal;
263
- }
264
-
265
- export type ProviderEvent =
266
- | { type: "start"; callId: string; model: string }
267
- | { type: "text-delta"; callId: string; text: string }
268
- | { type: "tool-call"; callId: string; name: string; arguments: string }
269
- | {
270
- type: "usage";
271
- callId: string;
272
- inputTokens?: number;
273
- outputTokens?: number;
274
- totalTokens?: number;
275
- }
276
- | { type: "done"; callId: string; finishReason?: string }
277
- | { type: "error"; callId: string; error: ProviderError };
278
-
279
- export interface ProviderAdapter {
280
- readonly id: string;
281
- listModels(): Promise<ModelCapability[]>;
282
- checkAuth(): Promise<AuthStatus>;
283
- queryLimits(authTier: AuthTier): Promise<LimitSnapshot>;
284
- estimate(
285
- request: RouteRequest,
286
- model: string,
287
- authTier: AuthTier,
288
- ): Promise<CostEstimate>;
289
- invoke(request: ProviderInvokeRequest): AsyncIterable<ProviderEvent>;
290
- cancel(callId: string): Promise<void>;
291
- }
292
-
293
- /** Required method names on every adapter. */
294
- export const PROVIDER_ADAPTER_METHODS = [
295
- "listModels",
296
- "checkAuth",
297
- "queryLimits",
298
- "estimate",
299
- "invoke",
300
- "cancel",
301
- ] as const satisfies readonly (keyof ProviderAdapter)[];
302
-
303
- /** Convert catalog models to ProviderCandidate rows for routing tests. */
304
- export function capabilityToCandidate(
305
- providerId: string,
306
- capability: ModelCapability,
307
- extras: Pick<ProviderCandidate, "authTier" | "authScope"> &
308
- Partial<ProviderCandidate>,
309
- ): ProviderCandidate {
310
- return {
311
- providerId,
312
- modelId: capability.modelId,
313
- displayName: capability.displayName,
314
- authTier: extras.authTier,
315
- authScope: extras.authScope,
316
- subscriptionStatus: extras.subscriptionStatus,
317
- health: extras.health ?? "healthy",
318
- supportsTools: capability.supportsTools,
319
- supportsStreaming: capability.supportsStreaming,
320
- supportsJson: capability.supportsJson,
321
- supportsImages: capability.supportsImages,
322
- contextWindowTokens: capability.contextWindowTokens,
323
- maxOutputTokens: capability.maxOutputTokens,
324
- estimatedCostUsd: extras.estimatedCostUsd ?? capability.estimatedCostUsd,
325
- accountId: extras.accountId,
326
- subscriptionQuotaPercentRemaining: extras.subscriptionQuotaPercentRemaining,
327
- };
328
- }
@@ -1,258 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- BUILTIN_PROVIDERS,
4
- BUILTIN_PROVIDER_IDS,
5
- BuiltinCatalogConfigSchema,
6
- ProviderCatalogConfigSchema,
7
- getBuiltinProvider,
8
- parseBuiltinCatalogConfig,
9
- selectBuiltinProviders,
10
- type BuiltinCatalogConfig,
11
- type BuiltinProviderEntry,
12
- type ProviderCatalogConfig,
13
- } from "./catalog-data.js";
14
- import {
15
- STATIC_CATALOG,
16
- loadBuiltinCatalog,
17
- } from "./catalog.js";
18
-
19
- // Substrings that should never appear in metadata-only catalog values.
20
- const SECRET_VALUE_PATTERNS = ["sk-", "Bearer ", "x-api-key:", "password="];
21
- // Field names that must not exist in a metadata-only catalog.
22
- const SECRET_KEY_RE = /(?:^|_)(key|secret|token|password|credential)(?:_|$)/i;
23
-
24
- function collectKeyNames(value: unknown, into: string[]): void {
25
- if (Array.isArray(value)) {
26
- for (const v of value) collectKeyNames(v, into);
27
- return;
28
- }
29
- if (value && typeof value === "object") {
30
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
31
- into.push(k);
32
- collectKeyNames(v, into);
33
- }
34
- }
35
- }
36
-
37
- function hasNoSecrets(value: unknown): boolean {
38
- const text = JSON.stringify(value ?? "");
39
- if (SECRET_VALUE_PATTERNS.some((p) => text.includes(p))) return false;
40
- const names: string[] = [];
41
- collectKeyNames(value, names);
42
- return !names.some((n) => SECRET_KEY_RE.test(n));
43
- }
44
-
45
- describe("builtin provider catalog data", () => {
46
- it("loads a non-empty builtin catalog offline", () => {
47
- expect(BUILTIN_PROVIDERS.length).toBeGreaterThan(0);
48
- for (const provider of BUILTIN_PROVIDERS) {
49
- expect(provider.providerId).toBeTruthy();
50
- expect(provider.displayName).toBeTruthy();
51
- expect(provider.flavor).toBeTruthy();
52
- expect(Array.isArray(provider.knownModels)).toBe(true);
53
- }
54
- });
55
-
56
- it("covers the required provider families", () => {
57
- const ids = new Set(BUILTIN_PROVIDER_IDS);
58
- for (const id of [
59
- "openai",
60
- "anthropic",
61
- "google",
62
- "xai",
63
- "openrouter",
64
- "zai",
65
- "moonshot",
66
- "ollama",
67
- "lm-studio",
68
- "custom",
69
- ]) {
70
- expect(ids.has(id), `missing provider ${id}`).toBe(true);
71
- }
72
- });
73
-
74
- it("assigns correct default authTier and authScope per family", () => {
75
- const byId = new Map(
76
- BUILTIN_PROVIDERS.map((p) => [p.providerId, p] as const),
77
- );
78
- // Pure API providers default to api-key.
79
- expect(byId.get("openai")?.defaultAuthTier).toBe("api-key");
80
- expect(byId.get("anthropic")?.defaultAuthTier).toBe("api-key");
81
- expect(byId.get("google")?.defaultAuthTier).toBe("api-key");
82
- expect(byId.get("xai")?.defaultAuthTier).toBe("api-key");
83
- expect(byId.get("zai")?.defaultAuthTier).toBe("api-key");
84
- expect(byId.get("moonshot")?.defaultAuthTier).toBe("api-key");
85
- // Aggregator is third-party api-key.
86
- expect(byId.get("openrouter")?.defaultAuthTier).toBe("api-key");
87
- expect(byId.get("openrouter")?.defaultAuthScope).toBe("third-party");
88
- // Local runtimes.
89
- expect(byId.get("ollama")?.defaultAuthTier).toBe("local");
90
- expect(byId.get("lm-studio")?.defaultAuthTier).toBe("local");
91
- // Custom endpoint is a local OpenAI-compatible gateway by default; scope
92
- // is unknown until configured.
93
- expect(byId.get("custom")?.defaultAuthTier).toBe("local");
94
- expect(byId.get("custom")?.defaultAuthScope).toBe("unknown");
95
- // First-party API vendors carry first-party scope (direct vendor apis;
96
- // aggregators like openrouter stay third-party).
97
- expect(byId.get("openai")?.defaultAuthScope).toBe("first-party");
98
- expect(byId.get("anthropic")?.defaultAuthScope).toBe("first-party");
99
- expect(byId.get("google")?.defaultAuthScope).toBe("first-party");
100
- expect(byId.get("zai")?.defaultAuthScope).toBe("first-party");
101
- expect(byId.get("moonshot")?.defaultAuthScope).toBe("first-party");
102
- });
103
-
104
- it("documents the official subscription surface without auto-promoting api-key", () => {
105
- for (const provider of BUILTIN_PROVIDERS) {
106
- expect(typeof provider.supportsSubscriptionRouting).toBe("boolean");
107
- // API-key and local providers never claim a routing surface they lack.
108
- if (provider.defaultAuthTier !== "subscription") {
109
- // The flag is informational; subscription routing itself is the job of
110
- // the harness adapters, not this metadata.
111
- expect(provider.supportsSubscriptionRouting).toBe(false);
112
- }
113
- }
114
- });
115
-
116
- it("looks up a builtin provider by id", () => {
117
- expect(getBuiltinProvider("ollama")?.flavor).toBe("ollama");
118
- expect(getBuiltinProvider("missing")).toBeUndefined();
119
- });
120
-
121
- it("excludes disabled providers from selectBuiltinProviders", () => {
122
- const config: BuiltinCatalogConfig = { openai: { enabled: false } };
123
- const selected = selectBuiltinProviders(config);
124
- expect(selected.find((p) => p.providerId === "openai")).toBeUndefined();
125
- expect(selected.find((p) => p.providerId === "anthropic")).toBeDefined();
126
- });
127
-
128
- it("applies base URL overrides from provider config", () => {
129
- const config: BuiltinCatalogConfig = {
130
- custom: { enabled: true, baseUrl: "https://gateway.example.test/v1" },
131
- };
132
- const selected = selectBuiltinProviders(config);
133
- const custom = selected.find((p) => p.providerId === "custom");
134
- expect(custom?.defaultBaseUrl).toBe("https://gateway.example.test/v1");
135
- });
136
-
137
- it("returns copies so callers cannot mutate the static source", () => {
138
- const selected = selectBuiltinProviders();
139
- const before = getBuiltinProvider("openai")?.defaultBaseUrl;
140
- selected[0]!.defaultBaseUrl = "mutated";
141
- expect(getBuiltinProvider("openai")?.defaultBaseUrl).toBe(before);
142
- });
143
-
144
- it("custom OpenAI-compatible endpoint entry is usable when configured", () => {
145
- const custom = getBuiltinProvider("custom") as BuiltinProviderEntry;
146
- expect(custom.flavor).toBe("openai");
147
- const entries = loadBuiltinCatalog({
148
- providers: {
149
- custom: {
150
- enabled: true,
151
- baseUrl: "https://gateway.example.test/v1",
152
- } satisfies ProviderCatalogConfig,
153
- },
154
- });
155
- const customRow = entries.find((e) => e.providerId === "custom");
156
- expect(customRow).toBeDefined();
157
- });
158
-
159
- it("does not auto-promote api-key over subscription for the same family", () => {
160
- const entries = loadBuiltinCatalog({ families: ["frontier-coder"] });
161
- const tiers = new Set(entries.map((e) => e.authTier));
162
- expect(tiers.has("subscription")).toBe(true);
163
- expect(tiers.has("api-key")).toBe(true);
164
- // Subscription entries must survive the merge (never replaced by api-key).
165
- expect(entries.some((e) => e.authTier === "subscription")).toBe(true);
166
- });
167
-
168
- it("subscription-backed and api-key candidates coexist for shared families", () => {
169
- for (const family of ["frontier-coder", "fast-coder"] as const) {
170
- const entries = loadBuiltinCatalog({ families: [family] });
171
- const tiers = new Set(entries.map((e) => e.authTier));
172
- expect(tiers.has("subscription"), `${family} subscription`).toBe(true);
173
- expect(tiers.has("api-key"), `${family} api-key`).toBe(true);
174
- }
175
- });
176
-
177
- it("merges builtin entries with the existing static catalog", () => {
178
- const merged = loadBuiltinCatalog();
179
- // Static subscription rows must still be present.
180
- const staticIds = new Set(STATIC_CATALOG.map((e) => e.providerId));
181
- for (const id of staticIds) {
182
- expect(
183
- merged.some((e) => e.providerId === id),
184
- `static provider ${id} dropped`,
185
- ).toBe(true);
186
- }
187
- // Builtin-only providers must appear too.
188
- expect(merged.some((e) => e.providerId === "openai")).toBe(true);
189
- expect(merged.some((e) => e.providerId === "anthropic")).toBe(true);
190
- });
191
-
192
- it("excludes disabled providers across both catalog sources", () => {
193
- const entries = loadBuiltinCatalog({
194
- providers: { openrouter: { enabled: false } },
195
- });
196
- expect(entries.every((e) => e.providerId !== "openrouter")).toBe(true);
197
- });
198
-
199
- it("dedups identical rows across static and builtin sources", () => {
200
- // ollama/llava:latest appears in BOTH STATIC_CATALOG (vision-helper, local)
201
- // and the builtin ollama entry with identical providerId/modelId/tier/
202
- // family, so the structured dedup key must collapse them to a single row.
203
- const merged = loadBuiltinCatalog();
204
- const llava = merged.filter(
205
- (e) =>
206
- e.providerId === "ollama" &&
207
- e.modelId === "llava:latest" &&
208
- e.authTier === "local" &&
209
- e.family === "vision-helper",
210
- );
211
- expect(llava.length).toBe(1);
212
- });
213
-
214
- it("contains no secrets anywhere in the catalog", () => {
215
- expect(hasNoSecrets(BUILTIN_PROVIDERS)).toBe(true);
216
- expect(hasNoSecrets(selectBuiltinProviders())).toBe(true);
217
- expect(hasNoSecrets(loadBuiltinCatalog())).toBe(true);
218
- });
219
-
220
- it("every builtin catalog row carries authTier and authScope", () => {
221
- const entries = loadBuiltinCatalog();
222
- expect(entries.length).toBeGreaterThan(0);
223
- for (const e of entries) {
224
- expect(["subscription", "local", "api-key"]).toContain(e.authTier);
225
- expect(["first-party", "third-party", "unknown"]).toContain(e.authScope);
226
- }
227
- });
228
-
229
- it("Zod schemas reject invalid catalog config", () => {
230
- // Wrong type for enabled.
231
- expect(() =>
232
- ProviderCatalogConfigSchema.parse({ enabled: "yes" }),
233
- ).toThrow();
234
- // Unknown key rejected by strict schema.
235
- expect(() =>
236
- ProviderCatalogConfigSchema.parse({ nope: true }),
237
- ).toThrow();
238
- // baseUrl must be a string.
239
- expect(() =>
240
- ProviderCatalogConfigSchema.parse({ baseUrl: 42 }),
241
- ).toThrow();
242
- // Record schema rejects non-object provider entries.
243
- expect(() =>
244
- BuiltinCatalogConfigSchema.parse({ openai: "on" }),
245
- ).toThrow();
246
- });
247
-
248
- it("parseBuiltinCatalogConfig accepts valid config and rejects invalid", () => {
249
- expect(() =>
250
- parseBuiltinCatalogConfig({
251
- openai: { enabled: true, baseUrl: "https://api.openai.com/v1" },
252
- }),
253
- ).not.toThrow();
254
- expect(() =>
255
- parseBuiltinCatalogConfig({ openai: { enabled: "true" } }),
256
- ).toThrow();
257
- });
258
- });