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 { import {
agenticProjectConfigSchema, agenticProjectConfigSchema,
defaultRequestsRateLimit,
type OriginAdapter, type OriginAdapter,
type PricingPlanList, type PricingPlanList,
type RateLimit,
resolvedAgenticProjectConfigSchema, resolvedAgenticProjectConfigSchema,
type Tool, type Tool,
type ToolConfig type ToolConfig
@ -92,7 +94,16 @@ export const deployments = pgTable(
pricingPlans: jsonb().$type<PricingPlanList>().notNull(), pricingPlans: jsonb().$type<PricingPlanList>().notNull(),
// Which pricing intervals are supported for subscriptions to this project // 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: metadata config (logo, keywords, examples, etc)
// TODO: webhooks // TODO: webhooks
@ -159,7 +170,8 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans, pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans,
pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals, pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals,
tools: resolvedAgenticProjectConfigSchema.shape.tools, tools: resolvedAgenticProjectConfigSchema.shape.tools,
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs,
defaultRateLimit: resolvedAgenticProjectConfigSchema.shape.defaultRateLimit
}) })
.omit({ .omit({
originUrl: true originUrl: true

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -13,16 +13,35 @@ export function getRateLimitHeaders(
const { id, limit, remaining, resetTimeMs, intervalMs } = rateLimitResult const { id, limit, remaining, resetTimeMs, intervalMs } = rateLimitResult
const intervalSeconds = Math.ceil(intervalMs / 1000) const intervalSeconds = Math.ceil(intervalMs / 1000)
const retryAfterSeconds = Math.max( const resetTimeSeconds = Math.ceil(resetTimeMs / 1000)
0,
Math.ceil((resetTimeMs - Date.now()) / 1000)
)
headers['RateLimit-Policy'] = `${limit};w=${intervalSeconds}` const rateLimitPolicy = `${limit};w=${intervalSeconds}`
headers['RateLimit-Limit'] = limit.toString() const limitString = limit.toString()
headers['RateLimit-Remaining'] = remaining.toString() const remainingString = remaining.toString()
headers['Retry-After'] = retryAfterSeconds.toString() const resetTimeString = resetTimeSeconds.toString()
headers['X-RateLimit-Id'] = id
// 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 return headers
} }

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -8,13 +8,12 @@ exports[`rateLimitSchema invalid 1`] = `
{ {
"issues": [ "issues": [
{ {
"code": "invalid_type", "code": "invalid_literal",
"expected": "number", "expected": false,
"received": "string",
"path": [ "path": [
"interval" "enabled"
], ],
"message": "Expected number, received string" "message": "Invalid literal value, expected false"
} }
], ],
"name": "ZodError" "name": "ZodError"
@ -22,23 +21,49 @@ exports[`rateLimitSchema invalid 1`] = `
{ {
"issues": [ "issues": [
{ {
"code": "too_small", "code": "invalid_union",
"minimum": 1, "unionErrors": [
"type": "string", {
"inclusive": true, "issues": [
"exact": false, {
"message": "String must contain at least 1 character(s)", "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": [ "path": [
"interval" "interval"
] ],
"message": "Invalid input"
} }
], ],
"name": "ZodError" "name": "ZodError"
} }
], ],
"path": [ "path": [],
"interval"
],
"message": "Invalid input" "message": "Invalid input"
} }
]] ]]
@ -83,7 +108,7 @@ exports[`rateLimitSchema invalid 4`] = `
"exact": false, "exact": false,
"message": "Number must be greater than or equal to 0", "message": "Number must be greater than or equal to 0",
"path": [ "path": [
"maxPerInterval" "limit"
] ]
} }
]] ]]
@ -92,23 +117,38 @@ exports[`rateLimitSchema invalid 4`] = `
exports[`rateLimitSchema valid 1`] = ` exports[`rateLimitSchema valid 1`] = `
{ {
"async": true, "async": true,
"enabled": true,
"interval": 10, "interval": 10,
"maxPerInterval": 100, "limit": 100,
} }
`; `;
exports[`rateLimitSchema valid 2`] = ` exports[`rateLimitSchema valid 2`] = `
{ {
"async": true, "async": true,
"enabled": true,
"interval": 10, "interval": 10,
"maxPerInterval": 100, "limit": 100,
} }
`; `;
exports[`rateLimitSchema valid 3`] = ` exports[`rateLimitSchema valid 3`] = `
{ {
"async": false, "async": false,
"enabled": true,
"interval": 86400, "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' usageType: 'metered'
billingScheme: 'per_unit' billingScheme: 'per_unit'
unitAmount: 50 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>() }>().toExtend<AgenticProjectConfigInput>()
@ -63,13 +63,13 @@ test('AgenticProjectConfig input types', () => {
usageType: 'metered' usageType: 'metered'
billingScheme: 'per_unit' billingScheme: 'per_unit'
unitAmount: 50 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>() }>().toExtend<AgenticProjectConfigInput>()

Wyświetl plik

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

Wyświetl plik

@ -479,20 +479,25 @@ export interface components {
}; };
}; };
RateLimit: { 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). */ /** @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; interval: number | string;
/** @description Maximum number of operations per interval (unitless). */ /** @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 * @default true
*/ */
async: boolean; async: boolean;
/** @default true */
enabled: boolean;
}; };
PricingPlanToolOverride: { PricingPlanToolOverride: {
enabled?: boolean; enabled?: boolean;
reportUsage?: boolean; reportUsage?: boolean;
rateLimit?: components["schemas"]["RateLimit"] | null; rateLimit?: components["schemas"]["RateLimit"];
}; };
ToolConfig: { ToolConfig: {
/** @description Agentic tool name */ /** @description Agentic tool name */
@ -501,7 +506,7 @@ export interface components {
pure?: boolean; pure?: boolean;
cacheControl?: string; cacheControl?: string;
reportUsage?: boolean; reportUsage?: boolean;
rateLimit?: components["schemas"]["RateLimit"] | null; rateLimit?: components["schemas"]["RateLimit"];
inputSchemaAdditionalProperties?: boolean; inputSchemaAdditionalProperties?: boolean;
outputSchemaAdditionalProperties?: 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. */ /** @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} */ /** @enum {string} */
usageType: "metered"; usageType: "metered";
unitLabel?: string; unitLabel?: string;
rateLimit?: components["schemas"]["RateLimit"];
billingScheme: "per_unit" | "tiered"; billingScheme: "per_unit" | "tiered";
unitAmount?: number; unitAmount?: number;
tiersMode?: "graduated" | "volume"; tiersMode?: "graduated" | "volume";
@ -625,6 +629,7 @@ export interface components {
description?: string; description?: string;
features?: string[]; features?: string[];
trialPeriodDays?: number; trialPeriodDays?: number;
rateLimit?: components["schemas"]["RateLimit"];
lineItems: components["schemas"]["PricingPlanLineItem"][]; 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. /** @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", * "usageType": "licensed",
* "amount": 0 * "amount": 0
* } * }
* ] * ],
* "rateLimit": {
* "interval": 60,
* "limit": 1000,
* "async": true,
* "enabled": true
* }
* } * }
* ] * ]
*/ */
@ -694,6 +705,7 @@ export interface components {
* ] * ]
*/ */
pricingIntervals: components["schemas"]["PricingInterval"][]; pricingIntervals: components["schemas"]["PricingInterval"][];
defaultRateLimit?: components["schemas"]["RateLimit"] & unknown;
project?: components["schemas"]["Project"]; project?: components["schemas"]["Project"];
}; };
/** /**
@ -1654,7 +1666,13 @@ export interface operations {
* "usageType": "licensed", * "usageType": "licensed",
* "amount": 0 * "amount": 0
* } * }
* ] * ],
* "rateLimit": {
* "interval": 60,
* "limit": 1000,
* "async": true,
* "enabled": true
* }
* } * }
* ] * ]
*/ */
@ -1670,6 +1688,7 @@ export interface operations {
* ] * ]
*/ */
pricingIntervals?: components["schemas"]["PricingInterval"][]; pricingIntervals?: components["schemas"]["PricingInterval"][];
defaultRateLimit?: components["schemas"]["RateLimit"] & unknown;
/** @default [] */ /** @default [] */
toolConfigs?: components["schemas"]["ToolConfig"][]; toolConfigs?: components["schemas"]["ToolConfig"][];
}; };

