From 6820a95bca09cd1c236ddebcf274d4aa28c380c9 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 2 Mar 2026 18:18:19 -0300 Subject: [PATCH 1/2] fix(deco-ai-gateway): reconcile billing mode for postpaid limit updates Persist limit period semantics and allow postpaid limit updates to honor explicit billing mode so legacy prepaid rows do not block limit reductions. Made-with: Cursor --- deco-ai-gateway/server/db/mcps.code-workspace | 11 +++ deco-ai-gateway/server/db/schema.sql | 28 ++++++- deco-ai-gateway/server/lib/config-cache.ts | 7 +- .../server/lib/confirm-payment-service.ts | 4 +- deco-ai-gateway/server/lib/provisioning.ts | 23 +++--- deco-ai-gateway/server/lib/supabase-client.ts | 8 ++ deco-ai-gateway/server/tools/credits.ts | 35 +++++++- deco-ai-gateway/server/tools/index.ts | 6 +- deco-ai-gateway/server/tools/set-limit.ts | 82 ++++++++++++++----- deco-ai-gateway/server/tools/usage.ts | 75 ++++++++++++----- 10 files changed, 213 insertions(+), 66 deletions(-) create mode 100644 deco-ai-gateway/server/db/mcps.code-workspace diff --git a/deco-ai-gateway/server/db/mcps.code-workspace b/deco-ai-gateway/server/db/mcps.code-workspace new file mode 100644 index 00000000..a1485d8f --- /dev/null +++ b/deco-ai-gateway/server/db/mcps.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "../../.." + }, + { + "path": "../../../../mesh" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/deco-ai-gateway/server/db/schema.sql b/deco-ai-gateway/server/db/schema.sql index 79b42254..50cf6d1a 100644 --- a/deco-ai-gateway/server/db/schema.sql +++ b/deco-ai-gateway/server/db/schema.sql @@ -50,7 +50,8 @@ CREATE TABLE IF NOT EXISTS llm_gateway_connections ( encryption_iv TEXT, -- 12-byte Initialization Vector (hex) encryption_tag TEXT, -- 16-byte auth tag for integrity verification (hex) billing_mode TEXT NOT NULL DEFAULT 'prepaid', -- 'prepaid' (buy credits) or 'postpaid' (pay per use) - is_subscription BOOLEAN NOT NULL DEFAULT FALSE, -- FALSE = wallet (credit never resets), TRUE = subscription (resets monthly) + is_subscription BOOLEAN NOT NULL DEFAULT FALSE, -- Kept for backwards compat; use limit_period instead + limit_period TEXT CHECK (limit_period IN ('daily', 'weekly', 'monthly')), -- NULL = no reset, 'monthly' etc = OpenRouter auto-reset usage_markup_pct NUMERIC(5,2) NOT NULL DEFAULT 15, -- Surcharge % on top of OpenRouter cost (e.g. 30 = 30%) max_limit_usd NUMERIC(10,4) DEFAULT NULL, -- Maximum spending limit cap (NULL = no cap) alert_enabled BOOLEAN NOT NULL DEFAULT FALSE, -- Whether low-balance email alerts are on @@ -324,6 +325,31 @@ BEGIN END IF; END $$; +-- Migration: Add limit_period column (replaces is_subscription logic) +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'llm_gateway_connections' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'llm_gateway_connections' + AND column_name = 'limit_period' + ) THEN + ALTER TABLE llm_gateway_connections + ADD COLUMN limit_period TEXT CHECK (limit_period IN ('daily', 'weekly', 'monthly')); + + -- Migrate existing is_subscription=true rows to limit_period='monthly' + UPDATE llm_gateway_connections + SET limit_period = 'monthly' + WHERE is_subscription = TRUE AND limit_period IS NULL; + + RAISE NOTICE 'Migration: Added column limit_period and migrated is_subscription data'; + END IF; +END $$; + -- Migration: Add organization_id index to payments (if not exists) CREATE INDEX IF NOT EXISTS idx_llm_gw_pay_org ON llm_gateway_payments(organization_id); diff --git a/deco-ai-gateway/server/lib/config-cache.ts b/deco-ai-gateway/server/lib/config-cache.ts index 91c2ec3e..5b674aa5 100644 --- a/deco-ai-gateway/server/lib/config-cache.ts +++ b/deco-ai-gateway/server/lib/config-cache.ts @@ -5,6 +5,7 @@ import { isSupabaseConfigured, type LlmGatewayConnectionRow, type BillingMode, + type LimitPeriod, } from "./supabase-client.ts"; import { logger } from "./logger.ts"; import { DEFAULT_MARKUP_PCT } from "./constants.ts"; @@ -80,12 +81,13 @@ export async function saveApiKey(params: { openrouterKeyName: string; openrouterKeyHash: string; billingMode?: BillingMode; - isSubscription?: boolean; + limitPeriod?: LimitPeriod | null; usageMarkupPct?: number; maxLimitUsd?: number | null; }): Promise { const { ciphertext, iv, tag } = encrypt(params.apiKey); const now = new Date().toISOString(); + const limitPeriod = params.limitPeriod ?? null; const row: LlmGatewayConnectionRow = { connection_id: params.connectionId, @@ -97,7 +99,8 @@ export async function saveApiKey(params: { encryption_iv: iv, encryption_tag: tag, billing_mode: params.billingMode ?? "prepaid", - is_subscription: params.isSubscription ?? false, + is_subscription: limitPeriod === "monthly", + limit_period: limitPeriod, usage_markup_pct: params.usageMarkupPct ?? DEFAULT_MARKUP_PCT, max_limit_usd: params.maxLimitUsd ?? null, alert_enabled: false, diff --git a/deco-ai-gateway/server/lib/confirm-payment-service.ts b/deco-ai-gateway/server/lib/confirm-payment-service.ts index 8497338a..a67eabd0 100644 --- a/deco-ai-gateway/server/lib/confirm-payment-service.ts +++ b/deco-ai-gateway/server/lib/confirm-payment-service.ts @@ -72,11 +72,11 @@ export async function confirmPaymentForConnection( }; } - const isSubscription = row.is_subscription ?? false; + const limitPeriod = row.limit_period ?? null; await updateKeyLimit( row.openrouter_key_hash, payment.new_limit_usd, - isSubscription ? "monthly" : null, + limitPeriod, false, ); await markPaymentCompleted(payment.id); diff --git a/deco-ai-gateway/server/lib/provisioning.ts b/deco-ai-gateway/server/lib/provisioning.ts index f5eab473..d82ff5c4 100644 --- a/deco-ai-gateway/server/lib/provisioning.ts +++ b/deco-ai-gateway/server/lib/provisioning.ts @@ -3,6 +3,7 @@ import { isSupabaseConfigured, findExistingOrgConnection, type BillingMode, + type LimitPeriod, } from "./supabase-client.ts"; import { decrypt } from "./encryption.ts"; import { updateKeyLimit, type LimitReset } from "./openrouter-keys.ts"; @@ -61,7 +62,7 @@ async function createOpenRouterKey( organizationId: string, organizationName: string | undefined, billingMode: BillingMode, - isSubscription: boolean, + limitPeriod: LimitPeriod | null, ): Promise<{ key: string; hash: string; name: string }> { const managementKey = process.env.OPENROUTER_MANAGEMENT_KEY; if (!managementKey) { @@ -90,7 +91,7 @@ async function createOpenRouterKey( billingMode === "prepaid" ? DEFAULT_LIMIT_USD : DEFAULT_POSTPAID_LIMIT_USD, - limit_reset: isSubscription ? "monthly" : undefined, + limit_reset: limitPeriod ?? undefined, }), }); @@ -140,7 +141,7 @@ async function provisionOrReuseKey( meshUrl: string, organizationName: string | undefined, billingMode: BillingMode, - isSubscription: boolean, + limitPeriod: LimitPeriod | null, ): Promise { const existingOrgLock = orgProvisioningLocks.get(organizationId); if (existingOrgLock) { @@ -157,7 +158,7 @@ async function provisionOrReuseKey( meshUrl, organizationName, billingMode, - isSubscription, + limitPeriod, ); } @@ -195,7 +196,7 @@ async function provisionOrReuseKey( openrouterKeyName: existingOrgRow.openrouter_key_name ?? "", openrouterKeyHash: existingOrgRow.openrouter_key_hash ?? "", billingMode: existingOrgRow.billing_mode, - isSubscription: existingOrgRow.is_subscription, + limitPeriod: existingOrgRow.limit_period ?? null, usageMarkupPct: existingOrgRow.usage_markup_pct, maxLimitUsd: existingOrgRow.max_limit_usd, }); @@ -215,10 +216,10 @@ async function provisionOrReuseKey( organizationId, organizationName, billingMode, - isSubscription, + limitPeriod, ); - const limitReset: LimitReset | null = isSubscription ? "monthly" : null; + const limitReset: LimitReset | null = limitPeriod ?? null; const defaultLimit = billingMode === "prepaid" ? DEFAULT_LIMIT_USD @@ -229,7 +230,7 @@ async function provisionOrReuseKey( keyHash: hash, billingMode, limitUsd: defaultLimit, - isSubscription, + limitPeriod, }); const limitResult = await updateKeyLimit( hash, @@ -255,7 +256,7 @@ async function provisionOrReuseKey( openrouterKeyName: name, openrouterKeyHash: hash, billingMode, - isSubscription, + limitPeriod, }); logger.info("Key provisioned and persisted", { @@ -289,7 +290,7 @@ export async function ensureApiKey( meshUrl: string, organizationName?: string, billingMode: BillingMode = "prepaid", - isSubscription = false, + limitPeriod: LimitPeriod | null = null, ): Promise { logger.debug("ensureApiKey called", { connectionId, organizationId }); @@ -337,7 +338,7 @@ export async function ensureApiKey( meshUrl, organizationName, billingMode, - isSubscription, + limitPeriod, ); } catch (error) { logger.error("Failed to provision OpenRouter API key", { diff --git a/deco-ai-gateway/server/lib/supabase-client.ts b/deco-ai-gateway/server/lib/supabase-client.ts index 929175c7..d96cfc06 100644 --- a/deco-ai-gateway/server/lib/supabase-client.ts +++ b/deco-ai-gateway/server/lib/supabase-client.ts @@ -2,6 +2,7 @@ import { createClient, type SupabaseClient } from "@supabase/supabase-js"; import { logger } from "./logger.ts"; export type BillingMode = "prepaid" | "postpaid"; +export type LimitPeriod = "daily" | "weekly" | "monthly"; export interface LlmGatewayConnectionRow { connection_id: string; @@ -14,6 +15,7 @@ export interface LlmGatewayConnectionRow { encryption_tag: string | null; billing_mode: BillingMode; is_subscription: boolean; + limit_period: LimitPeriod | null; usage_markup_pct: number; max_limit_usd: number | null; alert_enabled: boolean; @@ -177,6 +179,7 @@ export async function deleteConnectionConfig( export interface BillingConfig { billingMode: BillingMode; isSubscription: boolean; + limitPeriod: LimitPeriod | null; usageMarkupPct: number; maxLimitUsd: number | null; } @@ -199,6 +202,11 @@ export async function updateBillingConfig( if (config.isSubscription !== undefined) { updates.is_subscription = config.isSubscription; } + if ("limitPeriod" in config) { + updates.limit_period = config.limitPeriod ?? null; + // Keep is_subscription in sync for backwards compat + updates.is_subscription = config.limitPeriod === "monthly"; + } if (config.usageMarkupPct !== undefined) { updates.usage_markup_pct = config.usageMarkupPct; } diff --git a/deco-ai-gateway/server/tools/credits.ts b/deco-ai-gateway/server/tools/credits.ts index 0477f5a1..9439f59d 100644 --- a/deco-ai-gateway/server/tools/credits.ts +++ b/deco-ai-gateway/server/tools/credits.ts @@ -10,20 +10,33 @@ export const createGatewayCreditsTool = (env: Env) => createTool({ id: "GATEWAY_CREDITS", description: - "Returns the available credit balance for this organization's AI Gateway. " + - "Use this to check how much credit remains before making LLM calls.", + "Returns the current balance or usage for this organization's AI Gateway. " + + "For prepaid mode: shows available credit. " + + "For postpaid mode: shows usage vs limit (if set) or raw usage.", inputSchema: z.object({}).strict(), outputSchema: z .object({ available: z .number() .nullable() - .describe("Remaining credit balance in USD (null = unlimited)"), + .describe( + "Remaining credit in USD (prepaid) or remaining limit headroom (postpaid with limit). Null = unlimited.", + ), total: z .number() .nullable() - .describe("Total credit limit in USD (null = unlimited)"), + .describe("Total credit/limit in USD (null = unlimited)"), used: z.number().describe("Total amount spent in USD"), + percentUsed: z + .number() + .nullable() + .describe( + "Percentage of limit used (0-100). Null when no limit is set.", + ), + limitPeriod: z + .enum(["daily", "weekly", "monthly"]) + .nullable() + .describe("Reset period for the limit (null = no reset / wallet)"), billingMode: z .enum(["prepaid", "postpaid"]) .describe("Billing mode for this organization"), @@ -48,6 +61,8 @@ export const createGatewayCreditsTool = (env: Env) => available: DEFAULT_LIMIT_USD, total: DEFAULT_LIMIT_USD, used: 0, + percentUsed: 0, + limitPeriod: null, billingMode: (row?.billing_mode ?? "prepaid") as | "prepaid" | "postpaid", @@ -59,11 +74,23 @@ export const createGatewayCreditsTool = (env: Env) => const billingMode = (row.billing_mode ?? "prepaid") as | "prepaid" | "postpaid"; + const limitPeriod = (row.limit_period ?? null) as + | "daily" + | "weekly" + | "monthly" + | null; + + const percentUsed = + d.limit != null && d.limit > 0 + ? Math.min(100, Math.round((d.usage / d.limit) * 100 * 10) / 10) + : null; return { available: d.limit_remaining, total: d.limit, used: d.usage, + percentUsed, + limitPeriod, billingMode, keyDisabled: d.disabled, }; diff --git a/deco-ai-gateway/server/tools/index.ts b/deco-ai-gateway/server/tools/index.ts index c2c6b741..dfa001d5 100644 --- a/deco-ai-gateway/server/tools/index.ts +++ b/deco-ai-gateway/server/tools/index.ts @@ -28,7 +28,7 @@ async function ensureKeyLimitMatchesBillingMode( if (!verifiedConnections.has(connectionId)) { const billingMode = row.billing_mode ?? "prepaid"; - const isSubscription = row.is_subscription ?? false; + const limitPeriod = row.limit_period ?? null; const details = await getKeyDetails(row.openrouter_key_hash); const expectedDefault = @@ -41,12 +41,12 @@ async function ensureKeyLimitMatchesBillingMode( connectionId, billingMode, defaultLimit: expectedDefault, - isSubscription, + limitPeriod, }); await updateKeyLimit( row.openrouter_key_hash, expectedDefault, - isSubscription ? "monthly" : null, + limitPeriod, false, ); } diff --git a/deco-ai-gateway/server/tools/set-limit.ts b/deco-ai-gateway/server/tools/set-limit.ts index 154c4be7..14bedf04 100644 --- a/deco-ai-gateway/server/tools/set-limit.ts +++ b/deco-ai-gateway/server/tools/set-limit.ts @@ -5,6 +5,9 @@ import { loadPendingPayment, savePendingPayment, markPaymentExpired, + updateBillingConfig, + type BillingMode, + type LimitPeriod, } from "../lib/supabase-client.ts"; import { getKeyDetails, updateKeyLimit } from "../lib/openrouter-keys.ts"; import { @@ -51,6 +54,7 @@ const outputSchema = z .object({ summary: z.string(), billing_mode: z.enum(["prepaid", "postpaid"]), + limit_period: z.enum(["daily", "weekly", "monthly"]).nullable(), checkout_url: z.string().nullable(), amount_usd: z.number().nullable(), markup_pct: z.number(), @@ -74,7 +78,19 @@ export const createSetLimitTool = (env: Env) => .number() .positive() .describe( - "Desired new spending limit in USD. Must be greater than the current limit. Examples: 5, 10, 50", + "Desired new spending limit in USD. In prepaid mode must be greater than the current limit. In postpaid mode can be set to any positive value. Examples: 5, 10, 50", + ), + limit_period: z + .enum(["daily", "weekly", "monthly", "none"]) + .optional() + .describe( + "Limit reset period. Only applies to postpaid mode. 'none' removes the reset. Omit to keep the current period.", + ), + billing_mode: z + .enum(["prepaid", "postpaid"]) + .optional() + .describe( + "Optional explicit billing mode. Useful for migrating legacy connections where DB mode is stale.", ), return_url: z .string() @@ -87,7 +103,12 @@ export const createSetLimitTool = (env: Env) => .strict(), outputSchema, execute: async ({ context }) => { - const { limit_usd: newLimit, return_url: returnUrl } = context; + const { + limit_usd: newLimit, + limit_period: limitPeriodInput, + billing_mode: billingModeInput, + return_url: returnUrl, + } = context; const connectionId = env.MESH_REQUEST_CONTEXT?.connectionId; const organizationId = env.MESH_REQUEST_CONTEXT?.organizationId; @@ -108,31 +129,42 @@ export const createSetLimitTool = (env: Env) => const keyDetails = await getKeyDetails(row.openrouter_key_hash); const currentLimit = keyDetails.limit ?? 0; - if (newLimit <= currentLimit) { - throw new Error( - `New limit ($${newLimit.toFixed(2)}) must be greater than current limit ($${currentLimit.toFixed(2)}).`, - ); - } - const effectiveCap = row.max_limit_usd ?? HARD_CAP_USD; if (newLimit > effectiveCap) { throw new Error( `New limit ($${newLimit.toFixed(2)}) exceeds the maximum allowed ($${effectiveCap.toFixed(2)}).`, ); } - - const billingMode = row.billing_mode ?? "prepaid"; const markupPct = row.usage_markup_pct ?? 0; - const isSubscription = row.is_subscription ?? false; - if (billingMode === "postpaid") { + // Resolve limit_period: explicit input overrides DB value; "none" clears it + const currentLimitPeriod = row.limit_period ?? null; + const resolvedLimitPeriod: LimitPeriod | null = + limitPeriodInput === undefined + ? currentLimitPeriod + : limitPeriodInput === "none" + ? null + : (limitPeriodInput as LimitPeriod); + + const billingModeFromDb = row.billing_mode ?? "prepaid"; + const effectiveBillingMode: BillingMode = + billingModeInput ?? + (limitPeriodInput !== undefined ? "postpaid" : billingModeFromDb); + + if (effectiveBillingMode === "prepaid" && newLimit <= currentLimit) { + throw new Error( + `New limit ($${newLimit.toFixed(2)}) must be greater than current limit ($${currentLimit.toFixed(2)}).`, + ); + } + + if (effectiveBillingMode === "postpaid") { return handlePostpaid( row.openrouter_key_hash, currentLimit, newLimit, connectionId, markupPct, - isSubscription, + resolvedLimitPeriod, ); } @@ -154,26 +186,32 @@ async function handlePostpaid( newLimit: number, connectionId: string, markupPct: number, - isSubscription: boolean, + limitPeriod: LimitPeriod | null, ): Promise> { - await updateKeyLimit( - keyHash, - newLimit, - isSubscription ? "monthly" : null, - false, - ); + await updateKeyLimit(keyHash, newLimit, limitPeriod, false); + + await updateBillingConfig(connectionId, { + billingMode: "postpaid", + isSubscription: limitPeriod === "monthly", + limitPeriod, + }); logger.info("Postpaid limit updated directly", { connectionId, currentLimit, newLimit, markupPct, + limitPeriod, }); + const periodLabel = limitPeriod + ? { daily: "daily", weekly: "weekly", monthly: "monthly" }[limitPeriod] + : null; + const lines = [ `Spending limit updated (postpaid mode).`, `Previous limit: $${currentLimit.toFixed(2)}`, - `New limit: $${newLimit.toFixed(2)}`, + `New limit: $${newLimit.toFixed(2)}${periodLabel ? ` (resets ${periodLabel})` : ""}`, ]; if (markupPct > 0) { lines.push(`Usage markup: ${markupPct}%`); @@ -182,6 +220,7 @@ async function handlePostpaid( return { summary: lines.join("\n"), billing_mode: "postpaid", + limit_period: limitPeriod, checkout_url: null, amount_usd: null, markup_pct: markupPct, @@ -286,6 +325,7 @@ async function handlePrepaid( return { summary: lines.join("\n"), billing_mode: "prepaid", + limit_period: null, checkout_url: checkoutUrl, amount_usd: chargeUsd, markup_pct: markupPct, diff --git a/deco-ai-gateway/server/tools/usage.ts b/deco-ai-gateway/server/tools/usage.ts index 186154af..54ad261c 100644 --- a/deco-ai-gateway/server/tools/usage.ts +++ b/deco-ai-gateway/server/tools/usage.ts @@ -30,6 +30,7 @@ export const createGatewayUsageTool = (env: Env) => }), billing: z.object({ mode: z.enum(["prepaid", "postpaid"]), + limitPeriod: z.enum(["daily", "weekly", "monthly"]).nullable(), }), limit: z.object({ total: z.number().nullable(), @@ -106,6 +107,11 @@ export const createGatewayUsageTool = (env: Env) => const d = await getKeyDetails(row.openrouter_key_hash); const billingMode = row.billing_mode ?? "prepaid"; + const limitPeriod = (row.limit_period ?? null) as + | "daily" + | "weekly" + | "monthly" + | null; const estimation: CreditEstimation | null = estimateCreditDuration({ limitRemaining: d.limit_remaining, @@ -116,33 +122,57 @@ export const createGatewayUsageTool = (env: Env) => keyCreatedAt: d.created_at, }); - const limitLine = d.limit - ? `Limit: $${d.limit.toFixed(4)} | Remaining: $${(d.limit_remaining ?? 0).toFixed(4)} | Reset: ${d.limit_reset ?? "none"}` - : "Limit: none"; + const percentUsed = + d.limit != null && d.limit > 0 + ? Math.min(100, Math.round((d.usage / d.limit) * 100 * 10) / 10) + : null; - let forecastLabel: string; - if (estimation) { - forecastLabel = estimationSummary(estimation); - } else if (d.limit == null) { - forecastLabel = "No spending limit set — usage is unlimited."; - } else if ((d.limit_remaining ?? 0) <= 0) { - forecastLabel = "Credit exhausted."; + let summaryLines: string[]; + if (billingMode === "postpaid") { + if (d.limit != null) { + const periodLabel = limitPeriod ?? "no reset"; + summaryLines = [ + `Key: ${d.name} | Status: ${d.disabled ? "disabled" : "active"}`, + `Billing: postpaid | Limit: $${d.limit.toFixed(2)} (${periodLabel})`, + `Usage: $${d.usage.toFixed(4)} of $${d.limit.toFixed(2)} — ${percentUsed}% used`, + `Period usage — Daily: $${d.usage_daily.toFixed(4)} | Weekly: $${d.usage_weekly.toFixed(4)} | Monthly: $${d.usage_monthly.toFixed(4)}`, + ]; + if (d.limit_reset) { + summaryLines.push(`Next reset: ${d.limit_reset}`); + } + } else { + summaryLines = [ + `Key: ${d.name} | Status: ${d.disabled ? "disabled" : "active"}`, + `Billing: postpaid | No spending limit configured`, + `Usage — Total: $${d.usage.toFixed(4)} | Daily: $${d.usage_daily.toFixed(4)} | Weekly: $${d.usage_weekly.toFixed(4)} | Monthly: $${d.usage_monthly.toFixed(4)}`, + ]; + } } else { - forecastLabel = "No usage yet — estimation not available."; - } + const limitLine = d.limit + ? `Limit: $${d.limit.toFixed(4)} | Remaining: $${(d.limit_remaining ?? 0).toFixed(4)}` + : "Limit: none"; + + let forecastLabel: string; + if (estimation) { + forecastLabel = estimationSummary(estimation); + } else if (d.limit == null) { + forecastLabel = "No spending limit set — usage is unlimited."; + } else if ((d.limit_remaining ?? 0) <= 0) { + forecastLabel = "Credit exhausted."; + } else { + forecastLabel = "No usage yet — estimation not available."; + } - const lines = [ - `Key: ${d.name}`, - `Status: ${d.disabled ? "disabled" : "active"}`, - ...(billingMode === "postpaid" ? ["Billing: postpaid"] : []), - `Usage — Total: $${d.usage.toFixed(6)} | Daily: $${d.usage_daily.toFixed(6)} | Weekly: $${d.usage_weekly.toFixed(6)} | Monthly: $${d.usage_monthly.toFixed(6)}`, - `BYOK — Total: $${d.byok_usage.toFixed(6)} | Daily: $${d.byok_usage_daily.toFixed(6)} | Weekly: $${d.byok_usage_weekly.toFixed(6)} | Monthly: $${d.byok_usage_monthly.toFixed(6)}`, - limitLine, - `Forecast: ${forecastLabel}`, - ]; + summaryLines = [ + `Key: ${d.name} | Status: ${d.disabled ? "disabled" : "active"}`, + `Usage — Total: $${d.usage.toFixed(6)} | Daily: $${d.usage_daily.toFixed(6)} | Weekly: $${d.usage_weekly.toFixed(6)} | Monthly: $${d.usage_monthly.toFixed(6)}`, + limitLine, + `Forecast: ${forecastLabel}`, + ]; + } return { - summary: lines.join("\n"), + summary: summaryLines.join("\n"), key: { name: d.name, label: d.label, @@ -153,6 +183,7 @@ export const createGatewayUsageTool = (env: Env) => }, billing: { mode: billingMode, + limitPeriod, }, limit: { total: d.limit, From 46d6f753d91f5c113155873cb17edff72d606cd8 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Mon, 2 Mar 2026 18:37:30 -0300 Subject: [PATCH 2/2] fix(deco-ai-gateway): avoid null percent for zero limit Prevent usage summaries from rendering null percentage values when a postpaid limit is zero or non-positive. Made-with: Cursor --- deco-ai-gateway/server/tools/usage.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deco-ai-gateway/server/tools/usage.ts b/deco-ai-gateway/server/tools/usage.ts index 54ad261c..b8339fb1 100644 --- a/deco-ai-gateway/server/tools/usage.ts +++ b/deco-ai-gateway/server/tools/usage.ts @@ -131,10 +131,14 @@ export const createGatewayUsageTool = (env: Env) => if (billingMode === "postpaid") { if (d.limit != null) { const periodLabel = limitPeriod ?? "no reset"; + const usageVsLimitLine = + percentUsed == null + ? `Usage: $${d.usage.toFixed(4)} of $${d.limit.toFixed(2)}` + : `Usage: $${d.usage.toFixed(4)} of $${d.limit.toFixed(2)} — ${percentUsed}% used`; summaryLines = [ `Key: ${d.name} | Status: ${d.disabled ? "disabled" : "active"}`, `Billing: postpaid | Limit: $${d.limit.toFixed(2)} (${periodLabel})`, - `Usage: $${d.usage.toFixed(4)} of $${d.limit.toFixed(2)} — ${percentUsed}% used`, + usageVsLimitLine, `Period usage — Daily: $${d.usage_daily.toFixed(4)} | Weekly: $${d.usage_weekly.toFixed(4)} | Monthly: $${d.usage_monthly.toFixed(4)}`, ]; if (d.limit_reset) {