diff --git a/ai b/ai index b1efb8d30..761b84255 160000 --- a/ai +++ b/ai @@ -1 +1 @@ -Subproject commit b1efb8d303caf7fdc76d820b00ad87d305f6867f +Subproject commit 761b842553d890355b77e618c3aa703dddb661c1 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..9fed45b3e --- /dev/null +++ b/apps/docs/api-reference-generator/balances/trackTokens.mdx @@ -0,0 +1,61 @@ +--- +title: "Track Token Usage" +openapi: "openapi POST /v1/balances.trackTokens" +--- + +import { DynamicParamField } from "/components/dynamic-param-field.jsx"; +import { DynamicResponseExample } from "/components/dynamic-response-example.jsx"; +import { DynamicResponseField } from "/components/dynamic-response-field.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`. + +### Common Use Cases + + + +```typescript Anthropic +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 1000, + outputTokens: 500 +}); +``` + +```typescript OpenAI +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "openai/gpt-4o", + inputTokens: 500, + outputTokens: 200, + properties: { + conversation_id: "conv_abc123", + prompt_type: "summarization" + } +}); +``` + +```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 +}); +``` + + 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..28f811123 --- /dev/null +++ b/apps/docs/mintlify/api-reference/balances/trackTokens.mdx @@ -0,0 +1,171 @@ +--- +title: "Track Token Usage" +openapi: "openapi POST /v1/balances.trackTokens" +--- + +import { DynamicParamField } from "/components/dynamic-param-field.jsx"; +import { DynamicResponseExample } from "/components/dynamic-response-example.jsx"; +import { DynamicResponseField } from "/components/dynamic-response-field.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`. + +### Common Use Cases + + + +```typescript Anthropic +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "anthropic/claude-opus-4-6", + inputTokens: 1000, + outputTokens: 500 +}); +``` + +```typescript OpenAI +await autumn.balances.trackTokens({ + customerId: "cus_123", + modelId: "openai/gpt-4o", + inputTokens: 500, + outputTokens: 200, + properties: { + conversation_id: "conv_abc123", + prompt_type: "summarization" + } +}); +``` + +```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 +}); +``` + + + +### 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 input tokens consumed. + + + + Number of output tokens consumed. + + + + The ID of the AI credit system feature. If omitted, automatically detects the organization's AI credit system feature. + + + + The ID of the entity for entity-scoped balances. + + + + Additional properties to attach to this usage event. The `model`, `input_tokens`, and `output_tokens` values 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 c4d4dfc6a..9985a5eea 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/balances/createBalance", "api-reference/balances/updateBalance", "api-reference/balances/deleteBalance", diff --git a/apps/docs/mintlify/documentation/customers/tracking-usage.mdx b/apps/docs/mintlify/documentation/customers/tracking-usage.mdx index e0809faac..90a65b734 100644 --- a/apps/docs/mintlify/documentation/customers/tracking-usage.mdx +++ b/apps/docs/mintlify/documentation/customers/tracking-usage.mdx @@ -131,6 +131,74 @@ 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`. + + + +```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.trackTokens" \ + -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 your organization has only one AI credit system feature, you can omit the `featureId` parameter — it will be auto-detected. + + +### 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..bb33e85f0 --- /dev/null +++ b/apps/docs/mintlify/documentation/external-providers/ai-sdk.mdx @@ -0,0 +1,141 @@ +--- +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 `withTokenTracking` 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 { withTokenTracking } from "@useautumn/ai-sdk"; + +const autumn = new Autumn({ secretKey: "am_sk_test_1234" }); + +const model = withTokenTracking({ + 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); +} +``` + +## 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 = withTokenTracking({ + 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 { withTokenTracking } from "@useautumn/ai-sdk"; + +const autumn = new Autumn({ secretKey: process.env.AUTUMN_SECRET_KEY! }); + +async function chat(customerId: string, message: string) { + const model = withTokenTracking({ + 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..b4382ec75 100644 --- a/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx +++ b/apps/docs/mintlify/documentation/modelling-pricing/credit-systems.mdx @@ -271,3 +271,114 @@ 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. + + + + +Define an AI credit system with `modelMarkups` that maps model IDs to pricing configuration: + +```ts autumn.config.ts +import { feature, item, plan } from 'atmn'; + +export const aiCredits = feature({ + id: 'ai_credits', + name: 'AI Credits', + type: 'ai_credit_system', + modelMarkups: { + 'anthropic/claude-opus-4-5': { markup: 20 }, + 'anthropic/claude-sonnet-4-5': { markup: 15 }, + 'openai/gpt-4o': { markup: 15 }, + // 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. Add the models you want to support with their markup percentages +5. For custom models, also specify input/output costs per million tokens +6. 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. For custom models, you must specify `inputCost` and `outputCost` in dollars per million tokens. + +### 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.trackTokens" \ + -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/knip.json b/knip.json index 74649a7a2..1775aaa17 100644 --- a/knip.json +++ b/knip.json @@ -13,6 +13,7 @@ ], "ignore": ["ai/**", "others/**", ".trigger/**"], "ignoreWorkspaces": [ + "packages/ai-sdk", "packages/atmn", "packages/autumn-js", "packages/openapi", diff --git a/package.json b/package.json index d2abaf7cd..13c56e25f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "packages/autumn-js", "packages/openapi", "packages/ksuid", - "packages/stripe-sync" + "packages/stripe-sync", + "packages/ai-sdk" ], "catalog": { "stripe": "19.3.0-beta.1", @@ -131,7 +132,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", + "ts": "turbo run ts --filter=@autumn/server --filter=autumn-js --filter=@autumn/openapi --filter=atmn --filter=checkout --filter=@useautumn/ai-sdk", "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..f8adf149d --- /dev/null +++ b/packages/ai-sdk/package.json @@ -0,0 +1,35 @@ +{ + "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", + "build": "rm -rf dist && tsup", + "prepublishOnly": "bun run build" + }, + "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..544423e5a --- /dev/null +++ b/packages/ai-sdk/src/index.ts @@ -0,0 +1,104 @@ +import type { LanguageModelV3, LanguageModelV3Usage } from "@ai-sdk/provider"; +import { + type LanguageModelMiddleware, + type LanguageModelUsage, + wrapLanguageModel, +} from "ai"; +import type { Autumn } from "autumn-js"; + +type TokenCount = + | LanguageModelV3Usage["inputTokens"] + | LanguageModelUsage["inputTokens"]; + +export const withTokenTracking = ({ + autumn, + model, + customerId, + providerId, + featureId, + entityId, + properties, +}: { + /** Autumn SDK client instance. */ + autumn: Autumn; + /** 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. Falls back to `model.provider`. */ + providerId?: "custom" | 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 the usage event. */ + properties?: Record; +}) => { + const provider = providerId ?? model.provider; + const modelName = `${provider}/${model.modelId}`; + + const resolveTokens = (tokens: TokenCount, label: string): number => { + const value = typeof tokens === "number" ? tokens : tokens?.total; + 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 trackUsage = async ( + usage: LanguageModelV3Usage | LanguageModelUsage, + ) => { + try { + await autumn.balances.trackTokens({ + customerId, + modelId: modelName, + inputTokens: resolveTokens(usage.inputTokens, "Input"), + outputTokens: resolveTokens(usage.outputTokens, "Output"), + 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); + 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); + } + controller.enqueue(chunk); + }, + async flush() { + if (trackingPromise) { + await trackingPromise; + } + }, + }); + + return { + stream: stream.pipeThrough(transformStream), + ...rest, + }; + }, + }; + + return wrapLanguageModel({ model: model, middleware }); +}; 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/compose/models/featureModels.ts b/packages/atmn/src/compose/models/featureModels.ts index 5a2e63bdb..fac022ce7 100644 --- a/packages/atmn/src/compose/models/featureModels.ts +++ b/packages/atmn/src/compose/models/featureModels.ts @@ -85,11 +85,17 @@ export type CreditSystemFeature = FeatureBase & { }>; }; -/** - * 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 = { + markup: number; + inputCost?: number; + outputCost?: number; +}; + +/** AI credit system feature - uses model-based pricing */ +export type AiCreditSystemFeature = FeatureBase & { + type: "ai_credit_system"; + modelMarkups?: 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..e4ba94ffb 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,22 @@ 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), }, }, @@ -85,6 +105,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..a67702701 100644 --- a/packages/atmn/src/lib/transforms/sdkToApi/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToApi/feature.ts @@ -11,6 +11,11 @@ export interface ApiFeatureParams { metered_feature_id: string; credit_cost: number; }>; + model_markups?: Record; } export function transformFeatureToApi(feature: Feature): ApiFeatureParams { @@ -39,5 +44,18 @@ export function transformFeatureToApi(feature: Feature): ApiFeatureParams { })); } + if (feature.type === "ai_credit_system" && 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, + }, + ]) + ); + } + return base; } diff --git a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts index 3e61d4e1a..54a728f17 100644 --- a/packages/atmn/src/lib/transforms/sdkToCode/feature.ts +++ b/packages/atmn/src/lib/transforms/sdkToCode/feature.ts @@ -42,6 +42,11 @@ export function buildFeatureCode(feature: Feature, varNameOverride?: string): st lines.push(`\tcreditSchema: ${formatValue(feature.creditSchema)},`); } + // Add modelMarkups for ai_credit_system features + if (feature.type === "ai_credit_system" && feature.modelMarkups) { + lines.push(`\tmodelMarkups: ${formatValue(feature.modelMarkups)},`); + } + lines.push(`});`); return lines.join("\n"); diff --git a/server/src/internal/balances/balancesRouter.ts b/server/src/internal/balances/balancesRouter.ts index 56f096be8..95d6c54ce 100644 --- a/server/src/internal/balances/balancesRouter.ts +++ b/server/src/internal/balances/balancesRouter.ts @@ -7,6 +7,7 @@ import { handleFinalizeLock } from "./handlers/handleFinalizeLock.js"; import { handleListBalances } from "./handlers/handleListBalances.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 @@ -19,6 +20,7 @@ balancesRouter.post("/balances/update", ...handleUpdateBalance); // Track balancesRouter.post("/events", ...handleTrack); balancesRouter.post("/track", ...handleTrack); +balancesRouter.post("/trackTokens", ...handleTrackTokens); // Check balancesRouter.post("/entitled", ...handleCheck); @@ -33,5 +35,6 @@ balancesRpcRouter.post("/balances.update", ...handleUpdateBalance); balancesRpcRouter.post("/balances.delete", ...handleDeleteBalance); balancesRpcRouter.post("/balances.track", ...handleTrack); +balancesRpcRouter.post("/balances.trackTokens", ...handleTrackTokens); 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..b0f6df62f --- /dev/null +++ b/server/src/internal/balances/handlers/handleTrackTokens.ts @@ -0,0 +1,129 @@ +import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { runTrackV2 } from "@/internal/balances/track/runTrackV2.js"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.js"; +import { + AffectedResource, + ErrCode, + type Feature, + FeatureType, + RecaseError, + Scopes, + type TrackParams, + TrackTokensParamsSchema, +} from "@autumn/shared"; +import type { FeatureDeduction } from "../utils/types/featureDeduction.js"; + +const resolveAiCreditFeature = ({ + features, + featureId, +}: { + features: Feature[]; + featureId?: string; +}): Feature => { + if (featureId) { + const candidate = features.find((f) => f.id === featureId); + if (!candidate) { + throw new RecaseError({ + message: `Feature ${featureId} not found`, + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + if (candidate.type !== FeatureType.AiCreditSystem) { + throw new RecaseError({ + message: `Feature ${featureId} is not an AI credit system feature`, + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + return candidate; + } + + const matches = features.filter((f) => f.type === FeatureType.AiCreditSystem); + if (matches.length === 0) { + throw new RecaseError({ + message: "No AI credit system feature found for this organization", + code: ErrCode.FeatureNotFound, + statusCode: 404, + }); + } + if (matches.length > 1) { + throw new RecaseError({ + message: + "Multiple AI credit system features found. Please specify a feature_id to disambiguate.", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + return matches[0]; +}; + +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 aiCreditFeature = resolveAiCreditFeature({ + features: ctx.features, + featureId: body.feature_id, + }); + + const rawModelName = body.model_id; + + // Compute the dollar cost once and reuse it for both the response/event row + // and the deduction layer (avoids a second getCreditCost call per entitlement). + const cost = await getCreditCost({ + featureId: aiCreditFeature.id, + creditSystem: aiCreditFeature, + modelName: rawModelName, + tokens: { + input: body.input_tokens, + output: body.output_tokens, + }, + }); + + const featureDeductions: FeatureDeduction[] = [ + { + feature: aiCreditFeature, + deduction: 1, // multiplied by per-entitlement credit_cost in the deduction layer (Postgres) + tokenUsage: { + modelName: rawModelName, + inputTokens: body.input_tokens, + outputTokens: body.output_tokens, + }, + precomputedCreditCost: cost, + }, + ]; + + // Build TrackParams body — store model/tokens in properties for audit + const trackBody: TrackParams = { + customer_id: body.customer_id, + entity_id: body.entity_id, + feature_id: aiCreditFeature.id, + value: cost, + properties: { + ...body.properties, + model: rawModelName, + input_tokens: body.input_tokens, + output_tokens: body.output_tokens, + cost, + }, + idempotency_key: body.idempotency_key, + overage_behavior: body.overage_behavior, + customer_data: body.customer_data, + entity_data: body.entity_data, + skip_event: body.skip_event, + }; + + return c.json( + await runTrackV2({ + ctx, + body: trackBody, + featureDeductions, + }), + ); + }, +}); diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index d0f3a84ac..05c015f1e 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -111,7 +111,7 @@ export const executePostgresDeduction = async ({ customerEntitlements, unlimitedFeatureIds, lock: preparedLock, - } = prepareFeatureDeduction({ + } = await prepareFeatureDeduction({ ctx, fullCustomer, deduction, diff --git a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts index 9ac4e797a..43a5040b7 100644 --- a/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executeRedisDeduction.ts @@ -109,7 +109,7 @@ export const executeRedisDeduction = async ({ customerEntitlements, unlimitedFeatureIds, lock: preparedLock, - } = prepareFeatureDeduction({ + } = await prepareFeatureDeduction({ ctx, fullCustomer, deduction, diff --git a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts index f89bb8c3a..1d4cba8f3 100644 --- a/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts +++ b/server/src/internal/balances/utils/deduction/prepareFeatureDeduction.ts @@ -27,7 +27,7 @@ import type { FeatureDeduction } from "../types/featureDeduction.js"; * Prepares all the inputs needed to execute a deduction for a single feature. * Shared by both Redis (Lua) and Postgres (SQL) deduction paths. */ -export const prepareFeatureDeduction = ({ +export const prepareFeatureDeduction = async ({ ctx, fullCustomer, deduction, @@ -37,7 +37,7 @@ export const prepareFeatureDeduction = ({ fullCustomer: FullCustomer; deduction: FeatureDeduction; options?: DeductionOptions; -}): PreparedFeatureDeduction => { +}): Promise => { const { org } = ctx; const { env } = ctx; const { feature, lock, targetBalance } = deduction; @@ -101,14 +101,31 @@ export const prepareFeatureDeduction = ({ .map((ce) => ce.entitlement.feature.id), ); + // Compute credit cost once per customer entitlement + const creditCostByEntitlementId = new Map(); + await Promise.all( + cusEnts.map(async (ce) => { + const creditCost = + deduction.precomputedCreditCost ?? + (await getCreditCost({ + featureId: feature.id, + creditSystem: ce.entitlement.feature, + modelName: deduction.tokenUsage?.modelName, + tokens: deduction.tokenUsage + ? { + input: deduction.tokenUsage.inputTokens, + output: deduction.tokenUsage.outputTokens, + } + : undefined, + })); + creditCostByEntitlementId.set(ce.id, creditCost); + }), + ); + // Build input for each customer entitlement const customerEntitlementDeductions: CustomerEntitlementDeduction[] = cusEnts.map((ce) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: ce.entitlement.feature, - }); - + const creditCost = creditCostByEntitlementId.get(ce.id)!; const maxOverage = getMaxOverage({ cusEnt: ce }); const isFreeAllocated = @@ -148,10 +165,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 = creditCostByEntitlementId.get(ce.id)!; return (ce.rollovers || []).map((r) => ({ ...r, credit_cost: creditCost, diff --git a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts index 9f025e7fb..1b6d5ca84 100644 --- a/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executePostgresDeductionV2.ts @@ -111,7 +111,7 @@ export const executePostgresDeductionV2 = async ({ unlimitedFeatureIds, unlimitedCusEnt, lock: preparedLock, - } = prepareFeatureDeductionV2({ + } = await prepareFeatureDeductionV2({ ctx, fullSubject, deduction, diff --git a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts index a9501a6e4..bfd9cdcb2 100644 --- a/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/executeRedisDeductionV2.ts @@ -122,7 +122,7 @@ export const executeRedisDeductionV2 = async ({ unlimitedFeatureIds, unlimitedCusEnt, lock: preparedLock, - } = prepareFeatureDeductionV2({ + } = await prepareFeatureDeductionV2({ ctx, fullSubject, deduction, diff --git a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts index 4b550de10..44147d7ba 100644 --- a/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts +++ b/server/src/internal/balances/utils/deductionV2/prepareFeatureDeductionV2.ts @@ -29,7 +29,7 @@ import type { FeatureDeduction } from "../types/featureDeduction.js"; * Prepares all the inputs needed to execute a deduction for a single feature. * Mirrors the legacy helper, but reads from FullSubject. */ -export const prepareFeatureDeductionV2 = ({ +export const prepareFeatureDeductionV2 = async ({ ctx, fullSubject, deduction, @@ -39,7 +39,7 @@ export const prepareFeatureDeductionV2 = ({ fullSubject: FullSubject; deduction: FeatureDeduction; options?: DeductionOptions; -}): PreparedFeatureDeduction => { +}): Promise => { const { org, env } = ctx; const { feature, lock, targetBalance } = deduction; const { overageBehaviour = "cap", customerEntitlementFilters } = options; @@ -115,12 +115,31 @@ export const prepareFeatureDeductionV2 = ({ .map((customerEntitlement) => customerEntitlement.entitlement.feature.id), ); + const creditCostByCustomerEntitlementId = new Map(); + await Promise.all( + customerEntitlements.map(async (customerEntitlement) => { + const creditCost = + deduction.precomputedCreditCost ?? + (await getCreditCost({ + featureId: feature.id, + creditSystem: customerEntitlement.entitlement.feature, + modelName: deduction.tokenUsage?.modelName, + tokens: deduction.tokenUsage + ? { + input: deduction.tokenUsage.inputTokens, + output: deduction.tokenUsage.outputTokens, + } + : undefined, + })); + creditCostByCustomerEntitlementId.set(customerEntitlement.id, creditCost); + }), + ); + const customerEntitlementDeductions: CustomerEntitlementDeduction[] = customerEntitlements.map((customerEntitlement) => { - const creditCost = getCreditCost({ - featureId: feature.id, - creditSystem: customerEntitlement.entitlement.feature, - }); + const creditCost = creditCostByCustomerEntitlementId.get( + customerEntitlement.id, + )!; const maxOverage = getMaxOverage({ cusEnt: customerEntitlement, @@ -162,26 +181,24 @@ 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 = creditCostByCustomerEntitlementId.get( + 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..eb6d3b0eb 100644 --- a/server/src/internal/balances/utils/types/featureDeduction.ts +++ b/server/src/internal/balances/utils/types/featureDeduction.ts @@ -4,8 +4,17 @@ export type FeatureDeduction = { feature: Feature; deduction: number; targetBalance?: number; - lock?: LockParams; + tokenUsage?: { + modelName: string; + inputTokens: number; + outputTokens: number; + }; + + /** Pre-computed dollar cost; if set, the deduction layer skips its own getCreditCost call. */ + precomputedCreditCost?: number; + + lock?: LockParams; lockReceipt?: LockReceipt; lockReceiptKey?: string; unwindValue?: number; diff --git a/server/src/internal/features/creditSystemUtils.ts b/server/src/internal/features/creditSystemUtils.ts index 12a0363ad..367af65e6 100644 --- a/server/src/internal/features/creditSystemUtils.ts +++ b/server/src/internal/features/creditSystemUtils.ts @@ -1,9 +1,13 @@ import { type CreditSchemaItem, + ErrCode, type Feature, FeatureType, + InternalError, + RecaseError, } from "@autumn/shared"; import { Decimal } from "decimal.js"; +import { getModelsDevPricing } from "@/internal/features/utils/getModelPricing"; const creditSystemContainsFeature = ({ creditSystem, @@ -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) { @@ -69,21 +74,127 @@ export const featureToCreditSystem = ({ return amount; }; +// Costs are in $/M tokens; markup is a percentage (e.g. 20 = +20%). +const computeMarkedUpCost = ({ + inputCostPerMillion, + outputCostPerMillion, + input, + output, + markup, +}: { + inputCostPerMillion: Decimal.Value; + outputCostPerMillion: Decimal.Value; + input: number; + output: number; + markup: number; +}) => + new Decimal(inputCostPerMillion) + .mul(input) + .add(new Decimal(outputCostPerMillion).mul(output)) + .div(1_000_000) + .mul(new Decimal(1).add(new Decimal(markup).div(100))) + .toNumber(); -export const getCreditCost = ({ +const getModelCreditCost = async ({ + modelName, + creditSystem, + input, + output, +}: { + modelName: string; + creditSystem: Feature; + input: number; + output: number; +}) => { + const markups = creditSystem.model_markups || {}; + const markupEntry = markups[modelName]; + const { markup } = markupEntry ?? { markup: 0 }; + + if (modelName.startsWith("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 }, + }); + } + return computeMarkedUpCost({ + inputCostPerMillion: markupEntry.input_cost, + outputCostPerMillion: markupEntry.output_cost, + input, + output, + markup, + }); + } + + const pricingData = await getModelsDevPricing(); + if (!pricingData) { + throw new InternalError({ + message: "Failed to fetch models.dev pricing data", + code: ErrCode.InternalError, + }); + } + + const [providerKey, ...modelParts] = modelName.split("/"); + const modelKey = modelParts.join("/"); + const model = pricingData[providerKey]?.models[modelKey]; + + if (!model) { + throw new RecaseError({ + message: `Model ${modelName} not found in models.dev pricing data ${providerKey} provider config.`, + code: ErrCode.InvalidRequest, + statusCode: 400, + data: { modelName }, + }); + } + + return computeMarkedUpCost({ + inputCostPerMillion: model.cost.input, + outputCostPerMillion: model.cost.output, + input, + output, + markup, + }); +}; + +export const getCreditCost = async ({ featureId, creditSystem, amount = 1, + tokens, + modelName, }: { featureId: string; creditSystem: Feature; amount?: number; + modelName?: string; + tokens?: { + input: number; + output: number; + }; }) => { - if (creditSystem.type !== FeatureType.CreditSystem) { + if (creditSystem.type !== FeatureType.CreditSystem && creditSystem.type !== FeatureType.AiCreditSystem) { + return amount; + } + if (creditSystem.type === FeatureType.AiCreditSystem) { + if (!tokens || !modelName) { + throw new RecaseError({ + message: "modelName and tokens must be provided for AI credit systems", + code: ErrCode.InvalidRequest, + statusCode: 400, + }); + } + return await getModelCreditCost({ + modelName, + creditSystem, + ...tokens, + }); + } + // If tracking the credit system feature itself, 1:1 mapping + if (featureId === creditSystem.id) { return amount; } 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 +204,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..1a1bbc1b2 100644 --- a/server/src/internal/features/featureActions/createFeature.ts +++ b/server/src/internal/features/featureActions/createFeature.ts @@ -9,15 +9,15 @@ import { } from "../featureUtils.js"; const validateFeature = (data: any) => { - const featureType = data.type; - - // validateFeatureId(data.id); + 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); + config = validateCreditSystem(config, { isAiCreditSystem: false }); + } else if (featureType === FeatureType.AiCreditSystem) { + config = validateCreditSystem(config, { isAiCreditSystem: true }); } const parsedFeature = CreateFeatureSchema.parse({ ...data, config }); @@ -32,6 +32,14 @@ interface CreateFeatureParams { type: string; config?: any; event_names?: string[]; + model_markups?: Record< + string, + { + markup: number; + input_cost?: number; + output_cost?: number; + } + > | null; }; skipGenerateDisplay?: boolean; } @@ -54,6 +62,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..514d9b164 100644 --- a/server/src/internal/features/featureActions/updateFeature.ts +++ b/server/src/internal/features/featureActions/updateFeature.ts @@ -3,6 +3,7 @@ import { ErrCode, type Feature, FeatureType, + type ModelMarkups, notNullish, } from "@autumn/shared"; import type { AutumnContext } from "@/honoUtils/HonoEnv.js"; @@ -31,6 +32,34 @@ interface UpdateFeatureParams { * Checks if the credit schema has changed between old and new config. * Returns true if schema changed (different items or different credit amounts). */ +const areModelMarkupsEqual = ({ + a, + b, +}: { + a: ModelMarkups; + b: ModelMarkups; +}): 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 (aEntry.markup !== bEntry.markup) return false; + if (aEntry.input_cost !== bEntry.input_cost) return false; + if (aEntry.output_cost !== bEntry.output_cost) return false; + } + + return true; +}; + const hasCreditSchemaChanged = ({ oldSchema, newSchema, @@ -146,12 +175,14 @@ export const updateFeature = async ({ } } - // Validate config based on feature type + const effectiveType = updates.type ?? feature.type; + const isAiCreditSystem = effectiveType === FeatureType.AiCreditSystem; + const newConfig = updates.config !== undefined - ? feature.type === FeatureType.CreditSystem - ? validateCreditSystem(updates.config) - : feature.type === FeatureType.Metered + ? effectiveType === FeatureType.CreditSystem || isAiCreditSystem + ? validateCreditSystem(updates.config, { isAiCreditSystem }) + : effectiveType === FeatureType.Metered ? validateMeteredConfig(updates.config) : updates.config : feature.config; @@ -165,10 +196,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 +213,23 @@ 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 + if ((feature.type === FeatureType.CreditSystem || feature.type === FeatureType.AiCreditSystem) && 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, + }); + + if (schemaChanged || markupsChanged) { 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..761b84e04 100644 --- a/server/src/internal/features/featureUtils.ts +++ b/server/src/internal/features/featureUtils.ts @@ -41,9 +41,13 @@ export const validateMeteredConfig = (config: MeteredConfig) => { return newConfig as MeteredConfig; }; -export const validateCreditSystem = (config: CreditSystemConfig) => { +export const validateCreditSystem = ( + config: CreditSystemConfig, + { isAiCreditSystem = false }: { isAiCreditSystem?: boolean } = {}, +) => { const schema = config.schema; - if (!schema || schema.length === 0) { + + if (!isAiCreditSystem && (!schema || schema.length === 0)) { throw new RecaseError({ message: `At least one metered feature is required for credit system`, code: ErrCode.InvalidFeature, @@ -51,11 +55,14 @@ export const validateCreditSystem = (config: CreditSystemConfig) => { }); } + if (isAiCreditSystem) { + return { ...config, usage_type: FeatureUsageType.Single }; + } + // Check if multiple of the same feature 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({ diff --git a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts index 17fceaaea..2c4bd4263 100644 --- a/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts +++ b/server/src/internal/features/handlers/handleUpdateFeature/handleUpdateFeatureV1.ts @@ -58,6 +58,8 @@ export const handleUpdateFeatureV1 = createRoute({ archived: body.archived, event_names: body.event_names, display: body.display, + model_markups: + body.model_markups === undefined ? undefined : 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..29c712eed 100644 --- a/server/src/internal/features/utils/constructFeatureUtils.ts +++ b/server/src/internal/features/utils/constructFeatureUtils.ts @@ -4,6 +4,7 @@ import { type Feature, FeatureType, FeatureUsageType, + type ModelMarkups, } from "@autumn/shared"; import { generateId, keyToTitle } from "@server/utils/genUtils"; @@ -36,8 +37,9 @@ const constructFeature = ({ display, archived: false, event_names: [], + model_markups: null, }; - + // This function isn't used anywhere, maybe delete it? return newFeature; }; @@ -64,6 +66,7 @@ export const constructBooleanFeature = ({ config: null, archived: false, event_names: [], + model_markups: null, }; return newFeature; @@ -109,6 +112,7 @@ export const constructMeteredFeature = ({ }, archived: false, event_names: eventNames, + model_markups: null, }; return newFeature; @@ -151,6 +155,43 @@ export const constructCreditSystem = ({ config, archived: false, event_names: [], + model_markups: null, + }; + + return newFeature; +}; + +export const constructAiCreditSystem = ({ + featureId, + name, + orgId, + env, + modelMarkups, +}: { + featureId: string; + name?: string; + orgId: string; + env: AppEnv; + modelMarkups: ModelMarkups; +}) => { + const config = { + schema: [], + usage_type: FeatureUsageType.Single, + }; + + 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..ac61bb8a3 --- /dev/null +++ b/server/src/internal/features/utils/getModelPricing.ts @@ -0,0 +1,40 @@ +import { CacheManager } from "@/utils/cacheUtils/CacheManager"; +import { ErrCode, InternalError, type ModelsDevProvider } from "@autumn/shared"; + +const MODELS_DEV_CACHE_KEY = "models_dev_pricing"; + +export const getModelsDevPricing = async () => { + try { + const cached = + await CacheManager.getJson>( + MODELS_DEV_CACHE_KEY, + ); + if (cached) return cached; + const response = await fetch("https://models.dev/api.json"); + if (!response.ok) + throw new InternalError({ + message: `models.dev returned ${response.status}`, + code: ErrCode.InternalError, + }); + + const data: Record = await response.json(); + Promise.all([ + CacheManager.setJson(MODELS_DEV_CACHE_KEY, data, 60 * 60 * 3), + CacheManager.setJson( + `${MODELS_DEV_CACHE_KEY}_stale`, + data, + 60 * 60 * 24 * 3, + ), + ]).catch(() => {}); + return data; + } catch { + const stale = await CacheManager.getJson>( + `${MODELS_DEV_CACHE_KEY}_stale`, + ); + 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/usage/usage2.test.ts b/server/tests/advanced/usage/usage2.test.ts index 886ce2e4d..75344a9dd 100644 --- a/server/tests/advanced/usage/usage2.test.ts +++ b/server/tests/advanced/usage/usage2.test.ts @@ -104,7 +104,7 @@ describe(`${chalk.yellowBright("usage2: Testing basic usage product")}`, () => { .toNumber(); const featureId = i % 2 === 0 ? TestFeature.Action1 : TestFeature.Action2; - const creditsUsed = getCreditCost({ + const creditsUsed = await getCreditCost({ creditSystem: creditsFeature, featureId: featureId, amount: randomVal, diff --git a/server/tests/advanced/usage/usage3.test.ts b/server/tests/advanced/usage/usage3.test.ts index c1474e470..6a48c6ebe 100644 --- a/server/tests/advanced/usage/usage3.test.ts +++ b/server/tests/advanced/usage/usage3.test.ts @@ -121,7 +121,7 @@ describe(`${chalk.yellowBright( .toNumber(); const featureId = i % 2 === 0 ? TestFeature.Action1 : TestFeature.Action2; - const creditsUsed = getCreditCost({ + const creditsUsed = await getCreditCost({ creditSystem: creditsFeature, featureId: featureId, amount: randomVal, diff --git a/server/tests/advanced/usage/usage4.test.ts b/server/tests/advanced/usage/usage4.test.ts index 3a3d6f1a8..7199ecc5b 100644 --- a/server/tests/advanced/usage/usage4.test.ts +++ b/server/tests/advanced/usage/usage4.test.ts @@ -105,7 +105,7 @@ describe(`${chalk.yellowBright("usage4: GPU starter annual")}`, () => { .toNumber(); const featureId = i % 2 === 0 ? TestFeature.Action1 : TestFeature.Action2; - const creditsUsed = getCreditCost({ + const creditsUsed = await getCreditCost({ creditSystem: creditsFeature, featureId: featureId, amount: randomVal, diff --git a/server/tests/balances/check/credit-systems/credit-systems1.test.ts b/server/tests/balances/check/credit-systems/credit-systems1.test.ts index 22f1a2fef..6fe182bee 100644 --- a/server/tests/balances/check/credit-systems/credit-systems1.test.ts +++ b/server/tests/balances/check/credit-systems/credit-systems1.test.ts @@ -71,7 +71,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses required_balance: requiredActionUnits, })) as unknown as CheckResponseV2; - const creditCost = getCreditCost({ + const creditCost = await getCreditCost({ featureId: action, creditSystem: creditFeature!, amount: requiredActionUnits, @@ -172,7 +172,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses required_balance: requiredAction1Units, })) as unknown as CheckResponseV0; - const meteredCost = getCreditCost({ + const meteredCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: requiredAction1Units, @@ -196,7 +196,7 @@ describe(`${chalk.yellowBright("credit-systems1: test /check on action that uses required_balance: requiredAction2Units, })) as unknown as CheckResponseV0; - const meteredCost = getCreditCost({ + const meteredCost = await getCreditCost({ featureId: TestFeature.Action2, creditSystem: creditFeature!, amount: requiredAction2Units, diff --git a/server/tests/balances/check/send-event/send-event3.test.ts b/server/tests/balances/check/send-event/send-event3.test.ts index 866be0b0b..4d84de2ca 100644 --- a/server/tests/balances/check/send-event/send-event3.test.ts +++ b/server/tests/balances/check/send-event/send-event3.test.ts @@ -93,7 +93,7 @@ describe(`${chalk.yellowBright("send-event3: Testing check with track, credit sy send_event: true, })) as unknown as CheckResponseV2; - const creditCost = getCreditCost({ + const creditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 10, @@ -156,7 +156,7 @@ describe(`${chalk.yellowBright("send-event3: Testing check with track, credit sy allowed: true, customer_id: customerId, feature_id: TestFeature.Credits, - required_balance: getCreditCost({ + required_balance: await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 10, @@ -167,7 +167,7 @@ describe(`${chalk.yellowBright("send-event3: Testing check with track, credit sy test("should check with track and deduct from credits", async () => { const value = 2.5; - const creditCost = getCreditCost({ + const creditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: value, diff --git a/server/tests/balances/check/send-event/send-event4.test.ts b/server/tests/balances/check/send-event/send-event4.test.ts index aa1a91409..05ce02f20 100644 --- a/server/tests/balances/check/send-event/send-event4.test.ts +++ b/server/tests/balances/check/send-event/send-event4.test.ts @@ -82,7 +82,7 @@ describe(`${chalk.yellowBright("send-event4: Testing check with track, unlimited send_event: true, }); - const requiredBalance = getCreditCost({ + const requiredBalance = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: ctx.features.find((f) => f.id === TestFeature.Credits)!, amount: 1000, diff --git a/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts b/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts index 85399f694..5336d752e 100644 --- a/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts +++ b/server/tests/integration/balances/auto-topup/auto-topup-credit-systems.test.ts @@ -77,7 +77,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup cs1: action track depletes cre // Track 845 units → 845 × 0.2 = 169 credits deducted // Balance: 200 - 169 = 31 → strictly above threshold (30) → does NOT trigger // (exact threshold uses <= in code, so landing on 30 would fire auto top-up) - const action1Cost = getCreditCost({ + const action1Cost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 845, @@ -98,7 +98,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup cs1: action track depletes cre // Track 10 units of action1 → 10 × 0.2 = 2 credits // Balance: 31 - 2 = 29 → 29 <= threshold → auto top-up fires → 29 + 100 = 129 - const action1CostSmall = getCreditCost({ + const action1CostSmall = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 10, @@ -169,7 +169,7 @@ test.concurrent(`${chalk.yellowBright("auto-topup cs2: action track depletes cre // Action1 costs 0.2 credits per unit // Track 900 units of action1 → 900 × 0.2 = 180 credits deducted // Balance: 200 - 180 = 20 → auto top-up fires - const action1Cost = getCreditCost({ + const action1Cost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: 900, diff --git a/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts index 94487bbe0..988e87ed8 100644 --- a/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts +++ b/server/tests/integration/balances/check/spend-limit/check-entity-product-spend-limit.test.ts @@ -362,7 +362,7 @@ test.concurrent(`${chalk.yellowBright("check-entity-product-spend-limit5: credit const creditsFeature = ctx.features.find( (f) => f.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts b/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts index 83d0f53c8..dab02bfdf 100644 --- a/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/check/spend-limit/check-per-entity-spend-limit.test.ts @@ -210,7 +210,7 @@ test.concurrent(`${chalk.yellowBright("check-per-entity-spend-limit4: credit-sys const creditsFeature = ctx.features.find( (f) => f.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts b/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts index 3c4598022..b60c09c95 100644 --- a/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts +++ b/server/tests/integration/balances/lock/check-with-lock-credit-system.test.ts @@ -131,12 +131,12 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-2: cross-boundary lock=8 c }); const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const lockCreditCost = getCreditCost({ + const lockCreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 3, // overflow during lock: 8 - 5 remaining = 3 }); - const extraCreditCost = getCreditCost({ + const extraCreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 4, // confirm delta: 12 - 8 = 4 more units @@ -289,7 +289,7 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-4: lock within action1, co // Lock deducted 10 from action1 (→90). Confirm delta=+105: // exhaust remaining 90 from action1 (→0), then 15 overflow → 15×0.2=3 credits. const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const overflowCreditCost = getCreditCost({ + const overflowCreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 15, @@ -628,12 +628,12 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-9: cross-boundary lock=8 c // Lock deducted: 5 from action1 + 3 overflow (0.6 credits). // Confirm delta = 20 - 8 = 12 more units, action1 is already 0, all go to credits: 12×0.2=2.4. const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const lockOverflowCost = getCreditCost({ + const lockOverflowCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 3, }); - const confirmExtraCost = getCreditCost({ + const confirmExtraCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 12, @@ -721,7 +721,7 @@ test.concurrent(`${chalk.yellowBright("lock-credit CS-10: confirm no override_va // Balances unchanged from what the lock left const creditFeature = features.find((f) => f.id === TestFeature.Credits)!; - const lockOverflowCost = getCreditCost({ + const lockOverflowCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature, amount: 3, // overflow during lock: 8 - 5 remaining = 3 diff --git a/server/tests/integration/balances/track/basic/track-credit-system.test.ts b/server/tests/integration/balances/track/basic/track-credit-system.test.ts index 7cd345c7d..a84933213 100644 --- a/server/tests/integration/balances/track/basic/track-credit-system.test.ts +++ b/server/tests/integration/balances/track/basic/track-credit-system.test.ts @@ -88,7 +88,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system2: track metered featu expect(customerBefore.features[TestFeature.Credits].balance).toBe(200); const action1Value = 50.25; - const expectedAction1CreditCost = getCreditCost({ + const expectedAction1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: action1Value, @@ -108,7 +108,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system2: track metered featu }); const action2Value = 33.67; - const expectedAction2CreditCost = getCreditCost({ + const expectedAction2CreditCost = await getCreditCost({ featureId: TestFeature.Action2, creditSystem: creditFeature!, amount: action2Value, @@ -209,7 +209,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde const deduct2 = 80; const remainingAction1 = 100 - deduct1; const overflowAmount = deduct2 - remainingAction1; - const creditCostForOverflow = getCreditCost({ + const creditCostForOverflow = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAmount, @@ -244,7 +244,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system3: test deduction orde const creditsBefore = customer2.features[TestFeature.Credits].balance; const deduct3 = 50.75; - const creditCost3 = getCreditCost({ + const creditCost3 = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: deduct3, @@ -367,13 +367,13 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with const overflowAction1 = deduct2 - remainingAction1; const overflowAction3 = deduct2 - remainingAction3; - const creditCostAction1 = getCreditCost({ + const creditCostAction1 = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAction1, }); - const creditCostAction3 = getCreditCost({ + const creditCostAction3 = await getCreditCost({ featureId: TestFeature.Action3, creditSystem: credit2Feature!, amount: overflowAction3, @@ -419,13 +419,13 @@ test.concurrent(`${chalk.yellowBright("track-credit-system4: test deduction with const deduct3 = 40.25; - const creditCostAction1Deduct3 = getCreditCost({ + const creditCostAction1Deduct3 = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: deduct3, }); - const creditCostAction3Deduct3 = getCreditCost({ + const creditCostAction3Deduct3 = await getCreditCost({ featureId: TestFeature.Action3, creditSystem: credit2Feature!, amount: deduct3, @@ -556,7 +556,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde const deduct2 = 80; const remainingAction1 = 100 - deduct1; const overflowAmount = deduct2 - remainingAction1; - const creditCostForOverflow = getCreditCost({ + const creditCostForOverflow = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAmount, @@ -596,7 +596,7 @@ test.concurrent(`${chalk.yellowBright("track-credit-system5: test deduction orde const creditsBefore = customer2.features[TestFeature.Credits].balance; const deduct3 = 50.75; - const creditCost3 = getCreditCost({ + const creditCost3 = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: deduct3, 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..47dc727f2 100644 --- a/server/tests/integration/balances/track/basic/track-deductions.test.ts +++ b/server/tests/integration/balances/track/basic/track-deductions.test.ts @@ -304,7 +304,7 @@ test.concurrent( }); const overflowAmount = 50; - const expectedCreditCost = getCreditCost({ + const expectedCreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditFeature!, amount: overflowAmount, 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..fe7b3bc8e --- /dev/null +++ b/server/tests/integration/balances/track/basic/track-tokens.test.ts @@ -0,0 +1,349 @@ +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"; +import { getCreditCost } from "@/internal/features/creditSystemUtils.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, + ); + + 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 getCreditCost({ + featureId: aiCreditFeature!.id, + creditSystem: aiCreditFeature!, + modelName: modelId, + tokens: { input: inputTokens, output: outputTokens }, + }); + + const trackRes: TrackResponseV2 = await autumnV2.post("/trackTokens", { + 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("/trackTokens", { + 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, + ); + + const inputTokens = 2000; + const outputTokens = 1000; + const modelId = "anthropic/claude-sonnet-4-20250514"; + + const expectedCost = await getCreditCost({ + featureId: aiCreditFeature!.id, + creditSystem: aiCreditFeature!, + modelName: modelId, + tokens: { input: inputTokens, output: outputTokens }, + }); + + const trackRes: TrackResponseV2 = await autumnV2.post("/trackTokens", { + 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("/trackTokens", { + 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("/trackTokens", { + 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, + ); + + // 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 getCreditCost({ + featureId: aiCreditFeature!.id, + creditSystem: aiCreditFeature!, + modelName: modelId, + tokens: { input: inputTokens, output: outputTokens }, + }); + + const trackRes: TrackResponseV2 = await autumnV2.post("/trackTokens", { + 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("/trackTokens", { + 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, + ); + + // First track: custom/internal-model (input_cost=5, output_cost=15, markup=0%) + const cost1 = await getCreditCost({ + featureId: aiCreditFeature!.id, + creditSystem: aiCreditFeature!, + modelName: "custom/internal-model", + tokens: { input: 5000, output: 2000 }, + }); + + await autumnV2.post("/trackTokens", { + 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 getCreditCost({ + featureId: aiCreditFeature!.id, + creditSystem: aiCreditFeature!, + modelName: "custom/marked-up-model", + tokens: { input: 3000, output: 1000 }, + }); + + await autumnV2.post("/trackTokens", { + 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, + }); +}); diff --git a/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts b/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts index 6e3242fad..636513eb9 100644 --- a/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts +++ b/server/tests/integration/balances/track/overage-allowed/track-overage-allowed-consumable.test.ts @@ -368,7 +368,7 @@ test.concurrent(`${chalk.yellowBright("track-consumable-overage-8: credit system (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts index 64e0b3622..25c11d2ec 100644 --- a/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-customer-spend-limit.test.ts @@ -430,7 +430,7 @@ test.concurrent(`${chalk.yellowBright("track-customer-spend-limit6: credit-syste const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts index 85c5ab273..79fae21fa 100644 --- a/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-entity-product-spend-limit.test.ts @@ -463,7 +463,7 @@ test.concurrent(`${chalk.yellowBright("track-entity-product-spend-limit5: credit const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts index e61f687bd..89822ddad 100644 --- a/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-per-entity-spend-limit.test.ts @@ -489,7 +489,7 @@ test.concurrent(`${chalk.yellowBright("track-per-entity-spend-limit5: credit-sys const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts b/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts index 00422b328..72ebef0b4 100644 --- a/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts +++ b/server/tests/integration/balances/track/spend-limit/track-postgres-entity-spend-limit.test.ts @@ -216,7 +216,7 @@ test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit3: credi const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, @@ -334,7 +334,7 @@ test.concurrent(`${chalk.yellowBright("track-postgres-entity-spend-limit4: prepa const creditsFeature = ctx.features.find( (feature) => feature.id === TestFeature.Credits, )!; - const action1CreditCost = getCreditCost({ + const action1CreditCost = await getCreditCost({ featureId: TestFeature.Action1, creditSystem: creditsFeature, amount: 1, diff --git a/server/tests/setup/v2Features.ts b/server/tests/setup/v2Features.ts index 26b8aa106..2a4150b83 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,9 @@ 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) } export const getFeatures = ({ orgId }: { orgId: string }) => ({ @@ -121,4 +125,37 @@ 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, + }, + }, + }), }); diff --git a/server/tests/utils/fixtures/db/entitlements.ts b/server/tests/utils/fixtures/db/entitlements.ts index f8c05e8e2..b8be6cafd 100644 --- a/server/tests/utils/fixtures/db/entitlements.ts +++ b/server/tests/utils/fixtures/db/entitlements.ts @@ -21,6 +21,7 @@ const create = ({ intervalCount = 1, entityFeatureId = null, rollover = null, + modelMarkups = null, }: { id?: string; featureId: string; @@ -33,6 +34,10 @@ const create = ({ intervalCount?: number; entityFeatureId?: string | null; rollover?: RolloverConfig | null; + modelMarkups?: Record< + string, + { markup: number; input_cost?: number; output_cost?: number } + > | null; }) => ({ id: id ?? `ent_${featureId}_${crypto.randomUUID().slice(0, 8)}`, created_at: Date.now(), @@ -54,6 +59,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..695a5053a 100644 --- a/server/tests/utils/fixtures/db/features.ts +++ b/server/tests/utils/fixtures/db/features.ts @@ -9,12 +9,17 @@ const create = ({ name, type = FeatureType.Metered, config = {}, + modelMarkups = null, }: { id: string; internalId?: string; name: string; type?: FeatureType; config?: Record; + modelMarkups?: Record< + string, + { markup: number; input_cost?: number; output_cost?: number } + > | null; }) => ({ internal_id: internalId ?? `internal_${id}`, org_id: "org_test", @@ -27,6 +32,7 @@ const create = ({ display: null, archived: false, event_names: [], + model_markups: modelMarkups ?? null, }); // ═══════════════════════════════════════════════════════════════════ diff --git a/shared/api/balances/track/trackTokensParams.ts b/shared/api/balances/track/trackTokensParams.ts new file mode 100644 index 000000000..3d79830a5 --- /dev/null +++ b/shared/api/balances/track/trackTokensParams.ts @@ -0,0 +1,45 @@ +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 if omitted.", + }), + model_id: z.string().meta({ + description: "The AI model name with provider prefix (e.g., 'anthropic/claude-opus-4-6').", + }), + input_tokens: z.number().int().nonnegative().meta({ + description: "Number of input tokens consumed.", + }), + output_tokens: z.number().int().nonnegative().meta({ + description: "Number of output tokens consumed.", + }), + 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..bc3b92cfc 100644 --- a/shared/api/features/apiFeatureV1.ts +++ b/shared/api/features/apiFeatureV1.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ModelMarkupsSchema } from "../../models/featureModels/featureConfig/creditConfig"; import { FeatureType } from "../../models/featureModels/featureEnums"; export const ApiFeatureV1Schema = z.object({ @@ -12,7 +13,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 +43,10 @@ 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 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..101e9cc57 100644 --- a/shared/api/features/changes/V1.2_FeatureChange.ts +++ b/shared/api/features/changes/V1.2_FeatureChange.ts @@ -83,7 +83,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..69fee0f97 100644 --- a/shared/api/features/crud/common/baseFeatureParamsV1.ts +++ b/shared/api/features/crud/common/baseFeatureParamsV1.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { ModelMarkupsSchema } from "../../../../models/featureModels/featureConfig/creditConfig"; import { FeatureType } from "../../../../models/featureModels/featureEnums"; import { idRegex } from "../../../../utils/utils"; @@ -43,8 +44,13 @@ 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 percentages for AI credit systems. Maps model IDs to their markup configuration.", + }), + event_names: z.array(z.string()).optional(), }); 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/index.ts b/shared/index.ts index 437e4bf13..b93810450 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -195,6 +195,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"; diff --git a/shared/models/aiModels/modelsDevTypes.ts b/shared/models/aiModels/modelsDevTypes.ts new file mode 100644 index 000000000..35f7bb30e --- /dev/null +++ b/shared/models/aiModels/modelsDevTypes.ts @@ -0,0 +1,17 @@ +/** Shape of a single model from the models.dev API */ +export interface ModelsDevModel { + id: string; + name: string; + release_date?: string; + cost: { + input: number; + output: number; + }; +} + +/** 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..5bc5b9b51 100644 --- a/shared/models/featureModels/featureConfig/creditConfig.ts +++ b/shared/models/featureModels/featureConfig/creditConfig.ts @@ -11,12 +11,23 @@ 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), }); +export const ModelMarkupsSchema = z + .record( + z.string(), // Represents the model name in "provider/model" format, e.g. "anthropic/claude-2" + z.object({ + markup: z.number().min(0), // percentage markup, e.g. 20 for 20% + 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; 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..69e0ca6ad 100644 --- a/shared/utils/agentTypes.ts +++ b/shared/utils/agentTypes.ts @@ -27,7 +27,8 @@ export type AgentFeatureType = | "boolean" | "single_use" | "continuous_use" - | "credit_system"; + | "credit_system" + | "ai_credit_system"; export interface AgentFeature { id: string; @@ -41,6 +42,14 @@ export interface AgentFeature { metered_feature_id: string; credit_cost: number; }> | null; + model_markups?: Record< + string, + { + markup: number; + input_cost?: number; + output_cost?: number; + } + > | null; } export interface AgentProductItem { @@ -84,6 +93,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; } @@ -129,6 +140,7 @@ export function agentFeatureToFeature(agentFeature: AgentFeature): Feature { display: agentFeature.display ?? undefined, archived: false, event_names: [], + model_markups: agentFeature.model_markups ?? null, }; } @@ -217,6 +229,9 @@ export function featureToAgentFeature(feature: Feature): AgentFeature { }), ); } + if (feature.type === FeatureType.AiCreditSystem) { + agentFeature.model_markups = feature.model_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..dff59220a 100644 --- a/shared/utils/featureUtils/apiFeatureToDbFeature.ts +++ b/shared/utils/featureUtils/apiFeatureToDbFeature.ts @@ -72,6 +72,7 @@ export const apiFeatureToDbFeature = ({ config: newConfig, archived: apiFeature.archived ?? originalFeature?.archived ?? false, event_names: [], + model_markups: null, } satisfies Feature; }; @@ -150,6 +151,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 +169,7 @@ export const featureV1ToDbFeature = ({ ? apiFeature.archived : (originalFeature?.archived ?? false), event_names: eventNames ?? [], + model_markups: modelMarkups, } satisfies Feature; }; @@ -192,6 +197,7 @@ export const dbToApiFeatureV1 = ({ type: dbFeature.type, consumable: dbFeature.type === FeatureType.CreditSystem || + dbFeature.type === FeatureType.AiCreditSystem || dbFeature.config?.usage_type === FeatureUsageType.Single, credit_schema: Array.isArray(dbFeature.config?.schema) @@ -200,6 +206,7 @@ export const dbToApiFeatureV1 = ({ credit_cost: schema.credit_amount, })) : undefined, + model_markups: dbFeature.model_markups ?? undefined, event_names: Array.isArray(dbFeature.event_names) ? dbFeature.event_names : [], 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/productDisplayUtils.ts b/shared/utils/productDisplayUtils.ts index 1cba7d5a5..7d32f4dc2 100644 --- a/shared/utils/productDisplayUtils.ts +++ b/shared/utils/productDisplayUtils.ts @@ -54,7 +54,9 @@ const getIncludedUsageText = (item: ProductItem, feature: Feature): string => { if (item.included_usage === Infinite) { return `Unlimited ${featureName}`; } - + if (feature.type === FeatureType.AiCreditSystem) { + return `$${numberWithCommas(item.included_usage ?? 0)} of ${featureName}`; + } if (nullish(item.included_usage) || item.included_usage === 0) { return `0 ${featureName}`; } @@ -219,14 +221,20 @@ export const getFeaturePriceItemDisplay = ({ // Build included usage string (e.g., "100 credits") const includedUsage = item.included_usage as number | null; const hasIncludedUsage = notNullish(includedUsage) && includedUsage > 0; + const isAiCreditSystem = feature.type === FeatureType.AiCreditSystem; const includedFeatureName = getFeatureName({ feature, units: item.included_usage, }); - const includedUsageStr = hasIncludedUsage - ? `${numberWithCommas(includedUsage)} ${includedFeatureName}` - : ""; + let includedUsageStr = ""; + if (hasIncludedUsage) { + if (isAiCreditSystem) { + 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) { + 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) { + 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/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..b29ae767c 100644 --- a/vite/src/utils/product/product-item/formatProductItem.ts +++ b/vite/src/utils/product/product-item/formatProductItem.ts @@ -140,13 +140,15 @@ const getFeatureString = ({ item: ProductItem; features: Feature[]; }) => { - const feature = features.find((f: Feature) => f.id == item.feature_id); + // This function isn't exported or used + // Just updated it to account for the new AI Credit System features, but it may be unused and can probably be deleted + 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 +157,14 @@ const getFeatureString = ({ intervalCount: item.interval_count ?? undefined, }); + const isAiCreditSystem = feature?.type === FeatureType.AiCreditSystem; + if (isAiCreditSystem) { + 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 +181,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/products/features/components/CreateFeatureSheet.tsx b/vite/src/views/products/features/components/CreateFeatureSheet.tsx index fed83c894..527f6a0ea 100644 --- a/vite/src/views/products/features/components/CreateFeatureSheet.tsx +++ b/vite/src/views/products/features/components/CreateFeatureSheet.tsx @@ -70,6 +70,8 @@ function CreateFeatureSheet({ setLoading(false); } else { try { + const isAiCreditSystem = feature.type === FeatureType.AiCreditSystem; + const { data: createdFeature } = await FeatureService.createFeature( axiosInstance, { @@ -77,12 +79,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, - }), - ), + model_markups: feature.model_markups ?? undefined, + credit_schema: isAiCreditSystem + ? undefined + : feature.config?.schema?.map((x: CreditSchemaItem) => ({ + metered_feature_id: x.metered_feature_id, + credit_cost: x.credit_amount, + })), event_names: feature.event_names, }, ); @@ -119,13 +122,6 @@ function CreateFeatureSheet({ return ( - {/* {!isControlled && ( - - - - )} */} ({ + metered_feature_id: item.metered_feature_id, + credit_cost: item.credit_amount, + })), }); await refetch(); diff --git a/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx b/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx new file mode 100644 index 000000000..10bb29b0e --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/AiCreditSchema.tsx @@ -0,0 +1,191 @@ +import type { CreateFeature } from "@autumn/shared"; +import { PlusIcon, 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 { SearchableSelect } from "@/components/v2/selects/SearchableSelect"; +import { useAiCreditSchema } from "../hooks/useAiCreditSchema"; +import { AiCreditSchemaRow } from "./AiCreditSchemaRow"; + +interface AiCreditSchemaProps { + creditSystem: CreateFeature; + setCreditSystem: ( + creditSystem: CreateFeature | ((prev: CreateFeature) => CreateFeature), + ) => void; +} + +export function AiCreditSchema({ + creditSystem, + setCreditSystem, +}: AiCreditSchemaProps) { + const { + providers, + modelsLoading, + modelMarkups, + defaultMarkup, + providerGroups, + activeProviderKeys, + availableProviders, + handleModelChange, + handleMarkupChange, + handleDefaultMarkupChange, + handleCostChange, + handleRemoveModel, + handleRemoveProvider, + addProvider, + addModelToProvider, + } = useAiCreditSchema({ creditSystem, setCreditSystem }); + + return ( +
+
+ Default Markup % + + handleDefaultMarkupChange(Number(e.target.value) || 0) + } + onBlur={(e) => handleDefaultMarkupChange(Number(e.target.value) || 0)} + placeholder="0" + className="w-24" + /> +
+
+ {activeProviderKeys.map((providerKey) => { + const provider = providers[providerKey]; + const modelFullIds = providerGroups[providerKey] ?? []; + const providerName = + provider?.name ?? + providerKey.charAt(0).toUpperCase() + providerKey.slice(1); + + return ( +
+
+ + {providerName} + {providerKey !== "custom" && ( + {providerName} + )} + + } + onClick={() => handleRemoveProvider(providerKey)} + /> +
+ +
+ {providerKey === "custom" && ( +

+ In your API tracking, use the format{" "} + + custom/{"modelId"} + +

+ )} + +
+
+ Model +
+
+ {providerKey === "custom" ? "In $/M" : "Cost In"} +
+
+ {providerKey === "custom" ? "Out $/M" : "Cost Out"} +
+
+ Markup % +
+
+
+ + {modelFullIds.map((fullId) => { + const [, ...parts] = fullId.split("/"); + const modelKey = parts.join("/"); + const isCustom = providerKey === "custom"; + return ( + + handleModelChange(providerKey, oldKey, newKey) + } + onMarkupChange={(key, newMarkup) => + handleMarkupChange(providerKey, key, newMarkup) + } + onCostChange={(key, field, value) => + handleCostChange(providerKey, key, field, value) + } + onRemove={(key) => handleRemoveModel(providerKey, key)} + /> + ); + })} + + addModelToProvider(providerKey)} + className="w-fit mt-0.5" + icon={} + disabled={ + providerKey === "custom" + ? false + : Object.keys(provider?.models ?? {}).length === + modelFullIds.length + } + > + Add model + +
+
+ ); + })} +
+ +

