chore: increase test coverage

This commit is contained in:
Charlie Lamb
2026-06-10 11:11:10 +01:00
parent 22f1260bf7
commit 2c43fce1d1
6 changed files with 929 additions and 1 deletions

View File

@@ -0,0 +1,254 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV5, TrackResponseV3 } from "@autumn/shared";
import { ErrCode } from "@autumn/shared";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0%
// in=5000/out=2500 -> 0.0625; in=10000/out=5000 -> 0.125
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-LIM-1: default behavior caps deduction at zero balance
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-lim-1: default behavior caps token deduction at zero balance")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 0.1,
});
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-lim-1",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// First track: cost 0.0625 fits within the 0.1 balance
const trackRes1: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 5000,
output_tokens: 2500,
});
expect(trackRes1.value).toBeCloseTo(0.0625, 10);
// Second track: cost 0.125 exceeds the remaining 0.0375 — capped at zero
await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.AiCredits,
remaining: 0,
usage: 0.1,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-LIM-2: overage_behavior "reject" errors, balance intact
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-lim-2: overage_behavior reject errors with InsufficientBalance")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 0.1,
});
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-lim-2",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
await expectAutumnError({
errCode: ErrCode.InsufficientBalance,
func: () =>
autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
overage_behavior: "reject",
}),
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.AiCredits,
remaining: 0.1,
usage: 0,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-LIM-3: explicit overage_behavior "cap" deducts up to zero
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-lim-3: explicit overage_behavior cap deducts up to zero")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 0.1,
});
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-lim-3",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
overage_behavior: "cap",
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.AiCredits,
remaining: 0,
usage: 0.1,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-LIM-4: unlimited balance never rejects or deducts
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-lim-4: unlimited AI credit balance never rejects or deducts")}`,
async () => {
const aiCreditsItem = items.unlimited({ featureId: TestFeature.AiCredits });
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-lim-4",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
});
expect(trackRes.value).toBeCloseTo(0.125, 10);
expect(trackRes.balance).toMatchObject({
feature_id: TestFeature.AiCredits,
unlimited: true,
usage: 0,
});
// Second track: still no deduction, never rejected
await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 20000,
output_tokens: 10000,
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expect(customer.balances[TestFeature.AiCredits]).toMatchObject({
unlimited: true,
usage: 0,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-LIM-5: duplicate idempotency_key rejected, deducts once
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-lim-5: duplicate idempotency_key rejected, deducts once")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-lim-5",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const body = {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
idempotency_key: `track-tokens-idem-${Date.now().toString(36)}`,
};
const trackRes: TrackResponseV3 = await autumnV2_2.post(
"/track_tokens",
body,
);
expect(trackRes.value).toBeCloseTo(0.125, 10);
await expectAutumnError({
errCode: ErrCode.DuplicateIdempotencyKey,
func: () => autumnV2_2.post("/track_tokens", body),
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.AiCredits,
remaining: 999.875,
usage: 0.125,
});
},
);

View File

