@wopr-network/platform-core 1.14.0 → 1.14.1

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.
@@ -1,11 +1,10 @@
1
1
  name: Dependabot Auto-Merge
2
2
 
3
3
  on:
4
- pull_request:
4
+ pull_request_target:
5
5
  types: [opened, synchronize, reopened]
6
6
 
7
7
  jobs:
8
8
  auto-merge:
9
9
  uses: wopr-network/.github/.github/workflows/dependabot-auto-merge.yml@main
10
10
  secrets: inherit
11
-
@@ -1,3 +1,4 @@
1
+ import { Credit } from "@wopr-network/platform-core/credits";
1
2
  import { Hono } from "hono";
2
3
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
4
  import { createAnthropicRoutes } from "./anthropic.js";
@@ -152,6 +153,22 @@ describe("Anthropic protocol handler", () => {
152
153
  });
153
154
  });
154
155
  describe("format translation", () => {
156
+ it("injects Authorization: Bearer with provider API key in upstream request", async () => {
157
+ deps.fetchFn = mockFetchOk(openaiChatResponse("Hello!"));
158
+ await app.request("/v1/anthropic/v1/messages", {
159
+ method: "POST",
160
+ body: JSON.stringify({
161
+ model: "claude-3-5-sonnet-20241022",
162
+ messages: [{ role: "user", content: "Hello" }],
163
+ max_tokens: 1024,
164
+ }),
165
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
166
+ });
167
+ const fetchCall = vi.mocked(deps.fetchFn).mock.calls[0];
168
+ const headers = fetchCall[1]?.headers;
169
+ expect(headers.Authorization).toBe("Bearer or-test-key");
170
+ expect(headers["Content-Type"]).toBe("application/json");
171
+ });
155
172
  it("translates Anthropic request to OpenAI and response back", async () => {
156
173
  deps.fetchFn = mockFetchOk(openaiChatResponse("Translated response!"));
157
174
  const res = await app.request("/v1/anthropic/v1/messages", {
@@ -244,6 +261,67 @@ describe("Anthropic protocol handler", () => {
244
261
  const body = await res.json();
245
262
  expect(body.type).toBe("error");
246
263
  });
264
+ it("maps upstream 400 to Anthropic invalid_request_error", async () => {
265
+ deps.fetchFn = mockFetchError(400, "Bad request from provider");
266
+ const res = await app.request("/v1/anthropic/v1/messages", {
267
+ method: "POST",
268
+ body: JSON.stringify({
269
+ model: "claude-3-5-sonnet-20241022",
270
+ messages: [{ role: "user", content: "Hi" }],
271
+ max_tokens: 100,
272
+ }),
273
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
274
+ });
275
+ expect(res.status).toBe(400);
276
+ const body = await res.json();
277
+ expect(body.type).toBe("error");
278
+ expect(body.error.type).toBe("invalid_request_error");
279
+ });
280
+ it("maps upstream 401 to Anthropic authentication_error", async () => {
281
+ deps.fetchFn = mockFetchError(401, "Invalid API key");
282
+ const res = await app.request("/v1/anthropic/v1/messages", {
283
+ method: "POST",
284
+ body: JSON.stringify({
285
+ model: "claude-3-5-sonnet-20241022",
286
+ messages: [{ role: "user", content: "Hi" }],
287
+ max_tokens: 100,
288
+ }),
289
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
290
+ });
291
+ expect(res.status).toBe(401);
292
+ const body = await res.json();
293
+ expect(body.error.type).toBe("authentication_error");
294
+ });
295
+ it("maps upstream 429 to Anthropic rate_limit_error", async () => {
296
+ deps.fetchFn = mockFetchError(429, "Rate limited");
297
+ const res = await app.request("/v1/anthropic/v1/messages", {
298
+ method: "POST",
299
+ body: JSON.stringify({
300
+ model: "claude-3-5-sonnet-20241022",
301
+ messages: [{ role: "user", content: "Hi" }],
302
+ max_tokens: 100,
303
+ }),
304
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
305
+ });
306
+ expect(res.status).toBe(429);
307
+ const body = await res.json();
308
+ expect(body.error.type).toBe("rate_limit_error");
309
+ });
310
+ it("maps upstream 502 to Anthropic 529 overloaded", async () => {
311
+ deps.fetchFn = mockFetchError(502, "Bad gateway");
312
+ const res = await app.request("/v1/anthropic/v1/messages", {
313
+ method: "POST",
314
+ body: JSON.stringify({
315
+ model: "claude-3-5-sonnet-20241022",
316
+ messages: [{ role: "user", content: "Hi" }],
317
+ max_tokens: 100,
318
+ }),
319
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
320
+ });
321
+ expect(res.status).toBe(529);
322
+ const body = await res.json();
323
+ expect(body.type).toBe("error");
324
+ });
247
325
  });
248
326
  describe("streaming", () => {
249
327
  it("pipes upstream response without JSON parsing when stream is true", async () => {
@@ -283,6 +361,55 @@ describe("Anthropic protocol handler", () => {
283
361
  const body = await res.text();
284
362
  expect(body).toContain("[DONE]");
285
363
  });
364
+ it("sets correct SSE response headers for streaming", async () => {
365
+ const ssePayload = 'data: {"type":"content_block_delta"}\n\ndata: [DONE]\n\n';
366
+ deps.fetchFn = mockFetchStream(ssePayload);
367
+ const res = await app.request("/v1/anthropic/v1/messages", {
368
+ method: "POST",
369
+ body: JSON.stringify({
370
+ model: "claude-3-5-sonnet-20241022",
371
+ messages: [{ role: "user", content: "Hi" }],
372
+ max_tokens: 100,
373
+ stream: true,
374
+ }),
375
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
376
+ });
377
+ expect(res.headers.get("Transfer-Encoding")).toBe("chunked");
378
+ expect(res.headers.get("Cache-Control")).toBe("no-cache");
379
+ expect(res.headers.get("Connection")).toBe("keep-alive");
380
+ });
381
+ it("meters streaming cost when x-openrouter-cost header is present", async () => {
382
+ const ssePayload = 'data: {"type":"content_block_delta"}\n\ndata: [DONE]\n\n';
383
+ deps.fetchFn = mockFetchStream(ssePayload, { "x-openrouter-cost": "0.004" });
384
+ await app.request("/v1/anthropic/v1/messages", {
385
+ method: "POST",
386
+ body: JSON.stringify({
387
+ model: "claude-3-5-sonnet-20241022",
388
+ messages: [{ role: "user", content: "Hi" }],
389
+ max_tokens: 100,
390
+ stream: true,
391
+ }),
392
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
393
+ });
394
+ expect(deps.meterEvents).toHaveLength(1);
395
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.004);
396
+ expect(deps.meterEvents[0].capability).toBe("chat-completions");
397
+ });
398
+ it("does not meter streaming when cost is zero", async () => {
399
+ const ssePayload = 'data: {"type":"content_block_delta"}\n\ndata: [DONE]\n\n';
400
+ deps.fetchFn = mockFetchStream(ssePayload);
401
+ await app.request("/v1/anthropic/v1/messages", {
402
+ method: "POST",
403
+ body: JSON.stringify({
404
+ model: "claude-3-5-sonnet-20241022",
405
+ messages: [{ role: "user", content: "Hi" }],
406
+ max_tokens: 100,
407
+ stream: true,
408
+ }),
409
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
410
+ });
411
+ expect(deps.meterEvents).toHaveLength(0);
412
+ });
286
413
  });
287
414
  describe("provider not configured", () => {
288
415
  it("returns 529 when no provider configured", async () => {
@@ -299,6 +426,81 @@ describe("Anthropic protocol handler", () => {
299
426
  expect(res.status).toBe(529);
300
427
  });
301
428
  });
429
+ describe("fetch exception handling", () => {
430
+ it("returns 500 in Anthropic format when fetchFn throws an Error", async () => {
431
+ deps.fetchFn = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
432
+ const res = await app.request("/v1/anthropic/v1/messages", {
433
+ method: "POST",
434
+ body: JSON.stringify({
435
+ model: "claude-3-5-sonnet-20241022",
436
+ messages: [{ role: "user", content: "Hi" }],
437
+ max_tokens: 100,
438
+ }),
439
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
440
+ });
441
+ expect(res.status).toBe(500);
442
+ const body = await res.json();
443
+ expect(body.type).toBe("error");
444
+ expect(body.error.type).toBe("api_error");
445
+ expect(body.error.message).toBe("ECONNREFUSED");
446
+ });
447
+ it("handles non-Error thrown values", async () => {
448
+ deps.fetchFn = vi.fn().mockRejectedValue("string error");
449
+ const res = await app.request("/v1/anthropic/v1/messages", {
450
+ method: "POST",
451
+ body: JSON.stringify({
452
+ model: "claude-3-5-sonnet-20241022",
453
+ messages: [{ role: "user", content: "Hi" }],
454
+ max_tokens: 100,
455
+ }),
456
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
457
+ });
458
+ expect(res.status).toBe(500);
459
+ const body = await res.json();
460
+ expect(body.error.message).toBe("string error");
461
+ });
462
+ });
463
+ describe("cost fallback without x-openrouter-cost header", () => {
464
+ it("estimates cost from Anthropic usage when no cost header", async () => {
465
+ deps.fetchFn = mockFetchOk(openaiChatResponse("Hello!"));
466
+ await app.request("/v1/anthropic/v1/messages", {
467
+ method: "POST",
468
+ body: JSON.stringify({
469
+ model: "claude-3-5-sonnet-20241022",
470
+ messages: [{ role: "user", content: "Hi" }],
471
+ max_tokens: 100,
472
+ }),
473
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
474
+ });
475
+ // estimateAnthropicCost: 10 * 0.000003 + 5 * 0.000015 = 0.0000300 + 0.0000750 = 0.000105
476
+ expect(deps.meterEvents).toHaveLength(1);
477
+ expect(deps.meterEvents[0].cost.toDollars()).toBeCloseTo(0.000105, 6);
478
+ });
479
+ });
480
+ describe("credit balance check", () => {
481
+ it("returns 402 in Anthropic format when credits are exhausted", async () => {
482
+ deps = createMockDeps({
483
+ creditLedger: {
484
+ balance: vi.fn(async () => Credit.fromCents(-100)),
485
+ debit: vi.fn(),
486
+ },
487
+ });
488
+ app = new Hono();
489
+ app.route("/v1/anthropic", createAnthropicRoutes(deps));
490
+ const res = await app.request("/v1/anthropic/v1/messages", {
491
+ method: "POST",
492
+ body: JSON.stringify({
493
+ model: "claude-3-5-sonnet-20241022",
494
+ messages: [{ role: "user", content: "Hi" }],
495
+ max_tokens: 100,
496
+ }),
497
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
498
+ });
499
+ expect(res.status).toBe(402);
500
+ const body = await res.json();
501
+ expect(body.type).toBe("error");
502
+ });
503
+ });
302
504
  });
303
505
  // ---------------------------------------------------------------------------
304
506
  // OpenAI Handler Tests
@@ -342,6 +544,19 @@ describe("OpenAI protocol handler", () => {
342
544
  const body = await res.json();
343
545
  expect(body.error.code).toBe("invalid_api_key");
344
546
  });
547
+ it("rejects empty Bearer token (only whitespace after Bearer)", async () => {
548
+ // "Bearer " with extra space — trim() strips trailing space so it becomes "Bearer"
549
+ // which doesn't match "bearer " prefix check → invalid_auth_format
550
+ const res = await app.request("/v1/openai/v1/chat/completions", {
551
+ method: "POST",
552
+ body: "{}",
553
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " },
554
+ });
555
+ expect(res.status).toBe(401);
556
+ const body = await res.json();
557
+ // "Bearer " trims to "Bearer" which doesn't pass the startsWith("bearer ") check
558
+ expect(body.error.code).toBe("invalid_auth_format");
559
+ });
345
560
  it("accepts valid Bearer key", async () => {
346
561
  deps.fetchFn = mockFetchOk(openaiChatResponse("Hi!"));
347
562
  const res = await app.request("/v1/openai/v1/chat/completions", {
@@ -392,6 +607,44 @@ describe("OpenAI protocol handler", () => {
392
607
  expect(fetchCall[0]).toBe("https://mock-openrouter.test/v1/chat/completions");
393
608
  expect(fetchCall[1]?.body).toBe(requestBody);
394
609
  });
610
+ it("injects Authorization: Bearer with provider API key in upstream request", async () => {
611
+ deps.fetchFn = mockFetchOk(openaiChatResponse("x"));
612
+ await app.request("/v1/openai/v1/chat/completions", {
613
+ method: "POST",
614
+ body: JSON.stringify({
615
+ model: "gpt-4",
616
+ messages: [{ role: "user", content: "Hello" }],
617
+ }),
618
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
619
+ });
620
+ const fetchCall = vi.mocked(deps.fetchFn).mock.calls[0];
621
+ const headers = fetchCall[1]?.headers;
622
+ expect(headers.Authorization).toBe("Bearer or-test-key");
623
+ expect(headers["Content-Type"]).toBe("application/json");
624
+ });
625
+ it("passes through upstream error status without metering", async () => {
626
+ deps.fetchFn = mockFetchError(429, '{"error":{"message":"Rate limited"}}');
627
+ const res = await app.request("/v1/openai/v1/chat/completions", {
628
+ method: "POST",
629
+ body: JSON.stringify({
630
+ model: "gpt-4",
631
+ messages: [{ role: "user", content: "Hi" }],
632
+ }),
633
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
634
+ });
635
+ expect(res.status).toBe(429);
636
+ expect(deps.meterEvents).toHaveLength(0);
637
+ });
638
+ it("handles invalid JSON in request body gracefully", async () => {
639
+ deps.fetchFn = mockFetchOk(openaiChatResponse("works"));
640
+ const res = await app.request("/v1/openai/v1/chat/completions", {
641
+ method: "POST",
642
+ body: "not valid json",
643
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
644
+ });
645
+ // Handler reads body as text and forwards it — invalid JSON doesn't cause a 400
646
+ expect(res.status).toBe(200);
647
+ });
395
648
  });
396
649
  describe("embeddings", () => {
397
650
  it("proxies embeddings request", async () => {
@@ -411,6 +664,98 @@ describe("OpenAI protocol handler", () => {
411
664
  const body = await res.json();
412
665
  expect(body.data[0].embedding).toEqual([0.1, 0.2, 0.3]);
413
666
  });
667
+ it("sends Authorization header to upstream for embeddings", async () => {
668
+ const embeddingResponse = {
669
+ object: "list",
670
+ data: [{ object: "embedding", embedding: [0.1], index: 0 }],
671
+ model: "text-embedding-3-small",
672
+ usage: { prompt_tokens: 1, total_tokens: 1 },
673
+ };
674
+ deps.fetchFn = mockFetchOk(embeddingResponse);
675
+ await app.request("/v1/openai/v1/embeddings", {
676
+ method: "POST",
677
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hi" }),
678
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
679
+ });
680
+ const fetchCall = vi.mocked(deps.fetchFn).mock.calls[0];
681
+ expect(fetchCall[0]).toBe("https://mock-openrouter.test/v1/embeddings");
682
+ const headers = fetchCall[1]?.headers;
683
+ expect(headers.Authorization).toBe("Bearer or-test-key");
684
+ });
685
+ it("meters embeddings with x-openrouter-cost header", async () => {
686
+ const embeddingResponse = {
687
+ object: "list",
688
+ data: [{ object: "embedding", embedding: [0.1], index: 0 }],
689
+ model: "text-embedding-3-small",
690
+ usage: { prompt_tokens: 5, total_tokens: 5 },
691
+ };
692
+ deps.fetchFn = mockFetchOk(embeddingResponse, { "x-openrouter-cost": "0.0001" });
693
+ await app.request("/v1/openai/v1/embeddings", {
694
+ method: "POST",
695
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
696
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
697
+ });
698
+ expect(deps.meterEvents).toHaveLength(1);
699
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.0001);
700
+ expect(deps.meterEvents[0].capability).toBe("embeddings");
701
+ expect(deps.meterEvents[0].provider).toBe("openrouter");
702
+ });
703
+ it("uses 0.0001 default cost when no cost header on embeddings", async () => {
704
+ const embeddingResponse = {
705
+ object: "list",
706
+ data: [{ object: "embedding", embedding: [0.1], index: 0 }],
707
+ model: "text-embedding-3-small",
708
+ usage: { prompt_tokens: 5, total_tokens: 5 },
709
+ };
710
+ deps.fetchFn = mockFetchOk(embeddingResponse);
711
+ await app.request("/v1/openai/v1/embeddings", {
712
+ method: "POST",
713
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
714
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
715
+ });
716
+ expect(deps.meterEvents).toHaveLength(1);
717
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.0001);
718
+ });
719
+ it("returns 429 when budget exceeded for embeddings", async () => {
720
+ deps.budgetChecker.check = vi.fn(async () => ({
721
+ allowed: false,
722
+ reason: "Budget exceeded",
723
+ httpStatus: 429,
724
+ currentHourlySpend: 100,
725
+ currentMonthlySpend: 1000,
726
+ maxSpendPerHour: 100,
727
+ maxSpendPerMonth: 1000,
728
+ }));
729
+ const res = await app.request("/v1/openai/v1/embeddings", {
730
+ method: "POST",
731
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
732
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
733
+ });
734
+ expect(res.status).toBe(429);
735
+ const body = await res.json();
736
+ expect(body.error.code).toBe("insufficient_quota");
737
+ });
738
+ it("returns 503 when no provider configured for embeddings", async () => {
739
+ deps.providers = {};
740
+ const res = await app.request("/v1/openai/v1/embeddings", {
741
+ method: "POST",
742
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
743
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
744
+ });
745
+ expect(res.status).toBe(503);
746
+ const body = await res.json();
747
+ expect(body.error.code).toBe("service_unavailable");
748
+ });
749
+ it("returns upstream error status for embeddings without metering", async () => {
750
+ deps.fetchFn = mockFetchError(400, "Bad embedding request");
751
+ const res = await app.request("/v1/openai/v1/embeddings", {
752
+ method: "POST",
753
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
754
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
755
+ });
756
+ expect(res.status).toBe(400);
757
+ expect(deps.meterEvents).toHaveLength(0);
758
+ });
414
759
  });
415
760
  describe("metering", () => {
416
761
  it("emits meter event on success", async () => {
@@ -441,6 +786,38 @@ describe("OpenAI protocol handler", () => {
441
786
  });
442
787
  expect(deps.meterEvents).toHaveLength(0);
443
788
  });
789
+ it("falls back to token-based cost estimation when no cost header", async () => {
790
+ deps.fetchFn = mockFetchOk(openaiChatResponse("Hello!"));
791
+ await app.request("/v1/openai/v1/chat/completions", {
792
+ method: "POST",
793
+ body: JSON.stringify({
794
+ model: "gpt-4",
795
+ messages: [{ role: "user", content: "Hi" }],
796
+ }),
797
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
798
+ });
799
+ expect(deps.meterEvents).toHaveLength(1);
800
+ expect(deps.meterEvents[0].tenant).toBe("tenant-001");
801
+ // Cost estimated from token counts — should be > 0
802
+ expect(deps.meterEvents[0].cost.toDollars()).toBeGreaterThan(0);
803
+ });
804
+ it("estimates cost as 0.001 when response body is invalid JSON and no cost header", async () => {
805
+ deps.fetchFn = vi.fn().mockResolvedValue(new Response("not json at all", {
806
+ status: 200,
807
+ headers: { "Content-Type": "application/json" },
808
+ }));
809
+ await app.request("/v1/openai/v1/chat/completions", {
810
+ method: "POST",
811
+ body: JSON.stringify({
812
+ model: "gpt-4",
813
+ messages: [{ role: "user", content: "Hi" }],
814
+ }),
815
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
816
+ });
817
+ expect(deps.meterEvents).toHaveLength(1);
818
+ // estimateTokenCostFromBody catches JSON parse error and returns 0.001
819
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.001);
820
+ });
444
821
  });
445
822
  describe("budget checking", () => {
446
823
  it("returns 429 when budget exceeded", async () => {
@@ -516,6 +893,22 @@ describe("OpenAI protocol handler", () => {
516
893
  expect(deps.meterEvents).toHaveLength(1);
517
894
  expect(deps.meterEvents[0].cost.toDollars()).toBe(0.002);
518
895
  });
896
+ it("sets correct SSE response headers for streaming", async () => {
897
+ const ssePayload = 'data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: [DONE]\n\n';
898
+ deps.fetchFn = mockFetchStream(ssePayload);
899
+ const res = await app.request("/v1/openai/v1/chat/completions", {
900
+ method: "POST",
901
+ body: JSON.stringify({
902
+ model: "gpt-4",
903
+ messages: [{ role: "user", content: "Hello" }],
904
+ stream: true,
905
+ }),
906
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
907
+ });
908
+ expect(res.headers.get("Transfer-Encoding")).toBe("chunked");
909
+ expect(res.headers.get("Cache-Control")).toBe("no-cache");
910
+ expect(res.headers.get("Connection")).toBe("keep-alive");
911
+ });
519
912
  });
520
913
  describe("provider not configured", () => {
521
914
  it("returns 503 when no provider", async () => {
@@ -531,4 +924,72 @@ describe("OpenAI protocol handler", () => {
531
924
  expect(res.status).toBe(503);
532
925
  });
533
926
  });
927
+ describe("fetch exception handling", () => {
928
+ it("returns 500 in OpenAI format when fetchFn throws on chat completions", async () => {
929
+ deps.fetchFn = vi.fn().mockRejectedValue(new Error("Connection refused"));
930
+ const res = await app.request("/v1/openai/v1/chat/completions", {
931
+ method: "POST",
932
+ body: JSON.stringify({
933
+ model: "gpt-4",
934
+ messages: [{ role: "user", content: "Hi" }],
935
+ }),
936
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
937
+ });
938
+ expect(res.status).toBe(500);
939
+ const body = await res.json();
940
+ expect(body.error.type).toBe("server_error");
941
+ expect(body.error.code).toBe("internal_error");
942
+ expect(body.error.message).toBe("Connection refused");
943
+ });
944
+ it("handles non-Error thrown values in chat completions", async () => {
945
+ deps.fetchFn = vi.fn().mockRejectedValue(42);
946
+ const res = await app.request("/v1/openai/v1/chat/completions", {
947
+ method: "POST",
948
+ body: JSON.stringify({
949
+ model: "gpt-4",
950
+ messages: [{ role: "user", content: "Hi" }],
951
+ }),
952
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
953
+ });
954
+ expect(res.status).toBe(500);
955
+ const body = await res.json();
956
+ expect(body.error.message).toBe("42");
957
+ });
958
+ it("returns 500 in OpenAI format when fetchFn throws on embeddings", async () => {
959
+ deps.fetchFn = vi.fn().mockRejectedValue(new Error("Embed network error"));
960
+ const res = await app.request("/v1/openai/v1/embeddings", {
961
+ method: "POST",
962
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hi" }),
963
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
964
+ });
965
+ expect(res.status).toBe(500);
966
+ const body = await res.json();
967
+ expect(body.error.type).toBe("server_error");
968
+ expect(body.error.code).toBe("internal_error");
969
+ expect(body.error.message).toBe("Embed network error");
970
+ });
971
+ });
972
+ describe("credit balance check", () => {
973
+ it("returns 402 in OpenAI format when credits are exhausted", async () => {
974
+ deps = createMockDeps({
975
+ creditLedger: {
976
+ balance: vi.fn(async () => Credit.fromCents(-100)),
977
+ debit: vi.fn(),
978
+ },
979
+ });
980
+ app = new Hono();
981
+ app.route("/v1/openai", createOpenAIRoutes(deps));
982
+ const res = await app.request("/v1/openai/v1/chat/completions", {
983
+ method: "POST",
984
+ body: JSON.stringify({
985
+ model: "gpt-4",
986
+ messages: [{ role: "user", content: "Hi" }],
987
+ }),
988
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
989
+ });
990
+ expect(res.status).toBe(402);
991
+ const body = await res.json();
992
+ expect(body.error.type).toBe("billing_error");
993
+ });
994
+ });
534
995
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -1,4 +1,4 @@
1
- import type { Credit } from "@wopr-network/platform-core/credits";
1
+ import { Credit, type ILedger } from "@wopr-network/platform-core/credits";
2
2
  import type { MeterEvent } from "@wopr-network/platform-core/metering";
3
3
  import { Hono } from "hono";
4
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -189,6 +189,25 @@ describe("Anthropic protocol handler", () => {
189
189
  });
190
190
 
191
191
  describe("format translation", () => {
192
+ it("injects Authorization: Bearer with provider API key in upstream request", async () => {
193
+ deps.fetchFn = mockFetchOk(openaiChatResponse("Hello!"));
194
+
195
+ await app.request("/v1/anthropic/v1/messages", {
196
+ method: "POST",
197
+ body: JSON.stringify({
198
+ model: "claude-3-5-sonnet-20241022",
199
+ messages: [{ role: "user", content: "Hello" }],
200
+ max_tokens: 1024,
201
+ }),
202
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
203
+ });
204
+
205
+ const fetchCall = vi.mocked(deps.fetchFn).mock.calls[0];
206
+ const headers = fetchCall[1]?.headers as Record<string, string>;
207
+ expect(headers.Authorization).toBe("Bearer or-test-key");
208
+ expect(headers["Content-Type"]).toBe("application/json");
209
+ });
210
+
192
211
  it("translates Anthropic request to OpenAI and response back", async () => {
193
212
  deps.fetchFn = mockFetchOk(openaiChatResponse("Translated response!"));
194
213
 
@@ -294,6 +313,79 @@ describe("Anthropic protocol handler", () => {
294
313
  const body = await res.json();
295
314
  expect(body.type).toBe("error");
296
315
  });
316
+
317
+ it("maps upstream 400 to Anthropic invalid_request_error", async () => {
318
+ deps.fetchFn = mockFetchError(400, "Bad request from provider");
319
+
320
+ const res = await app.request("/v1/anthropic/v1/messages", {
321
+ method: "POST",
322
+ body: JSON.stringify({
323
+ model: "claude-3-5-sonnet-20241022",
324
+ messages: [{ role: "user", content: "Hi" }],
325
+ max_tokens: 100,
326
+ }),
327
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
328
+ });
329
+
330
+ expect(res.status).toBe(400);
331
+ const body = await res.json();
332
+ expect(body.type).toBe("error");
333
+ expect(body.error.type).toBe("invalid_request_error");
334
+ });
335
+
336
+ it("maps upstream 401 to Anthropic authentication_error", async () => {
337
+ deps.fetchFn = mockFetchError(401, "Invalid API key");
338
+
339
+ const res = await app.request("/v1/anthropic/v1/messages", {
340
+ method: "POST",
341
+ body: JSON.stringify({
342
+ model: "claude-3-5-sonnet-20241022",
343
+ messages: [{ role: "user", content: "Hi" }],
344
+ max_tokens: 100,
345
+ }),
346
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
347
+ });
348
+
349
+ expect(res.status).toBe(401);
350
+ const body = await res.json();
351
+ expect(body.error.type).toBe("authentication_error");
352
+ });
353
+
354
+ it("maps upstream 429 to Anthropic rate_limit_error", async () => {
355
+ deps.fetchFn = mockFetchError(429, "Rate limited");
356
+
357
+ const res = await app.request("/v1/anthropic/v1/messages", {
358
+ method: "POST",
359
+ body: JSON.stringify({
360
+ model: "claude-3-5-sonnet-20241022",
361
+ messages: [{ role: "user", content: "Hi" }],
362
+ max_tokens: 100,
363
+ }),
364
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
365
+ });
366
+
367
+ expect(res.status).toBe(429);
368
+ const body = await res.json();
369
+ expect(body.error.type).toBe("rate_limit_error");
370
+ });
371
+
372
+ it("maps upstream 502 to Anthropic 529 overloaded", async () => {
373
+ deps.fetchFn = mockFetchError(502, "Bad gateway");
374
+
375
+ const res = await app.request("/v1/anthropic/v1/messages", {
376
+ method: "POST",
377
+ body: JSON.stringify({
378
+ model: "claude-3-5-sonnet-20241022",
379
+ messages: [{ role: "user", content: "Hi" }],
380
+ max_tokens: 100,
381
+ }),
382
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
383
+ });
384
+
385
+ expect(res.status).toBe(529);
386
+ const body = await res.json();
387
+ expect(body.type).toBe("error");
388
+ });
297
389
  });
298
390
 
299
391
  describe("streaming", () => {
@@ -339,6 +431,64 @@ describe("Anthropic protocol handler", () => {
339
431
  const body = await res.text();
340
432
  expect(body).toContain("[DONE]");
341
433
  });
434
+
435
+ it("sets correct SSE response headers for streaming", async () => {
436
+ const ssePayload = 'data: {"type":"content_block_delta"}\n\ndata: [DONE]\n\n';
437
+ deps.fetchFn = mockFetchStream(ssePayload);
438
+
439
+ const res = await app.request("/v1/anthropic/v1/messages", {
440
+ method: "POST",
441
+ body: JSON.stringify({
442
+ model: "claude-3-5-sonnet-20241022",
443
+ messages: [{ role: "user", content: "Hi" }],
444
+ max_tokens: 100,
445
+ stream: true,
446
+ }),
447
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
448
+ });
449
+
450
+ expect(res.headers.get("Transfer-Encoding")).toBe("chunked");
451
+ expect(res.headers.get("Cache-Control")).toBe("no-cache");
452
+ expect(res.headers.get("Connection")).toBe("keep-alive");
453
+ });
454
+
455
+ it("meters streaming cost when x-openrouter-cost header is present", async () => {
456
+ const ssePayload = 'data: {"type":"content_block_delta"}\n\ndata: [DONE]\n\n';
457
+ deps.fetchFn = mockFetchStream(ssePayload, { "x-openrouter-cost": "0.004" });
458
+
459
+ await app.request("/v1/anthropic/v1/messages", {
460
+ method: "POST",
461
+ body: JSON.stringify({
462
+ model: "claude-3-5-sonnet-20241022",
463
+ messages: [{ role: "user", content: "Hi" }],
464
+ max_tokens: 100,
465
+ stream: true,
466
+ }),
467
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
468
+ });
469
+
470
+ expect(deps.meterEvents).toHaveLength(1);
471
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.004);
472
+ expect(deps.meterEvents[0].capability).toBe("chat-completions");
473
+ });
474
+
475
+ it("does not meter streaming when cost is zero", async () => {
476
+ const ssePayload = 'data: {"type":"content_block_delta"}\n\ndata: [DONE]\n\n';
477
+ deps.fetchFn = mockFetchStream(ssePayload);
478
+
479
+ await app.request("/v1/anthropic/v1/messages", {
480
+ method: "POST",
481
+ body: JSON.stringify({
482
+ model: "claude-3-5-sonnet-20241022",
483
+ messages: [{ role: "user", content: "Hi" }],
484
+ max_tokens: 100,
485
+ stream: true,
486
+ }),
487
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
488
+ });
489
+
490
+ expect(deps.meterEvents).toHaveLength(0);
491
+ });
342
492
  });
343
493
 
344
494
  describe("provider not configured", () => {
@@ -358,6 +508,93 @@ describe("Anthropic protocol handler", () => {
358
508
  expect(res.status).toBe(529);
359
509
  });
360
510
  });
511
+
512
+ describe("fetch exception handling", () => {
513
+ it("returns 500 in Anthropic format when fetchFn throws an Error", async () => {
514
+ deps.fetchFn = vi.fn().mockRejectedValue(new Error("ECONNREFUSED"));
515
+
516
+ const res = await app.request("/v1/anthropic/v1/messages", {
517
+ method: "POST",
518
+ body: JSON.stringify({
519
+ model: "claude-3-5-sonnet-20241022",
520
+ messages: [{ role: "user", content: "Hi" }],
521
+ max_tokens: 100,
522
+ }),
523
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
524
+ });
525
+
526
+ expect(res.status).toBe(500);
527
+ const body = await res.json();
528
+ expect(body.type).toBe("error");
529
+ expect(body.error.type).toBe("api_error");
530
+ expect(body.error.message).toBe("ECONNREFUSED");
531
+ });
532
+
533
+ it("handles non-Error thrown values", async () => {
534
+ deps.fetchFn = vi.fn().mockRejectedValue("string error");
535
+
536
+ const res = await app.request("/v1/anthropic/v1/messages", {
537
+ method: "POST",
538
+ body: JSON.stringify({
539
+ model: "claude-3-5-sonnet-20241022",
540
+ messages: [{ role: "user", content: "Hi" }],
541
+ max_tokens: 100,
542
+ }),
543
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
544
+ });
545
+
546
+ expect(res.status).toBe(500);
547
+ const body = await res.json();
548
+ expect(body.error.message).toBe("string error");
549
+ });
550
+ });
551
+
552
+ describe("cost fallback without x-openrouter-cost header", () => {
553
+ it("estimates cost from Anthropic usage when no cost header", async () => {
554
+ deps.fetchFn = mockFetchOk(openaiChatResponse("Hello!"));
555
+
556
+ await app.request("/v1/anthropic/v1/messages", {
557
+ method: "POST",
558
+ body: JSON.stringify({
559
+ model: "claude-3-5-sonnet-20241022",
560
+ messages: [{ role: "user", content: "Hi" }],
561
+ max_tokens: 100,
562
+ }),
563
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
564
+ });
565
+
566
+ // estimateAnthropicCost: 10 * 0.000003 + 5 * 0.000015 = 0.0000300 + 0.0000750 = 0.000105
567
+ expect(deps.meterEvents).toHaveLength(1);
568
+ expect(deps.meterEvents[0].cost.toDollars()).toBeCloseTo(0.000105, 6);
569
+ });
570
+ });
571
+
572
+ describe("credit balance check", () => {
573
+ it("returns 402 in Anthropic format when credits are exhausted", async () => {
574
+ deps = createMockDeps({
575
+ creditLedger: {
576
+ balance: vi.fn(async () => Credit.fromCents(-100)),
577
+ debit: vi.fn(),
578
+ } as unknown as ILedger,
579
+ });
580
+ app = new Hono();
581
+ app.route("/v1/anthropic", createAnthropicRoutes(deps));
582
+
583
+ const res = await app.request("/v1/anthropic/v1/messages", {
584
+ method: "POST",
585
+ body: JSON.stringify({
586
+ model: "claude-3-5-sonnet-20241022",
587
+ messages: [{ role: "user", content: "Hi" }],
588
+ max_tokens: 100,
589
+ }),
590
+ headers: { "Content-Type": "application/json", "x-api-key": VALID_KEY },
591
+ });
592
+
593
+ expect(res.status).toBe(402);
594
+ const body = await res.json();
595
+ expect(body.type).toBe("error");
596
+ });
597
+ });
361
598
  });
362
599
 
363
600
  // ---------------------------------------------------------------------------
@@ -411,6 +648,21 @@ describe("OpenAI protocol handler", () => {
411
648
  expect(body.error.code).toBe("invalid_api_key");
412
649
  });
413
650
 
651
+ it("rejects empty Bearer token (only whitespace after Bearer)", async () => {
652
+ // "Bearer " with extra space — trim() strips trailing space so it becomes "Bearer"
653
+ // which doesn't match "bearer " prefix check → invalid_auth_format
654
+ const res = await app.request("/v1/openai/v1/chat/completions", {
655
+ method: "POST",
656
+ body: "{}",
657
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " },
658
+ });
659
+
660
+ expect(res.status).toBe(401);
661
+ const body = await res.json();
662
+ // "Bearer " trims to "Bearer" which doesn't pass the startsWith("bearer ") check
663
+ expect(body.error.code).toBe("invalid_auth_format");
664
+ });
665
+
414
666
  it("accepts valid Bearer key", async () => {
415
667
  deps.fetchFn = mockFetchOk(openaiChatResponse("Hi!"));
416
668
 
@@ -470,6 +722,53 @@ describe("OpenAI protocol handler", () => {
470
722
  expect(fetchCall[0]).toBe("https://mock-openrouter.test/v1/chat/completions");
471
723
  expect(fetchCall[1]?.body).toBe(requestBody);
472
724
  });
725
+
726
+ it("injects Authorization: Bearer with provider API key in upstream request", async () => {
727
+ deps.fetchFn = mockFetchOk(openaiChatResponse("x"));
728
+
729
+ await app.request("/v1/openai/v1/chat/completions", {
730
+ method: "POST",
731
+ body: JSON.stringify({
732
+ model: "gpt-4",
733
+ messages: [{ role: "user", content: "Hello" }],
734
+ }),
735
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
736
+ });
737
+
738
+ const fetchCall = vi.mocked(deps.fetchFn).mock.calls[0];
739
+ const headers = fetchCall[1]?.headers as Record<string, string>;
740
+ expect(headers.Authorization).toBe("Bearer or-test-key");
741
+ expect(headers["Content-Type"]).toBe("application/json");
742
+ });
743
+
744
+ it("passes through upstream error status without metering", async () => {
745
+ deps.fetchFn = mockFetchError(429, '{"error":{"message":"Rate limited"}}');
746
+
747
+ const res = await app.request("/v1/openai/v1/chat/completions", {
748
+ method: "POST",
749
+ body: JSON.stringify({
750
+ model: "gpt-4",
751
+ messages: [{ role: "user", content: "Hi" }],
752
+ }),
753
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
754
+ });
755
+
756
+ expect(res.status).toBe(429);
757
+ expect(deps.meterEvents).toHaveLength(0);
758
+ });
759
+
760
+ it("handles invalid JSON in request body gracefully", async () => {
761
+ deps.fetchFn = mockFetchOk(openaiChatResponse("works"));
762
+
763
+ const res = await app.request("/v1/openai/v1/chat/completions", {
764
+ method: "POST",
765
+ body: "not valid json",
766
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
767
+ });
768
+
769
+ // Handler reads body as text and forwards it — invalid JSON doesn't cause a 400
770
+ expect(res.status).toBe(200);
771
+ });
473
772
  });
474
773
 
475
774
  describe("embeddings", () => {
@@ -492,6 +791,116 @@ describe("OpenAI protocol handler", () => {
492
791
  const body = await res.json();
493
792
  expect(body.data[0].embedding).toEqual([0.1, 0.2, 0.3]);
494
793
  });
794
+
795
+ it("sends Authorization header to upstream for embeddings", async () => {
796
+ const embeddingResponse = {
797
+ object: "list",
798
+ data: [{ object: "embedding", embedding: [0.1], index: 0 }],
799
+ model: "text-embedding-3-small",
800
+ usage: { prompt_tokens: 1, total_tokens: 1 },
801
+ };
802
+ deps.fetchFn = mockFetchOk(embeddingResponse);
803
+
804
+ await app.request("/v1/openai/v1/embeddings", {
805
+ method: "POST",
806
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hi" }),
807
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
808
+ });
809
+
810
+ const fetchCall = vi.mocked(deps.fetchFn).mock.calls[0];
811
+ expect(fetchCall[0]).toBe("https://mock-openrouter.test/v1/embeddings");
812
+ const headers = fetchCall[1]?.headers as Record<string, string>;
813
+ expect(headers.Authorization).toBe("Bearer or-test-key");
814
+ });
815
+
816
+ it("meters embeddings with x-openrouter-cost header", async () => {
817
+ const embeddingResponse = {
818
+ object: "list",
819
+ data: [{ object: "embedding", embedding: [0.1], index: 0 }],
820
+ model: "text-embedding-3-small",
821
+ usage: { prompt_tokens: 5, total_tokens: 5 },
822
+ };
823
+ deps.fetchFn = mockFetchOk(embeddingResponse, { "x-openrouter-cost": "0.0001" });
824
+
825
+ await app.request("/v1/openai/v1/embeddings", {
826
+ method: "POST",
827
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
828
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
829
+ });
830
+
831
+ expect(deps.meterEvents).toHaveLength(1);
832
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.0001);
833
+ expect(deps.meterEvents[0].capability).toBe("embeddings");
834
+ expect(deps.meterEvents[0].provider).toBe("openrouter");
835
+ });
836
+
837
+ it("uses 0.0001 default cost when no cost header on embeddings", async () => {
838
+ const embeddingResponse = {
839
+ object: "list",
840
+ data: [{ object: "embedding", embedding: [0.1], index: 0 }],
841
+ model: "text-embedding-3-small",
842
+ usage: { prompt_tokens: 5, total_tokens: 5 },
843
+ };
844
+ deps.fetchFn = mockFetchOk(embeddingResponse);
845
+
846
+ await app.request("/v1/openai/v1/embeddings", {
847
+ method: "POST",
848
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
849
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
850
+ });
851
+
852
+ expect(deps.meterEvents).toHaveLength(1);
853
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.0001);
854
+ });
855
+
856
+ it("returns 429 when budget exceeded for embeddings", async () => {
857
+ deps.budgetChecker.check = vi.fn(async () => ({
858
+ allowed: false,
859
+ reason: "Budget exceeded",
860
+ httpStatus: 429,
861
+ currentHourlySpend: 100,
862
+ currentMonthlySpend: 1000,
863
+ maxSpendPerHour: 100,
864
+ maxSpendPerMonth: 1000,
865
+ }));
866
+
867
+ const res = await app.request("/v1/openai/v1/embeddings", {
868
+ method: "POST",
869
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
870
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
871
+ });
872
+
873
+ expect(res.status).toBe(429);
874
+ const body = await res.json();
875
+ expect(body.error.code).toBe("insufficient_quota");
876
+ });
877
+
878
+ it("returns 503 when no provider configured for embeddings", async () => {
879
+ deps.providers = {};
880
+
881
+ const res = await app.request("/v1/openai/v1/embeddings", {
882
+ method: "POST",
883
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
884
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
885
+ });
886
+
887
+ expect(res.status).toBe(503);
888
+ const body = await res.json();
889
+ expect(body.error.code).toBe("service_unavailable");
890
+ });
891
+
892
+ it("returns upstream error status for embeddings without metering", async () => {
893
+ deps.fetchFn = mockFetchError(400, "Bad embedding request");
894
+
895
+ const res = await app.request("/v1/openai/v1/embeddings", {
896
+ method: "POST",
897
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hello" }),
898
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
899
+ });
900
+
901
+ expect(res.status).toBe(400);
902
+ expect(deps.meterEvents).toHaveLength(0);
903
+ });
495
904
  });
496
905
 
497
906
  describe("metering", () => {
@@ -528,6 +937,46 @@ describe("OpenAI protocol handler", () => {
528
937
 
529
938
  expect(deps.meterEvents).toHaveLength(0);
530
939
  });
940
+
941
+ it("falls back to token-based cost estimation when no cost header", async () => {
942
+ deps.fetchFn = mockFetchOk(openaiChatResponse("Hello!"));
943
+
944
+ await app.request("/v1/openai/v1/chat/completions", {
945
+ method: "POST",
946
+ body: JSON.stringify({
947
+ model: "gpt-4",
948
+ messages: [{ role: "user", content: "Hi" }],
949
+ }),
950
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
951
+ });
952
+
953
+ expect(deps.meterEvents).toHaveLength(1);
954
+ expect(deps.meterEvents[0].tenant).toBe("tenant-001");
955
+ // Cost estimated from token counts — should be > 0
956
+ expect(deps.meterEvents[0].cost.toDollars()).toBeGreaterThan(0);
957
+ });
958
+
959
+ it("estimates cost as 0.001 when response body is invalid JSON and no cost header", async () => {
960
+ deps.fetchFn = vi.fn().mockResolvedValue(
961
+ new Response("not json at all", {
962
+ status: 200,
963
+ headers: { "Content-Type": "application/json" },
964
+ }),
965
+ );
966
+
967
+ await app.request("/v1/openai/v1/chat/completions", {
968
+ method: "POST",
969
+ body: JSON.stringify({
970
+ model: "gpt-4",
971
+ messages: [{ role: "user", content: "Hi" }],
972
+ }),
973
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
974
+ });
975
+
976
+ expect(deps.meterEvents).toHaveLength(1);
977
+ // estimateTokenCostFromBody catches JSON parse error and returns 0.001
978
+ expect(deps.meterEvents[0].cost.toDollars()).toBe(0.001);
979
+ });
531
980
  });
532
981
 
533
982
  describe("budget checking", () => {
@@ -615,6 +1064,25 @@ describe("OpenAI protocol handler", () => {
615
1064
  expect(deps.meterEvents).toHaveLength(1);
616
1065
  expect(deps.meterEvents[0].cost.toDollars()).toBe(0.002);
617
1066
  });
1067
+
1068
+ it("sets correct SSE response headers for streaming", async () => {
1069
+ const ssePayload = 'data: {"choices":[{"delta":{"content":"Hi"}}]}\n\ndata: [DONE]\n\n';
1070
+ deps.fetchFn = mockFetchStream(ssePayload);
1071
+
1072
+ const res = await app.request("/v1/openai/v1/chat/completions", {
1073
+ method: "POST",
1074
+ body: JSON.stringify({
1075
+ model: "gpt-4",
1076
+ messages: [{ role: "user", content: "Hello" }],
1077
+ stream: true,
1078
+ }),
1079
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
1080
+ });
1081
+
1082
+ expect(res.headers.get("Transfer-Encoding")).toBe("chunked");
1083
+ expect(res.headers.get("Cache-Control")).toBe("no-cache");
1084
+ expect(res.headers.get("Connection")).toBe("keep-alive");
1085
+ });
618
1086
  });
619
1087
 
620
1088
  describe("provider not configured", () => {
@@ -633,4 +1101,84 @@ describe("OpenAI protocol handler", () => {
633
1101
  expect(res.status).toBe(503);
634
1102
  });
635
1103
  });
1104
+
1105
+ describe("fetch exception handling", () => {
1106
+ it("returns 500 in OpenAI format when fetchFn throws on chat completions", async () => {
1107
+ deps.fetchFn = vi.fn().mockRejectedValue(new Error("Connection refused"));
1108
+
1109
+ const res = await app.request("/v1/openai/v1/chat/completions", {
1110
+ method: "POST",
1111
+ body: JSON.stringify({
1112
+ model: "gpt-4",
1113
+ messages: [{ role: "user", content: "Hi" }],
1114
+ }),
1115
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
1116
+ });
1117
+
1118
+ expect(res.status).toBe(500);
1119
+ const body = await res.json();
1120
+ expect(body.error.type).toBe("server_error");
1121
+ expect(body.error.code).toBe("internal_error");
1122
+ expect(body.error.message).toBe("Connection refused");
1123
+ });
1124
+
1125
+ it("handles non-Error thrown values in chat completions", async () => {
1126
+ deps.fetchFn = vi.fn().mockRejectedValue(42);
1127
+
1128
+ const res = await app.request("/v1/openai/v1/chat/completions", {
1129
+ method: "POST",
1130
+ body: JSON.stringify({
1131
+ model: "gpt-4",
1132
+ messages: [{ role: "user", content: "Hi" }],
1133
+ }),
1134
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
1135
+ });
1136
+
1137
+ expect(res.status).toBe(500);
1138
+ const body = await res.json();
1139
+ expect(body.error.message).toBe("42");
1140
+ });
1141
+
1142
+ it("returns 500 in OpenAI format when fetchFn throws on embeddings", async () => {
1143
+ deps.fetchFn = vi.fn().mockRejectedValue(new Error("Embed network error"));
1144
+
1145
+ const res = await app.request("/v1/openai/v1/embeddings", {
1146
+ method: "POST",
1147
+ body: JSON.stringify({ model: "text-embedding-3-small", input: "Hi" }),
1148
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
1149
+ });
1150
+
1151
+ expect(res.status).toBe(500);
1152
+ const body = await res.json();
1153
+ expect(body.error.type).toBe("server_error");
1154
+ expect(body.error.code).toBe("internal_error");
1155
+ expect(body.error.message).toBe("Embed network error");
1156
+ });
1157
+ });
1158
+
1159
+ describe("credit balance check", () => {
1160
+ it("returns 402 in OpenAI format when credits are exhausted", async () => {
1161
+ deps = createMockDeps({
1162
+ creditLedger: {
1163
+ balance: vi.fn(async () => Credit.fromCents(-100)),
1164
+ debit: vi.fn(),
1165
+ } as unknown as ILedger,
1166
+ });
1167
+ app = new Hono();
1168
+ app.route("/v1/openai", createOpenAIRoutes(deps));
1169
+
1170
+ const res = await app.request("/v1/openai/v1/chat/completions", {
1171
+ method: "POST",
1172
+ body: JSON.stringify({
1173
+ model: "gpt-4",
1174
+ messages: [{ role: "user", content: "Hi" }],
1175
+ }),
1176
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${VALID_KEY}` },
1177
+ });
1178
+
1179
+ expect(res.status).toBe(402);
1180
+ const body = await res.json();
1181
+ expect(body.error.type).toBe("billing_error");
1182
+ });
1183
+ });
636
1184
  });