@oscharko-dev/keiko-model-gateway 0.2.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 (116) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/capabilities.d.ts +26 -0
  3. package/dist/capabilities.d.ts.map +1 -0
  4. package/dist/capabilities.data.d.ts +3 -0
  5. package/dist/capabilities.data.d.ts.map +1 -0
  6. package/dist/capabilities.data.js +5 -0
  7. package/dist/capabilities.js +169 -0
  8. package/dist/config.d.ts +34 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +733 -0
  11. package/dist/embedding.d.ts +38 -0
  12. package/dist/embedding.d.ts.map +1 -0
  13. package/dist/embedding.js +118 -0
  14. package/dist/gateway.d.ts +23 -0
  15. package/dist/gateway.d.ts.map +1 -0
  16. package/dist/gateway.js +144 -0
  17. package/dist/http.d.ts +24 -0
  18. package/dist/http.d.ts.map +1 -0
  19. package/dist/http.js +666 -0
  20. package/dist/index.d.ts +16 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +34 -0
  23. package/dist/model-selection.d.ts +22 -0
  24. package/dist/model-selection.d.ts.map +1 -0
  25. package/dist/model-selection.js +59 -0
  26. package/dist/normalize.d.ts +9 -0
  27. package/dist/normalize.d.ts.map +1 -0
  28. package/dist/normalize.js +114 -0
  29. package/dist/openai-adapter.d.ts +22 -0
  30. package/dist/openai-adapter.d.ts.map +1 -0
  31. package/dist/openai-adapter.js +382 -0
  32. package/dist/openai-embedding-adapter.d.ts +46 -0
  33. package/dist/openai-embedding-adapter.d.ts.map +1 -0
  34. package/dist/openai-embedding-adapter.js +271 -0
  35. package/dist/promptEnhancer/__tests__/_support.d.ts +15 -0
  36. package/dist/promptEnhancer/__tests__/_support.d.ts.map +1 -0
  37. package/dist/promptEnhancer/__tests__/_support.js +28 -0
  38. package/dist/promptEnhancer/__tests__/fixtures.d.ts +8 -0
  39. package/dist/promptEnhancer/__tests__/fixtures.d.ts.map +1 -0
  40. package/dist/promptEnhancer/__tests__/fixtures.js +58 -0
  41. package/dist/promptEnhancer/__tests__/grounding-fixtures.d.ts +11 -0
  42. package/dist/promptEnhancer/__tests__/grounding-fixtures.d.ts.map +1 -0
  43. package/dist/promptEnhancer/__tests__/grounding-fixtures.js +84 -0
  44. package/dist/promptEnhancer/candidates.d.ts +32 -0
  45. package/dist/promptEnhancer/candidates.d.ts.map +1 -0
  46. package/dist/promptEnhancer/candidates.js +109 -0
  47. package/dist/promptEnhancer/critic.d.ts +22 -0
  48. package/dist/promptEnhancer/critic.d.ts.map +1 -0
  49. package/dist/promptEnhancer/critic.js +237 -0
  50. package/dist/promptEnhancer/generator.d.ts +15 -0
  51. package/dist/promptEnhancer/generator.d.ts.map +1 -0
  52. package/dist/promptEnhancer/generator.js +424 -0
  53. package/dist/promptEnhancer/index.d.ts +16 -0
  54. package/dist/promptEnhancer/index.d.ts.map +1 -0
  55. package/dist/promptEnhancer/index.js +15 -0
  56. package/dist/promptEnhancer/optimize.d.ts +27 -0
  57. package/dist/promptEnhancer/optimize.d.ts.map +1 -0
  58. package/dist/promptEnhancer/optimize.js +203 -0
  59. package/dist/promptEnhancer/planner.d.ts +36 -0
  60. package/dist/promptEnhancer/planner.d.ts.map +1 -0
  61. package/dist/promptEnhancer/planner.js +55 -0
  62. package/dist/promptEnhancer/profiles.d.ts +20 -0
  63. package/dist/promptEnhancer/profiles.d.ts.map +1 -0
  64. package/dist/promptEnhancer/profiles.js +126 -0
  65. package/dist/promptEnhancer/rendering.d.ts +15 -0
  66. package/dist/promptEnhancer/rendering.d.ts.map +1 -0
  67. package/dist/promptEnhancer/rendering.js +72 -0
  68. package/dist/promptEnhancer/validate.d.ts +31 -0
  69. package/dist/promptEnhancer/validate.d.ts.map +1 -0
  70. package/dist/promptEnhancer/validate.js +144 -0
  71. package/dist/qualityIntelligence/budget.d.ts +10 -0
  72. package/dist/qualityIntelligence/budget.d.ts.map +1 -0
  73. package/dist/qualityIntelligence/budget.js +38 -0
  74. package/dist/qualityIntelligence/cancellation.d.ts +7 -0
  75. package/dist/qualityIntelligence/cancellation.d.ts.map +1 -0
  76. package/dist/qualityIntelligence/cancellation.js +58 -0
  77. package/dist/qualityIntelligence/capabilityGate.d.ts +13 -0
  78. package/dist/qualityIntelligence/capabilityGate.d.ts.map +1 -0
  79. package/dist/qualityIntelligence/capabilityGate.js +51 -0
  80. package/dist/qualityIntelligence/capabilityMapping.d.ts +4 -0
  81. package/dist/qualityIntelligence/capabilityMapping.d.ts.map +1 -0
  82. package/dist/qualityIntelligence/capabilityMapping.js +21 -0
  83. package/dist/qualityIntelligence/circuitBreaker.d.ts +26 -0
  84. package/dist/qualityIntelligence/circuitBreaker.d.ts.map +1 -0
  85. package/dist/qualityIntelligence/circuitBreaker.js +78 -0
  86. package/dist/qualityIntelligence/dispatcher.d.ts +38 -0
  87. package/dist/qualityIntelligence/dispatcher.d.ts.map +1 -0
  88. package/dist/qualityIntelligence/dispatcher.js +116 -0
  89. package/dist/qualityIntelligence/index.d.ts +20 -0
  90. package/dist/qualityIntelligence/index.d.ts.map +1 -0
  91. package/dist/qualityIntelligence/index.js +15 -0
  92. package/dist/qualityIntelligence/promptSegmentation.d.ts +13 -0
  93. package/dist/qualityIntelligence/promptSegmentation.d.ts.map +1 -0
  94. package/dist/qualityIntelligence/promptSegmentation.js +70 -0
  95. package/dist/qualityIntelligence/replayCache.d.ts +11 -0
  96. package/dist/qualityIntelligence/replayCache.d.ts.map +1 -0
  97. package/dist/qualityIntelligence/replayCache.js +72 -0
  98. package/dist/qualityIntelligence/routing.d.ts +11 -0
  99. package/dist/qualityIntelligence/routing.d.ts.map +1 -0
  100. package/dist/qualityIntelligence/routing.js +25 -0
  101. package/dist/qualityIntelligence/safeError.d.ts +38 -0
  102. package/dist/qualityIntelligence/safeError.d.ts.map +1 -0
  103. package/dist/qualityIntelligence/safeError.js +63 -0
  104. package/dist/qualityIntelligence/taskProfiles.d.ts +15 -0
  105. package/dist/qualityIntelligence/taskProfiles.d.ts.map +1 -0
  106. package/dist/qualityIntelligence/taskProfiles.js +101 -0
  107. package/dist/resilience.d.ts +26 -0
  108. package/dist/resilience.d.ts.map +1 -0
  109. package/dist/resilience.js +182 -0
  110. package/dist/types.d.ts +59 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +6 -0
  113. package/dist/version.d.ts +2 -0
  114. package/dist/version.d.ts.map +1 -0
  115. package/dist/version.js +3 -0
  116. package/package.json +47 -0