Wyświetl plik

@ -1,7 +1,7 @@
import type { Simplify } from 'type-fest' import type { Simplify } from 'type-fest'
import { z } from '@hono/zod-openapi' 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. * PricingPlanTier is a single tier in a tiered pricing plan.
@ -175,14 +175,6 @@ export const pricingPlanMeteredLineItemSchema =
*/ */
unitLabel: z.string().optional(), 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 * Describes how to compute the price per period. Either `per_unit` or
* `tiered`. * `tiered`.
@ -473,6 +465,15 @@ export type PricingPlanLineItem =
*/ */
export const pricingPlanSchema = z export const pricingPlanSchema = z
.object({ .object({
/**
* Human-readable name for the pricing plan.
*
* Used in UI and billing invoices.
*
* @example "Free"
* @example "Starter Monthly"
* @example "Pro Annual"
*/
name: z name: z
.string() .string()
.nonempty() .nonempty()
@ -481,6 +482,21 @@ export const pricingPlanSchema = z
) )
.openapi('name', { example: 'Starter Monthly' }), .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 slug: z
.string() .string()
.nonempty() .nonempty()
@ -495,12 +511,12 @@ export const pricingPlanSchema = z
interval: pricingIntervalSchema.optional(), 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(), 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(), features: z.array(z.string()).optional(),
@ -510,6 +526,23 @@ export const pricingPlanSchema = z
*/ */
trialPeriodDays: z.number().nonnegative().optional(), 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. * List of custom LineItems which are included in the PricingPlan.
* *
@ -601,6 +634,21 @@ export type StripeSubscriptionItemIdMap = z.infer<
// .openapi('Coupon') // .openapi('Coupon')
// export type Coupon = z.infer<typeof couponSchema> // 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 = { export const defaultFreePricingPlan = {
name: 'Free', name: 'Free',
slug: 'free', slug: 'free',
@ -610,5 +658,6 @@ export const defaultFreePricingPlan = {
usageType: 'licensed', usageType: 'licensed',
amount: 0 amount: 0
} }
] ],
rateLimit: defaultRequestsRateLimit
} as const satisfies Readonly<PricingPlan> } as const satisfies Readonly<PricingPlan>

