feat: improve rate-limits support

pull/715/head
Travis Fischer 2025-06-13 02:07:52 +07:00
rodzic eda5915fe9
commit 0216b3612c
21 zmienionych plików z 581 dodań i 230 usunięć

Wyświetl plik

@ -1,7 +1,9 @@
import {
agenticProjectConfigSchema,
defaultRequestsRateLimit,
type OriginAdapter,
type PricingPlanList,
type RateLimit,
resolvedAgenticProjectConfigSchema,
type Tool,
type ToolConfig
@ -92,7 +94,16 @@ export const deployments = pgTable(
pricingPlans: jsonb().$type<PricingPlanList>().notNull(),
// Which pricing intervals are supported for subscriptions to this project
pricingIntervals: pricingIntervalEnum().array().default(['month']).notNull()
pricingIntervals: pricingIntervalEnum()
.array()
.default(['month'])
.notNull(),
// Default rate limit across all pricing plans
defaultRateLimit: jsonb()
.$type<RateLimit>()
.notNull()
.default(defaultRequestsRateLimit)
// TODO: metadata config (logo, keywords, examples, etc)
// TODO: webhooks
@ -159,7 +170,8 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans,
pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals,
tools: resolvedAgenticProjectConfigSchema.shape.tools,
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs,
defaultRateLimit: resolvedAgenticProjectConfigSchema.shape.defaultRateLimit
})
.omit({
originUrl: true

Wyświetl plik

@ -16,7 +16,7 @@ const fixtures = [
// 'pricing-3-plans',
// 'pricing-monthly-annual',
// 'pricing-custom-0',
// 'basic-openapi',
'basic-openapi',
'basic-mcp',
'everything-openapi'
]

Wyświetl plik

@ -63,6 +63,7 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [
fixtures: [
{
path: '@dev/test-basic-openapi/getPost',
only: true,
request: {
method: 'POST',
json: {

Wyświetl plik

@ -19,7 +19,7 @@ const globalRateLimitCache: RateLimitCache = new Map()
export async function enforceRateLimit({
id,
interval,
maxPerInterval,
limit,
cost = 1,
async = true,
env,
@ -39,7 +39,7 @@ export async function enforceRateLimit({
/**
* Maximum number of requests that can be made per interval.
*/
maxPerInterval: number
limit: number
/**
* The cost of the request.
@ -84,18 +84,15 @@ export async function enforceRateLimit({
* and we can skip the request to the durable object entirely, which speeds
* everything up and is cheaper for us.
*/
if (
rateLimitState.current > maxPerInterval &&
now <= rateLimitState.resetTimeMs
) {
if (rateLimitState.current > limit && now <= rateLimitState.resetTimeMs) {
return {
id,
passed: false,
current: rateLimitState.current,
limit: maxPerInterval,
limit,
resetTimeMs: rateLimitState.resetTimeMs,
intervalMs,
remaining: Math.max(0, maxPerInterval - rateLimitState.current)
remaining: Math.max(0, limit - rateLimitState.current)
}
}
@ -129,11 +126,11 @@ export async function enforceRateLimit({
return {
id,
passed: rateLimitState.current <= maxPerInterval,
passed: rateLimitState.current <= limit,
current: rateLimitState.current,
limit: maxPerInterval,
limit,
resetTimeMs: rateLimitState.resetTimeMs,
intervalMs,
remaining: Math.max(0, maxPerInterval - rateLimitState.current)
remaining: Math.max(0, limit - rateLimitState.current)
}
}

Wyświetl plik

@ -1,7 +1,6 @@
import type {
AdminDeployment,
PricingPlan,
RateLimit,
Tool
} from '@agentic/platform-types'
import type { DurableObjectStub } from '@cloudflare/workers-types'
@ -61,27 +60,23 @@ export async function resolveOriginToolCall({
const { originAdapter } = deployment
// TODO: make this configurable via `ToolConfig.cost`
const numRequestsCost = 1
let rateLimit = deployment.defaultRateLimit
let rateLimitResult: RateLimitResult | undefined
let rateLimit: RateLimit | undefined | null
let cacheStatus: CacheStatus | undefined
let reportUsage = true
// Resolve rate limit and whether to report `requests` usage based on the
// customer's pricing plan and deployment config.
if (pricingPlan) {
if (pricingPlan.rateLimit) {
rateLimit = pricingPlan.rateLimit
}
const requestsLineItem = pricingPlan.lineItems.find(
(lineItem) => lineItem.slug === 'requests'
)
if (requestsLineItem) {
assert(
requestsLineItem.slug === 'requests',
403,
`Invalid pricing plan "${pricingPlan.slug}" for project "${deployment.project}"`
)
rateLimit = requestsLineItem.rateLimit
} else {
if (!requestsLineItem) {
// No `requests` line-item, so we don't report usage for this tool.
reportUsage = false
}
@ -97,8 +92,7 @@ export async function resolveOriginToolCall({
}
if (toolConfig.rateLimit !== undefined) {
// TODO: Improve RateLimitInput vs RateLimit types
rateLimit = toolConfig.rateLimit as RateLimit
rateLimit = toolConfig.rateLimit
}
if (cacheControl) {
@ -149,8 +143,7 @@ export async function resolveOriginToolCall({
}
if (pricingPlanToolOverride.rateLimit !== undefined) {
// TODO: Improve RateLimitInput vs RateLimit types
rateLimit = pricingPlanToolOverride.rateLimit as RateLimit
rateLimit = pricingPlanToolOverride.rateLimit
}
} else {
assert(isToolConfigEnabled, 404, `Tool "${tool.name}" is disabled`)
@ -171,13 +164,13 @@ export async function resolveOriginToolCall({
}
}
if (rateLimit) {
if (rateLimit && rateLimit.enabled !== false) {
// TODO: Consider decrementing rate limit if the response is cached or
// errors? this doesn't seem too important, so will leave as-is for now.
rateLimitResult = await enforceRateLimit({
id: consumer?.id ?? ip ?? sessionId,
interval: rateLimit.interval,
maxPerInterval: rateLimit.maxPerInterval,
limit: rateLimit.limit,
async: rateLimit.async,
cost: numRequestsCost,
env,
@ -224,10 +217,12 @@ export async function resolveOriginToolCall({
cacheKey,
fetchResponse: async () => {
let response = await fetch(originRequest)
if (cacheControl && isResponsePubliclyCacheable(response)) {
response = new Response(response.body, response)
response.headers.set('cache-control', cacheControl)
}
return response
},
waitUntil
@ -244,6 +239,7 @@ export async function resolveOriginToolCall({
return {
cacheStatus,
reportUsage,
rateLimit,
rateLimitResult,
toolCallArgs,
originRequest,
@ -312,6 +308,7 @@ export async function resolveOriginToolCall({
return {
cacheStatus: 'HIT',
reportUsage,
rateLimit,
rateLimitResult,
toolCallArgs,
toolCallResponse: (await response.json()) as McpToolCallResponse,
@ -346,6 +343,7 @@ export async function resolveOriginToolCall({
return {
cacheStatus: cacheStatus ?? (cacheKey ? 'MISS' : 'BYPASS'),
reportUsage,
rateLimit,
rateLimitResult,
toolCallArgs,
toolCallResponse,

Wyświetl plik

@ -6,6 +6,7 @@ import type {
} from '@agentic/platform-hono'
import type {
AdminConsumer as AdminConsumerImpl,
RateLimit,
ToolConfig,
User
} from '@agentic/platform-types'
@ -65,6 +66,7 @@ export type ResolvedOriginToolCallResult = {
originRequest?: Request
originResponse?: Response
toolCallResponse?: McpToolCallResponse
rateLimit?: RateLimit
rateLimitResult?: RateLimitResult
cacheStatus: CacheStatus
reportUsage: boolean

Wyświetl plik

@ -13,16 +13,35 @@ export function getRateLimitHeaders(
const { id, limit, remaining, resetTimeMs, intervalMs } = rateLimitResult
const intervalSeconds = Math.ceil(intervalMs / 1000)
const retryAfterSeconds = Math.max(
0,
Math.ceil((resetTimeMs - Date.now()) / 1000)
)
const resetTimeSeconds = Math.ceil(resetTimeMs / 1000)
headers['RateLimit-Policy'] = `${limit};w=${intervalSeconds}`
headers['RateLimit-Limit'] = limit.toString()
headers['RateLimit-Remaining'] = remaining.toString()
headers['Retry-After'] = retryAfterSeconds.toString()
headers['X-RateLimit-Id'] = id
const rateLimitPolicy = `${limit};w=${intervalSeconds}`
const limitString = limit.toString()
const remainingString = remaining.toString()
const resetTimeString = resetTimeSeconds.toString()
// NOTE: Cloudflare and/or origin servers like to set the x- headers, which
// can be pretty confusing since the end user gets both ratelimit headers.
// I'm hesitant to remove any extra origin headers, since they're a nice
// escape hatch for sending extra metadata, and the origin may in fact have
// its own separate rate-limiting policy, which we don't necessarily want to
// hide. So for now, we'll just set the standard rate-limit headers and make
// sure this distinction is documented.
headers['ratelimit-policy'] = rateLimitPolicy
headers['ratelimit-limit'] = limitString
headers['ratelimit-remaining'] = remainingString
headers['ratelimit-reset'] = resetTimeString
headers['x-ratelimit-id'] = id
if (!rateLimitResult.passed) {
const retryAfterSeconds = Math.max(
0,
Math.ceil((resetTimeMs - Date.now()) / 1000)
)
const retryAfterString = retryAfterSeconds.toString()
headers['retry-after'] = retryAfterString
}
return headers
}

Wyświetl plik

@ -14,7 +14,7 @@ export default defineConfig({
pure: true,
// cacheControl: 'no-cache',
reportUsage: true,
rateLimit: null,
rateLimit: { enabled: false },
pricingPlanOverridesMap: {
free: {
enabled: true,
@ -59,12 +59,12 @@ export default defineConfig({
name: 'custom_rate_limit_tool',
rateLimit: {
interval: '30s',
maxPerInterval: 10
limit: 10
}
},
{
name: 'disabled_rate_limit_tool',
rateLimit: null
rateLimit: { enabled: false }
},
{
name: 'strict_additional_properties',

Wyświetl plik

@ -30,15 +30,15 @@ export default defineConfig({
slug: 'custom-test',
usageType: 'metered',
billingScheme: 'per_unit',
unitAmount: 100,
rateLimit: {
maxPerInterval: 1000,
// TODO
// interval: '30d' // 60 * 60 * 24 * 30 // 30 days in seconds
interval: 60 * 60 * 24 * 30 // 30 days in seconds
}
unitAmount: 100
}
]
],
rateLimit: {
limit: 1000,
// TODO
// interval: '30d' // 60 * 60 * 24 * 30 // 30 days in seconds
interval: 60 * 60 * 24 * 30 // 30 days in seconds
}
},
{
name: 'Basic Annual',
@ -54,13 +54,13 @@ export default defineConfig({
slug: 'custom-test',
usageType: 'metered',
billingScheme: 'per_unit',
unitAmount: 80, // 20% discount
rateLimit: {
maxPerInterval: 1500,
interval: 60 * 60 * 24 * 30 // 30 days in seconds
}
unitAmount: 80 // 20% discount
}
]
],
rateLimit: {
limit: 1500,
interval: 60 * 60 * 24 * 30 // 30 days in seconds
}
}
]
})

Wyświetl plik

@ -17,14 +17,14 @@ export default defineConfig({
slug: 'requests',
usageType: 'metered',
billingScheme: 'per_unit',
unitAmount: 0,
unitAmount: 0
// Free but limited to 20 requests per day
rateLimit: {
maxPerInterval: 20,
interval: 60 * 60 * 24 // 1 day in seconds
}
}
]
],
rateLimit: {
limit: 20,
interval: 60 * 60 * 24 // 1 day in seconds
}
},
{
name: 'Pay-As-You-Go',
@ -48,7 +48,11 @@ export default defineConfig({
}
]
}
]
],
rateLimit: {
limit: 1000,
interval: '1d'
}
}
]
})

Wyświetl plik

@ -2,6 +2,12 @@
exports[`loadAgenticConfig > basic-mcp 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-basic-mcp",
"originAdapter": {
"location": "external",
@ -21,6 +27,12 @@ exports[`loadAgenticConfig > basic-mcp 1`] = `
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"slug": "free",
},
],
@ -31,6 +43,12 @@ exports[`loadAgenticConfig > basic-mcp 1`] = `
exports[`loadAgenticConfig > basic-openapi 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-basic-openapi",
"originAdapter": {
"location": "external",
@ -51,6 +69,12 @@ exports[`loadAgenticConfig > basic-openapi 1`] = `
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"slug": "free",
},
],
@ -70,6 +94,12 @@ exports[`loadAgenticConfig > basic-openapi 1`] = `
exports[`loadAgenticConfig > basic-raw-free-json 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-basic-raw-free-json",
"originAdapter": {
"location": "external",
@ -89,6 +119,12 @@ exports[`loadAgenticConfig > basic-raw-free-json 1`] = `
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"slug": "free",
},
],
@ -99,6 +135,12 @@ exports[`loadAgenticConfig > basic-raw-free-json 1`] = `
exports[`loadAgenticConfig > basic-raw-free-ts 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-basic-raw-free-ts",
"originAdapter": {
"location": "external",
@ -118,6 +160,12 @@ exports[`loadAgenticConfig > basic-raw-free-ts 1`] = `
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"slug": "free",
},
],
@ -128,6 +176,12 @@ exports[`loadAgenticConfig > basic-raw-free-ts 1`] = `
exports[`loadAgenticConfig > everything-openapi 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-everything-openapi",
"originAdapter": {
"location": "external",
@ -148,6 +202,12 @@ exports[`loadAgenticConfig > everything-openapi 1`] = `
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"slug": "free",
},
],
@ -162,7 +222,9 @@ exports[`loadAgenticConfig > everything-openapi 1`] = `
},
},
"pure": true,
"rateLimit": null,
"rateLimit": {
"enabled": false,
},
"reportUsage": true,
},
{
@ -201,13 +263,16 @@ exports[`loadAgenticConfig > everything-openapi 1`] = `
"name": "custom_rate_limit_tool",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 30,
"maxPerInterval": 10,
"limit": 10,
},
},
{
"name": "disabled_rate_limit_tool",
"rateLimit": null,
"rateLimit": {
"enabled": false,
},
},
{
"inputSchemaAdditionalProperties": false,
@ -253,6 +318,12 @@ exports[`loadAgenticConfig > invalid: pricing-empty-2 1`] = `[ZodValidationError
exports[`loadAgenticConfig > pricing-3-plans 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-pricing-3-plans",
"originAdapter": {
"location": "external",
@ -321,6 +392,12 @@ exports[`loadAgenticConfig > pricing-3-plans 1`] = `
exports[`loadAgenticConfig > pricing-custom-0 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-pricing-custom-0",
"originAdapter": {
"location": "external",
@ -353,17 +430,18 @@ exports[`loadAgenticConfig > pricing-custom-0 1`] = `
},
{
"billingScheme": "per_unit",
"rateLimit": {
"async": true,
"interval": 2592000,
"maxPerInterval": 1000,
},
"slug": "custom-test",
"unitAmount": 100,
"usageType": "metered",
},
],
"name": "Basic Monthly",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 2592000,
"limit": 1000,
},
"slug": "basic-monthly",
},
{
@ -376,17 +454,18 @@ exports[`loadAgenticConfig > pricing-custom-0 1`] = `
},
{
"billingScheme": "per_unit",
"rateLimit": {
"async": true,
"interval": 2592000,
"maxPerInterval": 1500,
},
"slug": "custom-test",
"unitAmount": 80,
"usageType": "metered",
},
],
"name": "Basic Annual",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 2592000,
"limit": 1500,
},
"slug": "basic-annual",
},
],
@ -397,6 +476,12 @@ exports[`loadAgenticConfig > pricing-custom-0 1`] = `
exports[`loadAgenticConfig > pricing-freemium 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-pricing-freemium",
"originAdapter": {
"location": "external",
@ -416,6 +501,12 @@ exports[`loadAgenticConfig > pricing-freemium 1`] = `
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"slug": "free",
},
{
@ -439,6 +530,12 @@ exports[`loadAgenticConfig > pricing-freemium 1`] = `
exports[`loadAgenticConfig > pricing-monthly-annual 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-pricing-monthly-annual",
"originAdapter": {
"location": "external",
@ -493,6 +590,12 @@ exports[`loadAgenticConfig > pricing-monthly-annual 1`] = `
exports[`loadAgenticConfig > pricing-pay-as-you-go 1`] = `
{
"defaultRateLimit": {
"async": true,
"enabled": true,
"interval": 60,
"limit": 1000,
},
"name": "test-pricing-pay-as-you-go",
"originAdapter": {
"location": "external",
@ -512,17 +615,18 @@ exports[`loadAgenticConfig > pricing-pay-as-you-go 1`] = `
},
{
"billingScheme": "per_unit",
"rateLimit": {
"async": true,
"interval": 86400,
"maxPerInterval": 20,
},
"slug": "requests",
"unitAmount": 0,
"usageType": "metered",
},
],
"name": "Free",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 86400,
"limit": 20,
},
"slug": "free",
},
{
@ -546,6 +650,12 @@ exports[`loadAgenticConfig > pricing-pay-as-you-go 1`] = `
},
],
"name": "Pay-As-You-Go",
"rateLimit": {
"async": true,
"enabled": true,
"interval": 86400,
"limit": 1000,
},
"slug": "pay-as-you-go",
},
],

Wyświetl plik

@ -8,13 +8,12 @@ exports[`rateLimitSchema invalid 1`] = `
{
"issues": [
{
"code": "invalid_type",
"expected": "number",
"received": "string",
"code": "invalid_literal",
"expected": false,
"path": [
"interval"
"enabled"
],
"message": "Expected number, received string"
"message": "Invalid literal value, expected false"
}
],
"name": "ZodError"
@ -22,23 +21,49 @@ exports[`rateLimitSchema invalid 1`] = `
{
"issues": [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"code": "invalid_union",
"unionErrors": [
{
"issues": [
{
"code": "invalid_type",
"expected": "number",
"received": "string",
"path": [
"interval"
],
"message": "Expected number, received string"
}
],
"name": "ZodError"
},
{
"issues": [
{
"code": "too_small",
"minimum": 1,
"type": "string",
"inclusive": true,
"exact": false,
"message": "String must contain at least 1 character(s)",
"path": [
"interval"
]
}
],
"name": "ZodError"
}
],
"path": [
"interval"
]
],
"message": "Invalid input"
}
],
"name": "ZodError"
}
],
"path": [
"interval"
],
"path": [],
"message": "Invalid input"
}
]]
@ -83,7 +108,7 @@ exports[`rateLimitSchema invalid 4`] = `
"exact": false,
"message": "Number must be greater than or equal to 0",
"path": [
"maxPerInterval"
"limit"
]
}
]]
@ -92,23 +117,38 @@ exports[`rateLimitSchema invalid 4`] = `
exports[`rateLimitSchema valid 1`] = `
{
"async": true,
"enabled": true,
"interval": 10,
"maxPerInterval": 100,
"limit": 100,
}
`;
exports[`rateLimitSchema valid 2`] = `
{
"async": true,
"enabled": true,
"interval": 10,
"maxPerInterval": 100,
"limit": 100,
}
`;
exports[`rateLimitSchema valid 3`] = `
{
"async": false,
"enabled": true,
"interval": 86400,
"maxPerInterval": 1000,
"limit": 1000,
}
`;
exports[`rateLimitSchema valid 4`] = `
{
"enabled": false,
}
`;
exports[`rateLimitSchema valid 5`] = `
{
"enabled": false,
}
`;

Wyświetl plik

@ -39,13 +39,13 @@ test('AgenticProjectConfig input types', () => {
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: 50
rateLimit: {
// Make sure `interval` can use a string as input
interval: '30s'
maxPerInterval: 100
}
}
]
rateLimit: {
// Make sure `interval` can use a string as input
interval: '30s'
limit: 100
}
}
]
}>().toExtend<AgenticProjectConfigInput>()
@ -63,13 +63,13 @@ test('AgenticProjectConfig input types', () => {
usageType: 'metered'
billingScheme: 'per_unit'
unitAmount: 50
rateLimit: {
// Make sure `interval` can use a number as input
interval: 300
maxPerInterval: 100
}
}
]
rateLimit: {
// Make sure `interval` can use a number as input
interval: 300
limit: 100
}
}
]
}>().toExtend<AgenticProjectConfigInput>()

Wyświetl plik

@ -7,11 +7,17 @@ import {
} from './origin-adapter'
import {
defaultFreePricingPlan,
defaultRequestsRateLimit,
pricingIntervalListSchema,
type PricingPlanList,
type PricingPlanListInput,
pricingPlanListSchema
} from './pricing'
import {
type RateLimit,
type RateLimitInput,
rateLimitSchema
} from './rate-limit'
import {
type ToolConfig,
type ToolConfigInput,
@ -136,6 +142,20 @@ To add support for annual pricing plans, for example, you can use: \`['month', '
.optional()
.default(['month']),
/**
* Optional default rate limits to enforce for all pricing plans.
*
* To disable the default rate-limit, set `defaultRateLimit.enabled` to
* `false`.
*
* Note that pricing-plan-specific rate-limits override this default (via
* `pricingPlans`), and tool-specific rate-limits may override both default
* and pricing-plan-specific rate-limits (via `toolConfigs`).
*/
defaultRateLimit: rateLimitSchema
.optional()
.default(defaultRequestsRateLimit),
/**
* Optional list of tool configs to customize the behavior of tools.
*
@ -170,6 +190,7 @@ export type AgenticProjectConfigInput = Simplify<
> & {
pricingPlans?: PricingPlanListInput
toolConfigs?: ToolConfigInput[]
defaultRateLimit?: RateLimitInput
}
>
export type AgenticProjectConfigRaw = z.output<
@ -179,6 +200,7 @@ export type AgenticProjectConfig = Simplify<
Omit<AgenticProjectConfigRaw, 'pricingPlans' | 'toolConfigs'> & {
pricingPlans: PricingPlanList
toolConfigs: ToolConfig[]
defaultRateLimit: RateLimit
}
>
@ -194,5 +216,6 @@ export type ResolvedAgenticProjectConfig = Simplify<
> & {
pricingPlans: PricingPlanList
toolConfigs: ToolConfig[]
defaultRateLimit: RateLimit
}
>

Wyświetl plik

@ -479,20 +479,25 @@ export interface components {
};
};
RateLimit: {
/** @enum {boolean} */
enabled: false;
} | {
/** @description The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc). */
interval: number | string;
/** @description Maximum number of operations per interval (unitless). */
maxPerInterval: number;
limit: number;
/**
* @description Whether to enforce the rate limit synchronously or asynchronously.
* @description Whether to enforce the rate limit synchronously (strict but slower) or asynchronously (approximate and faster, the default).
* @default true
*/
async: boolean;
/** @default true */
enabled: boolean;
};
PricingPlanToolOverride: {
enabled?: boolean;
reportUsage?: boolean;
rateLimit?: components["schemas"]["RateLimit"] | null;
rateLimit?: components["schemas"]["RateLimit"];
};
ToolConfig: {
/** @description Agentic tool name */
@ -501,7 +506,7 @@ export interface components {
pure?: boolean;
cacheControl?: string;
reportUsage?: boolean;
rateLimit?: components["schemas"]["RateLimit"] | null;
rateLimit?: components["schemas"]["RateLimit"];
inputSchemaAdditionalProperties?: boolean;
outputSchemaAdditionalProperties?: boolean;
/** @description Allows you to override this tool's behavior or disable it entirely for different pricing plans. This is a map of PricingPlan slug to PricingPlanToolOverrides for that plan. */
@ -603,7 +608,6 @@ export interface components {
/** @enum {string} */
usageType: "metered";
unitLabel?: string;
rateLimit?: components["schemas"]["RateLimit"];
billingScheme: "per_unit" | "tiered";
unitAmount?: number;
tiersMode?: "graduated" | "volume";
@ -625,6 +629,7 @@ export interface components {
description?: string;
features?: string[];
trialPeriodDays?: number;
rateLimit?: components["schemas"]["RateLimit"];
lineItems: components["schemas"]["PricingPlanLineItem"][];
};
/** @description A Deployment is a single, immutable instance of a Project. Each deployment contains pricing plans, origin server config (OpenAPI or MCP server), tool definitions, and metadata.
@ -678,7 +683,13 @@ export interface components {
* "usageType": "licensed",
* "amount": 0
* }
* ]
* ],
* "rateLimit": {
* "interval": 60,
* "limit": 1000,
* "async": true,
* "enabled": true
* }
* }
* ]
*/
@ -694,6 +705,7 @@ export interface components {
* ]
*/
pricingIntervals: components["schemas"]["PricingInterval"][];
defaultRateLimit?: components["schemas"]["RateLimit"] & unknown;
project?: components["schemas"]["Project"];
};
/**
@ -1654,7 +1666,13 @@ export interface operations {
* "usageType": "licensed",
* "amount": 0
* }
* ]
* ],
* "rateLimit": {
* "interval": 60,
* "limit": 1000,
* "async": true,
* "enabled": true
* }
* }
* ]
*/
@ -1670,6 +1688,7 @@ export interface operations {
* ]
*/
pricingIntervals?: components["schemas"]["PricingInterval"][];
defaultRateLimit?: components["schemas"]["RateLimit"] & unknown;
/** @default [] */
toolConfigs?: components["schemas"]["ToolConfig"][];
};

Wyświetl plik

@ -1,7 +1,7 @@
import type { Simplify } from 'type-fest'
import { z } from '@hono/zod-openapi'
import { rateLimitSchema } from './rate-limit'
import { type RateLimit, rateLimitSchema } from './rate-limit'
/**
* PricingPlanTier is a single tier in a tiered pricing plan.
@ -175,14 +175,6 @@ export const pricingPlanMeteredLineItemSchema =
*/
unitLabel: z.string().optional(),
/**
* Optional rate limit to enforce for this metered line-item.
*
* You can use this, for example, to limit the number of API calls that
* can be made during a given interval.
*/
rateLimit: rateLimitSchema.optional(),
/**
* Describes how to compute the price per period. Either `per_unit` or
* `tiered`.
@ -473,6 +465,15 @@ export type PricingPlanLineItem =
*/
export const pricingPlanSchema = z
.object({
/**
* Human-readable name for the pricing plan.
*
* Used in UI and billing invoices.
*
* @example "Free"
* @example "Starter Monthly"
* @example "Pro Annual"
*/
name: z
.string()
.nonempty()
@ -481,6 +482,21 @@ export const pricingPlanSchema = z
)
.openapi('name', { example: 'Starter Monthly' }),
/**
* A unique slug for the pricing plan which acts as a stable identifier
* across deployments.
*
* Should be lower-kebab-cased.
* Should be stable across deployments.
*
* For all plans aside from `free`, the `slug` should include the `interval`
* as a suffix so pricing plans can be uniquely differentiated from each
* other across billing intervals.
*
* @example "free"
* @example "starter-monthly"
* @example "pro-annual"
*/
slug: z
.string()
.nonempty()
@ -495,12 +511,12 @@ export const pricingPlanSchema = z
interval: pricingIntervalSchema.optional(),
/**
* Optional description of the PricingPlan which is used for UI-only.
* Optional description of the pricing plan (UI-only).
*/
description: z.string().optional(),
/**
* Optional list of features of the PricingPlan which is used for UI-only.
* Optional list of features of the pricing plan (UI-only).
*/
features: z.array(z.string()).optional(),
@ -510,6 +526,23 @@ export const pricingPlanSchema = z
*/
trialPeriodDays: z.number().nonnegative().optional(),
/**
* Optional rate limit to enforce for this pricing plan.
*
* You can use this to limit the number of API requests that can be made by
* a customer during a given interval.
*
* If not set, the pricing plan will inherit the default platform rate-limit
* set by `defaultRateLimit` in the Agentic project config.
*
* You can disable rate-limiting for this pricing plan by setting
* `rateLimit.enabled` to `false`.
*
* Note that tool-specific rate limits may override pricing-plan-specific
* rate-limits via `toolConfigs` in the Agentic project config.
*/
rateLimit: rateLimitSchema.optional(),
/**
* List of custom LineItems which are included in the PricingPlan.
*
@ -601,6 +634,21 @@ export type StripeSubscriptionItemIdMap = z.infer<
// .openapi('Coupon')
// export type Coupon = z.infer<typeof couponSchema>
/**
* The default platform rate limit for `requests` is a limit of 1000 requests
* per minute per customer.
*/
export const defaultRequestsRateLimit = {
interval: 60,
limit: 1000,
async: true,
enabled: true
} as const satisfies Readonly<RateLimit>
/**
* The default free pricing plan which is used for projects that don't specify
* custom pricing plans.
*/
export const defaultFreePricingPlan = {
name: 'Free',
slug: 'free',
@ -610,5 +658,6 @@ export const defaultFreePricingPlan = {
usageType: 'licensed',
amount: 0
}
]
],
rateLimit: defaultRequestsRateLimit
} as const satisfies Readonly<PricingPlan>

Wyświetl plik

@ -10,52 +10,67 @@ test('rateLimitSchema valid', () => {
expect(
rateLimitSchema.parse({
interval: 10,
maxPerInterval: 100
limit: 100
})
).toMatchSnapshot()
expect(
rateLimitSchema.parse({
interval: '10s',
maxPerInterval: 100
limit: 100
})
).toMatchSnapshot()
expect(
rateLimitSchema.parse({
interval: '1 day',
maxPerInterval: 1000,
limit: 1000,
async: false
})
).toMatchSnapshot()
expect(
rateLimitSchema.parse({
enabled: false
})
).toMatchSnapshot()
expect(
rateLimitSchema.parse({
interval: '10m',
limit: 100,
async: false,
enabled: false
})
).toMatchSnapshot()
})
test('rateLimitSchema invalid', () => {
expect(() =>
rateLimitSchema.parse({
interval: '',
maxPerInterval: 5
limit: 5
})
).toThrowErrorMatchingSnapshot()
expect(() =>
rateLimitSchema.parse({
interval: 0,
maxPerInterval: 5
limit: 5
})
).toThrowErrorMatchingSnapshot()
expect(() =>
rateLimitSchema.parse({
interval: '--',
maxPerInterval: 10
limit: 10
})
).toThrowErrorMatchingSnapshot()
expect(() =>
rateLimitSchema.parse({
interval: '1 day',
maxPerInterval: -1000
limit: -1000
})
).toThrowErrorMatchingSnapshot()
})
@ -63,58 +78,78 @@ test('rateLimitSchema invalid', () => {
test('RateLimit types', () => {
expectTypeOf({
interval: 10,
maxPerInterval: 100,
async: false
limit: 100,
async: false,
enabled: true
} as const).toExtend<RateLimit>()
expectTypeOf<{
interval: 10
maxPerInterval: 100
}>().toExtend<RateLimitInput>()
limit: 100
async: false
enabled: true
}>().toExtend<RateLimit>()
expectTypeOf({
interval: '10s',
maxPerInterval: 100,
async: true
limit: 100,
async: true,
enabled: true
} as const).not.toExtend<RateLimit>()
expectTypeOf<{
interval: '10s'
maxPerInterval: 100
limit: 100
async: false
}>().not.toExtend<RateLimit>()
expectTypeOf({
enabled: false
} as const).toExtend<RateLimit>()
expectTypeOf<{
enabled: false
}>().toExtend<RateLimit>()
})
test('RateLimitInput types', () => {
expectTypeOf({
interval: 10,
maxPerInterval: 100
limit: 100
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
interval: 10
maxPerInterval: 100
limit: 100
}>().toExtend<RateLimitInput>()
expectTypeOf({
interval: 10,
maxPerInterval: 100,
limit: 100,
async: false
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
interval: 10
maxPerInterval: 100
limit: 100
async: boolean
}>().toExtend<RateLimitInput>()
expectTypeOf({
interval: '3h',
maxPerInterval: 100
limit: 100
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
interval: '3h'
maxPerInterval: 100
limit: 100
}>().toExtend<RateLimitInput>()
expectTypeOf({
enabled: false
} as const).toExtend<RateLimitInput>()
expectTypeOf<{
enabled: false
}>().toExtend<RateLimitInput>()
})

Wyświetl plik

@ -1,37 +1,59 @@
import { z } from '@hono/zod-openapi'
import parseIntervalAsMs from 'ms'
// TODO: Consider adding support for this in the future
// export const rateLimitBySchema = z.union([
// z.literal('ip'),
// z.literal('customer'),
// z.literal('all')
// ])
/**
* Rate limit config for metered LineItems.
*/
export const rateLimitSchema = z
.object({
/**
* The interval at which the rate limit is applied.
*
* Either a positive integer expressed in seconds or a valid positive
* [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d",
* "1w", "1y", etc).
*/
interval: z
.union([
z.number().positive(), // seconds
.union([
z.object({
enabled: z.literal(false)
}),
z.object({
/**
* The interval at which the rate limit is applied.
*
* Either a positive integer expressed in seconds or a valid positive
* [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d",
* "1w", "1y", etc).
*/
interval: z
.union([
z.number().positive(), // seconds
z
.string()
.nonempty()
.transform((value, ctx) => {
try {
// TODO: `ms` module has broken types
const ms = parseIntervalAsMs(value as any) as unknown as number
const seconds = Math.floor(ms / 1000)
z
.string()
.nonempty()
.transform((value, ctx) => {
try {
// TODO: `ms` module has broken types
const ms = parseIntervalAsMs(value as any) as unknown as number
const seconds = Math.floor(ms / 1000)
if (
typeof ms !== 'number' ||
Number.isNaN(ms) ||
ms <= 0 ||
seconds <= 0
) {
if (
typeof ms !== 'number' ||
Number.isNaN(ms) ||
ms <= 0 ||
seconds <= 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
path: ctx.path
})
return z.NEVER
}
return seconds
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
@ -40,52 +62,60 @@ export const rateLimitSchema = z
return z.NEVER
}
})
])
.describe(
`The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc).`
),
return seconds
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
path: ctx.path
})
/**
* Maximum number of operations per interval (unitless).
*/
limit: z
.number()
.nonnegative()
.describe('Maximum number of operations per interval (unitless).'),
return z.NEVER
}
})
])
.describe(
`The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc).`
),
/**
* Whether to enforce the rate limit synchronously or asynchronously.
*
* The default rate-limiting mode is asynchronous, which means that requests
* are allowed to proceed immediately, with the limit being enforced in the
* background. This is much faster than synchronous mode, but it is less
* consistent if precise adherence to rate-limits is required.
*
* With synchronous mode, requests are blocked until the current limit has
* been confirmed. The downside with this approach is that it introduces
* more latency to every request by default. The advantage is that it is
* more precise and consistent.
*
* @default true
*/
async: z
.boolean()
.optional()
.default(true)
.describe(
'Whether to enforce the rate limit synchronously (strict but slower) or asynchronously (approximate and faster, the default).'
),
/**
* Maximum number of operations per interval (unitless).
*/
maxPerInterval: z
.number()
.nonnegative()
.describe('Maximum number of operations per interval (unitless).'),
// TODO: Consider adding support for this in the future
// /**
// * The key to rate-limit by.
// *
// * - `ip`: Rate-limit by incoming IP address.
// * - `customer`: Rate-limit by customer ID if available or IP address
// * otherwise.
// * - `global`: Rate-limit all usage globally across customers.
// *
// * @default 'customer'
// */
// rateLimitBy: rateLimitBySchema.optional().default('customer'),
/**
* Whether to enforce the rate limit synchronously or asynchronously.
*
* The default rate-limiting mode is asynchronous, which means that requests
* are allowed to proceed immediately, with the limit being enforced in the
* background. This is much faster than synchronous mode, but it is less
* consistent if precise adherence to rate-limits is required.
*
* With synchronous mode, requests will be blocked until the current limit
* has been confirmed. The downside with this approach is that it can
* introduce more latency to every request by default. The advantage is that
* it is more accurate and consistent.
*/
async: z
.boolean()
.optional()
.default(true)
.describe(
'Whether to enforce the rate limit synchronously or asynchronously.'
)
})
enabled: z.boolean().optional().default(true)
})
])
.openapi('RateLimit')
export type RateLimitInput = z.input<typeof rateLimitSchema>
export type RateLimit = z.infer<typeof rateLimitSchema>

Wyświetl plik

@ -76,12 +76,12 @@ export const pricingPlanToolOverrideSchema = z
* Customize or disable rate limits for this tool for customers on a given
* pricing plan.
*
* Set to `null` to disable the default request-based rate-limiting for
* this tool on a given pricing plan.
* To disable rate-limiting for this tool on a given pricing plan, set
* `rateLimit.enabled` to `false`.
*
* @default undefined
*/
rateLimit: z.union([rateLimitSchema, z.null()]).optional()
rateLimit: rateLimitSchema.optional()
})
.openapi('PricingPlanToolOverride')
export type PricingPlanToolOverride = z.infer<
@ -158,14 +158,15 @@ export const toolConfigSchema = z
/**
* Customize the default `requests`-based rate-limiting for this tool.
*
* Set to `null` to disable the built-in rate-limiting.
* To disable rate-limiting for this tool, set `rateLimit.enabled` to
* `false`.
*
* If not set, the default rate-limiting for the active pricing plan will be
* used.
*
* @default undefined
*/
rateLimit: z.union([rateLimitSchema, z.null()]).optional(),
rateLimit: rateLimitSchema.optional(),
/**
* Whether to allow additional properties in the tool's input schema.
@ -175,7 +176,7 @@ export const toolConfigSchema = z
*
* @note This is only relevant if the tool has defined an `outputSchema`.
*
* @default true
* @default undefined
*/
inputSchemaAdditionalProperties: z.boolean().optional(),
@ -187,7 +188,7 @@ export const toolConfigSchema = z
*
* @note This is only relevant if the tool has defined an `outputSchema`.
*
* @default true
* @default undefined
*/
outputSchemaAdditionalProperties: z.boolean().optional(),
@ -216,6 +217,7 @@ export const toolConfigSchema = z
// headers
})
.openapi('ToolConfig')
export type ToolConfigInput = z.input<typeof toolConfigSchema>
export type ToolConfig = z.infer<typeof toolConfigSchema>
@ -321,6 +323,3 @@ export const toolSchema = z
.passthrough()
.openapi('Tool')
export type Tool = z.infer<typeof toolSchema>
// export const toolMapSchema = z.record(toolNameSchema, toolSchema)
// export type ToolMap = z.infer<typeof toolMapSchema>

Wyświetl plik

@ -4,10 +4,12 @@
* types than what `@hono/zod-openapi` and `zod` v3 provide, but in general
* these types are meant to use the backend API as a source of truth.
*/
import type { PricingPlan, ToolConfig } from '@agentic/platform-types'
import type { Simplify } from 'type-fest'
import type { components } from './openapi.d.ts'
import type { PricingPlan } from './pricing'
import type { RateLimit } from './rate-limit.js'
import type { ToolConfig } from './tools'
export type Consumer = components['schemas']['Consumer']
export type Project = components['schemas']['Project']
@ -16,19 +18,24 @@ export type Team = components['schemas']['Team']
export type TeamMember = components['schemas']['TeamMember']
export type Deployment = Simplify<
Omit<components['schemas']['Deployment'], 'pricingPlans' | 'toolConfigs'> & {
Omit<
components['schemas']['Deployment'],
'pricingPlans' | 'toolConfigs' | 'defaultRateLimit'
> & {
pricingPlans: PricingPlan[]
toolConfigs: ToolConfig[]
defaultRateLimit: RateLimit
}
>
export type AdminDeployment = Simplify<
Omit<
components['schemas']['AdminDeployment'],
'pricingPlans' | 'toolConfigs'
'pricingPlans' | 'toolConfigs' | 'defaultRateLimit'
> & {
pricingPlans: PricingPlan[]
toolConfigs: ToolConfig[]
defaultRateLimit: RateLimit
}
>

Wyświetl plik

@ -26,6 +26,7 @@
- mcp-kitchen-sink
- how to handle binary bodies and responses?
- improve logger vs console for non-hono path and util methods
- default rate limits?
- **test rate limiting**
- **test usage tracking and reporting**
- disallow `mcp` as a tool name or figure out another workaround
@ -80,6 +81,11 @@
- handle or validate against dynamic MCP origin tools
- allow config name to be `project-name` or `@namespace/project-name`?
- upgrade to zod v4
- add seed-db script which creates `dev` user (or team) and test fixtures
- decide whether deployment fields like `defaultRateLimit` and others should be generated and stored in the db, or should be inferred based on `undefined` values
- support multiple rate-limits by slug
- RateLimit-Policy: "burst";q=100;w=60,"daily";q=1000;w=86400
- https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/
## License