kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: improve rate-limits support
rodzic
eda5915fe9
commit
0216b3612c
|
@ -1,7 +1,9 @@
|
|||
import {
|
||||
agenticProjectConfigSchema,
|
||||
defaultRequestsRateLimit,
|
||||
type OriginAdapter,
|
||||
type PricingPlanList,
|
||||
type RateLimit,
|
||||
resolvedAgenticProjectConfigSchema,
|
||||
type Tool,
|
||||
type ToolConfig
|
||||
|
@ -92,7 +94,16 @@ export const deployments = pgTable(
|
|||
pricingPlans: jsonb().$type<PricingPlanList>().notNull(),
|
||||
|
||||
// Which pricing intervals are supported for subscriptions to this project
|
||||
pricingIntervals: pricingIntervalEnum().array().default(['month']).notNull()
|
||||
pricingIntervals: pricingIntervalEnum()
|
||||
.array()
|
||||
.default(['month'])
|
||||
.notNull(),
|
||||
|
||||
// Default rate limit across all pricing plans
|
||||
defaultRateLimit: jsonb()
|
||||
.$type<RateLimit>()
|
||||
.notNull()
|
||||
.default(defaultRequestsRateLimit)
|
||||
|
||||
// TODO: metadata config (logo, keywords, examples, etc)
|
||||
// TODO: webhooks
|
||||
|
@ -159,7 +170,8 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
|
|||
pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans,
|
||||
pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals,
|
||||
tools: resolvedAgenticProjectConfigSchema.shape.tools,
|
||||
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs
|
||||
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs,
|
||||
defaultRateLimit: resolvedAgenticProjectConfigSchema.shape.defaultRateLimit
|
||||
})
|
||||
.omit({
|
||||
originUrl: true
|
||||
|
|
|
@ -16,7 +16,7 @@ const fixtures = [
|
|||
// 'pricing-3-plans',
|
||||
// 'pricing-monthly-annual',
|
||||
// 'pricing-custom-0',
|
||||
// 'basic-openapi',
|
||||
'basic-openapi',
|
||||
'basic-mcp',
|
||||
'everything-openapi'
|
||||
]
|
||||
|
|
|
@ -63,6 +63,7 @@ export const fixtureSuites: E2ETestFixtureSuite[] = [
|
|||
fixtures: [
|
||||
{
|
||||
path: '@dev/test-basic-openapi/getPost',
|
||||
only: true,
|
||||
request: {
|
||||
method: 'POST',
|
||||
json: {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -39,13 +39,13 @@ test('AgenticProjectConfig input types', () => {
|
|||
usageType: 'metered'
|
||||
billingScheme: 'per_unit'
|
||||
unitAmount: 50
|
||||
rateLimit: {
|
||||
// Make sure `interval` can use a string as input
|
||||
interval: '30s'
|
||||
maxPerInterval: 100
|
||||
}
|
||||
}
|
||||
]
|
||||
rateLimit: {
|
||||
// Make sure `interval` can use a string as input
|
||||
interval: '30s'
|
||||
limit: 100
|
||||
}
|
||||
}
|
||||
]
|
||||
}>().toExtend<AgenticProjectConfigInput>()
|
||||
|
@ -63,13 +63,13 @@ test('AgenticProjectConfig input types', () => {
|
|||
usageType: 'metered'
|
||||
billingScheme: 'per_unit'
|
||||
unitAmount: 50
|
||||
rateLimit: {
|
||||
// Make sure `interval` can use a number as input
|
||||
interval: 300
|
||||
maxPerInterval: 100
|
||||
}
|
||||
}
|
||||
]
|
||||
rateLimit: {
|
||||
// Make sure `interval` can use a number as input
|
||||
interval: 300
|
||||
limit: 100
|
||||
}
|
||||
}
|
||||
]
|
||||
}>().toExtend<AgenticProjectConfigInput>()
|
||||
|
|
|
@ -7,11 +7,17 @@ import {
|
|||
} from './origin-adapter'
|
||||
import {
|
||||
defaultFreePricingPlan,
|
||||
defaultRequestsRateLimit,
|
||||
pricingIntervalListSchema,
|
||||
type PricingPlanList,
|
||||
type PricingPlanListInput,
|
||||
pricingPlanListSchema
|
||||
} from './pricing'
|
||||
import {
|
||||
type RateLimit,
|
||||
type RateLimitInput,
|
||||
rateLimitSchema
|
||||
} from './rate-limit'
|
||||
import {
|
||||
type ToolConfig,
|
||||
type ToolConfigInput,
|
||||
|
@ -136,6 +142,20 @@ To add support for annual pricing plans, for example, you can use: \`['month', '
|
|||
.optional()
|
||||
.default(['month']),
|
||||
|
||||
/**
|
||||
* Optional default rate limits to enforce for all pricing plans.
|
||||
*
|
||||
* To disable the default rate-limit, set `defaultRateLimit.enabled` to
|
||||
* `false`.
|
||||
*
|
||||
* Note that pricing-plan-specific rate-limits override this default (via
|
||||
* `pricingPlans`), and tool-specific rate-limits may override both default
|
||||
* and pricing-plan-specific rate-limits (via `toolConfigs`).
|
||||
*/
|
||||
defaultRateLimit: rateLimitSchema
|
||||
.optional()
|
||||
.default(defaultRequestsRateLimit),
|
||||
|
||||
/**
|
||||
* Optional list of tool configs to customize the behavior of tools.
|
||||
*
|
||||
|
@ -170,6 +190,7 @@ export type AgenticProjectConfigInput = Simplify<
|
|||
> & {
|
||||
pricingPlans?: PricingPlanListInput
|
||||
toolConfigs?: ToolConfigInput[]
|
||||
defaultRateLimit?: RateLimitInput
|
||||
}
|
||||
>
|
||||
export type AgenticProjectConfigRaw = z.output<
|
||||
|
@ -179,6 +200,7 @@ export type AgenticProjectConfig = Simplify<
|
|||
Omit<AgenticProjectConfigRaw, 'pricingPlans' | 'toolConfigs'> & {
|
||||
pricingPlans: PricingPlanList
|
||||
toolConfigs: ToolConfig[]
|
||||
defaultRateLimit: RateLimit
|
||||
}
|
||||
>
|
||||
|
||||
|
@ -194,5 +216,6 @@ export type ResolvedAgenticProjectConfig = Simplify<
|
|||
> & {
|
||||
pricingPlans: PricingPlanList
|
||||
toolConfigs: ToolConfig[]
|
||||
defaultRateLimit: RateLimit
|
||||
}
|
||||
>
|
||||
|
|
|
@ -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"][];
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { Simplify } from 'type-fest'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { rateLimitSchema } from './rate-limit'
|
||||
import { type RateLimit, rateLimitSchema } from './rate-limit'
|
||||
|
||||
/**
|
||||
* PricingPlanTier is a single tier in a tiered pricing plan.
|
||||
|
@ -175,14 +175,6 @@ export const pricingPlanMeteredLineItemSchema =
|
|||
*/
|
||||
unitLabel: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Optional rate limit to enforce for this metered line-item.
|
||||
*
|
||||
* You can use this, for example, to limit the number of API calls that
|
||||
* can be made during a given interval.
|
||||
*/
|
||||
rateLimit: rateLimitSchema.optional(),
|
||||
|
||||
/**
|
||||
* Describes how to compute the price per period. Either `per_unit` or
|
||||
* `tiered`.
|
||||
|
@ -473,6 +465,15 @@ export type PricingPlanLineItem =
|
|||
*/
|
||||
export const pricingPlanSchema = z
|
||||
.object({
|
||||
/**
|
||||
* Human-readable name for the pricing plan.
|
||||
*
|
||||
* Used in UI and billing invoices.
|
||||
*
|
||||
* @example "Free"
|
||||
* @example "Starter Monthly"
|
||||
* @example "Pro Annual"
|
||||
*/
|
||||
name: z
|
||||
.string()
|
||||
.nonempty()
|
||||
|
@ -481,6 +482,21 @@ export const pricingPlanSchema = z
|
|||
)
|
||||
.openapi('name', { example: 'Starter Monthly' }),
|
||||
|
||||
/**
|
||||
* A unique slug for the pricing plan which acts as a stable identifier
|
||||
* across deployments.
|
||||
*
|
||||
* Should be lower-kebab-cased.
|
||||
* Should be stable across deployments.
|
||||
*
|
||||
* For all plans aside from `free`, the `slug` should include the `interval`
|
||||
* as a suffix so pricing plans can be uniquely differentiated from each
|
||||
* other across billing intervals.
|
||||
*
|
||||
* @example "free"
|
||||
* @example "starter-monthly"
|
||||
* @example "pro-annual"
|
||||
*/
|
||||
slug: z
|
||||
.string()
|
||||
.nonempty()
|
||||
|
@ -495,12 +511,12 @@ export const pricingPlanSchema = z
|
|||
interval: pricingIntervalSchema.optional(),
|
||||
|
||||
/**
|
||||
* Optional description of the PricingPlan which is used for UI-only.
|
||||
* Optional description of the pricing plan (UI-only).
|
||||
*/
|
||||
description: z.string().optional(),
|
||||
|
||||
/**
|
||||
* Optional list of features of the PricingPlan which is used for UI-only.
|
||||
* Optional list of features of the pricing plan (UI-only).
|
||||
*/
|
||||
features: z.array(z.string()).optional(),
|
||||
|
||||
|
@ -510,6 +526,23 @@ export const pricingPlanSchema = z
|
|||
*/
|
||||
trialPeriodDays: z.number().nonnegative().optional(),
|
||||
|
||||
/**
|
||||
* Optional rate limit to enforce for this pricing plan.
|
||||
*
|
||||
* You can use this to limit the number of API requests that can be made by
|
||||
* a customer during a given interval.
|
||||
*
|
||||
* If not set, the pricing plan will inherit the default platform rate-limit
|
||||
* set by `defaultRateLimit` in the Agentic project config.
|
||||
*
|
||||
* You can disable rate-limiting for this pricing plan by setting
|
||||
* `rateLimit.enabled` to `false`.
|
||||
*
|
||||
* Note that tool-specific rate limits may override pricing-plan-specific
|
||||
* rate-limits via `toolConfigs` in the Agentic project config.
|
||||
*/
|
||||
rateLimit: rateLimitSchema.optional(),
|
||||
|
||||
/**
|
||||
* List of custom LineItems which are included in the PricingPlan.
|
||||
*
|
||||
|
@ -601,6 +634,21 @@ export type StripeSubscriptionItemIdMap = z.infer<
|
|||
// .openapi('Coupon')
|
||||
// export type Coupon = z.infer<typeof couponSchema>
|
||||
|
||||
/**
|
||||
* The default platform rate limit for `requests` is a limit of 1000 requests
|
||||
* per minute per customer.
|
||||
*/
|
||||
export const defaultRequestsRateLimit = {
|
||||
interval: 60,
|
||||
limit: 1000,
|
||||
async: true,
|
||||
enabled: true
|
||||
} as const satisfies Readonly<RateLimit>
|
||||
|
||||
/**
|
||||
* The default free pricing plan which is used for projects that don't specify
|
||||
* custom pricing plans.
|
||||
*/
|
||||
export const defaultFreePricingPlan = {
|
||||
name: 'Free',
|
||||
slug: 'free',
|
||||
|
@ -610,5 +658,6 @@ export const defaultFreePricingPlan = {
|
|||
usageType: 'licensed',
|
||||
amount: 0
|
||||
}
|
||||
]
|
||||
],
|
||||
rateLimit: defaultRequestsRateLimit
|
||||
} as const satisfies Readonly<PricingPlan>
|
||||
|
|
|
@ -10,52 +10,67 @@ test('rateLimitSchema valid', () => {
|
|||
expect(
|
||||
rateLimitSchema.parse({
|
||||
interval: 10,
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
|
||||
expect(
|
||||
rateLimitSchema.parse({
|
||||
interval: '10s',
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
|
||||
expect(
|
||||
rateLimitSchema.parse({
|
||||
interval: '1 day',
|
||||
maxPerInterval: 1000,
|
||||
limit: 1000,
|
||||
async: false
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
|
||||
expect(
|
||||
rateLimitSchema.parse({
|
||||
enabled: false
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
|
||||
expect(
|
||||
rateLimitSchema.parse({
|
||||
interval: '10m',
|
||||
limit: 100,
|
||||
async: false,
|
||||
enabled: false
|
||||
})
|
||||
).toMatchSnapshot()
|
||||
})
|
||||
|
||||
test('rateLimitSchema invalid', () => {
|
||||
expect(() =>
|
||||
rateLimitSchema.parse({
|
||||
interval: '',
|
||||
maxPerInterval: 5
|
||||
limit: 5
|
||||
})
|
||||
).toThrowErrorMatchingSnapshot()
|
||||
|
||||
expect(() =>
|
||||
rateLimitSchema.parse({
|
||||
interval: 0,
|
||||
maxPerInterval: 5
|
||||
limit: 5
|
||||
})
|
||||
).toThrowErrorMatchingSnapshot()
|
||||
|
||||
expect(() =>
|
||||
rateLimitSchema.parse({
|
||||
interval: '--',
|
||||
maxPerInterval: 10
|
||||
limit: 10
|
||||
})
|
||||
).toThrowErrorMatchingSnapshot()
|
||||
|
||||
expect(() =>
|
||||
rateLimitSchema.parse({
|
||||
interval: '1 day',
|
||||
maxPerInterval: -1000
|
||||
limit: -1000
|
||||
})
|
||||
).toThrowErrorMatchingSnapshot()
|
||||
})
|
||||
|
@ -63,58 +78,78 @@ test('rateLimitSchema invalid', () => {
|
|||
test('RateLimit types', () => {
|
||||
expectTypeOf({
|
||||
interval: 10,
|
||||
maxPerInterval: 100,
|
||||
async: false
|
||||
limit: 100,
|
||||
async: false,
|
||||
enabled: true
|
||||
} as const).toExtend<RateLimit>()
|
||||
|
||||
expectTypeOf<{
|
||||
interval: 10
|
||||
maxPerInterval: 100
|
||||
}>().toExtend<RateLimitInput>()
|
||||
limit: 100
|
||||
async: false
|
||||
enabled: true
|
||||
}>().toExtend<RateLimit>()
|
||||
|
||||
expectTypeOf({
|
||||
interval: '10s',
|
||||
maxPerInterval: 100,
|
||||
async: true
|
||||
limit: 100,
|
||||
async: true,
|
||||
enabled: true
|
||||
} as const).not.toExtend<RateLimit>()
|
||||
|
||||
expectTypeOf<{
|
||||
interval: '10s'
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
async: false
|
||||
}>().not.toExtend<RateLimit>()
|
||||
|
||||
expectTypeOf({
|
||||
enabled: false
|
||||
} as const).toExtend<RateLimit>()
|
||||
|
||||
expectTypeOf<{
|
||||
enabled: false
|
||||
}>().toExtend<RateLimit>()
|
||||
})
|
||||
|
||||
test('RateLimitInput types', () => {
|
||||
expectTypeOf({
|
||||
interval: 10,
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
} as const).toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf<{
|
||||
interval: 10
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
}>().toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf({
|
||||
interval: 10,
|
||||
maxPerInterval: 100,
|
||||
limit: 100,
|
||||
async: false
|
||||
} as const).toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf<{
|
||||
interval: 10
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
async: boolean
|
||||
}>().toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf({
|
||||
interval: '3h',
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
} as const).toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf<{
|
||||
interval: '3h'
|
||||
maxPerInterval: 100
|
||||
limit: 100
|
||||
}>().toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf({
|
||||
enabled: false
|
||||
} as const).toExtend<RateLimitInput>()
|
||||
|
||||
expectTypeOf<{
|
||||
enabled: false
|
||||
}>().toExtend<RateLimitInput>()
|
||||
})
|
||||
|
|
|
@ -1,37 +1,59 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
import parseIntervalAsMs from 'ms'
|
||||
|
||||
// TODO: Consider adding support for this in the future
|
||||
// export const rateLimitBySchema = z.union([
|
||||
// z.literal('ip'),
|
||||
// z.literal('customer'),
|
||||
// z.literal('all')
|
||||
// ])
|
||||
|
||||
/**
|
||||
* Rate limit config for metered LineItems.
|
||||
*/
|
||||
export const rateLimitSchema = z
|
||||
.object({
|
||||
/**
|
||||
* The interval at which the rate limit is applied.
|
||||
*
|
||||
* Either a positive integer expressed in seconds or a valid positive
|
||||
* [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d",
|
||||
* "1w", "1y", etc).
|
||||
*/
|
||||
interval: z
|
||||
.union([
|
||||
z.number().positive(), // seconds
|
||||
.union([
|
||||
z.object({
|
||||
enabled: z.literal(false)
|
||||
}),
|
||||
z.object({
|
||||
/**
|
||||
* The interval at which the rate limit is applied.
|
||||
*
|
||||
* Either a positive integer expressed in seconds or a valid positive
|
||||
* [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d",
|
||||
* "1w", "1y", etc).
|
||||
*/
|
||||
interval: z
|
||||
.union([
|
||||
z.number().positive(), // seconds
|
||||
|
||||
z
|
||||
.string()
|
||||
.nonempty()
|
||||
.transform((value, ctx) => {
|
||||
try {
|
||||
// TODO: `ms` module has broken types
|
||||
const ms = parseIntervalAsMs(value as any) as unknown as number
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
z
|
||||
.string()
|
||||
.nonempty()
|
||||
.transform((value, ctx) => {
|
||||
try {
|
||||
// TODO: `ms` module has broken types
|
||||
const ms = parseIntervalAsMs(value as any) as unknown as number
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
|
||||
if (
|
||||
typeof ms !== 'number' ||
|
||||
Number.isNaN(ms) ||
|
||||
ms <= 0 ||
|
||||
seconds <= 0
|
||||
) {
|
||||
if (
|
||||
typeof ms !== 'number' ||
|
||||
Number.isNaN(ms) ||
|
||||
ms <= 0 ||
|
||||
seconds <= 0
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid interval "${value}"`,
|
||||
path: ctx.path
|
||||
})
|
||||
|
||||
return z.NEVER
|
||||
}
|
||||
|
||||
return seconds
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid interval "${value}"`,
|
||||
|
@ -40,52 +62,60 @@ export const rateLimitSchema = z
|
|||
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
])
|
||||
.describe(
|
||||
`The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc).`
|
||||
),
|
||||
|
||||
return seconds
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Invalid interval "${value}"`,
|
||||
path: ctx.path
|
||||
})
|
||||
/**
|
||||
* Maximum number of operations per interval (unitless).
|
||||
*/
|
||||
limit: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.describe('Maximum number of operations per interval (unitless).'),
|
||||
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
])
|
||||
.describe(
|
||||
`The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc).`
|
||||
),
|
||||
/**
|
||||
* Whether to enforce the rate limit synchronously or asynchronously.
|
||||
*
|
||||
* The default rate-limiting mode is asynchronous, which means that requests
|
||||
* are allowed to proceed immediately, with the limit being enforced in the
|
||||
* background. This is much faster than synchronous mode, but it is less
|
||||
* consistent if precise adherence to rate-limits is required.
|
||||
*
|
||||
* With synchronous mode, requests are blocked until the current limit has
|
||||
* been confirmed. The downside with this approach is that it introduces
|
||||
* more latency to every request by default. The advantage is that it is
|
||||
* more precise and consistent.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
async: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(
|
||||
'Whether to enforce the rate limit synchronously (strict but slower) or asynchronously (approximate and faster, the default).'
|
||||
),
|
||||
|
||||
/**
|
||||
* Maximum number of operations per interval (unitless).
|
||||
*/
|
||||
maxPerInterval: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.describe('Maximum number of operations per interval (unitless).'),
|
||||
// TODO: Consider adding support for this in the future
|
||||
// /**
|
||||
// * The key to rate-limit by.
|
||||
// *
|
||||
// * - `ip`: Rate-limit by incoming IP address.
|
||||
// * - `customer`: Rate-limit by customer ID if available or IP address
|
||||
// * otherwise.
|
||||
// * - `global`: Rate-limit all usage globally across customers.
|
||||
// *
|
||||
// * @default 'customer'
|
||||
// */
|
||||
// rateLimitBy: rateLimitBySchema.optional().default('customer'),
|
||||
|
||||
/**
|
||||
* Whether to enforce the rate limit synchronously or asynchronously.
|
||||
*
|
||||
* The default rate-limiting mode is asynchronous, which means that requests
|
||||
* are allowed to proceed immediately, with the limit being enforced in the
|
||||
* background. This is much faster than synchronous mode, but it is less
|
||||
* consistent if precise adherence to rate-limits is required.
|
||||
*
|
||||
* With synchronous mode, requests will be blocked until the current limit
|
||||
* has been confirmed. The downside with this approach is that it can
|
||||
* introduce more latency to every request by default. The advantage is that
|
||||
* it is more accurate and consistent.
|
||||
*/
|
||||
async: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(
|
||||
'Whether to enforce the rate limit synchronously or asynchronously.'
|
||||
)
|
||||
})
|
||||
enabled: z.boolean().optional().default(true)
|
||||
})
|
||||
])
|
||||
.openapi('RateLimit')
|
||||
|
||||
export type RateLimitInput = z.input<typeof rateLimitSchema>
|
||||
export type RateLimit = z.infer<typeof rateLimitSchema>
|
||||
|
|
|
@ -76,12 +76,12 @@ export const pricingPlanToolOverrideSchema = z
|
|||
* Customize or disable rate limits for this tool for customers on a given
|
||||
* pricing plan.
|
||||
*
|
||||
* Set to `null` to disable the default request-based rate-limiting for
|
||||
* this tool on a given pricing plan.
|
||||
* To disable rate-limiting for this tool on a given pricing plan, set
|
||||
* `rateLimit.enabled` to `false`.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
rateLimit: z.union([rateLimitSchema, z.null()]).optional()
|
||||
rateLimit: rateLimitSchema.optional()
|
||||
})
|
||||
.openapi('PricingPlanToolOverride')
|
||||
export type PricingPlanToolOverride = z.infer<
|
||||
|
@ -158,14 +158,15 @@ export const toolConfigSchema = z
|
|||
/**
|
||||
* Customize the default `requests`-based rate-limiting for this tool.
|
||||
*
|
||||
* Set to `null` to disable the built-in rate-limiting.
|
||||
* To disable rate-limiting for this tool, set `rateLimit.enabled` to
|
||||
* `false`.
|
||||
*
|
||||
* If not set, the default rate-limiting for the active pricing plan will be
|
||||
* used.
|
||||
*
|
||||
* @default undefined
|
||||
*/
|
||||
rateLimit: z.union([rateLimitSchema, z.null()]).optional(),
|
||||
rateLimit: rateLimitSchema.optional(),
|
||||
|
||||
/**
|
||||
* Whether to allow additional properties in the tool's input schema.
|
||||
|
@ -175,7 +176,7 @@ export const toolConfigSchema = z
|
|||
*
|
||||
* @note This is only relevant if the tool has defined an `outputSchema`.
|
||||
*
|
||||
* @default true
|
||||
* @default undefined
|
||||
*/
|
||||
inputSchemaAdditionalProperties: z.boolean().optional(),
|
||||
|
||||
|
@ -187,7 +188,7 @@ export const toolConfigSchema = z
|
|||
*
|
||||
* @note This is only relevant if the tool has defined an `outputSchema`.
|
||||
*
|
||||
* @default true
|
||||
* @default undefined
|
||||
*/
|
||||
outputSchemaAdditionalProperties: z.boolean().optional(),
|
||||
|
||||
|
@ -216,6 +217,7 @@ export const toolConfigSchema = z
|
|||
// headers
|
||||
})
|
||||
.openapi('ToolConfig')
|
||||
|
||||
export type ToolConfigInput = z.input<typeof toolConfigSchema>
|
||||
export type ToolConfig = z.infer<typeof toolConfigSchema>
|
||||
|
||||
|
@ -321,6 +323,3 @@ export const toolSchema = z
|
|||
.passthrough()
|
||||
.openapi('Tool')
|
||||
export type Tool = z.infer<typeof toolSchema>
|
||||
|
||||
// export const toolMapSchema = z.record(toolNameSchema, toolSchema)
|
||||
// export type ToolMap = z.infer<typeof toolMapSchema>
|
||||
|
|
|
@ -4,10 +4,12 @@
|
|||
* types than what `@hono/zod-openapi` and `zod` v3 provide, but in general
|
||||
* these types are meant to use the backend API as a source of truth.
|
||||
*/
|
||||
import type { PricingPlan, ToolConfig } from '@agentic/platform-types'
|
||||
import type { Simplify } from 'type-fest'
|
||||
|
||||
import type { components } from './openapi.d.ts'
|
||||
import type { PricingPlan } from './pricing'
|
||||
import type { RateLimit } from './rate-limit.js'
|
||||
import type { ToolConfig } from './tools'
|
||||
|
||||
export type Consumer = components['schemas']['Consumer']
|
||||
export type Project = components['schemas']['Project']
|
||||
|
@ -16,19 +18,24 @@ export type Team = components['schemas']['Team']
|
|||
export type TeamMember = components['schemas']['TeamMember']
|
||||
|
||||
export type Deployment = Simplify<
|
||||
Omit<components['schemas']['Deployment'], 'pricingPlans' | 'toolConfigs'> & {
|
||||
Omit<
|
||||
components['schemas']['Deployment'],
|
||||
'pricingPlans' | 'toolConfigs' | 'defaultRateLimit'
|
||||
> & {
|
||||
pricingPlans: PricingPlan[]
|
||||
toolConfigs: ToolConfig[]
|
||||
defaultRateLimit: RateLimit
|
||||
}
|
||||
>
|
||||
|
||||
export type AdminDeployment = Simplify<
|
||||
Omit<
|
||||
components['schemas']['AdminDeployment'],
|
||||
'pricingPlans' | 'toolConfigs'
|
||||
'pricingPlans' | 'toolConfigs' | 'defaultRateLimit'
|
||||
> & {
|
||||
pricingPlans: PricingPlan[]
|
||||
toolConfigs: ToolConfig[]
|
||||
defaultRateLimit: RateLimit
|
||||
}
|
||||
>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue