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.
Files changed (139) hide show
  1. package/dist/audit.d.ts +137 -0
  2. package/dist/audit.d.ts.map +1 -0
  3. package/dist/audit.js +320 -0
  4. package/dist/audit.js.map +1 -0
  5. package/dist/auth/credential-chain.d.ts +84 -0
  6. package/dist/auth/credential-chain.d.ts.map +1 -0
  7. package/dist/auth/credential-chain.js +150 -0
  8. package/dist/auth/credential-chain.js.map +1 -0
  9. package/dist/auth/index.d.ts +10 -0
  10. package/dist/auth/index.d.ts.map +1 -0
  11. package/dist/auth/index.js +7 -0
  12. package/dist/auth/index.js.map +1 -0
  13. package/dist/auth/keychain.d.ts +60 -0
  14. package/dist/auth/keychain.d.ts.map +1 -0
  15. package/dist/auth/keychain.js +104 -0
  16. package/dist/auth/keychain.js.map +1 -0
  17. package/dist/auth/oauth-pkce.d.ts +138 -0
  18. package/dist/auth/oauth-pkce.d.ts.map +1 -0
  19. package/dist/auth/oauth-pkce.js +375 -0
  20. package/dist/auth/oauth-pkce.js.map +1 -0
  21. package/dist/auth/oauth-server.d.ts +36 -0
  22. package/dist/auth/oauth-server.d.ts.map +1 -0
  23. package/dist/auth/oauth-server.js +210 -0
  24. package/dist/auth/oauth-server.js.map +1 -0
  25. package/dist/config.d.ts +76 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +355 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/delegation.d.ts +123 -0
  30. package/dist/delegation.d.ts.map +1 -0
  31. package/dist/delegation.js +455 -0
  32. package/dist/delegation.js.map +1 -0
  33. package/dist/index.d.ts +41 -0
  34. package/dist/index.d.ts.map +1 -0
  35. package/dist/index.js +26 -0
  36. package/dist/index.js.map +1 -0
  37. package/dist/policy.d.ts +45 -0
  38. package/dist/policy.d.ts.map +1 -0
  39. package/dist/policy.js +318 -0
  40. package/dist/policy.js.map +1 -0
  41. package/{src/protocol.ts → dist/protocol.d.ts} +2 -2
  42. package/dist/protocol.d.ts.map +1 -0
  43. package/dist/protocol.js +6 -0
  44. package/dist/protocol.js.map +1 -0
  45. package/dist/providers/adapter.d.ts +137 -0
  46. package/dist/providers/adapter.d.ts.map +1 -0
  47. package/dist/providers/adapter.js +163 -0
  48. package/dist/providers/adapter.js.map +1 -0
  49. package/dist/providers/catalog-data.d.ts +128 -0
  50. package/dist/providers/catalog-data.d.ts.map +1 -0
  51. package/dist/providers/catalog-data.js +397 -0
  52. package/dist/providers/catalog-data.js.map +1 -0
  53. package/dist/providers/catalog.d.ts +63 -0
  54. package/dist/providers/catalog.d.ts.map +1 -0
  55. package/dist/providers/catalog.js +394 -0
  56. package/dist/providers/catalog.js.map +1 -0
  57. package/dist/providers/fake.d.ts +46 -0
  58. package/dist/providers/fake.d.ts.map +1 -0
  59. package/dist/providers/fake.js +234 -0
  60. package/dist/providers/fake.js.map +1 -0
  61. package/dist/providers/openai-compatible.d.ts +65 -0
  62. package/dist/providers/openai-compatible.d.ts.map +1 -0
  63. package/dist/providers/openai-compatible.js +434 -0
  64. package/dist/providers/openai-compatible.js.map +1 -0
  65. package/dist/proxy/responses-mapper.d.ts +366 -0
  66. package/dist/proxy/responses-mapper.d.ts.map +1 -0
  67. package/dist/proxy/responses-mapper.js +517 -0
  68. package/dist/proxy/responses-mapper.js.map +1 -0
  69. package/dist/proxy/responses-server.d.ts +29 -0
  70. package/dist/proxy/responses-server.d.ts.map +1 -0
  71. package/dist/proxy/responses-server.js +360 -0
  72. package/dist/proxy/responses-server.js.map +1 -0
  73. package/dist/router.d.ts +18 -0
  74. package/dist/router.d.ts.map +1 -0
  75. package/dist/router.js +296 -0
  76. package/dist/router.js.map +1 -0
  77. package/dist/schemas.d.ts +560 -0
  78. package/dist/schemas.d.ts.map +1 -0
  79. package/{src/schemas.ts → dist/schemas.js} +83 -103
  80. package/dist/schemas.js.map +1 -0
  81. package/dist/secrets.d.ts +112 -0
  82. package/dist/secrets.d.ts.map +1 -0
  83. package/dist/secrets.js +326 -0
  84. package/dist/secrets.js.map +1 -0
  85. package/dist/types.d.ts +117 -0
  86. package/dist/types.d.ts.map +1 -0
  87. package/dist/types.js +6 -0
  88. package/dist/types.js.map +1 -0
  89. package/dist/usage-file.d.ts +59 -0
  90. package/dist/usage-file.d.ts.map +1 -0
  91. package/dist/usage-file.js +316 -0
  92. package/dist/usage-file.js.map +1 -0
  93. package/dist/usage.d.ts +235 -0
  94. package/dist/usage.d.ts.map +1 -0
  95. package/dist/usage.js +517 -0
  96. package/dist/usage.js.map +1 -0
  97. package/package.json +7 -2
  98. package/src/audit.test.ts +0 -302
  99. package/src/audit.ts +0 -553
  100. package/src/auth/credential-chain.test.ts +0 -326
  101. package/src/auth/credential-chain.ts +0 -235
  102. package/src/auth/index.ts +0 -45
  103. package/src/auth/keychain.test.ts +0 -265
  104. package/src/auth/keychain.ts +0 -168
  105. package/src/auth/oauth-pkce.test.ts +0 -329
  106. package/src/auth/oauth-pkce.ts +0 -571
  107. package/src/auth/oauth-server.test.ts +0 -83
  108. package/src/auth/oauth-server.ts +0 -296
  109. package/src/config.test.ts +0 -479
  110. package/src/config.ts +0 -505
  111. package/src/delegation.test.ts +0 -368
  112. package/src/delegation.ts +0 -605
  113. package/src/index.ts +0 -280
  114. package/src/policy.test.ts +0 -634
  115. package/src/policy.ts +0 -420
  116. package/src/providers/adapter.test.ts +0 -293
  117. package/src/providers/adapter.ts +0 -328
  118. package/src/providers/catalog-data.test.ts +0 -258
  119. package/src/providers/catalog-data.ts +0 -498
  120. package/src/providers/catalog.test.ts +0 -84
  121. package/src/providers/catalog.ts +0 -483
  122. package/src/providers/fake.ts +0 -312
  123. package/src/providers/openai-compatible.test.ts +0 -366
  124. package/src/providers/openai-compatible.ts +0 -554
  125. package/src/proxy/responses-mapper.test.ts +0 -290
  126. package/src/proxy/responses-mapper.ts +0 -736
  127. package/src/proxy/responses-server.test.ts +0 -322
  128. package/src/proxy/responses-server.ts +0 -469
  129. package/src/router.test.ts +0 -699
  130. package/src/router.ts +0 -352
  131. package/src/schemas.test.ts +0 -291
  132. package/src/secrets.test.ts +0 -271
  133. package/src/secrets.ts +0 -461
  134. package/src/types.ts +0 -173
  135. package/src/usage-file.test.ts +0 -243
  136. package/src/usage-file.ts +0 -435
  137. package/src/usage.test.ts +0 -335
  138. package/src/usage.ts +0 -859
  139. package/tsconfig.json +0 -9
