pull/715/head
Travis Fischer 2025-05-30 02:46:49 +07:00
rodzic 8ed18982ad
commit 22d389f759
17 zmienionych plików z 584 dodań i 338 usunięć

Wyświetl plik

@ -1,4 +1,4 @@
import { validateAgenticProjectConfig } from '@agentic/platform'
import { resolveAgenticProjectConfig } from '@agentic/platform'
import { assert, parseZodSchema, sha256 } from '@agentic/platform-core'
import { validators } from '@agentic/platform-validators'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
@ -111,9 +111,8 @@ export function registerV1DeploymentsCreateDeployment(
// - origin API base URL
// - origin adapter OpenAPI or MCP specs
// - tool definitions
const agenticProjectConfig = await validateAgenticProjectConfig(body, {
const agenticProjectConfig = await resolveAgenticProjectConfig(body, {
label: `deployment "${deploymentIdentifier}"`,
strip: true,
logger
})

Wyświetl plik

@ -2,6 +2,7 @@ import {
agenticProjectConfigSchema,
type OriginAdapter,
type PricingPlanList,
resolvedAgenticProjectConfigSchema,
type Tool,
type ToolConfig
} from '@agentic/platform-schemas'
@ -149,16 +150,16 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
message: 'Invalid deployment hash'
}),
version: agenticProjectConfigSchema.shape.version,
description: agenticProjectConfigSchema.shape.description,
readme: agenticProjectConfigSchema.shape.readme,
iconUrl: agenticProjectConfigSchema.shape.iconUrl,
sourceUrl: agenticProjectConfigSchema.shape.sourceUrl,
originAdapter: agenticProjectConfigSchema.shape.originAdapter,
pricingPlans: agenticProjectConfigSchema.shape.pricingPlans,
pricingIntervals: agenticProjectConfigSchema.shape.pricingIntervals,
tools: agenticProjectConfigSchema.shape.toolConfigs,
toolConfigs: agenticProjectConfigSchema.shape.toolConfigs
version: resolvedAgenticProjectConfigSchema.shape.version,
description: resolvedAgenticProjectConfigSchema.shape.description,
readme: resolvedAgenticProjectConfigSchema.shape.readme,
iconUrl: resolvedAgenticProjectConfigSchema.shape.iconUrl,
sourceUrl: resolvedAgenticProjectConfigSchema.shape.sourceUrl,
originAdapter: resolvedAgenticProjectConfigSchema.shape.originAdapter,
pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans,
pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals,
tools: resolvedAgenticProjectConfigSchema.shape.tools,
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs
})
.omit({
originUrl: true

Wyświetl plik

@ -32,6 +32,9 @@ export function registerDebugCommand({ program, logger }: Context) {
}
)
// TODO: we may want to resolve the resulting agentic config so we see
// the inferred `tools` (and `toolToOperationMap` for mcp servers)
logger.log(config)
})

Wyświetl plik

