From 0216b3612c9ea318d67f47fb2989734789f6ac3c Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Fri, 13 Jun 2025 02:07:52 +0700 Subject: [PATCH] feat: improve rate-limits support --- apps/api/src/db/schema/deployment.ts | 16 +- apps/e2e/bin/deploy-fixtures.ts | 2 +- apps/e2e/src/http-fixtures.ts | 1 + .../src/lib/rate-limits/enforce-rate-limit.ts | 19 +- .../src/lib/resolve-origin-tool-call.ts | 32 ++-- apps/gateway/src/lib/types.ts | 2 + packages/core/src/rate-limit-headers.ts | 37 +++- .../everything-openapi/agentic.config.ts | 6 +- .../valid/pricing-custom-0/agentic.config.ts | 28 +-- .../pricing-pay-as-you-go/agentic.config.ts | 18 +- .../load-agentic-config.test.ts.snap | 146 ++++++++++++++-- .../src/__snapshots__/rate-limit.test.ts.snap | 78 +++++++-- .../types/src/agentic-project-config.test.ts | 20 +-- packages/types/src/agentic-project-config.ts | 23 +++ packages/types/src/openapi.d.ts | 33 +++- packages/types/src/pricing.ts | 73 ++++++-- packages/types/src/rate-limit.test.ts | 75 +++++--- packages/types/src/rate-limit.ts | 164 +++++++++++------- packages/types/src/tools.ts | 19 +- packages/types/src/types.ts | 13 +- readme.md | 6 + 21 files changed, 581 insertions(+), 230 deletions(-) diff --git a/apps/api/src/db/schema/deployment.ts b/apps/api/src/db/schema/deployment.ts index 7c1dafd3..21f1caff 100644 --- a/apps/api/src/db/schema/deployment.ts +++ b/apps/api/src/db/schema/deployment.ts @@ -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().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() + .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 diff --git a/apps/e2e/bin/deploy-fixtures.ts b/apps/e2e/bin/deploy-fixtures.ts index 615510c2..12afcc8f 100644 --- a/apps/e2e/bin/deploy-fixtures.ts +++ b/apps/e2e/bin/deploy-fixtures.ts @@ -16,7 +16,7 @@ const fixtures = [ // 'pricing-3-plans', // 'pricing-monthly-annual', // 'pricing-custom-0', - // 'basic-openapi', + 'basic-openapi', 'basic-mcp', 'everything-openapi' ] diff --git a/apps/e2e/src/http-fixtures.ts b/apps/e2e/src/http-fixtures.ts index b7ec251c..c71363f5 100644 --- a/apps/e2e/src/http-fixtures.ts +++ b/apps/e2e/src/http-fixtures.ts @@ -63,6 +63,7 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [ fixtures: [ { path: '@dev/test-basic-openapi/getPost', + only: true, request: { method: 'POST', json: { diff --git a/apps/gateway/src/lib/rate-limits/enforce-rate-limit.ts b/apps/gateway/src/lib/rate-limits/enforce-rate-limit.ts index e94bd4af..6a12ec65 100644 --- a/apps/gateway/src/lib/rate-limits/enforce-rate-limit.ts +++ b/apps/gateway/src/lib/rate-limits/enforce-rate-limit.ts @@ -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) } } diff --git a/apps/gateway/src/lib/resolve-origin-tool-call.ts b/apps/gateway/src/lib/resolve-origin-tool-call.ts index a12de755..5170eca3 100644 --- a/apps/gateway/src/lib/resolve-origin-tool-call.ts +++ b/apps/gateway/src/lib/resolve-origin-tool-call.ts @@ -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, diff --git a/apps/gateway/src/lib/types.ts b/apps/gateway/src/lib/types.ts index 39400e7d..2bc77434 100644 --- a/apps/gateway/src/lib/types.ts +++ b/apps/gateway/src/lib/types.ts @@ -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 diff --git a/packages/core/src/rate-limit-headers.ts b/packages/core/src/rate-limit-headers.ts index 0e48486f..9d799b16 100644 --- a/packages/core/src/rate-limit-headers.ts +++ b/packages/core/src/rate-limit-headers.ts @@ -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 } diff --git a/packages/fixtures/valid/everything-openapi/agentic.config.ts b/packages/fixtures/valid/everything-openapi/agentic.config.ts index 412453ab..d89df7e8 100644 --- a/packages/fixtures/valid/everything-openapi/agentic.config.ts +++ b/packages/fixtures/valid/everything-openapi/agentic.config.ts @@ -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', diff --git a/packages/fixtures/valid/pricing-custom-0/agentic.config.ts b/packages/fixtures/valid/pricing-custom-0/agentic.config.ts index b1321a5f..be7eef7d 100644 --- a/packages/fixtures/valid/pricing-custom-0/agentic.config.ts +++ b/packages/fixtures/valid/pricing-custom-0/agentic.config.ts @@ -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 + } } ] }) diff --git a/packages/fixtures/valid/pricing-pay-as-you-go/agentic.config.ts b/packages/fixtures/valid/pricing-pay-as-you-go/agentic.config.ts index e501cbbe..1dc29c26 100644 --- a/packages/fixtures/valid/pricing-pay-as-you-go/agentic.config.ts +++ b/packages/fixtures/valid/pricing-pay-as-you-go/agentic.config.ts @@ -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' + } } ] }) diff --git a/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap b/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap index a51f0809..ef4bd847 100644 --- a/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap +++ b/packages/platform/src/__snapshots__/load-agentic-config.test.ts.snap @@ -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", }, ], diff --git a/packages/types/src/__snapshots__/rate-limit.test.ts.snap b/packages/types/src/__snapshots__/rate-limit.test.ts.snap index b5bde10d..c583b4c8 100644 --- a/packages/types/src/__snapshots__/rate-limit.test.ts.snap +++ b/packages/types/src/__snapshots__/rate-limit.test.ts.snap @@ -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, } `; diff --git a/packages/types/src/agentic-project-config.test.ts b/packages/types/src/agentic-project-config.test.ts index 7fb95710..76bdf446 100644 --- a/packages/types/src/agentic-project-config.test.ts +++ b/packages/types/src/agentic-project-config.test.ts @@ -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() @@ -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() diff --git a/packages/types/src/agentic-project-config.ts b/packages/types/src/agentic-project-config.ts index 7726bbfa..7dc8edef 100644 --- a/packages/types/src/agentic-project-config.ts +++ b/packages/types/src/agentic-project-config.ts @@ -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 & { pricingPlans: PricingPlanList toolConfigs: ToolConfig[] + defaultRateLimit: RateLimit } > @@ -194,5 +216,6 @@ export type ResolvedAgenticProjectConfig = Simplify< > & { pricingPlans: PricingPlanList toolConfigs: ToolConfig[] + defaultRateLimit: RateLimit } > diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index 17d72482..6f36398c 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -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"][]; }; diff --git a/packages/types/src/pricing.ts b/packages/types/src/pricing.ts index 46959ff7..21869a85 100644 --- a/packages/types/src/pricing.ts +++ b/packages/types/src/pricing.ts @@ -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 +/** + * 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 + +/** + * 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 diff --git a/packages/types/src/rate-limit.test.ts b/packages/types/src/rate-limit.test.ts index 584b52f3..cfe048da 100644 --- a/packages/types/src/rate-limit.test.ts +++ b/packages/types/src/rate-limit.test.ts @@ -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() expectTypeOf<{ interval: 10 - maxPerInterval: 100 - }>().toExtend() + limit: 100 + async: false + enabled: true + }>().toExtend() expectTypeOf({ interval: '10s', - maxPerInterval: 100, - async: true + limit: 100, + async: true, + enabled: true } as const).not.toExtend() expectTypeOf<{ interval: '10s' - maxPerInterval: 100 + limit: 100 async: false }>().not.toExtend() + + expectTypeOf({ + enabled: false + } as const).toExtend() + + expectTypeOf<{ + enabled: false + }>().toExtend() }) test('RateLimitInput types', () => { expectTypeOf({ interval: 10, - maxPerInterval: 100 + limit: 100 } as const).toExtend() expectTypeOf<{ interval: 10 - maxPerInterval: 100 + limit: 100 }>().toExtend() expectTypeOf({ interval: 10, - maxPerInterval: 100, + limit: 100, async: false } as const).toExtend() expectTypeOf<{ interval: 10 - maxPerInterval: 100 + limit: 100 async: boolean }>().toExtend() expectTypeOf({ interval: '3h', - maxPerInterval: 100 + limit: 100 } as const).toExtend() expectTypeOf<{ interval: '3h' - maxPerInterval: 100 + limit: 100 + }>().toExtend() + + expectTypeOf({ + enabled: false + } as const).toExtend() + + expectTypeOf<{ + enabled: false }>().toExtend() }) diff --git a/packages/types/src/rate-limit.ts b/packages/types/src/rate-limit.ts index 7255cca6..b8ccfbaa 100644 --- a/packages/types/src/rate-limit.ts +++ b/packages/types/src/rate-limit.ts @@ -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 export type RateLimit = z.infer diff --git a/packages/types/src/tools.ts b/packages/types/src/tools.ts index f736a4e8..6fb00899 100644 --- a/packages/types/src/tools.ts +++ b/packages/types/src/tools.ts @@ -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 export type ToolConfig = z.infer @@ -321,6 +323,3 @@ export const toolSchema = z .passthrough() .openapi('Tool') export type Tool = z.infer - -// export const toolMapSchema = z.record(toolNameSchema, toolSchema) -// export type ToolMap = z.infer diff --git a/packages/types/src/types.ts b/packages/types/src/types.ts index 415fef14..fb9a09e0 100644 --- a/packages/types/src/types.ts +++ b/packages/types/src/types.ts @@ -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 & { + 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 } > diff --git a/readme.md b/readme.md index 84d0209d..8a7c2920 100644 --- a/readme.md +++ b/readme.md @@ -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