diff --git a/src/gateway/proxy.ts b/src/gateway/proxy.ts index 748f12d5..1ab64dfc 100644 --- a/src/gateway/proxy.ts +++ b/src/gateway/proxy.ts @@ -29,7 +29,11 @@ import type { GatewayAuthEnv } from "./service-key-auth.js"; import { proxySSEStream } from "./streaming.js"; import type { FetchFn, GatewayConfig, ProviderConfig } from "./types.js"; -const DEFAULT_MARGIN = 1.3; +/** + * Fallback only used when resolveMargin is not provided (tests only). + * Production MUST provide resolveMargin — mountRoutes enforces this. + */ +const TEST_ONLY_MARGIN = 1.3; /** Max call duration cap: 4 hours = 240 minutes. */ const MAX_CALL_DURATION_MINUTES = 240; @@ -96,7 +100,10 @@ export function buildProxyDeps(config: GatewayConfig): ProxyDeps { providers: config.providers, defaultModel: config.defaultModel, resolveDefaultModel: config.resolveDefaultModel, - defaultMargin: config.defaultMargin ?? DEFAULT_MARGIN, + get defaultMargin() { + if (config.resolveMargin) return config.resolveMargin(); + return config.defaultMargin ?? TEST_ONLY_MARGIN; + }, fetchFn: config.fetchFn ?? fetch, arbitrageRouter: config.arbitrageRouter, rateLookupFn: config.rateLookupFn, diff --git a/src/gateway/types.ts b/src/gateway/types.ts index f497ea23..87f63a38 100644 --- a/src/gateway/types.ts +++ b/src/gateway/types.ts @@ -115,8 +115,10 @@ export interface GatewayConfig { graceBufferCents?: number; /** Upstream provider credentials */ providers: ProviderConfig; - /** Default margin multiplier (default: 1.3 = 30%) */ + /** Static margin (for tests only). Production should use resolveMargin. */ defaultMargin?: number; + /** Live margin resolver — called per-request, reads from DB. Takes priority over defaultMargin. */ + resolveMargin?: () => number; /** Optional arbitrage router for multi-provider cost optimization (WOP-463) */ arbitrageRouter?: import("../monetization/arbitrage/router.js").ArbitrageRouter; /** Injectable fetch for testing */ diff --git a/src/index.ts b/src/index.ts index 4997d29f..b1513c6c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,3 +48,7 @@ export * from "./tenancy/index.js"; // tRPC export * from "./trpc/index.js"; +// monorepo e2e cutover test +// hybrid dockerfile e2e +// sequential build test +// lockfile build diff --git a/src/server/__tests__/container.test.ts b/src/server/__tests__/container.test.ts index 6beb3bb0..4949c8db 100644 --- a/src/server/__tests__/container.test.ts +++ b/src/server/__tests__/container.test.ts @@ -169,6 +169,8 @@ describe("createTestContainer", () => { const gateway: GatewayServices = { serviceKeyRepo: {} as never, + meter: {} as never, + budgetChecker: {} as never, }; const hotPool: HotPoolServices = { @@ -189,7 +191,9 @@ describe("createTestContainer", () => { }); it("overrides merge without affecting other defaults", () => { - const c = createTestContainer({ gateway: { serviceKeyRepo: {} as never } }); + const c = createTestContainer({ + gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never }, + }); // Overridden field expect(c.gateway).not.toBeNull(); diff --git a/src/server/container.ts b/src/server/container.ts index 17fabe9e..54f77090 100644 --- a/src/server/container.ts +++ b/src/server/container.ts @@ -51,6 +51,8 @@ export interface StripeServices { export interface GatewayServices { serviceKeyRepo: IServiceKeyRepository; + meter: import("../metering/emitter.js").MeterEmitter; + budgetChecker: import("../monetization/budget/budget-checker.js").IBudgetChecker; } export interface HotPoolServices { @@ -239,8 +241,14 @@ export async function buildContainer(bootConfig: BootConfig): Promise { // 1. CORS middleware const origins = deriveCorsOrigins(container.productConfig.product, container.productConfig.domains); app.use( @@ -95,7 +95,45 @@ export function mountRoutes( ); } - // 6. Product-specific route plugins + // 6. Metered inference gateway (when gateway is enabled) + if (container.gateway) { + // Validate billing config exists in DB — fail hard, no silent defaults + const billingConfig = container.productConfig.billing; + const marginConfig = billingConfig?.marginConfig as { default?: number } | null; + if (!marginConfig?.default) { + throw new Error( + "Gateway enabled but product_billing_config.margin_config.default is not set. " + + "Seed the DB: INSERT INTO product_billing_config (product_id, margin_config) VALUES ('', '{\"default\": 4.0}')", + ); + } + + // Live margin — reads from productConfig per-request (DB-cached with TTL) + const initialMargin = marginConfig.default; + const resolveMargin = (): number => { + const cfg = container.productConfig.billing?.marginConfig as { default?: number } | null; + return cfg?.default ?? initialMargin; + }; + + const gw = container.gateway; + const { mountGateway } = await import("../gateway/index.js"); + mountGateway(app, { + meter: gw.meter, + budgetChecker: gw.budgetChecker, + creditLedger: container.creditLedger, + resolveMargin, + providers: { + openrouter: process.env.OPENROUTER_API_KEY + ? { apiKey: process.env.OPENROUTER_API_KEY, baseUrl: process.env.OPENROUTER_BASE_URL || undefined } + : undefined, + }, + resolveServiceKey: async (key: string) => { + const tenant = await gw.serviceKeyRepo.resolve(key); + return tenant ?? null; + }, + }); + } + + // 7. Product-specific route plugins for (const plugin of plugins) { app.route(plugin.path, plugin.handler(container)); } diff --git a/src/server/routes/__tests__/admin.test.ts b/src/server/routes/__tests__/admin.test.ts index 13a0c7e5..6528d498 100644 --- a/src/server/routes/__tests__/admin.test.ts +++ b/src/server/routes/__tests__/admin.test.ts @@ -173,6 +173,7 @@ describe("createAdminRouter", () => { }, serviceKeyRepo: {} as never, }, + gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never }, }); const caller = makeCaller(container); @@ -220,6 +221,7 @@ describe("createAdminRouter", () => { }, serviceKeyRepo: {} as never, }, + gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never }, }); const caller = makeCaller(container); @@ -273,9 +275,7 @@ describe("createAdminRouter", () => { const container = createTestContainer({ pool: mockPool as never, - gateway: { - serviceKeyRepo: {} as never, - }, + gateway: { serviceKeyRepo: {} as never, meter: {} as never, budgetChecker: {} as never }, }); const caller = makeCaller(container);