Merge pull request #1049 from TheUntraceable/feat/ai-credit-system

feat: ai credit system
This commit is contained in:
Charlie Lamb
2026-06-10 17:51:21 +01:00
committed by GitHub
132 changed files with 7438 additions and 570 deletions

1
.gitignore vendored
View File

@@ -22,6 +22,7 @@ supabase.sh
tests/
!server/tests
!packages/mcp/tests
!packages/ai-sdk/tests
!apps/leaf/tests
!vite/tests
.secrets

View File

@@ -0,0 +1,78 @@
---
title: "Track Token Usage"
openapi: "openapi POST /v1/balances.track_tokens"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
<Note>
Track AI token usage against a customer's AI credit system balance. Converts token counts to a dollar cost using [Models.dev](https://models.dev) pricing and your configured markup, then deducts from the customer's credit balance.
</Note>
The `model_id` must use `provider/model` format, matching the provider and model keys from [Models.dev](https://models.dev). For providers with nested model paths (like OpenRouter), include the full path: `openrouter/anthropic/claude-opus-4.6`. The first path segment is the provider key used for `providerMarkups` lookup.
### Common Use Cases
<CodeGroup>
```typescript Anthropic
await autumn.balances.trackTokens({
customerId: "cus_123",
modelId: "anthropic/claude-opus-4-6",
inputTokens: 1000,
outputTokens: 500
});
```
```typescript With cache + reasoning
await autumn.balances.trackTokens({
customerId: "cus_123",
modelId: "anthropic/claude-opus-4-6",
inputTokens: 800, // excludes the cached tokens below
outputTokens: 350, // excludes the reasoning tokens below
cacheReadTokens: 1000,
cacheWriteTokens: 200,
reasoningTokens: 150
});
```
```typescript OpenRouter (nested path)
await autumn.balances.trackTokens({
customerId: "cus_123",
modelId: "openrouter/anthropic/claude-opus-4-6",
inputTokens: 2000,
outputTokens: 1000
});
```
```typescript With explicit feature
await autumn.balances.trackTokens({
customerId: "cus_123",
featureId: "ai_credits",
modelId: "anthropic/claude-haiku-4-5",
inputTokens: 2000,
outputTokens: 1000
});
```
</CodeGroup>
### Token Pools
Each token parameter is an exclusive pool — no token should be counted in more than one. `input_tokens` is non-cached text input only (cached tokens go in `cache_read_tokens` / `cache_write_tokens`), and `output_tokens` is text output only (reasoning tokens go in `reasoning_tokens`, audio in `audio_input_tokens` / `audio_output_tokens`). Each pool is billed at the model's published rate for that pool, falling back to the text input/output rate when the model has none.
<Warning>
If you pass a provider's raw totals (e.g. OpenAI's `prompt_tokens` and `completion_tokens`), subtract the cache and reasoning counts first — otherwise those tokens are billed twice. The [`@useautumn/ai-sdk` wrapper](/documentation/external-providers/ai-sdk) does this normalization for you.
</Warning>
### Markup Resolution
Markups are optional — the credit system's default markup applies unless overridden per provider or per model. With no markups set, the Models.dev base cost is charged as-is. A markup of `-100` makes the model free — the usage event is still recorded, but nothing is deducted. See [AI Credit Systems](/documentation/modelling-pricing/credit-systems#ai-credit-systems) for configuration.
The recorded event's `properties` include the full pricing breakdown: `cost`, `base_cost`, `markup`, `markup_source` (`model`, `provider`, or `default`), `tier_applied` (whether large-context tier pricing applied), and the per-pool `rates` used.
<Tip>
`feature_id` is auto-detected when the customer has exactly one AI credit system. The request fails if the customer has none, or has more than one and `feature_id` is omitted.
</Tip>

View File

@@ -0,0 +1,206 @@
---
title: "Track Token Usage"
openapi: "openapi POST /v1/balances.track_tokens"
---
import { DynamicParamField } from "/snippets/dynamic-param-field.jsx";
import { DynamicResponseField } from "/snippets/dynamic-response-field.jsx";
import { DynamicResponseExample } from "/snippets/dynamic-response-example.jsx";
<Note>
Track AI token usage against a customer's AI credit system balance. Converts token counts to a dollar cost using [Models.dev](https://models.dev) pricing and your configured markup, then deducts from the customer's credit balance.
</Note>
The `model_id` must use `provider/model` format, matching the provider and model keys from [Models.dev](https://models.dev). For providers with nested model paths (like OpenRouter), include the full path: `openrouter/anthropic/claude-opus-4.6`. The first path segment is the provider key used for provider-level markup lookup.
### Common Use Cases
<CodeGroup>
```typescript Anthropic
await autumn.balances.trackTokens({
customerId: "cus_123",
modelId: "anthropic/claude-opus-4-6",
inputTokens: 1000,
outputTokens: 500
});
```
```typescript With cache + reasoning
await autumn.balances.trackTokens({
customerId: "cus_123",
modelId: "anthropic/claude-opus-4-6",
inputTokens: 800, // excludes the cached tokens below
outputTokens: 350, // excludes the reasoning tokens below
cacheReadTokens: 1000,
cacheWriteTokens: 200,
reasoningTokens: 150
});
```
```typescript OpenRouter (nested path)
await autumn.balances.trackTokens({
customerId: "cus_123",
modelId: "openrouter/anthropic/claude-opus-4.6",
inputTokens: 2000,
outputTokens: 1000
});
```
```typescript With explicit feature
await autumn.balances.trackTokens({
customerId: "cus_123",
featureId: "ai_credits",
modelId: "anthropic/claude-haiku-4-5",
inputTokens: 2000,
outputTokens: 1000
});
```
</CodeGroup>
### Token Pools
Each token parameter is an exclusive pool — no token should be counted in more than one. Each pool is billed at the model's published rate for that pool, falling back to the text input/output rate when the model has none.
<Warning>
If you pass a provider's raw totals (e.g. OpenAI's `prompt_tokens` and `completion_tokens`), subtract the cache and reasoning counts first — otherwise those tokens are billed twice. The [`@useautumn/ai-sdk` wrapper](/documentation/external-providers/ai-sdk) does this normalization for you.
</Warning>
### Markup Resolution
Markups are optional — the credit system's default markup applies unless overridden per provider or per model. With no markups set, the Models.dev base cost is charged as-is. A markup of `-100` makes the model free — the usage event is still recorded, but nothing is deducted. See [AI Credit Systems](/documentation/modelling-pricing/credit-systems#ai-credit-systems) for configuration.
<Tip>
`feature_id` is auto-detected when the customer has exactly one AI credit system. The request fails if the customer has none, or has more than one and `feature_id` is omitted.
</Tip>
### Body Parameters
<DynamicParamField body="customer_id" type="string" required>
The ID of the customer.
</DynamicParamField>
<DynamicParamField body="model_id" type="string" required>
The AI model in `provider/model` format, matching keys from [Models.dev](https://models.dev) (e.g., `anthropic/claude-opus-4-6`, `openai/gpt-4o`, `openrouter/anthropic/claude-opus-4.6`).
</DynamicParamField>
<DynamicParamField body="input_tokens" type="number" required>
Number of non-cached text input tokens consumed. Exclusive of the cache and audio token pools.
</DynamicParamField>
<DynamicParamField body="output_tokens" type="number" required>
Number of text output tokens consumed. Exclusive of the reasoning and audio output pools.
</DynamicParamField>
<DynamicParamField body="cache_read_tokens" type="number">
Number of cached input tokens read, billed at the model's cache read rate.
</DynamicParamField>
<DynamicParamField body="cache_write_tokens" type="number">
Number of input tokens written to the cache, billed at the model's cache write rate.
</DynamicParamField>
<DynamicParamField body="reasoning_tokens" type="number">
Number of reasoning tokens generated, billed at the model's reasoning rate (falls back to the output rate).
</DynamicParamField>
<DynamicParamField body="audio_input_tokens" type="number">
Number of audio input tokens consumed, billed at the model's audio input rate (falls back to the input rate).
</DynamicParamField>
<DynamicParamField body="audio_output_tokens" type="number">
Number of audio output tokens generated, billed at the model's audio output rate (falls back to the output rate).
</DynamicParamField>
<DynamicParamField body="feature_id" type="string">
The ID of the AI credit system feature. If omitted, automatically detects the customer's AI credit system feature. Required when the customer has more than one.
</DynamicParamField>
<DynamicParamField body="entity_id" type="string">
The ID of the entity for entity-scoped balances.
</DynamicParamField>
<DynamicParamField body="properties" type="object">
Additional properties to attach to this usage event. The token counts and a pricing breakdown (`cost`, `base_cost`, `markup`, `markup_source`, `tier_applied`, `rates`) are automatically included.
</DynamicParamField>
### Response
<DynamicResponseField name="customer_id" type="string">
The ID of the customer whose token usage was tracked.
</DynamicResponseField>
<DynamicResponseField name="value" type="number">
The dollar cost that was deducted from the customer's AI credit balance.
</DynamicResponseField>
<DynamicResponseField name="balance" type="object | null">
The updated balance for the AI credit system feature.
<Expandable title="properties">
<DynamicResponseField name="feature_id" type="string">
The feature ID this balance is for.
</DynamicResponseField>
<DynamicResponseField name="granted" type="number">
Total balance granted (included + prepaid).
</DynamicResponseField>
<DynamicResponseField name="remaining" type="number">
Remaining balance available for use.
</DynamicResponseField>
<DynamicResponseField name="usage" type="number">
Total usage consumed in the current period.
</DynamicResponseField>
<DynamicResponseField name="unlimited" type="boolean">
Whether this feature has unlimited usage.
</DynamicResponseField>
<DynamicResponseField name="overage_allowed" type="boolean">
Whether usage beyond the granted balance is allowed.
</DynamicResponseField>
<DynamicResponseField name="next_reset_at" type="number | null">
Timestamp when the balance will reset, or null for no reset.
</DynamicResponseField>
</Expandable>
</DynamicResponseField>
<ResponseExample>
```json 200
{
"customer_id": "cus_123",
"value": 0.06,
"balance": {
"feature_id": "ai_credits",
"granted": 10.00,
"remaining": 9.94,
"usage": 0.06,
"unlimited": false,
"overage_allowed": false,
"next_reset_at": 1773851121437,
"breakdown": [
{
"id": "cus_ent_abc123",
"plan_id": "pro_plan",
"included_grant": 10.00,
"prepaid_grant": 0,
"remaining": 9.94,
"usage": 0.06,
"unlimited": false,
"reset": {
"interval": "month",
"resets_at": 1773851121437
},
"price": null,
"expires_at": null
}
]
}
}
```
</ResponseExample>

View File

@@ -133,6 +133,7 @@
"documentation/slack-discord-notifications",
"documentation/fail-open",
"documentation/rate-limits",
"documentation/external-providers/ai-sdk",
"documentation/external-providers/convex",
"documentation/external-providers/revenuecat",
"documentation/external-providers/vercel-marketplace"
@@ -205,6 +206,7 @@
"pages": [
"api-reference/core/check",
"api-reference/core/track",
"api-reference/balances/trackTokens",
"api-reference/core/batchTrack",
"api-reference/balances/createBalance",
"api-reference/balances/updateBalance",

View File

@@ -131,6 +131,85 @@ curl -X POST "https://api.useautumn.com/v1/balances/update" \
can reset or override incremental usage recorded through events.
</Warning>
## Tracking AI Token Usage
If you're using an [AI credit system](/examples/monetary-credits), you can track token usage directly with `trackTokens`. This automatically converts token counts to a dollar cost using [Models.dev](https://models.dev) pricing and your configured markup, then deducts from the customer's credit balance.
The `modelId` must be in `provider/model` format, matching the provider and model keys from [Models.dev](https://models.dev). For example:
- `anthropic/claude-sonnet-4-5-20250514`
- `openai/gpt-4o`
- `google/gemini-2.5-pro`
For providers with nested model paths (like OpenRouter), include the full path after the provider: `openrouter/anthropic/claude-opus-4.6`.
Token counts are **exclusive pools**: `inputTokens` should exclude cached tokens (pass those as `cacheReadTokens` / `cacheWriteTokens`) and `outputTokens` should exclude reasoning tokens (pass those as `reasoningTokens`). Audio tokens go in `audioInputTokens` / `audioOutputTokens`. See the [API reference](/api-reference/balances/trackTokens) for the full parameter list.
<Note>
`autumn.balances.trackTokens` requires an autumn-js release that includes
the method. On older versions, call the REST endpoint directly — see the
cURL tab below.
</Note>
<CodeGroup>
```typescript TypeScript
import { Autumn } from "autumn-js";
const autumn = new Autumn({ secretKey: "am_sk_test_1234" });
await autumn.balances.trackTokens({
customerId: "user_123",
modelId: "anthropic/claude-opus-4-6",
inputTokens: 1000,
outputTokens: 500,
});
```
```python Python
from autumn_sdk import Autumn
autumn = Autumn("am_sk_test_1234")
await autumn.balances.track_tokens(
customer_id="user_123",
model_id="anthropic/claude-opus-4-6",
input_tokens=1000,
output_tokens=500,
)
```
```bash cURL
curl -X POST "https://api.useautumn.com/v1/balances.track_tokens" \
-H "Authorization: Bearer am_sk_test_1234" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"model_id": "anthropic/claude-opus-4-6",
"input_tokens": 1000,
"output_tokens": 500
}'
```
</CodeGroup>
<Tip>
If the customer has exactly one AI credit system feature, you can omit the
`featureId` parameter — it will be auto-detected. The request fails with an
error if the customer has no AI credit system, or has more than one and no
`featureId` is provided.
</Tip>
### Vercel AI SDK integration
If you're using the [Vercel AI SDK](https://sdk.vercel.ai), the `@useautumn/ai-sdk` package can automatically track token usage for every `generateText` or `streamText` call — no manual `trackTokens` calls needed.
<Card
title="Vercel AI SDK Integration"
horizontal
href="/documentation/external-providers/ai-sdk"
icon="wand-magic-sparkles"
/>
## Using Event Names
In the above examples, we used the `featureId` to identify the feature. You can instead use the `eventName` parameter to link balances to different events in your application. This can be useful when:

View File

@@ -0,0 +1,145 @@
---
title: "Vercel AI SDK"
description: "Automatically track AI token usage with the Vercel AI SDK"
---
The `@useautumn/ai-sdk` package integrates Autumn with the [Vercel AI SDK](https://sdk.vercel.ai), automatically tracking token usage for every `generateText` or `streamText` call. No manual `trackTokens` calls needed.
## Setup
#### 1. Install the package
<CodeGroup>
```bash npm
npm install @useautumn/ai-sdk
```
```bash pnpm
pnpm add @useautumn/ai-sdk
```
```bash yarn
yarn add @useautumn/ai-sdk
```
```bash bun
bun add @useautumn/ai-sdk
```
</CodeGroup>
<Note>
Requires `autumn-js` and `ai` (v6+) as peer dependencies.
</Note>
#### 2. Wrap your model
Use `withAutumn` to wrap any AI SDK language model. It intercepts generate and stream calls, reads the token usage from the response, and reports it to Autumn automatically.
```typescript
import { Autumn } from "autumn-js";
import { anthropic } from "@ai-sdk/anthropic";
import { withAutumn } from "@useautumn/ai-sdk";
const autumn = new Autumn({ secretKey: "am_sk_test_1234" });
const model = withAutumn({
autumn,
model: anthropic("claude-sonnet-4-5-20250514"),
customerId: "user_123",
});
```
#### 3. Use as normal
The wrapped model works exactly like a regular AI SDK model. Token usage is tracked in the background after each call.
```typescript
import { generateText, streamText } from "ai";
// Generate — usage tracked automatically
const { text } = await generateText({
model,
prompt: "Explain quantum computing in one paragraph",
});
// Stream — usage tracked when the stream finishes
const result = streamText({
model,
prompt: "Write a short poem about recursion",
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}
```
## Token pools
The wrapper normalizes the AI SDK's usage object into the exclusive token pools that [trackTokens](/api-reference/balances/trackTokens) expects: text input (excluding cached tokens), text output (excluding reasoning tokens), cache reads, cache writes, and reasoning tokens. Each pool is billed at the model's published rate, so cached and reasoning-heavy requests are priced correctly without any extra work.
## Model ID format
The wrapped model constructs the `modelId` sent to Autumn using `provider/model` format, derived from the AI SDK model's `provider` and `modelId` fields. This must match a valid provider and model key from [Models.dev](https://models.dev).
For example:
- `@ai-sdk/anthropic` → `anthropic/claude-sonnet-4-5-20250514`
- `@ai-sdk/openai` → `openai/gpt-4o`
- `@ai-sdk/google` → `google/gemini-2.5-pro`
If the AI SDK provider name doesn't match the Models.dev provider key, use the `providerId` option to override it:
```typescript
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
const openrouter = createOpenRouter();
const model = withAutumn({
autumn,
model: openrouter("anthropic/claude-opus-4.6"),
customerId: "user_123",
providerId: "openrouter", // Override provider prefix
});
// Sends modelId as "openrouter/anthropic/claude-opus-4.6"
```
## Options
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `autumn` | `Autumn` | Yes | Your Autumn SDK client instance |
| `model` | `LanguageModelV3` | Yes | The AI SDK language model to wrap |
| `customerId` | `string` | Yes | The Autumn customer ID to attribute usage to |
| `providerId` | `string` | No | Override the provider prefix in the model name sent to Autumn. Falls back to the model's `provider` field |
| `featureId` | `string` | No | Target a specific AI credit system feature. Auto-detected if you only have one |
| `entityId` | `string` | No | Entity ID for entity-scoped balance tracking |
| `properties` | `Record<string, unknown>` | No | Additional properties to attach to each usage event |
## Full example
```typescript
import { Autumn } from "autumn-js";
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
import { withAutumn } from "@useautumn/ai-sdk";
const autumn = new Autumn({ secretKey: process.env.AUTUMN_SECRET_KEY! });
async function chat(customerId: string, message: string) {
const model = withAutumn({
autumn,
model: openai("gpt-4o"),
customerId,
});
const { text } = await generateText({
model,
prompt: message,
});
return text;
}
```
<Tip>
Tracking failures are caught and logged to the console — they won't break your AI features. Check your server logs if usage isn't appearing in Autumn.
</Tip>

View File

@@ -271,3 +271,137 @@ See the credits pricing guide for a more detailed example of setting up a moneta
href="/examples/monetary-credits"
icon="money-bills"
/>
## AI Credit Systems
For AI applications that need to track token usage with per-model pricing, you can create an AI credit system. This lets you define markup percentages for each model and automatically calculate costs based on input/output tokens.
<Tabs>
<Tab title="CLI">
Markups are optional. `defaultMarkup` applies to every model unless overridden — by `providerMarkups` (keyed by the first segment of the model ID, e.g. `openrouter`), or by `modelMarkups` for a specific model, which takes highest priority. With no markups set, models are billed at their Models.dev base cost.
A markup of `-100` makes the model free: usage events are still recorded, but nothing is deducted from the balance.
```ts Simplest setup — one markup for everything
export const aiCredits = feature({
id: 'ai_credits',
name: 'AI Credits',
type: 'ai_credit_system',
defaultMarkup: 30, // every model billed at models.dev cost + 30%
});
```
Or mix the levels for finer control:
```ts autumn.config.ts
import { feature, item, plan } from 'atmn';
export const aiCredits = feature({
id: 'ai_credits',
name: 'AI Credits',
type: 'ai_credit_system',
// Global fallback markup
defaultMarkup: 30,
// Per-provider defaults
providerMarkups: {
openrouter: { markup: 25 },
},
// Per-model overrides (highest priority)
modelMarkups: {
'anthropic/claude-opus-4-5': { markup: 20 },
'anthropic/claude-sonnet-4-5': { markup: 15 },
'openai/gpt-4o-mini': { markup: -100 }, // free for customers
// For custom/self-hosted models, specify input/output costs in $/M tokens
'custom/my-model': { markup: 25, inputCost: 0.01, outputCost: 0.03 },
},
});
export const pro = plan({
id: 'pro',
name: 'Pro',
price: { amount: 50, interval: 'month' },
items: [
item({
featureId: aiCredits.id,
included: 10, // $10 worth of AI credits
reset: { interval: 'month' },
}),
],
});
```
Push changes with `atmn push`.
</Tab>
<Tab title="Dashboard">
1. Navigate to the features page, under Plans.
2. Click "Create Credit System"
3. Toggle "AI Credit System" to enable model-based pricing
4. Set a default markup %, and optionally add providers with their own default markups
5. Add the models you want to support, overriding the markup per model where needed
6. For custom models, also specify input/output costs per million tokens
7. Click "Create"
</Tab>
</Tabs>
### Model ID Format
Model IDs follow the `provider/model` format:
- Standard models: `anthropic/claude-opus-4-5`, `openai/gpt-4o`
- OpenRouter models: `openrouter/anthropic/claude-opus-4.6`
- Custom models: `custom/my-model-name`
For standard models, pricing is automatically fetched from models.dev, including separate rates for cache reads/writes, reasoning, and audio tokens where the model publishes them, plus large-context tier pricing (e.g. above 200k input tokens) when applicable.
For custom models, you must specify both `inputCost` and `outputCost` in dollars per million tokens — tracking fails if either is missing. Custom models bill input and output tokens only; cache, reasoning, and audio pools are ignored.
### Tracking Token Usage
Use the `trackTokens` endpoint to deduct credits based on token usage:
<CodeGroup>
```typescript TypeScript
import { Autumn } from "autumn-js";
const autumn = new Autumn({ secretKey: "am_sk_test_1234" });
await autumn.balances.trackTokens({
customerId: "user_123",
modelId: "anthropic/claude-opus-4-5",
inputTokens: 1500,
outputTokens: 500,
});
```
```python Python
from autumn_sdk import Autumn
autumn = Autumn("am_sk_test_1234")
await autumn.balances.track_tokens(
customer_id="user_123",
model_id="anthropic/claude-opus-4-5",
input_tokens=1500,
output_tokens=500,
)
```
```bash cURL
curl -X POST "https://api.useautumn.com/v1/balances.track_tokens" \
-H "Authorization: Bearer am_sk_test_1234" \
-H "Content-Type: application/json" \
-d '{
"customer_id": "user_123",
"model_id": "anthropic/claude-opus-4-5",
"input_tokens": 1500,
"output_tokens": 500
}'
```
</CodeGroup>
The cost is calculated automatically based on the model's pricing plus your configured markup percentage.

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "autumn",
@@ -177,6 +178,22 @@
"typescript": "^6.0.2",
},
},
"packages/ai-sdk": {
"name": "@useautumn/ai-sdk",
"version": "0.0.1",
"dependencies": {
"@ai-sdk/provider": "^3.0.0",
},
"devDependencies": {
"@types/node": "^24.9.1",
"tsup": "^8.4.0",
"typescript": "^5.8.3",
},
"peerDependencies": {
"ai": "^6.0.116",
"autumn-js": "*",
},
},
"packages/atmn": {
"name": "atmn",
"version": "1.1.8",
@@ -2722,6 +2739,8 @@
"@upstash/redis": ["@upstash/redis@1.38.0", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg=="],
"@useautumn/ai-sdk": ["@useautumn/ai-sdk@workspace:packages/ai-sdk"],
"@useautumn/sdk": ["@useautumn/sdk@workspace:packages/sdk"],
"@vdemedes/prettier-config": ["@vdemedes/prettier-config@2.0.1", "", {}, "sha512-lcHyyLfS2ro282qsXKpxw+canUkOlFIGoanxt3BaNCm5K1NR8k4hGvYbFO54/+QWq12d0y/EYRz68yNQkqWFrw=="],
@@ -7084,6 +7103,8 @@
"@typespec/ts-http-runtime/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"@useautumn/ai-sdk/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"@useautumn/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
"@vercel/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],

View File

@@ -12,6 +12,7 @@
"duplicates"
],
"ignoreWorkspaces": [
"packages/ai-sdk",
"packages/atmn",
"packages/autumn-js",
"packages/mcp",

View File

@@ -22,7 +22,8 @@
"packages/autumn-js",
"packages/openapi",
"packages/ksuid",
"packages/stripe-sync"
"packages/stripe-sync",
"packages/ai-sdk"
],
"catalog": {
"stripe": "19.3.0-beta.1",
@@ -137,7 +138,7 @@
"site": "cd apps/website && bun dev && cd ../..",
"docs": "bun -F @autumn/docs dev",
"docs:build": "bun -F @autumn/docs build",
"ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf",
"ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@useautumn/ai-sdk --filter=@autumn/auth --filter=@autumn/mcp --filter=@autumn/leaf",
"kill:ts": "while pgrep -f tsgo > /dev/null; do pkill -9 -f tsgo; sleep 0.1; done",
"atmn:build": "bun -F atmn build",
"openapi:ts": "bun -F @autumn/openapi ts",

253
packages/ai-sdk/bun.lock Normal file
View File

@@ -0,0 +1,253 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "@useautumn/ai-sdk",
"devDependencies": {
"@types/node": "^24.9.1",
"tsup": "^8.4.0",
"typescript": "^5.8.3",
},
"peerDependencies": {
"ai": "^6.0.116",
"autumn-js": "*",
},
},
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.66", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="],
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="],
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="],
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ai": ["ai@6.0.116", "", { "dependencies": { "@ai-sdk/gateway": "3.0.66", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"autumn-js": ["autumn-js@1.0.5", "", { "dependencies": { "query-string": "^9.2.2", "rou3": "^0.6.1", "zod": "^4.0.0" }, "peerDependencies": { "better-auth": "^1.3.17", "better-call": "^1.0.12", "express": "^5.2.1", "hono": "^4.0.0", "next": "^14.0.0 || ^15.0.0", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["better-auth", "better-call", "express", "hono", "next", "react"] }, "sha512-Xh4hqx7EO+hilVjCuilLjUt5iw6RP8vxKmkKmwwHM8WhrHH4StAol17Qis9FtZ/ixeHfe4TOFojzgLhP74kETw=="],
"bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-uri-component": ["decode-uri-component@0.4.1", "", {}, "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ=="],
"esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"filter-obj": ["filter-obj@5.1.0", "", {}, "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng=="],
"fix-dts-default-cjs-exports": ["fix-dts-default-cjs-exports@1.0.1", "", { "dependencies": { "magic-string": "^0.30.17", "mlly": "^1.7.4", "rollup": "^4.34.8" } }, "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"load-tsconfig": ["load-tsconfig@0.2.5", "", {}, "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"query-string": ["query-string@9.3.1", "", { "dependencies": { "decode-uri-component": "^0.4.1", "filter-obj": "^5.1.0", "split-on-first": "^3.0.0" } }, "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"rou3": ["rou3@0.6.3", "", {}, "sha512-1HSG1ENTj7Kkm5muMnXuzzfdDOf7CFnbSYFA+H3Fp/rB9lOCxCPgy1jlZxTKyFoC5jJay8Mmc+VbPLYRjzYLrA=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"split-on-first": ["split-on-first@3.0.0", "", {}, "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsup": ["tsup@8.5.1", "", { "dependencies": { "bundle-require": "^5.1.0", "cac": "^6.7.14", "chokidar": "^4.0.3", "consola": "^3.4.0", "debug": "^4.4.0", "esbuild": "^0.27.0", "fix-dts-default-cjs-exports": "^1.0.0", "joycon": "^3.1.1", "picocolors": "^1.1.1", "postcss-load-config": "^6.0.1", "resolve-from": "^5.0.0", "rollup": "^4.34.8", "source-map": "^0.7.6", "sucrase": "^3.35.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.11", "tree-kill": "^1.2.2" }, "peerDependencies": { "@microsoft/api-extractor": "^7.36.0", "@swc/core": "^1", "postcss": "^8.4.12", "typescript": ">=4.5.0" }, "optionalPeers": ["@microsoft/api-extractor", "@swc/core", "postcss", "typescript"], "bin": { "tsup": "dist/cli-default.js", "tsup-node": "dist/cli-node.js" } }, "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
}
}

View File

@@ -0,0 +1,39 @@
{
"name": "@useautumn/ai-sdk",
"version": "0.0.1",
"type": "module",
"description": "AI SDK for Autumn",
"main": "./dist/sdk/index.cjs",
"module": "./dist/sdk/index.js",
"types": "./dist/sdk/index.d.ts",
"files": [
"dist",
"README.md",
"LICENSE.md"
],
"exports": {
".": {
"types": "./dist/sdk/index.d.ts",
"require": "./dist/sdk/index.cjs",
"import": "./dist/sdk/index.js"
}
},
"scripts": {
"ts": "tsgo --noEmit --skipLibCheck",
"test": "bun test tests/unit",
"build": "rm -rf dist && tsup",
"prepublishOnly": "bun run build"
},
"dependencies": {
"@ai-sdk/provider": "^3.0.0"
},
"peerDependencies": {
"ai": "^6.0.116",
"autumn-js": "*"
},
"devDependencies": {
"@types/node": "^24.9.1",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,107 @@
import type { LanguageModelV3 } from "@ai-sdk/provider";
import { type LanguageModelMiddleware, wrapLanguageModel } from "ai";
import { normalizeUsage, type TokenPools, type UsageLike } from "./usage.js";
export type { TokenPools, UsageLike } from "./usage.js";
type TrackTokensParams = TokenPools & {
customerId: string;
modelId: string;
featureId?: string;
entityId?: string;
properties?: Record<string, unknown>;
};
/** Structural view of the autumn-js client; older versions may not ship balances.trackTokens. */
export type AutumnClient = {
balances?: {
trackTokens?: (params: TrackTokensParams) => Promise<unknown>;
};
};
export type WithAutumnOptions = {
/** Autumn SDK client instance. */
autumn: AutumnClient;
/** The AI SDK language model to wrap. */
model: LanguageModelV3;
/** The Autumn customer ID to attribute usage to. */
customerId: string;
/** Override the provider prefix used in the model name (e.g. "openrouter", "custom"). Falls back to `model.provider`. */
providerId?: string;
/** Target a specific AI credit system feature. Auto-detected if omitted. */
featureId?: string;
/** Entity ID for entity-scoped balance tracking. */
entityId?: string;
/** Additional properties to attach to each usage event. */
properties?: Record<string, unknown>;
};
export const withAutumn = ({
autumn,
model,
customerId,
providerId,
featureId,
entityId,
properties,
}: WithAutumnOptions): LanguageModelV3 => {
const modelName = `${providerId ?? model.provider}/${model.modelId}`;
const trackUsage = async (usage: UsageLike) => {
try {
const trackTokens = autumn.balances?.trackTokens;
if (!trackTokens) {
throw new Error(
"autumn-js client does not support balances.trackTokens — upgrade autumn-js.",
);
}
await trackTokens({
...normalizeUsage(usage, modelName),
customerId,
modelId: modelName,
featureId,
entityId,
properties,
});
} catch (error) {
console.error("[Autumn Tracking] Failed to track usage:", error);
}
};
const middleware: LanguageModelMiddleware = {
specificationVersion: "v3",
wrapGenerate: async ({ doGenerate }) => {
const result = await doGenerate();
await trackUsage(result.usage as UsageLike);
return result;
},
wrapStream: async ({ doStream }) => {
const { stream, ...rest } = await doStream();
let trackingPromise: Promise<void> | undefined;
type StreamChunk = typeof stream extends ReadableStream<infer T>
? T
: never;
const transformStream = new TransformStream<StreamChunk, StreamChunk>({
transform(chunk, controller) {
if (chunk.type === "finish" && chunk.usage) {
trackingPromise = trackUsage(chunk.usage as UsageLike);
}
controller.enqueue(chunk);
},
async flush() {
await trackingPromise;
},
});
return {
stream: stream.pipeThrough(transformStream),
...rest,
};
},
};
return wrapLanguageModel({ model, middleware });
};

View File

@@ -0,0 +1,117 @@
type NestedTokens = {
total?: number | null;
noCache?: number | null;
cacheRead?: number | null;
cacheWrite?: number | null;
text?: number | null;
reasoning?: number | null;
};
type LegacyCount = number | { total?: number | null } | null;
/** Lenient view over AI SDK usage shapes: nested V3 counts, flat counts with token details, and legacy prompt/completion counts. */
export type UsageLike = {
inputTokens?: number | NestedTokens | null;
outputTokens?: number | NestedTokens | null;
promptTokens?: LegacyCount;
completionTokens?: LegacyCount;
inputTokenDetails?: {
noCacheTokens?: number | null;
cacheReadTokens?: number | null;
cacheWriteTokens?: number | null;
} | null;
outputTokenDetails?: {
textTokens?: number | null;
reasoningTokens?: number | null;
} | null;
cachedInputTokens?: number | null;
reasoningTokens?: number | null;
};
export type TokenPools = {
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheWriteTokens: number;
reasoningTokens: number;
};
const flatCount = (value: LegacyCount | undefined): number | undefined =>
typeof value === "number" ? value : (value?.total ?? undefined);
const isNested = (
value: number | NestedTokens | null | undefined,
): value is NestedTokens => value != null && typeof value === "object";
const toParts = (usage: UsageLike) => {
const input = usage.inputTokens;
const output = usage.outputTokens;
if (isNested(input)) {
const out = isNested(output) ? output : undefined;
return {
cacheRead: input.cacheRead ?? 0,
cacheWrite: input.cacheWrite ?? 0,
reasoning: out?.reasoning ?? 0,
textInput: input.noCache,
totalInput: input.total,
textOutput: out?.text,
totalOutput: out?.total,
};
}
return {
cacheRead:
usage.inputTokenDetails?.cacheReadTokens ?? usage.cachedInputTokens ?? 0,
cacheWrite: usage.inputTokenDetails?.cacheWriteTokens ?? 0,
reasoning:
usage.outputTokenDetails?.reasoningTokens ?? usage.reasoningTokens ?? 0,
textInput: usage.inputTokenDetails?.noCacheTokens,
totalInput:
typeof input === "number" ? input : flatCount(usage.promptTokens),
textOutput: usage.outputTokenDetails?.textTokens,
totalOutput:
typeof output === "number" ? output : flatCount(usage.completionTokens),
};
};
const clamp = (value: number) => Math.max(0, value);
/** Splits provider usage into exclusive token pools; throws if the provider returned no usable counts. */
export const normalizeUsage = (
usage: UsageLike,
modelName: string,
): TokenPools => {
const parts = toParts(usage);
const required = (
value: number | null | undefined,
label: string,
): number => {
if (value == null) {
throw new Error(
`[Autumn] ${label} token usage was not returned by the model provider (${modelName}). This provider may not support usage tracking.`,
);
}
return value;
};
const textInput =
parts.textInput ??
(parts.totalInput != null
? parts.totalInput - parts.cacheRead - parts.cacheWrite
: undefined);
const textOutput =
parts.textOutput ??
(parts.totalOutput != null
? parts.totalOutput - parts.reasoning
: undefined);
return {
inputTokens: clamp(required(textInput, "Input")),
outputTokens: clamp(required(textOutput, "Output")),
cacheReadTokens: clamp(parts.cacheRead),
cacheWriteTokens: clamp(parts.cacheWrite),
reasoningTokens: clamp(parts.reasoning),
};
};

View File

@@ -0,0 +1,144 @@
import { describe, expect, test } from "bun:test";
import type { LanguageModelV3, LanguageModelV3Usage } from "@ai-sdk/provider";
import { generateText, streamText } from "ai";
import { withAutumn } from "../../src/index.js";
type TrackTokensParams = {
customerId: string;
modelId: string;
inputTokens: number;
outputTokens: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
reasoningTokens?: number;
featureId?: string;
entityId?: string;
properties?: Record<string, unknown>;
};
const usage: LanguageModelV3Usage = {
inputTokens: {
total: 13,
noCache: 10,
cacheRead: 2,
cacheWrite: 1,
},
outputTokens: {
total: 7,
text: 5,
reasoning: 2,
},
};
const finishReason = { unified: "stop" as const, raw: "stop" };
const createAutumn = () => {
const calls: TrackTokensParams[] = [];
return {
calls,
autumn: {
balances: {
trackTokens: async (params: TrackTokensParams) => {
calls.push(params);
},
},
},
};
};
const createModel = (): LanguageModelV3 => ({
specificationVersion: "v3",
provider: "openai",
modelId: "gpt-test",
supportedUrls: {},
async doGenerate() {
return {
content: [{ type: "text", text: "hello" }],
finishReason,
usage,
warnings: [],
};
},
async doStream() {
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "text-1" });
controller.enqueue({
type: "text-delta",
id: "text-1",
delta: "hello",
});
controller.enqueue({ type: "text-end", id: "text-1" });
controller.enqueue({ type: "finish", finishReason, usage });
controller.close();
},
}),
};
},
});
describe("withAutumn", () => {
test("tracks token usage from generateText", async () => {
const { autumn, calls } = createAutumn();
const model = withAutumn({
autumn,
model: createModel(),
customerId: "cus_test",
featureId: "ai_credits",
entityId: "entity_test",
properties: { source: "test" },
});
const result = await generateText({ model, prompt: "Say hello" });
expect(result.text).toBe("hello");
expect(calls).toEqual([
{
customerId: "cus_test",
modelId: "openai/gpt-test",
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 2,
cacheWriteTokens: 1,
reasoningTokens: 2,
featureId: "ai_credits",
entityId: "entity_test",
properties: { source: "test" },
},
]);
});
test("tracks token usage from streamText when the stream finishes", async () => {
const { autumn, calls } = createAutumn();
const model = withAutumn({
autumn,
model: createModel(),
customerId: "cus_stream",
providerId: "custom-openai",
});
const result = streamText({ model, prompt: "Say hello" });
const chunks: string[] = [];
for await (const chunk of result.textStream) {
chunks.push(chunk);
}
expect(chunks.join("")).toBe("hello");
expect(calls).toEqual([
{
customerId: "cus_stream",
modelId: "custom-openai/gpt-test",
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 2,
cacheWriteTokens: 1,
reasoningTokens: 2,
},
]);
});
});

