diff --git a/changelog.d/fixes/12341-budget-alias-auto.md b/changelog.d/fixes/12341-budget-alias-auto.md new file mode 100644 index 0000000000..3abc4c0b25 --- /dev/null +++ b/changelog.d/fixes/12341-budget-alias-auto.md @@ -0,0 +1 @@ +- fix(usage): fail closed on API-key budget enforcement when a provider's `auto` routing alias has no pricing row, instead of silently counting it as $0 (#12341) diff --git a/src/lib/usage/apiKeyUsageLimits.ts b/src/lib/usage/apiKeyUsageLimits.ts index e5ed85871f..dc6f13da16 100644 --- a/src/lib/usage/apiKeyUsageLimits.ts +++ b/src/lib/usage/apiKeyUsageLimits.ts @@ -1,7 +1,7 @@ import { getDbInstance } from "@/lib/db/core"; import type { ProviderLimitsCacheEntry } from "@/lib/db/providerLimits"; import { getProviderQuotaWindowStartIso } from "@/lib/db/quotaResetEvents"; -import { calculateCost } from "./costCalculator"; +import { calculateCostDetailed } from "./costCalculator"; import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts"; const FORTALEZA_UTC_OFFSET_MS = 3 * 60 * 60 * 1000; @@ -29,6 +29,15 @@ export interface ApiKeyUsageLimitStatus { weeklyResetAtIso: string | null; dailyExceeded: boolean; weeklyExceeded: boolean; + /** + * True when at least one usage_history row in the daily/weekly window could not + * be priced at all (no pricing row for the provider+model — e.g. a routing + * alias such as `auto`, #12341). Enforcement fails closed on this: an unpriced + * row forces `*Exceeded = true` rather than silently contributing $0 to spend, + * since a real cost may be hiding behind the alias. + */ + dailyHasUnpricedUsage?: boolean; + weeklyHasUnpricedUsage?: boolean; } export interface ApiKeyUsageLimitDeps { @@ -373,8 +382,14 @@ async function getProviderWeeklyWindow( }; } -async function getApiKeyUsdSpendSince(apiKeyId: string, sinceIso: string): Promise { - if (!apiKeyId) return 0; +interface ApiKeyUsdSpend { + totalUsd: number; + /** True when at least one (provider, model) group had no pricing row at all (#12341). */ + hasUnpricedUsage: boolean; +} + +async function getApiKeyUsdSpendSince(apiKeyId: string, sinceIso: string): Promise { + if (!apiKeyId) return { totalUsd: 0, hasUnpricedUsage: false }; const db = getDbInstance(); const rows = db .prepare( @@ -398,12 +413,13 @@ async function getApiKeyUsdSpendSince(apiKeyId: string, sinceIso: string): Promi .all({ apiKeyId, sinceIso }) as UsageCostRow[]; let total = 0; + let hasUnpricedUsage = false; for (const row of rows) { const provider = typeof row.provider === "string" ? row.provider : ""; const model = typeof row.model === "string" ? row.model : ""; if (!provider || !model) continue; - total += await calculateCost( + const { costUsd, priced } = await calculateCostDetailed( provider, model, { @@ -419,9 +435,17 @@ async function getApiKeyUsdSpendSince(apiKeyId: string, sinceIso: string): Promi serviceTier: row.serviceTier || "standard", } ); + if (!priced) { + hasUnpricedUsage = true; + console.warn( + `[apiKeyUsageLimits] no pricing found for ${provider}/${model} — usage counted as $0 ` + + "and enforcement is failing closed for this window (#12341)" + ); + } + total += costUsd; } - return roundUsd(total); + return { totalUsd: roundUsd(total), hasUnpricedUsage }; } export async function getApiKeyUsageLimitStatus( @@ -443,10 +467,27 @@ export async function getApiKeyUsageLimitStatus( const weeklyLimitUsd = normalizeLimitUsd(metadata.weeklyUsageLimitUsd); const enabled = metadata.usageLimitEnabled === true; - const [dailySpentUsd, weeklySpentUsd] = await Promise.all([ + const [dailySpend, weeklySpend] = await Promise.all([ getApiKeyUsdSpendSince(metadata.id, dailyWindowStartIso), getApiKeyUsdSpendSince(metadata.id, weeklyWindowStartIso), ]); + const dailySpentUsd = dailySpend.totalUsd; + const weeklySpentUsd = weeklySpend.totalUsd; + + // Fail closed (#12341): a window with a configured limit that also contains + // usage which could not be priced at all (e.g. a provider's `auto` routing + // alias with no catalog price) must not let that usage silently pass the cap + // as an invisible $0 — treat the limit as exceeded rather than trust an + // undercounted spend total. A window with no configured limit was never + // enforced, so unpriced usage there is only logged, not blocking. + const dailyExceeded = + enabled && + dailyLimitUsd !== null && + (dailySpentUsd >= dailyLimitUsd || dailySpend.hasUnpricedUsage); + const weeklyExceeded = + enabled && + weeklyLimitUsd !== null && + (weeklySpentUsd >= weeklyLimitUsd || weeklySpend.hasUnpricedUsage); return { enabled, @@ -458,8 +499,10 @@ export async function getApiKeyUsageLimitStatus( dailyResetAtIso, weeklyWindowStartIso, weeklyResetAtIso, - dailyExceeded: enabled && dailyLimitUsd !== null && dailySpentUsd >= dailyLimitUsd, - weeklyExceeded: enabled && weeklyLimitUsd !== null && weeklySpentUsd >= weeklyLimitUsd, + dailyExceeded, + weeklyExceeded, + dailyHasUnpricedUsage: dailySpend.hasUnpricedUsage, + weeklyHasUnpricedUsage: weeklySpend.hasUnpricedUsage, }; } diff --git a/src/lib/usage/costCalculator.ts b/src/lib/usage/costCalculator.ts index 533eba8d74..ca0fd1db7d 100644 --- a/src/lib/usage/costCalculator.ts +++ b/src/lib/usage/costCalculator.ts @@ -175,18 +175,31 @@ export function computeCostFromPricing( return cost * getCodexFastCostMultiplier(options.provider, options.model, options.serviceTier); } -export async function calculateCost( +/** + * Result of a cost calculation that also reports whether the number is backed by + * a real pricing row. Budget-enforcement callers (#12341) must be able to tell + * "$0, priced" (a genuinely free/flat-rate model) apart from "$0, unpriced" (no + * pricing row was ever found — e.g. a routing alias like `auto`) so they can fail + * closed on the latter instead of letting it silently pass a hard budget cap. + */ +export interface CostCalculationResult { + costUsd: number; + /** false when no pricing row (direct, normalized, or codex-effortless) was found. */ + priced: boolean; +} + +export async function calculateCostDetailed( provider: string, model: string, tokens: Record | null | undefined, options: CostCalculationOptions = {} -): Promise { - if (!tokens || !provider || !model) return 0; +): Promise { + if (!tokens || !provider || !model) return { costUsd: 0, priced: true }; // Short-circuit before any pricing DB lookup when an exact, provider-reported // cost is present (currently xAI's `cost_in_usd_ticks` — see extractExactCostUsd). const exactCostUsd = extractExactCostUsd(tokens); - if (exactCostUsd !== null) return exactCostUsd; + if (exactCostUsd !== null) return { costUsd: exactCostUsd, priced: true }; try { const { getPricingForModel } = await import("@/lib/db/settings"); @@ -206,23 +219,37 @@ export async function calculateCost( } } } - if (!pricing) return 0; + // No pricing row anywhere — this is the #12341 case (e.g. a provider's own + // routing alias such as "auto" that has no catalog price). Report it as + // unpriced rather than a bare $0 so budget enforcement can fail closed. + if (!pricing) return { costUsd: 0, priced: false }; const pricingRecord = pricing && typeof pricing === "object" && !Array.isArray(pricing) ? (pricing as Record) : {}; - return computeCostFromPricing(pricingRecord, tokens, { + const costUsd = computeCostFromPricing(pricingRecord, tokens, { provider, model, ...options, }); + return { costUsd, priced: true }; } catch (error) { console.error("Error calculating cost:", error); - return 0; + return { costUsd: 0, priced: false }; } } +export async function calculateCost( + provider: string, + model: string, + tokens: Record | null | undefined, + options: CostCalculationOptions = {} +): Promise { + const result = await calculateCostDetailed(provider, model, tokens, options); + return result.costUsd; +} + type ModalPricing = Record; /** Per-image cost: flat per-image × n. 0 when pricing/usage absent. */ diff --git a/tests/unit/api-key-budget-alias-auto-12341.test.ts b/tests/unit/api-key-budget-alias-auto-12341.test.ts new file mode 100644 index 0000000000..276a034199 --- /dev/null +++ b/tests/unit/api-key-budget-alias-auto-12341.test.ts @@ -0,0 +1,113 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-api-key-budget-alias-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "budget-alias-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); +const usageHistory = await import("../../src/lib/usage/usageHistory.ts"); +const usageLimits = await import("../../src/lib/usage/apiKeyUsageLimits.ts"); + +const NOW = Date.parse("2026-06-19T20:00:00.000Z"); + +async function resetStorage() { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + usageHistory.clearPendingRequests(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +}); + +async function makeMeteredKey() { + const created = await apiKeysDb.createApiKey("Budget Alias Key", "machine-budget-01"); + await apiKeysDb.updateApiKeyPermissions(created.id, { + usageLimitEnabled: true, + dailyUsageLimitUsd: 10, + weeklyUsageLimitUsd: 50, + }); + apiKeysDb.clearApiKeyCaches(); + const metadata = await apiKeysDb.getApiKeyMetadata(created.key); + assert.ok(metadata); + return { created, metadata: metadata! }; +} + +test("BUG #12341: a real, billable completion routed through cursor/auto (unpriced) must not silently pass the daily budget cap as $0", async () => { + const { created, metadata } = await makeMeteredKey(); + + // Cursor's own default routing alias ("Auto (current, default)") has no + // pricing row anywhere — this is real, mainstream billable traffic, not an + // edge case. + await usageHistory.saveRequestUsage({ + provider: "cursor", + model: "auto", + apiKeyId: created.id, + apiKeyName: "Budget Alias Key", + tokens: { input: 1_000_000, output: 1_000_000 }, + success: true, + timestamp: "2026-06-19T12:00:00.000Z", + }); + + const status = await usageLimits.getApiKeyUsageLimitStatus( + { ...metadata, allowedConnections: null }, + { now: () => NOW } + ); + + // Fail closed (#12341): unpriced usage in a window with a configured limit + // must flip the window to exceeded, even though the naive USD total is $0. + assert.equal(status.dailySpentUsd, 0, "cost stays $0 — no pricing row exists for cursor/auto"); + assert.equal( + status.dailyHasUnpricedUsage, + true, + "status must flag that unpriced usage was seen in the daily window" + ); + assert.equal( + status.dailyExceeded, + true, + "enforcement must fail closed instead of silently allowing unlimited unpriced usage" + ); +}); + +test("control: a priced model routed at the same tokens does NOT trip fail-closed enforcement", async () => { + const { updatePricing } = await import("@/lib/db/settings"); + await updatePricing({ + openai: { + "gpt-4o": { input: 1, cached: 1, output: 1, reasoning: 1, cache_creation: 1 }, + }, + }); + + const { created, metadata } = await makeMeteredKey(); + + await usageHistory.saveRequestUsage({ + provider: "openai", + model: "gpt-4o", + apiKeyId: created.id, + apiKeyName: "Budget Alias Key", + tokens: { input: 1_000_000, output: 0 }, + success: true, + timestamp: "2026-06-19T12:00:00.000Z", + }); + + const status = await usageLimits.getApiKeyUsageLimitStatus( + { ...metadata, allowedConnections: null }, + { now: () => NOW } + ); + + assert.equal(status.dailySpentUsd, 1); + assert.equal(status.dailyHasUnpricedUsage, false); + assert.equal(status.dailyExceeded, false); +}); diff --git a/tests/unit/costcalculator-auto-alias-12341.test.ts b/tests/unit/costcalculator-auto-alias-12341.test.ts new file mode 100644 index 0000000000..23246d4cd5 --- /dev/null +++ b/tests/unit/costcalculator-auto-alias-12341.test.ts @@ -0,0 +1,42 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + calculateCost, + calculateCostDetailed, + normalizeModelName, +} from "../../src/lib/usage/costCalculator.ts"; +import { getDefaultPricing } from "../../src/shared/constants/pricing.ts"; + +const BILLABLE_TOKENS = { input: 1_000_000, output: 1_000_000 }; +const PROVIDERS_WITH_UNPRICED_AUTO_MODEL = ["cursor", "factory", "trae", "dify", "llm-kiwi"]; + +test('normalizeModelName is a no-op for a bare alias like "auto"', () => { + assert.equal(normalizeModelName("auto"), "auto"); +}); + +test('no pricing source carries an entry for the literal "auto" model id, for providers whose registry offers it as a real model', () => { + const pricing = getDefaultPricing() as Record>; + for (const provider of PROVIDERS_WITH_UNPRICED_AUTO_MODEL) { + const providerPricing = pricing[provider]; + assert.ok(!providerPricing || !providerPricing["auto"], `expected no DEFAULT_PRICING entry for ${provider}/auto`); + } +}); + +test('calculateCost() still returns $0 for a real, billable completion routed through the unpriced "auto" alias (unchanged legacy contract)', async () => { + const cost = await calculateCost("cursor", "auto", BILLABLE_TOKENS); + assert.equal(cost, 0, "calculateCost's numeric contract is unchanged — $0 for unpriced usage"); +}); + +test('#12341 fix: calculateCostDetailed() flags the "auto" alias as unpriced instead of a bare, indistinguishable $0', async () => { + for (const provider of PROVIDERS_WITH_UNPRICED_AUTO_MODEL) { + const result = await calculateCostDetailed(provider, "auto", BILLABLE_TOKENS); + assert.equal(result.costUsd, 0, `expected $0 for ${provider}/auto`); + assert.equal(result.priced, false, `expected ${provider}/auto to be reported as unpriced`); + } +}); + +test("control: calculateCostDetailed() DOES price a normal, non-alias model correctly and reports it as priced", async () => { + const result = await calculateCostDetailed("openai", "gpt-4o", BILLABLE_TOKENS); + assert.ok(result.costUsd > 0, `expected a known model to price above $0, got ${result.costUsd}`); + assert.equal(result.priced, true); +});