@@ -1,312 +0,0 @@
1
- /**
2
- * Deterministic fake provider adapter for offline tests and MCP demos.
3
- * Supports one-shot, streaming, configured errors, and cancellation.
4
- */
5
-
6
- import type { AuthTier, RouteRequest } from "../types.js";
7
- import {
8
- ProviderError,
9
- type AuthStatus,
10
- type CostEstimate,
11
- type LimitSnapshot,
12
- type ModelCapability,
13
- type ProviderAdapter,
14
- type ProviderEvent,
15
- type ProviderInvokeRequest,
16
- } from "./adapter.js";
17
-
18
- export interface FakeModelConfig extends ModelCapability {
19
- authTier?: AuthTier;
20
- }
21
-
22
- export interface FakeProviderOptions {
23
- id?: string;
24
- authTier?: AuthTier;
25
- /** Presence-only flag; never stores a secret value. */
26
- hasApiKey?: boolean;
27
- limitsDiscoverable?: boolean;
28
- opaqueLimits?: boolean;
29
- remaining?: number;
30
- limit?: number;
31
- models?: FakeModelConfig[];
32
- /** Fixed one-shot body when stream is false/omitted. */
33
- responseText?: string;
34
- /** Chunks for streaming invokes (token-ish pieces). */
35
- streamChunks?: string[];
36
- /** Delay between stream chunks in ms (default 0). */
37
- streamDelayMs?: number;
38
- /** If set, invoke throws/errors with this ProviderError. */
39
- error?: ProviderError;
40
- /** When true, delay long enough for cancel to win (default false). */
41
- hangUntilCancel?: boolean;
42
- estimatedCostUsd?: number;
43
- }
44
-
45
- const DEFAULT_MODELS: FakeModelConfig[] = [
46
- {
47
- modelId: "fake-coder",
48
- displayName: "Fake Coder",
49
- supportsTools: true,
50
- supportsStreaming: true,
51
- supportsJson: true,
52
- supportsImages: false,
53
- contextWindowTokens: 32_768,
54
- maxOutputTokens: 8_192,
55
- estimatedCostUsd: 0,
56
- },
57
- ];
58
-
59
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
60
- if (ms <= 0) return Promise.resolve();
61
- return new Promise((resolve, reject) => {
62
- if (signal?.aborted) {
63
- reject(
64
- new ProviderError({
65
- code: "cancelled",
66
- message: "aborted",
67
- retryable: false,
68
- }),
69
- );
70
- return;
71
- }
72
- const timer = setTimeout(() => {
73
- signal?.removeEventListener("abort", onAbort);
74
- resolve();
75
- }, ms);
76
- const onAbort = () => {
77
- clearTimeout(timer);
78
- signal?.removeEventListener("abort", onAbort);
79
- reject(
80
- new ProviderError({
81
- code: "cancelled",
82
- message: "aborted",
83
- retryable: false,
84
- }),
85
- );
86
- };
87
- signal?.addEventListener("abort", onAbort, { once: true });
88
- });
89
- }
90
-
91
- export class FakeProviderAdapter implements ProviderAdapter {
92
- readonly id: string;
93
- private readonly options: Required<
94
- Pick<
95
- FakeProviderOptions,
96
- | "authTier"
97
- | "hasApiKey"
98
- | "limitsDiscoverable"
99
- | "opaqueLimits"
100
- | "responseText"
101
- | "streamChunks"
102
- | "streamDelayMs"
103
- | "hangUntilCancel"
104
- | "estimatedCostUsd"
105
- >
106
- > &
107
- FakeProviderOptions;
108
-
109
- private readonly active = new Map<string, AbortController>();
110
- private cancelled = new Set<string>();
111
-
112
- constructor(options: FakeProviderOptions = {}) {
113
- this.id = options.id ?? "fake";
114
- this.options = {
115
- authTier: options.authTier ?? "api-key",
116
- hasApiKey: options.hasApiKey ?? true,
117
- limitsDiscoverable: options.limitsDiscoverable ?? true,
118
- opaqueLimits: options.opaqueLimits ?? false,
119
- responseText: options.responseText ?? "fake-ok",
120
- streamChunks: options.streamChunks ?? ["fake ", "stream ", "ok"],
121
- streamDelayMs: options.streamDelayMs ?? 0,
122
- hangUntilCancel: options.hangUntilCancel ?? false,
123
- estimatedCostUsd: options.estimatedCostUsd ?? 0,
124
- ...options,
125
- };
126
- }
127
-
128
- async listModels(): Promise<ModelCapability[]> {
129
- return (this.options.models ?? DEFAULT_MODELS).map((m) => ({ ...m }));
130
- }
131
-
132
- async checkAuth(): Promise<AuthStatus> {
133
- const tier = this.options.authTier;
134
- const hasApiKey = tier === "api-key" ? this.options.hasApiKey : false;
135
- return {
136
- authTier: tier,
137
- authScope: tier === "api-key" ? "third-party" : "first-party",
138
- configured:
139
- tier === "local" || tier === "subscription" ? true : hasApiKey,
140
- hasApiKey,
141
- subscriptionStatus: tier === "subscription" ? "active" : undefined,
142
- limitsDiscoverable: this.options.limitsDiscoverable,
143
- health: "healthy",
144
- credentialSource: hasApiKey ? "config" : "none",
145
- subscriptionEligible: tier === "subscription",
146
- };
147
- }
148
-
149
- async queryLimits(authTier: AuthTier): Promise<LimitSnapshot> {
150
- if (this.options.opaqueLimits || !this.options.limitsDiscoverable) {
151
- return {
152
- authTier,
153
- discoverable: false,
154
- opaque: true,
155
- metadata: {
156
- reason: "provider_does_not_expose_quota",
157
- },
158
- };
159
- }
160
- return {
161
- authTier,
162
- discoverable: true,
163
- opaque: false,
164
- remaining: this.options.remaining ?? 100,
165
- limit: this.options.limit ?? 100,
166
- unit: "requests",
167
- resetAt: "1970-01-01T01:00:00.000Z",
168
- metadata: {
169
- source: "fake",
170
- },
171
- };
172
- }
173
-
174
- async estimate(
175
- request: RouteRequest,
176
- model: string,
177
- authTier: AuthTier,
178
- ): Promise<CostEstimate> {
179
- void request;
180
- return {
181
- model,
182
- authTier,
183
- estimatedCostUsd:
184
- authTier === "api-key" ? this.options.estimatedCostUsd : 0,
185
- currency: "USD",
186
- note: "fake estimate",
187
- };
188
- }
189
-
190
- async *invoke(request: ProviderInvokeRequest): AsyncIterable<ProviderEvent> {
191
- if (this.cancelled.has(request.callId) || request.signal?.aborted) {
192
- yield {
193
- type: "error",
194
- callId: request.callId,
195
- error: new ProviderError({
196
- code: "cancelled",
197
- message: `call ${request.callId} cancelled`,
198
- retryable: false,
199
- }),
200
- };
201
- return;
202
- }
203
-
204
- const controller = new AbortController();
205
- this.active.set(request.callId, controller);
206
- const onExternalAbort = () => controller.abort();
207
- request.signal?.addEventListener("abort", onExternalAbort, { once: true });
208
-
209
- try {
210
- yield {
211
- type: "start",
212
- callId: request.callId,
213
- model: request.model,
214
- };
215
-
216
- if (this.options.error) {
217
- yield {
218
- type: "error",
219
- callId: request.callId,
220
- error: this.options.error,
221
- };
222
- return;
223
- }
224
-
225
- if (this.options.hangUntilCancel) {
226
- await sleep(60_000, controller.signal);
227
- }
228
-
229
- if (this.cancelled.has(request.callId) || controller.signal.aborted) {
230
- yield {
231
- type: "error",
232
- callId: request.callId,
233
- error: new ProviderError({
234
- code: "cancelled",
235
- message: `call ${request.callId} cancelled`,
236
- retryable: false,
237
- }),
238
- };
239
- return;
240
- }
241
-
242
- const stream = request.stream ?? false;
243
- if (stream) {
244
- for (const chunk of this.options.streamChunks) {
245
- if (this.cancelled.has(request.callId) || controller.signal.aborted) {
246
- yield {
247
- type: "error",
248
- callId: request.callId,
249
- error: new ProviderError({
250
- code: "cancelled",
251
- message: `call ${request.callId} cancelled`,
252
- retryable: false,
253
- }),
254
- };
255
- return;
256
- }
257
- if (this.options.streamDelayMs > 0) {
258
- await sleep(this.options.streamDelayMs, controller.signal);
259
- }
260
- yield {
261
- type: "text-delta",
262
- callId: request.callId,
263
- text: chunk,
264
- };
265
- }
266
- } else {
267
- yield {
268
- type: "text-delta",
269
- callId: request.callId,
270
- text: this.options.responseText,
271
- };
272
- }
273
-
274
- yield {
275
- type: "usage",
276
- callId: request.callId,
277
- inputTokens: 10,
278
- outputTokens: 5,
279
- totalTokens: 15,
280
- };
281
- yield {
282
- type: "done",
283
- callId: request.callId,
284
- finishReason: "stop",
285
- };
286
- } catch (err) {
287
- if (err instanceof ProviderError && err.code === "cancelled") {
288
- yield {
289
- type: "error",
290
- callId: request.callId,
291
- error: err,
292
- };
293
- return;
294
- }
295
- throw err;
296
- } finally {
297
- request.signal?.removeEventListener("abort", onExternalAbort);
298
- this.active.delete(request.callId);
299
- }
300
- }
301
-
302
- async cancel(callId: string): Promise<void> {
303
- this.cancelled.add(callId);
304
- this.active.get(callId)?.abort();
305
- }
306
- }
307
-
308
- export function createFakeProvider(
309
- options?: FakeProviderOptions,
310
- ): FakeProviderAdapter {
311
- return new FakeProviderAdapter(options);
312
- }
@@ -1,366 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import type { RouteRequest } from "../types.js";
3
- import { ProviderError } from "./adapter.js";
4
- import {
5
- OLLAMA_DUMMY_BEARER,
6
- createOpenAiCompatibleAdapter,
7
- } from "./openai-compatible.js";
8
-
9
- function sampleRoute(): RouteRequest {
10
- return {
11
- protocolVersion: "offrouter.route.v1",
12
- requestId: "req_oai_1",
13
- harness: { kind: "claude", profile: "claude-personal" },
14
- task: {
15
- promptPreview: "route this",
16
- promptDigest: "sha256:def",
17
- kind: "code",
18
- risk: "low",
19
- },
20
- workspace: { cwd: "/tmp", trusted: true },
21
- constraints: {},
22
- };
23
- }
24
-
25
- function sseResponse(lines: string[]): Response {
26
- const encoder = new TextEncoder();
27
- return new Response(
28
- new ReadableStream<Uint8Array>({
29
- start(controller) {
30
- for (const line of lines) {
31
- controller.enqueue(encoder.encode(line));
32
- }
33
- controller.close();
34
- },
35
- }),
36
- {
37
- status: 200,
38
- headers: { "content-type": "text/event-stream" },
39
- },
40
- );
41
- }
42
-
43
- describe("OpenAI-compatible adapter", () => {
44
- it("sends Authorization Bearer header for OpenRouter/xAI-style keys", async () => {
45
- const seen: { auth?: string; path?: string } = {};
46
- const fetchImpl = vi.fn(
47
- async (input: RequestInfo | URL, init?: RequestInit) => {
48
- const url = String(input);
49
- seen.path = url;
50
- const headers = new Headers(init?.headers);
51
- seen.auth = headers.get("authorization") ?? undefined;
52
- return new Response(
53
- JSON.stringify({
54
- choices: [{ message: { content: "hi" }, finish_reason: "stop" }],
55
- usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
56
- }),
57
- { status: 200, headers: { "content-type": "application/json" } },
58
- );
59
- },
60
- );
61
-
62
- const adapter = createOpenAiCompatibleAdapter({
63
- id: "openrouter",
64
- baseUrl: "https://openrouter.example/api/v1",
65
- flavor: "openrouter",
66
- apiKey: "sk-or-test",
67
- fetch: fetchImpl as unknown as typeof fetch,
68
- models: [
69
- {
70
- modelId: "openai/gpt-4.1-mini",
71
- supportsTools: true,
72
- supportsStreaming: true,
73
- supportsJson: true,
74
- supportsImages: false,
75
- },
76
- ],
77
- });
78
-
79
- for await (const event of adapter.invoke({
80
- callId: "auth-header",
81
- model: "openai/gpt-4.1-mini",
82
- messages: [{ role: "user", content: "hello" }],
83
- })) {
84
- void event;
85
- // drain
86
- }
87
-
88
- expect(fetchImpl).toHaveBeenCalled();
89
- expect(seen.auth).toBe("Bearer sk-or-test");
90
- expect(seen.path).toContain("/chat/completions");
91
-
92
- // OpenRouter metadata headers
93
- const firstCall = fetchImpl.mock.calls[0]!;
94
- const headers = new Headers(firstCall[1]?.headers);
95
- expect(headers.get("http-referer")).toBeTruthy();
96
- expect(headers.get("x-title")).toBe("OffRouter");
97
- });
98
-
99
- it("uses Ollama dummy key when no API key is configured", async () => {
100
- let auth: string | null = null;
101
- const fetchImpl = vi.fn(
102
- async (_input: RequestInfo | URL, init?: RequestInit) => {
103
- auth = new Headers(init?.headers).get("authorization");
104
- return new Response(
105
- JSON.stringify({ choices: [{ message: { content: "local" } }] }),
106
- { status: 200, headers: { "content-type": "application/json" } },
107
- );
108
- },
109
- );
110
-
111
- const adapter = createOpenAiCompatibleAdapter({
112
- id: "ollama",
113
- baseUrl: "http://127.0.0.1:11434/v1",
114
- flavor: "ollama",
115
- fetch: fetchImpl as unknown as typeof fetch,
116
- models: [
117
- {
118
- modelId: "llama3.2",
119
- supportsTools: false,
120
- supportsStreaming: true,
121
- supportsJson: false,
122
- supportsImages: false,
123
- },
124
- ],
125
- });
126
-
127
- const status = await adapter.checkAuth();
128
- expect(status.configured).toBe(true);
129
- expect(status.detail).toBe("using_dummy_key");
130
- expect(status.authTier).toBe("local");
131
- expect(status.authScope).toBe("first-party");
132
- expect(status.hasApiKey).toBe(false);
133
-
134
- for await (const event of adapter.invoke({
135
- callId: "ollama-1",
136
- model: "llama3.2",
137
- messages: [{ role: "user", content: "hi" }],
138
- })) {
139
- void event;
140
- // drain
141
- }
142
- expect(auth).toBe(OLLAMA_DUMMY_BEARER);
143
- });
144
-
145
- it("maps provider HTTP failures to canonical ProviderError on invoke", async () => {
146
- const adapter = createOpenAiCompatibleAdapter({
147
- id: "xai",
148
- baseUrl: "https://api.x.ai/v1",
149
- flavor: "xai",
150
- apiKey: "xai-key",
151
- fetch: (async () =>
152
- new Response(JSON.stringify({ error: "rate limit" }), {
153
- status: 429,
154
- headers: { "content-type": "application/json" },
155
- })) as typeof fetch,
156
- models: [
157
- {
158
- modelId: "grok-2",
159
- supportsTools: true,
160
- supportsStreaming: true,
161
- supportsJson: true,
162
- supportsImages: false,
163
- },
164
- ],
165
- });
166
-
167
- const errors: ProviderError[] = [];
168
- for await (const event of adapter.invoke({
169
- callId: "rate-1",
170
- model: "grok-2",
171
- messages: [{ role: "user", content: "x" }],
172
- })) {
173
- if (event.type === "error") errors.push(event.error);
174
- }
175
- expect(errors).toHaveLength(1);
176
- expect(errors[0]!.code).toBe("rate_limited");
177
- expect(errors[0]!.httpStatus).toBe(429);
178
- });
179
-
180
- it("blocks invoke live network by default without injected fetch", async () => {
181
- const adapter = createOpenAiCompatibleAdapter({
182
- id: "openrouter",
183
- baseUrl: "http://127.0.0.1:9",
184
- flavor: "openrouter",
185
- apiKey: "test-key",
186
- models: [],
187
- });
188
- const events = [];
189
- for await (const event of adapter.invoke({
190
- callId: "offline-guard",
191
- model: "m",
192
- messages: [{ role: "user", content: "x" }],
193
- })) {
194
- events.push(event);
195
- }
196
- expect(events).toHaveLength(1);
197
- expect(events[0]!.type).toBe("error");
198
- if (events[0]!.type === "error") {
199
- expect(events[0]!.error.code).toBe("invalid_request");
200
- expect(events[0]!.error.message).toContain("requires injected fetch");
201
- }
202
- });
203
-
204
- it("streams OpenAI-compatible SSE text deltas", async () => {
205
- const adapter = createOpenAiCompatibleAdapter({
206
- id: "openrouter",
207
- baseUrl: "https://openrouter.example/api/v1",
208
- flavor: "openrouter",
209
- apiKey: "test-key",
210
- fetch: (async () =>
211
- sseResponse([
212
- 'data: {"choices":[{"delta":{"content":"hi "}}]}\n\n',
213
- 'data: {"choices":[{"delta":{"content":"there"}}]}\n\n',
214
- "data: [DONE]\n\n",
215
- ])) as typeof fetch,
216
- models: [],
217
- });
218
-
219
- const texts: string[] = [];
220
- let done = false;
221
- for await (const event of adapter.invoke({
222
- callId: "sse-1",
223
- model: "openai/gpt-4.1-mini",
224
- messages: [{ role: "user", content: "hello" }],
225
- stream: true,
226
- })) {
227
- if (event.type === "text-delta") texts.push(event.text);
228
- if (event.type === "done") done = true;
229
- }
230
- expect(texts).toEqual(["hi ", "there"]);
231
- expect(done).toBe(true);
232
- });
233
-
234
- it("cancels OpenAI-compatible SSE reads while a stream is pending", async () => {
235
- const encoder = new TextEncoder();
236
- let streamCancelled = false;
237
- const body = new ReadableStream<Uint8Array>({
238
- start(c) {
239
- c.enqueue(
240
- encoder.encode(
241
- 'data: {"choices":[{"delta":{"content":"first"}}]}\n\n',
242
- ),
243
- );
244
- },
245
- cancel() {
246
- streamCancelled = true;
247
- },
248
- });
249
-
250
- const adapter = createOpenAiCompatibleAdapter({
251
- id: "openrouter",
252
- baseUrl: "https://openrouter.example/api/v1",
253
- flavor: "openrouter",
254
- apiKey: "test-key",
255
- fetch: (async () =>
256
- new Response(body, {
257
- status: 200,
258
- headers: { "content-type": "text/event-stream" },
259
- })) as typeof fetch,
260
- models: [],
261
- });
262
-
263
- const callId = "sse-cancel";
264
- const iterable = adapter.invoke({
265
- callId,
266
- model: "openai/gpt-4.1-mini",
267
- messages: [{ role: "user", content: "hello" }],
268
- stream: true,
269
- });
270
- const iterator = iterable[Symbol.asyncIterator]();
271
-
272
- expect((await iterator.next()).value?.type).toBe("start");
273
- expect((await iterator.next()).value).toMatchObject({
274
- type: "text-delta",
275
- text: "first",
276
- });
277
-
278
- const pending = iterator.next();
279
- await adapter.cancel(callId);
280
- const cancelled = await pending;
281
- expect(cancelled.value).toMatchObject({
282
- type: "error",
283
- error: expect.objectContaining({ code: "cancelled" }),
284
- });
285
- expect(streamCancelled).toBe(true);
286
- });
287
-
288
- it("reports opaque limits for SaaS OpenRouter by default", async () => {
289
- const adapter = createOpenAiCompatibleAdapter({
290
- id: "openrouter",
291
- baseUrl: "https://openrouter.example/api/v1",
292
- flavor: "openrouter",
293
- apiKey: "k",
294
- models: [],
295
- });
296
- const limits = await adapter.queryLimits("api-key");
297
- expect(limits.opaque).toBe(true);
298
- expect(limits.discoverable).toBe(false);
299
- expect(limits.metadata?.flavor).toBe("openrouter");
300
- });
301
-
302
- it("estimates cost heuristically without network", async () => {
303
- const adapter = createOpenAiCompatibleAdapter({
304
- id: "openrouter",
305
- baseUrl: "https://openrouter.example/api/v1",
306
- flavor: "openrouter",
307
- apiKey: "k",
308
- estimatedCostUsdPer1kTokens: 1,
309
- models: [],
310
- });
311
- const est = await adapter.estimate(sampleRoute(), "m", "api-key");
312
- expect(est.estimatedCostUsd).toBeGreaterThan(0);
313
- expect(est.currency).toBe("USD");
314
- });
315
-
316
- it("does not make live network calls for listModels when models are injected", async () => {
317
- const fetchImpl = vi.fn();
318
- const adapter = createOpenAiCompatibleAdapter({
319
- id: "openrouter",
320
- baseUrl: "https://openrouter.example/api/v1",
321
- flavor: "openrouter",
322
- apiKey: "k",
323
- fetch: fetchImpl as unknown as typeof fetch,
324
- models: [
325
- {
326
- modelId: "m1",
327
- supportsTools: true,
328
- supportsStreaming: true,
329
- supportsJson: true,
330
- supportsImages: false,
331
- },
332
- ],
333
- });
334
- const models = await adapter.listModels();
335
- expect(models).toHaveLength(1);
336
- expect(fetchImpl).not.toHaveBeenCalled();
337
- });
338
-
339
- it("api-key presence is boolean only in checkAuth", async () => {
340
- const missing = createOpenAiCompatibleAdapter({
341
- id: "openrouter",
342
- baseUrl: "https://openrouter.example/api/v1",
343
- flavor: "openrouter",
344
- models: [],
345
- });
346
- const status = await missing.checkAuth();
347
- expect(status.hasApiKey).toBe(false);
348
- expect(status.configured).toBe(false);
349
- // Ensure we never invent secret fields on the auth surface.
350
- for (const key of Object.keys(status)) {
351
- expect([
352
- "authScope",
353
- "authTier",
354
- "configured",
355
- "credentialSource",
356
- "detail",
357
- "hasApiKey",
358
- "health",
359
- "limitsDiscoverable",
360
- "subscriptionEligible",
361
- "subscriptionStatus",
362
- ]).toContain(key);
363
- }
364
- expect("apiKey" in status).toBe(false);
365
- });
366
- });