View File

@@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import { normalizeUsage } from "../../src/usage.js";
const MODEL = "openai/gpt-test";
describe("normalizeUsage", () => {
test("nested V3 counts split into exclusive pools", () => {
expect(
normalizeUsage(
{
inputTokens: { total: 13, noCache: 10, cacheRead: 2, cacheWrite: 1 },
outputTokens: { total: 7, text: 5, reasoning: 2 },
},
MODEL,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 2,
cacheWriteTokens: 1,
reasoningTokens: 2,
});
});
test("nested totals without breakdowns derive text pools", () => {
expect(
normalizeUsage(
{
inputTokens: { total: 13, cacheRead: 2, cacheWrite: 1 },
outputTokens: { total: 7, reasoning: 2 },
},
MODEL,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 2,
cacheWriteTokens: 1,
reasoningTokens: 2,
});
});
test("flat counts with token details", () => {
expect(
normalizeUsage(
{
inputTokens: 13,
outputTokens: 7,
inputTokenDetails: { cacheReadTokens: 2, cacheWriteTokens: 1 },
outputTokenDetails: { reasoningTokens: 2 },
},
MODEL,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 2,
cacheWriteTokens: 1,
reasoningTokens: 2,
});
});
test("legacy prompt/completion counts", () => {
expect(
normalizeUsage(
{ promptTokens: 100, completionTokens: { total: 50 } },
MODEL,
),
).toEqual({
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
});
});
test("inconsistent totals clamp to zero instead of going negative", () => {
const pools = normalizeUsage(
{
inputTokens: { total: 1, cacheRead: 5, cacheWrite: 0 },
outputTokens: { total: 1, reasoning: 5 },
},
MODEL,
);
expect(pools.inputTokens).toBe(0);
expect(pools.outputTokens).toBe(0);
});
test("missing usage throws with the model name", () => {
expect(() => normalizeUsage({}, MODEL)).toThrow(/gpt-test/);
});
});

View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"strict": true,
"noImplicitAny": true,
"esModuleInterop": true,
"strictNullChecks": true,
"target": "ES2022",
"moduleResolution": "NodeNext",
"module": "NodeNext",
"declaration": true,
"isolatedModules": true,
"noEmit": true,
"outDir": "dist",
"lib": ["ES2022"],
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
"sdk/index": "src/index.ts",
},
format: ["cjs", "esm"],
dts: true,
splitting: false,
sourcemap: false,
clean: true,
});

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;
}

View File

@@ -4,92 +4,108 @@
import { z } from "zod/v4";
export const FeatureSchema = z.object({
id: z.string().meta({
description:
"The unique identifier for this feature, used in /check and /track calls.",
}),
name: z.string().meta({
description:
"Human-readable name displayed in the dashboard and billing UI.",
}),
eventNames: z.array(z.string()).optional().meta({
description:
"Event names that trigger this feature's balance. Allows multiple features to respond to a single event.",
}),
creditSchema: z
.array(
z.object({
metered_feature_id: z.string().meta({
description:
"ID of the metered feature that draws from this credit system.",
}),
credit_cost: z.number().meta({
description: "Credits consumed per unit of the metered feature.",
}),
}),
)
.optional()
.meta({
description:
"For credit_system features: maps metered features to their credit costs.",
}),
archived: z.boolean().meta({
description:
"Whether the feature is archived and hidden from the dashboard.",
})
id: z.string().meta({
description:
"The unique identifier for this feature, used in /check and /track calls.",
}),
name: z.string().meta({
description:
"Human-readable name displayed in the dashboard and billing UI.",
}),
eventNames: z.array(z.string()).optional().meta({
description:
"Event names that trigger this feature's balance. Allows multiple features to respond to a single event.",
}),
creditSchema: z
.array(
z.object({
metered_feature_id: z.string().meta({
description:
"ID of the metered feature that draws from this credit system.",
}),
credit_cost: z.number().meta({
description: "Credits consumed per unit of the metered feature.",
}),
}),
)
.optional()
.meta({
description:
"For credit_system features: maps metered features to their credit costs.",
}),
archived: z.boolean().meta({
description:
"Whether the feature is archived and hidden from the dashboard.",
}),
});
// Base fields shared by all feature types
type FeatureBase = {
/** Unique identifier for the feature */
id: string;
/** Display name for the feature */
name: string;
/** Whether the feature is archived */
archived?: boolean;
/** Event names that trigger this feature */
eventNames?: string[];
/** Credit schema for credit_system features */
creditSchema?: Array<{
meteredFeatureId: string;
creditCost: number;
}>;
/** Unique identifier for the feature */
id: string;
/** Display name for the feature */
name: string;
/** Whether the feature is archived */
archived?: boolean;
/** Event names that trigger this feature */
eventNames?: string[];
/** Credit schema for credit_system features */
creditSchema?: Array<{
meteredFeatureId: string;
creditCost: number;
}>;
};
/** Boolean feature - no consumable field allowed */
export type BooleanFeature = FeatureBase & {
type: "boolean";
consumable?: never;
type: "boolean";
consumable?: never;
};
/** Metered feature - requires consumable field */
export type MeteredFeature = FeatureBase & {
type: "metered";
/** Whether usage is consumed (true) or accumulated (false) */
consumable: boolean;
type: "metered";
/** Whether usage is consumed (true) or accumulated (false) */
consumable: boolean;
};
/** Credit system feature - always consumable */
export type CreditSystemFeature = FeatureBase & {
type: "credit_system";
/** Credit systems are always consumable */
consumable?: true;
/** Required: defines how credits map to metered features */
creditSchema: Array<{
meteredFeatureId: string;
creditCost: number;
}>;
type: "credit_system";
/** Credit systems are always consumable */
consumable?: true;
/** Required: defines how credits map to metered features */
creditSchema: Array<{
meteredFeatureId: string;
creditCost: number;
}>;
};
/**
* Feature definition with type-safe constraints:
* - Boolean features cannot have consumable
* - Metered features require consumable (true = single_use style, false = continuous_use style)
* - Credit system features are always consumable and require creditSchema
*/
export type Feature = BooleanFeature | MeteredFeature | CreditSystemFeature;
export type ModelMarkupEntry = {
/** Per-model markup override. Omit to inherit provider/global markup. */
markup?: number;
inputCost?: number;
outputCost?: number;
};
export type ProviderMarkupEntry = {
markup: number;
};
/** AI credit system feature - uses model-based pricing */
export type AiCreditSystemFeature = FeatureBase & {
type: "ai_credit_system";
/** Per-model markup overrides (highest priority). */
modelMarkups?: Record<string, ModelMarkupEntry>;
/** Default markup applied when no model or provider markup matches. */
defaultMarkup?: number;
/** Per-provider default markups, keyed by the first segment of the model id. */
providerMarkups?: Record<string, ProviderMarkupEntry>;
};
export type Feature =
| BooleanFeature
| MeteredFeature
| CreditSystemFeature
| AiCreditSystemFeature;

