@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,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/dist/gateway/proxy.d.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* 4. Emit meter event
|
|
10
10
|
* 5. Return response to bot
|
|
11
11
|
*/
|
|
12
|
-
import type {
|
|
12
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
13
13
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
14
14
|
import type { Context } from "hono";
|
|
15
15
|
import type { IBudgetChecker } from "../monetization/budget/budget-checker.js";
|
|
@@ -20,7 +20,7 @@ import type { FetchFn, GatewayConfig, ProviderConfig } from "./types.js";
|
|
|
20
20
|
export interface ProxyDeps {
|
|
21
21
|
meter: MeterEmitter;
|
|
22
22
|
budgetChecker: IBudgetChecker;
|
|
23
|
-
creditLedger?:
|
|
23
|
+
creditLedger?: ILedger;
|
|
24
24
|
topUpUrl: string;
|
|
25
25
|
graceBufferCents?: number;
|
|
26
26
|
providers: ProviderConfig;
|
package/dist/gateway/types.d.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* /v1/... endpoints using WOPR service keys. The gateway authenticates,
|
|
6
6
|
* budget-checks, proxies to upstream providers, meters usage, and responds.
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type { ILedger } from "@wopr-network/platform-core/credits";
|
|
9
9
|
import type { MeterEmitter } from "@wopr-network/platform-core/metering";
|
|
10
10
|
import type { IRateLimitRepository } from "../api/rate-limit-repository.js";
|
|
11
11
|
import type { IBudgetChecker, SpendLimits } from "../monetization/budget/budget-checker.js";
|
|
@@ -103,7 +103,7 @@ export interface GatewayConfig {
|
|
|
103
103
|
/** BudgetChecker instance for pre-call budget validation */
|
|
104
104
|
budgetChecker: IBudgetChecker;
|
|
105
105
|
/** CreditLedger instance for deducting credits after proxy calls (optional — if absent, credit deduction is skipped) */
|
|
106
|
-
creditLedger?:
|
|
106
|
+
creditLedger?: ILedger;
|
|
107
107
|
/** URL to direct users to when they need to add credits (default: "/dashboard/credits") */
|
|
108
108
|
topUpUrl?: string;
|
|
109
109
|
/** Maximum negative credit balance (in cents) before hard-stop. Default: 50 (-$0.50). */
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
3
|
import { Credit } from "../credits/credit.js";
|
|
4
|
-
import {
|
|
4
|
+
import { DrizzleLedger } from "../credits/ledger.js";
|
|
5
5
|
import { usageSummaries } from "../db/schema/meter-events.js";
|
|
6
6
|
import { createTestDb, truncateAllTables } from "../test/db.js";
|
|
7
7
|
import { runReconciliation } from "./reconciliation-cron.js";
|
|
@@ -21,7 +21,7 @@ describe("runReconciliation", () => {
|
|
|
21
21
|
const t = await createTestDb();
|
|
22
22
|
pool = t.pool;
|
|
23
23
|
db = t.db;
|
|
24
|
-
ledger = new
|
|
24
|
+
ledger = new DrizzleLedger(db);
|
|
25
25
|
usageSummaryRepo = new DrizzleUsageSummaryRepository(db);
|
|
26
26
|
adapterUsageRepo = new DrizzleAdapterUsageRepository(db);
|
|
27
27
|
});
|
|
@@ -30,6 +30,7 @@ describe("runReconciliation", () => {
|
|
|
30
30
|
});
|
|
31
31
|
beforeEach(async () => {
|
|
32
32
|
await truncateAllTables(pool);
|
|
33
|
+
await ledger.seedSystemAccounts();
|
|
33
34
|
});
|
|
34
35
|
/** Insert a usage_summaries row directly. */
|
|
35
36
|
async function insertSummary(opts) {
|
|
@@ -57,7 +58,7 @@ describe("runReconciliation", () => {
|
|
|
57
58
|
const charge = Credit.fromCents(50);
|
|
58
59
|
await insertSummary({ tenant: "t1", totalCharge: charge.toRaw() });
|
|
59
60
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
60
|
-
await ledger.debit("t1", charge, "adapter_usage", "chat usage");
|
|
61
|
+
await ledger.debit("t1", charge, "adapter_usage", { description: "chat usage" });
|
|
61
62
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
62
63
|
expect(result.tenantsChecked).toBe(1);
|
|
63
64
|
expect(result.discrepancies).toEqual([]);
|
|
@@ -65,7 +66,7 @@ describe("runReconciliation", () => {
|
|
|
65
66
|
it("detects drift when metered charge exceeds ledger debit", async () => {
|
|
66
67
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(100).toRaw() });
|
|
67
68
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
68
|
-
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", "chat usage");
|
|
69
|
+
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", { description: "chat usage" });
|
|
69
70
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
70
71
|
expect(result.tenantsChecked).toBe(1);
|
|
71
72
|
expect(result.discrepancies).toHaveLength(1);
|
|
@@ -91,7 +92,7 @@ describe("runReconciliation", () => {
|
|
|
91
92
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(20).toRaw() });
|
|
92
93
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
93
94
|
// Debit as bot_runtime — should NOT count toward reconciliation
|
|
94
|
-
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", "daily runtime");
|
|
95
|
+
await ledger.debit("t1", Credit.fromCents(20), "bot_runtime", { description: "daily runtime" });
|
|
95
96
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
96
97
|
// Metered 20c, ledger adapter_usage = 0 => drift = 20c
|
|
97
98
|
expect(result.discrepancies).toHaveLength(1);
|
|
@@ -119,11 +120,11 @@ describe("runReconciliation", () => {
|
|
|
119
120
|
// t1: balanced
|
|
120
121
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(50).toRaw() });
|
|
121
122
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
122
|
-
await ledger.debit("t1", Credit.fromCents(50), "adapter_usage", "chat");
|
|
123
|
+
await ledger.debit("t1", Credit.fromCents(50), "adapter_usage", { description: "chat" });
|
|
123
124
|
// t2: drifted
|
|
124
125
|
await insertSummary({ tenant: "t2", totalCharge: Credit.fromCents(100).toRaw() });
|
|
125
126
|
await ledger.credit("t2", Credit.fromCents(500), "purchase");
|
|
126
|
-
await ledger.debit("t2", Credit.fromCents(60), "adapter_usage", "chat");
|
|
127
|
+
await ledger.debit("t2", Credit.fromCents(60), "adapter_usage", { description: "chat" });
|
|
127
128
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
128
129
|
expect(result.tenantsChecked).toBe(2);
|
|
129
130
|
expect(result.discrepancies).toHaveLength(1);
|
|
@@ -154,7 +155,7 @@ describe("runReconciliation", () => {
|
|
|
154
155
|
// Metered 50c but debited 80c (over-billed)
|
|
155
156
|
await insertSummary({ tenant: "t1", totalCharge: Credit.fromCents(50).toRaw() });
|
|
156
157
|
await ledger.credit("t1", Credit.fromCents(500), "purchase");
|
|
157
|
-
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", "chat usage");
|
|
158
|
+
await ledger.debit("t1", Credit.fromCents(80), "adapter_usage", { description: "chat usage" });
|
|
158
159
|
const result = await runReconciliation({ usageSummaryRepo, adapterUsageRepo, targetDate: TODAY });
|
|
159
160
|
expect(result.discrepancies).toHaveLength(1);
|
|
160
161
|
expect(result.discrepancies[0].driftRaw).toBe(Credit.fromCents(-30).toRaw());
|