package/dist/config.js ADDED
@@ -0,0 +1,733 @@
1
+ // Gateway config loading, hand-rolled validation, and redaction-aware serialisation.
2
+ // No schema library: validation is explicit if/throw with actionable messages.
3
+ // API keys are sourced only from environment or the config file, never CLI flags,
4
+ // and are excluded from every serialisation path.
5
+ import { readFileSync } from "node:fs";
6
+ import { isIP } from "node:net";
7
+ import { ConfigInvalidError } from "@oscharko-dev/keiko-security/errors/gateway";
8
+ import { DEFAULT_GROUNDING_LIMITS, resolveGroundingLimits, } from "@oscharko-dev/keiko-contracts/bff-wire";
9
+ const DEFAULT_TIMEOUT_MS = 30_000;
10
+ const DEFAULT_MAX_RETRIES = 3;
11
+ const DEFAULT_RETRY_BASE_DELAY_MS = 500;
12
+ const DEFAULT_FAILURE_THRESHOLD = 5;
13
+ const DEFAULT_COOLDOWN_MS = 30_000;
14
+ const DEFAULT_HALF_OPEN_PROBES = 2;
15
+ export const DEFAULT_API_KEY_HEADER_NAME = "authorization";
16
+ const MAX_API_KEY_HEADER_NAME_LENGTH = 64;
17
+ const API_KEY_HEADER_NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
18
+ export const SUPPORTED_API_KEY_HEADER_NAMES = [
19
+ DEFAULT_API_KEY_HEADER_NAME,
20
+ "x-litellm-key",
21
+ "x-api-key",
22
+ "api-key",
23
+ ];
24
+ const SUPPORTED_API_KEY_HEADER_NAME_SET = new Set(SUPPORTED_API_KEY_HEADER_NAMES);
25
+ const BEARER_API_KEY_HEADER_NAME_SET = new Set([
26
+ DEFAULT_API_KEY_HEADER_NAME,
27
+ "x-litellm-key",
28
+ ]);
29
+ function isRecord(value) {
30
+ return typeof value === "object" && value !== null && !Array.isArray(value);
31
+ }
32
+ function requirePositiveInt(value, path) {
33
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
34
+ throw new ConfigInvalidError(`${path} must be a positive integer`);
35
+ }
36
+ return value;
37
+ }
38
+ function requireNonEmptyString(value, path) {
39
+ if (typeof value !== "string" || value.length === 0) {
40
+ throw new ConfigInvalidError(`${path} must be a non-empty string`);
41
+ }
42
+ return value;
43
+ }
44
+ function optionalStringArray(value, path, fallback) {
45
+ if (value === undefined) {
46
+ return fallback;
47
+ }
48
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
49
+ throw new ConfigInvalidError(`${path} must be an array of strings`);
50
+ }
51
+ return value;
52
+ }
53
+ function optionalNonNegativeInt(value, path, fallback) {
54
+ if (value === undefined) {
55
+ return fallback;
56
+ }
57
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
58
+ throw new ConfigInvalidError(`${path} must be a non-negative integer`);
59
+ }
60
+ return value;
61
+ }
62
+ function optionalBoolean(value, path, fallback) {
63
+ if (value === undefined) {
64
+ return fallback;
65
+ }
66
+ if (typeof value !== "boolean") {
67
+ throw new ConfigInvalidError(`${path} must be a boolean`);
68
+ }
69
+ return value;
70
+ }
71
+ function optionalNonEmptyString(value, path, fallback) {
72
+ if (value === undefined) {
73
+ return fallback;
74
+ }
75
+ return requireNonEmptyString(value, path);
76
+ }
77
+ function optionalTrimmedString(value, path) {
78
+ if (value === undefined)
79
+ return undefined;
80
+ if (typeof value !== "string") {
81
+ throw new ConfigInvalidError(`${path} must be a string`);
82
+ }
83
+ const trimmed = value.trim();
84
+ return trimmed.length === 0 ? undefined : trimmed;
85
+ }
86
+ function validateProxyUrl(value, path) {
87
+ let url;
88
+ try {
89
+ url = new URL(value);
90
+ }
91
+ catch {
92
+ throw new ConfigInvalidError(`${path} must be a valid absolute proxy URL`);
93
+ }
94
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
95
+ throw new ConfigInvalidError(`${path} must use the http or https scheme`);
96
+ }
97
+ if (url.username !== "" || url.password !== "") {
98
+ throw new ConfigInvalidError(`${path} must not embed credentials`);
99
+ }
100
+ if (url.search !== "" || url.hash !== "") {
101
+ throw new ConfigInvalidError(`${path} must not contain a query string or fragment`);
102
+ }
103
+ return url.toString();
104
+ }
105
+ function optionalProxyUrl(value, path) {
106
+ const raw = optionalTrimmedString(value, path);
107
+ return raw === undefined ? undefined : validateProxyUrl(raw, path);
108
+ }
109
+ function optionalCaBundlePath(value, path) {
110
+ return optionalTrimmedString(value, path);
111
+ }
112
+ function normalizeNoProxyItems(values) {
113
+ return Array.from(new Set(values
114
+ .flatMap((item) => item.split(","))
115
+ .map((item) => item.trim())
116
+ .filter(Boolean)));
117
+ }
118
+ function optionalNoProxy(value, path) {
119
+ if (value === undefined)
120
+ return undefined;
121
+ if (typeof value === "string") {
122
+ return normalizeNoProxyItems([value]);
123
+ }
124
+ if (Array.isArray(value) && value.every((item) => typeof item === "string")) {
125
+ return normalizeNoProxyItems(value);
126
+ }
127
+ throw new ConfigInvalidError(`${path} must be a string or an array of strings`);
128
+ }
129
+ function egressBlock(raw) {
130
+ if (raw !== undefined && !isRecord(raw)) {
131
+ throw new ConfigInvalidError("egress must be an object");
132
+ }
133
+ return isRecord(raw) ? raw : {};
134
+ }
135
+ function emptyToUndefined(config) {
136
+ return Object.keys(config).length === 0 ? undefined : config;
137
+ }
138
+ const EGRESS_FIELDS = [
139
+ {
140
+ key: "httpProxy",
141
+ envNames: ["KEIKO_HTTP_PROXY", "HTTP_PROXY", "http_proxy"],
142
+ parser: optionalProxyUrl,
143
+ },
144
+ {
145
+ key: "httpsProxy",
146
+ envNames: ["KEIKO_HTTPS_PROXY", "HTTPS_PROXY", "https_proxy"],
147
+ parser: optionalProxyUrl,
148
+ },
149
+ {
150
+ key: "noProxy",
151
+ envNames: ["KEIKO_NO_PROXY", "NO_PROXY", "no_proxy"],
152
+ parser: optionalNoProxy,
153
+ },
154
+ {
155
+ key: "caBundlePath",
156
+ envNames: ["KEIKO_CA_BUNDLE_PATH"],
157
+ parser: optionalCaBundlePath,
158
+ },
159
+ ];
160
+ function setEgressField(config, key, value) {
161
+ if (value !== undefined) {
162
+ config[key] = value;
163
+ }
164
+ }
165
+ function envVarForField(env, envNames) {
166
+ for (const name of envNames) {
167
+ const value = env[name];
168
+ if (value !== undefined && value.trim().length > 0)
169
+ return { name, value };
170
+ }
171
+ return undefined;
172
+ }
173
+ function warnInvalidEgressEnvVar(name, key) {
174
+ // Log the variable name only — never the value (may contain credentials).
175
+ // eslint-disable-next-line no-console
176
+ console.warn(`[keiko-model-gateway] Ignoring invalid egress env var ${name} (reason: ${key} parse failed)`);
177
+ }
178
+ export function resolveOutboundHttpEgressConfig(raw, env = {}) {
179
+ const block = egressBlock(raw);
180
+ const result = {};
181
+ for (const { key, parser } of EGRESS_FIELDS) {
182
+ setEgressField(result, key, parser(block[key], `egress.${key}`));
183
+ }
184
+ for (const { key, envNames, parser } of EGRESS_FIELDS) {
185
+ const envVar = envVarForField(env, envNames);
186
+ if (envVar === undefined)
187
+ continue;
188
+ try {
189
+ setEgressField(result, key, parser(envVar.value, `egress.${key}`));
190
+ }
191
+ catch {
192
+ warnInvalidEgressEnvVar(envVar.name, key);
193
+ }
194
+ }
195
+ return emptyToUndefined(result);
196
+ }
197
+ // Parses the four egress env vars INDEPENDENTLY so a malformed proxy URL (e.g. a
198
+ // credentialed HTTPS_PROXY) never silently discards a valid caBundlePath or noProxy.
199
+ // Each field is parsed in isolation; invalid fields are skipped with a console.warn
200
+ // (naming the var, never the value) and the rest are still applied.
201
+ export function parseEnvEgressConfigFaultTolerant(env) {
202
+ return resolveOutboundHttpEgressConfig(undefined, env) ?? {};
203
+ }
204
+ export function normalizeApiKeyHeaderName(value, path, fallback = DEFAULT_API_KEY_HEADER_NAME) {
205
+ if (value === undefined) {
206
+ return fallback;
207
+ }
208
+ if (typeof value !== "string") {
209
+ throw new ConfigInvalidError(`${path} must be a string`);
210
+ }
211
+ const headerName = value.trim().toLowerCase();
212
+ if (headerName.length === 0) {
213
+ return fallback;
214
+ }
215
+ if (headerName.length > MAX_API_KEY_HEADER_NAME_LENGTH ||
216
+ !API_KEY_HEADER_NAME_RE.test(headerName)) {
217
+ throw new ConfigInvalidError(`${path} must be a valid HTTP header name`);
218
+ }
219
+ if (!SUPPORTED_API_KEY_HEADER_NAME_SET.has(headerName)) {
220
+ throw new ConfigInvalidError(`${path} must be one of ${SUPPORTED_API_KEY_HEADER_NAMES.join(", ")}`);
221
+ }
222
+ return headerName;
223
+ }
224
+ export function apiKeyHeaderValue(headerName, apiKey) {
225
+ if (BEARER_API_KEY_HEADER_NAME_SET.has(headerName) &&
226
+ !apiKey.toLowerCase().startsWith("bearer ")) {
227
+ return `Bearer ${apiKey}`;
228
+ }
229
+ return apiKey;
230
+ }
231
+ function requireEnum(value, path, allowed) {
232
+ if (typeof value !== "string" || !allowed.includes(value)) {
233
+ throw new ConfigInvalidError(`${path} must be one of ${allowed.join(", ")}`);
234
+ }
235
+ return value;
236
+ }
237
+ // Model id → KEIKO_MODEL_<UPPER>_ form: non-alphanumerics become "_", uppercased.
238
+ function envModelToken(modelId) {
239
+ return modelId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase();
240
+ }
241
+ function resolveSecret(modelId, fileValue, env, suffix) {
242
+ const perModel = env[`KEIKO_MODEL_${envModelToken(modelId)}_${suffix}`];
243
+ if (perModel !== undefined && perModel.length > 0) {
244
+ return perModel;
245
+ }
246
+ if (fileValue.length > 0) {
247
+ return fileValue;
248
+ }
249
+ const fallback = env[`KEIKO_DEFAULT_${suffix}`];
250
+ return fallback ?? "";
251
+ }
252
+ function resolveSecretRef(rawRef, resolver) {
253
+ if (resolver === undefined || typeof rawRef !== "string" || rawRef.length === 0) {
254
+ return undefined;
255
+ }
256
+ // A resolver fault (e.g. a tampered or unreadable vault) must not crash config parsing; degrade to
257
+ // the next credential source so the provider either resolves elsewhere or fails the explicit
258
+ // "apiKey must be set" check below — never leaking a stack trace or partial key material.
259
+ try {
260
+ const resolved = resolver(rawRef);
261
+ return resolved !== undefined && resolved.length > 0 ? resolved : undefined;
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
267
+ // Resolves a provider's effective apiKey. Precedence (highest first):
268
+ // 1. per-model env KEIKO_MODEL_<id>_API_KEY — transient operator override, never persisted
269
+ // 2. vault secretResolver(apiKeySecretRef) — durable encrypted store (Issue #1320)
270
+ // 3. file plaintext raw.apiKey — legacy, tolerated until migrated to the vault
271
+ // 4. default env KEIKO_DEFAULT_API_KEY — final fallback
272
+ // The env tiers keep their existing positions so environment credentials stay transient and win as
273
+ // runtime overrides; the vault simply occupies the slot the legacy plaintext file value used to own.
274
+ function resolveProviderApiKey(raw, modelId, fileApiKey, env, options) {
275
+ const perModel = env[`KEIKO_MODEL_${envModelToken(modelId)}_API_KEY`];
276
+ if (perModel !== undefined && perModel.length > 0) {
277
+ return perModel;
278
+ }
279
+ const fromVault = resolveSecretRef(raw.apiKeySecretRef, options.secretResolver);
280
+ if (fromVault !== undefined) {
281
+ return fromVault;
282
+ }
283
+ if (fileApiKey.length > 0) {
284
+ return fileApiKey;
285
+ }
286
+ return env.KEIKO_DEFAULT_API_KEY ?? "";
287
+ }
288
+ function resolveApiKeyHeaderName(rawValue, path, modelId, env) {
289
+ const token = envModelToken(modelId);
290
+ const perModelName = `KEIKO_MODEL_${token}_API_KEY_HEADER_NAME`;
291
+ const perModel = env[perModelName];
292
+ if (perModel !== undefined && perModel.length > 0) {
293
+ return normalizeApiKeyHeaderName(perModel, perModelName);
294
+ }
295
+ if (rawValue !== undefined) {
296
+ return normalizeApiKeyHeaderName(rawValue, path);
297
+ }
298
+ return normalizeApiKeyHeaderName(env.KEIKO_DEFAULT_API_KEY_HEADER_NAME, "KEIKO_DEFAULT_API_KEY_HEADER_NAME");
299
+ }
300
+ // Validates a resolved baseUrl for scheme and credential hygiene. Host/IP is
301
+ // intentionally NOT restricted: Keiko addresses private network endpoints
302
+ // (private IPs are a valid, first-class target); this guard is scheme/credential
303
+ // hygiene + defence-in-depth, not host filtering.
304
+ function isLoopbackHost(hostname) {
305
+ if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") {
306
+ return true;
307
+ }
308
+ // Real IPv4 loopback only. isIP === 4 guarantees a well-formed dotted-quad, so a "127." prefix
309
+ // here is the 127.0.0.0/8 block — never a domain such as "127.evil.com" or "127.0.0.1.evil.com".
310
+ // The WHATWG URL parser has already canonicalised IPv4 shorthand/hex into url.hostname.
311
+ return isIP(hostname) === 4 && hostname.startsWith("127.");
312
+ }
313
+ export function validateBaseUrl(baseUrl, path) {
314
+ let url;
315
+ try {
316
+ url = new URL(baseUrl);
317
+ }
318
+ catch {
319
+ throw new ConfigInvalidError(`${path}.baseUrl must be a valid absolute URL`);
320
+ }
321
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
322
+ throw new ConfigInvalidError(`${path}.baseUrl must use the http or https scheme`);
323
+ }
324
+ if (url.search !== "" || url.hash !== "") {
325
+ throw new ConfigInvalidError(`${path}.baseUrl must not contain a query string or fragment`);
326
+ }
327
+ if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
328
+ throw new ConfigInvalidError(`${path}.baseUrl must use https unless it targets localhost or loopback`);
329
+ }
330
+ if (url.username !== "" || url.password !== "") {
331
+ throw new ConfigInvalidError(`${path}.baseUrl must not embed credentials in the URL; provide the key via apiKey`);
332
+ }
333
+ }
334
+ // Modality + determinism capability flags, defaulted to false (lenient provider-inline form).
335
+ function providerCapabilityFlags(raw, path) {
336
+ return {
337
+ toolCalling: optionalBoolean(raw.toolCalling, `${path}.toolCalling`, false),
338
+ structuredOutput: optionalBoolean(raw.structuredOutput, `${path}.structuredOutput`, false),
339
+ streaming: optionalBoolean(raw.streaming, `${path}.streaming`, false),
340
+ supportsImageInput: optionalBoolean(raw.supportsImageInput, `${path}.supportsImageInput`, false),
341
+ supportsDocumentInput: optionalBoolean(raw.supportsDocumentInput, `${path}.supportsDocumentInput`, false),
342
+ supportsSeeding: optionalBoolean(raw.supportsSeeding, `${path}.supportsSeeding`, false),
343
+ supportsResponseFormat: optionalBoolean(raw.supportsResponseFormat, `${path}.supportsResponseFormat`, false),
344
+ supportsInfilling: optionalBoolean(raw.supportsInfilling, `${path}.supportsInfilling`, false),
345
+ };
346
+ }
347
+ // Resolves the optional `infillingAlignment` enum and enforces the two FIM invariants (Issue #1210),
348
+ // shared by the lenient inline parser and the strict top-level parser:
349
+ // 1. suffix-aware completion is a chat-only capability — `supportsInfilling` must be false for any
350
+ // non-chat kind (defence in depth alongside the contract predicates);
351
+ // 2. an alignment posture is meaningless without the capability — `infillingAlignment` requires
352
+ // `supportsInfilling: true`.
353
+ // Returns the alignment only when declared so a capability record round-trips exactly.
354
+ function resolveInfillingAlignment(raw, path, supportsInfilling, kind) {
355
+ if (supportsInfilling && kind !== "chat") {
356
+ throw new ConfigInvalidError(`${path}.supportsInfilling must be false when ${path}.kind is not "chat"`);
357
+ }
358
+ if (raw.infillingAlignment === undefined) {
359
+ return {};
360
+ }
361
+ if (!supportsInfilling) {
362
+ throw new ConfigInvalidError(`${path}.infillingAlignment requires ${path}.supportsInfilling to be true`);
363
+ }
364
+ return {
365
+ infillingAlignment: requireEnum(raw.infillingAlignment, `${path}.infillingAlignment`, ["base", "instruct", "edit-tuned"]),
366
+ };
367
+ }
368
+ function buildProviderCapabilityBody(raw, path, id, kind, workflowEligible) {
369
+ const flags = providerCapabilityFlags(raw, path);
370
+ return {
371
+ id,
372
+ kind,
373
+ contextWindow: optionalNonNegativeInt(raw.contextWindow, `${path}.contextWindow`, 0),
374
+ maxOutputTokens: optionalNonNegativeInt(raw.maxOutputTokens, `${path}.maxOutputTokens`, 0),
375
+ ...flags,
376
+ ...resolveInfillingAlignment(raw, path, flags.supportsInfilling ?? false, kind),
377
+ workflowEligible,
378
+ costClass: requireEnum(raw.costClass ?? "medium", `${path}.costClass`, [
379
+ "low",
380
+ "medium",
381
+ "high",
382
+ ]),
383
+ latencyClass: requireEnum(raw.latencyClass ?? "standard", `${path}.latencyClass`, ["fast", "standard", "slow"]),
384
+ throughputHint: optionalNonEmptyString(raw.throughputHint, `${path}.throughputHint`, "runtime-configured"),
385
+ preferredUseCases: optionalStringArray(raw.preferredUseCases, `${path}.preferredUseCases`, [
386
+ "Runtime-configured model",
387
+ ]),
388
+ knownLimitations: optionalStringArray(raw.knownLimitations, `${path}.knownLimitations`, [
389
+ "Capabilities are runtime-declared and should be verified in the target environment",
390
+ ]),
391
+ };
392
+ }
393
+ function parseProviderCapability(raw, path, modelId) {
394
+ if (raw === undefined) {
395
+ return undefined;
396
+ }
397
+ if (!isRecord(raw)) {
398
+ throw new ConfigInvalidError(`${path} must be an object`);
399
+ }
400
+ const id = optionalNonEmptyString(raw.id, `${path}.id`, modelId);
401
+ if (id !== modelId) {
402
+ throw new ConfigInvalidError(`${path}.id must match the provider modelId`);
403
+ }
404
+ const kind = requireEnum(raw.kind, `${path}.kind`, [
405
+ "chat",
406
+ "embedding",
407
+ "ocr-vision",
408
+ ]);
409
+ // Conservative defaults for the per-provider inline capability path (Issue #143).
410
+ // The strict, no-default surface is parseModelCapability for the top-level
411
+ // `capabilities` array. Workflow eligibility is also gated by the chat invariant
412
+ // here so an inline embedding/ocr-vision declaration cannot opt itself in.
413
+ const workflowEligible = optionalBoolean(raw.workflowEligible, `${path}.workflowEligible`, false);
414
+ if (kind !== "chat" && workflowEligible) {
415
+ throw new ConfigInvalidError(`${path}.workflowEligible must be false when ${path}.kind is not "chat"`);
416
+ }
417
+ return buildProviderCapabilityBody(raw, path, id, kind, workflowEligible);
418
+ }
419
+ // Strict, fail-closed parser for explicit wire-facing capability records (Issue #143).
420
+ // Used by `parseCapabilityList` against the top-level `capabilities` array. Every
421
+ // boolean is REQUIRED here — callers that want a default chat capability call
422
+ // `createDefaultChatCapability` instead. Error messages identify the field path
423
+ // and never echo sibling-field values; the `ConfigInvalidError` base also runs
424
+ // `redact()` so apiKey-shaped substrings are scrubbed defensively.
425
+ const MODEL_CAPABILITY_KNOWN_KEYS = new Set([
426
+ "id",
427
+ "kind",
428
+ "contextWindow",
429
+ "maxOutputTokens",
430
+ "toolCalling",
431
+ "structuredOutput",
432
+ "streaming",
433
+ "supportsImageInput",
434
+ "supportsDocumentInput",
435
+ "supportsSeeding",
436
+ "supportsResponseFormat",
437
+ "supportsInfilling",
438
+ "infillingAlignment",
439
+ "workflowEligible",
440
+ "costClass",
441
+ "latencyClass",
442
+ "throughputHint",
443
+ "preferredUseCases",
444
+ "knownLimitations",
445
+ ]);
446
+ function requireBoolean(value, path) {
447
+ if (typeof value !== "boolean") {
448
+ throw new ConfigInvalidError(`${path} must be a boolean`);
449
+ }
450
+ return value;
451
+ }
452
+ function requireNonNegativeIntStrict(value, path) {
453
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
454
+ throw new ConfigInvalidError(`${path} must be a non-negative integer`);
455
+ }
456
+ return value;
457
+ }
458
+ function requireStringArray(value, path) {
459
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
460
+ throw new ConfigInvalidError(`${path} must be an array of strings`);
461
+ }
462
+ return value;
463
+ }
464
+ // Optional determinism flags for the strict list parser — preserved only when declared so a
465
+ // capability record round-trips exactly (Epic #761).
466
+ function optionalDeterminismFlags(value, path) {
467
+ return {
468
+ ...(value.supportsSeeding !== undefined
469
+ ? { supportsSeeding: requireBoolean(value.supportsSeeding, `${path}.supportsSeeding`) }
470
+ : {}),
471
+ ...(value.supportsResponseFormat !== undefined
472
+ ? {
473
+ supportsResponseFormat: requireBoolean(value.supportsResponseFormat, `${path}.supportsResponseFormat`),
474
+ }
475
+ : {}),
476
+ };
477
+ }
478
+ // Optional infilling/FIM flags for the strict list parser — preserved only when declared so a
479
+ // capability record round-trips exactly (Issue #1210). The two FIM invariants are enforced by the
480
+ // shared `resolveInfillingAlignment`.
481
+ function optionalInfillingFlags(value, path, kind) {
482
+ const supportsInfilling = value.supportsInfilling !== undefined
483
+ ? requireBoolean(value.supportsInfilling, `${path}.supportsInfilling`)
484
+ : undefined;
485
+ return {
486
+ ...(supportsInfilling !== undefined ? { supportsInfilling } : {}),
487
+ ...resolveInfillingAlignment(value, path, supportsInfilling === true, kind),
488
+ };
489
+ }
490
+ // Reject unknown top-level keys so an adversarial config cannot smuggle future-named fields past
491
+ // the parser. The first offending key is reported by name; values are NEVER echoed.
492
+ function assertKnownCapabilityKeys(value, path) {
493
+ for (const key of Object.keys(value)) {
494
+ if (!MODEL_CAPABILITY_KNOWN_KEYS.has(key)) {
495
+ throw new ConfigInvalidError(`${path}.${key} is not a recognised capability field`);
496
+ }
497
+ }
498
+ }
499
+ export function parseModelCapability(value, path) {
500
+ if (!isRecord(value)) {
501
+ throw new ConfigInvalidError(`${path} must be an object`);
502
+ }
503
+ assertKnownCapabilityKeys(value, path);
504
+ const id = requireNonEmptyString(value.id, `${path}.id`);
505
+ const kind = requireEnum(value.kind, `${path}.kind`, [
506
+ "chat",
507
+ "embedding",
508
+ "ocr-vision",
509
+ ]);
510
+ const workflowEligible = requireBoolean(value.workflowEligible, `${path}.workflowEligible`);
511
+ if (kind !== "chat" && workflowEligible) {
512
+ throw new ConfigInvalidError(`${path}.workflowEligible must be false when ${path}.kind is not "chat"`);
513
+ }
514
+ return {
515
+ id,
516
+ kind,
517
+ contextWindow: requireNonNegativeIntStrict(value.contextWindow, `${path}.contextWindow`),
518
+ maxOutputTokens: requireNonNegativeIntStrict(value.maxOutputTokens, `${path}.maxOutputTokens`),
519
+ toolCalling: requireBoolean(value.toolCalling, `${path}.toolCalling`),
520
+ structuredOutput: requireBoolean(value.structuredOutput, `${path}.structuredOutput`),
521
+ streaming: requireBoolean(value.streaming, `${path}.streaming`),
522
+ supportsImageInput: requireBoolean(value.supportsImageInput, `${path}.supportsImageInput`),
523
+ supportsDocumentInput: requireBoolean(value.supportsDocumentInput, `${path}.supportsDocumentInput`),
524
+ ...optionalDeterminismFlags(value, path),
525
+ ...optionalInfillingFlags(value, path, kind),
526
+ workflowEligible,
527
+ costClass: requireEnum(value.costClass, `${path}.costClass`, [
528
+ "low",
529
+ "medium",
530
+ "high",
531
+ ]),
532
+ latencyClass: requireEnum(value.latencyClass, `${path}.latencyClass`, [
533
+ "fast",
534
+ "standard",
535
+ "slow",
536
+ ]),
537
+ throughputHint: requireNonEmptyString(value.throughputHint, `${path}.throughputHint`),
538
+ preferredUseCases: requireStringArray(value.preferredUseCases, `${path}.preferredUseCases`),
539
+ knownLimitations: requireStringArray(value.knownLimitations, `${path}.knownLimitations`),
540
+ };
541
+ }
542
+ export function parseCapabilityList(value, path) {
543
+ if (!Array.isArray(value)) {
544
+ throw new ConfigInvalidError(`${path} must be an array`);
545
+ }
546
+ return value.map((entry, index) => parseModelCapability(entry, `${path}[${String(index)}]`));
547
+ }
548
+ function resolveProviderConnection(raw, path, modelId, env, options) {
549
+ const fileBaseUrl = typeof raw.baseUrl === "string" ? raw.baseUrl : "";
550
+ const fileApiKey = typeof raw.apiKey === "string" ? raw.apiKey : "";
551
+ const baseUrl = resolveSecret(modelId, fileBaseUrl, env, "BASE_URL");
552
+ const apiKey = resolveProviderApiKey(raw, modelId, fileApiKey, env, options);
553
+ if (baseUrl.length === 0) {
554
+ throw new ConfigInvalidError(`${path}.baseUrl must be set via config or environment`);
555
+ }
556
+ if (apiKey.length === 0) {
557
+ throw new ConfigInvalidError(`${path}.apiKey must be set via config, secret reference, or environment`);
558
+ }
559
+ validateBaseUrl(baseUrl, path);
560
+ return { baseUrl, apiKey };
561
+ }
562
+ function parseProviderConfig(raw, path, modelId, env, options) {
563
+ const { baseUrl, apiKey } = resolveProviderConnection(raw, path, modelId, env, options);
564
+ return {
565
+ modelId,
566
+ baseUrl,
567
+ apiKey,
568
+ apiKeyHeaderName: resolveApiKeyHeaderName(raw.apiKeyHeaderName, `${path}.apiKeyHeaderName`, modelId, env),
569
+ timeoutMs: requirePositiveInt(raw.timeoutMs ?? DEFAULT_TIMEOUT_MS, `${path}.timeoutMs`),
570
+ maxRetries: requireNonNegativeInt(raw.maxRetries ?? DEFAULT_MAX_RETRIES, `${path}.maxRetries`),
571
+ retryBaseDelayMs: requirePositiveInt(raw.retryBaseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS, `${path}.retryBaseDelayMs`),
572
+ };
573
+ }
574
+ function parseProvider(raw, index, env, options) {
575
+ const path = `providers[${String(index)}]`;
576
+ if (!isRecord(raw)) {
577
+ throw new ConfigInvalidError(`${path} must be an object`);
578
+ }
579
+ const modelId = requireNonEmptyString(raw.modelId, `${path}.modelId`);
580
+ const capability = parseProviderCapability(raw.capability, `${path}.capability`, modelId);
581
+ return {
582
+ provider: parseProviderConfig(raw, path, modelId, env, options),
583
+ ...(capability === undefined ? {} : { capability }),
584
+ };
585
+ }
586
+ function requireNonNegativeInt(value, path) {
587
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
588
+ throw new ConfigInvalidError(`${path} must be a non-negative integer`);
589
+ }
590
+ return value;
591
+ }
592
+ function parseGroundingLimits(raw) {
593
+ if (!isRecord(raw) || raw.grounding === undefined) {
594
+ return undefined;
595
+ }
596
+ const block = raw.grounding;
597
+ if (!isRecord(block)) {
598
+ throw new ConfigInvalidError("grounding must be an object");
599
+ }
600
+ const partial = {};
601
+ for (const key of Object.keys(DEFAULT_GROUNDING_LIMITS)) {
602
+ const value = block[key];
603
+ if (value !== undefined) {
604
+ // Reject non-integer / non-positive — resolveGroundingLimits silently coerces,
605
+ // but the config layer must fail loudly on a malformed explicit value.
606
+ if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
607
+ throw new ConfigInvalidError(`grounding.${key} must be a positive integer`);
608
+ }
609
+ // Over-ceiling values are clamped (not rejected) by resolveGroundingLimits.
610
+ // Record the validated value; the resolver applies the ceiling.
611
+ partial[key] = value;
612
+ }
613
+ // Unknown keys in the grounding block are ignored (forward-compat).
614
+ }
615
+ return resolveGroundingLimits(partial);
616
+ }
617
+ function parseFigmaConnectorConfig(raw) {
618
+ if (!isRecord(raw) || raw.figma === undefined) {
619
+ return undefined;
620
+ }
621
+ const block = raw.figma;
622
+ if (!isRecord(block)) {
623
+ throw new ConfigInvalidError("figma must be an object");
624
+ }
625
+ const accessToken = optionalTrimmedString(block.accessToken, "figma.accessToken");
626
+ return accessToken === undefined ? {} : { accessToken };
627
+ }
628
+ function parseCircuitBreaker(raw) {
629
+ const source = isRecord(raw) ? raw : {};
630
+ return {
631
+ failureThreshold: requirePositiveInt(source.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD, "circuitBreaker.failureThreshold"),
632
+ cooldownMs: requirePositiveInt(source.cooldownMs ?? DEFAULT_COOLDOWN_MS, "circuitBreaker.cooldownMs"),
633
+ halfOpenProbes: requirePositiveInt(source.halfOpenProbes ?? DEFAULT_HALF_OPEN_PROBES, "circuitBreaker.halfOpenProbes"),
634
+ };
635
+ }
636
+ function providersWithEgress(parsed, egress) {
637
+ if (egress === undefined) {
638
+ return parsed.map((item) => item.provider);
639
+ }
640
+ return parsed.map((item) => ({ ...item.provider, egress }));
641
+ }
642
+ function inlineCapabilities(parsed) {
643
+ return parsed
644
+ .map((item) => item.capability)
645
+ .filter((item) => item !== undefined);
646
+ }
647
+ function topLevelCapabilities(raw) {
648
+ // Top-level `capabilities` array is the wire-facing surface for explicit
649
+ // capability records (Issue #143). Validated by the strict parser so a
650
+ // malformed entry fails closed before reaching any consumer.
651
+ return raw.capabilities === undefined
652
+ ? []
653
+ : parseCapabilityList(raw.capabilities, "capabilities");
654
+ }
655
+ function mergeCapabilities(inlineItems, topLevelItems) {
656
+ const mergedCapabilities = new Map();
657
+ for (const capability of inlineItems) {
658
+ mergedCapabilities.set(capability.id, capability);
659
+ }
660
+ // Explicit top-level capability records are the authoritative surface for a
661
+ // model id. They must override the inline provider defaults when both exist.
662
+ for (const capability of topLevelItems) {
663
+ mergedCapabilities.set(capability.id, capability);
664
+ }
665
+ return [...mergedCapabilities.values()];
666
+ }
667
+ function buildGatewayConfig(raw, providersRaw, env, egress, options) {
668
+ const parsed = providersRaw.map((item, index) => parseProvider(item, index, env, options));
669
+ const capabilities = mergeCapabilities(inlineCapabilities(parsed), topLevelCapabilities(raw));
670
+ const grounding = parseGroundingLimits(raw);
671
+ const figma = parseFigmaConnectorConfig(raw);
672
+ return {
673
+ providers: providersWithEgress(parsed, egress),
674
+ circuitBreaker: parseCircuitBreaker(raw.circuitBreaker),
675
+ ...(capabilities.length === 0 ? {} : { capabilities }),
676
+ ...(grounding !== undefined ? { grounding } : {}),
677
+ ...(egress !== undefined ? { egress } : {}),
678
+ ...(figma !== undefined ? { figma } : {}),
679
+ };
680
+ }
681
+ export function parseGatewayConfig(raw, env = {}, options = {}) {
682
+ if (!isRecord(raw)) {
683
+ throw new ConfigInvalidError("config root must be a JSON object");
684
+ }
685
+ const egress = resolveOutboundHttpEgressConfig(raw.egress, env);
686
+ const providersRaw = raw.providers;
687
+ if (!Array.isArray(providersRaw) || providersRaw.length === 0) {
688
+ throw new ConfigInvalidError("providers must be a non-empty array");
689
+ }
690
+ return buildGatewayConfig(raw, providersRaw, env, egress, options);
691
+ }
692
+ function readGatewayConfigFile(path) {
693
+ let text;
694
+ try {
695
+ text = readFileSync(path, "utf8");
696
+ }
697
+ catch {
698
+ throw new ConfigInvalidError(`config file could not be read: ${path}`);
699
+ }
700
+ let parsed;
701
+ try {
702
+ parsed = JSON.parse(text);
703
+ }
704
+ catch {
705
+ throw new ConfigInvalidError(`config file is not valid JSON: ${path}`);
706
+ }
707
+ return parsed;
708
+ }
709
+ export function loadConfigFromFile(path, env = {}, options = {}) {
710
+ return parseGatewayConfig(readGatewayConfigFile(path), env, options);
711
+ }
712
+ export function loadEgressConfigFromFile(path, env = {}) {
713
+ const parsed = readGatewayConfigFile(path);
714
+ if (!isRecord(parsed)) {
715
+ throw new ConfigInvalidError("config root must be a JSON object");
716
+ }
717
+ return resolveOutboundHttpEgressConfig(parsed.egress, env);
718
+ }
719
+ // Credential- and endpoint-free projection for logging, CLI output, and serialisation.
720
+ export function toSafeObject(config) {
721
+ return {
722
+ providers: config.providers.map((provider) => ({
723
+ modelId: provider.modelId,
724
+ credentialHeaderName: provider.apiKeyHeaderName ?? DEFAULT_API_KEY_HEADER_NAME,
725
+ timeoutMs: provider.timeoutMs,
726
+ maxRetries: provider.maxRetries,
727
+ retryBaseDelayMs: provider.retryBaseDelayMs,
728
+ })),
729
+ circuitBreaker: config.circuitBreaker,
730
+ ...(config.capabilities === undefined ? {} : { capabilities: config.capabilities }),
731
+ ...(config.grounding !== undefined ? { grounding: config.grounding } : {}),
732
+ };
733
+ }