View File

@@ -6,14 +6,14 @@ import { createTransformer } from "./Transformer.js";
describe("Transformer", () => {
describe("Feature transforms", () => {
test("boolean feature", () => {
const apiFeature = {
const result = transformApiFeature({
id: "enabled",
name: "Feature Enabled",
type: "boolean",
consumable: false,
archived: false,
event_names: [],
};
const result = transformApiFeature(apiFeature);
});
expect(result.type).toBe("boolean");
expect(result.id).toBe("enabled");
@@ -21,47 +21,72 @@ describe("Transformer", () => {
});
test("single_use → metered with consumable=true", () => {
const apiFeature = {
const result = transformApiFeature({
id: "api_calls",
name: "API Calls",
type: "single_use",
consumable: true,
archived: false,
event_names: ["api.call"],
};
const result = transformApiFeature(apiFeature);
});
expect(result.type).toBe("metered");
expect(result.consumable).toBe(true);
if (result.type === "metered") {
expect(result.consumable).toBe(true);
}
expect(result.id).toBe("api_calls");
});
test("continuous_use → metered with consumable=false", () => {
const apiFeature = {
const result = transformApiFeature({
id: "seats",
name: "Seats",
type: "continuous_use",
consumable: false,
archived: false,
event_names: [],
};
const result = transformApiFeature(apiFeature);
});
expect(result.type).toBe("metered");
expect(result.consumable).toBe(false);
if (result.type === "metered") {
expect(result.consumable).toBe(false);
}
});
test("credit_system", () => {
const apiFeature = {
const result = transformApiFeature({
id: "credits",
name: "Credits",
type: "credit_system",
consumable: true,
archived: false,
credit_schema: [{ metered_feature_id: "api_calls", credit_cost: 10 }],
};
const result = transformApiFeature(apiFeature);
});
expect(result.type).toBe("credit_system");
expect(result.consumable).toBe(true);
expect(result.creditSchema).toHaveLength(1);
if (result.type === "credit_system") {
expect(result.consumable).toBe(true);
expect(result.creditSchema).toHaveLength(1);
}
});
test("ai_credit_system", () => {
const result = transformApiFeature({
id: "ai_credits",
name: "AI Credits",
type: "ai_credit_system",
consumable: true,
archived: false,
model_markups: {
"anthropic/claude-opus-4-5": { markup: 20 },
},
});
expect(result.type).toBe("ai_credit_system");
if (result.type === "ai_credit_system") {
expect(result.modelMarkups).toBeDefined();
expect(result.modelMarkups!["anthropic/claude-opus-4-5"].markup).toBe(20);
}
});
});

View File

@@ -1,26 +1,38 @@
import type { Feature } from "../../../compose/models/featureModels.js";
import type { Feature, ModelMarkupEntry } from "../../../compose/models/featureModels.js";
import type { ApiFeature } from "../../api/types/feature.js";
import { createTransformer } from "./Transformer.js";
type RawApiFeature = Omit<ApiFeature, "type"> & { type: string };
function mapCreditSchema(
api: any,
api: RawApiFeature,
): Array<{ meteredFeatureId: string; creditCost: number }> {
return (api.credit_schema ?? []).map(
(cs: { metered_feature_id: string; credit_cost: number }) => ({
meteredFeatureId: cs.metered_feature_id,
creditCost: cs.credit_cost,
}),
return (api.credit_schema ?? []).map((cs) => ({
meteredFeatureId: cs.metered_feature_id,
creditCost: cs.credit_cost,
}));
}
function mapModelMarkups(api: RawApiFeature): Record<string, ModelMarkupEntry> | undefined {
if (!api.model_markups) return undefined;
return Object.fromEntries(
Object.entries(api.model_markups).map(([modelId, entry]) => [
modelId,
{
markup: entry.markup,
inputCost: entry.input_cost,
outputCost: entry.output_cost,
},
])
);
}
const BASE_COMPUTE = {
eventNames: (api: any) =>
eventNames: (api: RawApiFeature) =>
api.event_names && api.event_names.length > 0 ? api.event_names : undefined,
};
/**
* Declarative feature transformer - replaces 79 lines with 40 lines of config
*/
export const featureTransformer = createTransformer<any, Feature>({
export const featureTransformer = createTransformer<RawApiFeature, Feature>({
discriminator: "type",
cases: {
// Boolean features: just copy base fields, no consumable
@@ -32,14 +44,24 @@ export const featureTransformer = createTransformer<any, Feature>({
},
},
// Credit system features: always consumable
credit_system: {
copy: ["id", "name", "archived"],
compute: {
...BASE_COMPUTE,
type: () => "credit_system" as const,
consumable: () => true,
creditSchema: mapCreditSchema,
creditSchema: (api) => mapCreditSchema(api),
},
},
ai_credit_system: {
copy: ["id", "name", "archived"],
compute: {
...BASE_COMPUTE,
type: () => "ai_credit_system" as const,
modelMarkups: (api) => mapModelMarkups(api),
defaultMarkup: (api) => api.default_markup ?? undefined,
providerMarkups: (api) => api.provider_markups ?? undefined,
},
},
@@ -85,6 +107,6 @@ export const featureTransformer = createTransformer<any, Feature>({
},
});
export function transformApiFeature(apiFeature: any): Feature {
export function transformApiFeature(apiFeature: RawApiFeature): Feature {
return featureTransformer.transform(apiFeature);
}

View File

@@ -11,6 +11,13 @@ export interface ApiFeatureParams {
metered_feature_id: string;
credit_cost: number;
}>;
model_markups?: Record<string, {
markup?: number;
input_cost?: number;
output_cost?: number;
}>;
default_markup?: number;
provider_markups?: Record<string, { markup: number }>;
}
export function transformFeatureToApi(feature: Feature): ApiFeatureParams {
@@ -39,5 +46,26 @@ export function transformFeatureToApi(feature: Feature): ApiFeatureParams {
}));
}
if (feature.type === "ai_credit_system") {
if (feature.modelMarkups) {
base.model_markups = Object.fromEntries(
Object.entries(feature.modelMarkups).map(([modelId, entry]) => [
modelId,
{
markup: entry.markup,
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;
}

View File

@@ -42,6 +42,19 @@ export function buildFeatureCode(feature: Feature, varNameOverride?: string): st
lines.push(`\tcreditSchema: ${formatValue(feature.creditSchema)},`);
}
// Add markup config for ai_credit_system features
if (feature.type === "ai_credit_system") {
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(`});`);
return lines.join("\n");

View File

@@ -7,6 +7,7 @@
"moduleResolution": "bundler",
"target": "ES2020",
"noEmit": true,
"types": ["node", "bun"],
"paths": {
"@autumn/shared": ["../../shared/index.ts"],
"@api/*": ["../../shared/api/*"],
@@ -15,6 +16,5 @@
}
},
"include": ["./**/*"],
"types": ["node"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -9,6 +9,7 @@ import {
FinalizeLockParamsV0Schema,
TrackParamsSchema,
TrackResponseV3Schema,
TrackTokensParamsSchema,
UpdateBalanceParamsV0Schema,
} from "@autumn/shared";
import { oc } from "@orpc/contract";
@@ -16,6 +17,7 @@ import { z } from "zod/v4";
import {
balancesCheckJsDoc,
balancesTrackJsDoc,
balancesTrackTokensJsDoc,
} from "../jsDocs/balancesJsDocs";
type SpecWithResponses = {
@@ -157,6 +159,63 @@ export const balancesTrackContract = oc
}),
);
export const balancesTrackTokensContract = oc
.route({
method: "POST",
path: "/v1/balances.track_tokens",
operationId: "trackTokens",
description: balancesTrackTokensJsDoc,
spec: (spec) =>
withAcceptedResponse(
spec,
"trackTokens",
"Accepted. Autumn is experiencing degraded service from a downstream provider, so the token usage event was accepted for replay and will be tracked as soon as the service is restored.",
),
})
.input(
TrackTokensParamsSchema.meta({
title: "TrackTokensParams",
examples: [
{
customer_id: "cus_123",
feature_id: "ai_credits",
model_id: "anthropic/claude-sonnet-4-20250514",
input_tokens: 1000,
output_tokens: 500,
},
],
}),
)
.output(
TrackResponseV3Schema.meta({
examples: [
{
customer_id: "cus_123",
value: 0.006,
balance: {
...API_BALANCE_V1_EXAMPLE,
feature_id: "ai_credits",
granted: 10,
remaining: 9.994,
usage: 0.006,
},
deductions: [
{
balance_id: "cus_ent_3DdSDoyFmoA9Neecl2a2Gc507X2",
feature_id: "ai_credits",
plan_id: "pro",
reset: {
interval: "month",
resets_at: 1781288736881,
},
value: 0.006,
},
],
},
],
}),
);
export const balancesBatchTrackContract = oc
.route({
method: "POST",

View File

@@ -6,6 +6,7 @@ import {
balancesDeleteContract,
balancesFinalizeContract,
balancesTrackContract,
balancesTrackTokensContract,
balancesUpdateContract,
} from "./balancesContract.js";
import {
@@ -102,6 +103,7 @@ export const v2_3ContractRouter = oc.router({
balancesFinalize: balancesFinalizeContract,
balancesCheck: balancesCheckContract,
balancesTrack: balancesTrackContract,
balancesTrackTokens: balancesTrackTokensContract,
balancesBatchTrack: balancesBatchTrackContract,
// Events

View File

@@ -1,4 +1,8 @@
import { ExtCheckParamsSchema, TrackParamsSchema } from "@autumn/shared";
import {
ExtCheckParamsSchema,
TrackParamsSchema,
TrackTokensParamsSchema,
} from "@autumn/shared";
import { createJSDocDescription, example } from "../../utils/jsDocs/index.js";
export const balancesCheckJsDoc = createJSDocDescription({
@@ -58,3 +62,26 @@ export const balancesTrackJsDoc = createJSDocDescription({
returns:
"The usage value recorded, with either a single updated balance or a map of updated balances. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the event for replay so it can be tracked as soon as the service is restored.",
});
export const balancesTrackTokensJsDoc = createJSDocDescription({
description:
"Records AI token usage for a customer and returns the updated AI credit balance.",
whenToUse:
"Use this after an LLM request when you have input and output token counts. Autumn converts token usage to a dollar amount using the configured model pricing and markup, then tracks that value against the customer's AI credit system.",
body: TrackTokensParamsSchema,
examples: [
example({
description: "Track one LLM response",
values: {
customerId: "cus_123",
featureId: "ai_credits",
modelId: "anthropic/claude-sonnet-4-20250514",
inputTokens: 1000,
outputTokens: 500,
},
}),
],
methodName: "trackTokens",
returns:
"The dollar value recorded and the updated AI credit system balance. If Autumn is experiencing degraded service from a downstream provider, the API may return 202 after accepting the token usage event for replay so it can be tracked as soon as the service is restored.",
});

View File

@@ -23,6 +23,7 @@ import {
SetupPaymentResponseV1Schema,
TrackParamsSchema,
TrackResponseV3Schema,
TrackTokensParamsSchema,
UpdateBalanceParamsV0Schema,
UpdateSubscriptionV1ParamsSchema,
} from "@autumn/shared";
@@ -64,6 +65,7 @@ async function generateOpenApiDocument(): Promise<Record<string, unknown>> {
registerInternalSchemas(UpdateBalanceParamsV0Schema);
registerInternalSchemas(CheckParamsSchema);
registerInternalSchemas(TrackParamsSchema);
registerInternalSchemas(TrackTokensParamsSchema);
registerInternalSchemas(BillingResponseSchema);
registerInternalSchemas(AttachPreviewResponseSchema);
registerInternalSchemas(PreviewUpdateSubscriptionResponseSchema);

View File

@@ -74,9 +74,15 @@ local function deduct_from_rollovers(params)
local rollover_id = rollover_obj.id
local credit_cost = rollover_obj.credit_cost
if is_nil(credit_cost) or credit_cost == 0 then
if is_nil(credit_cost) then
credit_cost = 1
end
if credit_cost == 0 then
-- Zero credit cost (e.g. -100% markup AI model): usage is free, leave rollovers untouched.
logger.log(" Rollover %s credit_cost=0 - free deduction, skipping", rollover_id)
remaining = 0
break
end
local rollover_data = context.rollovers[rollover_id]
if not rollover_data then

View File

@@ -50,7 +50,7 @@ local function process_deduction_pass(params)
local ent_id = ent_obj.customer_entitlement_id
local credit_cost = ent_obj.credit_cost
local ent_feature_id = ent_obj.feature_id
if credit_cost == cjson.null or credit_cost == nil or credit_cost == 0 then
if credit_cost == cjson.null or credit_cost == nil then
credit_cost = 1
end
@@ -91,6 +91,11 @@ local function process_deduction_pass(params)
if not should_process then
logger.log("%s skipping %s - usage_allowed=false or not in context", pass_name, ent_id)
elseif credit_cost == 0 then
-- Zero credit cost (e.g. -100% markup AI model): the usage is free.
-- Consume the requested amount without touching any balance.
logger.log("%s ent %s credit_cost=0 - free deduction, no balance change", pass_name, ent_id)
remaining_amount = 0
else
local deducted = deduct_from_main_balance({
context = context,

View File

@@ -66,6 +66,7 @@ const init = async ({ startupStartedAt }: { startupStartedAt: number }) => {
void preWarmOrgRedisConnections({ db }).catch((error) => {
logger.warn("[OrgRedis] Warmup failed", { error });
});
await startAllEdgeConfigPolling({ logger });
await Promise.all([primeRedisMonitor(), primeRedisV2Monitor()]);
startRedisMonitor();

View File

@@ -10,6 +10,7 @@ import { handleRecalculateBalance } from "./handlers/handleRecalculateBalance.js
import { handleRecalculateBalancePreview } from "./handlers/handleRecalculateBalancePreview.js";
import { handleSetUsage } from "./handlers/handleSetUsage.js";
import { handleTrack } from "./handlers/handleTrack.js";
import { handleTrackTokens } from "./handlers/handleTrackTokens.js";
import { handleUpdateBalance } from "./handlers/handleUpdateBalance.js";
// Create a Hono app for products
@@ -27,6 +28,7 @@ balancesRouter.post(
// Track
balancesRouter.post("/events", ...handleTrack);
balancesRouter.post("/track", ...handleTrack);
balancesRouter.post("/track_tokens", ...handleTrackTokens);
// Check
balancesRouter.post("/entitled", ...handleCheck);
@@ -46,6 +48,7 @@ balancesRpcRouter.post(
);
balancesRpcRouter.post("/balances.track", ...handleTrack);
balancesRpcRouter.post("/balances.track_tokens", ...handleTrackTokens);
balancesRpcRouter.post("/balances.batch_track", ...handleBatchTrack);
balancesRpcRouter.post("/balances.check", ...handleCheck);
balancesRpcRouter.post("/balances.finalize", ...handleFinalizeLock);

View File

@@ -0,0 +1,32 @@
import {
AffectedResource,
Scopes,
TrackTokensParamsSchema,
} from "@autumn/shared";
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
import { runTrackWithRollout } from "@/internal/balances/track/runTrackWithRollout.js";
import { getTokenTrackParams } from "@/internal/balances/track/utils/getTokenTrackParams.js";
export const handleTrackTokens = createRoute({
scopes: [Scopes.Balances.Write],
body: TrackTokensParamsSchema,
resource: AffectedResource.Track,
handler: async (c) => {
const body = c.req.valid("json");
const ctx = c.get("ctx");
const { body: trackBody, featureDeductions } = await getTokenTrackParams({
ctx,
input: body,
});
const response = await runTrackWithRollout({
ctx,
body: trackBody,
featureDeductions,
});
const status = ctx.extraLogs.trackQueuedForReplay ? 202 : 200;
return c.json(response, status);
},
});

View File

@@ -0,0 +1,28 @@
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
/**
* Aggregates the amounts deducted from each metered feature into a `credit_cost`
* map for an AI credit-system track, so the event records what each feature was
* charged. Returns undefined when the track isn't an AI credit deduction or
* nothing chargeable was deducted. The AI feature's own deduction is excluded so
* the map only contains the downstream metered features it consumed.
*/
export const buildAiCreditCostProperty = ({
featureDeductions,
entries,
}: {
featureDeductions: FeatureDeduction[];
entries: Array<{ featureId: string; amount: number }>;
}): Record<string, number> | undefined => {
const aiDeduction = featureDeductions.find((d) => d.tokens);
if (!aiDeduction) return;
const creditCost: Record<string, number> = {};
for (const { featureId, amount } of entries) {
if (featureId === aiDeduction.feature.id) continue;
if (!amount) continue;
creditCost[featureId] = (creditCost[featureId] ?? 0) + amount;
}
return Object.keys(creditCost).length > 0 ? creditCost : undefined;
};

View File

@@ -0,0 +1,187 @@
import {
ErrCode,
type Feature,
fullCustomerToCustomerEntitlements,
fullSubjectToFullCustomer,
isAiCreditSystem,
RecaseError,
type TrackParams,
type TrackTokensParams,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { getOrSetCachedFullSubject } from "@/internal/customers/cache/fullSubject/actions/getOrSetCachedFullSubject.js";
import { getOrSetCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrSetCachedFullCustomer.js";
import { getModelCreditCostBreakdown } from "@/internal/features/aiCreditSystemUtils.js";
import { isFullSubjectRolloutEnabled } from "@/internal/misc/rollouts/fullSubjectRolloutUtils.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
const resolveAiCreditFeatureById = ({
features,
featureId,
}: {
features: Feature[];
featureId: string;
}): Feature => {
const candidate = features.find((f) => f.id === featureId);
if (!candidate) {
throw new RecaseError({
message: `Feature ${featureId} not found`,
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
if (!isAiCreditSystem(candidate.type)) {
throw new RecaseError({
message: `Feature ${featureId} is not an AI credit system feature`,
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
return candidate;
};
const resolveAiCreditFeatureFromEntitlements = async ({
ctx,
customerId,
entityId,
}: {
ctx: AutumnContext;
customerId: string;
entityId?: string;
}): Promise<Feature> => {
const fullCustomer = isFullSubjectRolloutEnabled({ ctx })
? fullSubjectToFullCustomer({
fullSubject: await getOrSetCachedFullSubject({
ctx,
customerId,
entityId,
source: "resolveAiCreditFeature",
}),
})
: await getOrSetCachedFullCustomer({
ctx,
customerId,
entityId,
source: "resolveAiCreditFeature",
});
const entity = entityId
? fullCustomer.entities?.find((e) => e.id === entityId)
: undefined;
const cusEnts = fullCustomerToCustomerEntitlements({
fullCustomer,
entity,
});
const aiCreditFeatures = [
...new Map(
cusEnts
.filter((ce) => isAiCreditSystem(ce.entitlement.feature.type))
.map((ce) => [ce.entitlement.feature.id, ce.entitlement.feature]),
).values(),
];
if (aiCreditFeatures.length === 0) {
throw new RecaseError({
message: "No AI credit system feature found for this customer",
code: ErrCode.FeatureNotFound,
statusCode: 404,
});
}
if (aiCreditFeatures.length > 1) {
throw new RecaseError({
message:
"Multiple AI credit system features found for this customer. Please specify a feature_id to disambiguate.",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
}
return aiCreditFeatures[0];
};
export const getTokenTrackParams = async ({
ctx,
input,
}: {
ctx: AutumnContext;
input: TrackTokensParams;
}): Promise<{ body: TrackParams; featureDeductions: FeatureDeduction[] }> => {
const aiCreditFeature = input.feature_id
? resolveAiCreditFeatureById({
features: ctx.features,
featureId: input.feature_id,
})
: await resolveAiCreditFeatureFromEntitlements({
ctx,
customerId: input.customer_id,
entityId: input.entity_id,
});
const pricing = await getModelCreditCostBreakdown({
modelName: input.model_id,
creditSystem: aiCreditFeature,
input: input.input_tokens,
output: input.output_tokens,
cacheRead: input.cache_read_tokens,
cacheWrite: input.cache_write_tokens,
audioInput: input.audio_input_tokens,
audioOutput: input.audio_output_tokens,
reasoning: input.reasoning_tokens,
});
const cost = pricing.cost;
const featureDeductions: FeatureDeduction[] = [
{
feature: aiCreditFeature,
deduction: 1,
tokens: {
usage: {
modelName: input.model_id,
inputTokens: input.input_tokens,
outputTokens: input.output_tokens,
},
cost,
},
},
];
const body: TrackParams = {
customer_id: input.customer_id,
entity_id: input.entity_id,
feature_id: aiCreditFeature.id,
value: cost,
properties: {
...input.properties,
model: input.model_id,
input_tokens: input.input_tokens,
output_tokens: input.output_tokens,
cache_read_tokens: input.cache_read_tokens,
cache_write_tokens: input.cache_write_tokens,
audio_input_tokens: input.audio_input_tokens,
audio_output_tokens: input.audio_output_tokens,
reasoning_tokens: input.reasoning_tokens,
cost,
base_cost: pricing.baseCost,
markup: pricing.markup,
markup_source: pricing.markupSource,
tier_applied: pricing.tierApplied,
rates: {
input: pricing.rates.input,
output: pricing.rates.output,
cache_read: pricing.rates.cacheRead,
cache_write: pricing.rates.cacheWrite,
audio_input: pricing.rates.audioInput,
audio_output: pricing.rates.audioOutput,
reasoning: pricing.rates.reasoning,
},
},
idempotency_key: input.idempotency_key,
overage_behavior: input.overage_behavior,
customer_data: input.customer_data,
entity_data: input.entity_data,
skip_event: input.skip_event,
};
return { body, featureDeductions };
};

View File

@@ -15,8 +15,32 @@ import { globalSyncBatchingManagerV2 } from "../../utils/sync/SyncBatchingManage
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js";
import { buildAiCreditCostProperty } from "./buildAiCreditCostProperty.js";
import { handleRedisTrackError } from "./handleRedisTrackError.js";
const aiCreditCostEntries = ({
updates,
fullCustomer,
}: {
updates: Record<string, DeductionUpdate>;
fullCustomer: FullCustomer;
}): Array<{ featureId: string; amount: number }> => {
const cusEntIdToFeatureId = new Map<string, string>();
for (const cp of fullCustomer.customer_products) {
for (const ce of cp.customer_entitlements ?? []) {
cusEntIdToFeatureId.set(ce.id, ce.entitlement.feature.id);
}
}
const entries: Array<{ featureId: string; amount: number }> = [];
for (const [cusEntId, update] of Object.entries(updates)) {
const featureId = cusEntIdToFeatureId.get(cusEntId);
if (!featureId) continue;
entries.push({ featureId, amount: update.deducted });
}
return entries;
};
const queueSyncItem = ({
ctx,
body,
@@ -111,6 +135,14 @@ export const runRedisTrack = async ({
const { updates, fullCus, rolloverUpdates } = result;
const aiCreditCost = buildAiCreditCostProperty({
featureDeductions,
entries: aiCreditCostEntries({ updates, fullCustomer }),
});
if (aiCreditCost) {
body.properties = { ...(body.properties ?? {}), credit_cost: aiCreditCost };
}
// Queue sync and event
queueSyncItem({
ctx,

View File

@@ -20,6 +20,7 @@ import {
import { globalSyncBatchingManagerV3 } from "@/internal/balances/utils/sync/SyncBatchingManagerV3.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import type { RolloverUpdate } from "../../utils/types/rolloverUpdate.js";
import { buildAiCreditCostProperty } from "../utils/buildAiCreditCostProperty.js";
import { handleRedisTrackErrorV3 } from "./handleRedisTrackErrorV3.js";
const queueSyncItem = ({
@@ -148,6 +149,17 @@ export const runRedisTrackV3 = async ({
mutationLogs,
});
const aiCreditCost = buildAiCreditCostProperty({
featureDeductions,
entries: deductions.map((d) => ({
featureId: d.feature_id,
amount: d.value ?? 0,
})),
});
if (aiCreditCost) {
body.properties = { ...(body.properties ?? {}), credit_cost: aiCreditCost };
}
queueEvent({ ctx, body, fullSubject, deductions, internalProductId });
const { balance, balances } = await deductionToTrackResponseV2({

View File

@@ -0,0 +1,53 @@
import type { FullCusEntWithFullCusProduct } from "@autumn/shared";
import { logger } from "@/external/logtail/logtailUtils.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
const DEFAULT_CREDIT_COST = 1;
export type CreditCostLookup = (entitlementId: string) => number;
/** Per-entitlement credit cost lookup. Pure schema math — no I/O. */
export const computeCreditCosts = ({
cusEnts,
deduction,
}: {
cusEnts: FullCusEntWithFullCusProduct[];
deduction: FeatureDeduction;
}): CreditCostLookup => {
const costMap = new Map<string, number>();
for (const ce of cusEnts) {
// Token cost is USD: 1:1 on its own ent; parents apply their ratio to it.
if (
deduction.tokens &&
ce.entitlement.feature.id === deduction.feature.id
) {
costMap.set(ce.id, deduction.tokens.cost);
continue;
}
try {
costMap.set(
ce.id,
getCreditCost({
featureId: deduction.feature.id,
creditSystem: ce.entitlement.feature,
amount: deduction.tokens?.cost,
}),
);
} catch (error) {
// Cached cusEnt schemas can briefly trail a feature update; deduct at
// 1:1 rather than failing the track.
logger.warn("[computeCreditCosts] falling back to credit cost 1", {
feature_id: deduction.feature.id,
credit_system_id: ce.entitlement.feature.id,
customer_entitlement_id: ce.id,
error: String(error),
});
costMap.set(ce.id, DEFAULT_CREDIT_COST);
}
}
return (entitlementId) => costMap.get(entitlementId) ?? DEFAULT_CREDIT_COST;
};

View File

@@ -15,7 +15,7 @@ import {
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import { computeCreditCosts } from "./computeCreditCosts.js";
import type {
CustomerEntitlementDeduction,
DeductionOptions,
@@ -68,7 +68,7 @@ export const prepareFeatureDeduction = ({
for (const rf of relevantFeatures) {
const { unlimited: featureUnlimited } = getUnlimitedAndUsageAllowed({
cusEnts,
internalFeatureId: rf.internal_id!,
internalFeatureId: rf.internal_id,
});
if (featureUnlimited) {
@@ -101,14 +101,12 @@ export const prepareFeatureDeduction = ({
.map((ce) => ce.entitlement.feature.id),
);
const getCreditCostForEnt = computeCreditCosts({ cusEnts, deduction });
// Build input for each customer entitlement
const customerEntitlementDeductions: CustomerEntitlementDeduction[] =
cusEnts.map((ce) => {
const creditCost = getCreditCost({
featureId: feature.id,
creditSystem: ce.entitlement.feature,
});
const creditCost = getCreditCostForEnt(ce.id);
const maxOverage = getMaxOverage({ cusEnt: ce });
const isFreeAllocated =
@@ -148,10 +146,7 @@ export const prepareFeatureDeduction = ({
// Collect and sort rollovers by expires_at (oldest first), including credit_cost from parent entitlement
const sortedRollovers = cusEnts
.flatMap((ce) => {
const creditCost = getCreditCost({
featureId: feature.id,
creditSystem: ce.entitlement.feature,
});
const creditCost = getCreditCostForEnt(ce.id);
return (ce.rollovers || []).map((r) => ({
...r,
credit_cost: creditCost,

View File

@@ -0,0 +1,37 @@
import type { FullCusEntWithFullCusProduct } from "@autumn/shared";
import type { MutationLogItem } from "../types/mutationLogItem.js";
/**
* Attribute a track event to an unlimited plan even though we skip the actual
* deduction. Without this, resolveInternalProductIdForEvent gets an empty
* mutation log and the event lands in "No plan". Returns null when there is no
* unlimited entitlement to attribute to, or the resolved delta is zero.
*/
export const buildUnlimitedPlanMutationLog = ({
unlimitedCusEnt,
toDeduct,
fallbackDeduction,
entityId,
}: {
unlimitedCusEnt: FullCusEntWithFullCusProduct | undefined;
toDeduct: number | null | undefined;
fallbackDeduction: number | null | undefined;
entityId?: string | null;
}): MutationLogItem | null => {
if (!unlimitedCusEnt) return null;
const syntheticDelta = -(toDeduct ?? fallbackDeduction ?? 1);
if (syntheticDelta === 0) return null;
return {
target_type: "customer_entitlement",
customer_entitlement_id: unlimitedCusEnt.id,
rollover_id: null,
entity_id: entityId ?? null,
credit_cost: 1,
balance_delta: syntheticDelta,
adjustment_delta: 0,
usage_delta: 0,
value_delta: 0,
};
};

View File

@@ -17,6 +17,7 @@ import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { FeatureDeduction } from "../types/featureDeduction.js";
import type { MutationLogItem } from "../types/mutationLogItem.js";
import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js";
import { buildUnlimitedPlanMutationLog } from "./buildUnlimitedPlanMutationLog.js";
import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
@@ -130,24 +131,14 @@ export const executePostgresDeductionV2 = async ({
redisInstance: ctx.redisV2,
});
}
// Attribute the event to the unlimited plan even though we skip
// the actual deduction. Without this, resolveInternalProductIdForEvent
// gets an empty mutation log and the event lands in "No plan".
if (unlimitedCusEnt) {
const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1);
if (syntheticDelta !== 0) {
allMutationLogs.push({
target_type: "customer_entitlement",
customer_entitlement_id: unlimitedCusEnt.id,
rollover_id: null,
entity_id: entityId ?? null,
credit_cost: 1,
balance_delta: syntheticDelta,
adjustment_delta: 0,
usage_delta: 0,
value_delta: 0,
});
}
const unlimitedPlanLog = buildUnlimitedPlanMutationLog({
unlimitedCusEnt,
toDeduct,
fallbackDeduction: deduction.deduction,
entityId,
});
if (unlimitedPlanLog) {
allMutationLogs.push(unlimitedPlanLog);
}
continue;
}

View File

@@ -29,6 +29,7 @@ import type { LuaDeductionResult } from "../types/redisDeductionResult.js";
import type { RolloverUpdate } from "../types/rolloverUpdate.js";
import { applyDeductionUpdateToFullSubject } from "./applyDeductionUpdateToFullSubject.js";
import { applyRolloverUpdatesToFullSubject } from "./applyRolloverUpdatesToFullSubject.js";
import { buildUnlimitedPlanMutationLog } from "./buildUnlimitedPlanMutationLog.js";
import { logDeductionUpdatesV2 } from "./logDeductionUpdatesV2.js";
import { mutationLogsToFeaturesV2 } from "./mutationLogsToFeaturesV2.js";
import { normalizeDeductionSyncStateV2 } from "./normalizeDeductionSyncStateV2.js";
@@ -141,24 +142,14 @@ export const executeRedisDeductionV2 = async ({
redisInstance: redisInstance ?? ctx.redisV2,
});
}
// Attribute the event to the unlimited plan even though we skip
// the actual deduction. Without this, resolveInternalProductIdForEvent
// gets an empty mutation log and the event lands in "No plan".
if (unlimitedCusEnt) {
const syntheticDelta = -(toDeduct ?? deduction.deduction ?? 1);
if (syntheticDelta !== 0) {
allMutationLogs.push({
target_type: "customer_entitlement",
customer_entitlement_id: unlimitedCusEnt.id,
rollover_id: null,
entity_id: entityId ?? null,
credit_cost: 1,
balance_delta: syntheticDelta,
adjustment_delta: 0,
usage_delta: 0,
value_delta: 0,
});
}
const unlimitedPlanLog = buildUnlimitedPlanMutationLog({
unlimitedCusEnt,
toDeduct,
fallbackDeduction: deduction.deduction,
entityId,
});
if (unlimitedPlanLog) {
allMutationLogs.push(unlimitedPlanLog);
}
continue;
}

View File

@@ -17,7 +17,7 @@ import {
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { buildLockReceiptKey } from "@/internal/balances/utils/lock/buildLockReceiptKey.js";
import { getUnlimitedAndUsageAllowed } from "@/internal/customers/cusProducts/cusEnts/cusEntUtils.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import { computeCreditCosts } from "../deduction/computeCreditCosts.js";
import type {
CustomerEntitlementDeduction,
DeductionOptions,
@@ -115,12 +115,14 @@ export const prepareFeatureDeductionV2 = ({
.map((customerEntitlement) => customerEntitlement.entitlement.feature.id),
);
const getCreditCostForEnt = computeCreditCosts({
cusEnts: customerEntitlements,
deduction,
});
const customerEntitlementDeductions: CustomerEntitlementDeduction[] =
customerEntitlements.map((customerEntitlement) => {
const creditCost = getCreditCost({
featureId: feature.id,
creditSystem: customerEntitlement.entitlement.feature,
});
const creditCost = getCreditCostForEnt(customerEntitlement.id);
const maxOverage = getMaxOverage({
cusEnt: customerEntitlement,
@@ -162,26 +164,22 @@ export const prepareFeatureDeductionV2 = ({
};
});
const sortedRollovers = customerEntitlements
.flatMap((customerEntitlement) => {
const creditCost = getCreditCost({
featureId: feature.id,
creditSystem: customerEntitlement.entitlement.feature,
});
const rolloverArrays = customerEntitlements.map((customerEntitlement) => {
const creditCost = getCreditCostForEnt(customerEntitlement.id);
return (customerEntitlement.rollovers || []).map((rollover) => ({
...rollover,
credit_cost: creditCost,
}));
});
return (customerEntitlement.rollovers || []).map((rollover) => ({
...rollover,
credit_cost: creditCost,
}));
})
.sort((left, right) => {
if (left.expires_at && right.expires_at) {
return left.expires_at - right.expires_at;
}
if (left.expires_at && !right.expires_at) return -1;
if (!left.expires_at && right.expires_at) return 1;
return 0;
});
const sortedRollovers = rolloverArrays.flat().sort((left, right) => {
if (left.expires_at && right.expires_at) {
return left.expires_at - right.expires_at;
}
if (left.expires_at && !right.expires_at) return -1;
if (!left.expires_at && right.expires_at) return 1;
return 0;
});
const oneDaySeconds = 24 * 60 * 60;
const oneHourSeconds = 60 * 60;

View File

@@ -1,11 +1,25 @@
import type { Feature, LockParams } from "@autumn/shared";
import type { LockReceipt } from "../lock/fetchLockReceipt.js";
export type TokenUsage = {
modelName: string;
inputTokens: 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 = {
feature: Feature;
deduction: number;
targetBalance?: number;
/** Present only for track_tokens deductions; standard deductions omit it. */
tokens?: TokenDeduction;
lock?: LockParams;
lockReceipt?: LockReceipt;
lockReceiptKey?: string;
unwindValue?: number;

View File

@@ -0,0 +1,265 @@
import {
ErrCode,
type Feature,
isCustomModel,
type ModelsDevCost,
type ModelsDevCostTier,
type ModelsDevModel,
type ModelsDevProvider,
RecaseError,
splitModelId,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { getModelsDevPricing } from "@/internal/features/utils/getModelPricing.js";
export type TokenInput = {
input: number;
output: number;
cacheRead?: number;
cacheWrite?: number;
audioInput?: number;
audioOutput?: number;
reasoning?: number;
};
type ModelPricingData = Record<string, ModelsDevProvider>;
const LARGE_CONTEXT_THRESHOLD = 200_000;
type ResolvedModel =
| { custom: true }
| {
custom: false;
providerKey: string;
modelKey: string;
model: ModelsDevModel;
};
const modelNotFoundError = (modelName: string) =>
new RecaseError({
message: `Model ${modelName} not found in models.dev pricing data`,
code: ErrCode.InvalidRequest,
statusCode: 400,
data: { modelName },
});
/**
* Resolve a `model_id` to a models.dev entry by exact `<provider>/<model>` lookup. The id is
* split on the first `/` (so openrouter slugs like `openrouter/openai/gpt-4o` keep their inner
* `/`); the model key must match a models.dev entry exactly. `custom/` models skip resolution.
*/
const resolveModel = ({
modelName,
pricingData,
}: {
modelName: string;
pricingData: ModelPricingData;
}): ResolvedModel => {
if (isCustomModel(modelName)) {
return { custom: true };
}
const { provider, modelKey } = splitModelId(modelName);
const model = provider ? pricingData[provider]?.models[modelKey] : undefined;
if (!(provider && model)) {
throw modelNotFoundError(modelName);
}
return { custom: false, providerKey: provider, modelKey, model };
};
/**
* Resolve the effective per-token rates for a request, overlaying the active long-context
* tier (or `context_over_200k`) onto the base rates. Tier-level `cache_read`/`cache_write`
* override the base cache rates when present, so cache tokens above the threshold are billed
* at the tier rate too — not just input/output.
*/
const getEffectiveCost = (
cost: ModelsDevCost,
totalInputTokens: number,
): { effective: ModelsDevCost; tierApplied: boolean } => {
if (cost.tiers?.length) {
let chosen: ModelsDevCostTier | undefined;
for (const tier of cost.tiers) {
if (
totalInputTokens > tier.tier.size &&
(!chosen || tier.tier.size > chosen.tier.size)
) {
chosen = tier;
}
}
if (chosen) {
return {
effective: {
...cost,
input: chosen.input,
output: chosen.output,
cache_read: chosen.cache_read ?? cost.cache_read,
cache_write: chosen.cache_write ?? cost.cache_write,
},
tierApplied: true,
};
}
return { effective: cost, tierApplied: false };
}
if (cost.context_over_200k && totalInputTokens > LARGE_CONTEXT_THRESHOLD) {
return {
effective: {
...cost,
input: cost.context_over_200k.input,
output: cost.context_over_200k.output,
cache_read: cost.context_over_200k.cache_read ?? cost.cache_read,
cache_write: cost.context_over_200k.cache_write ?? cost.cache_write,
},
tierApplied: true,
};
}
return { effective: cost, tierApplied: false };
};
/** Effective per-token rates ($/M) used for a charge, after tier overlays and fallbacks. */
export type ModelCostRates = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
audioInput: number;
audioOutput: number;
reasoning: number;
};
export type ModelCostBreakdown = {
cost: number;
baseCost: number;
markup: number;
markupSource: "model" | "provider" | "default" | "none";
tierApplied: boolean;
rates: ModelCostRates;
};
const computeCost = ({
cost,
tokens,
markup,
}: {
cost: ModelsDevCost;
tokens: TokenInput;
markup: number;
}): { cost: number; baseCost: number; tierApplied: boolean; rates: ModelCostRates } => {
const cacheRead = tokens.cacheRead ?? 0;
const cacheWrite = tokens.cacheWrite ?? 0;
const audioInput = tokens.audioInput ?? 0;
const audioOutput = tokens.audioOutput ?? 0;
const reasoning = tokens.reasoning ?? 0;
const totalInput = tokens.input + cacheRead + cacheWrite;
const { effective, tierApplied } = getEffectiveCost(cost, totalInput);
// Pools without a published rate fall back to the base text rate.
const rates: ModelCostRates = {
input: effective.input,
output: effective.output,
cacheRead: effective.cache_read ?? effective.input,
cacheWrite: effective.cache_write ?? effective.input,
audioInput: effective.input_audio ?? effective.input,
audioOutput: effective.output_audio ?? effective.output,
reasoning: effective.reasoning ?? effective.output,
};
const baseCost = new Decimal(rates.input)
.mul(tokens.input)
.add(new Decimal(rates.output).mul(tokens.output))
.add(new Decimal(rates.cacheRead).mul(cacheRead))
.add(new Decimal(rates.cacheWrite).mul(cacheWrite))
.add(new Decimal(rates.audioInput).mul(audioInput))
.add(new Decimal(rates.audioOutput).mul(audioOutput))
.add(new Decimal(rates.reasoning).mul(reasoning))
.div(1_000_000);
return {
cost: baseCost
.mul(new Decimal(1).add(new Decimal(markup).div(100)))
.toNumber(),
baseCost: baseCost.toNumber(),
tierApplied,
rates,
};
};
const resolveAiMarkup = ({
modelName,
creditSystem,
modelMarkup,
}: {
modelName: string;
creditSystem: Feature;
modelMarkup?: { markup?: number | null } | null;
}): { markup: number; source: ModelCostBreakdown["markupSource"] } => {
if (modelMarkup?.markup != null) {
return { markup: modelMarkup.markup, source: "model" };
}
const { provider } = splitModelId(modelName);
const providerMarkup = provider
? creditSystem.config?.provider_markups?.[provider]?.markup
: undefined;
if (providerMarkup != null) {
return { markup: providerMarkup, source: "provider" };
}
const defaultMarkup = creditSystem.config?.default_markup;
if (defaultMarkup != null) {
return { markup: defaultMarkup, source: "default" };
}
return { markup: 0, source: "none" };
};
export const getModelCreditCostBreakdown = async ({
modelName,
creditSystem,
...tokens
}: {
modelName: string;
creditSystem: Feature;
} & TokenInput): Promise<ModelCostBreakdown> => {
const markups = creditSystem.model_markups || {};
const pricingData = await getModelsDevPricing();
const resolved = resolveModel({ modelName, pricingData });
const markupEntry = markups[modelName];
const { markup, source } = resolveAiMarkup({
modelName,
creditSystem,
modelMarkup: markupEntry,
});
// Custom models carry no models.dev rates; they bill input/output at the user-supplied
// costs only (cache/audio/reasoning pools are not priced for custom models).
if (resolved.custom) {
if (markupEntry?.input_cost == null || markupEntry?.output_cost == null) {
throw new RecaseError({
message: `Custom model ${modelName} is missing input_cost or output_cost in model_markups`,
code: ErrCode.InvalidRequest,
data: { modelName },
});
}
const computed = computeCost({
cost: { input: markupEntry.input_cost, output: markupEntry.output_cost },
tokens: { input: tokens.input, output: tokens.output },
markup,
});
return { ...computed, markup, markupSource: source };
}
const computed = computeCost({
cost: resolved.model.cost,
tokens,
markup,
});
return { ...computed, markup, markupSource: source };
};
export const getModelCreditCost = async (
args: { modelName: string; creditSystem: Feature } & TokenInput,
): Promise<number> => (await getModelCreditCostBreakdown(args)).cost;

View File

@@ -1,7 +1,11 @@
import {
type CreditSchemaItem,
ErrCode,
type Feature,
FeatureType,
isAiCreditSystem,
isAnyCreditSystem,
RecaseError,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
@@ -15,7 +19,8 @@ const creditSystemContainsFeature = ({
if (creditSystem.type !== FeatureType.CreditSystem) {
return false;
}
const schema: CreditSchemaItem[] = creditSystem.config.schema;
const schema: CreditSchemaItem[] | undefined = creditSystem.config?.schema;
if (!schema) return false;
for (const schemaItem of schema) {
if (schemaItem.metered_feature_id === meteredFeatureId) {
@@ -70,6 +75,7 @@ export const featureToCreditSystem = ({
return amount;
};
/** Sync credit-schema math; token pricing (models.dev I/O) lives in getModelCreditCost. */
export const getCreditCost = ({
featureId,
creditSystem,
@@ -79,11 +85,22 @@ export const getCreditCost = ({
creditSystem: Feature;
amount?: number;
}) => {
if (creditSystem.type !== FeatureType.CreditSystem) {
if (!isAnyCreditSystem(creditSystem.type)) {
return amount;
}
// Own balance is in the system's native unit (USD for AI), so values map 1:1.
if (featureId === creditSystem.id) {
return amount;
}
if (isAiCreditSystem(creditSystem.type)) {
throw new RecaseError({
message: `AI credit system ${creditSystem.id} has no schema; only its own feature can be priced here. Use getModelCreditCost for token pricing.`,
code: ErrCode.InvalidRequest,
statusCode: 400,
data: { featureId, creditSystemId: creditSystem.id },
});
}
const schema: CreditSchemaItem[] = creditSystem.config.schema;
for (const schemaItem of schema) {
if (schemaItem.metered_feature_id === featureId) {
return new Decimal(schemaItem.credit_amount)
@@ -93,5 +110,10 @@ export const getCreditCost = ({
}
}
return 1;
throw new RecaseError({
message: "Feature is not included in credit system schema",
code: ErrCode.InvalidRequest,
statusCode: 400,
data: { featureId, creditSystemId: creditSystem.id },
});
};

View File

@@ -1,23 +1,35 @@
import { CreateFeatureSchema, type Feature, FeatureType } from "@autumn/shared";
import {
CreateFeatureSchema,
type Feature,
FeatureType,
isAnyCreditSystem,
type ModelMarkups,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { workflows } from "@/queue/workflows.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "../FeatureService.js";
import {
validateCreditSystem,
validateCreditSystemSchemaReferences,
validateMeteredConfig,
} from "../featureUtils.js";
const validateFeature = (data: any) => {
const featureType = data.type;
// validateFeatureId(data.id);
const validateFeature = (data: any, allFeatures: Feature[]) => {
const featureType = data.type as FeatureType;
let config = data.config;
if (featureType === FeatureType.Metered) {
config = validateMeteredConfig(config);
} else if (featureType === FeatureType.CreditSystem) {
config = validateCreditSystem(config);
} else if (isAnyCreditSystem(featureType)) {
config = validateCreditSystem(config, featureType);
if (featureType === FeatureType.CreditSystem) {
validateCreditSystemSchemaReferences({
config,
allFeatures,
selfFeatureId: data.id,
});
}
}
const parsedFeature = CreateFeatureSchema.parse({ ...data, config });
@@ -32,6 +44,7 @@ interface CreateFeatureParams {
type: string;
config?: any;
event_names?: string[];
model_markups?: ModelMarkups;
};
skipGenerateDisplay?: boolean;
}
@@ -45,7 +58,7 @@ export const createFeature = async ({
data,
skipGenerateDisplay = false,
}: CreateFeatureParams): Promise<Feature | null> => {
const parsedFeature = validateFeature(data);
const parsedFeature = validateFeature(data, ctx.features);
const feature: Feature = {
archived: false,
@@ -54,6 +67,7 @@ export const createFeature = async ({
created_at: Date.now(),
env: ctx.env,
...parsedFeature,
model_markups: data.model_markups ?? null,
};
const insertedData = await FeatureService.insert({

View File

@@ -1,8 +1,12 @@
import {
type CreditSchemaItem,
type CreditSystemConfig,
ErrCode,
type Feature,
FeatureType,
isAiCreditSystem,
isAnyCreditSystem,
type ModelMarkups,
notNullish,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
@@ -13,6 +17,7 @@ import RecaseError from "@/utils/errorUtils.js";
import { FeatureService } from "../FeatureService.js";
import {
validateCreditSystem,
validateCreditSystemSchemaReferences,
validateMeteredConfig,
} from "../featureUtils.js";
import { getObjectsUsingFeature } from "../utils/updateFeatureUtils/getObjectsUsingFeature.js";
@@ -27,6 +32,58 @@ interface UpdateFeatureParams {
updates: Partial<Feature>;
}
/** Generic keyed-record equality check with a caller-supplied per-entry comparison. */
const areMarkupRecordsEqual = <T>(
a: Record<string, T> | null | undefined,
b: Record<string, T> | null | undefined,
entriesEqual: (aEntry: T, bEntry: T) => boolean,
): boolean => {
const aIsAbsent = a == null;
const bIsAbsent = b == null;
if (aIsAbsent && bIsAbsent) return true;
if (aIsAbsent || bIsAbsent) return false;
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
const aEntry = a[key];
const bEntry = b[key];
if (!bEntry) return false;
if (!entriesEqual(aEntry, bEntry)) return false;
}
return true;
};
const areModelMarkupsEqual = ({
a,
b,
}: {
a: ModelMarkups;
b: ModelMarkups;
}): boolean =>
areMarkupRecordsEqual<NonNullable<ModelMarkups>[string]>(
a,
b,
(aEntry, bEntry) =>
aEntry.markup === bEntry.markup &&
aEntry.input_cost === bEntry.input_cost &&
aEntry.output_cost === bEntry.output_cost,
);
const areProviderMarkupsEqual = ({
a,
b,
}: {
a: CreditSystemConfig["provider_markups"];
b: CreditSystemConfig["provider_markups"];
}): boolean =>
areMarkupRecordsEqual<
NonNullable<CreditSystemConfig["provider_markups"]>[string]
>(a, b, (aEntry, bEntry) => aEntry.markup === bEntry.markup);
/**
* Checks if the credit schema has changed between old and new config.
* Returns true if schema changed (different items or different credit amounts).
@@ -58,6 +115,26 @@ const hasCreditSchemaChanged = ({
return false;
};
const hasAiMarkupConfigChanged = ({
oldConfig,
newConfig,
}: {
oldConfig: CreditSystemConfig | undefined;
newConfig: CreditSystemConfig | undefined;
}): boolean => {
if (
(oldConfig?.default_markup ?? undefined) !==
(newConfig?.default_markup ?? undefined)
) {
return true;
}
return !areProviderMarkupsEqual({
a: oldConfig?.provider_markups,
b: newConfig?.provider_markups,
});
};
/**
* Updates an existing feature with full validation logic
*/
@@ -146,15 +223,32 @@ export const updateFeature = async ({
}
}
// Validate config based on feature type
const newConfig =
updates.config !== undefined
? feature.type === FeatureType.CreditSystem
? validateCreditSystem(updates.config)
: feature.type === FeatureType.Metered
? validateMeteredConfig(updates.config)
: updates.config
: feature.config;
const effectiveType = updates.type ?? feature.type;
const newConfig = (() => {
if (updates.config === undefined) return feature.config;
switch (effectiveType) {
case FeatureType.AiCreditSystem:
case FeatureType.CreditSystem: {
const validatedConfig = validateCreditSystem(
updates.config,
effectiveType,
);
if (effectiveType === FeatureType.CreditSystem) {
validateCreditSystemSchemaReferences({
config: validatedConfig,
allFeatures,
selfFeatureId: updates.id ?? feature.id,
});
}
return validatedConfig;
}
case FeatureType.Metered:
return validateMeteredConfig(updates.config);
default:
return updates.config;
}
})();
// Update the feature
const updatedFeature = await FeatureService.update({
@@ -165,10 +259,11 @@ export const updateFeature = async ({
updates: {
id: updates.id,
name: updates.name,
type: updates.type,
type: effectiveType,
archived: updates.archived,
event_names: updates.event_names,
config: newConfig,
model_markups: updates.model_markups,
},
});
@@ -181,18 +276,32 @@ export const updateFeature = async ({
});
}
// Queue cache clear for credit system if schema changed
if (
feature.type === FeatureType.CreditSystem &&
updates.config?.schema &&
updatedFeature
) {
const schemaChanged = hasCreditSchemaChanged({
oldSchema: feature.config?.schema,
newSchema: updates.config.schema,
});
// Queue cache clear for credit system if schema or model markups changed
const isCreditSystem = isAnyCreditSystem(feature.type);
if (isCreditSystem && updatedFeature) {
const schemaChanged =
updates.config != null &&
hasCreditSchemaChanged({
oldSchema: feature.config?.schema,
newSchema: updates.config.schema,
});
if (schemaChanged) {
const markupsChanged =
updates.model_markups !== undefined &&
!areModelMarkupsEqual({
a: updates.model_markups,
b: feature.model_markups,
});
const aiMarkupConfigChanged =
isAiCreditSystem(feature.type) &&
updates.config != null &&
hasAiMarkupConfigChanged({
oldConfig: feature.config,
newConfig,
});
if (schemaChanged || markupsChanged || aiMarkupConfigChanged) {
await addTaskToQueue({
jobName: JobName.ClearCreditSystemCustomerCache,
payload: {

View File

@@ -10,10 +10,12 @@ import { handleListFeaturesV1 } from "./handlers/handleListFeatures/handleListFe
import { handleUpdateFeatureV1 } from "./handlers/handleUpdateFeature/handleUpdateFeatureV1";
import { handleUpdateFeatureV2 } from "./handlers/handleUpdateFeature/handleUpdateFeatureV2";
import { handleGetFeatureDeletionInfo } from "./internalHandlers/handleGetFeatureDeletionInfo";
import { handleGetModelPricing } from "./internalHandlers/handleGetModelPricing";
export const featureRouter = new Hono<HonoEnv>();
featureRouter.get("", ...handleListFeaturesV1);
featureRouter.post("", ...handleCreateFeatureV1);
featureRouter.get("/ai/model_pricing", ...handleGetModelPricing);
featureRouter.get("/:feature_id", ...handleGetFeatureV1);
featureRouter.post("/:feature_id", ...handleUpdateFeatureV1);
featureRouter.delete("/:feature_id", ...handleDeleteFeatureV1);

View File

@@ -7,6 +7,7 @@ import {
FeatureType,
FeatureUsageType,
type FullCustomer,
isAiCreditSystem,
isAllocatedPrice,
type MeteredConfig,
type UsagePriceConfig,
@@ -41,9 +42,13 @@ export const validateMeteredConfig = (config: MeteredConfig) => {
return newConfig as MeteredConfig;
};
export const validateCreditSystem = (config: CreditSystemConfig) => {
const schema = config.schema;
if (!schema || schema.length === 0) {
export const validateCreditSystem = (
config: CreditSystemConfig,
featureType: FeatureType = FeatureType.CreditSystem,
) => {
const schema = Array.isArray(config?.schema) ? config.schema : [];
if (!isAiCreditSystem(featureType) && schema.length === 0) {
throw new RecaseError({
message: `At least one metered feature is required for credit system`,
code: ErrCode.InvalidFeature,
@@ -51,11 +56,17 @@ export const validateCreditSystem = (config: CreditSystemConfig) => {
});
}
// Check if multiple of the same feature
if (isAiCreditSystem(featureType) && schema.length > 0) {
throw new RecaseError({
message: `AI credit systems are leaf features and cannot define a schema. Model rates live in model_markups.`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
const meteredFeatureIds = schema.map(
(schemaItem) => schemaItem.metered_feature_id,
);
// console.log("Metered feature ids:", meteredFeatureIds);
const uniqueMeteredFeatureIds = Array.from(new Set(meteredFeatureIds));
if (meteredFeatureIds.length !== uniqueMeteredFeatureIds.length) {
throw new RecaseError({
@@ -65,7 +76,37 @@ export const validateCreditSystem = (config: CreditSystemConfig) => {
});
}
const newConfig = { ...config, usage_type: FeatureUsageType.Single };
const newConfig = { ...config, schema, usage_type: FeatureUsageType.Single };
const defaultMarkup = newConfig.default_markup;
if (defaultMarkup != null) {
const parsedDefaultMarkup = Number(defaultMarkup);
if (Number.isNaN(parsedDefaultMarkup) || parsedDefaultMarkup < -100) {
throw new RecaseError({
message:
"Default markup must be -100 or greater (-100 makes usage free)",
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
newConfig.default_markup = parsedDefaultMarkup;
}
const providerMarkups = newConfig.provider_markups;
if (providerMarkups != null) {
for (const [provider, entry] of Object.entries(providerMarkups)) {
const markup = Number(entry?.markup);
if (!provider || Number.isNaN(markup) || markup < -100) {
throw new RecaseError({
message:
"Provider markups must be -100 or greater (-100 makes usage free)",
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
entry.markup = markup;
}
}
for (let i = 0; i < newConfig.schema.length; i++) {
const creditAmount = parseFloat(
newConfig.schema[i].credit_amount.toString(),
@@ -85,6 +126,43 @@ export const validateCreditSystem = (config: CreditSystemConfig) => {
return newConfig;
};
/**
* Validates that every feature referenced by a credit system's schema is a
* Metered or AiCreditSystem feature. Rejects nesting one CreditSystem inside
* another — composition is capped at two levels (parent credit system → leaf).
*
* Self-references are tolerated (the create flow doesn't yet have the new id
* in the features list, and updates simply read back as the feature itself).
*/
export const validateCreditSystemSchemaReferences = ({
config,
allFeatures,
selfFeatureId,
}: {
config: CreditSystemConfig;
allFeatures: Feature[];
selfFeatureId?: string;
}) => {
const schema = Array.isArray(config?.schema) ? config.schema : [];
if (schema.length === 0) return;
for (const item of schema) {
const referencedId = item.metered_feature_id;
if (!referencedId || referencedId === selfFeatureId) continue;
const referenced = allFeatures.find((f) => f.id === referencedId);
if (!referenced) continue;
if (referenced.type === FeatureType.CreditSystem) {
throw new RecaseError({
message: `Credit system schema cannot reference another credit system (${referencedId}). Only metered or AI credit features are allowed.`,
code: ErrCode.InvalidFeature,
statusCode: 400,
});
}
}
};
const getCusFeatureType = ({ feature }: { feature: Feature }) => {
if (feature.type === FeatureType.Boolean) {
return ApiFeatureType.Static;

View File

@@ -58,6 +58,7 @@ export const handleUpdateFeatureV1 = createRoute({
archived: body.archived,
event_names: body.event_names,
display: body.display,
model_markups: body.model_markups,
},
});

View File

@@ -55,6 +55,7 @@ export const handleUpdateFeatureV2 = createRoute({
archived: body.archived,
event_names: body.event_names,
display: body.display,
model_markups: body.model_markups,
},
});

View File

@@ -0,0 +1,11 @@
import { createRoute } from "@/honoMiddlewares/routeHandler";
import { getModelsDevPricing } from "@/internal/features/utils/getModelPricing";
import { Scopes } from "@autumn/shared";
export const handleGetModelPricing = createRoute({
scopes: [Scopes.Features.Read],
handler: async (c) => {
const data = await getModelsDevPricing();
return c.json(data);
},
});

View File

@@ -1,9 +1,12 @@
import {
AggregateType,
type AppEnv,
buildAiCreditSystemConfig,
type Feature,
FeatureType,
FeatureUsageType,
type ModelMarkups,
type ProviderMarkups,
} from "@autumn/shared";
import { generateId, keyToTitle } from "@server/utils/genUtils";
@@ -36,8 +39,9 @@ const constructFeature = ({
display,
archived: false,
event_names: [],
model_markups: null,
};
// This function isn't used anywhere, maybe delete it?
return newFeature;
};
@@ -64,6 +68,7 @@ export const constructBooleanFeature = ({
config: null,
archived: false,
event_names: [],
model_markups: null,
};
return newFeature;
@@ -109,6 +114,7 @@ export const constructMeteredFeature = ({
},
archived: false,
event_names: eventNames,
model_markups: null,
};
return newFeature;
@@ -151,6 +157,44 @@ export const constructCreditSystem = ({
config,
archived: false,
event_names: [],
model_markups: null,
};
return newFeature;
};
export const constructAiCreditSystem = ({
featureId,
name,
orgId,
env,
modelMarkups,
defaultMarkup,
providerMarkups,
}: {
featureId: string;
name?: string;
orgId: string;
env: AppEnv;
modelMarkups: ModelMarkups;
defaultMarkup?: number;
providerMarkups?: ProviderMarkups;
}) => {
const config = buildAiCreditSystemConfig({ defaultMarkup, providerMarkups });
const newFeature: Feature = {
internal_id: generateId("fe"),
org_id: orgId,
env,
created_at: Date.now(),
id: featureId,
name: name || keyToTitle(featureId),
type: FeatureType.AiCreditSystem,
config,
archived: false,
event_names: [],
model_markups: modelMarkups,
};
return newFeature;

View File

@@ -0,0 +1,43 @@
import { CacheManager } from "@/utils/cacheUtils/CacheManager.js";
import { ErrCode, InternalError, type ModelsDevProvider } from "@autumn/shared";
type ModelPricingData = Record<string, ModelsDevProvider>;
const CACHE_KEY = "models_dev_pricing";
const STALE_KEY = `${CACHE_KEY}_stale`;
const TTL_PRIMARY = 60 * 60 * 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 response = await fetch("https://models.dev/api.json", {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!response.ok) {
throw new InternalError({
message: `models.dev returned ${response.status}`,
code: ErrCode.InternalError,
});
}
return response.json();
};
export const getModelsDevPricing = async (): Promise<ModelPricingData> => {
const cached = await CacheManager.getJson<ModelPricingData>(CACHE_KEY);
if (cached) return cached;
try {
const data = await fetchFromSource();
CacheManager.setJson(CACHE_KEY, data, TTL_PRIMARY).catch(() => {});
CacheManager.setJson(STALE_KEY, data, TTL_STALE).catch(() => {});
return data;
} catch {
const stale = await CacheManager.getJson<ModelPricingData>(STALE_KEY);
if (stale) return stale;
throw new InternalError({
message: "Failed to fetch models.dev pricing and no cache available",
code: ErrCode.InternalError,
});
}
};

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from "bun:test";
import {
type Feature,
FeatureType,
FeatureUsageType,
type ModelMarkups,
type ProviderMarkups,
} from "@autumn/shared";
import { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js";
// Custom models carry their own input/output costs, so getModelCreditCost
// resolves them without hitting the models.dev pricing fetch — ideal for
// unit-testing the tiered markup resolution (model > provider > global > none).
const CUSTOM_MODEL = "custom/foo";
const TOKENS = { input: 1000, output: 500 };
// base cost = (1000 * 1000 + 500 * 2000) / 1_000_000 = 2.0
const BASE_COST = 2.0;
const INPUT_COST = 1000;
const OUTPUT_COST = 2000;
const makeAiCredit = ({
model_markups,
default_markup,
provider_markups,
}: {
model_markups: ModelMarkups;
default_markup?: number;
provider_markups?: ProviderMarkups;
}): 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,
default_markup,
provider_markups,
},
archived: false,
event_names: [],
model_markups,
});
const cost = (creditSystem: Feature) =>
getModelCreditCost({
modelName: CUSTOM_MODEL,
creditSystem,
...TOKENS,
});
describe("getModelCreditCost — tiered AI markup resolution", () => {
test("per-model markup wins over provider and global", async () => {
const creditSystem = makeAiCredit({
model_markups: {
[CUSTOM_MODEL]: {
input_cost: INPUT_COST,
output_cost: OUTPUT_COST,
markup: 50,
},
},
provider_markups: { custom: { markup: 20 } },
default_markup: 10,
});
expect(await cost(creditSystem)).toBeCloseTo(BASE_COST * 1.5);
});
test("falls back to provider markup when model markup is omitted", async () => {
const creditSystem = makeAiCredit({
model_markups: {
[CUSTOM_MODEL]: { input_cost: INPUT_COST, output_cost: OUTPUT_COST },
},
provider_markups: { custom: { markup: 20 } },
default_markup: 10,
});
expect(await cost(creditSystem)).toBeCloseTo(BASE_COST * 1.2);
});
test("falls back to global default markup when model and provider are omitted", async () => {
const creditSystem = makeAiCredit({
model_markups: {
[CUSTOM_MODEL]: { input_cost: INPUT_COST, output_cost: OUTPUT_COST },
},
default_markup: 10,
});
expect(await cost(creditSystem)).toBeCloseTo(BASE_COST * 1.1);
});
test("bills at base cost (1:1) when no markup is configured anywhere", async () => {
const creditSystem = makeAiCredit({
model_markups: {
[CUSTOM_MODEL]: { input_cost: INPUT_COST, output_cost: OUTPUT_COST },
},
});
expect(await cost(creditSystem)).toBeCloseTo(BASE_COST);
});
test("explicit per-model markup of 0 overrides provider and global", async () => {
const creditSystem = makeAiCredit({
model_markups: {
[CUSTOM_MODEL]: {
input_cost: INPUT_COST,
output_cost: OUTPUT_COST,
markup: 0,
},
},
provider_markups: { custom: { markup: 20 } },
default_markup: 10,
});
expect(await cost(creditSystem)).toBeCloseTo(BASE_COST);
});
});

View File

@@ -0,0 +1,276 @@
import { describe, expect, mock, test } from "bun:test";
import {
type Feature,
FeatureType,
FeatureUsageType,
type ModelMarkups,
type ModelsDevProvider,
} from "@autumn/shared";
// Stub the models.dev fetch so resolution + pricing are deterministic and offline.
const pricingData: Record<string, ModelsDevProvider> = {
anthropic: {
id: "anthropic",
name: "Anthropic",
models: {
"claude-opus-4-5": {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
cost: { input: 5, output: 25, cache_read: 0.5, cache_write: 6.25 },
},
"claude-3-5-haiku-20241022": {
id: "claude-3-5-haiku-20241022",
name: "Claude 3.5 Haiku",
cost: { input: 1, output: 5 },
},
},
},
openai: {
id: "openai",
name: "OpenAI",
models: {
"gpt-4o-2024-08-06": {
id: "gpt-4o-2024-08-06",
name: "GPT-4o",
cost: { input: 2.5, output: 10, cache_read: 1.25 },
},
"gpt-5": {
id: "gpt-5",
name: "GPT-5",
cost: {
input: 1,
output: 2,
cache_read: 0.5,
tiers: [
{
input: 2,
output: 4,
cache_read: 1,
tier: { type: "context", size: 200_000 },
},
],
context_over_200k: { input: 2, output: 4, cache_read: 1 },
},
},
"omni-audio": {
id: "omni-audio",
name: "Omni Audio",
cost: {
input: 2,
output: 4,
input_audio: 3.5,
output_audio: 7,
reasoning: 10,
},
},
"no-cache-model": {
id: "no-cache-model",
name: "No Cache",
cost: { input: 10, output: 20 },
},
},
},
openrouter: {
id: "openrouter",
name: "OpenRouter",
models: {
// Openrouter keys contain a subprovider slash; the id keeps it after the
// first `/` split (openrouter/openai/gpt-4o-2024-08-06).
"openai/gpt-4o-2024-08-06": {
id: "openai/gpt-4o-2024-08-06",
name: "GPT-4o (OpenRouter)",
cost: { input: 3, output: 12 },
},
},
},
};
mock.module("@/internal/features/utils/getModelPricing.js", () => ({
getModelsDevPricing: async () => pricingData,
}));
const { getModelCreditCost, getModelCreditCostBreakdown } = await import(
"@/internal/features/aiCreditSystemUtils.js"
);
const makeFeature = (model_markups: ModelMarkups = {}): Feature => ({
internal_id: "fe_ai",
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,
});
const PER_MILLION = 1_000_000;
describe("resolveModel — exact match", () => {
test("exact provider-scoped match", async () => {
const cost = await getModelCreditCost({
modelName: "anthropic/claude-opus-4-5",
creditSystem: makeFeature(),
input: 1000,
output: 500,
});
expect(cost).toBeCloseTo((5 * 1000 + 25 * 500) / PER_MILLION, 10);
});
test("openrouter slug keeps its inner slash (split on the first '/')", async () => {
const cost = await getModelCreditCost({
modelName: "openrouter/openai/gpt-4o-2024-08-06",
creditSystem: makeFeature(),
input: 1000,
output: 0,
});
// openrouter's own rate (3), not openai's direct rate (2.5).
expect(cost).toBeCloseTo((3 * 1000) / PER_MILLION, 10);
});
test("throws when the model key does not match exactly", async () => {
expect(
getModelCreditCost({
modelName: "anthropic/claude-3-5-haiku",
creditSystem: makeFeature(),
input: 1,
output: 1,
}),
).rejects.toThrow(/not found/);
});
test("throws for a bare name with no provider", async () => {
expect(
getModelCreditCost({
modelName: "gpt-4o",
creditSystem: makeFeature(),
input: 1,
output: 1,
}),
).rejects.toThrow(/not found/);
});
});
describe("computeCost — token pools", () => {
test("bills cache read/write at their own rates", async () => {
const cost = await getModelCreditCost({
modelName: "anthropic/claude-opus-4-5",
creditSystem: makeFeature(),
input: 1000,
output: 500,
cacheRead: 2000,
cacheWrite: 100,
});
const expected =
(5 * 1000 + 25 * 500 + 0.5 * 2000 + 6.25 * 100) / PER_MILLION;
expect(cost).toBeCloseTo(expected, 10);
});
test("uses the long-context tier once the input exceeds the threshold", async () => {
const large = await getModelCreditCost({
modelName: "openai/gpt-5",
creditSystem: makeFeature(),
input: 300_000,
output: 1000,
});
expect(large).toBeCloseTo((2 * 300_000 + 4 * 1000) / PER_MILLION, 10);
const small = await getModelCreditCost({
modelName: "openai/gpt-5",
creditSystem: makeFeature(),
input: 100_000,
output: 1000,
});
expect(small).toBeCloseTo((1 * 100_000 + 2 * 1000) / PER_MILLION, 10);
});
test("bills cache at the tier rate above the context threshold", async () => {
const above = await getModelCreditCost({
modelName: "openai/gpt-5",
creditSystem: makeFeature(),
input: 300_000,
output: 0,
cacheRead: 1000,
});
// totalInput 301k > 200k -> tier input (2) and tier cache_read (1), not base 0.5.
expect(above).toBeCloseTo((2 * 300_000 + 1 * 1000) / PER_MILLION, 10);
const below = await getModelCreditCost({
modelName: "openai/gpt-5",
creditSystem: makeFeature(),
input: 1000,
output: 0,
cacheRead: 1000,
});
// totalInput 2k < 200k -> base input (1) and base cache_read (0.5).
expect(below).toBeCloseTo((1 * 1000 + 0.5 * 1000) / PER_MILLION, 10);
});
test("bills audio and reasoning pools at their modality rates", async () => {
const cost = await getModelCreditCost({
modelName: "openai/omni-audio",
creditSystem: makeFeature(),
input: 1000,
output: 500,
audioInput: 200,
audioOutput: 100,
reasoning: 50,
});
const expected =
(2 * 1000 + 4 * 500 + 3.5 * 200 + 7 * 100 + 10 * 50) / PER_MILLION;
expect(cost).toBeCloseTo(expected, 10);
});
test("falls back to the base input rate for missing cache rates", async () => {
const cost = await getModelCreditCost({
modelName: "openai/no-cache-model",
creditSystem: makeFeature(),
input: 0,
output: 0,
cacheRead: 1000,
});
// no published cache_read rate -> bill at the base input rate (10)
expect(cost).toBeCloseTo((10 * 1000) / PER_MILLION, 10);
});
test("applies markup to the full total", async () => {
const cost = await getModelCreditCost({
modelName: "anthropic/claude-opus-4-5",
creditSystem: makeFeature({
"anthropic/claude-opus-4-5": { markup: 50 },
}),
input: 1000,
output: 500,
});
expect(cost).toBeCloseTo(((5 * 1000 + 25 * 500) / PER_MILLION) * 1.5, 10);
});
test("breakdown reports tier_applied and the tier rates actually used", async () => {
const above = await getModelCreditCostBreakdown({
modelName: "openai/gpt-5",
creditSystem: makeFeature(),
input: 300_000,
output: 1000,
});
expect(above.tierApplied).toBe(true);
expect(above.rates.input).toBe(2);
expect(above.rates.cacheRead).toBe(1);
expect(above.baseCost).toBeCloseTo(
(2 * 300_000 + 4 * 1000) / PER_MILLION,
10,
);
expect(above.cost).toBe(above.baseCost);
const below = await getModelCreditCostBreakdown({
modelName: "openai/gpt-5",
creditSystem: makeFeature(),
input: 1000,
output: 1000,
});
expect(below.tierApplied).toBe(false);
expect(below.rates.input).toBe(1);
});
});

View File

@@ -0,0 +1,125 @@
import { describe, expect, test } from "bun:test";
import { type Feature, FeatureType, FeatureUsageType } from "@autumn/shared";
import {
validateCreditSystem,
validateCreditSystemSchemaReferences,
} from "@/internal/features/featureUtils.js";
const makeFeature = (id: string, type: FeatureType): Feature => ({
internal_id: `fe_${id}`,
org_id: "org_test",
created_at: Date.now(),
env: "sandbox" as Feature["env"],
id,
name: id,
type,
config: {},
archived: false,
event_names: [],
model_markups: null,
});
describe("validateCreditSystem — AI credit system schema restrictions", () => {
test("rejects an AI credit system with a non-empty schema", () => {
expect(() =>
validateCreditSystem(
{
schema: [{ metered_feature_id: "messages", credit_amount: 1 }] as any,
usage_type: FeatureUsageType.Single,
},
FeatureType.AiCreditSystem,
),
).toThrow(/leaf features/);
});
test("allows an AI credit system with an empty schema", () => {
const result = validateCreditSystem(
{
schema: [],
usage_type: FeatureUsageType.Single,
},
FeatureType.AiCreditSystem,
);
expect(result.schema).toHaveLength(0);
});
test("rejects a regular credit system with empty schema", () => {
expect(() =>
validateCreditSystem(
{ schema: [], usage_type: FeatureUsageType.Single },
FeatureType.CreditSystem,
),
).toThrow(/At least one metered feature/);
});
});
describe("validateCreditSystemSchemaReferences — cross-feature restrictions", () => {
const metered = makeFeature("messages", FeatureType.Metered);
const aiCredit = makeFeature("ai_credits", FeatureType.AiCreditSystem);
const otherCreditSystem = makeFeature("orbs", FeatureType.CreditSystem);
test("allows referencing a metered feature", () => {
expect(() =>
validateCreditSystemSchemaReferences({
config: {
schema: [{ metered_feature_id: "messages", credit_amount: 1 } as any],
usage_type: FeatureUsageType.Single,
},
allFeatures: [metered],
}),
).not.toThrow();
});
test("allows referencing an AI credit system", () => {
expect(() =>
validateCreditSystemSchemaReferences({
config: {
schema: [
{ metered_feature_id: "ai_credits", credit_amount: 1000 } as any,
],
usage_type: FeatureUsageType.Single,
},
allFeatures: [aiCredit],
}),
).not.toThrow();
});
test("rejects referencing another credit system (prevents nesting)", () => {
expect(() =>
validateCreditSystemSchemaReferences({
config: {
schema: [{ metered_feature_id: "orbs", credit_amount: 1 } as any],
usage_type: FeatureUsageType.Single,
},
allFeatures: [otherCreditSystem],
}),
).toThrow(/cannot reference another credit system/);
});
test("self-reference (id matches selfFeatureId) is tolerated", () => {
expect(() =>
validateCreditSystemSchemaReferences({
config: {
schema: [{ metered_feature_id: "self_id", credit_amount: 1 } as any],
usage_type: FeatureUsageType.Single,
},
allFeatures: [makeFeature("self_id", FeatureType.CreditSystem)],
selfFeatureId: "self_id",
}),
).not.toThrow();
});
test("dangling reference (id not in allFeatures) is tolerated", () => {
expect(() =>
validateCreditSystemSchemaReferences({
config: {
schema: [
{ metered_feature_id: "nonexistent", credit_amount: 1 } as any,
],
usage_type: FeatureUsageType.Single,
},
allFeatures: [],
}),
).not.toThrow();
});
});

View File

@@ -288,7 +288,9 @@ test.concurrent(
const creditFeature = ctx.features.find(
(f) => f.id === TestFeature.Credits,
);
expect(creditFeature).toBeDefined();
if (!creditFeature) {
throw new Error(`${TestFeature.Credits} feature not found`);
}
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
@@ -306,7 +308,7 @@ test.concurrent(
const overflowAmount = 50;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
creditSystem: creditFeature,
amount: overflowAmount,
});

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,129 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, TrackResponseV2 } 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";
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-ORBS: AI credit system nested inside a parent credit system
//
// Parent credit systems are overflow pools (same semantics as classic
// 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(
`${chalk.yellowBright("track-tokens-orbs-1: AI balance covers the cost — parent orbs untouched")}`,
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, autumnV2 } = await initScenario({
customerId: "track-tokens-orbs-1",
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%
const inputTokens = 10_000;
const outputTokens = 5_000;
const expectedUsdCost = new Decimal(5)
.mul(inputTokens)
.add(new Decimal(15).mul(outputTokens))
.div(1_000_000)
.toNumber(); // 0.125
const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: inputTokens,
output_tokens: outputTokens,
});
expect(trackRes.customer_id).toBe(customerId);
expect(trackRes.value).toBeCloseTo(expectedUsdCost, 10);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// AI credit feature balance dropped by USD cost (1:1)
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(100).minus(expectedUsdCost).toNumber(),
usage: expectedUsdCost,
});
// AI balance covered the full cost, so the parent overflow pool is untouched
expect(customer.features[TestFeature.Orbs]).toMatchObject({
balance: 50_000,
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,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,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,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

@@ -0,0 +1,76 @@
import { expect, test } from "bun:test";
import type { ApiCustomerV3, TrackResponseV2 } 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";
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-TIERED: per-model override vs provider markup fallback
//
// Uses custom/* models so pricing is deterministic (no models.dev fetch).
// AiCreditsTiered config: defaultMarkup=10, providerMarkups.custom=30.
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-tiered: per-model override wins over provider markup fallback")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCreditsTiered,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "track-tokens-tiered",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
const inputTokens = 10000;
const outputTokens = 5000;
// base = (10000 * 10 + 5000 * 20) / 1_000_000 = 0.2
const baseCost = new Decimal(10)
.mul(inputTokens)
.add(new Decimal(20).mul(outputTokens))
.div(1_000_000);
// Per-model override of 5% wins over provider (30%) and global (10%).
const overrideCost = baseCost.mul(1.05).toNumber(); // 0.21
const overrideRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCreditsTiered,
model_id: "custom/override-model",
input_tokens: inputTokens,
output_tokens: outputTokens,
});
expect(overrideRes.value).toBeCloseTo(overrideCost, 10);
// No per-model markup -> inherits the "custom" provider markup of 30%.
const providerCost = baseCost.mul(1.3).toNumber(); // 0.26
const providerRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCreditsTiered,
model_id: "custom/provider-fallback-model",
input_tokens: inputTokens,
output_tokens: outputTokens,
});
expect(providerRes.value).toBeCloseTo(providerCost, 10);
const totalCost = new Decimal(overrideCost).plus(providerCost).toNumber();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCreditsTiered]).toMatchObject({
balance: new Decimal(1000).minus(totalCost).toNumber(),
usage: totalCost,
});
},
);

View File

@@ -0,0 +1,583 @@
import { expect, test } from "bun:test";
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";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { getModelCreditCost } from "@/internal/features/aiCreditSystemUtils.js";
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-1: Basic trackTokens with models.dev pricing
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-1: basic trackTokens with models.dev pricing")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV1, autumnV2, ctx } = await initScenario({
customerId: "track-tokens-1",
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`);
}
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features[TestFeature.AiCredits].balance).toBe(1000);
const inputTokens = 1000;
const outputTokens = 500;
const modelId = "anthropic/claude-sonnet-4-20250514";
const expectedCost = await getModelCreditCost({
modelName: modelId,
creditSystem: aiCreditFeature,
input: inputTokens,
output: outputTokens,
});
const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: modelId,
input_tokens: inputTokens,
output_tokens: outputTokens,
});
expect(trackRes.customer_id).toBe(customerId);
expect(trackRes.value).toBeCloseTo(expectedCost, 10);
const customerAfter =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerAfter.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(1000).minus(expectedCost).toNumber(),
usage: expectedCost,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-2: Disambiguation error + explicit feature_id resolution
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-2: disambiguation error and explicit feature_id resolution")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 500,
});
const aiCredits2Item = items.free({
featureId: TestFeature.AiCredits2,
includedUsage: 500,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem, aiCredits2Item],
});
const { customerId, autumnV1, autumnV2, ctx } = await initScenario({
customerId: "track-tokens-2",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeProd] }),
],
actions: [s.attach({ productId: freeProd.id })],
});
// Without feature_id, should fail with disambiguation error
let error: any;
try {
await autumnV2.post("/track_tokens", {
customer_id: customerId,
model_id: "anthropic/claude-sonnet-4-20250514",
input_tokens: 100,
output_tokens: 50,
});
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toContain("Multiple AI credit system features");
// With explicit feature_id, should succeed and only deduct from AiCredits
const aiCreditFeature = ctx.features.find(
(f) => f.id === TestFeature.AiCredits,
);
if (!aiCreditFeature) {
throw new Error(`${TestFeature.AiCredits} feature not found`);
}
const inputTokens = 2000;
const outputTokens = 1000;
const modelId = "anthropic/claude-sonnet-4-20250514";
const expectedCost = await getModelCreditCost({
modelName: modelId,
creditSystem: aiCreditFeature,
input: inputTokens,
output: outputTokens,
});
const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: modelId,
input_tokens: inputTokens,
output_tokens: outputTokens,
});
expect(trackRes.customer_id).toBe(customerId);
expect(trackRes.value).toBeCloseTo(expectedCost, 10);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(500).minus(expectedCost).toNumber(),
usage: expectedCost,
});
expect(customer.features[TestFeature.AiCredits2]).toMatchObject({
balance: 500,
usage: 0,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-3: custom/* model pricing (with and without markup)
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-3: custom model pricing with and without markup")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "track-tokens-3",
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%
const expectedCostNoMarkup = new Decimal(5)
.mul(10000)
.add(new Decimal(15).mul(5000))
.div(1_000_000)
.toNumber(); // 0.125
const trackRes1: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 10000,
output_tokens: 5000,
});
expect(trackRes1.value).toBeCloseTo(expectedCostNoMarkup, 10);
// custom/marked-up-model: input_cost=10 $/M, output_cost=30 $/M, markup=50%
const baseCost = new Decimal(10)
.mul(8000)
.add(new Decimal(30).mul(2000))
.div(1_000_000);
const expectedCostWithMarkup = baseCost
.mul(new Decimal(1).add(new Decimal(50).div(100)))
.toNumber(); // 0.21
const trackRes2: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/marked-up-model",
input_tokens: 8000,
output_tokens: 2000,
});
expect(trackRes2.value).toBeCloseTo(expectedCostWithMarkup, 10);
const totalCost = new Decimal(expectedCostNoMarkup)
.plus(expectedCostWithMarkup)
.toNumber();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(1000).minus(totalCost).toNumber(),
usage: totalCost,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-4: models.dev pricing with markup + error for non-AI feature
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-4: models.dev markup and non-AI feature_id error")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const creditsItem = items.free({
featureId: TestFeature.Credits,
includedUsage: 100,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem, creditsItem],
});
const { customerId, autumnV1, autumnV2, ctx } = await initScenario({
customerId: "track-tokens-4",
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`);
}
// anthropic/claude-haiku-3.5 has 20% markup in test config
const inputTokens = 50000;
const outputTokens = 10000;
const modelId = "anthropic/claude-3-5-haiku-20241022";
const expectedCost = await getModelCreditCost({
modelName: modelId,
creditSystem: aiCreditFeature,
input: inputTokens,
output: outputTokens,
});
const trackRes: TrackResponseV2 = await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: modelId,
input_tokens: inputTokens,
output_tokens: outputTokens,
});
expect(trackRes.value).toBeCloseTo(expectedCost, 10);
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(1000).minus(expectedCost).toNumber(),
usage: expectedCost,
});
// Pointing at a regular credit system should fail
let error: any;
try {
await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.Credits,
model_id: "anthropic/claude-sonnet-4-20250514",
input_tokens: 100,
output_tokens: 50,
});
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(error.message).toContain("not an AI credit system");
},
);
// ═══════════════════════════════════════════════════════════════════
// TRACK-TOKENS-5: Multiple tracks accumulate correctly
// ═══════════════════════════════════════════════════════════════════
test.concurrent(
`${chalk.yellowBright("track-tokens-5: multiple tracks accumulate balance deductions")}`,
async () => {
const aiCreditsItem = items.free({
featureId: TestFeature.AiCredits,
includedUsage: 1000,
});
const freeProd = products.base({
id: "free",
items: [aiCreditsItem],
});
const { customerId, autumnV1, autumnV2, ctx } = await initScenario({
customerId: "track-tokens-5",
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`);
}
// First track: custom/internal-model (input_cost=5, output_cost=15, markup=0%)
const cost1 = await getModelCreditCost({
modelName: "custom/internal-model",
creditSystem: aiCreditFeature,
input: 5000,
output: 2000,
});
await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/internal-model",
input_tokens: 5000,
output_tokens: 2000,
});
// Second track: custom/marked-up-model (input_cost=10, output_cost=30, markup=50%)
const cost2 = await getModelCreditCost({
modelName: "custom/marked-up-model",
creditSystem: aiCreditFeature,
input: 3000,
output: 1000,
});
await autumnV2.post("/track_tokens", {
customer_id: customerId,
feature_id: TestFeature.AiCredits,
model_id: "custom/marked-up-model",
input_tokens: 3000,
output_tokens: 1000,
});
const totalCost = new Decimal(cost1).plus(cost2).toNumber();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.AiCredits]).toMatchObject({
balance: new Decimal(1000).minus(totalCost).toNumber(),
usage: totalCost,
});
},
);
// ═══════════════════════════════════════════════════════════════════
// 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 getModelCreditCost({
modelName: modelId,
creditSystem: aiCreditFeature,
...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 getModelCreditCost({
modelName: modelId,
creditSystem: aiCreditFeature,
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

@@ -4,6 +4,7 @@ dotenv.config();
import { AppEnv, FeatureUsageType } from "@autumn/shared";
import {
constructAiCreditSystem,
constructBooleanFeature,
constructCreditSystem,
constructMeteredFeature,
@@ -25,6 +26,12 @@ export enum TestFeature {
Action3 = "action3", // single use (pay per use)
Credits2 = "credits2", // credit system
AiCredits = "ai_credits", // AI credit system (models.dev pricing)
AiCredits2 = "ai_credits_2", // second AI credit system (for disambiguation tests)
AiCreditsTiered = "ai_credits_tiered", // AI credit system with global + provider markup tiers
Orbs = "orbs", // credit system that wraps an AI credit system (1000 orbs per $1)
}
export const getFeatures = ({ orgId }: { orgId: string }) => ({
@@ -121,4 +128,70 @@ export const getFeatures = ({ orgId }: { orgId: string }) => ({
},
],
}),
[TestFeature.AiCredits]: constructAiCreditSystem({
featureId: TestFeature.AiCredits,
orgId,
env: AppEnv.Sandbox,
modelMarkups: {
"anthropic/claude-sonnet-4-20250514": {
markup: 0,
},
"anthropic/claude-3-5-haiku-20241022": {
markup: 20,
},
"custom/internal-model": {
markup: 0,
input_cost: 5,
output_cost: 15,
},
"custom/marked-up-model": {
markup: 50,
input_cost: 10,
output_cost: 30,
},
},
}),
[TestFeature.AiCredits2]: constructAiCreditSystem({
featureId: TestFeature.AiCredits2,
orgId,
env: AppEnv.Sandbox,
modelMarkups: {
"anthropic/claude-sonnet-4-20250514": {
markup: 10,
},
},
}),
[TestFeature.AiCreditsTiered]: constructAiCreditSystem({
featureId: TestFeature.AiCreditsTiered,
orgId,
env: AppEnv.Sandbox,
defaultMarkup: 10,
providerMarkups: {
custom: { markup: 30 },
},
modelMarkups: {
// Per-model override wins over provider/global.
"custom/override-model": {
markup: 5,
input_cost: 10,
output_cost: 20,
},
// No markup -> inherits the "custom" provider markup (30%).
"custom/provider-fallback-model": {
input_cost: 10,
output_cost: 20,
},
},
}),
[TestFeature.Orbs]: constructCreditSystem({
featureId: TestFeature.Orbs,
orgId,
env: AppEnv.Sandbox,
schema: [
{
metered_feature_id: TestFeature.AiCredits,
credit_cost: 1000, // 1000 orbs per $1 of AI usage
},
],
}),
});

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "bun:test";
import {
type Feature,
FeatureType,
FeatureUsageType,
type FullCusEntWithFullCusProduct,
} from "@autumn/shared";
import { computeCreditCosts } from "@/internal/balances/utils/deduction/computeCreditCosts.js";
import type { FeatureDeduction } from "@/internal/balances/utils/types/featureDeduction.js";
const makeFeature = (
id: string,
type: FeatureType,
schema: { metered_feature_id: string; credit_amount: number }[] = [],
): Feature => ({
internal_id: `fe_${id}`,
org_id: "org_test",
created_at: 0,
env: "sandbox" as Feature["env"],
id,
name: id,
type,
config: { schema, usage_type: FeatureUsageType.Single },
archived: false,
event_names: [],
model_markups: null,
});
const makeCusEnt = (id: string, feature: Feature) =>
({ id, entitlement: { feature } }) as FullCusEntWithFullCusProduct;
const messages = makeFeature("messages", FeatureType.Metered);
const credits = makeFeature("credits", FeatureType.CreditSystem, [
{ metered_feature_id: "messages", credit_amount: 0.2 },
]);
// Simulates a stale cached snapshot whose schema no longer includes "messages".
const staleCredits = makeFeature("credits", FeatureType.CreditSystem, [
{ metered_feature_id: "other_feature", credit_amount: 5 },
]);
describe("computeCreditCosts", () => {
test("applies schema ratios for parent credit systems", () => {
const deduction: FeatureDeduction = { feature: messages, deduction: 10 };
const lookup = computeCreditCosts({
cusEnts: [makeCusEnt("ce_msg", messages), makeCusEnt("ce_cred", credits)],
deduction,
});
expect(lookup("ce_msg")).toBe(1);
expect(lookup("ce_cred")).toBe(0.2);
});
test("token deductions use their USD cost 1:1 and ratio-map to parents", () => {
const aiCredits = makeFeature("ai_credits", FeatureType.AiCreditSystem);
const orbs = makeFeature("orbs", FeatureType.CreditSystem, [
{ metered_feature_id: "ai_credits", credit_amount: 1000 },
]);
const deduction: FeatureDeduction = {
feature: aiCredits,
deduction: 1,
tokens: {
usage: { modelName: "custom/m", inputTokens: 1, outputTokens: 1 },
cost: 0.125,
},
};
const lookup = computeCreditCosts({
cusEnts: [makeCusEnt("ce_ai", aiCredits), makeCusEnt("ce_orbs", orbs)],
deduction,
});
expect(lookup("ce_ai")).toBe(0.125);
expect(lookup("ce_orbs")).toBe(125);
});
test("stale schema snapshot falls back to 1 instead of failing the track", () => {
const deduction: FeatureDeduction = { feature: messages, deduction: 10 };
const lookup = computeCreditCosts({
cusEnts: [makeCusEnt("ce_stale", staleCredits)],
deduction,
});
expect(lookup("ce_stale")).toBe(1);
});
});

View File

@@ -0,0 +1,133 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { Hono } from "hono";
import type { AutumnContext, HonoEnv } from "@/honoUtils/HonoEnv.js";
const mockState = {
getTokenTrackParamsCalls: [] as Record<string, unknown>[],
runTrackWithRolloutCalls: [] as Record<string, unknown>[],
queuedForReplay: false,
};
const trackBody = {
customer_id: "cus_123",
entity_id: "ent_123",
feature_id: "ai_credits",
value: 3.5,
};
const featureDeductions = [
{
feature: { id: "ai_credits" },
deduction: 1,
tokens: {
usage: {
modelName: "openai/gpt-4.1",
inputTokens: 100,
outputTokens: 50,
},
cost: 3.5,
},
},
];
mock.module("@/internal/balances/track/utils/getTokenTrackParams.js", () => ({
getTokenTrackParams: async (args: Record<string, unknown>) => {
mockState.getTokenTrackParamsCalls.push(args);
return { body: trackBody, featureDeductions };
},
}));
mock.module("@/internal/balances/track/runTrackWithRollout.js", () => ({
runTrackWithRollout: async (args: {
ctx: AutumnContext;
body: typeof trackBody;
featureDeductions: typeof featureDeductions;
}) => {
mockState.runTrackWithRolloutCalls.push(args);
if (mockState.queuedForReplay) {
args.ctx.extraLogs.trackQueuedForReplay = true;
}
return {
customer_id: args.body.customer_id,
entity_id: args.body.entity_id,
value: args.body.value,
balance: null,
};
},
}));
import { handleTrackTokens } from "@/internal/balances/handlers/handleTrackTokens.js";
const requestBody = {
customer_id: "cus_123",
entity_id: "ent_123",
model_id: "openai/gpt-4.1",
input_tokens: 100,
output_tokens: 50,
};
const createApp = ({ ctx }: { ctx: AutumnContext }) => {
const app = new Hono<HonoEnv>();
app.use("*", async (c, next) => {
c.set("ctx", ctx);
await next();
});
app.post("/track_tokens", ...handleTrackTokens);
return app;
};
const createCtx = (): AutumnContext =>
({
features: [],
extraLogs: {},
scopes: [],
skipCache: false,
}) as unknown as AutumnContext;
describe("handleTrackTokens", () => {
beforeEach(() => {
mockState.getTokenTrackParamsCalls = [];
mockState.runTrackWithRolloutCalls = [];
mockState.queuedForReplay = false;
});
test("tracks converted token usage through the rollout path", async () => {
const ctx = createCtx();
const response = await createApp({ ctx }).request("/track_tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
});
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
customer_id: "cus_123",
entity_id: "ent_123",
value: 3.5,
balance: null,
});
expect(mockState.getTokenTrackParamsCalls).toHaveLength(1);
expect(mockState.getTokenTrackParamsCalls[0]).toMatchObject({
input: requestBody,
});
expect(mockState.runTrackWithRolloutCalls).toHaveLength(1);
expect(mockState.runTrackWithRolloutCalls[0]).toMatchObject({
body: trackBody,
featureDeductions,
});
});
test("returns 202 when rollout fallback queues token tracking for replay", async () => {
mockState.queuedForReplay = true;
const ctx = createCtx();
const response = await createApp({ ctx }).request("/track_tokens", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(requestBody),
});
expect(response.status).toBe(202);
expect(ctx.extraLogs.trackQueuedForReplay).toBe(true);
expect(mockState.runTrackWithRolloutCalls).toHaveLength(1);
});
});

View File

@@ -0,0 +1,179 @@
import { describe, expect, test } from "bun:test";
import {
ErrCode,
type Feature,
FeatureType,
FeatureUsageType,
} from "@autumn/shared";
import {
getModelCreditCost,
getModelCreditCostBreakdown,
} from "@/internal/features/aiCreditSystemUtils.js";
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 schema math", () => {
test("self feature maps 1:1 (plain /track values, queued replays)", () => {
const cost = getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
amount: 5.25,
});
expect(cost).toBe(5.25);
});
test("self feature defaults to a per-unit cost of 1", () => {
const cost = getCreditCost({
featureId: aiCreditFeature.id,
creditSystem: aiCreditFeature,
});
expect(cost).toBe(1);
});
test("non-self feature throws — AI credit systems have no schema", () => {
expect(() =>
getCreditCost({
featureId: "some_other_feature",
creditSystem: aiCreditFeature,
amount: 5,
}),
).toThrow(/no schema/);
});
});
describe("getModelCreditCost — token pricing", () => {
test("prices through the model markup config", async () => {
const cost = await getModelCreditCost({
modelName: CUSTOM_MODEL,
creditSystem: aiCreditFeature,
input: 1000,
output: 500,
});
// (1000 * 1000 + 2000 * 500) / 1_000_000 = 2.0
expect(cost).toBeCloseTo(2.0, 10);
});
test("custom model without configured costs throws", async () => {
expect(
getModelCreditCost({
modelName: "custom/unconfigured",
creditSystem: aiCreditFeature,
input: 100,
output: 50,
}),
).rejects.toMatchObject({
code: ErrCode.InvalidRequest,
});
});
});
describe("getModelCreditCostBreakdown — pricing audit trail", () => {
test("records base cost, markup source, and effective rates", async () => {
const withMarkup: Feature = {
...aiCreditFeature,
model_markups: {
[CUSTOM_MODEL]: { markup: 50, input_cost: 1000, output_cost: 2000 },
},
};
const breakdown = await getModelCreditCostBreakdown({
modelName: CUSTOM_MODEL,
creditSystem: withMarkup,
input: 1000,
output: 500,
});
expect(breakdown.baseCost).toBeCloseTo(2.0, 10);
expect(breakdown.cost).toBeCloseTo(3.0, 10);
expect(breakdown.markup).toBe(50);
expect(breakdown.markupSource).toBe("model");
expect(breakdown.tierApplied).toBe(false);
expect(breakdown.rates.input).toBe(1000);
expect(breakdown.rates.output).toBe(2000);
// Unpublished pools fall back to the text rates.
expect(breakdown.rates.cacheRead).toBe(1000);
expect(breakdown.rates.reasoning).toBe(2000);
});
test("explicit markup 0 reports source model; no markup anywhere reports none", async () => {
const explicitZero = await getModelCreditCostBreakdown({
modelName: CUSTOM_MODEL,
creditSystem: aiCreditFeature,
input: 1000,
output: 500,
});
expect(explicitZero.markup).toBe(0);
expect(explicitZero.markupSource).toBe("model");
const unconfigured = await getModelCreditCostBreakdown({
modelName: CUSTOM_MODEL,
creditSystem: {
...aiCreditFeature,
model_markups: {
[CUSTOM_MODEL]: { input_cost: 1000, output_cost: 2000 },
},
},
input: 1000,
output: 500,
});
expect(unconfigured.markup).toBe(0);
expect(unconfigured.markupSource).toBe("none");
});
test("reports provider and default markup sources", async () => {
const noModelMarkup: Feature = {
...aiCreditFeature,
config: {
schema: [],
usage_type: FeatureUsageType.Single,
default_markup: 10,
provider_markups: { custom: { markup: 20 } },
},
model_markups: {
[CUSTOM_MODEL]: { input_cost: 1000, output_cost: 2000 },
},
};
const provider = await getModelCreditCostBreakdown({
modelName: CUSTOM_MODEL,
creditSystem: noModelMarkup,
input: 1000,
output: 500,
});
expect(provider.markup).toBe(20);
expect(provider.markupSource).toBe("provider");
const defaultOnly = await getModelCreditCostBreakdown({
modelName: CUSTOM_MODEL,
creditSystem: {
...noModelMarkup,
config: {
schema: [],
usage_type: FeatureUsageType.Single,
default_markup: 10,
},
},
input: 1000,
output: 500,
});
expect(defaultOnly.markup).toBe(10);
expect(defaultOnly.markupSource).toBe("default");
});
});

View File

@@ -0,0 +1,132 @@
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",
});
});
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);
});

View File

@@ -2,6 +2,7 @@ import {
AllowanceType,
type EntInterval,
FeatureType,
type ModelMarkups,
type RolloverConfig,
} from "@autumn/shared";
import { features } from "./features";
@@ -21,6 +22,7 @@ const create = ({
intervalCount = 1,
entityFeatureId = null,
rollover = null,
modelMarkups = null,
}: {
id?: string;
featureId: string;
@@ -33,6 +35,7 @@ const create = ({
intervalCount?: number;
entityFeatureId?: string | null;
rollover?: RolloverConfig | null;
modelMarkups?: ModelMarkups;
}) => ({
id: id ?? `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`,
created_at: Date.now(),
@@ -54,6 +57,7 @@ const create = ({
name: featureName,
type: featureType,
config: featureConfig,
modelMarkups: modelMarkups ?? null,
}),
});

View File

@@ -1,4 +1,4 @@
import { AppEnv, FeatureType } from "@autumn/shared";
import { AppEnv, FeatureType, type ModelMarkups } from "@autumn/shared";
/**
* Create a feature fixture
@@ -9,12 +9,14 @@ const create = ({
name,
type = FeatureType.Metered,
config = {},
modelMarkups = null,
}: {
id: string;
internalId?: string;
name: string;
type?: FeatureType;
config?: Record<string, unknown>;
modelMarkups?: ModelMarkups;
}) => ({
internal_id: internalId ?? `internal_${id}`,
org_id: "org_test",
@@ -27,6 +29,7 @@ const create = ({
display: null,
archived: false,
event_names: [],
model_markups: modelMarkups ?? null,
});
// ═══════════════════════════════════════════════════════════════════

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,

View File

@@ -0,0 +1,63 @@
import { z } from "zod/v4";
import { CustomerDataSchema } from "../../common/customerData";
import { EntityDataSchema } from "../../common/entityData";
export const TrackTokensParamsSchema = z.object({
customer_id: z.string().meta({
description: "The ID of the customer.",
}),
entity_id: z.string().optional().meta({
description: "The ID of the entity for entity-scoped balances.",
}),
feature_id: z.string().optional().meta({
description:
"The ID of the AI credit system feature. Auto-detected from the customer's entitlements if omitted — only required when a customer has multiple AI credit systems.",
}),
model_id: z.string().meta({
description:
"The AI model as '<provider>/<model>' (e.g. 'anthropic/claude-opus-4-8', 'openrouter/openai/gpt-4o'). The provider is the first path segment and must match a provider + model key in models.dev.",
}),
input_tokens: z.number().int().nonnegative().meta({
description:
"Number of non-cached text input tokens consumed. Exclusive of cache and audio token pools.",
}),
output_tokens: z.number().int().nonnegative().meta({
description:
"Number of text output tokens consumed. Exclusive of the reasoning and audio output pools.",
}),
cache_read_tokens: z.number().int().nonnegative().optional().meta({
description: "Number of cached input tokens read.",
}),
cache_write_tokens: z.number().int().nonnegative().optional().meta({
description: "Number of input tokens written to the cache.",
}),
audio_input_tokens: z.number().int().nonnegative().optional().meta({
description: "Number of audio input tokens consumed.",
}),
audio_output_tokens: z.number().int().nonnegative().optional().meta({
description: "Number of audio output tokens generated.",
}),
reasoning_tokens: z.number().int().nonnegative().optional().meta({
description: "Number of reasoning tokens generated.",
}),
properties: z.record(z.string(), z.any()).optional().meta({
description: "Additional properties to attach to this usage event.",
}),
idempotency_key: z.string().optional().meta({
internal: true,
}),
overage_behavior: z.enum(["cap", "reject"]).optional().meta({
internal: true,
}),
customer_data: CustomerDataSchema.optional().meta({
internal: true,
}),
entity_data: EntityDataSchema.optional().meta({
internal: true,
}),
skip_event: z.boolean().optional().meta({
internal: true,
}),
});
export type TrackTokensParams = z.infer<typeof TrackTokensParamsSchema>;

View File

@@ -1,4 +1,8 @@
import { z } from "zod/v4";
import {
ModelMarkupsSchema,
ProviderMarkupsSchema,
} from "../../models/featureModels/featureConfig/creditConfig";
import { FeatureType } from "../../models/featureModels/featureEnums";
export const ApiFeatureV1Schema = z.object({
@@ -12,7 +16,7 @@ export const ApiFeatureV1Schema = z.object({
}),
type: z.enum(FeatureType).meta({
description:
"Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools.",
"Feature type: 'boolean' for on/off access, 'metered' for usage-tracked features, 'credit_system' for unified credit pools, 'ai_credit_system' for model-based token pricing.",
}),
consumable: z.boolean().meta({
@@ -42,6 +46,20 @@ export const ApiFeatureV1Schema = z.object({
"For credit_system features: maps metered features to their credit costs.",
}),
model_markups: ModelMarkupsSchema.optional().meta({
description: "Per-model markup overrides for AI credit systems.",
}),
default_markup: z.number().min(-100).optional().meta({
description:
"Default percentage markup for AI credit systems. Use -100 to make usage free.",
}),
provider_markups: ProviderMarkupsSchema.optional().meta({
description:
"Per-provider default markup percentages for AI credit systems.",
}),
display: z
.object({
singular: z.string().nullish().meta({

View File

@@ -4,6 +4,7 @@ import {
defineVersionChange,
} from "@api/versionUtils/versionChangeUtils/VersionChange.js";
import { FeatureType } from "@models/featureModels/featureEnums.js";
import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
import type { z } from "zod/v4";
import { ApiFeatureV1Schema } from "../apiFeatureV1.js";
import {
@@ -63,13 +64,13 @@ export const V1_2_FeatureChange = defineVersionChange({
v0Type = ApiFeatureType.Boolean;
} else if (input.type === FeatureType.CreditSystem) {
v0Type = ApiFeatureType.CreditSystem;
} else if (isAiCreditSystem(input.type)) {
v0Type = ApiFeatureType.AiCreditSystem;
} else if (input.type === FeatureType.Metered) {
// Use consumable flag to determine single_use vs continuous_use
v0Type = input.consumable
? ApiFeatureType.SingleUsage
: ApiFeatureType.ContinuousUse;
} else {
// Fallback (should never happen)
v0Type = ApiFeatureType.Boolean;
}
@@ -83,7 +84,10 @@ export const V1_2_FeatureChange = defineVersionChange({
plural: input.display.plural || "",
}
: null,
credit_schema: input.credit_schema || null,
credit_schema: input.credit_schema?.map((item) => ({
metered_feature_id: item.metered_feature_id,
credit_cost: item.credit_cost,
})) || null,
archived: input.archived,
} satisfies z.infer<typeof ApiFeatureV0Schema>;
},

View File

@@ -1,4 +1,8 @@
import { z } from "zod/v4";
import {
ModelMarkupsSchema,
ProviderMarkupsSchema,
} from "../../../../models/featureModels/featureConfig/creditConfig";
import { FeatureType } from "../../../../models/featureModels/featureEnums";
import { idRegex } from "../../../../utils/utils";
@@ -43,8 +47,23 @@ export const BaseFeatureV1ParamsSchema = z.object({
.optional()
.meta({
description:
"A schema that maps 'single_use' feature IDs to credit costs. Applicable only for 'credit_system' features.",
"A schema that maps 'single_use' feature IDs to credit costs. For classic credit systems only — AI credit systems use model_markups instead.",
}),
model_markups: ModelMarkupsSchema.optional().meta({
description:
"Per-model markup overrides for AI credit systems. Maps model IDs to their markup configuration.",
}),
default_markup: z.number().min(-100).optional().meta({
description:
"Default percentage markup for this AI credit system. Used when no model or provider markup applies. Use -100 to make usage free.",
}),
provider_markups: ProviderMarkupsSchema.optional().meta({
description:
"Per-provider default markup percentages for AI credit systems. Provider keys match the first segment of model_id.",
}),
event_names: z.array(z.string()).optional(),
});

View File

@@ -7,6 +7,7 @@ export enum ApiFeatureType {
SingleUsage = "single_use",
ContinuousUse = "continuous_use",
CreditSystem = "credit_system",
AiCreditSystem = "ai_credit_system",
}
export const FEATURE_EXAMPLE = {

View File

@@ -47,6 +47,7 @@ export * from "./balances/index.js";
export * from "./balances/prevVersions/legacyUpdateBalanceModels.js";
export * from "./balances/track/prevVersions/trackResponseV1.js";
export * from "./balances/track/trackParams.js";
export * from "./balances/track/trackTokensParams.js";
export * from "./balances/track/trackResponseV2.js";
export * from "./balances/track/trackResponseV3.js";
export * from "./balances/update/updateBalanceParams.js";

View File

@@ -0,0 +1 @@
ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null;

View File

@@ -200,6 +200,8 @@ export * from "./models/rewardModels/rewardProgramModels/rewardProgramTable";
export * from "./models/scheduleModels/scheduleTable";
export * from "./models/subModels/subModels";
export * from "./models/subModels/subTable";
// AI Models
export * from "./models/aiModels/modelsDevTypes";
export * from "./types";
// Agent Types (for pricing agent AI)
export * from "./utils/agentTypes";
@@ -215,6 +217,8 @@ export * from "./utils/cusEntUtils/balanceUtils/cusEntToUsageAllowed";
export * from "./utils/cusEntUtils/index";
// Utils
export * from "./utils/displayUtils";
export * from "./utils/featureUtils/buildAiCreditSystemConfig";
export * from "./utils/featureUtils/resolveInheritedMarkup";
export * from "./utils/fullSubjectUtils";
export * from "./utils/index";
export * from "./utils/intervalUtils";

View File

@@ -0,0 +1,73 @@
/** Separator between the provider key and the model key in an AI credit `model_id`. */
export const PROVIDER_SEPARATOR = "/";
/** Prefix marking a user-defined model whose price comes from `model_markups`. */
export const CUSTOM_PROVIDER = "custom";
/**
* Split a `model_id` on the first {@link PROVIDER_SEPARATOR}. The model key keeps any
* remaining separators (e.g. openrouter slugs like `openrouter/openai/gpt-4o`). When no
* separator is present the id is a bare model name and `provider` is `undefined`.
*/
export const splitModelId = (
id: string,
): { provider: string | undefined; modelKey: string } => {
const index = id.indexOf(PROVIDER_SEPARATOR);
if (index === -1) {
return { provider: undefined, modelKey: id };
}
return {
provider: id.slice(0, index),
modelKey: id.slice(index + PROVIDER_SEPARATOR.length),
};
};
/** Build a canonical `model_id` from a provider key and model key. */
export const joinModelId = (provider: string, modelKey: string): string =>
`${provider}${PROVIDER_SEPARATOR}${modelKey}`;
/** Whether a `model_id` refers to a custom, user-priced model. */
export const isCustomModel = (id: string): boolean =>
id.startsWith(`${CUSTOM_PROVIDER}${PROVIDER_SEPARATOR}`);
/** A context-based price tier (e.g. higher rates once the prompt exceeds `size` tokens). */
export interface ModelsDevCostTier {
input: number;
output: number;
cache_read?: number;
cache_write?: number;
tier: { type: string; size: number };
}
/** Per-token rates ($/M tokens) for a model. Only `input`/`output` are guaranteed. */
export interface ModelsDevCost {
input: number;
output: number;
cache_read?: number;
cache_write?: number;
input_audio?: number;
output_audio?: number;
reasoning?: number;
tiers?: ModelsDevCostTier[];
context_over_200k?: {
input: number;
output: number;
cache_read?: number;
cache_write?: number;
};
}
/** Shape of a single model from the models.dev API */
export interface ModelsDevModel {
id: string;
name: string;
release_date?: string;
cost: ModelsDevCost;
}
/** Shape of a provider from the models.dev API */
export interface ModelsDevProvider {
id: string;
name: string;
models: Record<string, ModelsDevModel>;
}

View File

@@ -7,16 +7,41 @@ export const CreditSchemaItemSchema = z.object({
credit_amount: z.number(),
});
const MarkupEntrySchema = z.object({
markup: z.number().min(-100), // percentage markup, e.g. 20 for 20%, -100 for free
});
export const ProviderMarkupsSchema = z
.record(
z.string(), // Provider key from the model name, e.g. "openrouter" in "openrouter/anthropic/claude"
MarkupEntrySchema,
)
.nullish();
export const CreditSystemConfigSchema = z.object({
schema: z.array(
z.object({
metered_feature_id: z.string(),
// feature_amount: z.number(),
credit_amount: z.number(),
}),
),
usage_type: z.nativeEnum(FeatureUsageType),
default_markup: z.number().min(-100).optional(),
provider_markups: ProviderMarkupsSchema,
});
export const ModelMarkupsSchema = z
.record(
z.string(), // Represents the model name in "provider/model" format, e.g. "anthropic/claude-2"
MarkupEntrySchema.extend({
markup: z.number().min(-100).optional(), // Omit to inherit provider/global markup
input_cost: z.number().min(0).optional(), // $/M tokens, required for custom/ models
output_cost: z.number().min(0).optional(), // $/M tokens, required for custom/ models
}),
)
.nullish();
export type CreditSystemConfig = z.infer<typeof CreditSystemConfigSchema>;
export type CreditSchemaItem = z.infer<typeof CreditSchemaItemSchema>;
export type ModelMarkups = z.infer<typeof ModelMarkupsSchema>;
export type ProviderMarkups = z.infer<typeof ProviderMarkupsSchema>;

View File

@@ -2,6 +2,7 @@ export enum FeatureType {
Boolean = "boolean",
Metered = "metered",
CreditSystem = "credit_system",
AiCreditSystem = "ai_credit_system",
}
export enum AggregateType {

View File

@@ -1,3 +1,4 @@
import { ModelMarkupsSchema } from "@models/featureModels/featureConfig/creditConfig";
import { z } from "zod/v4";
import { AppEnv } from "../genModels/genEnums";
import { FeatureType } from "./featureEnums";
@@ -22,6 +23,7 @@ export const FeatureSchema = z.object({
.nullish(),
archived: z.boolean(),
event_names: z.array(z.string()).default([]),
model_markups: ModelMarkupsSchema.nullish(),
});
export const CreateFeatureSchema = FeatureSchema.pick({
@@ -31,6 +33,7 @@ export const CreateFeatureSchema = FeatureSchema.pick({
config: true,
display: true,
event_names: true,
model_markups: true,
});
export const MinFeatureSchema = z.object({

View File

@@ -10,7 +10,7 @@ import {
} from "drizzle-orm/pg-core";
import { collatePgColumn } from "../../db/utils";
import { organizations } from "../orgModels/orgTable";
import type { CreditSystemConfig } from "./featureConfig/creditConfig";
import type { CreditSystemConfig, ModelMarkups } from "./featureConfig/creditConfig";
import type { MeteredConfig } from "./featureConfig/meteredConfig";
type FeatureDisplay = {
@@ -33,6 +33,7 @@ export const features = pgTable(
display: jsonb().default(sql`null`).$type<FeatureDisplay>(),
archived: boolean("archived").notNull().default(false),
event_names: text("event_names").array().default([]),
model_markups: jsonb().$type<ModelMarkups>().default(sql`null`),
},
(table) => [
foreignKey({

View File

@@ -10,6 +10,10 @@
* - Converters: AgentFeature ↔ Feature, AgentProduct ↔ ProductV2
*/
import type {
ModelMarkups,
ProviderMarkups,
} from "../models/featureModels/featureConfig/creditConfig.js";
import {
FeatureType,
FeatureUsageType,
@@ -19,6 +23,8 @@ import { AppEnv } from "../models/genModels/genEnums.js";
import { Infinite } from "../models/productModels/productEnums.js";
import type { ProductItem } from "../models/productV2Models/productItemModels/productItemModels.js";
import type { ProductV2 } from "../models/productV2Models/productV2Models.js";
import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
import { buildAiCreditSystemConfig } from "./featureUtils/buildAiCreditSystemConfig.js";
// ============ INTERFACES ============
@@ -27,7 +33,8 @@ export type AgentFeatureType =
| "boolean"
| "single_use"
| "continuous_use"
| "credit_system";
| "credit_system"
| "ai_credit_system";
export interface AgentFeature {
id: string;
@@ -41,6 +48,9 @@ export interface AgentFeature {
metered_feature_id: string;
credit_cost: number;
}> | null;
model_markups?: ModelMarkups;
default_markup?: number | null;
provider_markups?: ProviderMarkups;
}
export interface AgentProductItem {
@@ -84,6 +94,8 @@ function mapAgentTypeToFeatureType(agentType: AgentFeatureType): FeatureType {
return FeatureType.Boolean;
case "credit_system":
return FeatureType.CreditSystem;
case "ai_credit_system":
return FeatureType.AiCreditSystem;
default:
return FeatureType.Metered;
}
@@ -116,6 +128,15 @@ export function agentFeatureToFeature(agentFeature: AgentFeature): Feature {
credit_amount: s.credit_cost,
}));
}
if (agentFeature.type === "ai_credit_system") {
Object.assign(
config,
buildAiCreditSystemConfig({
defaultMarkup: agentFeature.default_markup,
providerMarkups: agentFeature.provider_markups,
}),
);
}
return {
internal_id: agentFeature.id,
@@ -129,6 +150,7 @@ export function agentFeatureToFeature(agentFeature: AgentFeature): Feature {
display: agentFeature.display ?? undefined,
archived: false,
event_names: [],
model_markups: agentFeature.model_markups ?? null,
};
}
@@ -172,6 +194,10 @@ export function agentProductToProductV2(product: AgentProduct): ProductV2 {
// ============ SHARED → AGENT CONVERTERS ============
function mapFeatureTypeToAgentType(feature: Feature): AgentFeatureType {
if (isAiCreditSystem(feature.type)) {
return "ai_credit_system";
}
if (feature.type === FeatureType.CreditSystem) {
return "credit_system";
}
@@ -217,6 +243,11 @@ export function featureToAgentFeature(feature: Feature): AgentFeature {
}),
);
}
if (isAiCreditSystem(feature.type)) {
agentFeature.model_markups = feature.model_markups;
agentFeature.default_markup = feature.config?.default_markup;
agentFeature.provider_markups = feature.config?.provider_markups;
}
return agentFeature;
}

View File

@@ -18,7 +18,7 @@ export const toApiFeature = ({ feature }: { feature: Feature }) => {
}
let creditSchema: CreditSchemaItem[] | undefined;
if (feature.type === FeatureType.CreditSystem) {
if (feature.type === FeatureType.CreditSystem && feature.config?.schema) {
creditSchema = feature.config.schema.map((s: CreditSchemaItem) => ({
metered_feature_id: s.metered_feature_id,
credit_cost: s.credit_amount,

View File

@@ -8,6 +8,8 @@ import {
FeatureUsageType,
} from "@models/featureModels/featureEnums.js";
import type { Feature } from "@models/featureModels/featureModels.js";
import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
import { isAnyCreditSystem } from "./classifyFeature/isAnyCreditSystem.js";
import { AppEnv } from "@models/genModels/genEnums.js";
import type { ApiFeatureV1 } from "../../api/features/apiFeatureV1.js";
import type {
@@ -23,6 +25,7 @@ import {
import type { CreditSchemaItem } from "../../models/featureModels/featureConfig/creditConfig.js";
import type { SharedContext } from "../../types/sharedContext.js";
import { notNullish, nullish } from "../utils.js";
import { buildAiCreditSystemConfig } from "./buildAiCreditSystemConfig.js";
export const apiFeatureToDbFeature = ({
apiFeature,
@@ -72,6 +75,7 @@ export const apiFeatureToDbFeature = ({
config: newConfig,
archived: apiFeature.archived ?? originalFeature?.archived ?? false,
event_names: [],
model_markups: null,
} satisfies Feature;
};
@@ -83,6 +87,24 @@ export const featureV1ToDbFeatureConfig = ({
originalFeature: Feature;
}) => {
const type = apiFeature.type || originalFeature.type;
const hasProviderMarkups = "provider_markups" in apiFeature;
const hasDefaultMarkup = "default_markup" in apiFeature;
if (
isAiCreditSystem(type) &&
(isAiCreditSystem(apiFeature.type) ||
hasDefaultMarkup ||
hasProviderMarkups)
) {
return buildAiCreditSystemConfig({
defaultMarkup: hasDefaultMarkup
? apiFeature.default_markup
: originalFeature.config?.default_markup,
providerMarkups: hasProviderMarkups
? apiFeature.provider_markups
: originalFeature.config?.provider_markups,
});
}
if (nullish(apiFeature.consumable) && nullish(apiFeature.credit_schema))
return;
@@ -140,6 +162,16 @@ export const featureV1ToDbFeature = ({
: FeatureUsageType.Continuous;
}
if (isAiCreditSystem(apiFeature.type)) {
Object.assign(
newConfig,
buildAiCreditSystemConfig({
defaultMarkup: apiFeature.default_markup,
providerMarkups: apiFeature.provider_markups,
}),
);
}
if (apiFeature.credit_schema) {
newConfig.usage_type = FeatureUsageType.Single;
newConfig.schema = apiFeature.credit_schema.map(
@@ -150,6 +182,9 @@ export const featureV1ToDbFeature = ({
);
}
const modelMarkups =
apiFeature.model_markups ?? originalFeature?.model_markups ?? null;
return {
internal_id: originalFeature?.internal_id ?? "",
org_id: originalFeature?.org_id ?? "",
@@ -165,6 +200,7 @@ export const featureV1ToDbFeature = ({
? apiFeature.archived
: (originalFeature?.archived ?? false),
event_names: eventNames ?? [],
model_markups: modelMarkups,
} satisfies Feature;
};
@@ -191,7 +227,7 @@ export const dbToApiFeatureV1 = ({
name: dbFeature.name,
type: dbFeature.type,
consumable:
dbFeature.type === FeatureType.CreditSystem ||
isAnyCreditSystem(dbFeature.type) ||
dbFeature.config?.usage_type === FeatureUsageType.Single,
credit_schema: Array.isArray(dbFeature.config?.schema)
@@ -200,6 +236,9 @@ export const dbToApiFeatureV1 = ({
credit_cost: schema.credit_amount,
}))
: undefined,
model_markups: dbFeature.model_markups ?? undefined,
default_markup: dbFeature.config?.default_markup ?? undefined,
provider_markups: dbFeature.config?.provider_markups ?? undefined,
event_names: Array.isArray(dbFeature.event_names)
? dbFeature.event_names
: [],

View File

@@ -0,0 +1,16 @@
import type {
CreditSystemConfig,
ProviderMarkups,
} from "../../models/featureModels/featureConfig/creditConfig.js";
import { FeatureUsageType } from "../../models/featureModels/featureEnums.js";
/** Single factory for the AiCreditSystem `config` shape. Callers pass already-resolved markup values; no fallback resolution happens here. */
export const buildAiCreditSystemConfig = (args: {
defaultMarkup?: number | null;
providerMarkups?: ProviderMarkups;
}): CreditSystemConfig => ({
schema: [],
usage_type: FeatureUsageType.Single,
default_markup: args.defaultMarkup ?? undefined,
provider_markups: args.providerMarkups,
});

View File

@@ -0,0 +1,5 @@
import { FeatureType } from "@models/featureModels/featureEnums";
export const isAiCreditSystem = (
type: FeatureType | undefined | null,
): boolean => type === FeatureType.AiCreditSystem;

View File

@@ -0,0 +1,5 @@
import { FeatureType } from "@models/featureModels/featureEnums";
import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
export const isAnyCreditSystem = (type: FeatureType): boolean =>
type === FeatureType.CreditSystem || isAiCreditSystem(type);

View File

@@ -12,7 +12,8 @@ export const creditSystemContainsFeature = ({
if (creditSystem.type !== FeatureType.CreditSystem) {
return false;
}
const schema: CreditSchemaItem[] = creditSystem.config.schema;
const schema: CreditSchemaItem[] | undefined = creditSystem.config?.schema;
if (!schema) return false;
for (const schemaItem of schema) {
if (schemaItem.metered_feature_id === meteredFeatureId) {

View File

@@ -1,4 +1,6 @@
import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
import { isAllocatedFeature } from "@utils/featureUtils/classifyFeature/isAllocatedFeature";
import { isAnyCreditSystem } from "@utils/featureUtils/classifyFeature/isAnyCreditSystem";
import { isConsumableFeature } from "@utils/featureUtils/classifyFeature/isConsumableFeature";
import { findFeatureById } from "@utils/featureUtils/findFeatureUtils";
@@ -9,9 +11,14 @@ export * from "./creditSystemUtils";
export * from "./findFeatureUtils";
export * from "./sortFeatures";
export { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
export { isAnyCreditSystem } from "@utils/featureUtils/classifyFeature/isAnyCreditSystem";
export const featureUtils = {
isConsumable: isConsumableFeature,
isAllocated: isAllocatedFeature,
isAiCreditSystem,
isAnyCreditSystem,
find: {
byId: findFeatureById,

View File

@@ -0,0 +1,5 @@
/** Inherited markup precedence: a provider-level markup wins, otherwise the global default. Returns undefined when neither is set. */
export const resolveInheritedMarkup = (args: {
providerMarkup?: number | null;
defaultMarkup?: number | null;
}): number | undefined => args.providerMarkup ?? args.defaultMarkup ?? undefined;

View File

@@ -14,6 +14,7 @@ import {
isFeaturePriceItem,
isPriceItem,
} from "./productV2Utils/productItemUtils/getItemType.js";
import { isAiCreditSystem } from "@utils/featureUtils/classifyFeature/isAiCreditSystem";
import { notNullish, nullish } from "./utils.js";
// ============================================================================
@@ -54,7 +55,9 @@ const getIncludedUsageText = (item: ProductItem, feature: Feature): string => {
if (item.included_usage === Infinite) {
return `Unlimited ${featureName}`;
}
if (isAiCreditSystem(feature.type)) {
return `$${numberWithCommas(item.included_usage ?? 0)} of ${featureName}`;
}
if (nullish(item.included_usage) || item.included_usage === 0) {
return `0 ${featureName}`;
}
@@ -224,9 +227,14 @@ export const getFeaturePriceItemDisplay = ({
feature,
units: item.included_usage,
});
const includedUsageStr = hasIncludedUsage
? `${numberWithCommas(includedUsage)} ${includedFeatureName}`
: "";
let includedUsageStr = "";
if (hasIncludedUsage) {
if (isAiCreditSystem(feature.type)) {
includedUsageStr = `$${numberWithCommas(includedUsage)} of ${includedFeatureName}`;
} else {
includedUsageStr = `${numberWithCommas(includedUsage)} ${includedFeatureName}`;
}
}
const volumeFlatAmount = isVolumeFlatAmountItem(item);
@@ -249,10 +257,18 @@ export const getFeaturePriceItemDisplay = ({
feature,
units: billingUnits,
});
const perUnitStr =
billingUnits > 1
? `${numberWithCommas(billingUnits)} ${billingFeatureName}`
: billingFeatureName;
let perUnitStr: string;
if (isAiCreditSystem(feature.type)) {
perUnitStr =
billingUnits > 1
? `$${numberWithCommas(billingUnits)} of ${billingFeatureName}`
: `$1 of ${billingFeatureName}`;
} else {
perUnitStr =
billingUnits > 1
? `${numberWithCommas(billingUnits)} ${billingFeatureName}`
: billingFeatureName;
}
// Build interval string
const showInterval = isMainPrice || fullDisplay;
@@ -267,6 +283,13 @@ export const getFeaturePriceItemDisplay = ({
}
// Format output based on what we have
if (isAiCreditSystem(feature.type)) {
return {
primary_text: includedUsageStr || "$0 included",
secondary_text: "then charged based on model usage",
};
}
if (hasIncludedUsage) {
if (volumeFlatAmount) {
const featureName = getFeatureName({ feature, units: 2 });

View File

@@ -1,11 +1,10 @@
import type { Organization } from "@models/orgModels/orgTable";
import type { Entitlement } from "@models/productModels/entModels/entModels";
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import type { Price } from "@models/productModels/priceModels/priceModels";
import { orgToCurrency } from "@utils/orgUtils/convertOrgUtils";
import {
isFinalTier,
isNotFinalTier,
isPrepaidPrice,
} from "@utils/productUtils/priceUtils/classifyPriceUtils";
import { atmnToStripeAmountDecimal } from "@utils/productUtils/priceUtils/convertAmountUtils";
import { Decimal } from "decimal.js";
@@ -33,8 +32,13 @@ export const priceToStripePrepaidV2Tiers = ({
price: Price;
entitlement: Entitlement;
org: Organization;
}) => {
const config = price.config as UsagePriceConfig;
}): Stripe.PriceCreateParams.Tier[] => {
if (!isPrepaidPrice(price)) {
throw new Error(
`priceToStripePrepaidV2Tiers requires a prepaid price, got price ${price.id}`,
);
}
const config = price.config;
const tiers: Stripe.PriceCreateParams.Tier[] = [];
@@ -47,9 +51,8 @@ export const priceToStripePrepaidV2Tiers = ({
});
}
for (let i = 0; i < config.usage_tiers.length; i++) {
const tier = config.usage_tiers[i];
const atmnUnitAmount = new Decimal(tier.amount).div(
for (const tier of config.usage_tiers) {
const atmnUnitAmount = new Decimal(tier.amount ?? 0).div(
config.billing_units ?? 1,
);
@@ -58,14 +61,14 @@ export const priceToStripePrepaidV2Tiers = ({
currency: orgToCurrency({ org }),
});
let upTo = tier.to;
if (isNotFinalTier(tier) && entitlement.allowance) {
upTo = tier.to + entitlement.allowance;
let upTo: Stripe.PriceCreateParams.Tier["up_to"] = "inf";
if (isNotFinalTier(tier)) {
upTo = entitlement.allowance ? tier.to + entitlement.allowance : tier.to;
}
const stripeTier: Stripe.PriceCreateParams.Tier = {
unit_amount_decimal: stripeUnitAmountDecimal,
up_to: isFinalTier(tier) ? "inf" : upTo,
up_to: upTo,
};
if (tier.flat_amount) {
@@ -79,13 +82,13 @@ export const priceToStripePrepaidV2Tiers = ({
}
// Divide all tiers by billing units
const dividedTiers = tiers.map((tier, index: number) => ({
return tiers.map((tier, index) => ({
...tier,
up_to:
index === tiers.length - 1
index === tiers.length - 1 || tier.up_to === "inf"
? "inf"
: new Decimal(tier.up_to ?? 0)
: new Decimal(tier.up_to)
.div(config.billing_units ?? 1)
.ceil()
.toNumber(),
@@ -94,6 +97,4 @@ export const priceToStripePrepaidV2Tiers = ({
.mul(config.billing_units ?? 1)
.toString(),
}));
return dividedTiers;
};

View File

@@ -6,7 +6,7 @@ interface GroupedTabButtonProps {
onValueChange: (value: string) => void;
options: Array<{
value: string;
label: string;
label: React.ReactNode;
icon?: React.ReactNode;
}>;
className?: string;

Some files were not shown because too many files have changed in this diff Show More