@@ -0,0 +1,202 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
ApiCustomerV5,
TrackResponseV3,
} from "@autumn/shared";
import { ErrCode } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils.js";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0%
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-PAID-1: prepaid AI credits deduct through purchased balance
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-paid-1: prepaid AI credits deduct through purchased balance")}`,
async () => {
const prepaidItem = items.prepaid({
featureId: TestFeature.AiCredits,
price: 1,
billingUnits: 1,
includedUsage: 2,
});
const prepaidProduct = products.pro({
id: "prepaid-ai",
items: [prepaidItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-paid-1",
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [prepaidProduct] }),
],
actions: [
s.attach({
productId: prepaidProduct.id,
options: [{ feature_id: TestFeature.AiCredits, quantity: 3 }],
}),
],
});
// 2 included + 3 purchased = 5
const customerBefore =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerBefore,
featureId: TestFeature.AiCredits,
granted: 5,
remaining: 5,
});
// (5*100000 + 15*100000) / 1e6 = $2.00
const trackRes1: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 100000,
output_tokens: 100000,
});
expect(trackRes1.value).toBeCloseTo(2, 10);
// Cost $4 > remaining 3 with reject — errors, balance unchanged
await expectAutumnError({
errCode: ErrCode.InsufficientBalance,
func: () =>
autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 200000,
output_tokens: 200000,
overage_behavior: "reject",
}),
});
const customerMid =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerMid,
featureId: TestFeature.AiCredits,
remaining: 3,
usage: 2,
});
// Cost $3.00 drains the remaining balance exactly
const trackRes2: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 150000,
output_tokens: 150000,
});
expect(trackRes2.value).toBeCloseTo(3, 10);
// Cached vs DB agreement (mutation-log sync is async)
await timeout(6000);
const customerNonCached = await autumnV2_2.customers.get<ApiCustomerV5>(
customerId,
{ skip_cache: "true" },
);
expectBalanceCorrect({
customer: customerNonCached,
featureId: TestFeature.AiCredits,
granted: 5,
remaining: 0,
usage: 5,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-PAID-2: consumable AI credit overage lands on the invoice
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-paid-2: consumable AI credit overage lands on the renewal invoice")}`,
async () => {
const consumableItem = items.consumable({
featureId: TestFeature.AiCredits,
includedUsage: 1,
price: 1,
billingUnits: 1,
});
const proProduct = products.pro({
id: "consumable-ai",
items: [consumableItem],
});
const { customerId, autumnV1, autumnV2_2, testClockId } =
await initScenario({
customerId: "track-tokens-paid-2",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [proProduct] }),
],
actions: [s.attach({ productId: proProduct.id })],
});
// (5*200000 + 15*200000) / 1e6 = $4.00 exactly
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 200000,
output_tokens: 200000,
});
expect(trackRes.value).toBeCloseTo(4, 10);
const customerMid =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerMid,
featureId: TestFeature.AiCredits,
remaining: 0,
usage: 4,
});
await timeout(2000);
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
withPause: true,
});
// Renewal invoice: $20 pro base + 3 overage units × $1 = $23.
// Invoice lands via Stripe webhook — poll briefly before asserting.
for (let attempt = 0; attempt < 5; attempt++) {
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
if ((customer.invoices?.length ?? 0) >= 2) break;
await timeout(10000);
}
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: 23,
latestInvoiceProductId: proProduct.id,
});
// Balance resets for the new cycle
const customerReset =
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer: customerReset,
featureId: TestFeature.AiCredits,
remaining: 1,
usage: 0,
});
},
);

View File

@@ -0,0 +1,151 @@
import { expect, test } from "bun:test";
import type {
ApiCustomerV5,
ApiEntityV2,
TrackResponseV3,
} from "@autumn/shared";
import { ApiVersion, FeatureType } from "@autumn/shared";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0%
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-RES-1: entity_id deducts entity balance via auto-resolution
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-res-1: entity_id deducts entity balance via auto-resolution")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 100,
entityFeatureId: TestFeature.Users,
});
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
const { customerId, autumnV2_2, entities } = await initScenario({
customerId: "track-tokens-res-1",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// No feature_id — exercises AI credit auto-resolution with entity scoping
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
entity_id: entities[0].id,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
});
expect(trackRes.customer_id).toBe(customerId);
expect(trackRes.value).toBeCloseTo(0.125, 10);
const entity0 = await autumnV2_2.entities.get<ApiEntityV2>(
customerId,
entities[0].id,
);
expectBalanceCorrect({
customer: entity0,
featureId: TestFeature.AiCredits,
remaining: 99.875,
usage: 0.125,
});
const entity1 = await autumnV2_2.entities.get<ApiEntityV2>(
customerId,
entities[1].id,
);
expectBalanceCorrect({
customer: entity1,
featureId: TestFeature.AiCredits,
remaining: 100,
usage: 0,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-RES-2: updated model markup applies to subsequent tracks
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-res-2: updated model markup applies to subsequent tracks")}`,
async () => {
const autumn = new AutumnInt({ version: ApiVersion.V2_2 });
// Throwaway feature — never mutate the shared AiCredits fixtures
const featureId = `ai_credits_mut_${Date.now()}_${Math.random()
.toString(36)
.slice(2, 8)}`;
await autumn.post("/features.create", {
feature_id: featureId,
name: "AI Credits Mutable",
type: FeatureType.AiCreditSystem,
model_markups: {
"custom/mut-model": { markup: 0, input_cost: 10, output_cost: 20 },
},
});
const aiCreditsItem = items.free({ featureId, includedUsage: 1000 });
const freeProd = products.base({ id: "free-mut", items: [aiCreditsItem] });
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-res-2",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const trackBody = {
customer_id: customerId,
feature_id: featureId,
model_id: "custom/mut-model",
input_tokens: 10000,
output_tokens: 5000,
};
// Markup 0 → base cost (10*10000 + 20*5000)/1e6 = 0.2
const trackRes1: TrackResponseV3 = await autumnV2_2.post(
"/track_tokens",
trackBody,
);
expect(trackRes1.value).toBeCloseTo(0.2, 10);
// Bump the model markup to 100%
await autumn.post("/features.update", {
feature_id: featureId,
model_markups: {
"custom/mut-model": { markup: 100, input_cost: 10, output_cost: 20 },
},
});
// Explicit feature_id resolves from freshly loaded org features → 0.4
const trackRes2: TrackResponseV3 = await autumnV2_2.post(
"/track_tokens",
trackBody,
);
expect(trackRes2.value).toBeCloseTo(0.4, 10);
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId,
remaining: 999.4,
usage: 0.6,
});
},
);

