offrouter-core 0.2.1 → 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.
- package/dist/audit.d.ts +137 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +320 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth/credential-chain.d.ts +84 -0
- package/dist/auth/credential-chain.d.ts.map +1 -0
- package/dist/auth/credential-chain.js +150 -0
- package/dist/auth/credential-chain.js.map +1 -0
- package/dist/auth/index.d.ts +10 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +7 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/keychain.d.ts +60 -0
- package/dist/auth/keychain.d.ts.map +1 -0
- package/dist/auth/keychain.js +104 -0
- package/dist/auth/keychain.js.map +1 -0
- package/dist/auth/oauth-pkce.d.ts +138 -0
- package/dist/auth/oauth-pkce.d.ts.map +1 -0
- package/dist/auth/oauth-pkce.js +375 -0
- package/dist/auth/oauth-pkce.js.map +1 -0
- package/dist/auth/oauth-server.d.ts +36 -0
- package/dist/auth/oauth-server.d.ts.map +1 -0
- package/dist/auth/oauth-server.js +210 -0
- package/dist/auth/oauth-server.js.map +1 -0
- package/dist/config.d.ts +76 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +355 -0
- package/dist/config.js.map +1 -0
- package/dist/delegation.d.ts +123 -0
- package/dist/delegation.d.ts.map +1 -0
- package/dist/delegation.js +455 -0
- package/dist/delegation.js.map +1 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +45 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +318 -0
- package/dist/policy.js.map +1 -0
- package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +6 -0
- package/dist/protocol.js.map +1 -0
- package/dist/providers/adapter.d.ts +137 -0
- package/dist/providers/adapter.d.ts.map +1 -0
- package/dist/providers/adapter.js +163 -0
- package/dist/providers/adapter.js.map +1 -0
- package/dist/providers/catalog-data.d.ts +128 -0
- package/dist/providers/catalog-data.d.ts.map +1 -0
- package/dist/providers/catalog-data.js +397 -0
- package/dist/providers/catalog-data.js.map +1 -0
- package/dist/providers/catalog.d.ts +63 -0
- package/dist/providers/catalog.d.ts.map +1 -0
- package/dist/providers/catalog.js +394 -0
- package/dist/providers/catalog.js.map +1 -0
- package/dist/providers/fake.d.ts +46 -0
- package/dist/providers/fake.d.ts.map +1 -0
- package/dist/providers/fake.js +234 -0
- package/dist/providers/fake.js.map +1 -0
- package/dist/providers/openai-compatible.d.ts +65 -0
- package/dist/providers/openai-compatible.d.ts.map +1 -0
- package/dist/providers/openai-compatible.js +434 -0
- package/dist/providers/openai-compatible.js.map +1 -0
- package/dist/proxy/responses-mapper.d.ts +366 -0
- package/dist/proxy/responses-mapper.d.ts.map +1 -0
- package/dist/proxy/responses-mapper.js +517 -0
- package/dist/proxy/responses-mapper.js.map +1 -0
- package/dist/proxy/responses-server.d.ts +29 -0
- package/dist/proxy/responses-server.d.ts.map +1 -0
- package/dist/proxy/responses-server.js +360 -0
- package/dist/proxy/responses-server.js.map +1 -0
- package/dist/router.d.ts +18 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +296 -0
- package/dist/router.js.map +1 -0
- package/dist/schemas.d.ts +560 -0
- package/dist/schemas.d.ts.map +1 -0
- package/{src/schemas.ts → dist/schemas.js} +83 -103
- package/dist/schemas.js.map +1 -0
- package/dist/secrets.d.ts +112 -0
- package/dist/secrets.d.ts.map +1 -0
- package/dist/secrets.js +326 -0
- package/dist/secrets.js.map +1 -0
- package/dist/types.d.ts +117 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-file.d.ts +59 -0
- package/dist/usage-file.d.ts.map +1 -0
- package/dist/usage-file.js +316 -0
- package/dist/usage-file.js.map +1 -0
- package/dist/usage.d.ts +235 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +517 -0
- package/dist/usage.js.map +1 -0
- package/package.json +7 -2
- package/src/audit.test.ts +0 -302
- package/src/audit.ts +0 -553
- package/src/auth/credential-chain.test.ts +0 -326
- package/src/auth/credential-chain.ts +0 -235
- package/src/auth/index.ts +0 -45
- package/src/auth/keychain.test.ts +0 -265
- package/src/auth/keychain.ts +0 -168
- package/src/auth/oauth-pkce.test.ts +0 -329
- package/src/auth/oauth-pkce.ts +0 -571
- package/src/auth/oauth-server.test.ts +0 -83
- package/src/auth/oauth-server.ts +0 -296
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -505
- package/src/delegation.test.ts +0 -368
- package/src/delegation.ts +0 -605
- package/src/index.ts +0 -280
- package/src/policy.test.ts +0 -634
- package/src/policy.ts +0 -420
- package/src/providers/adapter.test.ts +0 -293
- package/src/providers/adapter.ts +0 -328
- package/src/providers/catalog-data.test.ts +0 -258
- package/src/providers/catalog-data.ts +0 -498
- package/src/providers/catalog.test.ts +0 -84
- package/src/providers/catalog.ts +0 -483
- package/src/providers/fake.ts +0 -312
- package/src/providers/openai-compatible.test.ts +0 -366
- package/src/providers/openai-compatible.ts +0 -554
- package/src/proxy/responses-mapper.test.ts +0 -290
- package/src/proxy/responses-mapper.ts +0 -736
- package/src/proxy/responses-server.test.ts +0 -322
- package/src/proxy/responses-server.ts +0 -469
- package/src/router.test.ts +0 -699
- package/src/router.ts +0 -352
- package/src/schemas.test.ts +0 -291
- package/src/secrets.test.ts +0 -271
- package/src/secrets.ts +0 -461
- package/src/types.ts +0 -173
- package/src/usage-file.test.ts +0 -243
- package/src/usage-file.ts +0 -435
- package/src/usage.test.ts +0 -335
- package/src/usage.ts +0 -859
- package/tsconfig.json +0 -9
package/src/router.ts
DELETED
|
@@ -1,352 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic model scoring and route explain for OffRouter V1.
|
|
3
|
-
*/
|
|
4
|
-
import { evaluatePolicy, type PolicyConfig } from "./policy.js";
|
|
5
|
-
import { ROUTE_PROTOCOL_VERSION } from "./protocol.js";
|
|
6
|
-
import type {
|
|
7
|
-
FallbackChainEntry,
|
|
8
|
-
FallbackReason,
|
|
9
|
-
ProviderCandidate,
|
|
10
|
-
RouteDecision,
|
|
11
|
-
RouteRequest,
|
|
12
|
-
} from "./types.js";
|
|
13
|
-
|
|
14
|
-
export interface RouteOptions {
|
|
15
|
-
/** Optional decision id factory for tests; defaults to deterministic hash-ish id. */
|
|
16
|
-
decisionId?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function candidateId(c: ProviderCandidate): string {
|
|
20
|
-
return `${c.providerId}:${c.modelId}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Rank score: higher is better.
|
|
25
|
-
* Order of preference:
|
|
26
|
-
* 1. Healthy active subscription (status !== "near-limit")
|
|
27
|
-
* 2. Healthy near-limit subscription
|
|
28
|
-
* 3. Local
|
|
29
|
-
* 4. Degraded subscription (active + degraded)
|
|
30
|
-
* 5. API-key
|
|
31
|
-
* Within same tier: lower estimatedCostUsd, then providerId, then modelId.
|
|
32
|
-
*/
|
|
33
|
-
function rankKey(c: ProviderCandidate): [number, number, string, string] {
|
|
34
|
-
let tierRank: number;
|
|
35
|
-
if (c.authTier === "subscription") {
|
|
36
|
-
if (c.health === "healthy") {
|
|
37
|
-
tierRank = c.subscriptionStatus === "near-limit" ? 350 : 400;
|
|
38
|
-
} else if (c.health === "degraded") {
|
|
39
|
-
tierRank = 200;
|
|
40
|
-
} else {
|
|
41
|
-
tierRank = 0;
|
|
42
|
-
}
|
|
43
|
-
} else if (c.authTier === "local") {
|
|
44
|
-
tierRank = 300;
|
|
45
|
-
} else {
|
|
46
|
-
// api-key
|
|
47
|
-
tierRank = 100;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Invert cost so higher is better; missing cost treated as mid-ish.
|
|
51
|
-
const cost = c.estimatedCostUsd ?? 0.05;
|
|
52
|
-
const costScore = -cost;
|
|
53
|
-
|
|
54
|
-
return [tierRank, costScore, c.providerId, c.modelId];
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function sortCandidates(candidates: ProviderCandidate[]): ProviderCandidate[] {
|
|
58
|
-
return [...candidates].sort((a, b) => {
|
|
59
|
-
const ka = rankKey(a);
|
|
60
|
-
const kb = rankKey(b);
|
|
61
|
-
// tierRank desc
|
|
62
|
-
if (ka[0] !== kb[0]) return kb[0] - ka[0];
|
|
63
|
-
// costScore desc (higher costScore => cheaper)
|
|
64
|
-
if (ka[1] !== kb[1]) return (kb[1] as number) - (ka[1] as number);
|
|
65
|
-
if (a.providerId !== b.providerId) {
|
|
66
|
-
return a.providerId < b.providerId ? -1 : 1;
|
|
67
|
-
}
|
|
68
|
-
if (a.modelId !== b.modelId) {
|
|
69
|
-
return a.modelId < b.modelId ? -1 : 1;
|
|
70
|
-
}
|
|
71
|
-
return 0;
|
|
72
|
-
});
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function selectionReason(winner: ProviderCandidate): {
|
|
76
|
-
reason: string;
|
|
77
|
-
fallbackReason: FallbackReason;
|
|
78
|
-
explanation: string;
|
|
79
|
-
} {
|
|
80
|
-
if (winner.authTier === "subscription") {
|
|
81
|
-
if (winner.health === "degraded") {
|
|
82
|
-
return {
|
|
83
|
-
reason: "subscription_degraded",
|
|
84
|
-
fallbackReason: "subscription_degraded",
|
|
85
|
-
explanation: `Selected degraded subscription candidate ${candidateId(winner)} as the best available subscription-backed route.`,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
return {
|
|
89
|
-
reason: "subscription_primary",
|
|
90
|
-
fallbackReason: "primary",
|
|
91
|
-
explanation: `Selected healthy subscription-backed candidate ${candidateId(winner)} over lower-priority routes.`,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
if (winner.authTier === "local") {
|
|
95
|
-
return {
|
|
96
|
-
reason: "local_preferred",
|
|
97
|
-
fallbackReason: "local_preferred",
|
|
98
|
-
explanation: `Selected local runtime candidate ${candidateId(winner)}.`,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
return {
|
|
102
|
-
reason: "api_key_fallback",
|
|
103
|
-
fallbackReason: "api_key_fallback",
|
|
104
|
-
explanation: `Selected API-key candidate ${candidateId(winner)} because no healthy subscription candidate could satisfy the task.`,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function buildFallbackChain(
|
|
109
|
-
sorted: ProviderCandidate[],
|
|
110
|
-
winner: ProviderCandidate,
|
|
111
|
-
): FallbackChainEntry[] {
|
|
112
|
-
const { fallbackReason } = selectionReason(winner);
|
|
113
|
-
const chain: FallbackChainEntry[] = [
|
|
114
|
-
{
|
|
115
|
-
provider: winner.providerId,
|
|
116
|
-
model: winner.modelId,
|
|
117
|
-
authTier: winner.authTier,
|
|
118
|
-
reason: fallbackReason,
|
|
119
|
-
},
|
|
120
|
-
];
|
|
121
|
-
|
|
122
|
-
// Include a short ordered list of alternative ranked candidates for explain.
|
|
123
|
-
for (const c of sorted) {
|
|
124
|
-
if (c.providerId === winner.providerId && c.modelId === winner.modelId) {
|
|
125
|
-
continue;
|
|
126
|
-
}
|
|
127
|
-
let reason: FallbackReason = "primary";
|
|
128
|
-
if (c.authTier === "api-key") {
|
|
129
|
-
reason = "api_key_fallback";
|
|
130
|
-
} else if (c.authTier === "local") {
|
|
131
|
-
reason = "local_preferred";
|
|
132
|
-
} else if (c.health === "degraded") {
|
|
133
|
-
reason = "subscription_degraded";
|
|
134
|
-
} else if (c.subscriptionStatus === "exhausted") {
|
|
135
|
-
reason = "subscription_exhausted";
|
|
136
|
-
} else if (c.subscriptionStatus === "unconfigured") {
|
|
137
|
-
reason = "subscription_unconfigured";
|
|
138
|
-
}
|
|
139
|
-
chain.push({
|
|
140
|
-
provider: c.providerId,
|
|
141
|
-
model: c.modelId,
|
|
142
|
-
authTier: c.authTier,
|
|
143
|
-
reason,
|
|
144
|
-
});
|
|
145
|
-
if (chain.length >= 5) break;
|
|
146
|
-
}
|
|
147
|
-
return chain;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function confidenceFor(winner: ProviderCandidate, nAllowed: number): number {
|
|
151
|
-
if (winner.authTier === "subscription" && winner.health === "healthy") {
|
|
152
|
-
return nAllowed === 1 ? 0.95 : 0.9;
|
|
153
|
-
}
|
|
154
|
-
if (winner.authTier === "local") return 0.8;
|
|
155
|
-
if (winner.authTier === "subscription") return 0.7;
|
|
156
|
-
return 0.6;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Route a request against candidates under policy.
|
|
161
|
-
* Pure and deterministic: same inputs -> same decision (except optional decisionId).
|
|
162
|
-
*/
|
|
163
|
-
export function routeRequest(
|
|
164
|
-
request: RouteRequest,
|
|
165
|
-
candidates: ProviderCandidate[],
|
|
166
|
-
config: PolicyConfig,
|
|
167
|
-
options: RouteOptions = {},
|
|
168
|
-
): RouteDecision {
|
|
169
|
-
const decisionId =
|
|
170
|
-
options.decisionId ??
|
|
171
|
-
`dec_${request.requestId}_${candidates.length}_${request.harness.profile}`;
|
|
172
|
-
|
|
173
|
-
// Empty catalog -> needs configuration (even before profile denials, so
|
|
174
|
-
// tests/docs can distinguish "no providers set up"). But work profiles still
|
|
175
|
-
// block on policy; empty list with blocked profile binary is fine either way.
|
|
176
|
-
// Spec: "no candidate returns actionable needsConfiguration"
|
|
177
|
-
// Spec: "denied work profile returns blocked decision" (needsConfiguration false).
|
|
178
|
-
if (candidates.length === 0) {
|
|
179
|
-
// Still surface hard policy denials. Work/untrusted contexts should not
|
|
180
|
-
// be framed as "needs configuration".
|
|
181
|
-
const policy = evaluatePolicy(request, candidates, config);
|
|
182
|
-
const hardBlock = policy.denials.some(
|
|
183
|
-
(d) =>
|
|
184
|
-
d.code === "work_profile_denied" ||
|
|
185
|
-
d.code === "privacy_label_denied" ||
|
|
186
|
-
d.code === "project_untrusted",
|
|
187
|
-
);
|
|
188
|
-
if (hardBlock) {
|
|
189
|
-
return {
|
|
190
|
-
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
191
|
-
decisionId,
|
|
192
|
-
requestId: request.requestId,
|
|
193
|
-
route: null,
|
|
194
|
-
reason: "policy_blocked",
|
|
195
|
-
explanation: policy.denials.map((d) => d.message).join(" "),
|
|
196
|
-
fallbackChain: [],
|
|
197
|
-
policyDenials: policy.denials,
|
|
198
|
-
confidence: 1,
|
|
199
|
-
auditSummary: `Blocked profile=${request.harness.profile} digests=${request.task.promptDigest}`,
|
|
200
|
-
blocked: true,
|
|
201
|
-
needsConfiguration: false,
|
|
202
|
-
};
|
|
203
|
-
}
|
|
204
|
-
return {
|
|
205
|
-
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
206
|
-
decisionId,
|
|
207
|
-
requestId: request.requestId,
|
|
208
|
-
route: null,
|
|
209
|
-
reason: "no_candidate_needs_configuration",
|
|
210
|
-
explanation:
|
|
211
|
-
"No provider candidates are available. Configure a subscription, local runtime, or API-key provider before routing.",
|
|
212
|
-
fallbackChain: [],
|
|
213
|
-
policyDenials: policy.denials,
|
|
214
|
-
confidence: 1,
|
|
215
|
-
auditSummary: `Needs configuration profile=${request.harness.profile} digest=${request.task.promptDigest}`,
|
|
216
|
-
blocked: true,
|
|
217
|
-
needsConfiguration: true,
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const policy = evaluatePolicy(request, candidates, config);
|
|
222
|
-
|
|
223
|
-
if (policy.allowed.length === 0) {
|
|
224
|
-
const profileHardBlock = policy.denials.some(
|
|
225
|
-
(d) =>
|
|
226
|
-
d.code === "work_profile_denied" ||
|
|
227
|
-
d.code === "profile_not_allowlisted" ||
|
|
228
|
-
d.code === "project_untrusted",
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
// If candidates existed but all filtered: may be needsConfiguration when
|
|
232
|
-
// nothing is policy-profile blocked and no usable routes after hard filters.
|
|
233
|
-
const onlyCapabilityOrCostOrApiKey = policy.denials.every(
|
|
234
|
-
(d) =>
|
|
235
|
-
d.code === "capability_missing" ||
|
|
236
|
-
d.code === "cost_cap_exceeded" ||
|
|
237
|
-
d.code === "local_only_required" ||
|
|
238
|
-
d.code === "api_key_blocked_by_subscription_first_policy" ||
|
|
239
|
-
d.code === "provider_denied" ||
|
|
240
|
-
d.code === "subscription_scope_denied",
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
return {
|
|
244
|
-
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
245
|
-
decisionId,
|
|
246
|
-
requestId: request.requestId,
|
|
247
|
-
route: null,
|
|
248
|
-
reason: profileHardBlock
|
|
249
|
-
? "policy_blocked"
|
|
250
|
-
: onlyCapabilityOrCostOrApiKey
|
|
251
|
-
? "no_allowed_candidate"
|
|
252
|
-
: "policy_blocked",
|
|
253
|
-
explanation:
|
|
254
|
-
policy.denials.map((d) => d.message).join(" ") ||
|
|
255
|
-
"No allowed candidate remaining after policy evaluation.",
|
|
256
|
-
fallbackChain: [],
|
|
257
|
-
policyDenials: policy.denials,
|
|
258
|
-
confidence: 1,
|
|
259
|
-
auditSummary: `Blocked profile=${request.harness.profile} denials=${policy.denials.length} digest=${request.task.promptDigest}`,
|
|
260
|
-
blocked: true,
|
|
261
|
-
needsConfiguration: !profileHardBlock && policy.denials.length === 0,
|
|
262
|
-
};
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const sorted = sortCandidates(policy.allowed);
|
|
266
|
-
const winner = sorted[0]!;
|
|
267
|
-
const { reason, explanation, fallbackReason } = selectionReason(winner);
|
|
268
|
-
|
|
269
|
-
// If winner is api-key but there were subscription denials for exhausted etc
|
|
270
|
-
// keep explanation accurate.
|
|
271
|
-
let finalExplanation = explanation;
|
|
272
|
-
if (winner.authTier === "api-key") {
|
|
273
|
-
const subExhausted = candidates.some(
|
|
274
|
-
(c) =>
|
|
275
|
-
c.authTier === "subscription" &&
|
|
276
|
-
(c.subscriptionStatus === "exhausted" ||
|
|
277
|
-
c.subscriptionStatus === "rate-limited"),
|
|
278
|
-
);
|
|
279
|
-
if (subExhausted) {
|
|
280
|
-
finalExplanation = `Selected API-key candidate ${candidateId(winner)} after subscription capacity was exhausted or rate-limited.`;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Prefer primary reason label for true cheapest api-key when no subscription involved
|
|
285
|
-
let finalReason = reason;
|
|
286
|
-
let finalFallback: FallbackReason = fallbackReason;
|
|
287
|
-
if (
|
|
288
|
-
winner.authTier === "api-key" &&
|
|
289
|
-
!candidates.some((c) => c.authTier === "subscription")
|
|
290
|
-
) {
|
|
291
|
-
finalReason = "primary";
|
|
292
|
-
finalFallback = "primary";
|
|
293
|
-
finalExplanation = `Selected cheap allowed candidate ${candidateId(winner)} for low-risk task.`;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const fallbackChain = buildFallbackChain(sorted, winner);
|
|
297
|
-
// Fix primary entry reason if we rewrote fallback reason
|
|
298
|
-
if (fallbackChain[0]) {
|
|
299
|
-
fallbackChain[0] = { ...fallbackChain[0], reason: finalFallback };
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Compute near-limit warning
|
|
303
|
-
let nearLimitWarning: string | undefined;
|
|
304
|
-
if (
|
|
305
|
-
winner.authTier === "subscription" &&
|
|
306
|
-
winner.subscriptionStatus === "near-limit"
|
|
307
|
-
) {
|
|
308
|
-
const hasHealthyAlt = sorted.some(
|
|
309
|
-
(c) =>
|
|
310
|
-
c !== winner &&
|
|
311
|
-
c.authTier === "subscription" &&
|
|
312
|
-
c.health === "healthy" &&
|
|
313
|
-
c.subscriptionStatus !== "near-limit",
|
|
314
|
-
);
|
|
315
|
-
if (!hasHealthyAlt) {
|
|
316
|
-
const acct = winner.accountId ?? winner.providerId;
|
|
317
|
-
const pct = winner.subscriptionQuotaPercentRemaining;
|
|
318
|
-
const pctText =
|
|
319
|
-
pct !== undefined ? ` (${Math.round(pct)}% remaining)` : "";
|
|
320
|
-
nearLimitWarning = `Your ${winner.providerId} subscription (${acct}) is near its limit${pctText}. Consider switching or adding another account.`;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (nearLimitWarning) {
|
|
325
|
-
finalExplanation = `${finalExplanation} ${nearLimitWarning}`;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return {
|
|
329
|
-
protocolVersion: ROUTE_PROTOCOL_VERSION,
|
|
330
|
-
decisionId,
|
|
331
|
-
requestId: request.requestId,
|
|
332
|
-
route: {
|
|
333
|
-
provider: winner.providerId,
|
|
334
|
-
model: winner.modelId,
|
|
335
|
-
authTier: winner.authTier,
|
|
336
|
-
authScope: winner.authScope,
|
|
337
|
-
accountId: winner.accountId ?? winner.providerId,
|
|
338
|
-
},
|
|
339
|
-
reason: finalReason,
|
|
340
|
-
explanation: finalExplanation,
|
|
341
|
-
fallbackChain,
|
|
342
|
-
policyDenials: policy.denials,
|
|
343
|
-
confidence: confidenceFor(winner, policy.allowed.length),
|
|
344
|
-
auditSummary: `route=${winner.providerId}/${winner.modelId} tier=${winner.authTier} profile=${request.harness.profile} digest=${request.task.promptDigest}`,
|
|
345
|
-
blocked: false,
|
|
346
|
-
needsConfiguration: false,
|
|
347
|
-
nearLimitWarning,
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** @internal exported for focused testing */
|
|
352
|
-
export { sortCandidates };
|
package/src/schemas.test.ts
DELETED
|
@@ -1,291 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
AuthScopeSchema,
|
|
4
|
-
AuthTierSchema,
|
|
5
|
-
FallbackReasonSchema,
|
|
6
|
-
PolicyDenialSchema,
|
|
7
|
-
ProviderCandidateSchema,
|
|
8
|
-
ProviderHealthSchema,
|
|
9
|
-
RouteDecisionSchema,
|
|
10
|
-
RouteRequestSchema,
|
|
11
|
-
SelectedRouteSchema,
|
|
12
|
-
SubscriptionStatusSchema,
|
|
13
|
-
} from "./schemas.js";
|
|
14
|
-
import { ROUTE_PROTOCOL_VERSION } from "./protocol.js";
|
|
15
|
-
|
|
16
|
-
describe("routing vocabulary schemas", () => {
|
|
17
|
-
it("accepts all AuthTier values", () => {
|
|
18
|
-
expect(AuthTierSchema.parse("subscription")).toBe("subscription");
|
|
19
|
-
expect(AuthTierSchema.parse("local")).toBe("local");
|
|
20
|
-
expect(AuthTierSchema.parse("api-key")).toBe("api-key");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("accepts all AuthScope values", () => {
|
|
24
|
-
expect(AuthScopeSchema.parse("first-party")).toBe("first-party");
|
|
25
|
-
expect(AuthScopeSchema.parse("third-party")).toBe("third-party");
|
|
26
|
-
expect(AuthScopeSchema.parse("unknown")).toBe("unknown");
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("accepts all SubscriptionStatus values", () => {
|
|
30
|
-
for (const status of [
|
|
31
|
-
"active",
|
|
32
|
-
"near-limit",
|
|
33
|
-
"exhausted",
|
|
34
|
-
"rate-limited",
|
|
35
|
-
"expired",
|
|
36
|
-
"unconfigured",
|
|
37
|
-
"unknown",
|
|
38
|
-
] as const) {
|
|
39
|
-
expect(SubscriptionStatusSchema.parse(status)).toBe(status);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("accepts all ProviderHealth values", () => {
|
|
44
|
-
expect(ProviderHealthSchema.parse("healthy")).toBe("healthy");
|
|
45
|
-
expect(ProviderHealthSchema.parse("degraded")).toBe("degraded");
|
|
46
|
-
expect(ProviderHealthSchema.parse("dead")).toBe("dead");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("accepts all FallbackReason values", () => {
|
|
50
|
-
for (const reason of [
|
|
51
|
-
"primary",
|
|
52
|
-
"subscription_exhausted",
|
|
53
|
-
"subscription_degraded",
|
|
54
|
-
"subscription_unconfigured",
|
|
55
|
-
"subscription_scope_denied",
|
|
56
|
-
"local_preferred",
|
|
57
|
-
"api_key_fallback",
|
|
58
|
-
"policy_denied",
|
|
59
|
-
"provider_error",
|
|
60
|
-
] as const) {
|
|
61
|
-
expect(FallbackReasonSchema.parse(reason)).toBe(reason);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
describe("routing schemas", () => {
|
|
67
|
-
it("accepts a minimal Claude personal route request", () => {
|
|
68
|
-
const request = RouteRequestSchema.parse({
|
|
69
|
-
protocolVersion: "offrouter.route.v1",
|
|
70
|
-
requestId: "req_1",
|
|
71
|
-
harness: { kind: "claude", profile: "claude-personal" },
|
|
72
|
-
task: {
|
|
73
|
-
promptPreview: "explain this repo",
|
|
74
|
-
promptDigest: "sha256:example",
|
|
75
|
-
kind: "explain",
|
|
76
|
-
risk: "low",
|
|
77
|
-
},
|
|
78
|
-
workspace: { cwd: "/tmp/project", trusted: true },
|
|
79
|
-
constraints: { maxCostUsd: 0.05, localOnly: false },
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
expect(request.harness.profile).toBe("claude-personal");
|
|
83
|
-
expect(request.protocolVersion).toBe(ROUTE_PROTOCOL_VERSION);
|
|
84
|
-
expect(request.task.promptPreview).toBe("explain this repo");
|
|
85
|
-
expect(request.task.promptDigest).toBe("sha256:example");
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("rejects a route request that carries raw prompt text", () => {
|
|
89
|
-
expect(() =>
|
|
90
|
-
RouteRequestSchema.parse({
|
|
91
|
-
protocolVersion: "offrouter.route.v1",
|
|
92
|
-
requestId: "req_2",
|
|
93
|
-
harness: { kind: "claude", profile: "claude-personal" },
|
|
94
|
-
task: {
|
|
95
|
-
prompt: "raw secret prompt",
|
|
96
|
-
promptPreview: "raw secret prompt",
|
|
97
|
-
promptDigest: "sha256:example",
|
|
98
|
-
kind: "explain",
|
|
99
|
-
risk: "low",
|
|
100
|
-
},
|
|
101
|
-
workspace: { cwd: "/tmp/project", trusted: true },
|
|
102
|
-
constraints: { maxCostUsd: 0.05, localOnly: false },
|
|
103
|
-
}),
|
|
104
|
-
).toThrow();
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("rejects a route request that carries raw prompt text at the top level", () => {
|
|
108
|
-
expect(() =>
|
|
109
|
-
RouteRequestSchema.parse({
|
|
110
|
-
protocolVersion: "offrouter.route.v1",
|
|
111
|
-
requestId: "req_3",
|
|
112
|
-
harness: { kind: "claude", profile: "claude-personal" },
|
|
113
|
-
task: {
|
|
114
|
-
promptPreview: "explain this repo",
|
|
115
|
-
promptDigest: "sha256:example",
|
|
116
|
-
kind: "explain",
|
|
117
|
-
risk: "low",
|
|
118
|
-
},
|
|
119
|
-
workspace: { cwd: "/tmp/project", trusted: true },
|
|
120
|
-
constraints: { maxCostUsd: 0.05, localOnly: false },
|
|
121
|
-
prompt: "raw secret prompt",
|
|
122
|
-
}),
|
|
123
|
-
).toThrow();
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("rejects raw prompt text in nested route request objects", () => {
|
|
127
|
-
const baseRequest = {
|
|
128
|
-
protocolVersion: "offrouter.route.v1",
|
|
129
|
-
requestId: "req_4",
|
|
130
|
-
harness: { kind: "claude", profile: "claude-personal" },
|
|
131
|
-
task: {
|
|
132
|
-
promptPreview: "explain this repo",
|
|
133
|
-
promptDigest: "sha256:example",
|
|
134
|
-
kind: "explain",
|
|
135
|
-
risk: "low",
|
|
136
|
-
},
|
|
137
|
-
workspace: { cwd: "/tmp/project", trusted: true },
|
|
138
|
-
constraints: { maxCostUsd: 0.05, localOnly: false },
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
for (const request of [
|
|
142
|
-
{ ...baseRequest, harness: { ...baseRequest.harness, prompt: "raw" } },
|
|
143
|
-
{ ...baseRequest, workspace: { ...baseRequest.workspace, prompt: "raw" } },
|
|
144
|
-
{
|
|
145
|
-
...baseRequest,
|
|
146
|
-
constraints: { ...baseRequest.constraints, prompt: "raw" },
|
|
147
|
-
},
|
|
148
|
-
]) {
|
|
149
|
-
expect(() => RouteRequestSchema.parse(request)).toThrow();
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
it("rejects a route decision without a reason", () => {
|
|
154
|
-
expect(() =>
|
|
155
|
-
RouteDecisionSchema.parse({
|
|
156
|
-
protocolVersion: "offrouter.route.v1",
|
|
157
|
-
decisionId: "dec_1",
|
|
158
|
-
requestId: "req_1",
|
|
159
|
-
route: { provider: "fake", model: "fast", authTier: "subscription" },
|
|
160
|
-
explanation: "Selected the healthy subscription-backed candidate.",
|
|
161
|
-
}),
|
|
162
|
-
).toThrow();
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it("rejects a route decision without an explanation", () => {
|
|
166
|
-
expect(() =>
|
|
167
|
-
RouteDecisionSchema.parse({
|
|
168
|
-
protocolVersion: "offrouter.route.v1",
|
|
169
|
-
decisionId: "dec_1",
|
|
170
|
-
requestId: "req_1",
|
|
171
|
-
route: { provider: "fake", model: "fast", authTier: "subscription" },
|
|
172
|
-
reason: "subscription_primary",
|
|
173
|
-
}),
|
|
174
|
-
).toThrow();
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
it("accepts a route decision with explanation and reason", () => {
|
|
178
|
-
const decision = RouteDecisionSchema.parse({
|
|
179
|
-
protocolVersion: "offrouter.route.v1",
|
|
180
|
-
decisionId: "dec_1",
|
|
181
|
-
requestId: "req_1",
|
|
182
|
-
route: { provider: "fake", model: "fast", authTier: "subscription" },
|
|
183
|
-
reason: "subscription_primary",
|
|
184
|
-
explanation:
|
|
185
|
-
"Selected the healthy subscription-backed candidate over the API-key fallback.",
|
|
186
|
-
fallbackChain: [
|
|
187
|
-
{
|
|
188
|
-
provider: "fake",
|
|
189
|
-
model: "fast",
|
|
190
|
-
authTier: "subscription",
|
|
191
|
-
reason: "primary",
|
|
192
|
-
},
|
|
193
|
-
],
|
|
194
|
-
policyDenials: [],
|
|
195
|
-
confidence: 0.9,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
expect(decision.explanation).toContain("subscription-backed");
|
|
199
|
-
expect(decision.reason).toBe("subscription_primary");
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it("accepts a provider candidate with subscription metadata", () => {
|
|
203
|
-
const candidate = ProviderCandidateSchema.parse({
|
|
204
|
-
providerId: "claude",
|
|
205
|
-
modelId: "sonnet",
|
|
206
|
-
authTier: "subscription",
|
|
207
|
-
authScope: "first-party",
|
|
208
|
-
subscriptionStatus: "active",
|
|
209
|
-
health: "healthy",
|
|
210
|
-
supportsTools: true,
|
|
211
|
-
supportsStreaming: true,
|
|
212
|
-
supportsJson: true,
|
|
213
|
-
supportsImages: false,
|
|
214
|
-
});
|
|
215
|
-
|
|
216
|
-
expect(candidate.authTier).toBe("subscription");
|
|
217
|
-
expect(candidate.authScope).toBe("first-party");
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
it("accepts a structured policy denial", () => {
|
|
221
|
-
const denial = PolicyDenialSchema.parse({
|
|
222
|
-
code: "api_key_blocked_by_subscription_first_policy",
|
|
223
|
-
target: "candidate",
|
|
224
|
-
id: "openrouter:fast",
|
|
225
|
-
message:
|
|
226
|
-
"API-key candidate blocked while healthy subscription capacity remains.",
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
expect(denial.code).toBe("api_key_blocked_by_subscription_first_policy");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("rejects unknown policy denial codes", () => {
|
|
233
|
-
expect(() =>
|
|
234
|
-
PolicyDenialSchema.parse({
|
|
235
|
-
code: "not_a_real_code",
|
|
236
|
-
target: "profile",
|
|
237
|
-
id: "claude-work",
|
|
238
|
-
message: "nope",
|
|
239
|
-
}),
|
|
240
|
-
).toThrow();
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
describe("multi-account schema fields", () => {
|
|
244
|
-
it("accepts ProviderCandidate with accountId and subscriptionQuotaPercentRemaining", () => {
|
|
245
|
-
const candidate = ProviderCandidateSchema.parse({
|
|
246
|
-
providerId: "anthropic",
|
|
247
|
-
modelId: "sonnet",
|
|
248
|
-
authTier: "subscription",
|
|
249
|
-
authScope: "first-party",
|
|
250
|
-
subscriptionStatus: "active",
|
|
251
|
-
accountId: "anthropic-1",
|
|
252
|
-
subscriptionQuotaPercentRemaining: 15,
|
|
253
|
-
health: "healthy",
|
|
254
|
-
supportsTools: true,
|
|
255
|
-
supportsStreaming: true,
|
|
256
|
-
supportsJson: true,
|
|
257
|
-
supportsImages: false,
|
|
258
|
-
});
|
|
259
|
-
expect(candidate.accountId).toBe("anthropic-1");
|
|
260
|
-
expect(candidate.subscriptionQuotaPercentRemaining).toBe(15);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("accepts near-limit SubscriptionStatus", () => {
|
|
264
|
-
expect(SubscriptionStatusSchema.parse("near-limit")).toBe("near-limit");
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it("accepts RouteDecision with nearLimitWarning", () => {
|
|
268
|
-
const decision = RouteDecisionSchema.parse({
|
|
269
|
-
protocolVersion: "offrouter.route.v1",
|
|
270
|
-
decisionId: "dec_nl",
|
|
271
|
-
requestId: "req_nl",
|
|
272
|
-
route: { provider: "anthropic", model: "sonnet", authTier: "subscription" },
|
|
273
|
-
reason: "subscription_primary",
|
|
274
|
-
explanation: "Selected near-limit subscription.",
|
|
275
|
-
nearLimitWarning:
|
|
276
|
-
"Your anthropic subscription (anthropic-1) is near its limit (15% remaining). Consider switching or adding another account.",
|
|
277
|
-
});
|
|
278
|
-
expect(decision.nearLimitWarning).toContain("near its limit");
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("accepts SelectedRoute with accountId", () => {
|
|
282
|
-
const route = {
|
|
283
|
-
provider: "anthropic",
|
|
284
|
-
model: "sonnet",
|
|
285
|
-
authTier: "subscription",
|
|
286
|
-
accountId: "anthropic-1",
|
|
287
|
-
};
|
|
288
|
-
expect(() => SelectedRouteSchema.parse(route)).not.toThrow();
|
|
289
|
-
});
|
|
290
|
-
});
|
|
291
|
-
});
|