pull/715/head
Travis Fischer 2025-05-25 22:46:54 +07:00
rodzic bf7f8f60f4
commit 23364946eb
7 zmienionych plików z 364 dodań i 142 usunięć

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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
}
]
}

Wyświetl plik

@ -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",

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -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<AgenticProjectConfigOutput> {
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<string, PricingPlanLineItem[]> = {}
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
}

Wyświetl plik

@ -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
>

Wyświetl plik

@ -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<typeof webhookSchema>
/**
* 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<typeof pricingPlanLineItemSchema>
/**
* 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<string, PricingPlanLineItem[]> =
{}
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<typeof pricingPlanListSchema>
@ -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)