View File

@@ -1,7 +1,15 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, TrackResponseV2 } from "@autumn/shared";
import type {
ApiCustomerV3,
ApiCustomerV5,
TrackResponseV2,
TrackResponseV3,
} from "@autumn/shared";
import { ErrCode } from "@autumn/shared";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
@@ -390,3 +398,187 @@ test.concurrent(
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-6: custom/* model without configured costs errors
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-6: custom model missing input_cost/output_cost errors")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-6",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
await expectAutumnError({
errCode: ErrCode.InvalidRequest,
errMessage: "missing input_cost or output_cost",
func: () =>
autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/unconfigured-model",
input_tokens: 100,
output_tokens: 50,
}),
});
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.AiCredits,
remaining: 1000,
usage: 0,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-7: cache/audio/reasoning pools forwarded end-to-end
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-7: cache/audio/reasoning token pools are billed end-to-end")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV2_2, ctx } = await initScenario({
customerId: "track-tokens-7",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const aiCreditFeature = ctx.features.find(
(f) => f.id === TestFeature.AiCredits,
);
if (!aiCreditFeature) {
throw new Error(`${TestFeature.AiCredits} feature not found`);
}
// Total input (input + cache pools) stays far below the 200k tier threshold
const modelId = "anthropic/claude-sonnet-4-20250514";
const pools = {
input: 10000,
output: 5000,
cacheRead: 20000,
cacheWrite: 8000,
audioInput: 1000,
audioOutput: 1000,
reasoning: 4000,
};
const expectedCost = await getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
modelName: modelId,
tokens: pools,
});
// Pools must increase the bill vs text-only — otherwise the assertion
// below couldn't tell whether the HTTP layer forwarded them at all.
const textOnlyCost = await getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
modelName: modelId,
tokens: { input: pools.input, output: pools.output },
});
expect(expectedCost).toBeGreaterThan(textOnlyCost);
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: modelId,
input_tokens: pools.input,
output_tokens: pools.output,
cache_read_tokens: pools.cacheRead,
cache_write_tokens: pools.cacheWrite,
audio_input_tokens: pools.audioInput,
audio_output_tokens: pools.audioOutput,
reasoning_tokens: pools.reasoning,
});
expect(trackRes.value).toBeCloseTo(expectedCost, 10);
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
expectBalanceCorrect({
customer,
featureId: TestFeature.AiCredits,
remaining: new Decimal(1000).minus(expectedCost).toNumber(),
usage: expectedCost,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-8: custom models bill input/output only
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-8: custom models ignore cache/audio/reasoning pools")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV2_2 } = await initScenario({
customerId: "track-tokens-8",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0%
// Pool tokens are dropped for custom models, so cost is text-only.
const expectedCost = new Decimal(5)
.mul(10000)
.add(new Decimal(15).mul(5000))
.div(1_000_000)
.toNumber(); // 0.125
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
cache_read_tokens: 20000,
cache_write_tokens: 8000,
audio_input_tokens: 1000,
audio_output_tokens: 1000,
reasoning_tokens: 4000,
});
expect(trackRes.value).toBeCloseTo(expectedCost, 10);
},
);

View File

@@ -0,0 +1,115 @@
import { afterAll, afterEach, expect, mock, test } from "bun:test";
import { ErrCode } from "@autumn/shared";
// Map-backed CacheManager stub — getModelsDevPricing's cache key is shared
// with the dev server, so the real Redis must never be touched here.
const store = new Map<string, unknown>();
const setJsonCalls: { key: string; value: unknown; ttl?: number }[] = [];
mock.module("@/utils/cacheUtils/CacheManager.js", () => ({
CacheManager: {
getJson: async (key: string) => store.get(key) ?? null,
setJson: async (key: string, value: unknown, ttl?: number) => {
setJsonCalls.push({ key, value, ttl });
store.set(key, value);
},
},
}));
const { getModelsDevPricing } = await import(
"@/internal/features/utils/getModelPricing.js"
);
const PRIMARY_KEY = "models_dev_pricing";
const STALE_KEY = "models_dev_pricing_stale";
const pricingData = {
anthropic: { id: "anthropic", name: "Anthropic", models: {} },
};
const stalePricingData = {
openai: { id: "openai", name: "OpenAI", models: {} },
};
const realFetch = globalThis.fetch;
let fetchCalls = 0;
const stubFetch = (impl: () => Promise<Response>) => {
globalThis.fetch = Object.assign(
async () => {
fetchCalls++;
return await impl();
},
{ preconnect: realFetch.preconnect },
);
};
afterEach(() => {
store.clear();
setJsonCalls.length = 0;
fetchCalls = 0;
globalThis.fetch = realFetch;
});
afterAll(() => {
mock.restore();
globalThis.fetch = realFetch;
});
test("primary cache hit returns cached data without fetching", async () => {
store.set(PRIMARY_KEY, pricingData);
stubFetch(() => {
throw new Error("should not fetch");
});
const result = await getModelsDevPricing();
expect(result).toEqual(pricingData);
expect(fetchCalls).toBe(0);
});
test("cache miss fetches and populates primary + stale caches", async () => {
stubFetch(async () => Response.json(pricingData));
const result = await getModelsDevPricing();
expect(result).toEqual(pricingData);
expect(fetchCalls).toBe(1);
// Cache writes are fire-and-forget — flush microtasks before asserting
await Bun.sleep(0);
expect(setJsonCalls).toEqual([
{ key: PRIMARY_KEY, value: pricingData, ttl: 60 * 60 * 3 },
{ key: STALE_KEY, value: pricingData, ttl: 60 * 60 * 24 * 3 },
]);
});
test("non-ok response falls back to the stale cache", async () => {
store.set(STALE_KEY, stalePricingData);
stubFetch(async () => new Response("oops", { status: 500 }));
const result = await getModelsDevPricing();
expect(result).toEqual(stalePricingData);
});
test("fetch network error falls back to the stale cache", async () => {
store.set(STALE_KEY, stalePricingData);
stubFetch(() => {
throw new Error("network down");
});
const result = await getModelsDevPricing();
expect(result).toEqual(stalePricingData);
});
test("fetch failure with no stale cache throws InternalError", async () => {
stubFetch(() => {
throw new Error("network down");
});
await expect(getModelsDevPricing()).rejects.toMatchObject({
code: ErrCode.InternalError,
message: "Failed to fetch models.dev pricing and no cache available",
});
});

View File

@@ -51,13 +51,16 @@ const adminRights = () =>
const free = ({
featureId,
includedUsage = 100,
entityFeatureId,
}: {
featureId: string;
includedUsage?: number;
entityFeatureId?: string;
}): LimitedItem =>
constructFeatureItem({
featureId,
includedUsage,
entityFeatureId,
}) as LimitedItem;
/**
@@ -163,6 +166,16 @@ const monthlyCredits = ({
rolloverConfig,
}) as LimitedItem;
/**
* Generic unlimited feature - no usage cap
* @param featureId - Feature ID
*/
const unlimited = ({ featureId }: { featureId: string }) =>
constructFeatureItem({
featureId,
unlimited: true,
});
/**
* Unlimited messages - no usage cap
* @returns Unlimited messages feature item
@@ -783,6 +796,7 @@ export const items = {
freeUsers,
freeAllocatedUsers,
freeAllocatedWorkflows,
unlimited,
unlimitedMessages,
weeklyMessages,
lifetimeMessages,