Wyświetl plik

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

Wyświetl plik

@ -1,37 +1,59 @@
import { z } from '@hono/zod-openapi' import { z } from '@hono/zod-openapi'
import parseIntervalAsMs from 'ms' 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. * Rate limit config for metered LineItems.
*/ */
export const rateLimitSchema = z export const rateLimitSchema = z
.object({ .union([
/** z.object({
* The interval at which the rate limit is applied. enabled: z.literal(false)
* }),
* Either a positive integer expressed in seconds or a valid positive z.object({
* [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", /**
* "1w", "1y", etc). * The interval at which the rate limit is applied.
*/ *
interval: z * Either a positive integer expressed in seconds or a valid positive
.union([ * [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d",
z.number().positive(), // seconds * "1w", "1y", etc).
*/
interval: z
.union([
z.number().positive(), // seconds
z z
.string() .string()
.nonempty() .nonempty()
.transform((value, ctx) => { .transform((value, ctx) => {
try { try {
// TODO: `ms` module has broken types // TODO: `ms` module has broken types
const ms = parseIntervalAsMs(value as any) as unknown as number const ms = parseIntervalAsMs(value as any) as unknown as number
const seconds = Math.floor(ms / 1000) const seconds = Math.floor(ms / 1000)
if ( if (
typeof ms !== 'number' || typeof ms !== 'number' ||
Number.isNaN(ms) || Number.isNaN(ms) ||
ms <= 0 || ms <= 0 ||
seconds <= 0 seconds <= 0
) { ) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`,
path: ctx.path
})
return z.NEVER
}
return seconds
} catch {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: `Invalid interval "${value}"`, message: `Invalid interval "${value}"`,
@ -40,52 +62,60 @@ export const rateLimitSchema = z
return z.NEVER 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 { * Maximum number of operations per interval (unitless).
ctx.addIssue({ */
code: z.ZodIssueCode.custom, limit: z
message: `Invalid interval "${value}"`, .number()
path: ctx.path .nonnegative()
}) .describe('Maximum number of operations per interval (unitless).'),
return z.NEVER /**
} * Whether to enforce the rate limit synchronously or asynchronously.
}) *
]) * The default rate-limiting mode is asynchronous, which means that requests
.describe( * are allowed to proceed immediately, with the limit being enforced in the
`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).` * 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).'
),
/** // TODO: Consider adding support for this in the future
* Maximum number of operations per interval (unitless). // /**
*/ // * The key to rate-limit by.
maxPerInterval: z // *
.number() // * - `ip`: Rate-limit by incoming IP address.
.nonnegative() // * - `customer`: Rate-limit by customer ID if available or IP address
.describe('Maximum number of operations per interval (unitless).'), // * otherwise.
// * - `global`: Rate-limit all usage globally across customers.
// *
// * @default 'customer'
// */
// rateLimitBy: rateLimitBySchema.optional().default('customer'),
/** enabled: z.boolean().optional().default(true)
* 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.'
)
})
.openapi('RateLimit') .openapi('RateLimit')
export type RateLimitInput = z.input<typeof rateLimitSchema> export type RateLimitInput = z.input<typeof rateLimitSchema>
export type RateLimit = z.infer<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 * Customize or disable rate limits for this tool for customers on a given
* pricing plan. * pricing plan.
* *
* Set to `null` to disable the default request-based rate-limiting for * To disable rate-limiting for this tool on a given pricing plan, set
* this tool on a given pricing plan. * `rateLimit.enabled` to `false`.
* *
* @default undefined * @default undefined
*/ */
rateLimit: z.union([rateLimitSchema, z.null()]).optional() rateLimit: rateLimitSchema.optional()
}) })
.openapi('PricingPlanToolOverride') .openapi('PricingPlanToolOverride')
export type PricingPlanToolOverride = z.infer< export type PricingPlanToolOverride = z.infer<
@ -158,14 +158,15 @@ export const toolConfigSchema = z
/** /**
* Customize the default `requests`-based rate-limiting for this tool. * 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 * If not set, the default rate-limiting for the active pricing plan will be
* used. * used.
* *
* @default undefined * @default undefined
*/ */
rateLimit: z.union([rateLimitSchema, z.null()]).optional(), rateLimit: rateLimitSchema.optional(),
/** /**
* Whether to allow additional properties in the tool's input schema. * 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`. * @note This is only relevant if the tool has defined an `outputSchema`.
* *
* @default true * @default undefined
*/ */
inputSchemaAdditionalProperties: z.boolean().optional(), inputSchemaAdditionalProperties: z.boolean().optional(),
@ -187,7 +188,7 @@ export const toolConfigSchema = z
* *
* @note This is only relevant if the tool has defined an `outputSchema`. * @note This is only relevant if the tool has defined an `outputSchema`.
* *
* @default true * @default undefined
*/ */
outputSchemaAdditionalProperties: z.boolean().optional(), outputSchemaAdditionalProperties: z.boolean().optional(),
@ -216,6 +217,7 @@ export const toolConfigSchema = z
// headers // headers
}) })
.openapi('ToolConfig') .openapi('ToolConfig')
export type ToolConfigInput = z.input<typeof toolConfigSchema> export type ToolConfigInput = z.input<typeof toolConfigSchema>
export type ToolConfig = z.infer<typeof toolConfigSchema> export type ToolConfig = z.infer<typeof toolConfigSchema>
@ -321,6 +323,3 @@ export const toolSchema = z
.passthrough() .passthrough()
.openapi('Tool') .openapi('Tool')
export type Tool = z.infer<typeof toolSchema> 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 * 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. * 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 { Simplify } from 'type-fest'
import type { components } from './openapi.d.ts' 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 Consumer = components['schemas']['Consumer']
export type Project = components['schemas']['Project'] export type Project = components['schemas']['Project']
@ -16,19 +18,24 @@ export type Team = components['schemas']['Team']
export type TeamMember = components['schemas']['TeamMember'] export type TeamMember = components['schemas']['TeamMember']
export type Deployment = Simplify< export type Deployment = Simplify<
Omit<components['schemas']['Deployment'], 'pricingPlans' | 'toolConfigs'> & { Omit<
components['schemas']['Deployment'],
'pricingPlans' | 'toolConfigs' | 'defaultRateLimit'
> & {
pricingPlans: PricingPlan[] pricingPlans: PricingPlan[]
toolConfigs: ToolConfig[] toolConfigs: ToolConfig[]
defaultRateLimit: RateLimit
} }
> >
export type AdminDeployment = Simplify< export type AdminDeployment = Simplify<
Omit< Omit<
components['schemas']['AdminDeployment'], components['schemas']['AdminDeployment'],
'pricingPlans' | 'toolConfigs' 'pricingPlans' | 'toolConfigs' | 'defaultRateLimit'
> & { > & {
pricingPlans: PricingPlan[] pricingPlans: PricingPlan[]
toolConfigs: ToolConfig[] toolConfigs: ToolConfig[]
defaultRateLimit: RateLimit
} }
> >

Wyświetl plik

@ -26,6 +26,7 @@
- mcp-kitchen-sink - mcp-kitchen-sink
- how to handle binary bodies and responses? - how to handle binary bodies and responses?
- improve logger vs console for non-hono path and util methods - improve logger vs console for non-hono path and util methods
- default rate limits?
- **test rate limiting** - **test rate limiting**
- **test usage tracking and reporting** - **test usage tracking and reporting**
- disallow `mcp` as a tool name or figure out another workaround - disallow `mcp` as a tool name or figure out another workaround
@ -80,6 +81,11 @@
- handle or validate against dynamic MCP origin tools - handle or validate against dynamic MCP origin tools
- allow config name to be `project-name` or `@namespace/project-name`? - allow config name to be `project-name` or `@namespace/project-name`?
- upgrade to zod v4 - 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 ## License