@wopr-network/platform-core 1.13.3 → 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.
- package/.github/workflows/dependabot-auto-merge.yml +1 -2
- package/dist/api/routes/admin-credits.d.ts +2 -2
- package/dist/api/routes/admin-credits.js +9 -4
- package/dist/api/routes/quota.d.ts +2 -2
- package/dist/api/routes/verify-email.d.ts +3 -3
- package/dist/backup/on-demand-snapshot-service.d.ts +2 -2
- package/dist/billing/payram/webhook.d.ts +3 -3
- package/dist/billing/payram/webhook.js +5 -1
- package/dist/billing/payram/webhook.test.js +5 -4
- package/dist/billing/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/billing/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/billing/stripe/tenant-store.d.ts +1 -1
- package/dist/billing/stripe/tenant-store.js +1 -1
- package/dist/credits/auto-topup-charge.d.ts +2 -2
- package/dist/credits/auto-topup-charge.js +5 -1
- package/dist/credits/auto-topup-charge.test.js +5 -4
- package/dist/credits/auto-topup-usage.d.ts +2 -2
- package/dist/credits/auto-topup-usage.test.js +53 -12
- package/dist/credits/credit-expiry-cron.d.ts +2 -2
- package/dist/credits/credit-expiry-cron.js +7 -4
- package/dist/credits/credit-expiry-cron.test.js +25 -8
- package/dist/credits/credit-ledger.d.ts +2 -2
- package/dist/credits/credit-ledger.js +1 -1
- package/dist/credits/dividend-cron.d.ts +4 -6
- package/dist/credits/dividend-cron.js +10 -16
- package/dist/credits/dividend-cron.test.js +31 -44
- package/dist/credits/dividend-repository.js +19 -22
- package/dist/credits/dividend-repository.test.js +4 -3
- package/dist/credits/index.d.ts +4 -2
- package/dist/credits/index.js +2 -1
- package/dist/credits/ledger.d.ts +195 -0
- package/dist/credits/ledger.js +561 -0
- package/dist/credits/ledger.test.js +418 -0
- package/dist/credits/signup-grant.d.ts +2 -2
- package/dist/credits/signup-grant.js +4 -4
- package/dist/credits/signup-grant.test.js +5 -3
- package/dist/credits/trial-balance-cron.d.ts +19 -0
- package/dist/credits/trial-balance-cron.js +30 -0
- package/dist/credits/trial-balance-cron.test.js +55 -0
- package/dist/db/schema/index.d.ts +1 -0
- package/dist/db/schema/index.js +1 -0
- package/dist/db/schema/ledger.d.ts +442 -0
- package/dist/db/schema/ledger.js +76 -0
- package/dist/gateway/credit-gate.d.ts +2 -2
- package/dist/gateway/credit-gate.js +5 -1
- package/dist/gateway/credit-gate.test.js +35 -33
- package/dist/gateway/protocol/deps.d.ts +2 -2
- package/dist/gateway/protocol/handlers.test.js +461 -0
- package/dist/gateway/proxy.d.ts +2 -2
- package/dist/gateway/types.d.ts +2 -2
- package/dist/metering/reconciliation-cron.test.js +9 -8
- package/dist/metering/reconciliation-repository.js +12 -10
- package/dist/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.js +10 -8
- package/dist/monetization/affiliate/affiliate-admin-repository.test.js +32 -13
- package/dist/monetization/affiliate/credit-match.d.ts +2 -2
- package/dist/monetization/affiliate/credit-match.js +4 -1
- package/dist/monetization/affiliate/credit-match.test.js +58 -13
- package/dist/monetization/affiliate/new-user-bonus.d.ts +2 -2
- package/dist/monetization/affiliate/new-user-bonus.js +4 -1
- package/dist/monetization/affiliate/new-user-bonus.test.js +4 -3
- package/dist/monetization/credits/auto-topup-charge.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-charge.js +5 -1
- package/dist/monetization/credits/auto-topup-charge.test.js +5 -4
- package/dist/monetization/credits/auto-topup-usage.d.ts +2 -2
- package/dist/monetization/credits/auto-topup-usage.test.js +53 -12
- package/dist/monetization/credits/bot-billing.d.ts +3 -3
- package/dist/monetization/credits/bot-billing.test.js +18 -5
- package/dist/monetization/credits/credit-expiry-cron.test.js +25 -8
- package/dist/monetization/credits/dividend-cron.d.ts +2 -4
- package/dist/monetization/credits/dividend-cron.js +7 -4
- package/dist/monetization/credits/dividend-cron.test.js +26 -46
- package/dist/monetization/credits/dividend-repository.js +15 -24
- package/dist/monetization/credits/dividend-repository.test.js +4 -3
- package/dist/monetization/credits/index.d.ts +2 -2
- package/dist/monetization/credits/index.js +1 -1
- package/dist/monetization/credits/member-usage.test.js +23 -10
- package/dist/monetization/credits/phone-billing.d.ts +2 -2
- package/dist/monetization/credits/phone-billing.js +5 -1
- package/dist/monetization/credits/phone-billing.test.js +9 -12
- package/dist/monetization/credits/runtime-cron.d.ts +2 -2
- package/dist/monetization/credits/runtime-cron.js +32 -8
- package/dist/monetization/credits/runtime-cron.test.js +28 -27
- package/dist/monetization/credits/runtime-scheduler.d.ts +2 -2
- package/dist/monetization/credits/runtime-scheduler.test.js +1 -1
- package/dist/monetization/credits/signup-grant.test.js +5 -3
- package/dist/monetization/credits/storage-tier-cron.test.js +3 -2
- package/dist/monetization/credits/trial-balance-cron.test.js +42 -0
- package/dist/monetization/feature-gate.d.ts +3 -3
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/metering/reconciliation-cron.test.js +9 -8
- package/dist/monetization/metering/reconciliation-repository.js +11 -10
- package/dist/monetization/metering/reconciliation-repository.test.js +9 -8
- package/dist/monetization/payram/webhook.d.ts +2 -2
- package/dist/monetization/payram/webhook.js +5 -1
- package/dist/monetization/payram/webhook.test.js +5 -4
- package/dist/monetization/promotions/engine.d.ts +2 -2
- package/dist/monetization/promotions/engine.js +4 -1
- package/dist/monetization/promotions/engine.test.js +3 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/monetization/stripe/stripe-payment-processor.d.ts +2 -2
- package/dist/monetization/stripe/stripe-payment-processor.test.js +7 -0
- package/dist/monetization/stripe/webhook.d.ts +2 -2
- package/dist/monetization/stripe/webhook.js +70 -6
- package/dist/monetization/stripe/webhook.test.js +20 -15
- package/dist/onboarding/onboarding-service.d.ts +2 -2
- package/dist/onboarding/onboarding-service.js +6 -2
- package/drizzle/migrations/0003_double_entry_ledger.sql +82 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/api/routes/admin-credits.ts +11 -14
- package/src/api/routes/quota.ts +2 -2
- package/src/api/routes/verify-email.ts +4 -4
- package/src/backup/on-demand-snapshot-service.test.ts +3 -3
- package/src/backup/on-demand-snapshot-service.ts +3 -3
- package/src/billing/payram/webhook.test.ts +7 -5
- package/src/billing/payram/webhook.ts +8 -11
- package/src/billing/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/billing/stripe/stripe-payment-processor.ts +3 -3
- package/src/billing/stripe/tenant-store.ts +1 -1
- package/src/credits/auto-topup-charge.test.ts +7 -5
- package/src/credits/auto-topup-charge.ts +7 -10
- package/src/credits/auto-topup-usage.test.ts +55 -13
- package/src/credits/auto-topup-usage.ts +2 -2
- package/src/credits/credit-expiry-cron.test.ts +26 -45
- package/src/credits/credit-expiry-cron.ts +9 -12
- package/src/credits/credit-ledger.ts +3 -3
- package/src/credits/dividend-cron.test.ts +38 -45
- package/src/credits/dividend-cron.ts +12 -26
- package/src/credits/dividend-repository.test.ts +4 -3
- package/src/credits/dividend-repository.ts +21 -23
- package/src/credits/index.ts +23 -4
- package/src/credits/ledger.test.ts +514 -0
- package/src/credits/ledger.ts +851 -0
- package/src/credits/signup-grant.test.ts +7 -4
- package/src/credits/signup-grant.ts +6 -12
- package/src/credits/trial-balance-cron.test.ts +68 -0
- package/src/credits/trial-balance-cron.ts +46 -0
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/ledger.ts +94 -0
- package/src/gateway/credit-gate-wiring.test.ts +3 -3
- package/src/gateway/credit-gate.test.ts +35 -33
- package/src/gateway/credit-gate.ts +6 -10
- package/src/gateway/gateway-routes.test.ts +5 -5
- package/src/gateway/protocol/deps.ts +2 -2
- package/src/gateway/protocol/handlers.test.ts +549 -1
- package/src/gateway/proxy.ts +2 -2
- package/src/gateway/route-mounting.test.ts +2 -2
- package/src/gateway/types.ts +2 -2
- package/src/metering/reconciliation-cron.test.ts +10 -9
- package/src/metering/reconciliation-repository.test.ts +10 -9
- package/src/metering/reconciliation-repository.ts +14 -11
- package/src/monetization/affiliate/affiliate-admin-repository.test.ts +32 -19
- package/src/monetization/affiliate/affiliate-admin-repository.ts +16 -8
- package/src/monetization/affiliate/credit-match.test.ts +60 -14
- package/src/monetization/affiliate/credit-match.ts +6 -9
- package/src/monetization/affiliate/new-user-bonus.test.ts +6 -4
- package/src/monetization/affiliate/new-user-bonus.ts +6 -9
- package/src/monetization/credits/auto-topup-charge.test.ts +7 -5
- package/src/monetization/credits/auto-topup-charge.ts +7 -10
- package/src/monetization/credits/auto-topup-usage.test.ts +55 -13
- package/src/monetization/credits/auto-topup-usage.ts +2 -2
- package/src/monetization/credits/bot-billing.test.ts +20 -6
- package/src/monetization/credits/bot-billing.ts +3 -3
- package/src/monetization/credits/credit-expiry-cron.test.ts +26 -45
- package/src/monetization/credits/dividend-cron.test.ts +34 -48
- package/src/monetization/credits/dividend-cron.ts +9 -14
- package/src/monetization/credits/dividend-repository.test.ts +4 -3
- package/src/monetization/credits/dividend-repository.ts +19 -25
- package/src/monetization/credits/index.ts +4 -4
- package/src/monetization/credits/member-usage.test.ts +25 -11
- package/src/monetization/credits/phone-billing.test.ts +18 -26
- package/src/monetization/credits/phone-billing.ts +7 -10
- package/src/monetization/credits/runtime-cron.test.ts +29 -28
- package/src/monetization/credits/runtime-cron.ts +34 -58
- package/src/monetization/credits/runtime-scheduler.test.ts +1 -1
- package/src/monetization/credits/runtime-scheduler.ts +2 -2
- package/src/monetization/credits/signup-grant.test.ts +7 -4
- package/src/monetization/credits/storage-tier-cron.test.ts +5 -3
- package/src/monetization/credits/trial-balance-cron.test.ts +52 -0
- package/src/monetization/feature-gate.ts +3 -3
- package/src/monetization/index.ts +4 -4
- package/src/monetization/metering/reconciliation-cron.test.ts +10 -9
- package/src/monetization/metering/reconciliation-repository.test.ts +11 -9
- package/src/monetization/metering/reconciliation-repository.ts +13 -11
- package/src/monetization/payram/webhook.test.ts +7 -5
- package/src/monetization/payram/webhook.ts +7 -10
- package/src/monetization/promotions/engine.test.ts +6 -5
- package/src/monetization/promotions/engine.ts +6 -3
- package/src/monetization/repository-types.ts +1 -1
- package/src/monetization/stripe/stripe-payment-processor.test.ts +10 -3
- package/src/monetization/stripe/stripe-payment-processor.ts +3 -3
- package/src/monetization/stripe/webhook.test.ts +22 -16
- package/src/monetization/stripe/webhook.ts +75 -50
- package/src/onboarding/onboarding-service.ts +8 -11
- package/dist/credits/credit-ledger-extra.test.js +0 -40
- package/dist/credits/credit-ledger.bench.js +0 -33
- package/dist/credits/credit-ledger.test.d.ts +0 -4
- package/dist/credits/credit-ledger.test.js +0 -203
- package/dist/credits/credit-transaction-repository.test.js +0 -232
- package/dist/monetization/credits/credit-ledger-extra.test.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger-extra.test.js +0 -39
- package/dist/monetization/credits/credit-ledger.bench.d.ts +0 -1
- package/dist/monetization/credits/credit-ledger.bench.js +0 -32
- package/dist/monetization/credits/credit-ledger.test.d.ts +0 -4
- package/dist/monetization/credits/credit-ledger.test.js +0 -202
- package/dist/monetization/credits/credit-transaction-repository.test.d.ts +0 -1
- package/dist/monetization/credits/credit-transaction-repository.test.js +0 -232
- package/src/credits/credit-ledger-extra.test.ts +0 -57
- package/src/credits/credit-ledger.bench.ts +0 -56
- package/src/credits/credit-ledger.test.ts +0 -276
- package/src/credits/credit-transaction-repository.test.ts +0 -274
- package/src/monetization/credits/credit-ledger-extra.test.ts +0 -56
- package/src/monetization/credits/credit-ledger.bench.ts +0 -55
- package/src/monetization/credits/credit-ledger.test.ts +0 -275
- package/src/monetization/credits/credit-transaction-repository.test.ts +0 -274
- /package/dist/credits/{credit-ledger-extra.test.d.ts → ledger.test.d.ts} +0 -0
- /package/dist/credits/{credit-ledger.bench.d.ts → trial-balance-cron.test.d.ts} +0 -0
- /package/dist/{credits/credit-transaction-repository.test.d.ts → monetization/credits/trial-balance-cron.test.d.ts} +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import
|
|
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
|
});
|
package/src/gateway/proxy.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* 5. Return response to bot
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type {
|
|
13
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
14
14
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
15
15
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
16
16
|
import type { Context } from "hono";
|
|
@@ -63,7 +63,7 @@ const smsDeliveryStatusBodySchema = z.object({
|
|
|
63
63
|
export interface ProxyDeps {
|
|
64
64
|
meter: MeterEmitter;
|
|
65
65
|
budgetChecker: IBudgetChecker;
|
|
66
|
-
creditLedger?:
|
|
66
|
+
creditLedger?: ILedger;
|
|
67
67
|
topUpUrl: string;
|
|
68
68
|
graceBufferCents?: number;
|
|
69
69
|
providers: ProviderConfig;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* is correctly applied, and route ordering is correct.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import type {
|
|
8
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
9
9
|
import { Credit } from "@wopr-network/platform-core/credits";
|
|
10
10
|
import { Hono } from "hono";
|
|
11
11
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
@@ -48,7 +48,7 @@ function buildTestConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig
|
|
|
48
48
|
debit: vi.fn().mockResolvedValue(undefined),
|
|
49
49
|
credit: vi.fn(),
|
|
50
50
|
history: vi.fn(),
|
|
51
|
-
} as unknown as
|
|
51
|
+
} as unknown as ILedger;
|
|
52
52
|
const fetchFn = vi.fn().mockResolvedValue(
|
|
53
53
|
new Response(
|
|
54
54
|
JSON.stringify({
|
package/src/gateway/types.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* budget-checks, proxies to upstream providers, meters usage, and responds.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type {
|
|
9
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
10
10
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
11
11
|
import type { IRateLimitRepository } from "../api/rate-limit-repository.js";
|
|
12
12
|
import type { IBudgetChecker, SpendLimits } from "../monetization/budget/budget-checker.js";
|
|
@@ -99,7 +99,7 @@ export interface GatewayConfig {
|
|
|
99
99
|
/** BudgetChecker instance for pre-call budget validation */
|
|
100
100
|
budgetChecker: IBudgetChecker;
|
|
101
101
|
/** CreditLedger instance for deducting credits after proxy calls (optional — if absent, credit deduction is skipped) */
|
|
102
|
-
creditLedger?:
|
|
102
|
+
creditLedger?: ILedger;
|
|
103
103
|
/** URL to direct users to when they need to add credits (default: "/dashboard/credits") */
|
|
104
104
|
topUpUrl?: string;
|
|
105
105
|
/** Maximum negative credit balance (in cents) before hard-stop. Default: 50 (-$0.50). */
|