All prices in $/M tokens

+ +
e.stopPropagation()}> + provider.id} + getOptionLabel={(provider) => provider.name} + renderValue={() => ( + + + Add provider + + )} + placeholder="Add provider" + searchable + searchPlaceholder="Search providers..." + emptyText="No providers available" + disabled={modelsLoading} + /> +
+
+ ); +} diff --git a/vite/src/views/products/features/credit-systems/components/AiCreditSchemaRow.tsx b/vite/src/views/products/features/credit-systems/components/AiCreditSchemaRow.tsx new file mode 100644 index 000000000..b0b2c7b29 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/AiCreditSchemaRow.tsx @@ -0,0 +1,172 @@ +import type { ModelsDevProvider } from "@autumn/shared"; +import { X } from "lucide-react"; +import { useState } from "react"; +import { IconButton } from "@/components/v2/buttons/IconButton"; +import { Input } from "@/components/v2/inputs/Input"; +import { AiModelSelectDropdown } from "./AiModelSelectDropdown"; + +interface AiCreditSchemaRowProps { + modelKey: string; + markup: number; + provider: ModelsDevProvider; + isLoading: boolean; + isCustom?: boolean; + inputCost?: number; + outputCost?: number; + onModelChange: (oldModelKey: string, newModelKey: string) => void; + onMarkupChange: (modelKey: string, markup: number) => void; + onCostChange?: ( + modelKey: string, + field: "input_cost" | "output_cost", + value: number, + ) => void; + onRemove: (modelKey: string) => void; +} + +function formatCost(value: number | null | undefined): string { + if (value == null) return "–"; + return value.toFixed(2); +} + +export function AiCreditSchemaRow({ + modelKey, + markup, + provider, + isLoading, + isCustom, + inputCost, + outputCost, + onModelChange, + onMarkupChange, + onCostChange, + onRemove, +}: AiCreditSchemaRowProps) { + const model = provider.models[modelKey]; + + const actualInput = isCustom + ? (inputCost ?? 0) + : model + ? (model.cost.input ?? 0) + : null; + const actualOutput = isCustom + ? (outputCost ?? 0) + : model + ? (model.cost.output ?? 0) + : null; + const multiplier = 1 + markup / 100; + const userInput = actualInput != null ? actualInput * multiplier : null; + const userOutput = actualOutput != null ? actualOutput * multiplier : null; + + const [localModelName, setLocalModelName] = useState(modelKey); + + return ( +
+
+ {/* Model Name */} +
+ {isCustom ? ( + setLocalModelName(e.target.value)} + onBlur={() => { + if (localModelName !== modelKey) { + onModelChange(modelKey, localModelName); + } + }} + placeholder="my-model-id" + className="w-full" + /> + ) : ( + + onModelChange(modelKey, newModelKey) + } + provider={provider} + isLoading={isLoading} + /> + )} +
+ + {/* Input Cost */} +
+ {isCustom ? ( + + onCostChange?.( + modelKey, + "input_cost", + Number(e.target.value) || 0, + ) + } + placeholder="0" + className="w-full" + /> + ) : ( +
+ {formatCost(actualInput)} +
+ )} +
+ + {/* Output Cost */} +
+ {isCustom ? ( + + onCostChange?.( + modelKey, + "output_cost", + Number(e.target.value) || 0, + ) + } + placeholder="0" + className="w-full" + /> + ) : ( +
+ {formatCost(actualOutput)} +
+ )} +
+ + {/* Markup % */} +
+ + onMarkupChange(modelKey, Number(e.target.value) || 0) + } + placeholder="0" + className="w-full" + /> +
+ + {/* Remove Button */} + } + onClick={() => onRemove(modelKey)} + className="shrink-0" + /> +
+ + {/* User Pays Info */} + {(userInput != null || userOutput != null) && ( +
+ User pays: ${formatCost(userInput)} in / ${formatCost(userOutput)} out + $/M +
+ )} +
+ ); +} 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..6512dae37 --- /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 models..." : "Select model"} + + ) + } + placeholder={isLoading ? "Loading models..." : "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..025640ea2 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/components/ClassicCreditSchema.tsx @@ -0,0 +1,161 @@ +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 { useEffect, useMemo, useRef } from "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 { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { FeatureSelectDropdown } from "./FeatureSelectDropdown"; + +interface ClassicCreditSchemaProps { + creditSystem: CreateFeature; + setCreditSystem: (creditSystem: CreateFeature) => void; +} + +export function ClassicCreditSchema({ + creditSystem, + setCreditSystem, +}: ClassicCreditSchemaProps) { + const { features } = useFeaturesQuery(); + const schema = creditSystem.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(); + } + return nextKeys; + }, [schema.length]); + + useEffect(() => { + schemaKeysRef.current = schemaKeys; + }, [schemaKeys]); + + const allMeteredFeatures = features.filter( + (feature: Feature) => feature.type === FeatureType.Metered, + ); + + 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 = () => { + schemaKeysRef.current = [...schemaKeysRef.current, crypto.randomUUID()]; + 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 item in the credit system"); + return; + } + const nextKeys = [...schemaKeysRef.current]; + nextKeys.splice(index, 1); + schemaKeysRef.current = nextKeys; + const newSchema = [...schema]; + newSchema.splice(index, 1); + setCreditSystem({ + ...creditSystem, + config: { ...creditSystem.config, schema: newSchema }, + }); + }; + + return ( +
+
+ Metered Feature + Credit Cost +
+ +
+ {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 + +
+ ); +} 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..cb3a082fe 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,136 @@ -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 { type CreateFeature, FeatureType, type ModelsDevProvider } from "@autumn/shared"; +import { useMemo } from "react"; +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 { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; +import { AiCreditSchema } from "./AiCreditSchema"; +import { ClassicCreditSchema } from "./ClassicCreditSchema"; + +type CreditSchemaMode = "classic" | "ai"; + +const DEFAULT_AI_MODEL_COMPANIES = ["anthropic", "google", "openai"] as const; + +const getReleaseDateMs = (releaseDate?: string) => { + if (!releaseDate) return -1; + const timestamp = Date.parse(releaseDate); + return Number.isNaN(timestamp) ? -1 : timestamp; +}; + +function getDefaultModelMarkups( + providers: Record, +): Record { + const result: Record = {}; + const preferredProvider = + providers["openrouter"] ?? Object.values(providers)[0]; + if (!preferredProvider) return result; + + const providerKey = preferredProvider.id; + for (const company of DEFAULT_AI_MODEL_COMPANIES) { + const companyModels = Object.entries(preferredProvider.models).filter( + ([key]) => key.startsWith(company), + ); + + const latestModel = companyModels.reduce< + [string, ModelsDevProvider["models"][string]] | null + >((currentLatest, candidate) => { + if (!currentLatest) return candidate; + + const currentRelease = getReleaseDateMs(currentLatest[1].release_date); + const candidateRelease = getReleaseDateMs(candidate[1].release_date); + + return candidateRelease > currentRelease ? candidate : currentLatest; + }, null); + + if (!latestModel) continue; + + const [modelKey] = latestModel; + result[`${providerKey}/${modelKey}`] = { + markup: 0, + }; + } + return result; +} interface CreditSystemSchemaProps { creditSystem: CreateFeature; setCreditSystem: (creditSystem: CreateFeature) => void; + disableModeSwitch?: boolean; } export function CreditSystemSchema({ creditSystem, setCreditSystem, + disableModeSwitch = false, }: CreditSystemSchemaProps) { - const { features } = useFeaturesQuery(); + const { providers } = useModelsDevPricing(); - const schema = creditSystem.config?.schema || []; + const mode: CreditSchemaMode = + creditSystem.type === FeatureType.AiCreditSystem ? "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") { + const modelMarkups = getDefaultModelMarkups(providers); + setCreditSystem({ + ...creditSystem, + type: FeatureType.AiCreditSystem, + config: { ...creditSystem.config, schema: [] }, + model_markups: Object.keys(modelMarkups).length > 0 ? modelMarkups : {}, + }); + } else { + setCreditSystem({ + ...creditSystem, + type: FeatureType.CreditSystem, + config: { + ...creditSystem.config, + schema: [ + { metered_feature_id: "", feature_amount: 1, credit_amount: 0 }, + ], + }, + model_markups: null, + }); } - 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/UpdateCreditSystemSheet.tsx b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx index 16d280699..de9b800bc 100644 --- a/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx +++ b/vite/src/views/products/features/credit-systems/components/UpdateCreditSystemSheet.tsx @@ -59,6 +59,7 @@ function UpdateCreditSystemSheet({ type: selectedCreditSystem.type, config: selectedCreditSystem.config, event_names: selectedCreditSystem.event_names, + model_markups: selectedCreditSystem.model_markups, }); } }, [open, selectedCreditSystem]); @@ -74,6 +75,8 @@ function UpdateCreditSystemSheet({ setLoading(true); try { + const isAiCreditSystem = creditSystem.type === FeatureType.AiCreditSystem; + await FeatureService.updateFeature( axiosInstance, selectedCreditSystem.id, @@ -81,12 +84,14 @@ function UpdateCreditSystemSheet({ 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), - }), - ), + model_markups: creditSystem.model_markups ?? undefined, + credit_schema: isAiCreditSystem + ? undefined + : creditSystem.config?.schema?.map((x: CreditSchemaItem) => ({ + metered_feature_id: x.metered_feature_id, + credit_cost: + x.credit_amount != null ? Number(x.credit_amount) : 0, + })), event_names: creditSystem.event_names, display: undefined, }, @@ -95,7 +100,6 @@ function UpdateCreditSystemSheet({ await refetch(); toast.success("Credit system updated successfully"); - // Call onSuccess with old and new IDs if (onSuccess) { onSuccess( selectedCreditSystem.id, @@ -105,7 +109,6 @@ function UpdateCreditSystemSheet({ setOpen(false); } catch (error: unknown) { - console.log(error); toast.error( getBackendErr(error as AxiosError, "Failed to update credit system"), ); @@ -134,6 +137,7 @@ function UpdateCreditSystemSheet({
diff --git a/vite/src/views/products/features/credit-systems/hooks/useAiCreditSchema.ts b/vite/src/views/products/features/credit-systems/hooks/useAiCreditSchema.ts new file mode 100644 index 000000000..0f3001eb0 --- /dev/null +++ b/vite/src/views/products/features/credit-systems/hooks/useAiCreditSchema.ts @@ -0,0 +1,259 @@ +import type { CreateFeature } from "@autumn/shared"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useModelsDevPricing } from "@/hooks/queries/useAiModelsQuery"; + +type ModelMarkupEntry = { + markup: number; + input_cost?: number; + output_cost?: number; +}; + +type ModelMarkups = Record; + +function groupByProvider(modelMarkups: ModelMarkups) { + const groups: Record = {}; + for (const fullId of Object.keys(modelMarkups)) { + const [providerKey] = fullId.split("/"); + if (!groups[providerKey]) groups[providerKey] = []; + groups[providerKey].push(fullId); + } + return groups; +} + +export function useAiCreditSchema({ + creditSystem, + setCreditSystem, +}: { + creditSystem: CreateFeature; + setCreditSystem: ( + creditSystem: CreateFeature | ((prev: CreateFeature) => CreateFeature), + ) => void; +}) { + const { + providers, + isLoading: modelsLoading, + error: modelsError, + } = useModelsDevPricing(); + + const modelMarkups = creditSystem.model_markups ?? {}; + + const [defaultMarkup, setDefaultMarkup] = useState(0); + const manuallyEditedModels = useRef>(new Set()); + + useEffect(() => { + if (modelsError) { + toast.error("Models.dev pricing is unavailable. Try again later."); + } + }, [modelsError]); + + const providerGroups = useMemo( + () => groupByProvider(modelMarkups), + [modelMarkups], + ); + const activeProviderKeys = Object.keys(providerGroups); + + const availableProviders = useMemo(() => { + const filtered = Object.values(providers).filter( + (provider) => !activeProviderKeys.includes(provider.id), + ); + if (!activeProviderKeys.includes("custom")) { + filtered.push({ id: "custom", name: "Custom", models: {} }); + } + return filtered; + }, [providers, activeProviderKeys]); + + const updateMarkups = useCallback( + (updatedMarkups: ModelMarkups) => { + setCreditSystem((prev) => ({ + ...prev, + model_markups: updatedMarkups, + })); + }, + [setCreditSystem], + ); + + const handleModelChange = useCallback( + (providerKey: string, oldModelKey: string, newModelKey: string) => { + const oldFullId = `${providerKey}/${oldModelKey}`; + const newFullId = `${providerKey}/${newModelKey}`; + if (oldFullId !== newFullId && newFullId in modelMarkups) return; + const updatedMarkups = { ...modelMarkups }; + const oldEntry = updatedMarkups[oldFullId]; + const markup = oldEntry?.markup ?? 0; + + if (manuallyEditedModels.current.has(oldFullId)) { + manuallyEditedModels.current.delete(oldFullId); + manuallyEditedModels.current.add(newFullId); + } + + delete updatedMarkups[oldFullId]; + if (providerKey === "custom") { + updatedMarkups[newFullId] = { + markup, + input_cost: oldEntry?.input_cost ?? 0, + output_cost: oldEntry?.output_cost ?? 0, + }; + } else { + updatedMarkups[newFullId] = { markup }; + } + updateMarkups(updatedMarkups); + }, + [modelMarkups, providers, updateMarkups], + ); + + const handleMarkupChange = useCallback( + (providerKey: string, modelKey: string, markup: number) => { + const fullId = `${providerKey}/${modelKey}`; + manuallyEditedModels.current.add(fullId); + updateMarkups({ + ...modelMarkups, + [fullId]: { ...modelMarkups[fullId], markup }, + }); + }, + [modelMarkups, updateMarkups], + ); + + const handleDefaultMarkupChange = useCallback( + (value: number) => { + setDefaultMarkup(value); + const updatedMarkups = { ...modelMarkups }; + for (const modelId of Object.keys(updatedMarkups)) { + if (!manuallyEditedModels.current.has(modelId)) { + updatedMarkups[modelId] = { + ...updatedMarkups[modelId], + markup: value, + }; + } + } + updateMarkups(updatedMarkups); + }, + [modelMarkups, updateMarkups], + ); + + const handleCostChange = useCallback( + ( + providerKey: string, + modelKey: string, + field: "input_cost" | "output_cost", + value: number, + ) => { + const fullId = `${providerKey}/${modelKey}`; + updateMarkups({ + ...modelMarkups, + [fullId]: { ...modelMarkups[fullId], [field]: value }, + }); + }, + [modelMarkups, updateMarkups], + ); + + const handleRemoveModel = useCallback( + (providerKey: string, modelKey: string) => { + const fullId = `${providerKey}/${modelKey}`; + manuallyEditedModels.current.delete(fullId); + const updatedMarkups = { ...modelMarkups }; + delete updatedMarkups[fullId]; + updateMarkups(updatedMarkups); + }, + [modelMarkups, updateMarkups], + ); + + const handleRemoveProvider = useCallback( + (providerKey: string) => { + const updatedMarkups = { ...modelMarkups }; + for (const fullId of providerGroups[providerKey] ?? []) { + manuallyEditedModels.current.delete(fullId); + delete updatedMarkups[fullId]; + } + updateMarkups(updatedMarkups); + }, + [modelMarkups, providerGroups, updateMarkups], + ); + + const addProvider = useCallback( + (providerKey: string) => { + if (providerKey === "custom") { + const existingKeys = (providerGroups.custom ?? []).map((fullId) => { + const [, ...parts] = fullId.split("/"); + return parts.join("/"); + }); + let counter = 1; + while (existingKeys.includes(`model-${counter}`)) counter++; + updateMarkups({ + ...modelMarkups, + [`custom/model-${counter}`]: { + markup: defaultMarkup, + input_cost: 0, + output_cost: 0, + }, + }); + return; + } + const provider = providers[providerKey]; + if (!provider) return; + const firstModelKey = Object.keys(provider.models)[0]; + if (!firstModelKey) return; + const fullId = `${providerKey}/${firstModelKey}`; + updateMarkups({ + ...modelMarkups, + [fullId]: { markup: defaultMarkup }, + }); + }, + [defaultMarkup, modelMarkups, providerGroups, providers, updateMarkups], + ); + + const addModelToProvider = useCallback( + (providerKey: string) => { + if (providerKey === "custom") { + const existingKeys = (providerGroups.custom ?? []).map((fullId) => { + const [, ...parts] = fullId.split("/"); + return parts.join("/"); + }); + let counter = 1; + while (existingKeys.includes(`model-${counter}`)) counter++; + const fullId = `custom/model-${counter}`; + updateMarkups({ + ...modelMarkups, + [fullId]: { markup: defaultMarkup, input_cost: 0, output_cost: 0 }, + }); + return; + } + const provider = providers[providerKey]; + if (!provider) return; + const usedModelKeys = new Set( + (providerGroups[providerKey] ?? []).map((fullId) => { + const [, ...parts] = fullId.split("/"); + return parts.join("/"); + }), + ); + const nextModelKey = Object.keys(provider.models).find( + (key) => !usedModelKeys.has(key), + ); + if (!nextModelKey) return; + const fullId = `${providerKey}/${nextModelKey}`; + updateMarkups({ + ...modelMarkups, + [fullId]: { markup: defaultMarkup }, + }); + }, + [defaultMarkup, modelMarkups, providerGroups, providers, updateMarkups], + ); + + return { + providers, + modelsLoading, + modelMarkups, + defaultMarkup, + providerGroups, + activeProviderKeys, + availableProviders, + handleModelChange, + handleMarkupChange, + handleDefaultMarkupChange, + handleCostChange, + handleRemoveModel, + handleRemoveProvider, + addProvider, + addModelToProvider, + }; +} 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..32c27e890 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,4 @@ -import type { CreateFeature } from "@autumn/shared"; +import { type CreateFeature, FeatureType } from "@autumn/shared"; export const validateCreditSystem = ( creditSystem: CreateFeature, @@ -7,16 +7,36 @@ export const validateCreditSystem = ( return "Please fill in all fields"; } - if (creditSystem.config.schema.length === 0) { - return "Need at least one metered feature"; + const isAiCreditSystem = creditSystem.type === FeatureType.AiCreditSystem; + + if (isAiCreditSystem) { + if ( + !creditSystem.model_markups || + Object.keys(creditSystem.model_markups).length === 0 + ) + return "Add at least one model markup"; + + for (const [modelId, entry] of Object.entries(creditSystem.model_markups)) { + if (!modelId) return "Select a model for each row"; + if (modelId.startsWith("custom/")) { + const customModelKey = modelId.slice("custom/".length); + if (!customModelKey) 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..fa94fbc2f 100644 --- a/vite/src/views/products/features/feature-list/CreditListColumns.tsx +++ b/vite/src/views/products/features/feature-list/CreditListColumns.tsx @@ -1,11 +1,22 @@ -import type { Feature } from "@autumn/shared"; +import type { Feature, ModelsDevProvider } from "@autumn/shared"; 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 [providerKey, ...modelParts] = fullId.split("/"); + const modelKey = modelParts.join("/"); + return providers[providerKey]?.models[modelKey]?.name ?? fullId; +} + +export const createCreditListColumns = ( + providers: Record, +): ColumnDef[] => [ { size: 150, header: "Name", @@ -43,13 +54,20 @@ export const createCreditListColumns = (): ColumnDef[] => [ 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}
); diff --git a/vite/src/views/products/features/feature-list/FeatureListTable.tsx b/vite/src/views/products/features/feature-list/FeatureListTable.tsx index a9aca0662..4bddbba2a 100644 --- a/vite/src/views/products/features/feature-list/FeatureListTable.tsx +++ b/vite/src/views/products/features/feature-list/FeatureListTable.tsx @@ -4,6 +4,7 @@ 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); @@ -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/plan/components/edit-plan-feature/BillingType.tsx b/vite/src/views/products/plan/components/edit-plan-feature/BillingType.tsx index 91d08183f..165307e2e 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 @@ -1,5 +1,6 @@ import { BillingInterval, + FeatureType, FeatureUsageType, getFeatureName, Infinite, @@ -73,6 +74,7 @@ export function BillingType() { }; const feature = features.find((f) => f.id === item.feature_id); + const isAiCreditSystem = feature?.type === FeatureType.AiCreditSystem; const featureName = getFeatureName({ feature, @@ -104,7 +106,9 @@ export function BillingType() {
Included
- {isConsumable + {isAiCreditSystem + ? "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 +128,9 @@ export function BillingType() {
Priced
- {isConsumable + {isAiCreditSystem + ? "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 95b2d427d..c6563308d 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 @@ -116,6 +116,7 @@ export function EditPlanFeatureSheet({ const feature = getFeature(item?.feature_id ?? "", features); const isFeaturePrice = isFeaturePriceItem(item); + const isAiCreditSystem = feature?.type === FeatureType.AiCreditSystem; // Allow confirming a priced feature that has a $0 tier (valid zero-price config) const isZeroPriceItem = @@ -166,7 +167,7 @@ export function EditPlanFeatureSheet({ - {isFeaturePrice && ( + {isFeaturePrice && !isAiCreditSystem && ( 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 1daaa82fd..8b4528277 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 @@ -1,18 +1,24 @@ +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"; import { BillingInterval, billingToItemInterval, EntInterval, entToItemInterval, + FeatureType, getFeatureName, Infinite, 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 { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; -import { isFeaturePriceItem } from "@/utils/product/getItemType"; -import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext"; import { UsageReset } from "./UsageReset"; export function IncludedUsage() { @@ -24,6 +30,8 @@ export function IncludedUsage() { const includedUsage = item.included_usage; const isFeaturePrice = isFeaturePriceItem(item); + const feature = features.find((f) => f.id === item.feature_id); + const isAiCreditSystem = feature?.type === FeatureType.AiCreditSystem; // Helper function to get the display value for the input const getInputValue = () => { @@ -41,35 +49,71 @@ 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 ? ( + <> + USD budget{" "} + {!isFeaturePrice + ? "allocated to this plan" + : "granted before billing"} + + ) : ( + <> + Quantity of  + + {getFeatureName({ feature, plural: true })}{" "} + + {!isFeaturePrice + ? " that can be used" + : " granted before billing"} + + )}
- { - const value = e.target.value.trim(); + {isAiCreditSystem ? ( + // Couldn't find a disabled property but data-disabled is accounted for in CSS + + $ + { + 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) }); + 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" - /> + }} + 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..3626ed9e5 100644 --- a/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx +++ b/vite/src/views/products/plan/components/new-feature/NewFeatureBehaviour.tsx @@ -17,7 +17,10 @@ export function NewFeatureBehaviour({ }) { if (feature.type === FeatureType.CreditSystem) { return ( - + ); } 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..7bc9cb3ae 100644 --- a/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx +++ b/vite/src/views/products/plan/components/plan-card/DummyPlanFeatureRow.tsx @@ -44,10 +44,16 @@ export const DummyPlanFeatureRow = () => { return "Chat Messages"; }; + const isAiCreditSystem = feature.type === FeatureType.AiCreditSystem; + // Build display text based on feature type const getDisplayText = () => { const name = hasName ? featureName : getPlaceholderName(); + if (isAiCreditSystem) { + 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 121e52f27..8961e88dc 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, FeatureType } from "@autumn/shared"; import { getProductItemDisplay } from "@autumn/shared"; import { TrashIcon } from "@phosphor-icons/react"; import { useEffect, useRef, useState } from "react"; @@ -68,6 +68,8 @@ export const PlanFeatureRow = ({ const feature = features.find((f) => f.id === item.feature_id); const hasFeatureName = feature?.name && feature.name.trim() !== ""; + const isAiCreditSystem = feature?.type === FeatureType.AiCreditSystem; + const displayText = hasFeatureName ? display.primary_text : "Name your feature"; @@ -209,7 +211,9 @@ export const PlanFeatureRow = ({ {displayText} - {display.secondary_text} + {!isAiCreditSystem && display.secondary_text && ( + {display.secondary_text} + )}