@ -19,8 +19,7 @@
"scripts": {
"test": "run-s test:*",
"test:lint": "eslint .",
"test:typecheck": "tsc --noEmit",
"test:unit": "vitest run"
"test:typecheck": "tsc --noEmit"
},
"dependencies": {
"@agentic/platform-core": "workspace:*",

Wyświetl plik

@ -1,4 +1,3 @@
export * from './define-config'
export * from './resolve-agentic-project-config'
export * from './validate-agentic-project-config'
export * from './validate-origin-adapter'
export * from './validate-tools'

Wyświetl plik

@ -0,0 +1,55 @@
import type {
MCPOriginAdapter,
MCPOriginAdapterConfig,
Tool
} from '@agentic/platform-schemas'
import { assert } from '@agentic/platform-core'
import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js'
export async function resolveMCPOriginAdapter({
name,
version,
originUrl,
originAdapter,
label
}: {
name: string
originUrl: string
originAdapter: MCPOriginAdapterConfig
label: string
version: string
}): Promise<{
originAdapter: MCPOriginAdapter
tools?: Tool[]
}> {
assert(
originAdapter.type === 'mcp',
400,
`Invalid origin adapter type "${originAdapter.type}" for ${label}`
)
const transport = new SSEClientTransport(new URL(originUrl))
const client = new McpClient({ name, version })
await client.connect(transport)
const serverInfo = {
name,
version,
...client.getServerVersion(),
capabilities: client.getServerCapabilities(),
instructions: client.getInstructions()
}
const listToolsResponse = await client.listTools()
// TODO: Validate MCP tools
const tools = listToolsResponse.tools
return {
tools,
originAdapter: {
...originAdapter,
serverInfo
}
}
}

Wyświetl plik

@ -0,0 +1,76 @@
import type {
OpenAPIOriginAdapter,
OpenAPIOriginAdapterConfig,
Tool
} from '@agentic/platform-schemas'
import { assert, type Logger } from '@agentic/platform-core'
import {
getToolsFromOpenAPISpec,
validateOpenAPISpec
} from '@agentic/platform-openapi'
export async function resolveOpenAPIOriginAdapter({
originAdapter,
label,
cwd,
logger
}: {
originAdapter: OpenAPIOriginAdapterConfig
label: string
cwd?: URL
logger?: Logger
}): Promise<{
originAdapter: OpenAPIOriginAdapter
tools?: Tool[]
}> {
assert(
originAdapter.type === 'openapi',
400,
`Invalid origin adapter type "${originAdapter.type}" for ${label}`
)
assert(
originAdapter.spec,
400,
`OpenAPI spec is required for ${label} with origin adapter type set to "openapi"`
)
// Validate and normalize the OpenAPI spec
const openapiSpec = await validateOpenAPISpec(originAdapter.spec, {
cwd,
logger
})
// Remove origin servers from the OpenAPI spec.
// TODO: Ensure that `originUrl` matches any origin servers in the openapi spec?
delete openapiSpec.servers
// TODO: Additional, agentic-specific validation of the OpenAPI spec's
// operations to ensure they are valid tools.
// TODO: Simplify OpenAPI spec by removing any query params and headers
// specific to the Agentic API gateway.
// TODO: Extract tool definitions from OpenAPI operationIds
const dereferencedOpenAPISpec = await validateOpenAPISpec(
originAdapter.spec,
{
cwd,
dereference: true
}
)
const { tools, toolToOperationMap } = await getToolsFromOpenAPISpec(
dereferencedOpenAPISpec
)
return {
tools,
originAdapter: {
...originAdapter,
// Update the openapi spec with the normalized version
spec: JSON.stringify(openapiSpec),
toolToOperationMap
}
}
}

Wyświetl plik

@ -0,0 +1,49 @@
import { type Logger, parseZodSchema } from '@agentic/platform-core'
import {
type AgenticProjectConfig,
agenticProjectConfigSchema,
type ResolvedAgenticProjectConfig,
resolvedAgenticProjectConfigSchema
} from '@agentic/platform-schemas'
import { resolveMetadata } from './resolve-metadata'
import { resolveOriginAdapter } from './resolve-origin-adapter'
import { validatePricing } from './validate-pricing'
import { validateTools } from './validate-tools'
export async function resolveAgenticProjectConfig(
inputConfig: AgenticProjectConfig,
opts: { logger?: Logger; cwd?: URL; label?: string } = {}
): Promise<ResolvedAgenticProjectConfig> {
const config = parseZodSchema(agenticProjectConfigSchema.strip(), inputConfig)
const { name, version } = resolveMetadata(config)
validatePricing(config)
const { originAdapter, tools } = await resolveOriginAdapter({
name,
version,
label: `project "${name}"`,
...opts,
originUrl: config.originUrl,
originAdapter: config.originAdapter
})
const resolvedConfig = parseZodSchema(resolvedAgenticProjectConfigSchema, {
...config,
name,
version,
originAdapter,
tools
})
validateTools({
label: `project "${name}"`,
...opts,
originAdapter: resolvedConfig.originAdapter,
tools: resolvedConfig.tools,
toolConfigs: resolvedConfig.toolConfigs || []
})
return resolvedConfig
}

Wyświetl plik

@ -0,0 +1,35 @@
import type { AgenticProjectConfig } from '@agentic/platform-schemas'
import { assert } from '@agentic/platform-core'
import { validators } from '@agentic/platform-validators'
import { clean as cleanSemver, valid as isValidSemver } from 'semver'
export function resolveMetadata({
name,
version
}: Pick<AgenticProjectConfig, 'name' | 'version'>): Pick<
AgenticProjectConfig,
'name' | 'version'
> {
assert(
validators.projectName(name),
`Invalid project name "${name}". Must be lower kebab-case with no spaces between 2 and 64 characters. Example: "my-project" or "linkedin-resolver-23"`
)
if (version) {
const normalizedVersion = cleanSemver(version)!
assert(version, `Invalid semver version "${version}" for project "${name}"`)
assert(
isValidSemver(version),
`Invalid semver version "${version}" for project "${name}"`
)
// Update the config with the normalized semver version
version = normalizedVersion
}
return {
name,
version
}
}

Wyświetl plik

@ -0,0 +1,63 @@
import type {
OriginAdapter,
OriginAdapterConfig,
Tool
} from '@agentic/platform-schemas'
import { assert, type Logger } from '@agentic/platform-core'
import { resolveMCPOriginAdapter } from './origin-adapters/mcp'
import { resolveOpenAPIOriginAdapter } from './origin-adapters/openapi'
import { validateOriginUrl } from './validate-origin-url'
/**
* Validates, normalizes, and resolves the origin adapter config for a project.
*/
export async function resolveOriginAdapter({
name,
version = '0.0.0',
originUrl,
originAdapter,
label,
cwd,
logger
}: {
name: string
originUrl: string
originAdapter: OriginAdapterConfig
label: string
version?: string
cwd?: URL
logger?: Logger
}): Promise<{
originAdapter: OriginAdapter
tools?: Tool[]
}> {
validateOriginUrl({ originUrl, label })
if (originAdapter.type === 'openapi') {
return resolveOpenAPIOriginAdapter({
originAdapter,
label,
cwd,
logger
})
} else if (originAdapter.type === 'mcp') {
return resolveMCPOriginAdapter({
name,
version,
originUrl,
originAdapter,
label
})
} else {
assert(
originAdapter.type === 'raw',
400,
`Invalid origin adapter type "${originAdapter.type}" for ${label}`
)
return {
originAdapter
}
}
}

Wyświetl plik

@ -1,18 +1,14 @@
import type { ZodTypeDef } from 'zod'
import { assert, type Logger, parseZodSchema } from '@agentic/platform-core'
import { type Logger, parseZodSchema } from '@agentic/platform-core'
import {
type AgenticProjectConfig,
type AgenticProjectConfigInput,
agenticProjectConfigSchema,
getPricingPlansByInterval,
type PricingPlanLineItem,
type ResolvedAgenticProjectConfig,
resolvedAgenticProjectConfigSchema
agenticProjectConfigSchema
} from '@agentic/platform-schemas'
import { validators } from '@agentic/platform-validators'
import { clean as cleanSemver, valid as isValidSemver } from 'semver'
import { resolveMetadata } from './resolve-metadata'
import { validateOriginAdapter } from './validate-origin-adapter'
import { validatePricing } from './validate-pricing'
export async function validateAgenticProjectConfig(
inputConfig: unknown,
@ -20,7 +16,7 @@ export async function validateAgenticProjectConfig(
strip = false,
...opts
}: { logger?: Logger; cwd?: URL; strip?: boolean; label?: string } = {}
): Promise<ResolvedAgenticProjectConfig> {
): Promise<AgenticProjectConfig> {
const config = parseZodSchema<
AgenticProjectConfig,
ZodTypeDef,
@ -32,228 +28,31 @@ export async function validateAgenticProjectConfig(
inputConfig
)
const { name, pricingIntervals, pricingPlans, originUrl } = config
assert(
validators.projectName(name),
`Invalid project name "${name}". Must be lower kebab-case with no spaces between 2 and 64 characters. Example: "my-project" or "linkedin-resolver-23"`
)
assert(
pricingPlans?.length,
'Invalid pricingPlans: must be a non-empty array'
)
assert(
pricingIntervals?.length,
'Invalid pricingIntervals: must be a non-empty array'
)
const { name, version } = resolveMetadata(config)
validatePricing(config)
try {
const parsedOriginUrl = new URL(originUrl)
assert(
parsedOriginUrl.protocol === 'https:',
'Invalid originUrl: must be a valid https URL'
)
assert(parsedOriginUrl.hostname, 'Invalid originUrl: must be a valid URL')
} catch (err) {
throw new Error('Invalid originUrl: must be a valid https URL', {
cause: err
})
}
if (config.version) {
const version = cleanSemver(config.version)
assert(
version,
`Invalid semver version "${config.version}" for project "${name}"`
)
assert(
isValidSemver(version),
`Invalid semver version "${version}" for project "${name}"`
)
// Update the config with the normalized semver version
config.version = version
}
{
// Validate pricing interval
const pricingIntervalsSet = new Set(pricingIntervals)
assert(
pricingIntervalsSet.size === pricingIntervals.length,
'Invalid pricingIntervals: duplicate pricing intervals'
)
assert(
pricingIntervals.length >= 1,
'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),
`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,
`Invalid pricingPlan "${pricingPlan.slug}": non-free PricingPlan "${pricingPlan.slug}" must specify an "interval" because the project supports multiple pricing intervals.`
)
}
} 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,
'Invalid pricingIntervals: must contain at least one valid pricing interval'
)
for (const pricingPlan of pricingPlans) {
if (pricingPlan.interval) {
assert(
pricingIntervalsSet.has(pricingPlan.interval),
`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
}
}
}
{
// Validate pricingPlans
const pricingPlanSlugsSet = new Set(pricingPlans.map((p) => p.slug))
assert(
pricingPlanSlugsSet.size === pricingPlans.length,
'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,
`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,
`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,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-negative "unitAmount" when using "per_unit" billing scheme.`
)
assert(
lineItem.tiersMode === undefined,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiersMode" when using "per_unit" billing scheme.`
)
assert(
lineItem.tiers === undefined,
`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,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "unitAmount" when using "tiered" billing scheme.`
)
assert(
lineItem.tiers?.length,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-empty "tiers" array when using "tiered" billing scheme.`
)
assert(
lineItem.tiersMode !== undefined,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "tiersMode" when using "tiered" billing scheme.`
)
assert(
lineItem.transformQuantity === undefined,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "transformQuantity" when using "tiered" billing scheme.`
)
break
default:
assert(
false,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "billingScheme".`
)
}
}
}
}
// Validate deployment pricing plans to ensure they contain at least one valid
// plan per pricing interval configured on the project.
for (const pricingInterval of config.pricingIntervals) {
const pricingPlansForInterval = getPricingPlansByInterval({
pricingInterval,
pricingPlans
})
assert(
pricingPlansForInterval.length > 0,
400,
`Invalid pricing config: no pricing plans for pricing interval "${pricingInterval}"`
)
}
const { originAdapter, tools } = await validateOriginAdapter({
const originAdapter = await validateOriginAdapter({
name,
version: config.version,
version,
label: `project "${name}"`,
...opts,
originUrl,
originUrl: config.originUrl,
originAdapter: config.originAdapter
})
return parseZodSchema(resolvedAgenticProjectConfigSchema, {
...config,
originAdapter,
tools
})
return parseZodSchema<
AgenticProjectConfig,
ZodTypeDef,
AgenticProjectConfigInput
>(
strip
? agenticProjectConfigSchema.strip()
: agenticProjectConfigSchema.strict(),
{
...config,
name,
version,
originAdapter
}
)
}

Wyświetl plik

@ -1,17 +1,12 @@
import type {
OriginAdapter,
OriginAdapterConfig,
Tool
} from '@agentic/platform-schemas'
import type { OriginAdapterConfig } from '@agentic/platform-schemas'
import { assert, type Logger } from '@agentic/platform-core'
import {
getToolsFromOpenAPISpec,
validateOpenAPISpec
} from '@agentic/platform-openapi'
import { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js'
import { resolveMCPOriginAdapter } from './origin-adapters/mcp'
import { resolveOpenAPIOriginAdapter } from './origin-adapters/openapi'
import { validateOriginUrl } from './validate-origin-url'
/**
* Validates and normalizes the origin adapter config for a project.
* Validates and normalizes the origin adapter for a project.
*/
export async function validateOriginAdapter({
name,
@ -24,93 +19,43 @@ export async function validateOriginAdapter({
}: {
name: string
originUrl: string
originAdapter: OriginAdapterConfig
originAdapter: Readonly<OriginAdapterConfig>
label: string
version?: string
cwd?: URL
logger?: Logger
}): Promise<{
originAdapter: OriginAdapter
tools?: Tool[]
}> {
assert(originUrl, 400, `Origin URL is required for ${label}`)
}): Promise<OriginAdapterConfig> {
validateOriginUrl({ originUrl, label })
if (originAdapter.type === 'openapi') {
assert(
originAdapter.spec,
400,
`OpenAPI spec is required for ${label} with origin adapter type set to "openapi"`
)
// Validate and normalize the OpenAPI spec
const openapiSpec = await validateOpenAPISpec(originAdapter.spec, {
cwd,
logger
})
// Remove origin servers from the OpenAPI spec.
// TODO: Ensure that `originUrl` matches any origin servers in the openapi spec?
delete openapiSpec.servers
// TODO: Additional, agentic-specific validation of the OpenAPI spec's
// operations to ensure they are valid tools.
// TODO: Simplify OpenAPI spec by removing any query params and headers
// specific to the Agentic API gateway.
// TODO: Extract tool definitions from OpenAPI operationIds
const dereferencedOpenAPISpec = await validateOpenAPISpec(
originAdapter.spec,
{
// We intentionally ignore the resolved tools here because the server will
// need to re-validate the OpenAPI spec and tools anyway. We do, however,
// override the `spec` field with the parsed, normalized version because
// that may have been pointing to a local file or remote URL.
const { originAdapter: resolvedOriginAdapter } =
await resolveOpenAPIOriginAdapter({
originAdapter,
label,
cwd,
dereference: true
}
)
const { tools, toolToOperationMap } = await getToolsFromOpenAPISpec(
dereferencedOpenAPISpec
)
logger
})
return {
tools,
originAdapter: {
...originAdapter,
// Update the openapi spec with the normalized version
spec: JSON.stringify(openapiSpec),
toolToOperationMap
}
...originAdapter,
spec: resolvedOriginAdapter.spec
}
} else if (originAdapter.type === 'mcp') {
// TODO: Validate MCP server info and tools
const { SSEClientTransport } = await import(
'@modelcontextprotocol/sdk/client/sse.js'
)
const transport = new SSEClientTransport(new URL(originUrl))
const client = new McpClient({ name, version })
await client.connect(transport)
const serverInfo = {
// We intentionally ignore the resolved version and tools here because the
// server will need to re-validate the MCP server info and tools anyway.
await resolveMCPOriginAdapter({
name,
version,
...client.getServerVersion(),
capabilities: client.getServerCapabilities(),
instructions: client.getInstructions()
}
originUrl,
originAdapter,
label
})
const listToolsResponse = await client.listTools()
// TODO: Validate MCP tools
const tools = listToolsResponse.tools
return {
originAdapter: {
...originAdapter,
serverInfo
},
tools
}
return originAdapter
} else {
assert(
originAdapter.type === 'raw',
@ -118,8 +63,6 @@ export async function validateOriginAdapter({
`Invalid origin adapter type "${originAdapter.type}" for ${label}`
)
return {
originAdapter
}
return originAdapter
}
}

Wyświetl plik

@ -0,0 +1,25 @@
import { assert } from '@agentic/platform-core'
export function validateOriginUrl({
originUrl,
label
}: {
originUrl: string
label: string
}) {
assert(originUrl, 400, `Origin URL is required for ${label}`)
try {
const parsedOriginUrl = new URL(originUrl)
assert(
parsedOriginUrl.protocol === 'https:',
'Invalid originUrl: must be a valid https URL'
)
assert(parsedOriginUrl.hostname, 'Invalid originUrl: must be a valid URL')
} catch (err) {
throw new Error('Invalid originUrl: must be a valid https URL', {
cause: err
})
}
}

Wyświetl plik

@ -0,0 +1,186 @@
import { assert } from '@agentic/platform-core'
import {
type AgenticProjectConfig,
getPricingPlansByInterval,
type PricingPlanLineItem
} from '@agentic/platform-schemas'
export function validatePricing({
pricingIntervals,
pricingPlans
}: Pick<AgenticProjectConfig, 'pricingIntervals' | 'pricingPlans'>) {
assert(
pricingPlans?.length,
'Invalid pricingPlans: must be a non-empty array'
)
assert(
pricingIntervals?.length,
'Invalid pricingIntervals: must be a non-empty array'
)
{
// Validate pricing interval
const pricingIntervalsSet = new Set(pricingIntervals)
assert(
pricingIntervalsSet.size === pricingIntervals.length,
'Invalid pricingIntervals: duplicate pricing intervals'
)
assert(
pricingIntervals.length >= 1,
'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),
`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,
`Invalid pricingPlan "${pricingPlan.slug}": non-free PricingPlan "${pricingPlan.slug}" must specify an "interval" because the project supports multiple pricing intervals.`
)
}
} 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,
'Invalid pricingIntervals: must contain at least one valid pricing interval'
)
for (const pricingPlan of pricingPlans) {
if (pricingPlan.interval) {
assert(
pricingIntervalsSet.has(pricingPlan.interval),
`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
}
}
}
{
// Validate pricingPlans
const pricingPlanSlugsSet = new Set(pricingPlans.map((p) => p.slug))
assert(
pricingPlanSlugsSet.size === pricingPlans.length,
'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,
`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,
`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,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-negative "unitAmount" when using "per_unit" billing scheme.`
)
assert(
lineItem.tiersMode === undefined,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "tiersMode" when using "per_unit" billing scheme.`
)
assert(
lineItem.tiers === undefined,
`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,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "unitAmount" when using "tiered" billing scheme.`
)
assert(
lineItem.tiers?.length,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a non-empty "tiers" array when using "tiered" billing scheme.`
)
assert(
lineItem.tiersMode !== undefined,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "tiersMode" when using "tiered" billing scheme.`
)
assert(
lineItem.transformQuantity === undefined,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must not specify "transformQuantity" when using "tiered" billing scheme.`
)
break
default:
assert(
false,
`Invalid pricingPlan "${pricingPlan.slug}": metered LineItem "${lineItem.slug}" must specify a valid "billingScheme".`
)
}
}
}
}
// Validate deployment pricing plans to ensure they contain at least one valid
// plan per pricing interval configured on the project.
for (const pricingInterval of pricingIntervals) {
const pricingPlansForInterval = getPricingPlansByInterval({
pricingInterval,
pricingPlans
})
assert(
pricingPlansForInterval.length > 0,
400,
`Invalid pricing config: no pricing plans for pricing interval "${pricingInterval}"`
)
}
}

Wyświetl plik

@ -2,9 +2,9 @@ import type { OriginAdapter, Tool, ToolConfig } from '@agentic/platform-schemas'
import { assert } from '@agentic/platform-core'
/**
* Validates and normalizes the origin adapter config for a project.
* Validates the origin server's tools for a project.
*/
export async function validateTools({
export function validateTools({
originAdapter,
tools,
toolConfigs,
@ -14,8 +14,13 @@ export async function validateTools({
tools: Tool[]
toolConfigs: ToolConfig[]
label: string
}): Promise<void> {
assert(tools.length > 0, 400, `No tools defined for ${label}`)
}) {
if (!tools.length) {
assert(
originAdapter.type === 'raw',
`No tools defined for ${label} with origin adapter type "${originAdapter.type}"`
)
}
const toolsMap: Record<string, Tool> = {}
for (const tool of tools) {
@ -35,8 +40,4 @@ export async function validateTools({
`Tool "${toolConfig.name}" from \`toolConfigs\` not found in \`tools\` for ${label}`
)
}
if (originAdapter.type === 'openapi') {
// TODO
}
}

Wyświetl plik

@ -54,6 +54,9 @@ export const openapiOriginAdapterConfigSchema = commonOriginAdapterSchema.merge(
)
})
)
export type OpenAPIOriginAdapterConfig = z.infer<
typeof openapiOriginAdapterConfigSchema
>
export const mcpOriginAdapterConfigSchema = commonOriginAdapterSchema.merge(
z.object({
@ -63,6 +66,9 @@ export const mcpOriginAdapterConfigSchema = commonOriginAdapterSchema.merge(
type: z.literal('mcp')
})
)
export type MCPOriginAdapterConfig = z.infer<
typeof mcpOriginAdapterConfigSchema
>
export const rawOriginAdapterConfigSchema = commonOriginAdapterSchema.merge(
z.object({
@ -76,6 +82,9 @@ export const rawOriginAdapterConfigSchema = commonOriginAdapterSchema.merge(
type: z.literal('raw')
})
)
export type RawOriginAdapterConfig = z.infer<
typeof rawOriginAdapterConfigSchema
>
/**
* Origin adapter is used to configure the origin API server downstream from
@ -176,6 +185,7 @@ export const openapiOriginAdapterSchema = commonOriginAdapterSchema.merge(
)
})
)
export type OpenAPIOriginAdapter = z.infer<typeof openapiOriginAdapterSchema>
export const mcpOriginAdapterSchema = commonOriginAdapterSchema.merge(
z.object({
@ -190,6 +200,7 @@ export const mcpOriginAdapterSchema = commonOriginAdapterSchema.merge(
serverInfo: mcpServerInfoSchema
})
)
export type MCPOriginAdapter = z.infer<typeof mcpOriginAdapterSchema>
export const rawOriginAdapterSchema = commonOriginAdapterSchema.merge(
z.object({
@ -203,6 +214,7 @@ export const rawOriginAdapterSchema = commonOriginAdapterSchema.merge(
type: z.literal('raw')
})
)
export type RawOriginAdapter = z.infer<typeof rawOriginAdapterSchema>
/**
* Origin adapter is used to configure the origin API server downstream from

Wyświetl plik

@ -30,6 +30,7 @@
- consider switching to [consola](https://github.com/unjs/consola) for logging?
- consider switching to `bun` (for `--hot` reloading!!)
- consider `projectName` and `projectSlug` or `projectIdentifier`?
- not sure I like the duplication between client and server AgenticProjectConfig and ResolvedAgenticProjectConfig, especially for openapi and mcp processing
## License