@wopr-network/platform-core 1.74.1 → 1.75.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -19,7 +19,11 @@ import { creditBalanceCheck, debitCredits } from "./credit-gate.js";
19
19
  import { mapBudgetError, mapProviderError } from "./error-mapping.js";
20
20
  import { resolveTokenRates } from "./rate-lookup.js";
21
21
  import { proxySSEStream } from "./streaming.js";
22
- const DEFAULT_MARGIN = 1.3;
22
+ /**
23
+ * Fallback only used when resolveMargin is not provided (tests only).
24
+ * Production MUST provide resolveMargin — mountRoutes enforces this.
25
+ */
26
+ const TEST_ONLY_MARGIN = 1.3;
23
27
  /** Max call duration cap: 4 hours = 240 minutes. */
24
28
  const MAX_CALL_DURATION_MINUTES = 240;
25
29
  const phoneInboundBodySchema = z.object({
@@ -54,7 +58,11 @@ export function buildProxyDeps(config) {
54
58
  providers: config.providers,
55
59
  defaultModel: config.defaultModel,
56
60
  resolveDefaultModel: config.resolveDefaultModel,
57
- defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN,
61
+ get defaultMargin() {
62
+ if (config.resolveMargin)
63
+ return config.resolveMargin();
64
+ return config.defaultMargin ?? TEST_ONLY_MARGIN;
65
+ },
58
66
  fetchFn: config.fetchFn ?? fetch,
59
67
  arbitrageRouter: config.arbitrageRouter,
60
68
  rateLookupFn: config.rateLookupFn,
@@ -119,8 +119,10 @@ export interface GatewayConfig {
119
119
  graceBufferCents?: number;
120
120
  /** Upstream provider credentials */
121
121
  providers: ProviderConfig;
122
- /** Default margin multiplier (default: 1.3 = 30%) */
122
+ /** Static margin (for tests only). Production should use resolveMargin. */
123
123
  defaultMargin?: number;
124
+ /** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */
125
+ resolveMargin?: () => number;
124
126
  /** Optional arbitrage router for multi-provider cost optimization (WOP-463) */
125
127
  arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
126
128
  /** Injectable fetch for testing */
package/dist/index.js CHANGED
@@ -24,3 +24,7 @@ export * from "./security/index.js";
24
24
  export * from "./tenancy/index.js";
25
25
  // tRPC
26
26
  export * from "./trpc/index.js";
27
+ // monorepo e2e cutover test
28
+ // hybrid dockerfile e2e
29
+ // sequential build test
30
+ // lockfile build
@@ -142,6 +142,8 @@ describe("createTestContainer", () => {
142
142
  };
143
143
  const gateway = {
144
144
  serviceKeyRepo: {},
145
+ meter: {},
146
+ budgetChecker: {},
145
147
  };
146
148
  const hotPool = {
147
149
  start: async () => ({ stop: () => { } }),
@@ -158,7 +160,9 @@ describe("createTestContainer", () => {
158
160
  expect(c.hotPool).not.toBeNull();
159
161
  });
160
162
  it("overrides merge without affecting other defaults", () => {
161
- const c = createTestContainer({ gateway: { serviceKeyRepo: {} } });
163
+ const c = createTestContainer({
164
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
165
+ });
162
166
  // Overridden field
163
167
  expect(c.gateway).not.toBeNull();
164
168
  // Other feature services remain null
@@ -44,6 +44,8 @@ export interface StripeServices {
44
44
  }
45
45
  export interface GatewayServices {
46
46
  serviceKeyRepo: IServiceKeyRepository;
47
+ meter: import("../metering/emitter.js").MeterEmitter;
48
+ budgetChecker: import("../monetization/budget/budget-checker.js").IBudgetChecker;
47
49
  }
48
50
  export interface HotPoolServices {
49
51
  /** Start the pool manager (replenish loop + cleanup). */
@@ -126,8 +126,13 @@ export async function buildContainer(bootConfig) {
126
126
  let gateway = null;
127
127
  if (bootConfig.features.gateway) {
128
128
  const { DrizzleServiceKeyRepository } = await import("../gateway/service-key-repository.js");
129
+ const { MeterEmitter } = await import("../metering/emitter.js");
130
+ const { DrizzleMeterEventRepository } = await import("../metering/meter-event-repository.js");
131
+ const { DrizzleBudgetChecker } = await import("../monetization/budget/budget-checker.js");
129
132
  const serviceKeyRepo = new DrizzleServiceKeyRepository(db);
130
- gateway = { serviceKeyRepo };
133
+ const meter = new MeterEmitter(new DrizzleMeterEventRepository(db), { flushIntervalMs: 5_000 });
134
+ const budgetChecker = new DrizzleBudgetChecker(db, { cacheTtlMs: 30_000 });
135
+ gateway = { serviceKeyRepo, meter, budgetChecker };
131
136
  }
132
137
  // 12. Build the container (hotPool bound after construction)
133
138
  const result = {
@@ -37,7 +37,7 @@ export { createTestContainer } from "./test-container.js";
37
37
  export async function bootPlatformServer(config) {
38
38
  const container = await buildContainer(config);
39
39
  const app = new Hono();
40
- mountRoutes(app, container, {
40
+ await mountRoutes(app, container, {
41
41
  provisionSecret: config.provisionSecret,
42
42
  cryptoServiceKey: config.cryptoServiceKey,
43
43
  platformDomain: container.productConfig.product?.domain ?? "localhost",
@@ -27,4 +27,4 @@ export interface MountConfig {
27
27
  * 6. Product-specific route plugins
28
28
  * 7. Tenant proxy middleware (catch-all — must be last)
29
29
  */
30
- export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): void;
30
+ export declare function mountRoutes(app: Hono, container: PlatformContainer, config: MountConfig, plugins?: RoutePlugin[]): Promise<void>;
@@ -28,7 +28,7 @@ import { createStripeWebhookRoutes } from "./routes/stripe-webhook.js";
28
28
  * 6. Product-specific route plugins
29
29
  * 7. Tenant proxy middleware (catch-all — must be last)
30
30
  */
31
- export function mountRoutes(app, container, config, plugins = []) {
31
+ export async function mountRoutes(app, container, config, plugins = []) {
32
32
  // 1. CORS middleware
33
33
  const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
34
34
  app.use("*", cors({
@@ -60,7 +60,40 @@ export function mountRoutes(app, container, config, plugins = []) {
60
60
  maxInstancesPerTenant: fleetConfig?.maxInstances ?? 5,
61
61
  }));
62
62
  }
63
- // 6. Product-specific route plugins
63
+ // 6. Metered inference gateway (when gateway is enabled)
64
+ if (container.gateway) {
65
+ // Validate billing config exists in DB — fail hard, no silent defaults
66
+ const billingConfig = container.productConfig.billing;
67
+ const marginConfig = billingConfig?.marginConfig;
68
+ if (!marginConfig?.default) {
69
+ throw new Error("Gateway enabled but product_billing_config.margin_config.default is not set. " +
70
+ "Seed the DB: INSERT INTO product_billing_config (product_id, margin_config) VALUES ('<id>', '{\"default\": 4.0}')");
71
+ }
72
+ // Live margin — reads from productConfig per-request (DB-cached with TTL)
73
+ const initialMargin = marginConfig.default;
74
+ const resolveMargin = () => {
75
+ const cfg = container.productConfig.billing?.marginConfig;
76
+ return cfg?.default ?? initialMargin;
77
+ };
78
+ const gw = container.gateway;
79
+ const { mountGateway } = await import("../gateway/index.js");
80
+ mountGateway(app, {
81
+ meter: gw.meter,
82
+ budgetChecker: gw.budgetChecker,
83
+ creditLedger: container.creditLedger,
84
+ resolveMargin,
85
+ providers: {
86
+ openrouter: process.env.OPENROUTER_API_KEY
87
+ ? { apiKey: process.env.OPENROUTER_API_KEY, baseUrl: process.env.OPENROUTER_BASE_URL || undefined }
88
+ : undefined,
89
+ },
90
+ resolveServiceKey: async (key) => {
91
+ const tenant = await gw.serviceKeyRepo.resolve(key);
92
+ return tenant ?? null;
93
+ },
94
+ });
95
+ }
96
+ // 7. Product-specific route plugins
64
97
  for (const plugin of plugins) {
65
98
  app.route(plugin.path, plugin.handler(container));
66
99
  }
@@ -144,6 +144,7 @@ describe("createAdminRouter", () => {
144
144
  },
145
145
  serviceKeyRepo: {},
146
146
  },
147
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
147
148
  });
148
149
  const caller = makeCaller(container);
149
150
  const result = await caller.admin.listAllInstances();
@@ -185,6 +186,7 @@ describe("createAdminRouter", () => {
185
186
  },
186
187
  serviceKeyRepo: {},
187
188
  },
189
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
188
190
  });
189
191
  const caller = makeCaller(container);
190
192
  const result = await caller.admin.listAllInstances();
@@ -229,9 +231,7 @@ describe("createAdminRouter", () => {
229
231
  };
230
232
  const container = createTestContainer({
231
233
  pool: mockPool,
232
- gateway: {
233
- serviceKeyRepo: {},
234
- },
234
+ gateway: { serviceKeyRepo: {}, meter: {}, budgetChecker: {} },
235
235
  });
236
236
  const caller = makeCaller(container);
237
237
  const result = await caller.admin.billingOverview();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.74.1",
3
+ "version": "1.75.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -29,7 +29,11 @@ import type { GatewayAuthEnv } from "./service-key-auth.js";
29
29
  import { proxySSEStream } from "./streaming.js";
30
30
  import type { FetchFn, GatewayConfig, ProviderConfig } from "./types.js";
31
31
 
32
- const DEFAULT_MARGIN = 1.3;
32
+ /**
33
+ * Fallback only used when resolveMargin is not provided (tests only).
34
+ * Production MUST provide resolveMargin — mountRoutes enforces this.
35
+ */
36
+ const TEST_ONLY_MARGIN = 1.3;
33
37
 
34
38
  /** Max call duration cap: 4 hours = 240 minutes. */
35
39
  const MAX_CALL_DURATION_MINUTES = 240;
@@ -96,7 +100,10 @@ export function buildProxyDeps(config: GatewayConfig): ProxyDeps {
96
100
  providers: config.providers,
97
101
  defaultModel: config.defaultModel,
98
102
  resolveDefaultModel: config.resolveDefaultModel,
99
- defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN,
103
+ get defaultMargin() {
104
+ if (config.resolveMargin) return config.resolveMargin();
105
+ return config.defaultMargin ?? TEST_ONLY_MARGIN;
106
+ },
100
107
  fetchFn: config.fetchFn ?? fetch,
101
108
  arbitrageRouter: config.arbitrageRouter,
102
109
  rateLookupFn: config.rateLookupFn,
@@ -115,8 +115,10 @@ export interface GatewayConfig {
115
115
  graceBufferCents?: number;
116
116
  /** Upstream provider credentials */
117
117
  providers: ProviderConfig;
118
- /** Default margin multiplier (default: 1.3 = 30%) */
118
+ /** Static margin (for tests only). Production should use resolveMargin. */
119
119
  defaultMargin?: number;
120
+ /** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */
121
+ resolveMargin?: () => number;
120
122
  /** Optional arbitrage router for multi-provider cost optimization (WOP-463) */
121
123
  arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter;
122
124
  /** Injectable fetch for testing */
package/src/index.ts CHANGED
@@ -48,3 +48,7 @@ export * from "./tenancy/index.js";
48
48
 
49
49
  // tRPC
50
50
  export * from "./trpc/index.js";
51
+ // monorepo e2e cutover test
52
+ // hybrid dockerfile e2e
53
+ // sequential build test
54
+ // lockfile build
@@ -169,6 +169,8 @@ describe("createTestContainer", () => {
169
169
 
170
170
  const gateway: GatewayServices = {
171
171
  serviceKeyRepo: {} as never,
172
+ meter: {} as never,
173
+ budgetChecker: {} as never,
172
174
  };
173
175
 
174
176
  const hotPool: HotPoolServices = {
@@ -189,7 +191,9 @@ describe("createTestContainer", () => {
189
191
  });
190
192
 
191
193
  it("overrides merge without affecting other defaults", () => {
192
- const c = createTestContainer({ gateway: { serviceKeyRepo: {} as never } });
194
+ const c = createTestContainer({
195
+ gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never },
196
+ });
193
197
 
194
198
  // Overridden field
195
199
  expect(c.gateway).not.toBeNull();
@@ -51,6 +51,8 @@ export interface StripeServices {
51
51
 
52
52
  export interface GatewayServices {
53
53
  serviceKeyRepo: IServiceKeyRepository;
54
+ meter: import("../metering/emitter.js").MeterEmitter;
55
+ budgetChecker: import("../monetization/budget/budget-checker.js").IBudgetChecker;
54
56
  }
55
57
 
56
58
  export interface HotPoolServices {
@@ -239,8 +241,14 @@ export async function buildContainer(bootConfig: BootConfig): Promise<PlatformCo
239
241
  let gateway: GatewayServices | null = null;
240
242
  if (bootConfig.features.gateway) {
241
243
  const { DrizzleServiceKeyRepository } = await import("../gateway/service-key-repository.js");
244
+ const { MeterEmitter } = await import("../metering/emitter.js");
245
+ const { DrizzleMeterEventRepository } = await import("../metering/meter-event-repository.js");
246
+ const { DrizzleBudgetChecker } = await import("../monetization/budget/budget-checker.js");
247
+
242
248
  const serviceKeyRepo: IServiceKeyRepository = new DrizzleServiceKeyRepository(db as never);
243
- gateway = { serviceKeyRepo };
249
+ const meter = new MeterEmitter(new DrizzleMeterEventRepository(db as never), { flushIntervalMs: 5_000 });
250
+ const budgetChecker = new DrizzleBudgetChecker(db as never, { cacheTtlMs: 30_000 });
251
+ gateway = { serviceKeyRepo, meter, budgetChecker };
244
252
  }
245
253
 
246
254
  // 12. Build the container (hotPool bound after construction)
@@ -56,7 +56,7 @@ export async function bootPlatformServer(config: BootConfig): Promise<BootResult
56
56
  const container = await buildContainer(config);
57
57
  const app = new Hono();
58
58
 
59
- mountRoutes(
59
+ await mountRoutes(
60
60
  app,
61
61
  container,
62
62
  {
@@ -44,12 +44,12 @@ export interface MountConfig {
44
44
  * 6. Product-specific route plugins
45
45
  * 7. Tenant proxy middleware (catch-all — must be last)
46
46
  */
47
- export function mountRoutes(
47
+ export async function mountRoutes(
48
48
  app: Hono,
49
49
  container: PlatformContainer,
50
50
  config: MountConfig,
51
51
  plugins: RoutePlugin[] = [],
52
- ): void {
52
+ ): Promise<void> {
53
53
  // 1. CORS middleware
54
54
  const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains);
55
55
  app.use(
@@ -95,7 +95,45 @@ export function mountRoutes(
95
95
  );
96
96
  }
97
97
 
98
- // 6. Product-specific route plugins
98
+ // 6. Metered inference gateway (when gateway is enabled)
99
+ if (container.gateway) {
100
+ // Validate billing config exists in DB — fail hard, no silent defaults
101
+ const billingConfig = container.productConfig.billing;
102
+ const marginConfig = billingConfig?.marginConfig as { default?: number } | null;
103
+ if (!marginConfig?.default) {
104
+ throw new Error(
105
+ "Gateway enabled but product_billing_config.margin_config.default is not set. " +
106
+ "Seed the DB: INSERT INTO product_billing_config (product_id, margin_config) VALUES ('<id>', '{\"default\": 4.0}')",
107
+ );
108
+ }
109
+
110
+ // Live margin — reads from productConfig per-request (DB-cached with TTL)
111
+ const initialMargin = marginConfig.default;
112
+ const resolveMargin = (): number => {
113
+ const cfg = container.productConfig.billing?.marginConfig as { default?: number } | null;
114
+ return cfg?.default ?? initialMargin;
115
+ };
116
+
117
+ const gw = container.gateway;
118
+ const { mountGateway } = await import("../gateway/index.js");
119
+ mountGateway(app, {
120
+ meter: gw.meter,
121
+ budgetChecker: gw.budgetChecker,
122
+ creditLedger: container.creditLedger,
123
+ resolveMargin,
124
+ providers: {
125
+ openrouter: process.env.OPENROUTER_API_KEY
126
+ ? { apiKey: process.env.OPENROUTER_API_KEY, baseUrl: process.env.OPENROUTER_BASE_URL || undefined }
127
+ : undefined,
128
+ },
129
+ resolveServiceKey: async (key: string) => {
130
+ const tenant = await gw.serviceKeyRepo.resolve(key);
131
+ return tenant ?? null;
132
+ },
133
+ });
134
+ }
135
+
136
+ // 7. Product-specific route plugins
99
137
  for (const plugin of plugins) {
100
138
  app.route(plugin.path, plugin.handler(container));
101
139
  }
@@ -173,6 +173,7 @@ describe("createAdminRouter", () => {
173
173
  },
174
174
  serviceKeyRepo: {} as never,
175
175
  },
176
+ gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never },
176
177
  });
177
178
 
178
179
  const caller = makeCaller(container);
@@ -220,6 +221,7 @@ describe("createAdminRouter", () => {
220
221
  },
221
222
  serviceKeyRepo: {} as never,
222
223
  },
224
+ gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never },
223
225
  });
224
226
 
225
227
  const caller = makeCaller(container);
@@ -273,9 +275,7 @@ describe("createAdminRouter", () => {
273
275
 
274
276
  const container = createTestContainer({
275
277
  pool: mockPool as never,
276
- gateway: {
277
- serviceKeyRepo: {} as never,
278
- },
278
+ gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never },
279
279
  });
280
280
 
281
281
  const caller = makeCaller(container);