kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
pull/715/head
rodzic
8ed18982ad
commit
22d389f759
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
@ -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:*",
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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}"`
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue