chore: improve types and atmn

This commit is contained in:
Charlie Lamb
2026-06-10 13:05:45 +01:00
parent 2c43fce1d1
commit f25838d21c
17 changed files with 467 additions and 66 deletions

View File

@@ -349,6 +349,32 @@ function normalizeFeatureForCompare(f: Feature): Record<string, unknown> {
})); }));
} }
if (f.type === "ai_credit_system") {
const ai = f as Extract<Feature, { type: "ai_credit_system" }>;
if (ai.modelMarkups && Object.keys(ai.modelMarkups).length > 0) {
result.modelMarkups = Object.fromEntries(
Object.entries(ai.modelMarkups)
.sort(([a], [b]) => a.localeCompare(b))
.map(([modelId, entry]) => [
modelId,
{
markup: entry.markup,
inputCost: entry.inputCost,
outputCost: entry.outputCost,
},
]),
);
}
if (ai.defaultMarkup != null) result.defaultMarkup = ai.defaultMarkup;
if (ai.providerMarkups && Object.keys(ai.providerMarkups).length > 0) {
result.providerMarkups = Object.fromEntries(
Object.entries(ai.providerMarkups).sort(([a], [b]) =>
a.localeCompare(b),
),
);
}
}
return result; return result;
} }

View File

@@ -60,6 +60,8 @@ export const featureTransformer = createTransformer<RawApiFeature, Feature>({
...BASE_COMPUTE, ...BASE_COMPUTE,
type: () => "ai_credit_system" as const, type: () => "ai_credit_system" as const,
modelMarkups: (api) => mapModelMarkups(api), modelMarkups: (api) => mapModelMarkups(api),
defaultMarkup: (api) => api.default_markup ?? undefined,
providerMarkups: (api) => api.provider_markups ?? undefined,
}, },
}, },

View File

@@ -16,6 +16,8 @@ export interface ApiFeatureParams {
input_cost?: number; input_cost?: number;
output_cost?: number; output_cost?: number;
}>; }>;
default_markup?: number;
provider_markups?: Record<string, { markup: number }>;
} }
export function transformFeatureToApi(feature: Feature): ApiFeatureParams { export function transformFeatureToApi(feature: Feature): ApiFeatureParams {
@@ -44,17 +46,25 @@ export function transformFeatureToApi(feature: Feature): ApiFeatureParams {
})); }));
} }
if (feature.type === "ai_credit_system" && feature.modelMarkups) { if (feature.type === "ai_credit_system") {
base.model_markups = Object.fromEntries( if (feature.modelMarkups) {
Object.entries(feature.modelMarkups).map(([modelId, entry]) => [ base.model_markups = Object.fromEntries(
modelId, Object.entries(feature.modelMarkups).map(([modelId, entry]) => [
{ modelId,
markup: entry.markup, {
input_cost: entry.inputCost, markup: entry.markup,
output_cost: entry.outputCost, input_cost: entry.inputCost,
}, output_cost: entry.outputCost,
]) },
); ])
);
}
if (feature.defaultMarkup !== undefined) {
base.default_markup = feature.defaultMarkup;
}
if (feature.providerMarkups) {
base.provider_markups = feature.providerMarkups;
}
} }
return base; return base;

View File

@@ -42,9 +42,17 @@ export function buildFeatureCode(feature: Feature, varNameOverride?: string): st
lines.push(`\tcreditSchema: ${formatValue(feature.creditSchema)},`); lines.push(`\tcreditSchema: ${formatValue(feature.creditSchema)},`);
} }
// Add modelMarkups for ai_credit_system features // Add markup config for ai_credit_system features
if (feature.type === "ai_credit_system" && feature.modelMarkups) { if (feature.type === "ai_credit_system") {
lines.push(`\tmodelMarkups: ${formatValue(feature.modelMarkups)},`); if (feature.modelMarkups) {
lines.push(`\tmodelMarkups: ${formatValue(feature.modelMarkups)},`);
}
if (feature.defaultMarkup !== undefined) {
lines.push(`\tdefaultMarkup: ${feature.defaultMarkup},`);
}
if (feature.providerMarkups) {
lines.push(`\tproviderMarkups: ${formatValue(feature.providerMarkups)},`);
}
} }
lines.push(`});`); lines.push(`});`);

View File

