offrouter-core 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/package.json +21 -0
  2. package/src/audit.test.ts +302 -0
  3. package/src/audit.ts +553 -0
  4. package/src/auth/credential-chain.test.ts +202 -0
  5. package/src/auth/credential-chain.ts +181 -0
  6. package/src/auth/index.ts +32 -0
  7. package/src/auth/keychain.test.ts +75 -0
  8. package/src/auth/keychain.ts +39 -0
  9. package/src/auth/oauth-pkce.test.ts +184 -0
  10. package/src/auth/oauth-pkce.ts +409 -0
  11. package/src/config.test.ts +415 -0
  12. package/src/config.ts +484 -0
  13. package/src/delegation.test.ts +368 -0
  14. package/src/delegation.ts +605 -0
  15. package/src/index.ts +261 -0
  16. package/src/policy.test.ts +634 -0
  17. package/src/policy.ts +418 -0
  18. package/src/protocol.ts +7 -0
  19. package/src/providers/adapter.test.ts +293 -0
  20. package/src/providers/adapter.ts +324 -0
  21. package/src/providers/catalog-data.test.ts +258 -0
  22. package/src/providers/catalog-data.ts +498 -0
  23. package/src/providers/catalog.test.ts +84 -0
  24. package/src/providers/catalog.ts +483 -0
  25. package/src/providers/fake.ts +312 -0
  26. package/src/providers/openai-compatible.test.ts +366 -0
  27. package/src/providers/openai-compatible.ts +554 -0
  28. package/src/proxy/responses-mapper.test.ts +290 -0
  29. package/src/proxy/responses-mapper.ts +736 -0
  30. package/src/proxy/responses-server.test.ts +322 -0
  31. package/src/proxy/responses-server.ts +469 -0
  32. package/src/router.test.ts +562 -0
  33. package/src/router.ts +323 -0
  34. package/src/schemas.test.ts +240 -0
  35. package/src/schemas.ts +203 -0
  36. package/src/secrets.test.ts +271 -0
  37. package/src/secrets.ts +461 -0
  38. package/src/types.ts +167 -0
  39. package/src/usage.test.ts +283 -0
  40. package/src/usage.ts +669 -0
  41. package/tsconfig.json +9 -0
