feat: WIP kittens

pull/715/head
Travis Fischer 2025-05-30 19:08:05 +07:00
rodzic f5622da6cf
commit 364f6a022b
19 zmienionych plików z 733 dodań i 130 usunięć

Wyświetl plik

@ -53,6 +53,22 @@ export function registerV1AdminConsumersGetConsumerByToken(
})
assert(consumer, 404, `API token not found "${token}"`)
if (
consumer.plan === 'free' ||
!consumer.activated ||
!consumer.isStripeSubscriptionActive
) {
c.res.headers.set(
'cache-control',
'public, max-age=1, s-maxage=1 stale-while-revalidate=1'
)
} else {
c.res.headers.set(
'cache-control',
'public, max-age=120, s-maxage=120, stale-while-revalidate=10'
)
}
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
})
}

Wyświetl plik

@ -0,0 +1,59 @@
import { assert, parseZodSchema } from '@agentic/platform-core'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { schema } from '@/db'
import { acl } from '@/lib/acl'
import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { deploymentIdentifierAndPopulateSchema } from './schemas'
const route = createRoute({
description: 'Gets a deployment by its public identifier (admin-only)',
tags: ['deployments'],
operationId: 'adminGetDeploymentByIdentifier',
method: 'get',
path: 'admin/deployments/by-identifier',
security: openapiAuthenticatedSecuritySchemas,
request: {
query: deploymentIdentifierAndPopulateSchema
},
responses: {
200: {
description: 'An admin deployment object',
content: {
'application/json': {
schema: schema.deploymentAdminSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
export function registerV1AdminDeploymentsGetDeploymentByIdentifier(
app: OpenAPIHono<AuthenticatedEnv>
) {
return app.openapi(route, async (c) => {
const { deploymentIdentifier, populate = [] } = c.req.valid('query')
const deployment = await tryGetDeploymentByIdentifier(c, {
deploymentIdentifier,
with: {
...Object.fromEntries(populate.map((field) => [field, true]))
}
})
assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`)
await acl(c, deployment, { label: 'Deployment' })
return c.json(
parseZodSchema(schema.deploymentAdminSelectSchema, deployment)
)
})
}

Wyświetl plik

@ -11,6 +11,7 @@ import { registerV1ConsumersGetConsumer } from './consumers/get-consumer'
import { registerV1ProjectsListConsumers } from './consumers/list-consumers'
import { registerV1ConsumersRefreshConsumerToken } from './consumers/refresh-consumer-token'
import { registerV1ConsumersUpdateConsumer } from './consumers/update-consumer'
import { registerV1AdminDeploymentsGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier copy'
import { registerV1DeploymentsCreateDeployment } from './deployments/create-deployment'
import { registerV1DeploymentsGetDeployment } from './deployments/get-deployment'
import { registerV1DeploymentsGetDeploymentByIdentifier } from './deployments/get-deployment-by-identifier'
@ -107,6 +108,7 @@ registerV1DeploymentsPublishDeployment(privateRouter)
// Internal admin routes
registerV1AdminConsumersGetConsumerByToken(privateRouter)
registerV1AdminDeploymentsGetDeploymentByIdentifier(privateRouter)
// Webhook event handlers
registerV1StripeWebhook(publicRouter)

Wyświetl plik

@ -191,6 +191,12 @@ Deployments are private to a developer or team until they are published, at whic
)
.openapi('Deployment')
export const deploymentAdminSelectSchema = deploymentSelectSchema
.extend({
originUrl: resolvedAgenticProjectConfigSchema.shape.originUrl
})
.openapi('AdminDeployment')
export const deploymentInsertSchema = agenticProjectConfigSchema.strict()
// TODO: Deployments should be immutable, so we should not allow updates aside

Wyświetl plik

@ -0,0 +1,33 @@
const cache = caches.default
export async function fetchCache(opts) {
const { event, cacheKey, fetch: fetchResponse } = opts
let response
if (cacheKey) {
response = await cache.match(cacheKey)
}
if (!response) {
response = await fetchResponse()
response = new Response(response.body, response)
if (cacheKey) {
if (response.headers.has('Cache-Control')) {
// cache will respect response headers
event.waitUntil(
cache.put(cacheKey, response.clone()).catch((err) => {
console.warn('cache put error', cacheKey, err)
})
)
}
response.headers.set('cf-cache-status', 'MISS')
} else {
response.headers.set('cf-cache-status', 'BYPASS')
}
}
return response
}

Wyświetl plik

@ -0,0 +1,16 @@
import type { Consumer } from '@agentic/platform-api-client'
import { assert } from '@agentic/platform-core'
import type { Context } from './types'
export async function getConsumer(
ctx: Context,
token: string
): Promise<Consumer> {
const consumer = await ctx.client.adminGetConsumerByToken({
token
})
assert(consumer, 404, `API token not found "${token}"`)
return consumer
}

Wyświetl plik

@ -0,0 +1,50 @@
import type { Deployment } from '@agentic/platform-api-client'
import type { Tool } from '@agentic/platform-schemas'
import { assert } from '@agentic/platform-core'
export function getTool({
method,
deployment,
toolPath
}: {
method: string
deployment: Deployment
toolPath: string
}): Tool {
const toolName = toolPath
.replaceAll(/^\//g, '')
.replaceAll(/\/$/g, '')
.split('/')
.at(-1)
assert(toolName, 404, `Invalid tool path "${toolPath}"`)
const tool = deployment.tools.find((tool) => {
if (tool.name === toolName) {
return true
}
return false
})
assert(tool, 404, `Tool not found "${toolPath}"`)
if (deployment.originAdapter.type === 'openapi') {
const operation = deployment.originAdapter.toolToOperationMap[tool.name]
assert(
operation,
404,
`OpenAPI operation not found for tool "${tool.name}"`
)
assert(
operation.method.toUpperCase() === method.toUpperCase(),
405,
`Invalid HTTP method "${method.toUpperCase()}" for tool "${tool.name}"`
)
return {
...tool,
operation
}
}
return tool
}

Wyświetl plik

@ -1,17 +1,13 @@
import type { Consumer } from '@agentic/platform-api-client'
import type { PricingPlan } from '@agentic/platform-schemas'
import type { PricingPlan, RateLimit } from '@agentic/platform-schemas'
import { assert } from '@agentic/platform-core'
import type { Context } from './types'
import * as config from './config'
import { ctxAssert } from './ctx-assert'
import { getConsumer } from './get-consumer'
import { getDeployment } from './get-deployment'
import { getService } from './get-service'
import { rateLimit } from './rate-limit'
import { getTool } from './get-tool'
import { updateOriginRequest } from './update-origin-request'
const isProd = config.get('isProd')
/**
* Resolves an input HTTP request to a specific deployment, tool call, and
* billing subscription.
@ -33,7 +29,7 @@ export async function resolveOriginRequest(ctx: Context) {
const { deployment, toolPath } = await getDeployment(ctx, pathname)
console.log('deployment', { deployment: deployment.id, toolPath })
const service = getService({
const tool = getTool({
method,
deployment,
toolPath
@ -48,91 +44,120 @@ export async function resolveOriginRequest(ctx: Context) {
.trim()
if (token) {
consumer = await getConsumer(event, token)
ctxAssert(consumer, 401, `Invalid auth token "${token}"`)
ctxAssert(
consumer.enabled || deployment.proxyMode !== 'active',
consumer = await getConsumer(ctx, token)
assert(consumer, 401, `Invalid auth token "${token}"`)
assert(
consumer.isStripeSubscriptionActive,
402,
`Auth token "${token}" does not have an active subscription`
)
ctxAssert(
consumer.project === deployment.project,
assert(
consumer.projectId === deployment.projectId,
403,
`Auth token "${token}" is not authorized for project "${deployment.project}"`
`Auth token "${token}" is not authorized for project "${deployment.projectId}"`
)
// TODO: ensure that consumer.plan is compatible with the target deployment
// TODO: this could definitely cause issues when changing pricing plans...
// TODO: Ensure that consumer.plan is compatible with the target deployment
// TODO: This could definitely cause issues when changing pricing plans.
pricingPlan = deployment.pricingPlans.find(
(plan) => consumer.plan === plan.slug
(pricingPlan) => consumer!.plan === pricingPlan.slug
)
// ctxAssert(
// assert(
// pricingPlan,
// 403,
// `Auth token "${token}" unable to find matching pricing plan for project "${deployment.project}"`
// )
} else {
// For unauthenticated requests, default to a free pricing plan if available.
pricingPlan = deployment.pricingPlans.find((plan) => plan.slug === 'free')
// ctxAssert(
// assert(
// pricingPlan,
// 403,
// `Auth error, unable to find matching pricing plan for project "${deployment.project}"`
// )
// ctxAssert(
// assert(
// !pricingPlan.auth,
// 403,
// `Auth error, encountered invalid pricing plan "${pricingPlan.slug}" for project "${deployment.project}"`
// )
}
let rateLimit: RateLimit | undefined | null
if (pricingPlan) {
let serviceRateLimit = pricingPlan.rateLimit
const requestsLineItem = pricingPlan.lineItems.find(
(lineItem) => lineItem.slug === 'requests'
)
if (service.rateLimit !== undefined) {
serviceRateLimit = service.rateLimit
if (requestsLineItem) {
assert(
requestsLineItem?.slug === 'requests',
403,
`Invalid pricing plan "${pricingPlan.slug}" for project "${deployment.project}"`
)
rateLimit = requestsLineItem?.rateLimit
} else {
// No `requests` line-item, so we don't report usage for this tool.
reportUsage = false
}
}
const toolConfig = deployment.toolConfigs.find(
(toolConfig) => toolConfig.name === tool.name
)
if (toolConfig) {
if (toolConfig.reportUsage !== undefined) {
reportUsage &&= !!toolConfig.reportUsage
}
if (service.reportUsage !== undefined) {
reportUsage = !!service.reportUsage
if (toolConfig.rateLimit !== undefined) {
// TODO: Improve RateLimitInput vs RateLimit types
rateLimit = toolConfig.rateLimit as RateLimit
}
if (service.pricingPlanConfig) {
const servicePricingPlanConfig =
service.pricingPlanConfig[pricingPlan.slug]
const pricingPlanToolConfig = pricingPlan
? toolConfig.pricingPlanConfig?.[pricingPlan.slug]
: undefined
if (servicePricingPlanConfig) {
ctxAssert(
servicePricingPlanConfig.enabled !== false,
403,
`Auth error, service "${service.name}" is disabled for pricing plan "${pricingPlan.slug}"`
)
if (pricingPlan && pricingPlanToolConfig) {
assert(
pricingPlanToolConfig.enabled &&
pricingPlanToolConfig.enabled === undefined &&
toolConfig.enabled,
403,
`Tool "${tool.name}" is not enabled for pricing plan "${pricingPlan.slug}"`
)
if (servicePricingPlanConfig.rateLimit !== undefined) {
serviceRateLimit = servicePricingPlanConfig.rateLimit
}
if (servicePricingPlanConfig.reportUsage !== undefined) {
reportUsage = !!servicePricingPlanConfig.reportUsage
}
if (pricingPlanToolConfig.reportUsage !== undefined) {
reportUsage &&= !!pricingPlanToolConfig.reportUsage
}
}
// enforce rate limits
if (serviceRateLimit && serviceRateLimit.enabled) {
await rateLimit(event, {
id: consumer ? consumer.id : ip,
duration: serviceRateLimit.requestsInterval * 1000,
max: serviceRateLimit.requestsMaxPerInterval,
method,
pathname
})
if (pricingPlanToolConfig.rateLimit !== undefined) {
// TODO: Improve RateLimitInput vs RateLimit types
rateLimit = pricingPlanToolConfig.rateLimit as RateLimit
}
} else {
assert(toolConfig.enabled, 403, `Tool "${tool.name}" is not enabled`)
}
}
// enforce requests rate limit
if (rateLimit) {
await enforceRateLimit(ctx, {
id: consumer ? consumer.id : ip,
duration: rateLimit.interval * 1000,
max: rateLimit.maxPerInterval,
method,
pathname
})
}
// TODO: decide whether or not this is something we actually want to support
// for long-term DX
const targetUrlOverride = isProd ? null : req.headers.get('x-saasify-target')
@ -147,7 +172,7 @@ export async function resolveOriginRequest(ctx: Context) {
originReq,
deployment: deployment.id,
project: deployment.project,
service,
tool,
consumer,
date,
ip,

Wyświetl plik

@ -4,6 +4,7 @@ import { parseZodSchema } from '@agentic/platform-core'
import type { Context } from './lib/types'
import { type AgenticEnv, envSchema } from './lib/env'
import { handleOptions } from './lib/handle-options'
import { resolveOriginRequest } from './lib/resolve-origin-request'
// Export Durable Objects for cloudflare
export { DurableObjectRateLimiter } from './durable-object'

Wyświetl plik

@ -336,11 +336,17 @@ export class AgenticApiClient {
.json()
}
async getConsumer({
async getConsumer<
TPopulate extends NonNullable<
OperationParameters<'getConsumer'>['populate']
>[number] = never
>({
consumerId,
...searchParams
}: OperationParameters<'getConsumer'>): Promise<
OperationResponse<'getConsumer'>
}: OperationParameters<'getConsumer'> & {
populate?: TPopulate[]
}): Promise<
Simplify<OperationResponse<'getConsumer'> & PopulateConsumer<TPopulate>>
> {
return this.ky
.get(`v1/consumers/${consumerId}`, {
@ -376,11 +382,17 @@ export class AgenticApiClient {
.json()
}
async listConsumers({
async listConsumers<
TPopulate extends NonNullable<
OperationParameters<'listConsumers'>['populate']
>[number] = never
>({
projectId,
...searchParams
}: OperationParameters<'listConsumers'>): Promise<
OperationResponse<'listConsumers'>
}: OperationParameters<'listConsumers'> & {
populate?: TPopulate[]
}): Promise<
Simplify<OperationResponse<'listConsumers'> & PopulateConsumer<TPopulate>>
> {
return this.ky
.get(`v1/projects/${projectId}/consumers`, {
@ -482,11 +494,19 @@ export class AgenticApiClient {
.json()
}
async adminGetConsumerByToken({
async adminGetConsumerByToken<
TPopulate extends NonNullable<
OperationParameters<'adminGetConsumerByToken'>['populate']
>[number] = never
>({
token,
...searchParams
}: OperationParameters<'adminGetConsumerByToken'>): Promise<
OperationResponse<'adminGetConsumerByToken'>
}: OperationParameters<'adminGetConsumerByToken'> & {
populate?: TPopulate[]
}): Promise<
Simplify<
OperationResponse<'adminGetConsumerByToken'> & PopulateConsumer<TPopulate>
>
> {
return this.ky
.get(`v1/admin/consumers/tokens/${token}`, {
@ -494,6 +514,27 @@ export class AgenticApiClient {
})
.json()
}
async adminGetDeploymentByIdentifier<
TPopulate extends NonNullable<
OperationParameters<'adminGetDeploymentByIdentifier'>['populate']
>[number] = never
>(
searchParams: OperationParameters<'adminGetDeploymentByIdentifier'> & {
populate?: TPopulate[]
}
): Promise<
Simplify<
OperationResponse<'adminGetDeploymentByIdentifier'> &
PopulateDeployment<TPopulate>
>
> {
return this.ky
.get(`v1/admin/deployments/by-identifier`, {
searchParams: sanitizeSearchParams(searchParams)
})
.json()
}
}
type OperationParameters<
@ -560,3 +601,19 @@ type PopulateDeployment<TPopulate> = (TPopulate extends 'user'
project: Project
}
: unknown)
type PopulateConsumer<TPopulate> = (TPopulate extends 'user'
? {
user: User
}
: unknown) &
(TPopulate extends 'project'
? {
project: Project
}
: unknown) &
(TPopulate extends 'deployment'
? {
deployment: Deployment
}
: unknown)

Wyświetl plik

@ -295,7 +295,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** @description Lists deployments the user or team has access to. */
/** @description Lists deployments the user or team has access to, optionally filtering by project. */
get: operations["listDeployments"];
put?: never;
/** @description Creates a new deployment within a project. */
@ -340,6 +340,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/admin/deployments/by-identifier": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Gets a deployment by its public identifier (admin-only) */
get: operations["adminGetDeploymentByIdentifier"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -439,43 +456,118 @@ export interface components {
};
/** @description Public deployment identifier (e.g. "namespace/project-name@{hash|version|latest}") */
DeploymentIdentifier: string;
/**
* @description Deployment origin API adapter is used to configure the origin API server downstream from Agentic's API gateway. It specifies whether the origin API server denoted by `originUrl` is hosted externally or deployed internally to Agentic's infrastructure. It also specifies the format for how origin tools / services are defined: either as an OpenAPI spec, an MCP server, or as a raw HTTP REST API.
*
* 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.
* @default {
* "location": "external",
* "type": "raw"
* }
*/
JsonSchemaObject: {
/** @enum {string} */
type: "object";
properties?: Record<string, never>;
required?: string[];
};
Tool: {
name: string;
description?: string;
inputSchema: components["schemas"]["JsonSchemaObject"];
outputSchema?: components["schemas"]["JsonSchemaObject"];
annotations?: {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
};
RateLimit: {
/** @description The interval at which the rate limit is applied. Either a positive integer expressed in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc). */
interval: number | string;
/** @description Maximum number of operations per interval (unitless). */
maxPerInterval: number;
};
PricingPlanToolConfig: {
/** @default true */
enabled: boolean;
reportUsage?: boolean;
rateLimit?: components["schemas"]["RateLimit"] | null;
};
ToolConfig: {
name: string;
/** @default true */
enabled: boolean;
/** @default false */
immutable: boolean;
/** @default true */
reportUsage: boolean;
rateLimit?: components["schemas"]["RateLimit"] | null;
/** @description Map of PricingPlan slug to tool config overrides for a given plan. This is useful to customize tool behavior or disable tools completely on different pricing plans. */
pricingPlanConfig?: {
[key: string]: components["schemas"]["PricingPlanToolConfig"];
};
};
/** @description Origin adapter is used to configure the origin API server downstream from Agentic's API gateway. It specifies whether the origin API server denoted by `originUrl` is hosted externally or deployed internally to Agentic's infrastructure. It also specifies the format for how origin tools are defined: either an OpenAPI spec, an MCP server, or a raw HTTP REST API. */
OriginAdapter: {
/** @enum {string} */
location: "external";
/** @enum {string} */
type: "openapi";
/** @description JSON stringified OpenAPI spec describing the origin API server. */
spec: string;
/** @enum {string} */
location: "external";
/** @description Mapping from tool name to OpenAPI Operation info. This is used by the Agentic API gateway to route tools to the correct origin API operation, along with the HTTP method, path, params, etc. */
toolToOperationMap: {
[key: string]: {
/** @description OpenAPI operationId for the tool */
operationId: string;
/** @description HTTP method */
method: "get" | "put" | "post" | "delete" | "patch" | "trace";
/** @description HTTP path template */
path: string;
/** @description Mapping from parameter name to HTTP source (query, path, JSON body, etc). */
parameterSources: {
[key: string]: "query" | "header" | "path" | "cookie" | "body" | "formData";
};
tags?: string[];
};
};
} | {
/** @enum {string} */
type: "raw";
location: "external";
/** @enum {string} */
type: "mcp";
serverInfo: {
name: string;
version: string;
capabilities?: {
experimental?: Record<string, never>;
logging?: Record<string, never>;
completions?: Record<string, never>;
prompts?: {
listChanged?: boolean;
};
resources?: {
subscribe?: boolean;
listChanged?: boolean;
};
tools?: {
listChanged?: boolean;
};
};
instructions?: string;
};
} | {
/** @enum {string} */
location: "external";
/** @enum {string} */
type: "raw";
};
/** @example Starter Monthly */
/**
* @description Human-readable name for the pricing plan
* @example Starter Monthly
*/
name: string;
/**
* @description PricingPlan slug ("free", "starter-monthly", "pro-annual", etc)
* @description PricingPlan slug ("free", "starter-monthly", "pro-annual", etc). Should be lower-cased and kebab-cased. Should be stable across deployments.
* @example starter-monthly
*/
slug: string;
/** @example API calls */
label: string;
RateLimit: {
/** @description The interval at which the rate limit is applied. Either a positive number in seconds or a valid positive [ms](https://github.com/vercel/ms) string (eg, "10s", "1m", "8h", "2d", "1w", "1y", etc). */
interval: number | string;
/** @description Maximum number of operations per interval (unitless). */
maxPerInterval: number;
};
PricingPlanTier: {
unitAmount?: number;
flatAmount?: number;
@ -483,13 +575,13 @@ export interface components {
};
/** @description 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. */
PricingPlanLineItem: {
slug: string | "base" | "requests";
slug: string;
label?: components["schemas"]["label"];
/** @enum {string} */
usageType: "licensed";
amount: number;
} | {
slug: string | "base" | "requests";
slug: string;
label?: components["schemas"]["label"];
/** @enum {string} */
usageType: "metered";
@ -552,7 +644,11 @@ export interface components {
teamId?: string;
/** @description Project id (e.g. "proj_tz4a98xxat96iws9zmbrgj3a") */
projectId: string;
originAdapter?: components["schemas"]["OriginAdapter"];
/** @default [] */
tools: components["schemas"]["Tool"][];
/** @default [] */
toolConfigs: components["schemas"]["ToolConfig"][];
originAdapter: components["schemas"]["OriginAdapter"];
/**
* @description 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 [
@ -583,6 +679,45 @@ export interface components {
pricingIntervals: components["schemas"]["PricingInterval"][];
project?: components["schemas"]["Project"];
};
/**
* @description Origin adapter is used to configure the origin API server downstream from Agentic's API gateway. It specifies whether the origin API server denoted by `originUrl` is hosted externally or deployed internally to Agentic's infrastructure. It also specifies the format for how origin tools are defined: either an OpenAPI spec, an MCP server, or a raw HTTP REST API.
*
* 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.
* @default {
* "location": "external",
* "type": "raw"
* }
*/
OriginAdapterConfig: {
/** @enum {string} */
location: "external";
/** @enum {string} */
type: "openapi";
/** @description Local file path, URL, or JSON stringified OpenAPI spec describing the origin API server. */
spec: string;
} | {
/** @enum {string} */
location: "external";
/** @enum {string} */
type: "mcp";
} | {
/** @enum {string} */
location: "external";
/** @enum {string} */
type: "raw";
};
/** @description A Deployment is a single, immutable instance of a Project. Each deployment contains pricing plans, origin server config (OpenAPI or MCP server), tool definitions, and metadata.
*
* Deployments are private to a developer or team until they are published, at which point they are accessible to any customers with access to the parent Project. */
AdminDeployment: components["schemas"]["Deployment"] & {
/**
* Format: uri
* @description Required base URL of the externally hosted origin API server. Must be a valid `https` URL.
*
* 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.
*/
originUrl: string;
};
};
responses: {
/** @description Bad Request */
@ -1441,7 +1576,7 @@ export interface operations {
* 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.
*/
originUrl: string;
originAdapter?: components["schemas"]["OriginAdapter"];
originAdapter?: components["schemas"]["OriginAdapterConfig"];
/**
* @description 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 [
@ -1470,6 +1605,8 @@ export interface operations {
* ]
*/
pricingIntervals?: components["schemas"]["PricingInterval"][];
/** @default [] */
toolConfigs?: components["schemas"]["ToolConfig"][];
};
};
};
@ -1552,4 +1689,32 @@ export interface operations {
404: components["responses"]["404"];
};
};
adminGetDeploymentByIdentifier: {
parameters: {
query: {
populate?: ("user" | "team" | "project")[];
/** @description Public deployment identifier (e.g. "namespace/project-name@{hash|version|latest}") */
deploymentIdentifier: components["schemas"]["DeploymentIdentifier"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description An admin deployment object */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminDeployment"];
};
};
400: components["responses"]["400"];
401: components["responses"]["401"];
403: components["responses"]["403"];
404: components["responses"]["404"];
};
};
}

Wyświetl plik

@ -1,25 +1,41 @@
import type { AuthUser } from '@agentic/platform-schemas'
import type { Tokens as AuthTokens } from '@openauthjs/openauth/client'
import type { Simplify } from 'type-fest'
import { type AuthUser, type PricingPlan } from '@agentic/platform-schemas'
import type { components } from './openapi'
export type Consumer = components['schemas']['Consumer']
export type Project = components['schemas']['Project']
export type Deployment = components['schemas']['Deployment']
export type User = components['schemas']['User']
export type Team = components['schemas']['Team']
export type TeamMember = components['schemas']['TeamMember']
export type Deployment = Simplify<
Omit<components['schemas']['Deployment'], 'pricingPlans'> & {
pricingPlans: PricingPlan[]
}
>
export type ProjectIdentifier = components['schemas']['ProjectIdentifier']
export type DeploymentIdentifier = components['schemas']['DeploymentIdentifier']
export type OriginAdapter = components['schemas']['OriginAdapter']
// export type OriginAdapter = components['schemas']['OriginAdapter']
export type RateLimit = components['schemas']['RateLimit']
export type PricingInterval = components['schemas']['PricingInterval']
export type PricingPlanTier = components['schemas']['PricingPlanTier']
export type PricingPlanLineItem = components['schemas']['PricingPlanLineItem']
export type PricingPlan = components['schemas']['PricingPlan']
export type {
OriginAdapter,
PricingInterval,
PricingPlan,
PricingPlanLineItem,
PricingPlanTier,
PricingPlanToolConfig,
RateLimit
} from '@agentic/platform-schemas'
// export type RateLimit = components['schemas']['RateLimit']
// export type PricingInterval = components['schemas']['PricingInterval']
// export type PricingPlanTier = components['schemas']['PricingPlanTier']
// export type PricingPlanLineItem = components['schemas']['PricingPlanLineItem']
// export type PricingPlan = components['schemas']['PricingPlan']
export type PricingPlanName = components['schemas']['name']
export type PricingPlanSlug = components['schemas']['slug']

Wyświetl plik

@ -118,51 +118,70 @@ export function validatePricing({
// Validate PricingPlanLineItems
for (const pricingPlan of pricingPlans) {
for (const lineItem of pricingPlan.lineItems) {
if (lineItem.slug === 'base') {
assert(
lineItem.usageType === 'licensed',
`Invalid PricingPlan "${pricingPlan.slug}": reserved LineItem "base" must have "licensed" usage type.`
)
} else if (lineItem.slug === 'requests') {
assert(
lineItem.usageType === 'metered',
`Invalid PricingPlan "${pricingPlan.slug}": reserved "requests" LineItem "${lineItem.slug}" must have "metered" usage type.`
)
} else {
assert(
lineItem.slug.startsWith('custom-'),
`Invalid PricingPlan "${pricingPlan.slug}": custom LineItem "${lineItem.slug}" must have a slug that starts with "custom-". This is required so that TypeScript can discriminate between custom and reserved line-items.`
)
}
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.`
(lineItem as any).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.`
(lineItem as any).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.`
(lineItem as any).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.`
(lineItem as any).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.`
(lineItem as any).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.`
(lineItem as any).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.`
)
// TODO: Not sure if this is a valid requirement or not. If it is, update
// the corresponding type in the schemas package.
// 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".`
`Invalid PricingPlan "${pricingPlan.slug}": metered LineItem "${(lineItem as any).slug}" must specify a valid "billingScheme".`
)
}
}

Wyświetl plik

@ -25,6 +25,7 @@
"dependencies": {
"@hono/zod-openapi": "catalog:",
"ms": "catalog:",
"type-fest": "catalog:",
"zod": "catalog:"
},
"devDependencies": {

Wyświetl plik

@ -1,3 +1,4 @@
import type { Simplify } from 'type-fest'
import { z } from '@hono/zod-openapi'
import {
@ -7,6 +8,7 @@ import {
import {
defaultFreePricingPlan,
pricingIntervalListSchema,
type PricingPlanList,
pricingPlanListSchema
} from './pricing'
import { toolConfigSchema, toolSchema } from './tools'
@ -151,20 +153,28 @@ To add support for annual pricing plans, for example, you can use: \`['month', '
* `toolConfigs`, it will use the default behavior of the Agentic API
* gateway.
*/
toolConfigs: z.array(toolConfigSchema).default([]).optional()
toolConfigs: z.array(toolConfigSchema).optional().default([])
})
.strip()
export type AgenticProjectConfigInput = z.input<
typeof agenticProjectConfigSchema
export type AgenticProjectConfigInput = Simplify<
Omit<z.input<typeof agenticProjectConfigSchema>, 'pricingPlans'> & {
pricingPlans?: PricingPlanList
}
>
export type AgenticProjectConfig = Simplify<
Omit<z.output<typeof agenticProjectConfigSchema>, 'pricingPlans'> & {
pricingPlans: PricingPlanList
}
>
export type AgenticProjectConfig = z.output<typeof agenticProjectConfigSchema>
export const resolvedAgenticProjectConfigSchema =
agenticProjectConfigSchema.extend({
originAdapter: originAdapterSchema,
tools: z.array(toolSchema).default([])
})
export type ResolvedAgenticProjectConfig = z.output<
typeof resolvedAgenticProjectConfigSchema
export type ResolvedAgenticProjectConfig = Simplify<
Omit<z.output<typeof resolvedAgenticProjectConfigSchema>, 'pricingPlans'> & {
pricingPlans: PricingPlanList
}
>

Wyświetl plik

@ -1,3 +1,4 @@
import type { Simplify } from 'type-fest'
import { z } from '@hono/zod-openapi'
import { rateLimitSchema } from './rate-limit'
@ -95,9 +96,19 @@ export const stripeMeterIdMapSchema = z
.openapi('StripeMeterIdMap')
export type StripeMeterIdMap = z.infer<typeof stripeMeterIdMapSchema>
// export const pricingPlanLineItemTypeSchema = z.union([
// z.literal('base'),
// z.literal('requests'),
// z.literal('custom')
// ])
// export type PricingPlanLineItemType = z.infer<
// typeof pricingPlanLineItemTypeSchema
// >
export type CustomPricingPlanLineItemSlug = `custom-${string}`
const commonPricingPlanLineItemSchema = z.object({
/**
* Slugs act as the primary key for LineItems. They should be lower and
* Slugs act as the primary key for LineItems. They should be lower-cased and
* kebab-cased ("base", "requests", "image-transformations").
*
* The `base` slug is reserved for a plan's default `licensed` line-item.
@ -106,8 +117,14 @@ const commonPricingPlanLineItemSchema = z.object({
* on the number of request made during a given billing interval.
*
* All other PricingPlanLineItem `slugs` are considered custom LineItems.
*
* Should be stable across deployments, so if a slug refers to one type of
* product / line-item / metric in one deployment, it should refer to the same
* product / line-item / metric in future deployments, even if they are
* configured differently. If you are switching between a licensed and metered
* line-item across deployments, they must use different slugs.
*/
slug: z.union([z.string(), z.literal('base'), z.literal('requests')]),
slug: z.string(),
/**
* Optional label for the line-item which will be displayed on customer bills.
@ -117,6 +134,9 @@ const commonPricingPlanLineItemSchema = z.object({
label: z.string().optional().openapi('label', { example: 'API calls' })
})
/**
* Licensed LineItems are used to charge for fixed-price services.
*/
export const pricingPlanLicensedLineItemSchema =
commonPricingPlanLineItemSchema.merge(
z.object({
@ -135,7 +155,13 @@ export const pricingPlanLicensedLineItemSchema =
amount: z.number().nonnegative()
})
)
export type PricingPlanLicensedLineItem = z.infer<
typeof pricingPlanLicensedLineItemSchema
>
/**
* Metered LineItems are used to charge for usage-based services.
*/
export const pricingPlanMeteredLineItemSchema =
commonPricingPlanLineItemSchema.merge(
z.object({
@ -249,6 +275,55 @@ export const pricingPlanMeteredLineItemSchema =
.optional()
})
)
export type PricingPlanMeteredLineItem = Simplify<
| (Omit<
z.infer<typeof pricingPlanMeteredLineItemSchema>,
'billingScheme' | 'tiers' | 'tiersMode'
> & {
billingScheme: 'per_unit'
})
| (Omit<
z.infer<typeof pricingPlanMeteredLineItemSchema>,
'billingScheme' | 'unitAmount'
> & {
billingScheme: 'tiered'
})
>
/**
* The `base` LineItem is used to charge a fixed amount for a service using
* `licensed` usage type.
*/
export const basePricingPlanLineItemSchema =
pricingPlanLicensedLineItemSchema.extend({
slug: z.literal('base')
})
export type BasePricingPlanLineItem = z.infer<
typeof basePricingPlanLineItemSchema
>
/**
* The `requests` LineItem is used to charge for usage-based services using the
* `metered` usage type.
*
* It corresponds to the total number of API calls made by a customer during a
* given billing interval.
*/
export const requestsPricingPlanLineItemSchema =
pricingPlanMeteredLineItemSchema.extend({
slug: z.literal('requests'),
/**
* 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().default('API calls').optional()
})
export type RequestsPricingPlanLineItem = PricingPlanMeteredLineItem & {
slug: 'requests'
}
/**
* PricingPlanLineItems represent a single line-item in a Stripe Subscription.
@ -285,11 +360,37 @@ export const pricingPlanLineItemSchema = z
message: `Invalid PricingPlanLineItem "${data.slug}": reserved "requests" LineItems must have "metered" usage type.`
})
)
.refine(
(data) => {
if (data.slug !== 'base' && data.slug !== 'requests') {
return data.slug.startsWith('custom-')
}
return true
},
(data) => ({
message: `Invalid PricingPlanLineItem "${data.slug}": custom line-item slugs must start with "custom-". This is required so that TypeScript can discriminate between custom and reserved line-items.`
})
)
.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 usage-based line-items.'
)
.openapi('PricingPlanLineItem')
export type PricingPlanLineItem = z.infer<typeof pricingPlanLineItemSchema>
// export type PricingPlanLineItem = z.infer<typeof pricingPlanLineItemSchema>
// This is a more complex discriminated union based on both `slug` and `usageType`
export type PricingPlanLineItem = Simplify<
| BasePricingPlanLineItem
| RequestsPricingPlanLineItem
| (
| (Omit<PricingPlanLicensedLineItem, 'slug'> & {
slug: CustomPricingPlanLineItemSlug
})
| (Omit<PricingPlanMeteredLineItem, 'slug'> & {
slug: CustomPricingPlanLineItemSlug
})
)
>
/**
* Represents the config for a single Stripe subscription plan with one or more
@ -297,12 +398,17 @@ export type PricingPlanLineItem = z.infer<typeof pricingPlanLineItemSchema>
*/
export const pricingPlanSchema = z
.object({
name: z.string().nonempty().openapi('name', { example: 'Starter Monthly' }),
name: z
.string()
.nonempty()
.describe('Human-readable name for the pricing plan')
.openapi('name', { example: 'Starter Monthly' }),
slug: z
.string()
.nonempty()
.describe(
'PricingPlan slug ("free", "starter-monthly", "pro-annual", etc)'
'PricingPlan slug ("free", "starter-monthly", "pro-annual", etc). Should be lower-cased and kebab-cased. Should be stable across deployments.'
)
.openapi('slug', { example: 'starter-monthly' }),
@ -328,7 +434,7 @@ export const pricingPlanSchema = z
trialPeriodDays: z.number().nonnegative().optional(),
/**
* List of LineItems which are included in the PricingPlan.
* List of custom LineItems which are included in the PricingPlan.
*
* Note: we currently support a max of 20 LineItems per plan.
*/
@ -341,7 +447,12 @@ export const pricingPlanSchema = z
'Represents the config for a Stripe subscription with one or more PricingPlanLineItems.'
)
.openapi('PricingPlan')
export type PricingPlan = z.infer<typeof pricingPlanSchema>
// export type PricingPlan = z.infer<typeof pricingPlanSchema>
export type PricingPlan = Simplify<
Omit<z.infer<typeof pricingPlanSchema>, 'lineItems'> & {
lineItems: PricingPlanLineItem[]
}
>
/**
* Map from PricingPlanLineItem **slug** to Stripe Product id
@ -364,7 +475,7 @@ export const pricingPlanListSchema = z
message: 'Must contain at least one PricingPlan'
})
.describe('List of PricingPlans')
export type PricingPlanList = z.infer<typeof pricingPlanListSchema>
export type PricingPlanList = PricingPlan[]
/**
* Map from internal PricingPlanLineItem **slug** to Stripe Subscription Item id

Wyświetl plik

@ -3,11 +3,19 @@ import { z } from '@hono/zod-openapi'
import { pricingPlanSlugSchema } from './pricing'
import { rateLimitSchema } from './rate-limit'
const toolNameBlacklist = new Set(['mcp'])
export const toolNameSchema = z
.string()
// TODO: validate this regex constraint
.regex(/^[a-zA-Z0-9_]+$/)
.nonempty()
.refine(
(name) => !toolNameBlacklist.has(name),
(name) => ({
message: `Tool name is reserved: "${name}"`
})
)
export const jsonSchemaObjectSchema = z
.object({
@ -28,7 +36,7 @@ export const pricingPlanToolConfigSchema = z
*
* @default true
*/
enabled: z.boolean().default(true).optional(),
enabled: z.boolean().optional().default(true),
/**
* Overrides whether to report default `requests` usage for metered billing
@ -74,7 +82,7 @@ export const toolConfigSchema = z
*
* @default true
*/
enabled: z.boolean().default(true).optional(),
enabled: z.boolean().optional().default(true),
/**
* Whether this tool's output is deterministic and idempotent given the
@ -89,7 +97,7 @@ export const toolConfigSchema = z
*
* @default false
*/
immutable: z.boolean().default(false).optional(),
immutable: z.boolean().optional().default(false),
/**
* Whether calls to this tool should be reported as usage for the default
@ -100,7 +108,7 @@ export const toolConfigSchema = z
*
* @default true
*/
reportUsage: z.boolean().default(true).optional(),
reportUsage: z.boolean().optional().default(true),
/**
* Customize the default `requests`-based rate-limiting for this tool.

Wyświetl plik

@ -558,6 +558,9 @@ importers:
ms:
specifier: 'catalog:'
version: 2.1.3
type-fest:
specifier: 'catalog:'
version: 4.41.0
zod:
specifier: 'catalog:'
version: 3.25.36

Wyświetl plik

@ -30,6 +30,11 @@
- consider switching to [consola](https://github.com/unjs/consola) for logging?
- consider switching to `bun` (for `--hot` reloading!!)
- consider `projectName` and `projectSlug` or `projectIdentifier`?
- for clients and internal packages, importing some types from platform-schemas and some types from platform-api-client is confusing
- this actually causes problems because some types from the openapi version aren't compatible with the schema types like `PricingPlan`
- validate stability of pricing plan slugs across deployments
- same for pricing plan line-items
- replace `ms` package
## License