diff --git a/.gitignore b/.gitignore index 58a0b8b47..9e04e6a10 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ supabase.sh tests/ !server/tests !packages/mcp/tests +!packages/ai-sdk/tests !apps/leaf/tests !vite/tests .secrets diff --git a/apps/docs/api-reference-generator/balances/trackTokens.mdx b/apps/docs/api-reference-generator/balances/trackTokens.mdx new file mode 100644 index 000000000..fa947801c --- /dev/null +++ b/apps/docs/api-reference-generator/balances/trackTokens.mdx @@ -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"; + + + 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. + + +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 + + + +```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 +}); +``` + + + +### 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. + + + 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. + + +### 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. + + + `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. + diff --git a/apps/docs/mintlify/api-reference/balances/trackTokens.mdx b/apps/docs/mintlify/api-reference/balances/trackTokens.mdx new file mode 100644 index 000000000..41894bbdc --- /dev/null +++ b/apps/docs/mintlify/api-reference/balances/trackTokens.mdx @@ -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"; + + + 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. + + +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 + + + +```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 +}); +``` + + + +### 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. + + + 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. + + +### 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. + + + `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. + + +### Body Parameters + + + The ID of the customer. + + + + 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`). + + + + Number of non-cached text input tokens consumed. Exclusive of the cache and audio token pools. + + + + Number of text output tokens consumed. Exclusive of the reasoning and audio output pools. + + + + Number of cached input tokens read, billed at the model's cache read rate. + + + + Number of input tokens written to the cache, billed at the model's cache write rate. + + + + Number of reasoning tokens generated, billed at the model's reasoning rate (falls back to the output rate). + + + + Number of audio input tokens consumed, billed at the model's audio input rate (falls back to the input rate). + + + + Number of audio output tokens generated, billed at the model's audio output rate (falls back to the output rate). + + + + 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. + + + + The ID of the entity for entity-scoped balances. + + + + 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. + + +### Response + + + The ID of the customer whose token usage was tracked. + + + + The dollar cost that was deducted from the customer's AI credit balance. + + + + The updated balance for the AI credit system feature. + + + The feature ID this balance is for. + + + + Total balance granted (included + prepaid). + + + + Remaining balance available for use. + + + + Total usage consumed in the current period. + + + + Whether this feature has unlimited usage. + + + + Whether usage beyond the granted balance is allowed. + + + + Timestamp when the balance will reset, or null for no reset. + + + + + + + +```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 + } + ] + } +} +``` + diff --git a/apps/docs/mintlify/docs.json b/apps/docs/mintlify/docs.json index 20de088ea..c3ad3a506 100644 --- a/apps/docs/mintlify/docs.json +++ b/apps/docs/mintlify/docs.json @@ -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", diff --git a/apps/docs/mintlify/documentation/customers/tracking-usage.mdx b/apps/docs/mintlify/documentation/customers/tracking-usage.mdx index e0809faac..4c5bff968 100644 --- a/apps/docs/mintlify/documentation/customers/tracking-usage.mdx +++ b/apps/docs/mintlify/documentation/customers/tracking-usage.mdx @@ -131,6 +131,85 @@ curl -X POST "https://api.useautumn.com/v1/balances/update" \ can reset or override incremental usage recorded through events. +## 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. + + + `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. + + + + +```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 + }' +``` + + + + + 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. + + +### 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. + + + ## 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: diff --git a/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx new file mode 100644 index 000000000..bf526f96e --- /dev/null +++ b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx @@ -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 + + +```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 +``` + + + + Requires `autumn-js` and `ai` (v6+) as peer dependencies. + + +#### 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` | 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; +} +``` + + + 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. + diff --git a/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx b/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx index e165aede4..661ccb98e 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx @@ -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. + + + + +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`. + + + + +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" + + + + +### 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: + + + +```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 + }' +``` + + + +The cost is calculated automatically based on the model's pricing plus your configured markup percentage. diff --git a/bun.lock b/bun.lock index fa06d1144..e3c8202ff 100644 --- a/bun.lock +++ b/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=="], diff --git a/knip.json b/knip.json index 017170f15..914fd849e 100644 --- a/knip.json +++ b/knip.json @@ -12,6 +12,7 @@ "duplicates" ], "ignoreWorkspaces": [ + "packages/ai-sdk", "packages/atmn", "packages/autumn-js", "packages/mcp", diff --git a/package.json b/package.json index c3a96f81f..4f1ec1338 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ai-sdk/bun.lock b/packages/ai-sdk/bun.lock new file mode 100644 index 000000000..34f668488 --- /dev/null +++ b/packages/ai-sdk/bun.lock @@ -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=="], + } +} diff --git a/packages/ai-sdk/package.json b/packages/ai-sdk/package.json new file mode 100644 index 000000000..6907b1fb9 --- /dev/null +++ b/packages/ai-sdk/package.json @@ -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" + } +} diff --git a/packages/ai-sdk/src/index.ts b/packages/ai-sdk/src/index.ts new file mode 100644 index 000000000..e786c48be --- /dev/null +++ b/packages/ai-sdk/src/index.ts @@ -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; +}; + +/** Structural view of the autumn-js client; older versions may not ship balances.trackTokens. */ +export type AutumnClient = { + balances?: { + trackTokens?: (params: TrackTokensParams) => Promise; + }; +}; + +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; +}; + +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 | undefined; + + type StreamChunk = typeof stream extends ReadableStream + ? T + : never; + + const transformStream = new TransformStream({ + 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 }); +}; diff --git a/packages/ai-sdk/src/usage.ts b/packages/ai-sdk/src/usage.ts new file mode 100644 index 000000000..eb73be51a --- /dev/null +++ b/packages/ai-sdk/src/usage.ts @@ -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), + }; +}; diff --git a/packages/ai-sdk/tests/unit/index.test.ts b/packages/ai-sdk/tests/unit/index.test.ts new file mode 100644 index 000000000..fd28f5863 --- /dev/null +++ b/packages/ai-sdk/tests/unit/index.test.ts @@ -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; +}; + +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, + }, + ]); + }); +}); diff --git a/packages/ai-sdk/tests/unit/usage.test.ts b/packages/ai-sdk/tests/unit/usage.test.ts new file mode 100644 index 000000000..132e39107 --- /dev/null +++ b/packages/ai-sdk/tests/unit/usage.test.ts @@ -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/); + }); +}); diff --git a/packages/ai-sdk/tsconfig.json b/packages/ai-sdk/tsconfig.json new file mode 100644 index 000000000..c3ea081e4 --- /dev/null +++ b/packages/ai-sdk/tsconfig.json @@ -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"] +} diff --git a/packages/ai-sdk/tsup.config.ts b/packages/ai-sdk/tsup.config.ts new file mode 100644 index 000000000..5ea218dce --- /dev/null +++ b/packages/ai-sdk/tsup.config.ts @@ -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, +}); diff --git a/packages/atmn/src/commands/push/push.ts b/packages/atmn/src/commands/push/push.ts index fe3480669..a3384ce97 100644 --- a/packages/atmn/src/commands/push/push.ts +++ b/packages/atmn/src/commands/push/push.ts @@ -349,6 +349,32 @@ function normalizeFeatureForCompare(f: Feature): Record { })); } + if (f.type === "ai_credit_system") { + const ai = f as Extract; + 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; } diff --git a/packages/atmn/src/compose/models/featureModels.ts b/packages/atmn/src/compose/models/featureModels.ts index 5a2e63bdb..826059b0d 100644 --- a/packages/atmn/src/compose/models/featureModels.ts +++ b/packages/atmn/src/compose/models/featureModels.ts @@ -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; + /** 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; +}; + +export type Feature = + | BooleanFeature + | MeteredFeature + | CreditSystemFeature + | AiCreditSystemFeature; diff --git a/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts b/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts index 0d77ee067..d8488addc 100644 --- a/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts +++ b/packages/atmn/src/lib/transforms/apiToSdk/Transformer.test.ts @@ -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); + } }); }); diff --git a/packages/atmn/src/lib/transforms/apiToSdk/feature.ts b/packages/atmn/src/lib/transforms/apiToSdk/feature.ts index 9d5519e30..793025736 100644 --- a/packages/atmn/src/lib/transforms/apiToSdk/feature.ts +++ b/packages/atmn/src/lib/transforms/apiToSdk/feature.ts @@ -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 & { 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 | 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({ +export const featureTransformer = createTransformer({ discriminator: "type", cases: { // Boolean features: just copy base fields, no consumable @@ -32,14 +44,24 @@ export const featureTransformer = createTransformer({ }, }, - // 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({ }, }); -export function transformApiFeature(apiFeature: any): Feature { +export function transformApiFeature(apiFeature: RawApiFeature): Feature { return featureTransformer.transform(apiFeature); } diff --git a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts index aa3b95f86..d963639e3 100644 --- a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts @@ -11,6 +11,13 @@ export interface ApiFeatureParams { metered_feature_id: string; credit_cost: number; }>; + model_markups?: Record; + default_markup?: number; + provider_markups?: Record; } 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; } diff --git a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts index 3e61d4e1a..eddbd30b1 100644 --- a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts @@ -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"); diff --git a/packages/openapi/tsconfig.json b/packages/openapi/tsconfig.json index 6d08724fc..fcbd4453d 100644 --- a/packages/openapi/tsconfig.json +++ b/packages/openapi/tsconfig.json @@ -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"] } diff --git a/packages/openapi/v2.3/contracts/balancesContract.ts b/packages/openapi/v2.3/contracts/balancesContract.ts index 1248ab7de..a18a81560 100644 --- a/packages/openapi/v2.3/contracts/balancesContract.ts +++ b/packages/openapi/v2.3/contracts/balancesContract.ts @@ -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", diff --git a/packages/openapi/v2.3/contracts/index.ts b/packages/openapi/v2.3/contracts/index.ts index a4f3d70d2..78748b962 100644 --- a/packages/openapi/v2.3/contracts/index.ts +++ b/packages/openapi/v2.3/contracts/index.ts @@ -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 diff --git a/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts b/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts index 4cbe6bc9c..12ec2c81a 100644 --- a/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts +++ b/packages/openapi/v2.3/jsDocs/balancesJsDocs.ts @@ -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.", +}); diff --git a/packages/openapi/v2.3/openapi2.3.ts b/packages/openapi/v2.3/openapi2.3.ts index 68014d488..f591ca6cc 100644 --- a/packages/openapi/v2.3/openapi2.3.ts +++ b/packages/openapi/v2.3/openapi2.3.ts @@ -23,6 +23,7 @@ import { SetupPaymentResponseV1Schema, TrackParamsSchema, TrackResponseV3Schema, + TrackTokensParamsSchema, UpdateBalanceParamsV0Schema, UpdateSubscriptionV1ParamsSchema, } from "@autumn/shared"; @@ -64,6 +65,7 @@ async function generateOpenApiDocument(): Promise> { registerInternalSchemas(UpdateBalanceParamsV0Schema); registerInternalSchemas(CheckParamsSchema); registerInternalSchemas(TrackParamsSchema); + registerInternalSchemas(TrackTokensParamsSchema); registerInternalSchemas(BillingResponseSchema); registerInternalSchemas(AttachPreviewResponseSchema); registerInternalSchemas(PreviewUpdateSubscriptionResponseSchema); diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua index 7ee5f7f47..60b8acdc8 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/deductFromRolloversV2.lua @@ -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 diff --git a/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua b/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua index e092ee3a3..1c590a479 100644 --- a/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua +++ b/server/src/_luaScriptsV2/fullSubjectDeduction/runDeductionOnContextV2.lua @@ -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, diff --git a/server/src/init.ts b/server/src/init.ts index 6d9ab9d43..254807594 100644 --- a/server/src/init.ts +++ b/server/src/init.ts @@ -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(); diff --git a/server/src/internal/balances/balancesRouter.ts b/server/src/internal/balances/balancesRouter.ts index babe788a7..fce9219fc 100644 --- a/server/src/internal/balances/balancesRouter.ts +++ b/server/src/internal/balances/balancesRouter.ts @@ -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); diff --git a/server/src/internal/balances/handlers/handleTrackTokens.ts b/server/src/internal/balances/handlers/handleTrackTokens.ts new file mode 100644 index 000000000..43207020f --- /dev/null +++ b/server/src/internal/balances/handlers/handleTrackTokens.ts @@ -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); + }, +}); diff --git a/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts b/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts new file mode 100644 index 000000000..673225f5d --- /dev/null +++ b/server/src/internal/balances/track/utils/buildAiCreditCostProperty.ts @@ -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 | undefined => { + const aiDeduction = featureDeductions.find((d) => d.tokens); + if (!aiDeduction) return; + + const creditCost: Record = {}; + 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; +}; diff --git a/server/src/internal/balances/track/utils/getTokenTrackParams.ts b/server/src/internal/balances/track/utils/getTokenTrackParams.ts new file mode 100644 index 000000000..e75950bbd --- /dev/null +++ b/server/src/internal/balances/track/utils/getTokenTrackParams.ts @@ -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 => { + 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 }; +}; diff --git a/server/src/internal/balances/track/utils/runRedisTrack.ts b/server/src/internal/balances/track/utils/runRedisTrack.ts index babd09206..9f5763c81 100644 --- a/server/src/internal/balances/track/utils/runRedisTrack.ts +++ b/server/src/internal/balances/track/utils/runRedisTrack.ts @@ -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; + fullCustomer: FullCustomer; +}): Array<{ featureId: string; amount: number }> => { + const cusEntIdToFeatureId = new Map(); + 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, diff --git a/server/src/internal/balances/track/v3/runRedisTrackV3.ts b/server/src/internal/balances/track/v3/runRedisTrackV3.ts index 494a393b6..fa7d99ef8 100644 --- a/server/src/internal/balances/track/v3/runRedisTrackV3.ts +++ b/server/src/internal/balances/track/v3/runRedisTrackV3.ts @@ -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({ diff --git a/server/src/internal/balances/utils/deduction/computeCreditCosts.ts b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts new file mode 100644 index 000000000..998f12a97 --- /dev/null +++ b/server/src/internal/balances/utils/deduction/computeCreditCosts.ts @@ -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(); + + 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; +}; diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index f89bb8c3a..bdb599fbf 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -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, diff --git a/server/src/internal/balances/utils/deductionV2/buildUnlimitedPlanMutationLog.ts b/server/src/internal/balances/utils/deductionV2/buildUnlimitedPlanMutationLog.ts new file mode 100644 index 000000000..36c3cb53b --- /dev/null +++ b/server/src/internal/balances/utils/deductionV2/buildUnlimitedPlanMutationLog.ts @@ -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, + }; +}; diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 9f025e7fb..99ebecc17 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -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; } diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index a9501a6e4..8d1d2f11e 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -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; } diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index 4b550de10..e7c2502ac 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -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; diff --git a/server/src/internal/balances/utils/types/featureDeduction.ts b/server/src/internal/balances/utils/types/featureDeduction.ts index b440158d1..8448112a5 100644 --- a/server/src/internal/balances/utils/types/featureDeduction.ts +++ b/server/src/internal/balances/utils/types/featureDeduction.ts @@ -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; diff --git a/server/src/internal/features/aiCreditSystemUtils.ts b/server/src/internal/features/aiCreditSystemUtils.ts new file mode 100644 index 000000000..b0eb44de2 --- /dev/null +++ b/server/src/internal/features/aiCreditSystemUtils.ts @@ -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; + +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 `/` 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 => { + 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 => (await getModelCreditCostBreakdown(args)).cost; diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index 12a0363ad..e9ddee717 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -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 }, + }); }; diff --git a/server/src/internal/features/featureActions/createFeature.ts b/server/src/internal/features/featureActions/createFeature.ts index d2622976f..6fd71732e 100644 --- a/server/src/internal/features/featureActions/createFeature.ts +++ b/server/src/internal/features/featureActions/createFeature.ts @@ -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 => { - 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({ diff --git a/server/src/internal/features/featureActions/updateFeature.ts b/server/src/internal/features/featureActions/updateFeature.ts index 1321b5140..f40846c7a 100644 --- a/server/src/internal/features/featureActions/updateFeature.ts +++ b/server/src/internal/features/featureActions/updateFeature.ts @@ -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; } +/** Generic keyed-record equality check with a caller-supplied per-entry comparison. */ +const areMarkupRecordsEqual = ( + a: Record | null | undefined, + b: Record | 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[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[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: { diff --git a/server/src/internal/features/featureRouter.ts b/server/src/internal/features/featureRouter.ts index 75113786f..75d11b38a 100644 --- a/server/src/internal/features/featureRouter.ts +++ b/server/src/internal/features/featureRouter.ts @@ -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(); 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); diff --git a/server/src/internal/features/featureUtils.ts b/server/src/internal/features/featureUtils.ts index 02015399d..35e66c579 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -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; diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts index 17fceaaea..3a0bb0631 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts @@ -58,6 +58,7 @@ export const handleUpdateFeatureV1 = createRoute({ archived: body.archived, event_names: body.event_names, display: body.display, + model_markups: body.model_markups, }, }); diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts index 80eae8d37..dba75e714 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV2.ts @@ -55,6 +55,7 @@ export const handleUpdateFeatureV2 = createRoute({ archived: body.archived, event_names: body.event_names, display: body.display, + model_markups: body.model_markups, }, }); diff --git a/server/src/internal/features/internalHandlers/handleGetModelPricing.ts b/server/src/internal/features/internalHandlers/handleGetModelPricing.ts new file mode 100644 index 000000000..91f9ba866 --- /dev/null +++ b/server/src/internal/features/internalHandlers/handleGetModelPricing.ts @@ -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); + }, +}); diff --git a/server/src/internal/features/utils/constructFeatureUtils.ts b/server/src/internal/features/utils/constructFeatureUtils.ts index 7ffab0b81..ca5562895 100644 --- a/server/src/internal/features/utils/constructFeatureUtils.ts +++ b/server/src/internal/features/utils/constructFeatureUtils.ts @@ -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; diff --git a/server/src/internal/features/utils/getModelPricing.ts b/server/src/internal/features/utils/getModelPricing.ts new file mode 100644 index 000000000..0b4cdf0a9 --- /dev/null +++ b/server/src/internal/features/utils/getModelPricing.ts @@ -0,0 +1,43 @@ +import { CacheManager } from "@/utils/cacheUtils/CacheManager.js"; +import { ErrCode, InternalError, type ModelsDevProvider } from "@autumn/shared"; + +type ModelPricingData = Record; + +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 => { + 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 => { + const cached = await CacheManager.getJson(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(STALE_KEY); + if (stale) return stale; + throw new InternalError({ + message: "Failed to fetch models.dev pricing and no cache available", + code: ErrCode.InternalError, + }); + } +}; diff --git a/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts b/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts new file mode 100644 index 000000000..39a9b710c --- /dev/null +++ b/server/tests/advanced/creditSystems/ai-markup-resolution.test.ts @@ -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); + }); +}); diff --git a/server/tests/advanced/creditSystems/ai-model-resolution.test.ts b/server/tests/advanced/creditSystems/ai-model-resolution.test.ts new file mode 100644 index 000000000..bb6830d2d --- /dev/null +++ b/server/tests/advanced/creditSystems/ai-model-resolution.test.ts @@ -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 = { + 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); + }); +}); diff --git a/server/tests/advanced/creditSystems/validate-credit-system.test.ts b/server/tests/advanced/creditSystems/validate-credit-system.test.ts new file mode 100644 index 000000000..be4a4e521 --- /dev/null +++ b/server/tests/advanced/creditSystems/validate-credit-system.test.ts @@ -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(); + }); +}); diff --git a/server/tests/integration/balances/track/basic/track-deductions.test.ts b/server/tests/integration/balances/track/basic/track-deductions.test.ts index cdf3bd0b0..55eb828be 100644 --- a/server/tests/integration/balances/track/basic/track-deductions.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -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(customerId); @@ -306,7 +308,7 @@ test.concurrent( const overflowAmount = 50; const expectedCreditCost = getCreditCost({ featureId: TestFeature.Action1, - creditSystem: creditFeature!, + creditSystem: creditFeature, amount: overflowAmount, }); diff --git a/server/tests/integration/balances/track/basic/track-tokens-limits.test.ts b/server/tests/integration/balances/track/basic/track-tokens-limits.test.ts new file mode 100644 index 000000000..35afcd6dd --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-limits.test.ts @@ -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(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(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(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(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(customerId); + expectBalanceCorrect({ + customer, + featureId: TestFeature.AiCredits, + remaining: 999.875, + usage: 0.125, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts b/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts new file mode 100644 index 000000000..77f2a4576 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-orbs.test.ts @@ -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(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(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, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-paid.test.ts b/server/tests/integration/balances/track/basic/track-tokens-paid.test.ts new file mode 100644 index 000000000..ec1240354 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-paid.test.ts @@ -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(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(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( + 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(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(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(customerId); + expectBalanceCorrect({ + customer: customerReset, + featureId: TestFeature.AiCredits, + remaining: 1, + usage: 0, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-replay.test.ts b/server/tests/integration/balances/track/basic/track-tokens-replay.test.ts new file mode 100644 index 000000000..23e501696 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-replay.test.ts @@ -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(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(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, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-resolution.test.ts b/server/tests/integration/balances/track/basic/track-tokens-resolution.test.ts new file mode 100644 index 000000000..691cf5944 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-resolution.test.ts @@ -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( + customerId, + entities[0].id, + ); + expectBalanceCorrect({ + customer: entity0, + featureId: TestFeature.AiCredits, + remaining: 99.875, + usage: 0.125, + }); + + const entity1 = await autumnV2_2.entities.get( + 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(customerId); + expectBalanceCorrect({ + customer, + featureId, + remaining: 999.4, + usage: 0.6, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens-tiered.test.ts b/server/tests/integration/balances/track/basic/track-tokens-tiered.test.ts new file mode 100644 index 000000000..8dec5f359 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens-tiered.test.ts @@ -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(customerId); + expect(customer.features[TestFeature.AiCreditsTiered]).toMatchObject({ + balance: new Decimal(1000).minus(totalCost).toNumber(), + usage: totalCost, + }); + }, +); diff --git a/server/tests/integration/balances/track/basic/track-tokens.test.ts b/server/tests/integration/balances/track/basic/track-tokens.test.ts new file mode 100644 index 000000000..fb891fac8 --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens.test.ts @@ -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(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(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(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(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(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(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(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(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); + }, +); diff --git a/server/tests/setup/v2Features.ts b/server/tests/setup/v2Features.ts index 26b8aa106..56b117301 100644 --- a/server/tests/setup/v2Features.ts +++ b/server/tests/setup/v2Features.ts @@ -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 + }, + ], + }), }); diff --git a/server/tests/unit/balances/compute-credit-costs.test.ts b/server/tests/unit/balances/compute-credit-costs.test.ts new file mode 100644 index 000000000..38df654d1 --- /dev/null +++ b/server/tests/unit/balances/compute-credit-costs.test.ts @@ -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); + }); +}); diff --git a/server/tests/unit/balances/track/handle-track-tokens.test.ts b/server/tests/unit/balances/track/handle-track-tokens.test.ts new file mode 100644 index 000000000..81f01e3e4 --- /dev/null +++ b/server/tests/unit/balances/track/handle-track-tokens.test.ts @@ -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[], + runTrackWithRolloutCalls: [] as Record[], + 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) => { + 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(); + 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); + }); +}); diff --git a/server/tests/unit/features/get-credit-cost.test.ts b/server/tests/unit/features/get-credit-cost.test.ts new file mode 100644 index 000000000..5f64b31fa --- /dev/null +++ b/server/tests/unit/features/get-credit-cost.test.ts @@ -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"); + }); +}); diff --git a/server/tests/unit/features/get-model-pricing.test.ts b/server/tests/unit/features/get-model-pricing.test.ts new file mode 100644 index 000000000..9b06aedeb --- /dev/null +++ b/server/tests/unit/features/get-model-pricing.test.ts @@ -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(); +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) => { + 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); +}); diff --git a/server/tests/utils/fixtures/db/entitlements.ts b/server/tests/utils/fixtures/db/entitlements.ts index f8c05e8e2..7bc7dcf0c 100644 --- a/server/tests/utils/fixtures/db/entitlements.ts +++ b/server/tests/utils/fixtures/db/entitlements.ts @@ -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, }), }); diff --git a/server/tests/utils/fixtures/db/features.ts b/server/tests/utils/fixtures/db/features.ts index 807712d42..05dcc2df6 100644 --- a/server/tests/utils/fixtures/db/features.ts +++ b/server/tests/utils/fixtures/db/features.ts @@ -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; + 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, }); // ═══════════════════════════════════════════════════════════════════ diff --git a/server/tests/utils/fixtures/items.ts b/server/tests/utils/fixtures/items.ts index 39e0b7ca7..131504a13 100644 --- a/server/tests/utils/fixtures/items.ts +++ b/server/tests/utils/fixtures/items.ts @@ -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, diff --git a/shared/api/balances/track/trackTokensParams.ts b/shared/api/balances/track/trackTokensParams.ts new file mode 100644 index 000000000..a8ebef2c5 --- /dev/null +++ b/shared/api/balances/track/trackTokensParams.ts @@ -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 '/' (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; diff --git a/shared/api/features/apiFeatureV1.ts b/shared/api/features/apiFeatureV1.ts index a5ebac6c1..0f63a62c4 100644 --- a/shared/api/features/apiFeatureV1.ts +++ b/shared/api/features/apiFeatureV1.ts @@ -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({ diff --git a/shared/api/features/changes/V1.2_FeatureChange.ts b/shared/api/features/changes/V1.2_FeatureChange.ts index e08a1e2d1..5bfc72148 100644 --- a/shared/api/features/changes/V1.2_FeatureChange.ts +++ b/shared/api/features/changes/V1.2_FeatureChange.ts @@ -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; }, diff --git a/shared/api/features/crud/common/baseFeatureParamsV1.ts b/shared/api/features/crud/common/baseFeatureParamsV1.ts index e3aa3fcd5..7394be049 100644 --- a/shared/api/features/crud/common/baseFeatureParamsV1.ts +++ b/shared/api/features/crud/common/baseFeatureParamsV1.ts @@ -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(), }); diff --git a/shared/api/features/prevVersions/apiFeatureV0.ts b/shared/api/features/prevVersions/apiFeatureV0.ts index 1ac487994..cf383d811 100644 --- a/shared/api/features/prevVersions/apiFeatureV0.ts +++ b/shared/api/features/prevVersions/apiFeatureV0.ts @@ -7,6 +7,7 @@ export enum ApiFeatureType { SingleUsage = "single_use", ContinuousUse = "continuous_use", CreditSystem = "credit_system", + AiCreditSystem = "ai_credit_system", } export const FEATURE_EXAMPLE = { diff --git a/shared/api/models.ts b/shared/api/models.ts index 23271c21c..289bd8d40 100644 --- a/shared/api/models.ts +++ b/shared/api/models.ts @@ -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"; diff --git a/shared/drizzle/0002_shocking_wong.sql b/shared/drizzle/0002_shocking_wong.sql new file mode 100644 index 000000000..be0692e60 --- /dev/null +++ b/shared/drizzle/0002_shocking_wong.sql @@ -0,0 +1 @@ +ALTER TABLE "features" ADD COLUMN "model_markups" jsonb DEFAULT null; \ No newline at end of file diff --git a/shared/index.ts b/shared/index.ts index 2b3e29b7e..2b7b4ffa3 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -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"; diff --git a/shared/models/aiModels/modelsDevTypes.ts b/shared/models/aiModels/modelsDevTypes.ts new file mode 100644 index 000000000..db0d72eb5 --- /dev/null +++ b/shared/models/aiModels/modelsDevTypes.ts @@ -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; +} diff --git a/shared/models/featureModels/featureConfig/creditConfig.ts b/shared/models/featureModels/featureConfig/creditConfig.ts index c75cc8265..63d524d42 100644 --- a/shared/models/featureModels/featureConfig/creditConfig.ts +++ b/shared/models/featureModels/featureConfig/creditConfig.ts @@ -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; export type CreditSchemaItem = z.infer; +export type ModelMarkups = z.infer; +export type ProviderMarkups = z.infer; diff --git a/shared/models/featureModels/featureEnums.ts b/shared/models/featureModels/featureEnums.ts index 430d87a62..c75480114 100644 --- a/shared/models/featureModels/featureEnums.ts +++ b/shared/models/featureModels/featureEnums.ts @@ -2,6 +2,7 @@ export enum FeatureType { Boolean = "boolean", Metered = "metered", CreditSystem = "credit_system", + AiCreditSystem = "ai_credit_system", } export enum AggregateType { diff --git a/shared/models/featureModels/featureModels.ts b/shared/models/featureModels/featureModels.ts index 87fd02e7b..fd9afeff7 100644 --- a/shared/models/featureModels/featureModels.ts +++ b/shared/models/featureModels/featureModels.ts @@ -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({ diff --git a/shared/models/featureModels/featureTable.ts b/shared/models/featureModels/featureTable.ts index 07cb69d00..4b9d7e81a 100644 --- a/shared/models/featureModels/featureTable.ts +++ b/shared/models/featureModels/featureTable.ts @@ -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(), archived: boolean("archived").notNull().default(false), event_names: text("event_names").array().default([]), + model_markups: jsonb().$type().default(sql`null`), }, (table) => [ foreignKey({ diff --git a/shared/utils/agentTypes.ts b/shared/utils/agentTypes.ts index b2d0abfdb..8e77a2a2d 100644 --- a/shared/utils/agentTypes.ts +++ b/shared/utils/agentTypes.ts @@ -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; } diff --git a/shared/utils/featureUtils.ts b/shared/utils/featureUtils.ts index de6d0db2e..0448e8073 100644 --- a/shared/utils/featureUtils.ts +++ b/shared/utils/featureUtils.ts @@ -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, diff --git a/shared/utils/featureUtils/apiFeatureToDbFeature.ts b/shared/utils/featureUtils/apiFeatureToDbFeature.ts index aa1c5bf1a..1df67b00a 100644 --- a/shared/utils/featureUtils/apiFeatureToDbFeature.ts +++ b/shared/utils/featureUtils/apiFeatureToDbFeature.ts @@ -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 : [], diff --git a/shared/utils/featureUtils/buildAiCreditSystemConfig.ts b/shared/utils/featureUtils/buildAiCreditSystemConfig.ts new file mode 100644 index 000000000..f461a17b3 --- /dev/null +++ b/shared/utils/featureUtils/buildAiCreditSystemConfig.ts @@ -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, +}); diff --git a/shared/utils/featureUtils/classifyFeature/isAiCreditSystem.ts b/shared/utils/featureUtils/classifyFeature/isAiCreditSystem.ts new file mode 100644 index 000000000..02f295e3d --- /dev/null +++ b/shared/utils/featureUtils/classifyFeature/isAiCreditSystem.ts @@ -0,0 +1,5 @@ +import { FeatureType } from "@models/featureModels/featureEnums"; + +export const isAiCreditSystem = ( + type: FeatureType | undefined | null, +): boolean => type === FeatureType.AiCreditSystem; diff --git a/shared/utils/featureUtils/classifyFeature/isAnyCreditSystem.ts b/shared/utils/featureUtils/classifyFeature/isAnyCreditSystem.ts new file mode 100644 index 000000000..eeeefe788 --- /dev/null +++ b/shared/utils/featureUtils/classifyFeature/isAnyCreditSystem.ts @@ -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); diff --git a/shared/utils/featureUtils/creditSystemUtils.ts b/shared/utils/featureUtils/creditSystemUtils.ts index 47c0095a8..039dbffde 100644 --- a/shared/utils/featureUtils/creditSystemUtils.ts +++ b/shared/utils/featureUtils/creditSystemUtils.ts @@ -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) { diff --git a/shared/utils/featureUtils/index.ts b/shared/utils/featureUtils/index.ts index f84b715e1..c94f96905 100644 --- a/shared/utils/featureUtils/index.ts +++ b/shared/utils/featureUtils/index.ts @@ -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, diff --git a/shared/utils/featureUtils/resolveInheritedMarkup.ts b/shared/utils/featureUtils/resolveInheritedMarkup.ts new file mode 100644 index 000000000..60bbfa416 --- /dev/null +++ b/shared/utils/featureUtils/resolveInheritedMarkup.ts @@ -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; diff --git a/shared/utils/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 1cba7d5a5..f219e76c9 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -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 }); diff --git a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts index 81a9e2d3f..a58047276 100644 --- a/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts +++ b/shared/utils/productUtils/priceUtils/convertPrice/priceToStripePrepaidV2Tiers.ts @@ -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; }; diff --git a/vite/src/components/v2/buttons/GroupedTabButton.tsx b/vite/src/components/v2/buttons/GroupedTabButton.tsx index 2c8a6bac8..611b2b418 100644 --- a/vite/src/components/v2/buttons/GroupedTabButton.tsx +++ b/vite/src/components/v2/buttons/GroupedTabButton.tsx @@ -6,7 +6,7 @@ interface GroupedTabButtonProps { onValueChange: (value: string) => void; options: Array<{ value: string; - label: string; + label: React.ReactNode; icon?: React.ReactNode; }>; className?: string; diff --git a/vite/src/hooks/queries/useAiModelsQuery.ts b/vite/src/hooks/queries/useAiModelsQuery.ts new file mode 100644 index 000000000..15de1a42c --- /dev/null +++ b/vite/src/hooks/queries/useAiModelsQuery.ts @@ -0,0 +1,24 @@ +import type { ModelsDevProvider } from "@autumn/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +export const useModelsDevPricing = () => { + const axiosInstance = useAxiosInstance(); + + const { data, isLoading, error } = useQuery({ + queryKey: ["models-dev-pricing"], + queryFn: async () => { + const { data } = await axiosInstance.get< + Record + >("/v1/features/ai/model_pricing"); + return data; + }, + staleTime: 1000 * 60 * 10, + }); + + return { + providers: data ?? {}, + isLoading, + error, + }; +}; diff --git a/vite/src/utils/product/product-item/formatProductItem.ts b/vite/src/utils/product/product-item/formatProductItem.ts index 212d9647e..0d2f992a0 100644 --- a/vite/src/utils/product/product-item/formatProductItem.ts +++ b/vite/src/utils/product/product-item/formatProductItem.ts @@ -5,6 +5,7 @@ import { formatAmount, formatInterval, Infinite, + isAiCreditSystem, type ProductItem, ProductItemType, TierBehavior, @@ -140,13 +141,13 @@ const getFeatureString = ({ item: ProductItem; features: Feature[]; }) => { - const feature = features.find((f: Feature) => f.id == item.feature_id); + const feature = features.find((f: Feature) => f.id === item.feature_id); if (feature?.type === FeatureType.Boolean) { return `${feature.name}`; } - if (item.included_usage == Infinite) { + if (item.included_usage === Infinite) { return `Unlimited ${feature?.name}`; } @@ -155,6 +156,13 @@ const getFeatureString = ({ intervalCount: item.interval_count ?? undefined, }); + if (isAiCreditSystem(feature?.type)) { + const amount = item.included_usage ?? 0; + const formattedAmount = + amount === 0 ? "$0.00" : `$${Number(amount).toFixed(2)}`; + return `${formattedAmount} of ${feature?.name}${notNullish(item.interval) ? ` ${intervalStr}` : ""}`; + } + return `${item.included_usage ?? 0} ${feature?.name}${item.entity_feature_id ? ` per ${getFeature(item.entity_feature_id, features)?.name}` : ""}${notNullish(item.interval) ? ` ${intervalStr}` : ""}`; }; @@ -171,13 +179,13 @@ export const formatProductItemText = ({ const itemType = getItemType(item); - if (itemType == ProductItemType.FeaturePrice) { + if (itemType === ProductItemType.FeaturePrice) { return getPaidFeatureString({ item, currency: org?.default_currency, features, }); - } else if (itemType == ProductItemType.Price) { + } else if (itemType === ProductItemType.Price) { return getFixedPriceString({ item, currency: org?.default_currency }); } }; diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx index b69ac65c5..05865724c 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx +++ b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageColumns.tsx @@ -73,6 +73,7 @@ export const CustomerFeatureUsageColumns = [ if (!subRowData && featureType === FeatureType.CreditSystem) { const subRows = (cusEnt as FullCusEntWithSubRows).subRows || []; let totalSpent = 0; + let canCompute = true; for (const subRow of subRows) { if (!("isSubRow" in subRow) || !subRow.isSubRow) continue; @@ -90,11 +91,15 @@ export const CustomerFeatureUsageColumns = [ const subUsed = subTotal - subRemaining; totalSpent += subUsed * creditCost; } + } else { + canCompute = false; } } - const total = allowance * quantity; - balance = total - totalSpent; + if (canCompute) { + const total = allowance * quantity; + balance = total - totalSpent; + } } return ( diff --git a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageDisplay.tsx b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageDisplay.tsx index 3525768f2..d1d879782 100644 --- a/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageDisplay.tsx +++ b/vite/src/views/customers2/components/table/customer-feature-usage/CustomerFeatureUsageDisplay.tsx @@ -51,6 +51,7 @@ export function CustomerFeatureUsageDisplay({ if (featureType === FeatureType.CreditSystem) { let totalSpent = 0; + let canCompute = true; for (const subRow of subRows) { // Only process CreditSystemSubRow items @@ -69,16 +70,20 @@ export function CustomerFeatureUsageDisplay({ }); totalSpent += used * creditCost; } + } else { + canCompute = false; } } - const total = allowance * quantity; + if (canCompute) { + const total = allowance * quantity; - return ( -
- {totalSpent}/{total} used -
- ); + return ( +
+ {totalSpent}/{total} used +
+ ); + } } const { total, used } = calculateUsageMetrics({ diff --git a/vite/src/views/products/features/components/CreateFeatureSheet.tsx b/vite/src/views/products/features/components/CreateFeatureSheet.tsx index fed83c894..469995c00 100644 --- a/vite/src/views/products/features/components/CreateFeatureSheet.tsx +++ b/vite/src/views/products/features/components/CreateFeatureSheet.tsx @@ -1,8 +1,7 @@ import { CreateFeatureSchema, - type CreditSchemaItem, - FeatureType, FeatureUsageType, + isAnyCreditSystem, } from "@autumn/shared"; import type { AxiosError } from "axios"; import { useEffect, useState } from "react"; @@ -23,6 +22,7 @@ import { NewFeatureBehaviour } from "../../plan/components/new-feature/NewFeatur import { NewFeatureDetails } from "../../plan/components/new-feature/NewFeatureDetails"; import { NewFeatureType } from "../../plan/components/new-feature/NewFeatureType"; import { validateCreditSystem } from "../credit-systems/utils/validateCreditSystem"; +import { buildFeatureMarkupParams } from "../utils/buildFeatureMutationParams"; import { getDefaultFeature } from "../utils/defaultFeature"; function CreateFeatureSheet({ @@ -52,7 +52,7 @@ function CreateFeatureSheet({ const handleCreateFeature = async () => { // Validate credit system specific fields first - if (feature.type === FeatureType.CreditSystem) { + if (isAnyCreditSystem(feature.type)) { const validationError = validateCreditSystem(feature); if (validationError) { toast.error(validationError); @@ -77,12 +77,13 @@ function CreateFeatureSheet({ id: feature.id, type: feature.type, consumable: feature.config?.usage_type === FeatureUsageType.Single, - credit_schema: feature.config?.schema?.map( - (x: CreditSchemaItem) => ({ - metered_feature_id: x.metered_feature_id, - credit_cost: x.credit_amount, - }), - ), + ...buildFeatureMarkupParams({ + type: feature.type, + modelMarkups: feature.model_markups ?? undefined, + defaultMarkup: feature.config?.default_markup, + providerMarkups: feature.config?.provider_markups, + schema: feature.config?.schema, + }), event_names: feature.event_names, }, ); @@ -119,13 +120,6 @@ function CreateFeatureSheet({ return ( - {/* {!isControlled && ( - - - - )} */} +
+ Default Markup % + { + const raw = e.target.value; + if (raw === "" || /^-?\d*\.?\d*$/.test(raw)) { + form.setFieldValue("defaultMarkup", raw === "" ? 0 : Number(raw)); + } + }} + placeholder="0" + /> +
+ + {activeProviderKeys.length > 0 && ( +
+ {activeProviderKeys.map((providerKey) => { + const provider = providers[providerKey]; + const modelFullIds = providerGroups[providerKey] ?? []; + const providerName = + provider?.name ?? + providerKey.charAt(0).toUpperCase() + providerKey.slice(1); + + return ( + + ); + })} +
+ )} + +
e.stopPropagation()} + > + + Add Provider Override + + + + + + Add specific markup overrides for certain providers/models. + + + + p.id} + getOptionLabel={(p) => p.name} + renderValue={() => ( + Select provider + )} + placeholder="Select provider" + searchable + searchPlaceholder="Search providers..." + emptyText="No providers available" + disabled={isLoading} + /> +
+ + ); +} diff --git a/vite/src/views/products/features/credit-systems/components/AiCreditSchemaTable.tsx b/vite/src/views/products/features/credit-systems/components/AiCreditSchemaTable.tsx new file mode 100644 index 000000000..6ad09edc1 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/AiCreditSchemaTable.tsx @@ -0,0 +1,337 @@ +import { + joinModelId, + type ModelsDevProvider, + splitModelId, +} from "@autumn/shared"; +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { InfoIcon, PlusIcon, X } from "lucide-react"; +import { useMemo } from "react"; +import { Table } from "@/components/general/table"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { Input } from "@/components/v2/inputs/Input"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/v2/tooltips/Tooltip"; +import { useProductTable } from "@/views/products/hooks/useProductTable"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; +import { useProviderMarkup } from "../hooks/useProviderMarkup"; +import { addCustomModelMarkup } from "../utils/modelMarkupUtils"; +import { AiModelSelectDropdown } from "./AiModelSelectDropdown"; +import { CustomModelInput } from "./CustomModelInput"; +import { EditableNumberCell } from "./EditableNumberCell"; + +interface ModelRow { + fullId: string; + modelKey: string; +} + +function MarkupCell({ + form, + fullId, + providerKey, +}: { + form: CreditSystemFormInstance; + fullId: string; + providerKey: string; +}) { + const { inheritedMarkup } = useProviderMarkup(form, providerKey); + + return ( + + ); +} + +interface AiCreditSchemaTableProps { + form: CreditSystemFormInstance; + providerKey: string; + providerName: string; + modelFullIds: string[]; + provider: ModelsDevProvider; + isLoading: boolean; + removeKeys: (keys: string[]) => void; + removeProvider: (providerKey: string) => void; + setProviderMarkup: (providerKey: string, value: number | undefined) => void; + renameKey: (oldKey: string, newKey: string) => void; +} + +function formatCost(value: number | null | undefined): string { + if (value == null) return "–"; + return value.toFixed(2); +} + +export function AiCreditSchemaTable({ + form, + providerKey, + providerName, + modelFullIds, + provider, + isLoading, + removeKeys, + removeProvider, + setProviderMarkup, + renameKey, +}: AiCreditSchemaTableProps) { + const isCustom = providerKey === "custom"; + + const { defaultMarkup, providerMarkup } = useProviderMarkup( + form, + providerKey, + ); + + const data: ModelRow[] = useMemo( + () => + modelFullIds.map((fullId) => ({ + fullId, + modelKey: splitModelId(fullId).modelKey, + })), + [modelFullIds.join(",")], + ); + + const columns: ColumnDef[] = useMemo( + () => [ + { + header: "Model", + accessorKey: "modelKey", + size: 200, + cell: ({ row }: { row: Row }) => { + const { modelKey } = row.original; + if (isCustom) { + return ( + + renameKey( + joinModelId(providerKey, modelKey), + joinModelId(providerKey, newKey), + ) + } + /> + ); + } + return ( + + renameKey( + joinModelId(providerKey, modelKey), + joinModelId(providerKey, newKey), + ) + } + provider={provider} + isLoading={isLoading} + /> + ); + }, + }, + { + header: isCustom ? "In $/M" : "Input", + id: "inputCost", + size: 80, + cell: ({ row }: { row: Row }) => { + const { fullId, modelKey } = row.original; + if (isCustom) { + return ( + + ); + } + const cost = provider.models[modelKey]?.cost?.input ?? null; + return ( + + {formatCost(cost)} + + ); + }, + }, + { + header: isCustom ? "Out $/M" : "Output", + id: "outputCost", + size: 80, + cell: ({ row }: { row: Row }) => { + const { fullId, modelKey } = row.original; + if (isCustom) { + return ( + + ); + } + const cost = provider.models[modelKey]?.cost?.output ?? null; + return ( + + {formatCost(cost)} + + ); + }, + }, + { + header: "Markup %", + id: "markup", + size: 80, + cell: ({ row }: { row: Row }) => ( + + ), + }, + { + header: "", + accessorKey: "actions", + size: 40, + enableSorting: false, + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + > + } + onClick={() => removeKeys([row.original.fullId])} + className="!text-subtle hover:!text-foreground" + /> +
+ ), + }, + ], + [isCustom, provider, isLoading, providerKey, form], + ); + + const allModelsUsed = + !isCustom && Object.keys(provider.models).length === modelFullIds.length; + + const table = useProductTable({ + data, + columns, + options: { getRowId: (row) => row.fullId }, + }); + + return ( +
+
+ + {providerName} + {!isCustom && ( + {providerName} + )} + {isCustom && ( + + + + + + Use format{" "} + + custom/modelId + {" "} + in API tracking + + + )} + +
+ {!isCustom && ( +
+ Markup % + { + const raw = e.target.value; + if (raw === "" || /^-?\d*\.?\d*$/.test(raw)) { + if (raw === "") { + setProviderMarkup(providerKey, undefined); + } else { + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + setProviderMarkup(providerKey, parsed); + } + } + } + }} + placeholder={String(defaultMarkup)} + className="w-20" + /> +
+ )} + } + onClick={() => removeProvider(providerKey)} + className="!text-subtle hover:!text-foreground" + /> +
+
+ +
+ + + + + + + + + + {!allModelsUsed && ( + + )} +
+
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/AiModelSelectDropdown.tsx b/vite/src/views/products/features/credit-systems/components/AiModelSelectDropdown.tsx new file mode 100644 index 000000000..e333cd314 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/AiModelSelectDropdown.tsx @@ -0,0 +1,53 @@ +import type { ModelsDevModel, ModelsDevProvider } from "@autumn/shared"; +import { useMemo } from "react"; +import { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; + +interface AiModelSelectDropdownProps { + value: string; + onValueChange: (modelKey: string) => void; + provider: ModelsDevProvider; + isLoading: boolean; +} + +export function AiModelSelectDropdown({ + value, + onValueChange, + provider, + isLoading, +}: AiModelSelectDropdownProps) { + const models: ModelsDevModel[] = useMemo( + () => Object.values(provider.models), + [provider], + ); + + return ( +
e.stopPropagation()}> + model.id} + getOptionLabel={(model) => model.name} + renderValue={(option) => + option ? ( + {option.name} + ) : provider.models[value]?.name ? ( + {provider.models[value].name} + ) : value ? ( + {value} + ) : ( + + {isLoading ? "Loading..." : "Select model"} + + ) + } + placeholder={isLoading ? "Loading..." : "Select model"} + searchable + searchPlaceholder="Search models..." + emptyText="No models found" + disabled={isLoading} + /> +
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx b/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx new file mode 100644 index 000000000..9c2817c2f --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx @@ -0,0 +1,113 @@ +import { + type CreditSchemaItem, + type Feature, + isAiCreditSystem, +} from "@autumn/shared"; +import { PlusIcon } from "@phosphor-icons/react"; +import { X } from "lucide-react"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { FormLabel } from "@/components/v2/form/FormLabel"; +import { Input } from "@/components/v2/inputs/Input"; +import { useCreditSchema } from "../hooks/useCreditSchema"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; +import { FeatureSelectDropdown } from "./FeatureSelectDropdown"; + +interface ClassicCreditSchemaProps { + form: CreditSystemFormInstance; +} + +export function ClassicCreditSchema({ form }: ClassicCreditSchemaProps) { + const { + schema, + schemaKeys, + allSchemaCandidateFeatures, + handleSchemaChange, + addSchemaItem, + removeSchemaItem, + } = useCreditSchema(form); + + return ( +
+
+ Feature + Credit Cost +
+ +
+ {schema.map((item: CreditSchemaItem, index: number) => { + const availableFeatures = allSchemaCandidateFeatures.filter( + (feature: Feature) => + !schema.some( + (schemaItem: CreditSchemaItem) => + feature.id !== item.metered_feature_id && + schemaItem.metered_feature_id === feature.id, + ), + ); + + const selectedFeature = allSchemaCandidateFeatures.find( + (f: Feature) => f.id === item.metered_feature_id, + ); + const isAiChild = isAiCreditSystem(selectedFeature?.type); + + return ( +
+ + handleSchemaChange(index, "metered_feature_id", featureId) + } + availableFeatures={availableFeatures} + allFeatures={allSchemaCandidateFeatures} + /> + +
+
+ + handleSchemaChange(index, "credit_amount", e.target.value) + } + onBlur={(e) => + handleSchemaChange( + index, + "credit_amount", + Number(e.target.value) || 0, + ) + } + placeholder="eg. 10" + /> + } + onClick={() => removeSchemaItem(index)} + /> +
+ {isAiChild && ( + + credits per $1 of AI usage + + )} +
+
+ ); + })} +
+ + = allSchemaCandidateFeatures.length} + className="w-fit mt-4" + icon={} + > + Add + +
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx b/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx index b1c834f36..7e273c280 100644 --- a/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreditSystemDetails.tsx @@ -1,23 +1,17 @@ -import type { CreateFeature } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; import { FormLabel } from "@/components/v2/form/FormLabel"; import { Input } from "@/components/v2/inputs/Input"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useAutoSlug } from "@/hooks/common/useAutoSlug"; +import { slugify } from "@/utils/formatUtils/formatTextUtils"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; interface CreditSystemDetailsProps { - creditSystem: CreateFeature; - setCreditSystem: (creditSystem: CreateFeature) => void; + form: CreditSystemFormInstance; } -export function CreditSystemDetails({ - creditSystem, - setCreditSystem, -}: CreditSystemDetailsProps) { - const { setSource, setTarget } = useAutoSlug({ - setState: setCreditSystem, - sourceKey: "name", - targetKey: "id", - }); +export function CreditSystemDetails({ form }: CreditSystemDetailsProps) { + const name = useStore(form.store, (s) => s.values.name); + const id = useStore(form.store, (s) => s.values.id); return ( @@ -26,16 +20,21 @@ export function CreditSystemDetails({ Name setSource(e.target.value)} + value={name} + onChange={(e) => { + form.setFieldValue("name", e.target.value); + if (!id || id === slugify(name)) { + form.setFieldValue("id", slugify(e.target.value)); + } + }} />
ID setTarget(e.target.value)} + value={id} + onChange={(e) => form.setFieldValue("id", e.target.value)} />
diff --git a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx index 2cef9ff85..14cffbab1 100644 --- a/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx +++ b/vite/src/views/products/features/credit-systems/components/CreditSystemSchema.tsx @@ -1,145 +1,88 @@ -import type { CreateFeature, CreditSchemaItem, Feature } from "@autumn/shared"; -import { FeatureType } from "@autumn/shared"; -import { PlusIcon } from "@phosphor-icons/react"; -import { X } from "lucide-react"; -import { toast } from "sonner"; -import { IconButton } from "@/components/v2/buttons/IconButton"; -import { FormLabel } from "@/components/v2/form/FormLabel"; -import { Input } from "@/components/v2/inputs/Input"; +import { FeatureType, isAiCreditSystem } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useMemo } from "react"; +import { BetaBadge } from "@/components/v2/badges/BetaBadge"; +import { GroupedTabButton } from "@/components/v2/buttons/GroupedTabButton"; import { SheetSection } from "@/components/v2/sheets/SharedSheetComponents"; -import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { FeatureSelectDropdown } from "@/views/products/features/credit-systems/components/FeatureSelectDropdown"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; +import { AiCreditSchema } from "./AiCreditSchema"; +import { ClassicCreditSchema } from "./ClassicCreditSchema"; + +type CreditSchemaMode = "classic" | "ai"; interface CreditSystemSchemaProps { - creditSystem: CreateFeature; - setCreditSystem: (creditSystem: CreateFeature) => void; + form: CreditSystemFormInstance; + disableModeSwitch?: boolean; } export function CreditSystemSchema({ - creditSystem, - setCreditSystem, + form, + disableModeSwitch = false, }: CreditSystemSchemaProps) { - const { features } = useFeaturesQuery(); + const type = useStore(form.store, (s) => s.values.type); - const schema = creditSystem.config?.schema || []; + const mode: CreditSchemaMode = isAiCreditSystem(type) ? "ai" : "classic"; - const handleSchemaChange = ( - index: number, - key: keyof CreditSchemaItem, - value: string | number, - ) => { - const newSchema = [...schema]; - newSchema[index] = { ...newSchema[index], [key]: value }; - setCreditSystem({ - ...creditSystem, - config: { ...creditSystem.config, schema: newSchema }, - }); - }; - - const addSchemaItem = () => { - const newSchema = [ - ...schema, - { - metered_feature_id: "", - feature_amount: 1, - credit_amount: 0, - }, - ]; - setCreditSystem({ - ...creditSystem, - config: { ...creditSystem.config, schema: newSchema }, - }); - }; - - const removeSchemaItem = (index: number) => { - if (schema.length === 1) { - toast.error("There must be at least one feature in the credit system"); - return; + const handleModeChange = (newMode: string) => { + if (newMode === "ai") { + form.setFieldValue("type", FeatureType.AiCreditSystem); + form.setFieldValue("config", { ...form.state.values.config, schema: [] }); + form.setFieldValue("model_markups", {}); + form.setFieldValue("provider_markups", {}); + } else { + form.setFieldValue("type", FeatureType.CreditSystem); + form.setFieldValue("config", { + ...form.state.values.config, + schema: [ + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }); + form.setFieldValue("model_markups", {}); + form.setFieldValue("provider_markups", {}); } - const newSchema = [...schema]; - newSchema.splice(index, 1); - setCreditSystem({ - ...creditSystem, - config: { ...creditSystem.config, schema: newSchema }, - }); }; - const allMeteredFeatures = features.filter( - (feature: Feature) => feature.type === FeatureType.Metered, + const modeOptions = useMemo( + () => [ + { value: "classic", label: "Classic" }, + { + value: "ai", + label: ( + + AI + + + ), + }, + ], + [], ); return ( -
-
- Metered Feature - Credit Cost -
+
+ {!disableModeSwitch && ( + + )} -
- {schema.map((item: CreditSchemaItem, index: number) => { - const availableFeatures = allMeteredFeatures.filter( - (feature: Feature) => - !schema.some( - (schemaItem: CreditSchemaItem) => - feature.id !== item.metered_feature_id && - schemaItem.metered_feature_id === feature.id, - ), - ); - - return ( -
- - handleSchemaChange(index, "metered_feature_id", featureId) - } - availableFeatures={availableFeatures} - allFeatures={allMeteredFeatures} - /> - -
- - handleSchemaChange(index, "credit_amount", e.target.value) - } - onBlur={(e) => - handleSchemaChange( - index, - "credit_amount", - Number(e.target.value) || 0, - ) - } - placeholder="eg. 10" - /> - } - onClick={() => removeSchemaItem(index)} - /> -
-
- ); - })} -
- - = allMeteredFeatures.length} - className="w-fit mt-4" - icon={} - > - Add - + {mode === "classic" ? ( + + ) : ( + + )}
); diff --git a/vite/src/views/products/features/credit-systems/components/CustomModelInput.tsx b/vite/src/views/products/features/credit-systems/components/CustomModelInput.tsx new file mode 100644 index 000000000..968abfb4e --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/CustomModelInput.tsx @@ -0,0 +1,23 @@ +import { useState } from "react"; +import { Input } from "@/components/v2/inputs/Input"; + +interface CustomModelInputProps { + modelKey: string; + onRename: (newKey: string) => void; +} + +export function CustomModelInput({ modelKey, onRename }: CustomModelInputProps) { + const [local, setLocal] = useState(modelKey); + return ( + setLocal(e.target.value)} + onBlur={() => { + if (local !== modelKey) onRename(local); + }} + placeholder="my-model-id" + className="text-sm" + /> + ); +} diff --git a/vite/src/views/products/features/credit-systems/components/EditableNumberCell.tsx b/vite/src/views/products/features/credit-systems/components/EditableNumberCell.tsx new file mode 100644 index 000000000..d949b212e --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/EditableNumberCell.tsx @@ -0,0 +1,81 @@ +import { useStore } from "@tanstack/react-form"; +import { useState } from "react"; +import { Input } from "@/components/v2/inputs/Input"; +import type { CreditSystemFormInstance } from "../hooks/useCreditSystemForm"; + +interface EditableNumberCellProps { + form: CreditSystemFormInstance; + fullId: string; + field: "markup" | "input_cost" | "output_cost"; + useDefaultAsPlaceholder?: boolean; + /** Effective inherited markup (provider default, else global default) shown as the placeholder. */ + inheritedPlaceholder?: number; + allowUndefined?: boolean; +} + +export function EditableNumberCell({ + form, + fullId, + field, + useDefaultAsPlaceholder = false, + inheritedPlaceholder = 0, + allowUndefined = false, +}: EditableNumberCellProps) { + const currentValue = useStore( + form.store, + (s) => s.values.model_markups[fullId]?.[field], + ); + const placeholder = useDefaultAsPlaceholder + ? String(inheritedPlaceholder) + : "0"; + const [local, setLocal] = useState(""); + const [focused, setFocused] = useState(false); + + const hasValue = currentValue != null; + const displayed = focused ? local : hasValue ? String(currentValue) : ""; + + return ( + { + const raw = e.target.value; + if (raw === "" || /^-?\d*\.?\d*$/.test(raw)) { + setLocal(raw); + if (raw === "" && allowUndefined) { + form.setFieldValue("model_markups", (prev) => { + const entry = { ...prev[fullId] }; + delete entry[field]; + return { ...prev, [fullId]: entry }; + }); + } else if (raw !== "") { + const parsed = Number(raw); + if (!Number.isNaN(parsed)) { + form.setFieldValue("model_markups", (prev) => ({ + ...prev, + [fullId]: { ...prev[fullId], [field]: parsed }, + })); + } + } + } + }} + onFocus={() => { + setLocal(hasValue ? String(currentValue) : ""); + setFocused(true); + }} + onBlur={() => { + setFocused(false); + if (local === "" && !allowUndefined) { + form.setFieldValue("model_markups", (prev) => ({ + ...prev, + [fullId]: { ...prev[fullId], [field]: 0 }, + })); + } + }} + placeholder={placeholder} + className="text-sm" + /> + ); +} diff --git a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx index 16d280699..a81747c4f 100644 --- a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx +++ b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx @@ -1,7 +1,6 @@ -import type { CreateFeature, CreditSchemaItem, Feature } from "@autumn/shared"; -import { FeatureType } from "@autumn/shared"; +import type { CreditSchemaItem, Feature } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; import type { AxiosError } from "axios"; -import { useEffect, useState } from "react"; import { toast } from "sonner"; import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton"; import { @@ -13,6 +12,8 @@ import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { FeatureService } from "@/services/FeatureService"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { getBackendErr } from "@/utils/genUtils"; +import { buildFeatureMarkupParams } from "../../utils/buildFeatureMutationParams"; +import { useCreditSystemForm } from "../hooks/useCreditSystemForm"; import { validateCreditSystem } from "../utils/validateCreditSystem"; import { CreditSystemDetails } from "./CreditSystemDetails"; import { CreditSystemSchema } from "./CreditSystemSchema"; @@ -30,127 +31,96 @@ function UpdateCreditSystemSheet({ selectedCreditSystem, onSuccess, }: UpdateCreditSystemSheetProps) { - const [loading, setLoading] = useState(false); - const [creditSystem, setCreditSystem] = useState({ - name: "", - id: "", - type: FeatureType.CreditSystem, - config: { - schema: [ - { - metered_feature_id: "", - feature_amount: 1, - credit_amount: 0, - }, - ], - }, - event_names: [], - }); - const axiosInstance = useAxiosInstance(); const { refetch } = useFeaturesQuery(); - // Initialize credit system when selectedCreditSystem changes - useEffect(() => { - if (open && selectedCreditSystem) { - setCreditSystem({ - name: selectedCreditSystem.name, - id: selectedCreditSystem.id, - type: selectedCreditSystem.type, - config: selectedCreditSystem.config, - event_names: selectedCreditSystem.event_names, - }); - } - }, [open, selectedCreditSystem]); + const form = useCreditSystemForm({ + feature: open ? selectedCreditSystem : null, + onSubmit: async (values) => { + if (!selectedCreditSystem) return; - const handleUpdateCreditSystem = async () => { - if (!selectedCreditSystem) return; + const creditSystem = { + name: values.name, + id: values.id, + type: values.type, + config: values.config, + event_names: values.event_names, + model_markups: values.model_markups, + }; - const validationError = validateCreditSystem(creditSystem); - if (validationError) { - toast.error(validationError); - return; - } + const validationError = validateCreditSystem(creditSystem); + if (validationError) { + toast.error(validationError); + return; + } - setLoading(true); - try { await FeatureService.updateFeature( axiosInstance, selectedCreditSystem.id, { - id: creditSystem.id, - name: creditSystem.name, - type: creditSystem.type, - credit_schema: creditSystem.config?.schema?.map( - (x: CreditSchemaItem) => ({ - metered_feature_id: x.metered_feature_id, - credit_cost: Number(x.credit_amount), - }), - ), - event_names: creditSystem.event_names, + id: values.id, + name: values.name, + type: values.type, + ...buildFeatureMarkupParams({ + type: values.type, + modelMarkups: values.model_markups, + defaultMarkup: values.defaultMarkup, + providerMarkups: values.provider_markups, + schema: values.config?.schema as CreditSchemaItem[] | undefined, + }), + event_names: values.event_names, display: undefined, }, ); await refetch(); toast.success("Credit system updated successfully"); - - // Call onSuccess with old and new IDs - if (onSuccess) { - onSuccess( - selectedCreditSystem.id, - creditSystem.id || selectedCreditSystem.id, - ); - } - - setOpen(false); - } catch (error: unknown) { - console.log(error); - toast.error( - getBackendErr(error as AxiosError, "Failed to update credit system"), + onSuccess?.( + selectedCreditSystem.id, + values.id || selectedCreditSystem.id, ); - } finally { - setLoading(false); - } - }; + setOpen(false); + }, + }); - const handleCancel = () => { - setOpen(false); - }; + const isSubmitting = useStore(form.store, (s) => s.isSubmitting); return ( - +
- - + +
setOpen(false)} singleShortcut="escape" > Cancel + form.handleSubmit().catch((err: AxiosError) => { + toast.error( + getBackendErr(err, "Failed to update credit system"), + ); + }) + } metaShortcut="enter" - isLoading={loading} + isLoading={isSubmitting} > Update credit system diff --git a/vite/src/views/products/features/credit-systems/hooks/useAiProviders.ts b/vite/src/views/products/features/credit-systems/hooks/useAiProviders.ts new file mode 100644 index 000000000..8c511fbb6 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useAiProviders.ts @@ -0,0 +1,130 @@ +import { + joinModelId, + type ModelsDevProvider, + splitModelId, +} from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useMemo } from "react"; +import { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; +import { addCustomModelMarkup } from "../utils/modelMarkupUtils"; +import type { CreditSystemFormInstance } from "./useCreditSystemForm"; + +function groupByProvider(markups: Record) { + const groups: Record = {}; + for (const fullId of Object.keys(markups)) { + const { provider } = splitModelId(fullId); + if (!provider) continue; + const group = groups[provider] ?? []; + group.push(fullId); + groups[provider] = group; + } + return groups; +} + +export function useAiProviders(form: CreditSystemFormInstance) { + const { providers, isLoading } = useModelsDevPricing(); + const modelMarkups = useStore(form.store, (s) => s.values.model_markups); + const defaultMarkup = useStore(form.store, (s) => s.values.defaultMarkup); + const providerMarkups = useStore( + form.store, + (s) => s.values.provider_markups, + ); + + const providerGroups = useMemo( + () => groupByProvider(modelMarkups), + [modelMarkups], + ); + // A provider is "active" if it has model overrides OR a provider-level markup. + const activeProviderKeys = useMemo( + () => + Array.from( + new Set([ + ...Object.keys(providerGroups), + ...Object.keys(providerMarkups), + ]), + ), + [providerGroups, providerMarkups], + ); + + const availableProviders = useMemo(() => { + const filtered = Object.values(providers).filter( + (p) => !activeProviderKeys.includes(p.id), + ); + if (!activeProviderKeys.includes("custom")) { + filtered.push({ + id: "custom", + name: "Custom", + models: {}, + } as ModelsDevProvider); + } + return filtered; + }, [providers, activeProviderKeys]); + + const addProvider = (providerKey: string) => { + form.setFieldValue("model_markups", (prev) => { + if (providerKey === "custom") { + return addCustomModelMarkup(prev); + } + const provider = providers[providerKey]; + if (!provider) return prev; + const firstKey = Object.keys(provider.models)[0]; + if (!firstKey) return prev; + return { ...prev, [joinModelId(providerKey, firstKey)]: {} }; + }); + }; + + const removeKeys = (keys: string[]) => + form.setFieldValue("model_markups", (prev) => { + const updated = { ...prev }; + for (const k of keys) delete updated[k]; + return updated; + }); + + const setProviderMarkup = (providerKey: string, value: number | undefined) => + form.setFieldValue("provider_markups", (prev) => { + const updated = { ...prev }; + if (value == null) { + delete updated[providerKey]; + } else { + updated[providerKey] = { markup: value }; + } + return updated; + }); + + // Removes the whole provider section: all its model overrides and its markup. + const removeProvider = (providerKey: string) => { + form.setFieldValue("model_markups", (prev) => { + const updated = { ...prev }; + for (const k of Object.keys(updated)) { + if (splitModelId(k).provider === providerKey) delete updated[k]; + } + return updated; + }); + setProviderMarkup(providerKey, undefined); + }; + + const renameKey = (oldKey: string, newKey: string) => + form.setFieldValue("model_markups", (prev) => { + if (newKey in prev) return prev; + const updated = { ...prev }; + const entry = updated[oldKey]; + delete updated[oldKey]; + updated[newKey] = { ...entry }; + return updated; + }); + + return { + providers, + isLoading, + defaultMarkup, + providerMarkups, + providerGroups, + activeProviderKeys, + availableProviders, + addProvider, + removeKeys, + removeProvider, + setProviderMarkup, + renameKey, + }; +} diff --git a/vite/src/views/products/features/credit-systems/hooks/useCreditSchema.ts b/vite/src/views/products/features/credit-systems/hooks/useCreditSchema.ts new file mode 100644 index 000000000..169eb12dd --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useCreditSchema.ts @@ -0,0 +1,70 @@ +import type { CreditSchemaItem, Feature } from "@autumn/shared"; +import { FeatureType, isAiCreditSystem } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useMemo, useRef } from "react"; +import { toast } from "sonner"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import type { CreditSystemFormInstance } from "./useCreditSystemForm"; + +export function useCreditSchema(form: CreditSystemFormInstance) { + const { features } = useFeaturesQuery(); + const config = useStore(form.store, (s) => s.values.config); + const schema: CreditSchemaItem[] = config?.schema || []; + + const schemaKeysRef = useRef([]); + const schemaKeys = useMemo(() => { + const nextKeys = [...schemaKeysRef.current]; + while (nextKeys.length < schema.length) nextKeys.push(crypto.randomUUID()); + while (nextKeys.length > schema.length) nextKeys.pop(); + schemaKeysRef.current = nextKeys; + return nextKeys; + }, [schema.length]); + + const allSchemaCandidateFeatures = features.filter( + (f: Feature) => + f.type === FeatureType.Metered || isAiCreditSystem(f.type), + ); + + const handleSchemaChange = ( + index: number, + key: keyof CreditSchemaItem, + value: string | number, + ) => { + const newSchema = [...schema]; + newSchema[index] = { ...newSchema[index], [key]: value }; + form.setFieldValue("config", { ...config, schema: newSchema }); + }; + + const addSchemaItem = () => { + schemaKeysRef.current = [...schemaKeysRef.current, crypto.randomUUID()]; + form.setFieldValue("config", { + ...config, + schema: [ + ...schema, + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }); + }; + + const removeSchemaItem = (index: number) => { + if (schema.length === 1) { + toast.error("There must be at least one item in the credit system"); + return; + } + const nextKeys = [...schemaKeysRef.current]; + nextKeys.splice(index, 1); + schemaKeysRef.current = nextKeys; + const newSchema = [...schema]; + newSchema.splice(index, 1); + form.setFieldValue("config", { ...config, schema: newSchema }); + }; + + return { + schema, + schemaKeys, + allSchemaCandidateFeatures, + handleSchemaChange, + addSchemaItem, + removeSchemaItem, + }; +} diff --git a/vite/src/views/products/features/credit-systems/hooks/useCreditSystemForm.ts b/vite/src/views/products/features/credit-systems/hooks/useCreditSystemForm.ts new file mode 100644 index 000000000..404ab9b67 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useCreditSystemForm.ts @@ -0,0 +1,67 @@ +import type { Feature, ModelMarkups } from "@autumn/shared"; +import { FeatureType } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import { useEffect, useRef } from "react"; +import { useAppForm } from "@/hooks/form/form"; + +export interface CreditSystemFormValues { + name: string; + id: string; + type: FeatureType; + config: Record; + event_names: string[]; + model_markups: NonNullable; + /** Global default markup for the AI credit system (persisted to config.default_markup). */ + defaultMarkup: number; + /** Per-provider default markups (persisted to config.provider_markups). */ + provider_markups: Record; +} + +export function useCreditSystemForm({ + feature, + onSubmit, + onChange, +}: { + feature: Feature | null; + onSubmit?: (values: CreditSystemFormValues) => Promise; + onChange?: (values: CreditSystemFormValues) => void; +}) { + const form = useAppForm({ + defaultValues: { + name: feature?.name ?? "", + id: feature?.id ?? "", + type: feature?.type ?? FeatureType.CreditSystem, + config: feature?.config ?? { + schema: [ + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }, + event_names: feature?.event_names ?? [], + model_markups: + (feature?.model_markups as CreditSystemFormValues["model_markups"]) ?? + {}, + defaultMarkup: + (feature?.config?.default_markup as number | undefined) ?? 0, + provider_markups: + (feature?.config + ?.provider_markups as CreditSystemFormValues["provider_markups"]) ?? + {}, + } satisfies CreditSystemFormValues, + onSubmit: onSubmit ? ({ value }) => onSubmit(value) : undefined, + }); + + // Form-level `listeners.onChange` only fires when a FieldApi instance is + // registered for the changed field (see form-core FormApi.setFieldValue). + // None of these fields are mounted via , so we subscribe to the + // store directly and push value changes out to the caller. + const values = useStore(form.store, (s) => s.values); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + useEffect(() => { + onChangeRef.current?.(values); + }, [values]); + + return form; +} + +export type CreditSystemFormInstance = ReturnType; diff --git a/vite/src/views/products/features/credit-systems/hooks/useProviderMarkup.ts b/vite/src/views/products/features/credit-systems/hooks/useProviderMarkup.ts new file mode 100644 index 000000000..54013bfaf --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useProviderMarkup.ts @@ -0,0 +1,21 @@ +import { resolveInheritedMarkup } from "@autumn/shared"; +import { useStore } from "@tanstack/react-form"; +import type { CreditSystemFormInstance } from "./useCreditSystemForm"; + +/** Centralizes the default/provider markup store selectors and their inherited-markup resolution for a single provider. */ +export const useProviderMarkup = ( + form: CreditSystemFormInstance, + providerKey: string, +) => { + const defaultMarkup = useStore(form.store, (s) => s.values.defaultMarkup); + const providerMarkup = useStore( + form.store, + (s) => s.values.provider_markups[providerKey]?.markup, + ); + const inheritedMarkup = resolveInheritedMarkup({ + providerMarkup, + defaultMarkup, + }); + + return { defaultMarkup, providerMarkup, inheritedMarkup }; +}; diff --git a/vite/src/views/products/features/credit-systems/utils/modelMarkupUtils.ts b/vite/src/views/products/features/credit-systems/utils/modelMarkupUtils.ts new file mode 100644 index 000000000..faf2fb7cd --- /dev/null +++ b/vite/src/views/products/features/credit-systems/utils/modelMarkupUtils.ts @@ -0,0 +1,24 @@ +import { + CUSTOM_PROVIDER, + isCustomModel, + joinModelId, + type ModelMarkups, +} from "@autumn/shared"; + +type ModelMarkupMap = NonNullable; + +/** Append a blank custom-model row, choosing the next free `custom/model-N` key. */ +export const addCustomModelMarkup = (prev: ModelMarkupMap): ModelMarkupMap => { + const existing = Object.keys(prev).filter((key) => isCustomModel(key)); + let index = 1; + while (existing.includes(joinModelId(CUSTOM_PROVIDER, `model-${index}`))) { + index++; + } + return { + ...prev, + [joinModelId(CUSTOM_PROVIDER, `model-${index}`)]: { + input_cost: 0, + output_cost: 0, + }, + }; +}; diff --git a/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts b/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts index d470bc254..d45d278b6 100644 --- a/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts +++ b/vite/src/views/products/features/credit-systems/utils/validateCreditSystem.ts @@ -1,4 +1,9 @@ -import type { CreateFeature } from "@autumn/shared"; +import { + type CreateFeature, + isAiCreditSystem, + isCustomModel, + splitModelId, +} from "@autumn/shared"; export const validateCreditSystem = ( creditSystem: CreateFeature, @@ -7,16 +12,32 @@ export const validateCreditSystem = ( return "Please fill in all fields"; } - if (creditSystem.config.schema.length === 0) { - return "Need at least one metered feature"; + if (isAiCreditSystem(creditSystem.type)) { + // No per-model rows is valid: such systems bill at the base cost, + // adjusted by any provider-level or global default markup. + for (const [modelId, entry] of Object.entries( + creditSystem.model_markups ?? {}, + )) { + if (!modelId) return "Select a model for each row"; + if (isCustomModel(modelId)) { + const { modelKey } = splitModelId(modelId); + if (!modelKey) return "Custom model ID cannot be empty"; + if (entry.input_cost == null || entry.output_cost == null) + return "Custom models require input and output costs"; + } + } + return null; + } + + if (!creditSystem.config?.schema || creditSystem.config.schema.length === 0) { + return "Need at least one item in the schema"; } for (const item of creditSystem.config.schema) { if (!item.metered_feature_id) { - return "Select a metered feature"; + return "Select a feature for each row"; } - - if (item.feature_amount <= 0 || item.credit_amount <= 0) { + if ((item.credit_amount ?? 0) <= 0) { return "Credit amount must be greater than 0"; } } diff --git a/vite/src/views/products/features/feature-list/CreditListColumns.tsx b/vite/src/views/products/features/feature-list/CreditListColumns.tsx index b6955c648..0a7abf6a1 100644 --- a/vite/src/views/products/features/feature-list/CreditListColumns.tsx +++ b/vite/src/views/products/features/feature-list/CreditListColumns.tsx @@ -1,11 +1,28 @@ -import type { Feature } from "@autumn/shared"; +import { + type Feature, + isAiCreditSystem, + type ModelsDevProvider, + splitModelId, +} from "@autumn/shared"; +import { CoinsIcon, CpuIcon } from "@phosphor-icons/react"; import type { ColumnDef, Row } from "@tanstack/react-table"; import { AdminHover } from "@/components/general/AdminHover"; import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; import { getFeatureHoverTexts } from "@/views/admin/adminUtils"; import { FeatureListRowToolbar } from "./FeatureListRowToolbar"; -export const createCreditListColumns = (): ColumnDef[] => [ +function resolveModelName( + fullId: string, + providers: Record, +): string { + const { provider, modelKey } = splitModelId(fullId); + if (!provider) return fullId; + return providers[provider]?.models[modelKey]?.name ?? fullId; +} + +export const createCreditListColumns = ( + providers: Record, +): ColumnDef[] => [ { size: 150, header: "Name", @@ -37,21 +54,53 @@ export const createCreditListColumns = (): ColumnDef[] => [ ); }, }, + { + header: "Type", + size: 160, + accessorKey: "type", + cell: ({ row }: { row: Row }) => { + const isAi = isAiCreditSystem(row.original.type); + return ( +
+ {isAi ? ( + <> + + AI Credit System + + ) : ( + <> + + Credit System + + )} +
+ ); + }, + }, { header: "Features", - size: 250, + size: 200, accessorKey: "features", cell: ({ row }: { row: Row }) => { const creditSystem = row.original; + const modelMarkupEntries = creditSystem.model_markups + ? Object.entries(creditSystem.model_markups) + : null; const featureIds = - creditSystem.config?.schema - ?.map( - (schema: { metered_feature_id: string }) => - schema.metered_feature_id, - ) - .join(", ") || "—"; + modelMarkupEntries && modelMarkupEntries.length > 0 + ? modelMarkupEntries + .map(([fullId]) => resolveModelName(fullId, providers)) + .join(", ") + : creditSystem.config?.schema + ?.map( + (schema: { metered_feature_id: string }) => + schema.metered_feature_id, + ) + .join(", ") || "—"; return ( -
{featureIds}
+
+ {featureIds} +
); }, }, diff --git a/vite/src/views/products/features/feature-list/FeatureListTable.tsx b/vite/src/views/products/features/feature-list/FeatureListTable.tsx index a9aca0662..8d65826a6 100644 --- a/vite/src/views/products/features/feature-list/FeatureListTable.tsx +++ b/vite/src/views/products/features/feature-list/FeatureListTable.tsx @@ -1,9 +1,10 @@ -import { AppEnv, type Feature, FeatureType } from "@autumn/shared"; +import { AppEnv, type Feature, isAnyCreditSystem } from "@autumn/shared"; import { ArrowSquareOutIcon, CoinsIcon, LegoIcon } from "@phosphor-icons/react"; import { useMemo, useState } from "react"; import { Table } from "@/components/general/table"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; +import { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useEnv } from "@/utils/envUtils"; import { useProductsQueryState } from "@/views/products/hooks/useProductsQueryState"; @@ -18,6 +19,7 @@ import { FeatureListMenuButton } from "./FeatureListMenuButton"; export function FeatureListTable() { const env = useEnv(); const { features } = useFeaturesQuery(); + const { providers } = useModelsDevPricing(); const { queryStates } = useProductsQueryState(); const [selectedFeature, setSelectedFeature] = useState(null); const [updateFeatureOpen, setUpdateFeatureOpen] = useState(false); @@ -28,14 +30,14 @@ export function FeatureListTable() { // Filter features and credit systems based on archived state const { regularFeatures, creditSystems, hasEventNames } = useMemo(() => { const regularFeatures = features?.filter((feature) => { - if (feature.type === FeatureType.CreditSystem) return false; + if (isAnyCreditSystem(feature.type)) return false; return queryStates.showArchivedFeatures ? feature.archived : !feature.archived; }); const creditSystems = features?.filter((feature) => { - if (feature.type !== FeatureType.CreditSystem) return false; + if (!isAnyCreditSystem(feature.type)) return false; return queryStates.showArchivedFeatures ? feature.archived : !feature.archived; @@ -52,7 +54,10 @@ export function FeatureListTable() { () => createFeatureListColumns({ showEventNames: hasEventNames }), [hasEventNames], ); - const creditColumns = useMemo(() => createCreditListColumns(), []); + const creditColumns = useMemo( + () => createCreditListColumns(providers), + [providers], + ); const featureTable = useProductTable({ data: regularFeatures || [], diff --git a/vite/src/views/products/features/utils/buildFeatureMutationParams.ts b/vite/src/views/products/features/utils/buildFeatureMutationParams.ts new file mode 100644 index 000000000..6a1e6a89e --- /dev/null +++ b/vite/src/views/products/features/utils/buildFeatureMutationParams.ts @@ -0,0 +1,49 @@ +import { + type CreditSchemaItem, + type FeatureType, + isAiCreditSystem, + type ModelMarkups, + type ProviderMarkups, +} from "@autumn/shared"; + +interface BuildFeatureMarkupParamsArgs { + type: FeatureType; + modelMarkups?: ModelMarkups; + defaultMarkup?: number | null; + providerMarkups?: ProviderMarkups; + schema?: CreditSchemaItem[]; +} + +interface FeatureMarkupParams { + model_markups?: ModelMarkups; + default_markup?: number | null; + provider_markups?: ProviderMarkups; + credit_schema?: { metered_feature_id: string; credit_cost: number }[]; +} + +/** + * Centralizes the AI-vs-classic credit system field selection shared by the + * feature mutation sheets. AI credit systems carry markup fields and omit the + * credit schema; classic credit systems do the inverse. + */ +export const buildFeatureMarkupParams = ({ + type, + modelMarkups, + defaultMarkup, + providerMarkups, + schema, +}: BuildFeatureMarkupParamsArgs): FeatureMarkupParams => { + const ai = isAiCreditSystem(type); + return { + model_markups: ai ? modelMarkups : undefined, + default_markup: ai ? defaultMarkup : undefined, + provider_markups: ai ? providerMarkups : undefined, + credit_schema: ai + ? undefined + : schema?.map((item) => ({ + metered_feature_id: item.metered_feature_id, + credit_cost: + item.credit_amount != null ? Number(item.credit_amount) : 0, + })), + }; +}; diff --git a/vite/src/views/products/features/utils/getFeatureIcon.tsx b/vite/src/views/products/features/utils/getFeatureIcon.tsx index 264e4c86c..f1d958cde 100644 --- a/vite/src/views/products/features/utils/getFeatureIcon.tsx +++ b/vite/src/views/products/features/utils/getFeatureIcon.tsx @@ -2,11 +2,13 @@ import type { Feature, ProductItem } from "@autumn/shared"; import { FeatureType, FeatureUsageType, + isAiCreditSystem, ProductItemFeatureType, } from "@autumn/shared"; import { BatteryHighIcon, CoinsIcon, + CpuIcon, TicketIcon, ToggleRightIcon, } from "@phosphor-icons/react"; @@ -68,6 +70,15 @@ export const getFeatureIconConfig = ( }; } + // Handle AI credit system + if (isAiCreditSystem(typeStr) || typeStr === "ai_credit_system") { + return { + icon: , + color: "text-yellow-500", + label: "AI Credit System", + }; + } + // Handle credit system if (typeStr === FeatureType.CreditSystem || typeStr === "credit_system") { return { diff --git a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx index 91d08183f..62ffb5d49 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx @@ -3,6 +3,7 @@ import { FeatureUsageType, getFeatureName, Infinite, + isAiCreditSystem, isContUseItem, isFeaturePriceItem, ProductItemInterval, @@ -104,7 +105,9 @@ export function BillingType() {
Included
- {isConsumable + {isAiCreditSystem(feature?.type) + ? "Set an included USD budget (eg, $10 per month)." + : isConsumable ? `Set an included usage limit (eg, 100 ${featureName} per month).` : isAllocated ? `Set a usage limit (eg, 5 ${featureName}).` @@ -124,7 +127,9 @@ export function BillingType() {
Priced
- {isConsumable + {isAiCreditSystem(feature?.type) + ? "Bill model usage at the markup you set in USD after the included budget is used." + : isConsumable ? `Charge a price for usage (eg, $0.05 per ${singleFeatureName}).` : isAllocated ? `Charge a price based on usage (eg, $10 per ${singleFeatureName}).` diff --git a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx index d3982aaf8..135ef106e 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/EditPlanFeatureSheet.tsx @@ -1,4 +1,4 @@ -import { FeatureType, TierBehavior } from "@autumn/shared"; +import { FeatureType, isAiCreditSystem, TierBehavior } from "@autumn/shared"; import { PencilSimpleIcon } from "@phosphor-icons/react"; import { useState } from "react"; import { IconButton } from "@/components/v2/buttons/IconButton"; @@ -170,7 +170,7 @@ export function EditPlanFeatureSheet({ - {isFeaturePrice && ( + {isFeaturePrice && !isAiCreditSystem(feature?.type) && ( 1 ? ( diff --git a/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx b/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx index ecf356ad5..16ece32b8 100644 --- a/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx +++ b/vite/src/views/products/plan/components/edit-plan-feature/IncludedUsage.tsx @@ -5,11 +5,17 @@ import { entToItemInterval, getFeatureName, Infinite, + isAiCreditSystem, isContUseItem, } from "@autumn/shared"; import { InfinityIcon } from "@phosphor-icons/react"; import { IconCheckbox } from "@/components/v2/checkboxes/IconCheckbox"; import { Input } from "@/components/v2/inputs/Input"; +import { + InputGroup, + InputGroupInput, + InputGroupText, +} from "@/components/v2/inputs/InputGroup"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { isFeaturePriceItem } from "@/utils/product/getItemType"; import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; @@ -24,6 +30,7 @@ export function IncludedUsage() { const includedUsage = item.included_usage; const isFeaturePrice = isFeaturePriceItem(item); + const feature = features.find((f) => f.id === item.feature_id); // Helper function to get the display value for the input const getInputValue = () => { @@ -41,35 +48,67 @@ export function IncludedUsage() {
- Quantity of  - - {getFeatureName({ - feature: features.find((f) => f.id === item.feature_id), - plural: true, - })}{" "} - - {!isFeaturePrice ? " that can be used" : " granted before billing"} + {isAiCreditSystem(feature?.type) ? ( + `USD budget ${isFeaturePrice ? "granted before billing" : "allocated to this plan"}` + ) : ( + <> + Quantity of  + + {getFeatureName({ feature, plural: true })} + + {isFeaturePrice + ? " granted before billing" + : " that can be used"} + + )}
- { - const value = e.target.value.trim(); - - if (value === "") { - setItem({ ...item, included_usage: null }); - } else { - const numValue = value; - if (!Number.isNaN(numValue)) { - setItem({ ...item, included_usage: Number(numValue) }); + {isAiCreditSystem(feature?.type) ? ( + + $ + + value={getInputValue()} + onChange={(e) => { + const value = e.target.value.trim(); + + if (value === "") { + setItem({ ...item, included_usage: null }); + } else { + const numValue = Number(value); + if (!Number.isNaN(numValue)) { + setItem({ ...item, included_usage: numValue }); + } + } + }} + disabled={includedUsage === Infinite} + type="number" + /> + + ) : ( + { + const value = e.target.value.trim(); + + if (value === "") { + setItem({ ...item, included_usage: null }); + } else { + const numValue = Number(value); + if (!Number.isNaN(numValue)) { + setItem({ ...item, included_usage: numValue }); + } + } + }} + disabled={includedUsage === Infinite} + type="number" + /> + )} } diff --git a/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx b/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx index a3cba3ac6..64292d89e 100644 --- a/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx +++ b/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx @@ -2,11 +2,56 @@ import { type CreateFeature, FeatureType, FeatureUsageType, + isAiCreditSystem, + isAnyCreditSystem, } from "@autumn/shared"; import { AreaRadioGroupItem } from "@/components/v2/radio-groups/AreaRadioGroupItem"; import { RadioGroup } from "@/components/v2/radio-groups/RadioGroup"; import { SheetSection } from "@/components/v2/sheets/InlineSheet"; import { CreditSystemSchema } from "@/views/products/features/credit-systems/components/CreditSystemSchema"; +import { useCreditSystemForm } from "@/views/products/features/credit-systems/hooks/useCreditSystemForm"; + +function NewFeatureCreditSchema({ + feature, + setFeature, +}: { + feature: CreateFeature; + setFeature: (feature: CreateFeature) => void; +}) { + const form = useCreditSystemForm({ + feature: { + internal_id: "", + org_id: "", + created_at: 0, + env: "sandbox" as any, + id: feature.id ?? "", + name: feature.name ?? "", + type: feature.type, + config: feature.config ?? {}, + archived: false, + event_names: feature.event_names ?? [], + model_markups: feature.model_markups ?? null, + }, + onChange: (values) => { + const isAi = isAiCreditSystem(values.type); + + setFeature({ + ...feature, + type: values.type, + config: isAi + ? { + ...values.config, + default_markup: values.defaultMarkup, + provider_markups: values.provider_markups, + } + : values.config, + model_markups: values.model_markups, + }); + }, + }); + + return ; +} export function NewFeatureBehaviour({ feature, @@ -15,10 +60,8 @@ export function NewFeatureBehaviour({ feature: CreateFeature; setFeature: (feature: CreateFeature) => void; }) { - if (feature.type === FeatureType.CreditSystem) { - return ( - - ); + if (isAnyCreditSystem(feature.type)) { + return ; } if (feature.type === FeatureType.Metered) { diff --git a/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx b/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx index 3e06d28c3..f046fe991 100644 --- a/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx +++ b/vite/src/views/products/plan/components/new-feature/NewFeatureType.tsx @@ -2,6 +2,7 @@ import { FeatureType as APIFeatureType, type CreateFeature, FeatureUsageType, + isAnyCreditSystem, } from "@autumn/shared"; import { BarcodeIcon, CoinsIcon } from "@phosphor-icons/react"; import { PanelButton } from "@/components/v2/buttons/PanelButton"; @@ -57,7 +58,7 @@ export function NewFeatureType({
{ setFeature({ ...feature, diff --git a/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx index 13a045e96..78e184def 100644 --- a/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx @@ -1,4 +1,4 @@ -import { FeatureType, FeatureUsageType } from "@autumn/shared"; +import { FeatureType, FeatureUsageType, isAiCreditSystem } from "@autumn/shared"; import { BoxArrowDownIcon } from "@phosphor-icons/react"; import { useFeatureStore } from "@/hooks/stores/useFeatureStore"; import { cn } from "@/lib/utils"; @@ -48,6 +48,10 @@ export const DummyPlanFeatureRow = () => { const getDisplayText = () => { const name = hasName ? featureName : getPlaceholderName(); + if (isAiCreditSystem(feature.type)) { + return { primary: `$10.00 of ${name}`, secondary: "" }; + } + if (featureType === FeatureType.CreditSystem) { return { primary: `100 ${name}`, secondary: "" }; } diff --git a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx index f718e1ea7..a61e44c65 100644 --- a/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/PlanFeatureRow.tsx @@ -1,6 +1,6 @@ /** biome-ignore-all lint/a11y/noStaticElementInteractions: needed */ /** biome-ignore-all lint/a11y/useSemanticElements: needed */ -import type { ProductItem } from "@autumn/shared"; +import { type ProductItem, isAiCreditSystem } from "@autumn/shared"; import { getProductItemDisplay } from "@autumn/shared"; import { TrashIcon } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; @@ -68,6 +68,7 @@ export const PlanFeatureRow = ({ const feature = features.find((f) => f.id === item.feature_id); const hasFeatureName = feature?.name && feature.name.trim() !== ""; + const displayText = hasFeatureName ? display.primary_text : "Name your feature"; @@ -206,7 +207,9 @@ export const PlanFeatureRow = ({ {displayText} - {display.secondary_text} + {!isAiCreditSystem(feature?.type) && display.secondary_text && ( + {display.secondary_text} + )}