@@ -0,0 +1,293 @@
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
+ });
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Provider adapter contract shared by fake and OpenAI-compatible backends.
3
+ * Keeps harness-specific shapes out of the core router.
4
+ */
5
+
6
+ import type {
7
+ AuthScope,
8
+ AuthTier,
9
+ ProviderCandidate,
10
+ ProviderHealth,
11
+ RouteRequest,
12
+ SubscriptionStatus,
13
+ } from "../types.js";
14
+ import { redact } from "../secrets.js";
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Canonical provider errors
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export type ProviderErrorCode =
21
+ | "auth_failed"
22
+ | "rate_limited"
23
+ | "quota_exhausted"
24
+ | "billing"
25
+ | "overload"
26
+ | "model_not_found"
27
+ | "context_window"
28
+ | "safety_refusal"
29
+ | "stream_interrupted"
30
+ | "cancelled"
31
+ | "timeout"
32
+ | "network"
33
+ | "invalid_request"
34
+ | "unknown";
35
+
36
+ export interface ProviderErrorOptions {
37
+ code: ProviderErrorCode;
38
+ message: string;
39
+ retryable?: boolean;
40
+ httpStatus?: number;
41
+ providerCode?: string;
42
+ cause?: unknown;
43
+ }
44
+
45
+ export class ProviderError extends Error {
46
+ readonly code: ProviderErrorCode;
47
+ readonly retryable: boolean;
48
+ readonly httpStatus?: number;
49
+ readonly providerCode?: string;
50
+
51
+ constructor(options: ProviderErrorOptions) {
52
+ super(
53
+ options.message,
54
+ options.cause !== undefined ? { cause: options.cause } : undefined,
55
+ );
56
+ this.name = "ProviderError";
57
+ this.code = options.code;
58
+ this.retryable = options.retryable ?? false;
59
+ this.httpStatus = options.httpStatus;
60
+ this.providerCode = options.providerCode;
61
+ }
62
+ }
63
+
64
+ export function isProviderError(value: unknown): value is ProviderError {
65
+ return value instanceof ProviderError;
66
+ }
67
+
68
+ /**
69
+ * Map HTTP status + optional provider body into a ProviderError.
70
+ * Presence-only; never logs credential material.
71
+ */
72
+ export function mapHttpToProviderError(
73
+ status: number,
74
+ bodyText?: string,
75
+ providerCode?: string,
76
+ ): ProviderError {
77
+ const snippet =
78
+ typeof bodyText === "string" && bodyText.length > 0
79
+ ? redact(bodyText.slice(0, 240))
80
+ : undefined;
81
+ const base = snippet ?? `HTTP ${status}`;
82
+
83
+ if (status === 401 || status === 403) {
84
+ return new ProviderError({
85
+ code: "auth_failed",
86
+ message: base,
87
+ retryable: false,
88
+ httpStatus: status,
89
+ providerCode,
90
+ });
91
+ }
92
+ if (status === 404) {
93
+ return new ProviderError({
94
+ code: "model_not_found",
95
+ message: base,
96
+ retryable: false,
97
+ httpStatus: status,
98
+ providerCode,
99
+ });
100
+ }
101
+ if (status === 408 || status === 504) {
102
+ return new ProviderError({
103
+ code: "timeout",
104
+ message: base,
105
+ retryable: true,
106
+ httpStatus: status,
107
+ providerCode,
108
+ });
109
+ }
110
+ if (status === 429) {
111
+ const lower = (bodyText ?? "").toLowerCase();
112
+ if (
113
+ lower.includes("quota") ||
114
+ lower.includes("billing") ||
115
+ lower.includes("insufficient")
116
+ ) {
117
+ return new ProviderError({
118
+ code: "quota_exhausted",
119
+ message: base,
120
+ retryable: false,
121
+ httpStatus: status,
122
+ providerCode,
123
+ });
124
+ }
125
+ return new ProviderError({
126
+ code: "rate_limited",
127
+ message: base,
128
+ retryable: true,
129
+ httpStatus: status,
130
+ providerCode,
131
+ });
132
+ }
133
+ if (status === 402) {
134
+ return new ProviderError({
135
+ code: "billing",
136
+ message: base,
137
+ retryable: false,
138
+ httpStatus: status,
139
+ providerCode,
140
+ });
141
+ }
142
+ if (status === 413) {
143
+ return new ProviderError({
144
+ code: "context_window",
145
+ message: base,
146
+ retryable: false,
147
+ httpStatus: status,
148
+ providerCode,
149
+ });
150
+ }
151
+ if (status === 503 || status === 529) {
152
+ return new ProviderError({
153
+ code: "overload",
154
+ message: base,
155
+ retryable: true,
156
+ httpStatus: status,
157
+ providerCode,
158
+ });
159
+ }
160
+ if (status >= 500) {
161
+ return new ProviderError({
162
+ code: "network",
163
+ message: base,
164
+ retryable: true,
165
+ httpStatus: status,
166
+ providerCode,
167
+ });
168
+ }
169
+ if (status >= 400) {
170
+ return new ProviderError({
171
+ code: "invalid_request",
172
+ message: base,
173
+ retryable: false,
174
+ httpStatus: status,
175
+ providerCode,
176
+ });
177
+ }
178
+ return new ProviderError({
179
+ code: "unknown",
180
+ message: base,
181
+ retryable: false,
182
+ httpStatus: status,
183
+ providerCode,
184
+ });
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Supporting types
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export interface ModelCapability {
192
+ modelId: string;
193
+ displayName?: string;
194
+ supportsTools: boolean;
195
+ supportsStreaming: boolean;
196
+ supportsJson: boolean;
197
+ supportsImages: boolean;
198
+ contextWindowTokens?: number;
199
+ maxOutputTokens?: number;
200
+ estimatedCostUsd?: number;
201
+ }
202
+
203
+ export interface AuthStatus {
204
+ authTier: AuthTier;
205
+ authScope: AuthScope;
206
+ configured: boolean;
207
+ /** Presence-only; never returns key material. */
208
+ hasApiKey: boolean;
209
+ subscriptionStatus?: SubscriptionStatus;
210
+ limitsDiscoverable: boolean;
211
+ health: ProviderHealth;
212
+ detail?: string;
213
+ /**
214
+ * Which credential source satisfied auth (if any).
215
+ * "none" when no credential was found.
216
+ */
217
+ credentialSource?: "env" | "config" | "keychain" | "oauth" | "none";
218
+ /** Whether this auth path uses a subscription-backed credential. */
219
+ subscriptionEligible: boolean;
220
+ }
221
+
222
+ /**
223
+ * Limit snapshot. Opaque providers report discoverable=false and omit remaining.
224
+ */
225
+ export interface LimitSnapshot {
226
+ authTier: AuthTier;
227
+ /** When false, remaining/reset fields are unknown; rely on errors for fallback. */
228
+ discoverable: boolean;
229
+ opaque: boolean;
230
+ remaining?: number;
231
+ limit?: number;
232
+ resetAt?: string;
233
+ unit?: string;
234
+ metadata?: Record<string, string | number | boolean | null>;
235
+ }
236
+
237
+ export interface CostEstimate {
238
+ model: string;
239
+ authTier: AuthTier;
240
+ estimatedCostUsd: number;
241
+ currency: "USD";
242
+ inputTokensEstimate?: number;
243
+ outputTokensEstimate?: number;
244
+ note?: string;
245
+ }
246
+
247
+ export interface ProviderInvokeMessage {
248
+ role: "system" | "user" | "assistant" | "tool";
249
+ content: string;
250
+ }
251
+
252
+ export interface ProviderInvokeRequest {
253
+ callId: string;
254
+ model: string;
255
+ messages: ProviderInvokeMessage[];
256
+ stream?: boolean;
257
+ maxOutputTokens?: number;
258
+ temperature?: number;
259
+ /** Optional abort signal for cancellation races. */
260
+ signal?: AbortSignal;
261
+ }
262
+
263
+ export type ProviderEvent =
264
+ | { type: "start"; callId: string; model: string }
265
+ | { type: "text-delta"; callId: string; text: string }
266
+ | { type: "tool-call"; callId: string; name: string; arguments: string }
267
+ | {
268
+ type: "usage";
269
+ callId: string;
270
+ inputTokens?: number;
271
+ outputTokens?: number;
272
+ totalTokens?: number;
273
+ }
274
+ | { type: "done"; callId: string; finishReason?: string }
275
+ | { type: "error"; callId: string; error: ProviderError };
276
+
277
+ export interface ProviderAdapter {
278
+ readonly id: string;
279
+ listModels(): Promise<ModelCapability[]>;
280
+ checkAuth(): Promise<AuthStatus>;
281
+ queryLimits(authTier: AuthTier): Promise<LimitSnapshot>;
282
+ estimate(
283
+ request: RouteRequest,
284
+ model: string,
285
+ authTier: AuthTier,
286
+ ): Promise<CostEstimate>;
287
+ invoke(request: ProviderInvokeRequest): AsyncIterable<ProviderEvent>;
288
+ cancel(callId: string): Promise<void>;
289
+ }
290
+
291
+ /** Required method names on every adapter. */
292
+ export const PROVIDER_ADAPTER_METHODS = [
293
+ "listModels",
294
+ "checkAuth",
295
+ "queryLimits",
296
+ "estimate",
297
+ "invoke",
298
+ "cancel",
299
+ ] as const satisfies readonly (keyof ProviderAdapter)[];
300
+
301
+ /** Convert catalog models to ProviderCandidate rows for routing tests. */
302
+ export function capabilityToCandidate(
303
+ providerId: string,
304
+ capability: ModelCapability,
305
+ extras: Pick<ProviderCandidate, "authTier" | "authScope"> &
306
+ Partial<ProviderCandidate>,
307
+ ): ProviderCandidate {
308
+ return {
309
+ providerId,
310
+ modelId: capability.modelId,
311
+ displayName: capability.displayName,
312
+ authTier: extras.authTier,
313
+ authScope: extras.authScope,
314
+ subscriptionStatus: extras.subscriptionStatus,
315
+ health: extras.health ?? "healthy",
316
+ supportsTools: capability.supportsTools,
317
+ supportsStreaming: capability.supportsStreaming,
318
+ supportsJson: capability.supportsJson,
319
+ supportsImages: capability.supportsImages,
320
+ contextWindowTokens: capability.contextWindowTokens,
321
+ maxOutputTokens: capability.maxOutputTokens,
322
+ estimatedCostUsd: extras.estimatedCostUsd ?? capability.estimatedCostUsd,
323
+ };
324
+ }