chore: increase test coverage
This commit is contained in:
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
115
server/tests/unit/features/get-model-pricing.test.ts
Normal file
115
server/tests/unit/features/get-model-pricing.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user