Merge pull request #1049 from TheUntraceable/feat/ai-credit-system
feat: ai credit system
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -22,6 +22,7 @@ supabase.sh
|
||||
tests/
|
||||
!server/tests
|
||||
!packages/mcp/tests
|
||||
!packages/ai-sdk/tests
|
||||
!apps/leaf/tests
|
||||
!vite/tests
|
||||
.secrets
|
||||
|
||||
78
apps/docs/api-reference-generator/balances/trackTokens.mdx
Normal file
78
apps/docs/api-reference-generator/balances/trackTokens.mdx
Normal 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>
|
||||
206
apps/docs/mintlify/api-reference/balances/trackTokens.mdx
Normal file
206
apps/docs/mintlify/api-reference/balances/trackTokens.mdx
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
145
apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx
Normal file
145
apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
21
bun.lock
21
bun.lock
@@ -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=="],
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"duplicates"
|
||||
],
|
||||
"ignoreWorkspaces": [
|
||||
"packages/ai-sdk",
|
||||
"packages/atmn",
|
||||
"packages/autumn-js",
|
||||
"packages/mcp",
|
||||
|
||||
@@ -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
253
packages/ai-sdk/bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
39
packages/ai-sdk/package.json
Normal file
39
packages/ai-sdk/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
107
packages/ai-sdk/src/index.ts
Normal file
107
packages/ai-sdk/src/index.ts
Normal 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 });
|
||||
};
|
||||
117
packages/ai-sdk/src/usage.ts
Normal file
117
packages/ai-sdk/src/usage.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
144
packages/ai-sdk/tests/unit/index.test.ts
Normal file
144
packages/ai-sdk/tests/unit/index.test.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
93
packages/ai-sdk/tests/unit/usage.test.ts
Normal file
93
packages/ai-sdk/tests/unit/usage.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
19
packages/ai-sdk/tsconfig.json
Normal file
19
packages/ai-sdk/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/ai-sdk/tsup.config.ts
Normal file
12
packages/ai-sdk/tsup.config.ts
Normal 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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
2
packages/openapi/v2.3/contracts/index.ts
vendored
2
packages/openapi/v2.3/contracts/index.ts
vendored
@@ -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
|
||||
|
||||
29
packages/openapi/v2.3/jsDocs/balancesJsDocs.ts
vendored
29
packages/openapi/v2.3/jsDocs/balancesJsDocs.ts
vendored
@@ -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.",
|
||||
});
|
||||
|
||||
2
packages/openapi/v2.3/openapi2.3.ts
vendored
2
packages/openapi/v2.3/openapi2.3.ts
vendored
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
32
server/src/internal/balances/handlers/handleTrackTokens.ts
Normal file
32
server/src/internal/balances/handlers/handleTrackTokens.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
187
server/src/internal/balances/track/utils/getTokenTrackParams.ts
Normal file
187
server/src/internal/balances/track/utils/getTokenTrackParams.ts
Normal 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 };
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
265
server/src/internal/features/aiCreditSystemUtils.ts
Normal file
265
server/src/internal/features/aiCreditSystemUtils.ts
Normal 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;
|
||||
@@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -58,6 +58,7 @@ export const handleUpdateFeatureV1 = createRoute({
|
||||
archived: body.archived,
|
||||
event_names: body.event_names,
|
||||
display: body.display,
|
||||
model_markups: body.model_markups,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const handleUpdateFeatureV2 = createRoute({
|
||||
archived: body.archived,
|
||||
event_names: body.event_names,
|
||||
display: body.display,
|
||||
model_markups: body.model_markups,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
43
server/src/internal/features/utils/getModelPricing.ts
Normal file
43
server/src/internal/features/utils/getModelPricing.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
};
|
||||
115
server/tests/advanced/creditSystems/ai-markup-resolution.test.ts
Normal file
115
server/tests/advanced/creditSystems/ai-markup-resolution.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
276
server/tests/advanced/creditSystems/ai-model-resolution.test.ts
Normal file
276
server/tests/advanced/creditSystems/ai-model-resolution.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import type { ApiCustomerV5, TrackResponseV3 } from "@autumn/shared";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0%
|
||||
// in=5000/out=2500 -> 0.0625; in=10000/out=5000 -> 0.125
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-LIM-1: default behavior caps deduction at zero balance
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-lim-1: default behavior caps token deduction at zero balance")}`,
|
||||
async () => {
|
||||
const aiCreditsItem = items.free({
|
||||
featureId: TestFeature.AiCredits,
|
||||
includedUsage: 0.1,
|
||||
});
|
||||
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
|
||||
|
||||
const { customerId, autumnV2_2 } = await initScenario({
|
||||
customerId: "track-tokens-lim-1",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeProd] }),
|
||||
],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
// First track: cost 0.0625 fits within the 0.1 balance
|
||||
const trackRes1: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 5000,
|
||||
output_tokens: 2500,
|
||||
});
|
||||
expect(trackRes1.value).toBeCloseTo(0.0625, 10);
|
||||
|
||||
// Second track: cost 0.125 exceeds the remaining 0.0375 — capped at zero
|
||||
await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 10000,
|
||||
output_tokens: 5000,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 0,
|
||||
usage: 0.1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-LIM-2: overage_behavior "reject" errors, balance intact
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-lim-2: overage_behavior reject errors with InsufficientBalance")}`,
|
||||
async () => {
|
||||
const aiCreditsItem = items.free({
|
||||
featureId: TestFeature.AiCredits,
|
||||
includedUsage: 0.1,
|
||||
});
|
||||
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
|
||||
|
||||
const { customerId, autumnV2_2 } = await initScenario({
|
||||
customerId: "track-tokens-lim-2",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeProd] }),
|
||||
],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InsufficientBalance,
|
||||
func: () =>
|
||||
autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 10000,
|
||||
output_tokens: 5000,
|
||||
overage_behavior: "reject",
|
||||
}),
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 0.1,
|
||||
usage: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-LIM-3: explicit overage_behavior "cap" deducts up to zero
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-lim-3: explicit overage_behavior cap deducts up to zero")}`,
|
||||
async () => {
|
||||
const aiCreditsItem = items.free({
|
||||
featureId: TestFeature.AiCredits,
|
||||
includedUsage: 0.1,
|
||||
});
|
||||
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
|
||||
|
||||
const { customerId, autumnV2_2 } = await initScenario({
|
||||
customerId: "track-tokens-lim-3",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeProd] }),
|
||||
],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 10000,
|
||||
output_tokens: 5000,
|
||||
overage_behavior: "cap",
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 0,
|
||||
usage: 0.1,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-LIM-4: unlimited balance never rejects or deducts
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-lim-4: unlimited AI credit balance never rejects or deducts")}`,
|
||||
async () => {
|
||||
const aiCreditsItem = items.unlimited({ featureId: TestFeature.AiCredits });
|
||||
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
|
||||
|
||||
const { customerId, autumnV2_2 } = await initScenario({
|
||||
customerId: "track-tokens-lim-4",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeProd] }),
|
||||
],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 10000,
|
||||
output_tokens: 5000,
|
||||
});
|
||||
|
||||
expect(trackRes.value).toBeCloseTo(0.125, 10);
|
||||
expect(trackRes.balance).toMatchObject({
|
||||
feature_id: TestFeature.AiCredits,
|
||||
unlimited: true,
|
||||
usage: 0,
|
||||
});
|
||||
|
||||
// Second track: still no deduction, never rejected
|
||||
await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 20000,
|
||||
output_tokens: 10000,
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expect(customer.balances[TestFeature.AiCredits]).toMatchObject({
|
||||
unlimited: true,
|
||||
usage: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-LIM-5: duplicate idempotency_key rejected, deducts once
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-lim-5: duplicate idempotency_key rejected, deducts once")}`,
|
||||
async () => {
|
||||
const aiCreditsItem = items.free({
|
||||
featureId: TestFeature.AiCredits,
|
||||
includedUsage: 1000,
|
||||
});
|
||||
const freeProd = products.base({ id: "free", items: [aiCreditsItem] });
|
||||
|
||||
const { customerId, autumnV2_2 } = await initScenario({
|
||||
customerId: "track-tokens-lim-5",
|
||||
setup: [
|
||||
s.customer({ testClock: false }),
|
||||
s.products({ list: [freeProd] }),
|
||||
],
|
||||
actions: [s.attach({ productId: freeProd.id })],
|
||||
});
|
||||
|
||||
const body = {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 10000,
|
||||
output_tokens: 5000,
|
||||
idempotency_key: `track-tokens-idem-${Date.now().toString(36)}`,
|
||||
};
|
||||
|
||||
const trackRes: TrackResponseV3 = await autumnV2_2.post(
|
||||
"/track_tokens",
|
||||
body,
|
||||
);
|
||||
expect(trackRes.value).toBeCloseTo(0.125, 10);
|
||||
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.DuplicateIdempotencyKey,
|
||||
func: () => autumnV2_2.post("/track_tokens", body),
|
||||
});
|
||||
|
||||
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 999.875,
|
||||
usage: 0.125,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import type {
|
||||
ApiCustomerV3,
|
||||
ApiCustomerV5,
|
||||
TrackResponseV3,
|
||||
} from "@autumn/shared";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
|
||||
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect.js";
|
||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
|
||||
import { items } from "@tests/utils/fixtures/items.js";
|
||||
import { products } from "@tests/utils/fixtures/products.js";
|
||||
import { timeout } from "@tests/utils/genUtils.js";
|
||||
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
|
||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
|
||||
import chalk from "chalk";
|
||||
|
||||
// custom/internal-model: input_cost=5 $/M, output_cost=15 $/M, markup=0%
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-PAID-1: prepaid AI credits deduct through purchased balance
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-paid-1: prepaid AI credits deduct through purchased balance")}`,
|
||||
async () => {
|
||||
const prepaidItem = items.prepaid({
|
||||
featureId: TestFeature.AiCredits,
|
||||
price: 1,
|
||||
billingUnits: 1,
|
||||
includedUsage: 2,
|
||||
});
|
||||
const prepaidProduct = products.pro({
|
||||
id: "prepaid-ai",
|
||||
items: [prepaidItem],
|
||||
});
|
||||
|
||||
const { customerId, autumnV2_2 } = await initScenario({
|
||||
customerId: "track-tokens-paid-1",
|
||||
setup: [
|
||||
s.customer({ testClock: false, paymentMethod: "success" }),
|
||||
s.products({ list: [prepaidProduct] }),
|
||||
],
|
||||
actions: [
|
||||
s.attach({
|
||||
productId: prepaidProduct.id,
|
||||
options: [{ feature_id: TestFeature.AiCredits, quantity: 3 }],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
// 2 included + 3 purchased = 5
|
||||
const customerBefore =
|
||||
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: customerBefore,
|
||||
featureId: TestFeature.AiCredits,
|
||||
granted: 5,
|
||||
remaining: 5,
|
||||
});
|
||||
|
||||
// (5*100000 + 15*100000) / 1e6 = $2.00
|
||||
const trackRes1: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 100000,
|
||||
output_tokens: 100000,
|
||||
});
|
||||
expect(trackRes1.value).toBeCloseTo(2, 10);
|
||||
|
||||
// Cost $4 > remaining 3 with reject — errors, balance unchanged
|
||||
await expectAutumnError({
|
||||
errCode: ErrCode.InsufficientBalance,
|
||||
func: () =>
|
||||
autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 200000,
|
||||
output_tokens: 200000,
|
||||
overage_behavior: "reject",
|
||||
}),
|
||||
});
|
||||
|
||||
const customerMid =
|
||||
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: customerMid,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 3,
|
||||
usage: 2,
|
||||
});
|
||||
|
||||
// Cost $3.00 drains the remaining balance exactly
|
||||
const trackRes2: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 150000,
|
||||
output_tokens: 150000,
|
||||
});
|
||||
expect(trackRes2.value).toBeCloseTo(3, 10);
|
||||
|
||||
// Cached vs DB agreement (mutation-log sync is async)
|
||||
await timeout(6000);
|
||||
const customerNonCached = await autumnV2_2.customers.get<ApiCustomerV5>(
|
||||
customerId,
|
||||
{ skip_cache: "true" },
|
||||
);
|
||||
expectBalanceCorrect({
|
||||
customer: customerNonCached,
|
||||
featureId: TestFeature.AiCredits,
|
||||
granted: 5,
|
||||
remaining: 0,
|
||||
usage: 5,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// TRACK-TOKENS-PAID-2: consumable AI credit overage lands on the invoice
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
test.concurrent(
|
||||
`${chalk.yellowBright("track-tokens-paid-2: consumable AI credit overage lands on the renewal invoice")}`,
|
||||
async () => {
|
||||
const consumableItem = items.consumable({
|
||||
featureId: TestFeature.AiCredits,
|
||||
includedUsage: 1,
|
||||
price: 1,
|
||||
billingUnits: 1,
|
||||
});
|
||||
const proProduct = products.pro({
|
||||
id: "consumable-ai",
|
||||
items: [consumableItem],
|
||||
});
|
||||
|
||||
const { customerId, autumnV1, autumnV2_2, testClockId } =
|
||||
await initScenario({
|
||||
customerId: "track-tokens-paid-2",
|
||||
setup: [
|
||||
s.customer({ paymentMethod: "success" }),
|
||||
s.products({ list: [proProduct] }),
|
||||
],
|
||||
actions: [s.attach({ productId: proProduct.id })],
|
||||
});
|
||||
|
||||
// (5*200000 + 15*200000) / 1e6 = $4.00 exactly
|
||||
const trackRes: TrackResponseV3 = await autumnV2_2.post("/track_tokens", {
|
||||
customer_id: customerId,
|
||||
feature_id: TestFeature.AiCredits,
|
||||
model_id: "custom/internal-model",
|
||||
input_tokens: 200000,
|
||||
output_tokens: 200000,
|
||||
});
|
||||
expect(trackRes.value).toBeCloseTo(4, 10);
|
||||
|
||||
const customerMid =
|
||||
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: customerMid,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 0,
|
||||
usage: 4,
|
||||
});
|
||||
|
||||
await timeout(2000);
|
||||
await advanceToNextInvoice({
|
||||
stripeCli: ctx.stripeCli,
|
||||
testClockId: testClockId!,
|
||||
withPause: true,
|
||||
});
|
||||
|
||||
// Renewal invoice: $20 pro base + 3 overage units × $1 = $23.
|
||||
// Invoice lands via Stripe webhook — poll briefly before asserting.
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
|
||||
if ((customer.invoices?.length ?? 0) >= 2) break;
|
||||
await timeout(10000);
|
||||
}
|
||||
await expectCustomerInvoiceCorrect({
|
||||
customerId,
|
||||
count: 2,
|
||||
latestTotal: 23,
|
||||
latestInvoiceProductId: proProduct.id,
|
||||
});
|
||||
|
||||
// Balance resets for the new cycle
|
||||
const customerReset =
|
||||
await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
|
||||
expectBalanceCorrect({
|
||||
customer: customerReset,
|
||||
featureId: TestFeature.AiCredits,
|
||||
remaining: 1,
|
||||
usage: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
84
server/tests/unit/balances/compute-credit-costs.test.ts
Normal file
84
server/tests/unit/balances/compute-credit-costs.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
server/tests/unit/balances/track/handle-track-tokens.test.ts
Normal file
133
server/tests/unit/balances/track/handle-track-tokens.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
179
server/tests/unit/features/get-credit-cost.test.ts
Normal file
179
server/tests/unit/features/get-credit-cost.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
132
server/tests/unit/features/get-model-pricing.test.ts
Normal file
132
server/tests/unit/features/get-model-pricing.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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,
|
||||
|
||||
63
shared/api/balances/track/trackTokensParams.ts
Normal file
63
shared/api/balances/track/trackTokensParams.ts
Normal 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>;
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum ApiFeatureType {
|
||||
SingleUsage = "single_use",
|
||||
ContinuousUse = "continuous_use",
|
||||
CreditSystem = "credit_system",
|
||||
AiCreditSystem = "ai_credit_system",
|
||||
}
|
||||
|
||||
export const FEATURE_EXAMPLE = {
|
||||
|
||||
@@ -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";
|
||||
|
||||
1
shared/drizzle/0002_shocking_wong.sql
Normal file
1
shared/drizzle/0002_shocking_wong.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null;
|
||||
@@ -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";
|
||||
|
||||
73
shared/models/aiModels/modelsDevTypes.ts
Normal file
73
shared/models/aiModels/modelsDevTypes.ts
Normal 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>;
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -2,6 +2,7 @@ export enum FeatureType {
|
||||
Boolean = "boolean",
|
||||
Metered = "metered",
|
||||
CreditSystem = "credit_system",
|
||||
AiCreditSystem = "ai_credit_system",
|
||||
}
|
||||
|
||||
export enum AggregateType {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
: [],
|
||||
|
||||
16
shared/utils/featureUtils/buildAiCreditSystemConfig.ts
Normal file
16
shared/utils/featureUtils/buildAiCreditSystemConfig.ts
Normal 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,
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FeatureType } from "@models/featureModels/featureEnums";
|
||||
|
||||
export const isAiCreditSystem = (
|
||||
type: FeatureType | undefined | null,
|
||||
): boolean => type === FeatureType.AiCreditSystem;
|
||||
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
shared/utils/featureUtils/resolveInheritedMarkup.ts
Normal file
5
shared/utils/featureUtils/resolveInheritedMarkup.ts
Normal 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;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user