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,312 @@
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
+ }
@@ -0,0 +1,366 @@
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
+ });