From 23364946ebaf9ceccf0de3985866d15d362721d5 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Sun, 25 May 2025 22:46:54 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=98=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/billing/upsert-stripe-pricing.ts | 2 +- .../pricing-freemium/agentic.config.ts | 8 +- .../load-agentic-config.test.ts.snap | 49 +++- packages/cli/src/lib/load-agentic-config.ts | 10 +- .../cli/src/lib/validate-agentic-config.ts | 224 ++++++++++++++++++ .../src/agentic-project-config-schema.ts | 28 ++- packages/schemas/src/schemas.ts | 185 ++++++--------- 7 files changed, 364 insertions(+), 142 deletions(-) create mode 100644 packages/cli/src/lib/validate-agentic-config.ts diff --git a/apps/api/src/lib/billing/upsert-stripe-pricing.ts b/apps/api/src/lib/billing/upsert-stripe-pricing.ts index 39de9179..6f73b428 100644 --- a/apps/api/src/lib/billing/upsert-stripe-pricing.ts +++ b/apps/api/src/lib/billing/upsert-stripe-pricing.ts @@ -187,7 +187,7 @@ export async function upsertStripePricing({ ) priceParams.tiers_mode = pricingPlanLineItem.tiersMode - priceParams.tiers = pricingPlanLineItem.tiers!.map((tierData) => { + priceParams.tiers = pricingPlanLineItem.tiers.map((tierData) => { const tier: Stripe.PriceCreateParams.Tier = { up_to: tierData.upTo } diff --git a/packages/cli/fixtures/pricing-freemium/agentic.config.ts b/packages/cli/fixtures/pricing-freemium/agentic.config.ts index f4156f9d..81b59b14 100644 --- a/packages/cli/fixtures/pricing-freemium/agentic.config.ts +++ b/packages/cli/fixtures/pricing-freemium/agentic.config.ts @@ -1,22 +1,20 @@ -import { defineConfig, freePricingPlan } from '@agentic/platform-schemas' +import { defaultFreePricingPlan, defineConfig } from '@agentic/platform-schemas' export default defineConfig({ // TODO: resolve name / slug conflicts name: 'My Project', originUrl: 'https://httpbin.org', pricingPlans: [ - freePricingPlan, + defaultFreePricingPlan, { name: 'Basic', slug: 'basic', - // interval: 'month', trialPeriodDays: 7, lineItems: [ { slug: 'base', usageType: 'licensed', - amount: 490 - // interval: 'month' + amount: 499 // $4.99 USD } ] } diff --git a/packages/cli/src/lib/__snapshots__/load-agentic-config.test.ts.snap b/packages/cli/src/lib/__snapshots__/load-agentic-config.test.ts.snap index 9a21e2b0..71eb3c0d 100644 --- a/packages/cli/src/lib/__snapshots__/load-agentic-config.test.ts.snap +++ b/packages/cli/src/lib/__snapshots__/load-agentic-config.test.ts.snap @@ -3,21 +3,68 @@ exports[`loadAgenticConfig > basic-raw-free-json 1`] = ` { "name": "My Project", + "originAdapter": { + "location": "external", + "type": "raw", + }, "originUrl": "https://jsonplaceholder.typicode.com", + "pricingIntervals": [ + "month", + ], + "pricingPlans": [ + { + "lineItems": [ + { + "amount": 0, + "slug": "base", + "usageType": "licensed", + }, + ], + "name": "Free", + "slug": "free", + }, + ], } `; exports[`loadAgenticConfig > basic-raw-free-ts 1`] = ` { "name": "My Project", + "originAdapter": { + "location": "external", + "type": "raw", + }, "originUrl": "https://jsonplaceholder.typicode.com", + "pricingIntervals": [ + "month", + ], + "pricingPlans": [ + { + "lineItems": [ + { + "amount": 0, + "slug": "base", + "usageType": "licensed", + }, + ], + "name": "Free", + "slug": "free", + }, + ], } `; exports[`loadAgenticConfig > pricing-freemium 1`] = ` { "name": "My Project", + "originAdapter": { + "location": "external", + "type": "raw", + }, "originUrl": "https://httpbin.org", + "pricingIntervals": [ + "month", + ], "pricingPlans": [ { "lineItems": [ @@ -34,7 +81,7 @@ exports[`loadAgenticConfig > pricing-freemium 1`] = ` "interval": "month", "lineItems": [ { - "amount": 490, + "amount": 499, "interval": "month", "slug": "base", "usageType": "licensed", diff --git a/packages/cli/src/lib/load-agentic-config.ts b/packages/cli/src/lib/load-agentic-config.ts index 9937e353..f3487db4 100644 --- a/packages/cli/src/lib/load-agentic-config.ts +++ b/packages/cli/src/lib/load-agentic-config.ts @@ -1,10 +1,8 @@ -import { parseZodSchema } from '@agentic/platform-core' -import { - type AgenticProjectConfigOutput, - agenticProjectConfigSchema -} from '@agentic/platform-schemas' +import type { AgenticProjectConfigOutput } from '@agentic/platform-schemas' import { loadConfig } from 'unconfig' +import { validateAgenticConfig } from './validate-agentic-config' + export async function loadAgenticConfig({ cwd }: { @@ -20,5 +18,5 @@ export async function loadAgenticConfig({ ] }) - return parseZodSchema(agenticProjectConfigSchema, config) + return validateAgenticConfig(config) } diff --git a/packages/cli/src/lib/validate-agentic-config.ts b/packages/cli/src/lib/validate-agentic-config.ts new file mode 100644 index 00000000..26593182 --- /dev/null +++ b/packages/cli/src/lib/validate-agentic-config.ts @@ -0,0 +1,224 @@ +import type { ZodTypeDef } from 'zod' +import { assert, parseZodSchema } from '@agentic/platform-core' +import { + type AgenticProjectConfigInput, + type AgenticProjectConfigOutput, + agenticProjectConfigSchema, + type PricingPlanLineItem +} from '@agentic/platform-schemas' + +export async function validateAgenticConfig( + inputConfig: unknown +): Promise { + const config = parseZodSchema< + AgenticProjectConfigOutput, + ZodTypeDef, + AgenticProjectConfigInput + >(agenticProjectConfigSchema, inputConfig) + + console.log('config', config) + + const { pricingIntervals, pricingPlans } = config + assert( + pricingPlans?.length, + 400, + 'Invalid pricingPlans: must be a non-empty array' + ) + assert( + pricingIntervals?.length, + 400, + 'Invalid pricingIntervals: must be a non-empty array' + ) + + { + // Validate pricing interval + const pricingIntervalsSet = new Set(pricingIntervals) + assert( + pricingIntervalsSet.size === pricingIntervals.length, + 400, + 'Invalid pricingIntervals: duplicate pricing intervals' + ) + assert( + pricingIntervals.length >= 1, + 400, + 'Invalid pricingIntervals: must contain at least one pricing interval' + ) + + if (pricingIntervals.length > 1) { + for (const pricingPlan of pricingPlans) { + if (pricingPlan.interval) { + assert( + pricingIntervalsSet.has(pricingPlan.interval), + 400, + `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` + ) + } + + if (pricingPlan.slug === 'free') continue + + assert( + pricingPlan.interval !== undefined, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": non-free PricingPlan "${pricingPlan.slug}" must specify an "interval" because the project supports multiple pricing intervals.` + ) + + for (const lineItem of pricingPlan.lineItems) { + lineItem.interval ??= pricingPlan.interval + + assert( + lineItem.interval === pricingPlan.interval, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": non-free PricingPlan "${pricingPlan.slug}" LineItem "${lineItem.slug}" "interval" must match the PricingPlan interval "${pricingPlan.interval}" because the project supports multiple pricing intervals.` + ) + + assert( + pricingIntervalsSet.has(lineItem.interval), + 400, + `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" LineItem "${lineItem.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` + ) + } + } + } else { + // Only a single pricing interval is supported, so default all pricing + // plans to use the default pricing interval. + const defaultPricingInterval = pricingIntervals[0]! + assert( + defaultPricingInterval, + 400, + 'Invalid pricingIntervals: must contain at least one valid pricing interval' + ) + + for (const pricingPlan of pricingPlans) { + if (pricingPlan.interval) { + assert( + pricingIntervalsSet.has(pricingPlan.interval), + 400, + `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` + ) + } + + if (pricingPlan.slug === 'free') continue + + pricingPlan.interval ??= defaultPricingInterval + + for (const lineItem of pricingPlan.lineItems) { + lineItem.interval ??= defaultPricingInterval + + assert( + pricingIntervalsSet.has(lineItem.interval), + 400, + `Invalid pricingPlan "${pricingPlan.slug}": PricingPlan "${pricingPlan.slug}" LineItem "${lineItem.slug}" has invalid interval "${pricingPlan.interval}" which is not included in the "pricingIntervals" array.` + ) + } + } + } + } + + { + // Validate pricingPlans + const pricingPlanSlugsSet = new Set(pricingPlans.map((p) => p.slug)) + assert( + pricingPlanSlugsSet.size === pricingPlans.length, + 400, + 'Invalid pricingPlans: duplicate PricingPlan slugs. All PricingPlan slugs must be unique (e.g. "free", "starter-monthly", "pro-annual", etc).' + ) + + const pricingPlanLineItemSlugMap: Record = {} + + for (const pricingPlan of pricingPlans) { + const lineItemSlugsSet = new Set( + pricingPlan.lineItems.map((lineItem) => lineItem.slug) + ) + + assert( + lineItemSlugsSet.size === pricingPlan.lineItems.length, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": duplicate line-item slugs` + ) + + for (const lineItem of pricingPlan.lineItems) { + if (!pricingPlanLineItemSlugMap[lineItem.slug]) { + pricingPlanLineItemSlugMap[lineItem.slug] = [] + } + + pricingPlanLineItemSlugMap[lineItem.slug]!.push(lineItem) + } + } + + for (const lineItems of Object.values(pricingPlanLineItemSlugMap)) { + if (lineItems.length <= 1) continue + + const lineItem0 = lineItems[0]! + + for (let i = 1; i < lineItems.length; ++i) { + const lineItem = lineItems[i]! + + assert( + lineItem.usageType === lineItem0.usageType, + 400, + `Invalid pricingPlans: all PricingPlans which contain the same LineItems (by slug "${lineItem.slug}") must have the same usage type ("licensed" or "metered").` + ) + } + } + } + + // Validate PricingPlanLineItems + for (const pricingPlan of pricingPlans) { + for (const lineItem of pricingPlan.lineItems) { + if (lineItem.usageType === 'metered') { + switch (lineItem.billingScheme) { + case 'per_unit': + assert( + lineItem.unitAmount !== undefined, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-negative "unitAmount" when using "per_unit" billing scheme.` + ) + + assert( + lineItem.tiersMode === undefined, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiersMode" when using "per_unit" billing scheme.` + ) + + assert( + lineItem.tiers === undefined, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiers" when using "per_unit" billing scheme.` + ) + + break + + case 'tiered': + assert( + lineItem.unitAmount === undefined, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "unitAmount" when using "tiered" billing scheme.` + ) + + assert( + lineItem.tiers?.length, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-empty "tiers" array when using "tiered" billing scheme.` + ) + + assert( + lineItem.transformQuantity === undefined, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "transformQuantity" when using "tiered" billing scheme.` + ) + + break + + default: + assert( + false, + 400, + `Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "billingScheme".` + ) + } + } + } + } + + return config +} diff --git a/packages/schemas/src/agentic-project-config-schema.ts b/packages/schemas/src/agentic-project-config-schema.ts index 5c54d38b..c168bf9b 100644 --- a/packages/schemas/src/agentic-project-config-schema.ts +++ b/packages/schemas/src/agentic-project-config-schema.ts @@ -17,7 +17,7 @@ import { // - optional version // - optional agentic version -export const freePricingPlan = { +export const defaultFreePricingPlan = { name: 'Free', slug: 'free', lineItems: [ @@ -70,30 +70,36 @@ export const agenticProjectConfigSchema = z.object({ NOTE: Agentic currently only supports \`external\` API servers. If you'd like to host your API or MCP server on Agentic's infrastructure, please reach out to support@agentic.so.`), /** Optional origin API config */ - originAdapter: deploymentOriginAdapterSchema - .default({ - location: 'external', - type: 'raw' - }) - .optional(), + originAdapter: deploymentOriginAdapterSchema.optional().default({ + location: 'external', + type: 'raw' + }), /** Optional subscription pricing config */ pricingPlans: pricingPlanListSchema .describe( 'List of PricingPlans configuring which Stripe subscriptions should be available for the project. Defaults to a single free plan which is useful for developing and testing.your project.' ) - .default([freePricingPlan]) - .optional(), + .optional() + .default([defaultFreePricingPlan]), /** * Optional list of billing intervals to enable in the pricingPlans. * * Defaults to a single monthly interval `['month']`. * - * To add an annual plan, you can use `['month', 'year']`. + * To add support for annual pricing plans, you can use `['month', 'year']`. + * + * Note that for every pricing interval, you must define a corresponding set + * of PricingPlans in the `pricingPlans` array. If you only have one pricing + * interval (like the default `month` interval), `pricingPlans` don't need to + * specify their `interval` property. Otherwise, all PricingPlans and + * LineItems must specify their `interval` property to differentiate between + * different pricing intervals. */ - pricingIntervals: z.array(pricingIntervalSchema).default(['month']).optional() + pricingIntervals: z.array(pricingIntervalSchema).optional().default(['month']) }) + export type AgenticProjectConfigInput = z.input< typeof agenticProjectConfigSchema > diff --git a/packages/schemas/src/schemas.ts b/packages/schemas/src/schemas.ts index 5d38d529..51340e5d 100644 --- a/packages/schemas/src/schemas.ts +++ b/packages/schemas/src/schemas.ts @@ -1,4 +1,3 @@ -import { parseJson } from '@agentic/platform-core' import { z } from '@hono/zod-openapi' export const webhookSchema = z @@ -10,7 +9,7 @@ export const webhookSchema = z export type Webhook = z.infer /** - * Rate limit config for metered line-items. + * Rate limit config for metered LineItems. */ export const rateLimitSchema = z .object({ @@ -64,7 +63,9 @@ export const pricingPlanLineItemHashSchema = z .describe('Internal PricingPlanLineItem hash') /** - * PricingPlanLineItem slug which acts as a unique lookup key for LineItems across deployments. They must be lower and kebab-cased ("base", "requests", "image-transformations"). + * PricingPlanLineItem slug which acts as a unique lookup key for LineItems + * across deployments. They must be lower and kebab-cased ("base", "requests", + * "image-transformations", etc). */ export const pricingPlanLineItemSlugSchema = z .string() @@ -111,7 +112,7 @@ const commonPricingPlanLineItemSchema = z.object({ * The `requests` slug is reserved for charging using `metered billing based * on the number of request made during a given billing interval. * - * All other PricingPlanLineItem `slugs` are considered custom line-items. + * All other PricingPlanLineItem `slugs` are considered custom LineItems. */ slug: z.union([z.string(), z.literal('base'), z.literal('requests')]), @@ -123,6 +124,11 @@ const commonPricingPlanLineItemSchema = z.object({ */ interval: pricingIntervalSchema.optional(), + /** + * Optional label for the line-item which will be displayed on customer bills. + * + * If unset, the line-item's `slug` will be used as the label. + */ label: z.string().optional().openapi('label', { example: 'API calls' }) }) @@ -136,6 +142,9 @@ export const pricingPlanLineItemSchema = z .discriminatedUnion('usageType', [ commonPricingPlanLineItemSchema.merge( z.object({ + /** + * Licensed LineItems are used to charge for fixed-price services. + */ usageType: z.literal('licensed'), /** @@ -151,11 +160,21 @@ export const pricingPlanLineItemSchema = z commonPricingPlanLineItemSchema.merge( z.object({ + /** + * Metered LineItems are used to charge for usage-based services. + */ usageType: z.literal('metered'), + + /** + * Optional label for the line-item which will be displayed on customer + * bills. + * + * If unset, the line-item's `slug` will be used as the unit label. + */ unitLabel: z.string().optional(), /** - * Optional rate limit to enforce for this metered LineItem. + * 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. @@ -175,7 +194,6 @@ export const pricingPlanLineItemSchema = z */ billingScheme: z.union([z.literal('per_unit'), z.literal('tiered')]), - // /** * The fixed amount to charge per unit of usage. * @@ -225,6 +243,8 @@ export const pricingPlanLineItemSchema = z .object({ /** * Divide usage by this number. + * + * Must be a positive number. */ divideBy: z.number().positive(), @@ -246,7 +266,7 @@ export const pricingPlanLineItemSchema = z return true }, (data) => ({ - message: `Invalid PricingPlanLineItem "${data.slug}": reserved "base" line-items must have "licensed" usage type.` + message: `Invalid PricingPlanLineItem "${data.slug}": reserved "base" LineItems must have "licensed" usage type.` }) ) .refine( @@ -258,18 +278,18 @@ export const pricingPlanLineItemSchema = z return true }, (data) => ({ - message: `Invalid PricingPlanLineItem "${data.slug}": reserved "requests" line-items must have "metered" usage type.` + message: `Invalid PricingPlanLineItem "${data.slug}": reserved "requests" LineItems must have "metered" usage type.` }) ) .describe( - 'PricingPlanLineItems represent a single line-item in a Stripe Subscription. They map to a Stripe billing `Price` and possibly a corresponding Stripe `Meter` for metered usage.' + 'PricingPlanLineItems represent a single line-item in a Stripe Subscription. They map to a Stripe billing `Price` and possibly a corresponding Stripe `Meter` for usage-based line-items.' ) .openapi('PricingPlanLineItem') export type PricingPlanLineItem = z.infer /** - * Represents the config for a Stripe subscription with one or more - * PricingPlanLineItems. + * Represents the config for a single Stripe subscription plan with one or more + * LineItems. */ export const pricingPlanSchema = z .object({ @@ -287,53 +307,32 @@ export const pricingPlanSchema = z */ interval: pricingIntervalSchema.optional(), - desc: z.string().optional(), + /** + * Optional description of the PricingPlan which is used for UI-only. + */ + description: z.string().optional(), + + /** + * Optional list of features of the PricingPlan which is used for UI-only. + */ features: z.array(z.string()).optional(), - // TODO? + /** + * Optional number of days for a free trial period when a customer signs up + * for a new subscription. + */ trialPeriodDays: z.number().nonnegative().optional(), + /** + * List of LineItems which are included in the PricingPlan. + * + * Note: we currently support a max of 20 LineItems per plan. + */ lineItems: z.array(pricingPlanLineItemSchema).nonempty().max(20, { message: - 'Stripe Checkout currently supports a max of 20 line-items per subscription.' + 'Stripe Checkout currently supports a max of 20 LineItems per subscription.' }) }) - .refine( - (data) => { - if (data.interval === undefined) { - return data.slug === 'free' - } - - return true - }, - (data) => ({ - message: `Invalid PricingPlan "${data.slug}": non-free pricing plans must have a valid interval` - }) - ) - .refine( - (data) => { - if (data.slug === 'free') { - return data.interval === undefined - } - - return true - }, - (data) => ({ - message: `Invalid PricingPlan "${data.slug}": free pricing plans must not have an interval` - }) - ) - .refine( - (data) => { - const lineItemSlugs = new Set( - data.lineItems.map((lineItem) => lineItem.slug) - ) - - return lineItemSlugs.size === data.lineItems.length - }, - (data) => ({ - message: `Invalid PricingPlan "${data.slug}": duplicate line-item slugs` - }) - ) .describe( 'Represents the config for a Stripe subscription with one or more PricingPlanLineItems.' ) @@ -360,49 +359,6 @@ export const pricingPlanListSchema = z .nonempty({ message: 'Must contain at least one PricingPlan' }) - .refine( - (pricingPlans) => { - const slugs = new Set(pricingPlans.map((p) => p.slug)) - return slugs.size === pricingPlans.length - }, - { - message: `Invalid PricingPlanList: duplicate PricingPlan slugs` - } - ) - .refine( - (pricingPlans) => { - const pricingPlanLineItemSlugMap: Record = - {} - for (const pricingPlan of pricingPlans) { - for (const lineItem of pricingPlan.lineItems) { - if (!pricingPlanLineItemSlugMap[lineItem.slug]) { - pricingPlanLineItemSlugMap[lineItem.slug] = [] - } - - pricingPlanLineItemSlugMap[lineItem.slug]!.push(lineItem) - } - } - - for (const lineItems of Object.values(pricingPlanLineItemSlugMap)) { - if (lineItems.length <= 1) continue - - const lineItem0 = lineItems[0]! - - for (let i = 1; i < lineItems.length; ++i) { - const lineItem = lineItems[i]! - - if (lineItem.usageType !== lineItem0.usageType) { - return false - } - } - } - - return true - }, - { - message: `Invalid PricingPlanList: all pricing plans which contain the same LineItems (by slug) must have the same usage type (licensed or metered).` - } - ) .describe('List of PricingPlans') export type PricingPlanList = z.infer @@ -487,34 +443,20 @@ export const deploymentOriginAdapterSchema = z .discriminatedUnion('type', [ z .object({ + /** + * OpenAPI 3.x spec describing the origin API server. + */ type: z.literal('openapi'), - // NOTE: The origin API servers should be hidden in the embedded - // OpenAPI spec, because clients should only be aware of the upstream - // Agentic API gateway. + + /** + * JSON stringified OpenAPI spec describing the origin API server. + * + * The origin API servers are be hidden in the embedded OpenAPI spec, + * because clients should only be aware of the upstream Agentic API + * gateway. + */ spec: z .string() - .refine( - (spec) => { - try { - parseJson(spec) - } catch { - return false - } - }, - (data) => { - try { - parseJson(data) - } catch (err: any) { - return { - message: `Invalid OpenAPI spec: ${err.message}` - } - } - - return { - message: 'Invalid OpenAPI spec' - } - } - ) .describe( 'JSON stringified OpenAPI spec describing the origin API server.' ) @@ -523,6 +465,13 @@ export const deploymentOriginAdapterSchema = z z .object({ + /** + * Marks the origin server as a raw HTTP REST API without any additional + * tool or service definitions. + * + * In this mode, Agentic's API gateway acts as a simple reverse-proxy + * to the origin server, without validating tools or services. + */ type: z.literal('raw') }) .merge(commonDeploymentOriginAdapterSchema)