@@ -11,13 +11,17 @@ export const generateAndUpdateAgentRules = async ({
endTime?: string; endTime?: string;
startTime?: string; startTime?: string;
}) => { }) => {
const generated = await generateAgentRules({ ctx, endTime, startTime }); const [generated, existing] = await Promise.all([
generateAgentRules({ ctx, endTime, startTime }),
agentRulesRepo.get({ db: ctx.db, orgId: ctx.org.id }),
]);
// Generation only derives entity/credit rules; never overwrite user-written notes.
const rules = await agentRulesRepo.upsert({ const rules = await agentRulesRepo.upsert({
db: ctx.db, db: ctx.db,
metadata: generated.metadata, metadata: generated.metadata,
orgId: ctx.org.id, orgId: ctx.org.id,
orgSlug: ctx.org.slug, orgSlug: ctx.org.slug,
rules: generated.rules, rules: { ...generated.rules, notes: existing.notes },
}); });
return { return {

View File

@@ -14,7 +14,7 @@ export const buildAiCreditCostProperty = ({
featureDeductions: FeatureDeduction[]; featureDeductions: FeatureDeduction[];
entries: Array<{ featureId: string; amount: number }>; entries: Array<{ featureId: string; amount: number }>;
}): Record<string, number> | undefined => { }): Record<string, number> | undefined => {
const aiDeduction = featureDeductions.find((d) => d.tokenUsage); const aiDeduction = featureDeductions.find((d) => d.tokens);
if (!aiDeduction) return; if (!aiDeduction) return;
const creditCost: Record<string, number> = {}; const creditCost: Record<string, number> = {};

View File

@@ -139,12 +139,14 @@ export const getTokenTrackParams = async ({
{ {
feature: aiCreditFeature, feature: aiCreditFeature,
deduction: 1, deduction: 1,
tokenUsage: { tokens: {
modelName: input.model_id, usage: {
inputTokens: input.input_tokens, modelName: input.model_id,
outputTokens: input.output_tokens, inputTokens: input.input_tokens,
outputTokens: input.output_tokens,
},
cost,
}, },
precomputedCreditCost: cost,
}, },
]; ];

View File

@@ -8,8 +8,8 @@ export type CreditCostLookup = (entitlementId: string) => number;
/** /**
* Computes the credit cost for each customer entitlement and returns a lookup * Computes the credit cost for each customer entitlement and returns a lookup
* function. Uses precomputedCreditCost when available (token tracking), * function. Token deductions carry their USD cost from the API layer; all other
* otherwise calls getCreditCost per entitlement (credit system schema lookups). * costs come from credit system schema ratios.
*/ */
export const computeCreditCosts = async ({ export const computeCreditCosts = async ({
cusEnts, cusEnts,
@@ -20,33 +20,23 @@ export const computeCreditCosts = async ({
}): Promise<CreditCostLookup> => { }): Promise<CreditCostLookup> => {
const costMap = new Map<string, number>(); const costMap = new Map<string, number>();
const tokens = deduction.tokenUsage
? {
input: deduction.tokenUsage.inputTokens,
output: deduction.tokenUsage.outputTokens,
}
: undefined;
await Promise.all( await Promise.all(
cusEnts.map(async (ce) => { cusEnts.map(async (ce) => {
// Precomputed cost (from /track/tokens) is in the AI credit feature's // A token deduction's cost is in the AI feature's native unit (USD): it
// native unit (USD). It applies 1:1 to that feature's own entitlement, // applies 1:1 to its own entitlement, while parent credit systems apply
// but parent credit systems still need their schema ratio applied — // their schema ratio to it via getCreditCost's amount.
// fall through to getCreditCost with amount = precomputed cost.
if ( if (
deduction.precomputedCreditCost != null && deduction.tokens &&
ce.entitlement.feature.id === deduction.feature.id ce.entitlement.feature.id === deduction.feature.id
) { ) {
costMap.set(ce.id, deduction.precomputedCreditCost); costMap.set(ce.id, deduction.tokens.cost);
return; return;
} }
const creditCost = await getCreditCost({ const creditCost = await getCreditCost({
featureId: deduction.feature.id, featureId: deduction.feature.id,
creditSystem: ce.entitlement.feature, creditSystem: ce.entitlement.feature,
amount: deduction.precomputedCreditCost, amount: deduction.tokens?.cost,
modelName: deduction.tokenUsage?.modelName,
tokens,
}); });
costMap.set(ce.id, creditCost); costMap.set(ce.id, creditCost);
}), }),

View File

@@ -7,13 +7,18 @@ export type TokenUsage = {
outputTokens: number; outputTokens: number;
}; };
/** Token usage and its USD cost are priced together at the API layer — one cannot exist without the other. */
export type TokenDeduction = {
usage: TokenUsage;
cost: number;
};
export type FeatureDeduction = { export type FeatureDeduction = {
feature: Feature; feature: Feature;
deduction: number; deduction: number;
targetBalance?: number; targetBalance?: number;
tokenUsage?: TokenUsage; /** Present only for track_tokens deductions; standard deductions omit it. */
/** Pre-computed dollar cost; if set, the deduction layer skips its own getCreditCost call. */ tokens?: TokenDeduction;
precomputedCreditCost?: number;
lock?: LockParams; lock?: LockParams;
lockReceipt?: LockReceipt; lockReceipt?: LockReceipt;
lockReceiptKey?: string; lockReceiptKey?: string;

View File

@@ -96,17 +96,22 @@ export const getCreditCost = async ({
return amount; return amount;
} }
if (isAiCreditSystem(creditSystem.type)) { if (isAiCreditSystem(creditSystem.type)) {
if (!tokens || !modelName) { if (tokens && modelName) {
throw new RecaseError({ return await getModelCreditCost({
message: "modelName and tokens must be provided for AI credit systems", modelName,
code: ErrCode.InvalidRequest, creditSystem,
statusCode: 400, ...tokens,
}); });
} }
return await getModelCreditCost({ // No token context (plain /track values, balance updates, queued replays):
modelName, // the feature's own balance is already in USD, so the value maps 1:1.
creditSystem, if (featureId === creditSystem.id) {
...tokens, return amount;
}
throw new RecaseError({
message: "modelName and tokens must be provided for AI credit systems",
code: ErrCode.InvalidRequest,
statusCode: 400,
}); });
} }
// If tracking the credit system feature itself, 1:1 mapping // If tracking the credit system feature itself, 1:1 mapping

View File

@@ -7,9 +7,13 @@ const CACHE_KEY = "models_dev_pricing";
const STALE_KEY = `${CACHE_KEY}_stale`; const STALE_KEY = `${CACHE_KEY}_stale`;
const TTL_PRIMARY = 60 * 60 * 3; const TTL_PRIMARY = 60 * 60 * 3;
const TTL_STALE = 60 * 60 * 24 * 3; const TTL_STALE = 60 * 60 * 24 * 3;
// Runs inside the track request path — a hanging models.dev must not hang tracks.
const FETCH_TIMEOUT_MS = 5000;
const fetchFromSource = async (): Promise<ModelPricingData> => { const fetchFromSource = async (): Promise<ModelPricingData> => {
const response = await fetch("https://models.dev/api.json"); const response = await fetch("https://models.dev/api.json", {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) { if (!response.ok) {
throw new InternalError({ throw new InternalError({
message: `models.dev returned ${response.status}`, message: `models.dev returned ${response.status}`,

View File

@@ -10,13 +10,14 @@ import { Decimal } from "decimal.js";
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-ORBS: AI credit system nested inside a parent credit system // TRACK-TOKENS-ORBS: AI credit system nested inside a parent credit system
// Verifies that a single /track/tokens call deducts USD from the AI credit //
// feature AND deducts the ratio-mapped amount from any parent credit // Parent credit systems are overflow pools (same semantics as classic
// system whose schema references it. // metered → credits deduction order): a token track drains the AI credit
// balance first, and only the overflow is ratio-mapped onto the parent.
// ═══════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════
test.concurrent( test.concurrent(
`${chalk.yellowBright("track-tokens-orbs: AI credit system inside parent credit system deducts both balances")}`, `${chalk.yellowBright("track-tokens-orbs-1: AI balance covers the cost — parent orbs untouched")}`,
async () => { async () => {
const aiCreditsItem = items.free({ const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits, featureId: TestFeature.AiCredits,
@@ -24,7 +25,7 @@ test.concurrent(
}); });
const orbsItem = items.free({ const orbsItem = items.free({
featureId: TestFeature.Orbs, featureId: TestFeature.Orbs,
includedUsage: 50_000, // 50,000 orbs includedUsage: 50_000, // orbs schema: 1000 orbs per $1 of AI usage
}); });
const freeProd = products.base({ const freeProd = products.base({
id: "free", id: "free",
@@ -32,7 +33,7 @@ test.concurrent(
}); });
const { customerId, autumnV1, autumnV2 } = await initScenario({ const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "track-tokens-orbs", customerId: "track-tokens-orbs-1",
setup: [ setup: [
s.customer({ testClock: false }), s.customer({ testClock: false }),
s.products({ list: [freeProd] }), s.products({ list: [freeProd] }),
@@ -49,9 +50,6 @@ test.concurrent(
.div(1_000_000) .div(1_000_000)
.toNumber(); // 0.125 .toNumber(); // 0.125
// Orbs schema: 1000 orbs per $1 of AI usage
const expectedOrbsCost = new Decimal(expectedUsdCost).mul(1000).toNumber(); // 125
const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", { const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId, customer_id: customerId,
feature_id: TestFeature.AiCredits, feature_id: TestFeature.AiCredits,
@@ -71,10 +69,61 @@ test.concurrent(
usage: expectedUsdCost, usage: expectedUsdCost,
}); });
// Parent orbs balance dropped by USD cost × 1000 // AI balance covered the full cost, so the parent overflow pool is untouched
expect(customer.features[TestFeature.Orbs]).toMatchObject({ expect(customer.features[TestFeature.Orbs]).toMatchObject({
balance: new Decimal(50_000).minus(expectedOrbsCost).toNumber(), balance: 50_000,
usage: expectedOrbsCost, usage: 0,
});
},
);
test.concurrent(
`${chalk.yellowBright("track-tokens-orbs-2: cost exceeding AI balance overflows into parent orbs at the schema ratio")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 100, // $100 of AI usage
});
const orbsItem = items.free({
featureId: TestFeature.Orbs,
includedUsage: 50_000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem, orbsItem],
});
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "track-tokens-orbs-2",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// (5 * 24M) / 1M = $120 > the $100 AI balance
const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 24_000_000,
output_tokens: 0,
});
expect(trackRes.value).toBeCloseTo(120, 10);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// AI pool fully drained
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: 0,
usage: 100,
});
// $20 overflow lands on orbs at 1000 orbs per $1
expect(customer.features[TestFeature.Orbs]).toMatchObject({
balance: new Decimal(50_000).minus(20_000).toNumber(),
usage: 20_000,
}); });
}, },
); );

View File

@@ -0,0 +1,120 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { ApiVersion, ApiVersionClass } from "@autumn/shared";
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 { Decimal } from "decimal.js";
import { runQueuedTrack } from "@/internal/balances/track/runQueuedTrack.js";
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-REPLAY: queued replay + plain value tracks on AI credit features
//
// When Redis fails open, track_tokens queues only the TrackParams body — the
// token context (FeatureDeduction.tokens) is not serialized. The
// replay worker rebuilds deductions from {feature_id, value}, so the USD value
// must deduct 1:1 from the AI credit balance, exactly like the original token
// track would have. Parent credit systems are overflow pools: untouched while
// the AI balance covers the deduction (same as live track_tokens behavior).
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-replay-1: queued replay body deducts AI credits 1:1")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 100, // $100 of AI usage
});
const orbsItem = items.free({
featureId: TestFeature.Orbs,
includedUsage: 50_000, // orbs schema: 1000 orbs per $1 of AI usage
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem, orbsItem],
});
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "track-tokens-replay-1",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// The USD cost computed by the original track_tokens call; only this
// survives in the queued body.
const usdCost = 0.125;
await runQueuedTrack({
ctx: { ...ctx, apiVersion: new ApiVersionClass(ApiVersion.V2_1) },
body: {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
value: usdCost,
idempotency_key: `replay-${crypto.randomUUID()}`,
},
apiVersion: ApiVersion.V2_1,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(100).minus(usdCost).toNumber(),
usage: usdCost,
});
expect(customer.features[TestFeature.Orbs]).toMatchObject({
balance: 50_000,
usage: 0,
});
},
);
test.concurrent(
`${chalk.yellowBright("track-tokens-replay-2: plain /track with a USD value deducts an AI credit balance 1:1")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 100,
});
const orbsItem = items.free({
featureId: TestFeature.Orbs,
includedUsage: 50_000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem, orbsItem],
});
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "track-tokens-replay-2",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const usdValue = 5;
await autumnV2.post("/track", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
value: usdValue,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(100).minus(usdValue).toNumber(),
usage: usdValue,
});
expect(customer.features[TestFeature.Orbs]).toMatchObject({
balance: 50_000,
usage: 0,
});
},
);

View File

@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
const generatedRules = {
entity_rules: { attach_to_entities: true, entity_feature_id: "deployments" },
credit_rules: { credit_feature_id: "credits" },
notes: "",
};
const mockState = {
existingNotes: "",
upsertCalls: [] as Record<string, unknown>[],
};
mock.module(
"@/internal/agent/workflows/generateAgentRules/generateAgentRules.js",
() => ({
generateAgentRules: async () => ({
rules: generatedRules,
metadata: { generated_from: "axiom" },
unconfigured: false,
}),
}),
);
mock.module("@/internal/agent/rules/repos/index.js", () => ({
agentRulesRepo: {
get: async () => ({
entity_rules: { attach_to_entities: false, entity_feature_id: "" },
credit_rules: { credit_feature_id: "" },
notes: mockState.existingNotes,
metadata: {},
org_id: "org_test",
org_slug: "test",
updated_at: null,
}),
upsert: async (args: { rules: typeof generatedRules }) => {
mockState.upsertCalls.push(args);
return { ...args.rules, metadata: {}, org_id: "org_test" };
},
},
}));
const { generateAndUpdateAgentRules } = await import(
"@/internal/agent/rules/actions/generateAndUpdateAgentRules.js"
);
const ctx = {
db: {},
org: { id: "org_test", slug: "test" },
} as unknown as AutumnContext;
describe("generateAndUpdateAgentRules", () => {
beforeEach(() => {
mockState.existingNotes = "";
mockState.upsertCalls = [];
});
test("preserves existing user notes when applying generated rules", async () => {
mockState.existingNotes = "Always attach add-ons at the customer level.";
const result = await generateAndUpdateAgentRules({ ctx });
expect(mockState.upsertCalls).toHaveLength(1);
expect(mockState.upsertCalls[0]).toMatchObject({
rules: {
entity_rules: generatedRules.entity_rules,
credit_rules: generatedRules.credit_rules,
notes: "Always attach add-ons at the customer level.",
},
});
expect(result.notes).toBe("Always attach add-ons at the customer level.");
});
test("keeps notes empty when none were saved", async () => {
await generateAndUpdateAgentRules({ ctx });
expect(mockState.upsertCalls[0]).toMatchObject({
rules: { notes: "" },
});
});
});

View File

@@ -19,7 +19,14 @@ const featureDeductions = [
{ {
feature: { id: "ai_credits" }, feature: { id: "ai_credits" },
deduction: 1, deduction: 1,
precomputedCreditCost: 3.5, tokens: {
usage: {
modelName: "openai/gpt-4.1",
inputTokens: 100,
outputTokens: 50,
},
cost: 3.5,
},
}, },
]; ];

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import {
ErrCode,
type Feature,
FeatureType,
FeatureUsageType,
} from "@autumn/shared";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
// Uses custom/* models so pricing resolves offline (no models.dev fetch).
const CUSTOM_MODEL = "custom/foo";
const aiCreditFeature: Feature = {
internal_id: "fe_ai_credits",
org_id: "org_test",
created_at: Date.now(),
env: "sandbox" as Feature["env"],
id: "ai_credits",
name: "AI Credits",
type: FeatureType.AiCreditSystem,
config: { schema: [], usage_type: FeatureUsageType.Single },
archived: false,
event_names: [],
model_markups: {
[CUSTOM_MODEL]: { markup: 0, input_cost: 1000, output_cost: 2000 },
},
};
describe("getCreditCost — AI credit system without token context", () => {
test("self feature with no tokens maps 1:1 (plain /track values, queued replays)", async () => {
const cost = await getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
amount: 5.25,
});
expect(cost).toBe(5.25);
});
test("self feature with no tokens defaults to a per-unit cost of 1", async () => {
const cost = await getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
});
expect(cost).toBe(1);
});
test("self feature WITH tokens still prices through the model (not 1:1)", async () => {
const cost = await getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
modelName: CUSTOM_MODEL,
tokens: { input: 1000, output: 500 },
});
// (1000 * 1000 + 2000 * 500) / 1_000_000 = 2.0
expect(cost).toBeCloseTo(2.0, 10);
});
test("non-self feature with no tokens throws", async () => {
expect(
getCreditCost({
featureId: "some_other_feature",
creditSystem: aiCreditFeature,
amount: 5,
}),
).rejects.toMatchObject({
code: ErrCode.InvalidRequest,
message: expect.stringContaining("modelName and tokens"),
});
});
});

View File

@@ -113,3 +113,20 @@ test("fetch failure with no stale cache throws InternalError", async () => {
message: "Failed to fetch models.dev pricing and no cache available", message: "Failed to fetch models.dev pricing and no cache available",
}); });
}); });
test("fetch carries an abort timeout so a hanging models.dev cannot hang tracks", async () => {
let capturedSignal: AbortSignal | undefined;
globalThis.fetch = Object.assign(
async (_input: unknown, init?: RequestInit) => {
fetchCalls++;
capturedSignal = init?.signal ?? undefined;
return Response.json(pricingData);
},
{ preconnect: realFetch.preconnect },
) as typeof fetch;
await getModelsDevPricing();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
expect(capturedSignal?.aborted).toBe(false);
});