offrouter-core 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +9 -4
- 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/policy.ts
DELETED
|
@@ -1,420 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Profile and candidate policy engine for OffRouter V1.
|
|
3
|
-
* Hard filters first; subscription-first rules gate API-key candidates.
|
|
4
|
-
*/
|
|
5
|
-
import type {
|
|
6
|
-
PolicyDecision,
|
|
7
|
-
PolicyDenial,
|
|
8
|
-
ProviderCandidate,
|
|
9
|
-
RouteRequest,
|
|
10
|
-
SubscriptionStatus,
|
|
11
|
-
} from "./types.js";
|
|
12
|
-
|
|
13
|
-
export interface PolicyConfig {
|
|
14
|
-
/** Profiles explicitly allowed to route through OffRouter. */
|
|
15
|
-
allowlistedProfiles: string[];
|
|
16
|
-
/**
|
|
17
|
-
* Glob-like patterns for denied profiles (supports `*` wildcards).
|
|
18
|
-
* Default use case: `*-work`.
|
|
19
|
-
*/
|
|
20
|
-
deniedProfilePatterns?: string[];
|
|
21
|
-
/**
|
|
22
|
-
* When false (default), subscription-tier candidates must be first-party.
|
|
23
|
-
* Third-party subscription adapters are denied unless opted in.
|
|
24
|
-
*/
|
|
25
|
-
allowThirdPartySubscriptionAdapters?: boolean;
|
|
26
|
-
/** Optional explicit provider deny list by providerId. */
|
|
27
|
-
deniedProviders?: string[];
|
|
28
|
-
/** Fraction of subscription quota remaining at/under which an account is "near-limit". Default 0.2. */
|
|
29
|
-
nearLimitThreshold?: number;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
const HEALTHY_SUBSCRIPTION_STATUSES: ReadonlySet<SubscriptionStatus> = new Set([
|
|
33
|
-
"active",
|
|
34
|
-
]);
|
|
35
|
-
|
|
36
|
-
const UNAVAILABLE_SUBSCRIPTION_STATUSES: ReadonlySet<SubscriptionStatus> =
|
|
37
|
-
new Set(["exhausted", "rate-limited", "expired", "unconfigured", "unknown"]);
|
|
38
|
-
|
|
39
|
-
function candidateId(c: ProviderCandidate): string {
|
|
40
|
-
return `${c.providerId}:${c.modelId}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function matchesGlob(pattern: string, value: string): boolean {
|
|
44
|
-
// Escape regex special chars except *, then turn * into .*
|
|
45
|
-
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
46
|
-
const re = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`);
|
|
47
|
-
return re.test(value);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function isWorkProfileDenied(
|
|
51
|
-
profile: string,
|
|
52
|
-
patterns: string[] | undefined,
|
|
53
|
-
): boolean {
|
|
54
|
-
// Work profiles are denied by default. Custom patterns are additive.
|
|
55
|
-
// Align with the Claude installer: a profile is work-like if it ends with
|
|
56
|
-
// "-work" (e.g. claude-work) or contains "-work-" (e.g. claude-work-staging).
|
|
57
|
-
const effectivePatterns = ["*-work", ...(patterns ?? [])];
|
|
58
|
-
return (
|
|
59
|
-
profile.endsWith("-work") ||
|
|
60
|
-
profile.includes("-work-") ||
|
|
61
|
-
effectivePatterns.some((pattern) => matchesGlob(pattern, profile))
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function isAllowedProfile(profile: string, config: PolicyConfig): boolean {
|
|
66
|
-
return config.allowlistedProfiles.includes(profile);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Whether a candidate meets hard capability requirements from the task.
|
|
71
|
-
*/
|
|
72
|
-
export function meetsCapabilities(
|
|
73
|
-
candidate: ProviderCandidate,
|
|
74
|
-
request: RouteRequest,
|
|
75
|
-
): boolean {
|
|
76
|
-
const { task } = request;
|
|
77
|
-
if (task.requiresTools && !candidate.supportsTools) return false;
|
|
78
|
-
if (task.requiresImages && !candidate.supportsImages) return false;
|
|
79
|
-
if (task.requiresStreaming && !candidate.supportsStreaming) return false;
|
|
80
|
-
if (task.requiresJson && !candidate.supportsJson) return false;
|
|
81
|
-
// Task kind shortcuts when requires* not set explicitly
|
|
82
|
-
if (task.kind === "tools" && task.requiresTools !== false && !candidate.supportsTools) {
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
if (task.kind === "image" && task.requiresImages !== false && !candidate.supportsImages) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function missingCapabilityMessage(
|
|
92
|
-
candidate: ProviderCandidate,
|
|
93
|
-
request: RouteRequest,
|
|
94
|
-
): string {
|
|
95
|
-
const missing: string[] = [];
|
|
96
|
-
if (
|
|
97
|
-
(request.task.requiresTools || request.task.kind === "tools") &&
|
|
98
|
-
!candidate.supportsTools
|
|
99
|
-
) {
|
|
100
|
-
missing.push("tools");
|
|
101
|
-
}
|
|
102
|
-
if (
|
|
103
|
-
(request.task.requiresImages || request.task.kind === "image") &&
|
|
104
|
-
!candidate.supportsImages
|
|
105
|
-
) {
|
|
106
|
-
missing.push("images");
|
|
107
|
-
}
|
|
108
|
-
if (request.task.requiresStreaming && !candidate.supportsStreaming) {
|
|
109
|
-
missing.push("streaming");
|
|
110
|
-
}
|
|
111
|
-
if (request.task.requiresJson && !candidate.supportsJson) {
|
|
112
|
-
missing.push("json");
|
|
113
|
-
}
|
|
114
|
-
return `Candidate ${candidateId(candidate)} missing required capabilities: ${missing.join(", ") || "unknown"}.`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Healthy subscription capacity: active status (or omitted treated carefully)
|
|
119
|
-
* with non-dead health, and policy/capability eligible.
|
|
120
|
-
*
|
|
121
|
-
* Degraded health is still "usable" for routing later but is not treated as
|
|
122
|
-
* fully healthy capacity for blocking API-key candidates when another healthy
|
|
123
|
-
* subscription exists — that nuance lives in the router score. For policy
|
|
124
|
-
* gatekeeping "can a healthy subscription satisfy the task?", we accept
|
|
125
|
-
* health === "healthy" and subscriptionStatus in active (defaulting missing
|
|
126
|
-
* status on subscription tier to unknown, not healthy).
|
|
127
|
-
*/
|
|
128
|
-
export function isHealthySubscriptionCapacity(
|
|
129
|
-
candidate: ProviderCandidate,
|
|
130
|
-
request: RouteRequest,
|
|
131
|
-
config: PolicyConfig,
|
|
132
|
-
): boolean {
|
|
133
|
-
if (candidate.authTier !== "subscription") return false;
|
|
134
|
-
if (candidate.health !== "healthy") return false;
|
|
135
|
-
|
|
136
|
-
const status = candidate.subscriptionStatus ?? "unknown";
|
|
137
|
-
if (!HEALTHY_SUBSCRIPTION_STATUSES.has(status)) return false;
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
candidate.authScope === "third-party" &&
|
|
141
|
-
!config.allowThirdPartySubscriptionAdapters
|
|
142
|
-
) {
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
if (candidate.authScope === "unknown") {
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
if (!meetsCapabilities(candidate, request)) return false;
|
|
150
|
-
|
|
151
|
-
// Subscription positions are never local; localOnly excludes them.
|
|
152
|
-
if (request.constraints.localOnly) {
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
request.constraints.maxCostUsd !== undefined &&
|
|
158
|
-
candidate.estimatedCostUsd !== undefined &&
|
|
159
|
-
candidate.estimatedCostUsd > request.constraints.maxCostUsd
|
|
160
|
-
) {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
if (config.deniedProviders?.includes(candidate.providerId)) {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return true;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Whether any remaining subscription candidate has healthy capacity after hard
|
|
173
|
-
* filters, so API-key stays blocked only while a healthy subscription route can
|
|
174
|
-
* satisfy the task.
|
|
175
|
-
*/
|
|
176
|
-
function hasUsableSubscriptionForTask(
|
|
177
|
-
candidates: ProviderCandidate[],
|
|
178
|
-
request: RouteRequest,
|
|
179
|
-
config: PolicyConfig,
|
|
180
|
-
): boolean {
|
|
181
|
-
return candidates.some((c) => {
|
|
182
|
-
if (c.authTier !== "subscription") return false;
|
|
183
|
-
if (c.health !== "healthy") return false;
|
|
184
|
-
const status = c.subscriptionStatus ?? "unknown";
|
|
185
|
-
// Only active + healthy subscription capacity blocks API-key candidates
|
|
186
|
-
// under subscription-first.
|
|
187
|
-
if (!HEALTHY_SUBSCRIPTION_STATUSES.has(status)) return false;
|
|
188
|
-
if (
|
|
189
|
-
c.authScope === "third-party" &&
|
|
190
|
-
!config.allowThirdPartySubscriptionAdapters
|
|
191
|
-
) {
|
|
192
|
-
return false;
|
|
193
|
-
}
|
|
194
|
-
if (c.authScope === "unknown") return false;
|
|
195
|
-
if (!meetsCapabilities(c, request)) return false;
|
|
196
|
-
if (request.constraints.localOnly) return false;
|
|
197
|
-
if (
|
|
198
|
-
request.constraints.maxCostUsd !== undefined &&
|
|
199
|
-
c.estimatedCostUsd !== undefined &&
|
|
200
|
-
c.estimatedCostUsd > request.constraints.maxCostUsd
|
|
201
|
-
) {
|
|
202
|
-
return false;
|
|
203
|
-
}
|
|
204
|
-
if (config.deniedProviders?.includes(c.providerId)) return false;
|
|
205
|
-
return true;
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function hardFilterDenial(
|
|
210
|
-
candidate: ProviderCandidate,
|
|
211
|
-
request: RouteRequest,
|
|
212
|
-
config: PolicyConfig,
|
|
213
|
-
): PolicyDenial | null {
|
|
214
|
-
const id = candidateId(candidate);
|
|
215
|
-
|
|
216
|
-
if (config.deniedProviders?.includes(candidate.providerId)) {
|
|
217
|
-
return {
|
|
218
|
-
code: "provider_denied",
|
|
219
|
-
target: "provider",
|
|
220
|
-
id: candidate.providerId,
|
|
221
|
-
message: `Provider ${candidate.providerId} is denied by policy.`,
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (request.constraints.localOnly && candidate.authTier !== "local") {
|
|
226
|
-
return {
|
|
227
|
-
code: "local_only_required",
|
|
228
|
-
target: "candidate",
|
|
229
|
-
id,
|
|
230
|
-
message: `Candidate ${id} is not a local runtime; localOnly is required.`,
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (
|
|
235
|
-
request.constraints.maxCostUsd !== undefined &&
|
|
236
|
-
candidate.authTier === "api-key" &&
|
|
237
|
-
candidate.estimatedCostUsd === undefined
|
|
238
|
-
) {
|
|
239
|
-
return {
|
|
240
|
-
code: "cost_cap_exceeded",
|
|
241
|
-
target: "candidate",
|
|
242
|
-
id,
|
|
243
|
-
message: `API-key candidate ${id} has unknown cost and cannot satisfy maxCostUsd ${request.constraints.maxCostUsd}.`,
|
|
244
|
-
};
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (
|
|
248
|
-
request.constraints.maxCostUsd !== undefined &&
|
|
249
|
-
candidate.estimatedCostUsd !== undefined &&
|
|
250
|
-
candidate.estimatedCostUsd > request.constraints.maxCostUsd
|
|
251
|
-
) {
|
|
252
|
-
return {
|
|
253
|
-
code: "cost_cap_exceeded",
|
|
254
|
-
target: "candidate",
|
|
255
|
-
id,
|
|
256
|
-
message: `Candidate ${id} estimated cost ${candidate.estimatedCostUsd} exceeds maxCostUsd ${request.constraints.maxCostUsd}.`,
|
|
257
|
-
};
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (!meetsCapabilities(candidate, request)) {
|
|
261
|
-
return {
|
|
262
|
-
code: "capability_missing",
|
|
263
|
-
target: "candidate",
|
|
264
|
-
id,
|
|
265
|
-
message: missingCapabilityMessage(candidate, request),
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// First-party subscription surface via third-party adapter
|
|
270
|
-
if (
|
|
271
|
-
candidate.authTier === "subscription" &&
|
|
272
|
-
candidate.authScope === "third-party" &&
|
|
273
|
-
!config.allowThirdPartySubscriptionAdapters
|
|
274
|
-
) {
|
|
275
|
-
return {
|
|
276
|
-
code: "subscription_scope_denied",
|
|
277
|
-
target: "candidate",
|
|
278
|
-
id,
|
|
279
|
-
message: `Subscription candidate ${id} uses a third-party adapter surface without policy allow.`,
|
|
280
|
-
};
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
candidate.authTier === "subscription" &&
|
|
285
|
-
candidate.authScope === "unknown"
|
|
286
|
-
) {
|
|
287
|
-
return {
|
|
288
|
-
code: "subscription_scope_denied",
|
|
289
|
-
target: "candidate",
|
|
290
|
-
id,
|
|
291
|
-
message: `Subscription candidate ${id} has unknown auth scope and cannot route until the adapter proves support.`,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
// Dead candidates never route
|
|
296
|
-
if (candidate.health === "dead") {
|
|
297
|
-
// Treat as policy-ineligible for selection; keep as structured denial so
|
|
298
|
-
// explain paths can show why. Prefer provider_denied-ish message without
|
|
299
|
-
// inventing a new code — map to provider_denied for dead health is
|
|
300
|
-
// slightly imprecise; capability isn't right. Use provider_denied for
|
|
301
|
-
// explicit denylist only. For dead, we skip without adding denial so
|
|
302
|
-
// they simply don't appear as allowed. Router will then handle empty set.
|
|
303
|
-
// Actually: don't allow them, no denial needed for "dead" — just exclude.
|
|
304
|
-
return {
|
|
305
|
-
code: "provider_denied",
|
|
306
|
-
target: "candidate",
|
|
307
|
-
id,
|
|
308
|
-
message: `Candidate ${id} is dead and cannot be selected.`,
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Exhausted/rate-limited/expired subscription tiers are not allowed as primary
|
|
313
|
-
if (candidate.authTier === "subscription") {
|
|
314
|
-
const status = candidate.subscriptionStatus ?? "unknown";
|
|
315
|
-
if (UNAVAILABLE_SUBSCRIPTION_STATUSES.has(status)) {
|
|
316
|
-
return {
|
|
317
|
-
code: "provider_denied",
|
|
318
|
-
target: "candidate",
|
|
319
|
-
id,
|
|
320
|
-
message: `Subscription candidate ${id} is ${status} and cannot satisfy the task.`,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
return null;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
/**
|
|
329
|
-
* Evaluate profile and candidate policy.
|
|
330
|
-
* Returns allowed candidates plus structured denials (never string-only).
|
|
331
|
-
*/
|
|
332
|
-
export function evaluatePolicy(
|
|
333
|
-
request: RouteRequest,
|
|
334
|
-
candidates: ProviderCandidate[],
|
|
335
|
-
config: PolicyConfig,
|
|
336
|
-
): PolicyDecision {
|
|
337
|
-
const denials: PolicyDenial[] = [];
|
|
338
|
-
const profile = request.harness.profile;
|
|
339
|
-
|
|
340
|
-
// 1. Profile gates
|
|
341
|
-
if (isWorkProfileDenied(profile, config.deniedProfilePatterns)) {
|
|
342
|
-
denials.push({
|
|
343
|
-
code: "work_profile_denied",
|
|
344
|
-
target: "profile",
|
|
345
|
-
id: profile,
|
|
346
|
-
message: `Work profile ${profile} is denied by default. OffRouter V1 only routes allowlisted personal profiles.`,
|
|
347
|
-
});
|
|
348
|
-
return { allowed: [], denials };
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (!isAllowedProfile(profile, config)) {
|
|
352
|
-
denials.push({
|
|
353
|
-
code: "profile_not_allowlisted",
|
|
354
|
-
target: "profile",
|
|
355
|
-
id: profile,
|
|
356
|
-
message: `Profile ${profile} is not allowlisted. Add it explicitly before installing or routing.`,
|
|
357
|
-
});
|
|
358
|
-
return { allowed: [], denials };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Workspace trust is a hard filter when untrusted
|
|
362
|
-
if (!request.workspace.trusted) {
|
|
363
|
-
denials.push({
|
|
364
|
-
code: "project_untrusted",
|
|
365
|
-
target: "workspace",
|
|
366
|
-
id: request.workspace.cwd,
|
|
367
|
-
message: `Workspace ${request.workspace.cwd} is untrusted; routing is blocked.`,
|
|
368
|
-
});
|
|
369
|
-
return { allowed: [], denials };
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
// 2. Hard filters per candidate
|
|
373
|
-
const hardPassed: ProviderCandidate[] = [];
|
|
374
|
-
for (const c of candidates) {
|
|
375
|
-
const denial = hardFilterDenial(c, request, config);
|
|
376
|
-
if (denial) {
|
|
377
|
-
denials.push(denial);
|
|
378
|
-
} else {
|
|
379
|
-
hardPassed.push(c);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// 3. Subscription-first: gate API-key when usable subscription capacity exists,
|
|
384
|
-
// or when allowApiKeyFallback is false.
|
|
385
|
-
const subscriptionFirst = request.constraints.subscriptionFirst !== false;
|
|
386
|
-
const allowApiKeyFallback = request.constraints.allowApiKeyFallback === true;
|
|
387
|
-
|
|
388
|
-
const usableSubscription = hasUsableSubscriptionForTask(
|
|
389
|
-
hardPassed,
|
|
390
|
-
request,
|
|
391
|
-
config,
|
|
392
|
-
);
|
|
393
|
-
|
|
394
|
-
const allowed: ProviderCandidate[] = [];
|
|
395
|
-
for (const c of hardPassed) {
|
|
396
|
-
if (c.authTier === "api-key") {
|
|
397
|
-
if (subscriptionFirst && usableSubscription) {
|
|
398
|
-
denials.push({
|
|
399
|
-
code: "api_key_blocked_by_subscription_first_policy",
|
|
400
|
-
target: "candidate",
|
|
401
|
-
id: candidateId(c),
|
|
402
|
-
message: `API-key candidate ${candidateId(c)} blocked while healthy subscription capacity remains.`,
|
|
403
|
-
});
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
if (subscriptionFirst && !allowApiKeyFallback) {
|
|
407
|
-
denials.push({
|
|
408
|
-
code: "api_key_blocked_by_subscription_first_policy",
|
|
409
|
-
target: "candidate",
|
|
410
|
-
id: candidateId(c),
|
|
411
|
-
message: `API-key candidate ${candidateId(c)} blocked: allowApiKeyFallback is false.`,
|
|
412
|
-
});
|
|
413
|
-
continue;
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
allowed.push(c);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
return { allowed, denials };
|
|
420
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import type { RouteRequest } from "../types.js";
|
|
3
|
-
import {
|
|
4
|
-
PROVIDER_ADAPTER_METHODS,
|
|
5
|
-
ProviderError,
|
|
6
|
-
isProviderError,
|
|
7
|
-
mapHttpToProviderError,
|
|
8
|
-
type ProviderAdapter,
|
|
9
|
-
} from "./adapter.js";
|
|
10
|
-
import { createFakeProvider } from "./fake.js";
|
|
11
|
-
import { createOpenAiCompatibleAdapter } from "./openai-compatible.js";
|
|
12
|
-
|
|
13
|
-
function sampleRoute(): RouteRequest {
|
|
14
|
-
return {
|
|
15
|
-
protocolVersion: "offrouter.route.v1",
|
|
16
|
-
requestId: "req_adapter_1",
|
|
17
|
-
harness: { kind: "claude", profile: "claude-personal" },
|
|
18
|
-
task: {
|
|
19
|
-
promptPreview: "hello",
|
|
20
|
-
promptDigest: "sha256:abc",
|
|
21
|
-
kind: "explain",
|
|
22
|
-
risk: "low",
|
|
23
|
-
},
|
|
24
|
-
workspace: { cwd: "/tmp", trusted: true },
|
|
25
|
-
constraints: { subscriptionFirst: true, allowApiKeyFallback: true },
|
|
26
|
-
};
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function adaptersUnderTest(): ProviderAdapter[] {
|
|
30
|
-
return [
|
|
31
|
-
createFakeProvider({ id: "fake-a" }),
|
|
32
|
-
createOpenAiCompatibleAdapter({
|
|
33
|
-
id: "openrouter",
|
|
34
|
-
baseUrl: "http://127.0.0.1:9",
|
|
35
|
-
flavor: "openrouter",
|
|
36
|
-
apiKey: "test-key",
|
|
37
|
-
models: [
|
|
38
|
-
{
|
|
39
|
-
modelId: "openai/gpt-4.1-mini",
|
|
40
|
-
supportsTools: true,
|
|
41
|
-
supportsStreaming: true,
|
|
42
|
-
supportsJson: true,
|
|
43
|
-
supportsImages: false,
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
fetch: async () =>
|
|
47
|
-
new Response(
|
|
48
|
-
JSON.stringify({ choices: [{ message: { content: "ok" } }] }),
|
|
49
|
-
{
|
|
50
|
-
status: 200,
|
|
51
|
-
headers: { "content-type": "application/json" },
|
|
52
|
-
},
|
|
53
|
-
),
|
|
54
|
-
}),
|
|
55
|
-
createOpenAiCompatibleAdapter({
|
|
56
|
-
id: "ollama",
|
|
57
|
-
baseUrl: "http://127.0.0.1:11434/v1",
|
|
58
|
-
flavor: "ollama",
|
|
59
|
-
models: [
|
|
60
|
-
{
|
|
61
|
-
modelId: "qwen2.5-coder:7b",
|
|
62
|
-
supportsTools: true,
|
|
63
|
-
supportsStreaming: true,
|
|
64
|
-
supportsJson: true,
|
|
65
|
-
supportsImages: false,
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
fetch: async () =>
|
|
69
|
-
new Response(
|
|
70
|
-
JSON.stringify({ choices: [{ message: { content: "local" } }] }),
|
|
71
|
-
{
|
|
72
|
-
status: 200,
|
|
73
|
-
headers: { "content-type": "application/json" },
|
|
74
|
-
},
|
|
75
|
-
),
|
|
76
|
-
}),
|
|
77
|
-
];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
describe("ProviderAdapter contract", () => {
|
|
81
|
-
it("lists every required method on the contract constant", () => {
|
|
82
|
-
expect([...PROVIDER_ADAPTER_METHODS]).toEqual([
|
|
83
|
-
"listModels",
|
|
84
|
-
"checkAuth",
|
|
85
|
-
"queryLimits",
|
|
86
|
-
"estimate",
|
|
87
|
-
"invoke",
|
|
88
|
-
"cancel",
|
|
89
|
-
]);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("every provider implements listModels, checkAuth, queryLimits, estimate, invoke, and cancel", async () => {
|
|
93
|
-
for (const adapter of adaptersUnderTest()) {
|
|
94
|
-
for (const method of PROVIDER_ADAPTER_METHODS) {
|
|
95
|
-
expect(
|
|
96
|
-
typeof adapter[method],
|
|
97
|
-
`${adapter.id}.${method} must be a function`,
|
|
98
|
-
).toBe("function");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const models = await adapter.listModels();
|
|
102
|
-
expect(Array.isArray(models)).toBe(true);
|
|
103
|
-
expect(models.length).toBeGreaterThan(0);
|
|
104
|
-
|
|
105
|
-
const auth = await adapter.checkAuth();
|
|
106
|
-
expect(auth.authTier).toBeTruthy();
|
|
107
|
-
expect(auth.authScope).toBeTruthy();
|
|
108
|
-
expect(typeof auth.hasApiKey).toBe("boolean");
|
|
109
|
-
expect(typeof auth.limitsDiscoverable).toBe("boolean");
|
|
110
|
-
|
|
111
|
-
const limits = await adapter.queryLimits(auth.authTier);
|
|
112
|
-
expect(limits.authTier).toBe(auth.authTier);
|
|
113
|
-
expect(typeof limits.opaque).toBe("boolean");
|
|
114
|
-
expect(typeof limits.discoverable).toBe("boolean");
|
|
115
|
-
|
|
116
|
-
const modelId = models[0]!.modelId;
|
|
117
|
-
const estimate = await adapter.estimate(
|
|
118
|
-
sampleRoute(),
|
|
119
|
-
modelId,
|
|
120
|
-
auth.authTier,
|
|
121
|
-
);
|
|
122
|
-
expect(estimate.model).toBe(modelId);
|
|
123
|
-
expect(estimate.currency).toBe("USD");
|
|
124
|
-
expect(typeof estimate.estimatedCostUsd).toBe("number");
|
|
125
|
-
|
|
126
|
-
const events: unknown[] = [];
|
|
127
|
-
for await (const event of adapter.invoke({
|
|
128
|
-
callId: `${adapter.id}-call`,
|
|
129
|
-
model: modelId,
|
|
130
|
-
messages: [{ role: "user", content: "hi" }],
|
|
131
|
-
stream: false,
|
|
132
|
-
})) {
|
|
133
|
-
events.push(event);
|
|
134
|
-
}
|
|
135
|
-
expect(events.length).toBeGreaterThan(0);
|
|
136
|
-
|
|
137
|
-
await expect(
|
|
138
|
-
adapter.cancel(`${adapter.id}-call`),
|
|
139
|
-
).resolves.toBeUndefined();
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
describe("canonical ProviderError mapping", () => {
|
|
145
|
-
it("is identifiable via isProviderError", () => {
|
|
146
|
-
const err = new ProviderError({
|
|
147
|
-
code: "rate_limited",
|
|
148
|
-
message: "slow down",
|
|
149
|
-
retryable: true,
|
|
150
|
-
httpStatus: 429,
|
|
151
|
-
});
|
|
152
|
-
expect(isProviderError(err)).toBe(true);
|
|
153
|
-
expect(isProviderError(new Error("nope"))).toBe(false);
|
|
154
|
-
expect(err.code).toBe("rate_limited");
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it("maps HTTP statuses onto canonical codes", () => {
|
|
158
|
-
expect(mapHttpToProviderError(401).code).toBe("auth_failed");
|
|
159
|
-
expect(mapHttpToProviderError(403).code).toBe("auth_failed");
|
|
160
|
-
expect(mapHttpToProviderError(404).code).toBe("model_not_found");
|
|
161
|
-
expect(mapHttpToProviderError(429, "rate limit").code).toBe("rate_limited");
|
|
162
|
-
expect(mapHttpToProviderError(429, "quota exceeded").code).toBe(
|
|
163
|
-
"quota_exhausted",
|
|
164
|
-
);
|
|
165
|
-
expect(mapHttpToProviderError(402).code).toBe("billing");
|
|
166
|
-
expect(mapHttpToProviderError(413).code).toBe("context_window");
|
|
167
|
-
expect(mapHttpToProviderError(503).code).toBe("overload");
|
|
168
|
-
expect(mapHttpToProviderError(500).code).toBe("network");
|
|
169
|
-
expect(mapHttpToProviderError(400).code).toBe("invalid_request");
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it("redacts likely API keys from provider error bodies", () => {
|
|
173
|
-
const err = mapHttpToProviderError(
|
|
174
|
-
500,
|
|
175
|
-
"upstream echoed sk-proj-1234567890abcdef and failed",
|
|
176
|
-
);
|
|
177
|
-
expect(err.message).toContain("[REDACTED]");
|
|
178
|
-
expect(err.message).not.toContain("sk-proj-1234567890abcdef");
|
|
179
|
-
});
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
describe("fake adapter behavior", () => {
|
|
183
|
-
it("streams text deltas", async () => {
|
|
184
|
-
const adapter = createFakeProvider({
|
|
185
|
-
streamChunks: ["a", "b", "c"],
|
|
186
|
-
});
|
|
187
|
-
const texts: string[] = [];
|
|
188
|
-
for await (const event of adapter.invoke({
|
|
189
|
-
callId: "stream-1",
|
|
190
|
-
model: "fake-coder",
|
|
191
|
-
messages: [{ role: "user", content: "x" }],
|
|
192
|
-
stream: true,
|
|
193
|
-
})) {
|
|
194
|
-
if (event.type === "text-delta") texts.push(event.text);
|
|
195
|
-
}
|
|
196
|
-
expect(texts).toEqual(["a", "b", "c"]);
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("supports one-shot responses", async () => {
|
|
200
|
-
const adapter = createFakeProvider({ responseText: "complete" });
|
|
201
|
-
const texts: string[] = [];
|
|
202
|
-
for await (const event of adapter.invoke({
|
|
203
|
-
callId: "oneshot-1",
|
|
204
|
-
model: "fake-coder",
|
|
205
|
-
messages: [{ role: "user", content: "x" }],
|
|
206
|
-
})) {
|
|
207
|
-
if (event.type === "text-delta") texts.push(event.text);
|
|
208
|
-
}
|
|
209
|
-
expect(texts).toEqual(["complete"]);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
it("surfaces configured provider errors", async () => {
|
|
213
|
-
const adapter = createFakeProvider({
|
|
214
|
-
error: new ProviderError({
|
|
215
|
-
code: "safety_refusal",
|
|
216
|
-
message: "nope",
|
|
217
|
-
retryable: false,
|
|
218
|
-
}),
|
|
219
|
-
});
|
|
220
|
-
const errors: ProviderError[] = [];
|
|
221
|
-
for await (const event of adapter.invoke({
|
|
222
|
-
callId: "err-1",
|
|
223
|
-
model: "fake-coder",
|
|
224
|
-
messages: [{ role: "user", content: "x" }],
|
|
225
|
-
})) {
|
|
226
|
-
if (event.type === "error") errors.push(event.error);
|
|
227
|
-
}
|
|
228
|
-
expect(errors).toHaveLength(1);
|
|
229
|
-
expect(errors[0]!.code).toBe("safety_refusal");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("honors cancel during hang", async () => {
|
|
233
|
-
const adapter = createFakeProvider({ hangUntilCancel: true });
|
|
234
|
-
const callId = "cancel-me";
|
|
235
|
-
const iterable = adapter.invoke({
|
|
236
|
-
callId,
|
|
237
|
-
model: "fake-coder",
|
|
238
|
-
messages: [{ role: "user", content: "x" }],
|
|
239
|
-
stream: true,
|
|
240
|
-
});
|
|
241
|
-
const iter = iterable[Symbol.asyncIterator]();
|
|
242
|
-
|
|
243
|
-
// start
|
|
244
|
-
await iter.next();
|
|
245
|
-
await adapter.cancel(callId);
|
|
246
|
-
|
|
247
|
-
let sawCancel = false;
|
|
248
|
-
while (true) {
|
|
249
|
-
const next = await iter.next();
|
|
250
|
-
if (next.done) break;
|
|
251
|
-
if (
|
|
252
|
-
next.value.type === "error" &&
|
|
253
|
-
next.value.error.code === "cancelled"
|
|
254
|
-
) {
|
|
255
|
-
sawCancel = true;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
expect(sawCancel).toBe(true);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it("reports opaque limit metadata when configured", async () => {
|
|
262
|
-
const adapter = createFakeProvider({
|
|
263
|
-
opaqueLimits: true,
|
|
264
|
-
limitsDiscoverable: false,
|
|
265
|
-
});
|
|
266
|
-
const limits = await adapter.queryLimits("subscription");
|
|
267
|
-
expect(limits.opaque).toBe(true);
|
|
268
|
-
expect(limits.discoverable).toBe(false);
|
|
269
|
-
expect(limits.metadata).toBeDefined();
|
|
270
|
-
expect(limits.remaining).toBeUndefined();
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("uses deterministic reset metadata for fake limits", async () => {
|
|
274
|
-
const adapter = createFakeProvider();
|
|
275
|
-
const a = await adapter.queryLimits("api-key");
|
|
276
|
-
const b = await adapter.queryLimits("api-key");
|
|
277
|
-
expect(a.resetAt).toBe("1970-01-01T01:00:00.000Z");
|
|
278
|
-
expect(b.resetAt).toBe(a.resetAt);
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
it("auth scope is present on checkAuth", async () => {
|
|
282
|
-
const sub = createFakeProvider({ authTier: "subscription" });
|
|
283
|
-
const status = await sub.checkAuth();
|
|
284
|
-
expect(status.authScope).toBe("first-party");
|
|
285
|
-
expect(status.subscriptionStatus).toBe("active");
|
|
286
|
-
expect(status.hasApiKey).toBe(false);
|
|
287
|
-
|
|
288
|
-
const key = createFakeProvider({ authTier: "api-key", hasApiKey: false });
|
|
289
|
-
const keyStatus = await key.checkAuth();
|
|
290
|
-
expect(keyStatus.hasApiKey).toBe(false);
|
|
291
|
-
expect(keyStatus.configured).toBe(false);
|
|
292
|
-
});
|
|
293
|
-
});
|