@kindgi/capabilities 0.0.0-bootstrap.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +112 -2
- package/dist/adapter-factory.d.ts +121 -0
- package/dist/adapter-factory.d.ts.map +1 -0
- package/dist/adapter-factory.js +36 -0
- package/dist/adapter-factory.js.map +1 -0
- package/dist/capability.schema.json +250 -0
- package/dist/define.d.ts +13 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +45 -0
- package/dist/define.js.map +1 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +4 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/registry.d.ts +26 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +156 -0
- package/dist/registry.js.map +1 -0
- package/dist/router.d.ts +73 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +484 -0
- package/dist/router.js.map +1 -0
- package/dist/types.d.ts +498 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +33 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -4
- package/src/adapter-factory.ts +155 -0
- package/src/capability.schema.json +250 -0
- package/src/define.ts +54 -0
- package/src/errors.ts +74 -0
- package/src/index.ts +51 -0
- package/src/registry.ts +179 -0
- package/src/router.ts +560 -0
- package/src/types.ts +533 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
import type { Result } from '@kindgi/types';
|
|
5
|
+
|
|
6
|
+
import type { CapabilityError, CapabilityUnsatisfiableError } from './errors.js';
|
|
7
|
+
import { DEFAULT_CAPABILITY_KIND } from './types.js';
|
|
8
|
+
import type {
|
|
9
|
+
Capability,
|
|
10
|
+
ComparisonOp,
|
|
11
|
+
ModelProvider,
|
|
12
|
+
Preference,
|
|
13
|
+
ProviderModelPick,
|
|
14
|
+
RejectionReason,
|
|
15
|
+
Requirement,
|
|
16
|
+
RoutingDecision,
|
|
17
|
+
TenantPolicy,
|
|
18
|
+
UpperBoundOp,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Input to `route()`. Callers pass the pre-scoped provider slice (e.g.
|
|
23
|
+
* `registry.list(tenantId)`) plus the capability declaration; the
|
|
24
|
+
* router expands each provider into one entry per exposed model,
|
|
25
|
+
* filters at the `(provider, model)` tuple level, ranks by
|
|
26
|
+
* preference, and returns the top pick.
|
|
27
|
+
*/
|
|
28
|
+
export interface RouteInput {
|
|
29
|
+
readonly capability: Capability;
|
|
30
|
+
readonly providers: readonly ModelProvider[];
|
|
31
|
+
readonly tenantPolicy?: TenantPolicy;
|
|
32
|
+
/**
|
|
33
|
+
* Optional soft hint — the router prefers tuples whose provider id
|
|
34
|
+
* matches. When the preferred provider is absent or filtered out,
|
|
35
|
+
* the router falls back to normal `capability.prefer`-based
|
|
36
|
+
* ranking. Typical source: `Agent.preferredProvider`.
|
|
37
|
+
*/
|
|
38
|
+
readonly preferredProvider?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Optional soft hint — the router prefers tuples whose
|
|
41
|
+
* `ModelInfo.name` matches. Combined with `preferredProvider`:
|
|
42
|
+
*
|
|
43
|
+
* - Both set → promote the exact `(provider, model)` tuple.
|
|
44
|
+
* - Only `preferredModel` set → promote any tuple whose model
|
|
45
|
+
* matches, across every provider.
|
|
46
|
+
* - Only `preferredProvider` set → promote every model of that
|
|
47
|
+
* provider.
|
|
48
|
+
*
|
|
49
|
+
* Typical source: `Agent.preferredModel`.
|
|
50
|
+
*/
|
|
51
|
+
readonly preferredModel?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Route a capability declaration against a provider slice. Returns
|
|
56
|
+
* the highest-ranked `(provider, model)` tuple satisfying every
|
|
57
|
+
* `needs` constraint plus tenant policy, with alternates in ranked
|
|
58
|
+
* order for failover.
|
|
59
|
+
*
|
|
60
|
+
* The router is pure: given the same (capability, providers, policy)
|
|
61
|
+
* it always produces the same decision. Ties are broken lexically on
|
|
62
|
+
* `(providerId, modelName)`, so deterministic replay from journal is
|
|
63
|
+
* guaranteed.
|
|
64
|
+
*
|
|
65
|
+
* A fallback provider (`metadata.fallback`) is considered only when no
|
|
66
|
+
* other provider satisfies the capability; the decision then says so
|
|
67
|
+
* (`fallback: true`). Its tuples are never alternates to a regular pick.
|
|
68
|
+
*
|
|
69
|
+
* Tenant scoping happens BEFORE the router — callers pass a
|
|
70
|
+
* `providers` array that already reflects the target tenant. This
|
|
71
|
+
* keeps the router decoupled from whether the underlying registry is
|
|
72
|
+
* in-memory, postgres-backed, or a composed view.
|
|
73
|
+
*/
|
|
74
|
+
export function route(input: RouteInput): Result<RoutingDecision, CapabilityError> {
|
|
75
|
+
const survivors = matchTuples(input);
|
|
76
|
+
if (survivors.kind === 'err') return survivors;
|
|
77
|
+
|
|
78
|
+
const regular = survivors.value.filter((t) => t.provider.metadata.fallback !== true);
|
|
79
|
+
const fallback = regular.length === 0;
|
|
80
|
+
const candidates = fallback ? survivors.value : regular;
|
|
81
|
+
const ranked = rankByPreference(candidates, input.capability.prefer ?? []);
|
|
82
|
+
const finalOrder = applyPreferredHints(ranked, input.preferredProvider, input.preferredModel);
|
|
83
|
+
const top = finalOrder[0];
|
|
84
|
+
if (top === undefined) {
|
|
85
|
+
// Should be caught by matchTuples, but defensive.
|
|
86
|
+
return unsatisfiable([], input.tenantPolicy);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
kind: 'ok',
|
|
90
|
+
value: {
|
|
91
|
+
provider: top.provider,
|
|
92
|
+
model: top.model,
|
|
93
|
+
reason: buildReason(top, input, fallback),
|
|
94
|
+
alternates: finalOrder.slice(1),
|
|
95
|
+
fallback,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Expand every provider into one entry per exposed model. Emits
|
|
102
|
+
* `(provider, model)` tuples in deterministic order: providers in
|
|
103
|
+
* input order, models in the order they appear on each provider's
|
|
104
|
+
* `metadata.models[]`. Downstream sort determines the final tiebreak.
|
|
105
|
+
*/
|
|
106
|
+
function expandTuples(providers: readonly ModelProvider[]): readonly ProviderModelPick[] {
|
|
107
|
+
const out: ProviderModelPick[] = [];
|
|
108
|
+
for (const p of providers) {
|
|
109
|
+
for (const m of p.metadata.models) {
|
|
110
|
+
out.push({ provider: p, model: m });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Hard-filter `(provider, model)` tuples against `capability.needs`
|
|
118
|
+
* and `tenantPolicy`. Returns the surviving set (order preserved from
|
|
119
|
+
* `expandTuples`; the ranker sorts). On empty result, returns
|
|
120
|
+
* `capability-unsatisfiable` with per-requirement rejection reasons
|
|
121
|
+
* for diagnosis.
|
|
122
|
+
*
|
|
123
|
+
* Filter semantics per requirement:
|
|
124
|
+
* - `feature` / `contextWindow` / `costPerCall` / `p95LatencyMs`
|
|
125
|
+
* read from `ModelInfo` — filter at the model level.
|
|
126
|
+
* - `region` reads from `ProviderMetadata.region` — filter at the
|
|
127
|
+
* provider level (all models of one connection share region).
|
|
128
|
+
* - `providers` allow/deny reads `ProviderMetadata.id`.
|
|
129
|
+
* - `models` allow/deny reads `ModelInfo.name`.
|
|
130
|
+
*/
|
|
131
|
+
export function matchTuples(
|
|
132
|
+
input: RouteInput,
|
|
133
|
+
): Result<readonly ProviderModelPick[], CapabilityUnsatisfiableError> {
|
|
134
|
+
const tuples = expandTuples(input.providers);
|
|
135
|
+
const rejections = new Map<string, string[]>();
|
|
136
|
+
const perRequirementReasons: CapabilityUnsatisfiableError['reasons'][number][] = [];
|
|
137
|
+
|
|
138
|
+
const requestedKind = input.capability.kind ?? DEFAULT_CAPABILITY_KIND;
|
|
139
|
+
const requirements: readonly {
|
|
140
|
+
readonly label: string;
|
|
141
|
+
readonly test: (t: ProviderModelPick) => RejectionReason | null;
|
|
142
|
+
}[] = [
|
|
143
|
+
// Kind gate runs first so providers of the wrong resource kind are
|
|
144
|
+
// rejected with a clear reason instead of being pinned to a specific
|
|
145
|
+
// needs-based diagnostic.
|
|
146
|
+
{
|
|
147
|
+
label: `capability.kind = ${requestedKind}`,
|
|
148
|
+
test: (t): RejectionReason | null => {
|
|
149
|
+
const providerKind = t.provider.metadata.capabilityKind ?? DEFAULT_CAPABILITY_KIND;
|
|
150
|
+
if (providerKind === requestedKind) return null;
|
|
151
|
+
return {
|
|
152
|
+
code: 'capability-kind-mismatch',
|
|
153
|
+
message: `provider kind "${providerKind}" does not match requested "${requestedKind}"`,
|
|
154
|
+
observed: providerKind,
|
|
155
|
+
wanted: requestedKind,
|
|
156
|
+
};
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
...input.capability.needs.map((req) => ({
|
|
160
|
+
label: describeRequirement(req),
|
|
161
|
+
test: (t: ProviderModelPick): RejectionReason | null => matchRequirement(req, t),
|
|
162
|
+
})),
|
|
163
|
+
...tenantPolicyRequirements(input.tenantPolicy),
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
for (const t of tuples) {
|
|
167
|
+
const key = tupleKey(t);
|
|
168
|
+
for (const r of requirements) {
|
|
169
|
+
const rejection = r.test(t);
|
|
170
|
+
if (rejection !== null) {
|
|
171
|
+
const existing = rejections.get(key) ?? [];
|
|
172
|
+
existing.push(`${r.label} → ${rejection.message}`);
|
|
173
|
+
rejections.set(key, existing);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const survivors = tuples.filter((t) => (rejections.get(tupleKey(t))?.length ?? 0) === 0);
|
|
179
|
+
if (survivors.length > 0) return { kind: 'ok', value: survivors };
|
|
180
|
+
|
|
181
|
+
// Build per-requirement diagnostic. Skip requirements every tuple
|
|
182
|
+
// already satisfied — they add noise (e.g. the kind gate matches
|
|
183
|
+
// every llm-inference provider when the caller asked for llm-inference).
|
|
184
|
+
for (const r of requirements) {
|
|
185
|
+
const satisfying: string[] = [];
|
|
186
|
+
const rejecting: { readonly id: string; readonly reason: RejectionReason }[] = [];
|
|
187
|
+
for (const t of tuples) {
|
|
188
|
+
const failure = r.test(t);
|
|
189
|
+
const id = tupleKey(t);
|
|
190
|
+
if (failure === null) satisfying.push(id);
|
|
191
|
+
else rejecting.push({ id, reason: failure });
|
|
192
|
+
}
|
|
193
|
+
if (rejecting.length === 0) continue;
|
|
194
|
+
perRequirementReasons.push({
|
|
195
|
+
requirement: r.label,
|
|
196
|
+
satisfyingProviders: satisfying,
|
|
197
|
+
rejectingProviders: rejecting,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
return unsatisfiable(perRequirementReasons, input.tenantPolicy);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Stable tuple identifier — `providerId/modelName`. Used in rejection diagnostics. */
|
|
204
|
+
function tupleKey(t: ProviderModelPick): string {
|
|
205
|
+
return `${t.provider.metadata.id}/${t.model.name}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Rank tuples by summed weight of matched preferences, stable sort.
|
|
210
|
+
* Equal scores tiebreak lexically on `(providerId, modelName)` so
|
|
211
|
+
* replay is deterministic even when multiple tuples tie.
|
|
212
|
+
*/
|
|
213
|
+
function rankByPreference(
|
|
214
|
+
tuples: readonly ProviderModelPick[],
|
|
215
|
+
prefs: readonly Preference[],
|
|
216
|
+
): readonly ProviderModelPick[] {
|
|
217
|
+
if (prefs.length === 0) return [...tuples].sort(compareTuplesLex);
|
|
218
|
+
const scored = tuples.map((t) => ({ t, score: preferenceScore(t, prefs) }));
|
|
219
|
+
scored.sort((a, b) => {
|
|
220
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
221
|
+
return compareTuplesLex(a.t, b.t);
|
|
222
|
+
});
|
|
223
|
+
return scored.map((s) => s.t);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function compareTuplesLex(a: ProviderModelPick, b: ProviderModelPick): number {
|
|
227
|
+
const p = a.provider.metadata.id.localeCompare(b.provider.metadata.id);
|
|
228
|
+
if (p !== 0) return p;
|
|
229
|
+
return a.model.name.localeCompare(b.model.name);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function preferenceScore(t: ProviderModelPick, prefs: readonly Preference[]): number {
|
|
233
|
+
let score = 0;
|
|
234
|
+
const providerAttrs = t.provider.metadata.attributes;
|
|
235
|
+
const modelFeatures = t.model.features;
|
|
236
|
+
for (const pref of prefs) {
|
|
237
|
+
if (
|
|
238
|
+
modelFeatures.includes(pref.feature as never) ||
|
|
239
|
+
providerAttrs?.includes(pref.feature) === true
|
|
240
|
+
) {
|
|
241
|
+
score += pref.weight;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return score;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Apply the `preferredProvider` + `preferredModel` soft hints. Matching
|
|
249
|
+
* tuples are promoted to the front while preserving the internal rank
|
|
250
|
+
* order from `rankByPreference`. Neither hint is required — the
|
|
251
|
+
* ranked order is returned untouched when both are absent.
|
|
252
|
+
*/
|
|
253
|
+
function applyPreferredHints(
|
|
254
|
+
ranked: readonly ProviderModelPick[],
|
|
255
|
+
preferredProvider: string | undefined,
|
|
256
|
+
preferredModel: string | undefined,
|
|
257
|
+
): readonly ProviderModelPick[] {
|
|
258
|
+
if (preferredProvider === undefined && preferredModel === undefined) return ranked;
|
|
259
|
+
const matches = (t: ProviderModelPick): boolean => {
|
|
260
|
+
if (preferredProvider !== undefined && t.provider.metadata.id !== preferredProvider) {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
if (preferredModel !== undefined && t.model.name !== preferredModel) return false;
|
|
264
|
+
return true;
|
|
265
|
+
};
|
|
266
|
+
return [...ranked.filter(matches), ...ranked.filter((t) => !matches(t))];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Test a single Requirement against a `(provider, model)` tuple.
|
|
271
|
+
* Returns null on satisfaction, a structured `RejectionReason` on
|
|
272
|
+
* rejection. Rejections flow into `CapabilityUnsatisfiableError.
|
|
273
|
+
* reasons[].rejectingProviders[].reason` — consumers can switch on
|
|
274
|
+
* `code` to route on specific failure modes.
|
|
275
|
+
*/
|
|
276
|
+
function matchRequirement(req: Requirement, t: ProviderModelPick): RejectionReason | null {
|
|
277
|
+
const meta = t.provider.metadata;
|
|
278
|
+
const model = t.model;
|
|
279
|
+
if ('feature' in req) {
|
|
280
|
+
if (model.features.includes(req.feature)) return null;
|
|
281
|
+
return {
|
|
282
|
+
code: 'missing-feature',
|
|
283
|
+
message: `does not support feature "${req.feature}"`,
|
|
284
|
+
feature: req.feature,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
if ('contextWindow' in req) {
|
|
288
|
+
if (compare(model.contextWindow, req.contextWindow.op, req.contextWindow.value)) return null;
|
|
289
|
+
return {
|
|
290
|
+
code: 'context-window-mismatch',
|
|
291
|
+
message: `contextWindow=${model.contextWindow} fails ${req.contextWindow.op}${req.contextWindow.value}`,
|
|
292
|
+
observed: model.contextWindow,
|
|
293
|
+
op: req.contextWindow.op,
|
|
294
|
+
wanted: req.contextWindow.value,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
if ('costPerCall' in req) {
|
|
298
|
+
const proxy = model.cost.promptUsdPer1kTokens;
|
|
299
|
+
if (compare(proxy, req.costPerCall.op, req.costPerCall.usd)) return null;
|
|
300
|
+
return {
|
|
301
|
+
code: 'cost-exceeds-cap',
|
|
302
|
+
message: `promptCost=$${proxy}/1k fails ${req.costPerCall.op}$${req.costPerCall.usd}`,
|
|
303
|
+
observedUsdPer1k: proxy,
|
|
304
|
+
op: req.costPerCall.op,
|
|
305
|
+
wantedUsd: req.costPerCall.usd,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if ('region' in req) {
|
|
309
|
+
if (meta.region === req.region) return null;
|
|
310
|
+
return {
|
|
311
|
+
code: 'region-mismatch',
|
|
312
|
+
message: `region "${meta.region}" != required "${req.region}"`,
|
|
313
|
+
observed: meta.region,
|
|
314
|
+
wanted: req.region,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
if ('p95LatencyMs' in req) {
|
|
318
|
+
if (model.p95LatencyMs === undefined) {
|
|
319
|
+
return { code: 'p95-latency-unknown', message: 'model does not report p95 latency' };
|
|
320
|
+
}
|
|
321
|
+
if (compareUpperBound(model.p95LatencyMs, req.p95LatencyMs.op, req.p95LatencyMs.value))
|
|
322
|
+
return null;
|
|
323
|
+
return {
|
|
324
|
+
code: 'p95-latency-exceeds',
|
|
325
|
+
message: `p95Latency=${model.p95LatencyMs}ms fails ${req.p95LatencyMs.op}${req.p95LatencyMs.value}ms`,
|
|
326
|
+
observedMs: model.p95LatencyMs,
|
|
327
|
+
op: req.p95LatencyMs.op,
|
|
328
|
+
wantedMs: req.p95LatencyMs.value,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if ('providers' in req) {
|
|
332
|
+
if (req.providers.deny?.includes(meta.id) === true) {
|
|
333
|
+
return {
|
|
334
|
+
code: 'provider-on-denylist',
|
|
335
|
+
message: 'provider on deny list',
|
|
336
|
+
providerId: meta.id,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (req.providers.allow !== undefined && !req.providers.allow.includes(meta.id)) {
|
|
340
|
+
return {
|
|
341
|
+
code: 'provider-not-in-allowlist',
|
|
342
|
+
message: 'provider not on allow list',
|
|
343
|
+
providerId: meta.id,
|
|
344
|
+
allow: req.providers.allow,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
if ('models' in req) {
|
|
350
|
+
if (req.models.deny?.includes(model.name) === true) {
|
|
351
|
+
return {
|
|
352
|
+
code: 'model-on-denylist',
|
|
353
|
+
message: 'model on deny list',
|
|
354
|
+
modelName: model.name,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
if (req.models.allow !== undefined && !req.models.allow.includes(model.name)) {
|
|
358
|
+
return {
|
|
359
|
+
code: 'model-not-in-allowlist',
|
|
360
|
+
message: 'model not on allow list',
|
|
361
|
+
modelName: model.name,
|
|
362
|
+
allow: req.models.allow,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
return null;
|
|
366
|
+
}
|
|
367
|
+
return { code: 'unknown-requirement', message: 'unknown requirement shape' };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Compile tenant policy into synthetic requirements for the shared filter
|
|
372
|
+
* loop. An allow list that is present restricts even when empty: `[]`
|
|
373
|
+
* allows nothing (fail closed), the same as a capability requirement's
|
|
374
|
+
* allow list. An empty deny list denies nothing.
|
|
375
|
+
*/
|
|
376
|
+
function tenantPolicyRequirements(policy: TenantPolicy | undefined): readonly {
|
|
377
|
+
readonly label: string;
|
|
378
|
+
readonly test: (t: ProviderModelPick) => RejectionReason | null;
|
|
379
|
+
}[] {
|
|
380
|
+
if (policy === undefined) return [];
|
|
381
|
+
const out: {
|
|
382
|
+
readonly label: string;
|
|
383
|
+
readonly test: (t: ProviderModelPick) => RejectionReason | null;
|
|
384
|
+
}[] = [];
|
|
385
|
+
const providerDeny = policy.providers?.deny;
|
|
386
|
+
if (providerDeny !== undefined && providerDeny.length > 0) {
|
|
387
|
+
const deny = providerDeny;
|
|
388
|
+
out.push({
|
|
389
|
+
label: `tenant.providers.deny[${deny.join(',')}]`,
|
|
390
|
+
test: (t): RejectionReason | null =>
|
|
391
|
+
deny.includes(t.provider.metadata.id)
|
|
392
|
+
? {
|
|
393
|
+
code: 'tenant-denied',
|
|
394
|
+
message: 'tenant policy deny',
|
|
395
|
+
providerId: t.provider.metadata.id,
|
|
396
|
+
}
|
|
397
|
+
: null,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
const providerAllow = policy.providers?.allow;
|
|
401
|
+
if (providerAllow !== undefined) {
|
|
402
|
+
const allow = providerAllow;
|
|
403
|
+
out.push({
|
|
404
|
+
label: `tenant.providers.allow[${allow.join(',')}]`,
|
|
405
|
+
test: (t): RejectionReason | null =>
|
|
406
|
+
allow.includes(t.provider.metadata.id)
|
|
407
|
+
? null
|
|
408
|
+
: {
|
|
409
|
+
code: 'tenant-not-allowed',
|
|
410
|
+
message: 'not on tenant allow list',
|
|
411
|
+
providerId: t.provider.metadata.id,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
const modelDeny = policy.models?.deny;
|
|
416
|
+
if (modelDeny !== undefined && modelDeny.length > 0) {
|
|
417
|
+
const deny = modelDeny;
|
|
418
|
+
out.push({
|
|
419
|
+
label: `tenant.models.deny[${deny.join(',')}]`,
|
|
420
|
+
test: (t): RejectionReason | null =>
|
|
421
|
+
deny.includes(t.model.name)
|
|
422
|
+
? {
|
|
423
|
+
code: 'tenant-model-denied',
|
|
424
|
+
message: 'tenant policy deny (model)',
|
|
425
|
+
modelName: t.model.name,
|
|
426
|
+
}
|
|
427
|
+
: null,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
const modelAllow = policy.models?.allow;
|
|
431
|
+
if (modelAllow !== undefined) {
|
|
432
|
+
const allow = modelAllow;
|
|
433
|
+
out.push({
|
|
434
|
+
label: `tenant.models.allow[${allow.join(',')}]`,
|
|
435
|
+
test: (t): RejectionReason | null =>
|
|
436
|
+
allow.includes(t.model.name)
|
|
437
|
+
? null
|
|
438
|
+
: {
|
|
439
|
+
code: 'tenant-model-not-allowed',
|
|
440
|
+
message: 'not on tenant model allow list',
|
|
441
|
+
modelName: t.model.name,
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
if (policy.regionAllow !== undefined) {
|
|
446
|
+
const regions = policy.regionAllow;
|
|
447
|
+
out.push({
|
|
448
|
+
label: `tenant.regionAllow[${regions.join(',')}]`,
|
|
449
|
+
test: (t): RejectionReason | null =>
|
|
450
|
+
regions.includes(t.provider.metadata.region)
|
|
451
|
+
? null
|
|
452
|
+
: {
|
|
453
|
+
code: 'tenant-region-not-allowed',
|
|
454
|
+
message: `region "${t.provider.metadata.region}" not in tenant allow list`,
|
|
455
|
+
observed: t.provider.metadata.region,
|
|
456
|
+
allow: regions,
|
|
457
|
+
},
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
if (policy.maxCostPerCallUsd !== undefined) {
|
|
461
|
+
const cap = policy.maxCostPerCallUsd;
|
|
462
|
+
out.push({
|
|
463
|
+
label: `tenant.maxCostPerCallUsd<=${cap}`,
|
|
464
|
+
test: (t): RejectionReason | null =>
|
|
465
|
+
t.model.cost.promptUsdPer1kTokens <= cap
|
|
466
|
+
? null
|
|
467
|
+
: {
|
|
468
|
+
code: 'tenant-cost-cap-exceeded',
|
|
469
|
+
message: `promptCost/1k=$${t.model.cost.promptUsdPer1kTokens} > tenant cap $${cap}`,
|
|
470
|
+
observedUsd: t.model.cost.promptUsdPer1kTokens,
|
|
471
|
+
capUsd: cap,
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
if (policy.maxTokensPerCall !== undefined) {
|
|
476
|
+
const cap = policy.maxTokensPerCall;
|
|
477
|
+
out.push({
|
|
478
|
+
label: `tenant.maxTokensPerCall<=${cap}`,
|
|
479
|
+
test: (t): RejectionReason | null =>
|
|
480
|
+
t.model.contextWindow >= cap
|
|
481
|
+
? null
|
|
482
|
+
: {
|
|
483
|
+
// Reuse context-window-mismatch for tenant-side context caps.
|
|
484
|
+
code: 'context-window-mismatch',
|
|
485
|
+
message: `contextWindow=${t.model.contextWindow} < tenant cap ${cap}`,
|
|
486
|
+
observed: t.model.contextWindow,
|
|
487
|
+
op: '>=',
|
|
488
|
+
wanted: cap,
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
return out;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function compare(actual: number, op: ComparisonOp, target: number): boolean {
|
|
496
|
+
switch (op) {
|
|
497
|
+
case '>=':
|
|
498
|
+
return actual >= target;
|
|
499
|
+
case '>':
|
|
500
|
+
return actual > target;
|
|
501
|
+
case '=':
|
|
502
|
+
return actual === target;
|
|
503
|
+
case '<=':
|
|
504
|
+
return actual <= target;
|
|
505
|
+
case '<':
|
|
506
|
+
return actual < target;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function compareUpperBound(actual: number, op: UpperBoundOp, target: number): boolean {
|
|
511
|
+
return op === '<=' ? actual <= target : actual < target;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function describeRequirement(req: Requirement): string {
|
|
515
|
+
if ('feature' in req) return `feature:${req.feature}`;
|
|
516
|
+
if ('contextWindow' in req)
|
|
517
|
+
return `contextWindow${req.contextWindow.op}${req.contextWindow.value}`;
|
|
518
|
+
if ('costPerCall' in req) return `costPerCall${req.costPerCall.op}$${req.costPerCall.usd}`;
|
|
519
|
+
if ('region' in req) return `region=${req.region}`;
|
|
520
|
+
if ('p95LatencyMs' in req) return `p95LatencyMs${req.p95LatencyMs.op}${req.p95LatencyMs.value}`;
|
|
521
|
+
if ('providers' in req) {
|
|
522
|
+
const allow = req.providers.allow?.join(',') ?? '';
|
|
523
|
+
const deny = req.providers.deny?.join(',') ?? '';
|
|
524
|
+
return `providers{allow:[${allow}]deny:[${deny}]}`;
|
|
525
|
+
}
|
|
526
|
+
if ('models' in req) {
|
|
527
|
+
const allow = req.models.allow?.join(',') ?? '';
|
|
528
|
+
const deny = req.models.deny?.join(',') ?? '';
|
|
529
|
+
return `models{allow:[${allow}]deny:[${deny}]}`;
|
|
530
|
+
}
|
|
531
|
+
return 'unknown';
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function buildReason(t: ProviderModelPick, input: RouteInput, fallback: boolean): string {
|
|
535
|
+
const parts = [
|
|
536
|
+
`picked "${t.provider.metadata.id}"/"${t.model.name}"`,
|
|
537
|
+
`satisfies ${input.capability.needs.length} constraint(s)`,
|
|
538
|
+
];
|
|
539
|
+
if (fallback) parts.push('a fallback provider — no other registered provider satisfies it');
|
|
540
|
+
if (input.tenantPolicy !== undefined) parts.push('respects tenant policy');
|
|
541
|
+
const prefs = input.capability.prefer;
|
|
542
|
+
if (prefs !== undefined && prefs.length > 0)
|
|
543
|
+
parts.push(`ranked top of ${prefs.length} preference(s)`);
|
|
544
|
+
return parts.join(', ');
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function unsatisfiable(
|
|
548
|
+
reasons: CapabilityUnsatisfiableError['reasons'],
|
|
549
|
+
tenantPolicy: TenantPolicy | undefined,
|
|
550
|
+
): Result<never, CapabilityUnsatisfiableError> {
|
|
551
|
+
return {
|
|
552
|
+
kind: 'err',
|
|
553
|
+
error: {
|
|
554
|
+
code: 'capability-unsatisfiable',
|
|
555
|
+
message: 'No registered provider satisfies the capability declaration',
|
|
556
|
+
reasons,
|
|
557
|
+
...(tenantPolicy !== undefined && { tenantId: tenantPolicy.tenantId }),
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|