kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
pull/715/head
rodzic
9c0975f170
commit
e2d2303275
|
@ -1,20 +1,22 @@
|
|||
import { validators } from '@agentic/validators'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
import semver from 'semver'
|
||||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { publishDeployment } from '@/lib/deployments/publish-deployment'
|
||||
import { resolveDeploymentVersion } from '@/lib/deployments/resolve-deployment-version'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
|
||||
|
||||
import { createDeploymentQuerySchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Creates a new deployment within a project.',
|
||||
tags: ['deployments'],
|
||||
|
@ -23,6 +25,7 @@ const route = createRoute({
|
|||
path: 'deployments',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: createDeploymentQuerySchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
|
@ -43,8 +46,7 @@ const route = createRoute({
|
|||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
...openapiErrorResponse409
|
||||
}
|
||||
})
|
||||
|
||||
|
@ -53,13 +55,14 @@ export function registerV1DeploymentsCreateDeployment(
|
|||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const user = await ensureAuthUser(c)
|
||||
const { publish } = c.req.valid('query')
|
||||
const body = c.req.valid('json')
|
||||
const teamMember = c.get('teamMember')
|
||||
const { projectId } = body
|
||||
|
||||
// validatePricingPlans(ctx, pricingPlans)
|
||||
|
||||
// TODO: OpenAPI support
|
||||
// TODO: validate OpenAPI origin schema
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId),
|
||||
|
@ -80,26 +83,23 @@ export function registerV1DeploymentsCreateDeployment(
|
|||
)
|
||||
|
||||
let { version } = body
|
||||
|
||||
if (version) {
|
||||
version = semver.clean(version) ?? undefined
|
||||
if (publish) {
|
||||
assert(
|
||||
version && semver.valid(version),
|
||||
version,
|
||||
400,
|
||||
`Invalid semver version "${version}"`
|
||||
)
|
||||
|
||||
const lastPublishedVersion =
|
||||
project.lastPublishedDeployment?.version ?? '0.0.0'
|
||||
|
||||
assert(
|
||||
semver.gt(version, lastPublishedVersion),
|
||||
400,
|
||||
`Semver version "${version}" must be greater than last published version "${lastPublishedVersion}"`
|
||||
`Deployment version is required to publish deployment "${deploymentId}"`
|
||||
)
|
||||
}
|
||||
|
||||
const [[deployment]] = await db.transaction(async (tx) => {
|
||||
if (version) {
|
||||
version = resolveDeploymentVersion({
|
||||
deploymentId,
|
||||
project,
|
||||
version
|
||||
})
|
||||
}
|
||||
|
||||
let [[deployment]] = await db.transaction(async (tx) => {
|
||||
return Promise.all([
|
||||
// Create the deployment
|
||||
tx
|
||||
|
@ -126,6 +126,15 @@ export function registerV1DeploymentsCreateDeployment(
|
|||
})
|
||||
assert(deployment, 500, `Failed to create deployment "${deploymentId}"`)
|
||||
|
||||
if (publish) {
|
||||
deployment = await publishDeployment(c, {
|
||||
deployment,
|
||||
version: deployment.version!
|
||||
})
|
||||
}
|
||||
// TODO: validate deployment originUrl, originAdapter, originSchema, and
|
||||
// originSchemaVersion
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
import semver from 'semver'
|
||||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { publishDeployment } from '@/lib/deployments/publish-deployment'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
|
@ -51,68 +51,20 @@ export function registerV1DeploymentsPublishDeployment(
|
|||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
const version = semver.clean(body.version)
|
||||
assert(version, 400, `Invalid semver version "${body.version}"`)
|
||||
const { version } = c.req.valid('json')
|
||||
|
||||
// First ensure the deployment exists and the user has access to it
|
||||
let deployment = await tryGetDeployment(c, deploymentId)
|
||||
const deployment = await tryGetDeployment(c, deploymentId)
|
||||
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
|
||||
await acl(c, deployment, { label: 'Deployment' })
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, deployment.projectId),
|
||||
with: {
|
||||
lastPublishedDeployment: true
|
||||
}
|
||||
const publishedDeployment = await publishDeployment(c, {
|
||||
deployment,
|
||||
version
|
||||
})
|
||||
assert(project, 404, `Project not found "${deployment.projectId}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
const lastPublishedVersion =
|
||||
project.lastPublishedDeployment?.version || '0.0.0'
|
||||
|
||||
assert(
|
||||
semver.valid(version),
|
||||
400,
|
||||
`Invalid semver version "${version}" for deployment "${deployment.id}"`
|
||||
return c.json(
|
||||
parseZodSchema(schema.deploymentSelectSchema, publishedDeployment)
|
||||
)
|
||||
assert(
|
||||
semver.gt(version, lastPublishedVersion),
|
||||
400,
|
||||
`Invalid semver version: "${version}" must be greater than current published version "${lastPublishedVersion}" for deployment "${deployment.id}"`
|
||||
)
|
||||
|
||||
// TODO: enforce certain semver constraints
|
||||
// - pricing changes require major version update
|
||||
// - deployment shouldn't already be published?
|
||||
// - any others?
|
||||
|
||||
// Update the deployment and project together in a transaction
|
||||
;[[deployment]] = await db.transaction(async (tx) => {
|
||||
return Promise.all([
|
||||
// Update the deployment
|
||||
tx
|
||||
.update(schema.deployments)
|
||||
.set({
|
||||
published: true,
|
||||
version
|
||||
})
|
||||
.where(eq(schema.deployments.id, deploymentId))
|
||||
.returning(),
|
||||
|
||||
tx
|
||||
.update(schema.projects)
|
||||
.set({
|
||||
lastPublishedDeploymentId: deploymentId
|
||||
})
|
||||
.where(eq(schema.projects.id, project.id))
|
||||
|
||||
// TODO: add publishDeploymentLogEntry
|
||||
])
|
||||
})
|
||||
assert(deployment, 500, `Failed to update deployment "${deploymentId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -13,6 +13,10 @@ export const deploymentIdParamsSchema = z.object({
|
|||
})
|
||||
})
|
||||
|
||||
export const createDeploymentQuerySchema = z.object({
|
||||
publish: z.boolean().default(false).optional()
|
||||
})
|
||||
|
||||
export const filterDeploymentSchema = z.object({
|
||||
projectId: projectIdSchema.optional()
|
||||
})
|
||||
|
|
|
@ -11,10 +11,10 @@ import { z } from '@hono/zod-openapi'
|
|||
|
||||
import { projects } from './project'
|
||||
import {
|
||||
type DeploymentOriginAdapter,
|
||||
deploymentOriginAdapterSchema,
|
||||
type PricingPlanList,
|
||||
pricingPlanListSchema
|
||||
// type Coupon,
|
||||
// couponSchema,
|
||||
} from './schemas'
|
||||
import { teams, teamSelectSchema } from './team'
|
||||
import { users, userSelectSchema } from './user'
|
||||
|
@ -43,6 +43,7 @@ export const deployments = pgTable(
|
|||
|
||||
description: text().default('').notNull(),
|
||||
readme: text().default('').notNull(),
|
||||
iconUrl: text(),
|
||||
|
||||
userId: cuid()
|
||||
.notNull()
|
||||
|
@ -57,13 +58,21 @@ export const deployments = pgTable(
|
|||
// TODO: Tool definitions or OpenAPI spec
|
||||
// services: jsonb().$type<Service[]>().default([]),
|
||||
|
||||
// TODO: metadata config (logo, keywords, etc)
|
||||
// TODO: metadata config (logo, keywords, examples, etc)
|
||||
// TODO: openapi spec or tool definitions or mcp adapter
|
||||
// TODO: webhooks
|
||||
// TODO: third-party auth provider config?
|
||||
// TODO: third-party auth provider config
|
||||
// NOTE: will need consumer.authProviders as well as user.authProviders for
|
||||
// this because custom oauth credentials that are deployment-specific. will
|
||||
// prolly also need to hash the individual AuthProviders in
|
||||
// deployment.authProviders to compare across deployments.
|
||||
|
||||
// Backend API URL
|
||||
// Origin API URL
|
||||
originUrl: text().notNull(),
|
||||
|
||||
// Origin API adapter config (openapi, mcp, hosted externally or internally, etc)
|
||||
originAdapter: jsonb().$type<DeploymentOriginAdapter>().notNull(),
|
||||
|
||||
// Array<PricingPlan>
|
||||
pricingPlans: jsonb().$type<PricingPlanList>().notNull()
|
||||
|
||||
|
@ -123,8 +132,8 @@ export const deploymentSelectSchema = createSelectSchema(deployments, {
|
|||
message: 'Invalid deployment hash'
|
||||
}),
|
||||
|
||||
pricingPlans: pricingPlanListSchema
|
||||
// coupons: z.array(couponSchema)
|
||||
pricingPlans: pricingPlanListSchema,
|
||||
originAdapter: deploymentOriginAdapterSchema
|
||||
})
|
||||
.omit({
|
||||
originUrl: true
|
||||
|
@ -152,11 +161,24 @@ export const deploymentInsertSchema = createInsertSchema(deployments, {
|
|||
message: 'Invalid project id'
|
||||
}),
|
||||
|
||||
originUrl: (schema) => schema.url(),
|
||||
iconUrl: (schema) =>
|
||||
schema
|
||||
.url()
|
||||
.describe(
|
||||
'Logo image URL to use for this delpoyment. Logos should have a square aspect ratio.'
|
||||
),
|
||||
|
||||
pricingPlans: pricingPlanListSchema
|
||||
originUrl: (schema) =>
|
||||
schema.url().describe(`Base URL of the externally hosted origin API server.
|
||||
|
||||
// TODOp
|
||||
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.`),
|
||||
|
||||
pricingPlans: pricingPlanListSchema.describe(
|
||||
'List of PricingPlans should be available as subscriptions for this deployment.'
|
||||
),
|
||||
originAdapter: deploymentOriginAdapterSchema.optional()
|
||||
|
||||
// TODO
|
||||
// coupons: z.array(couponSchema).optional()
|
||||
})
|
||||
.omit({
|
||||
|
|
|
@ -426,3 +426,64 @@ export type StripeSubscriptionItemIdMap = z.infer<
|
|||
// })
|
||||
// .openapi('Coupon')
|
||||
// export type Coupon = z.infer<typeof couponSchema>
|
||||
|
||||
export const deploymentOriginAdapterLocationSchema = z.enum([
|
||||
'external'
|
||||
// 'internal'
|
||||
])
|
||||
export type DeploymentOriginAdapterLocation = z.infer<
|
||||
typeof deploymentOriginAdapterLocationSchema
|
||||
>
|
||||
|
||||
// export const deploymentOriginAdapterInternalTypeSchema = z.enum([
|
||||
// // 'docker',
|
||||
// // 'mcp'
|
||||
// // 'python-fastapi'
|
||||
// // etc
|
||||
// ])
|
||||
// export type DeploymentOriginAdapterInternalType = z.infer<
|
||||
// typeof deploymentOriginAdapterInternalTypeSchema
|
||||
// >
|
||||
|
||||
export const commonDeploymentOriginAdapterSchema = z.object({
|
||||
location: deploymentOriginAdapterLocationSchema
|
||||
|
||||
// TODO: Add support for `internal` hosted API servers
|
||||
// internalType: deploymentOriginAdapterInternalTypeSchema.optional()
|
||||
})
|
||||
|
||||
// TODO: add future support for:
|
||||
// - external mcp
|
||||
// - internal docker
|
||||
// - internal mcp
|
||||
// - internal http
|
||||
export const deploymentOriginAdapterSchema = z
|
||||
.discriminatedUnion('type', [
|
||||
z
|
||||
.object({
|
||||
type: z.literal('openapi'),
|
||||
version: z.enum(['3.0', '3.1']),
|
||||
// TODO: Make sure origin API servers are hidden in this embedded OpenAPI spec
|
||||
spec: z.any().describe('JSON OpenAPI spec for the origin API server.')
|
||||
})
|
||||
.merge(commonDeploymentOriginAdapterSchema),
|
||||
|
||||
z
|
||||
.object({
|
||||
type: z.literal('raw')
|
||||
})
|
||||
.merge(commonDeploymentOriginAdapterSchema)
|
||||
])
|
||||
.default({
|
||||
location: 'external',
|
||||
type: 'raw'
|
||||
})
|
||||
.describe(
|
||||
`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.`
|
||||
)
|
||||
.openapi('DeploymentOriginAdapter')
|
||||
export type DeploymentOriginAdapter = z.infer<
|
||||
typeof deploymentOriginAdapterSchema
|
||||
>
|
||||
|
|
|
@ -51,7 +51,7 @@ export const users = pgTable(
|
|||
isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
|
||||
|
||||
// third-party auth providers
|
||||
providers: jsonb().$type<AuthProviders>().default({}).notNull(),
|
||||
authProviders: jsonb().$type<AuthProviders>().default({}).notNull(),
|
||||
|
||||
stripeCustomerId: stripeId().unique()
|
||||
},
|
||||
|
@ -70,7 +70,7 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||
}))
|
||||
|
||||
export const userSelectSchema = createSelectSchema(users, {
|
||||
providers: publicAuthProvidersSchema
|
||||
authProviders: publicAuthProvidersSchema
|
||||
})
|
||||
.omit({ password: true, emailConfirmToken: true, passwordResetToken: true })
|
||||
.strip()
|
||||
|
|
|
@ -4,7 +4,7 @@ import type { LogLevel } from '@/lib/logger'
|
|||
|
||||
import type { LogEntry, RawLogEntry, RawUser, User } from './types'
|
||||
|
||||
type UserKeys = Exclude<keyof User & keyof RawUser, 'providers'>
|
||||
type UserKeys = Exclude<keyof User & keyof RawUser, 'authProviders'>
|
||||
type LogEntryKeys = keyof RawLogEntry & keyof LogEntry
|
||||
|
||||
test('User types are compatible', () => {
|
||||
|
|
|
@ -5,6 +5,7 @@ import type {
|
|||
InferSelectModel
|
||||
} from '@fisch0920/drizzle-orm'
|
||||
import type { z } from '@hono/zod-openapi'
|
||||
import type { Simplify } from 'type-fest'
|
||||
|
||||
import type * as schema from './schema'
|
||||
|
||||
|
@ -35,7 +36,12 @@ export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
|||
Tables['projects'],
|
||||
{ with: { lastPublishedDeployment: true } }
|
||||
>
|
||||
export type RawProject = InferSelectModel<typeof schema.projects>
|
||||
export type RawProject = Simplify<
|
||||
InferSelectModel<typeof schema.projects> & {
|
||||
lastPublishedDeployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
|
||||
lastDeployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
|
||||
}
|
||||
>
|
||||
|
||||
export type Deployment = z.infer<typeof schema.deploymentSelectSchema>
|
||||
export type DeploymentWithProject = BuildQueryResult<
|
||||
|
@ -43,7 +49,11 @@ export type DeploymentWithProject = BuildQueryResult<
|
|||
Tables['deployments'],
|
||||
{ with: { project: true } }
|
||||
>
|
||||
export type RawDeployment = InferSelectModel<typeof schema.deployments>
|
||||
export type RawDeployment = Simplify<
|
||||
InferSelectModel<typeof schema.deployments> & {
|
||||
project?: RawProject | null // TODO: remove null (requires drizzle-orm changes)
|
||||
}
|
||||
>
|
||||
|
||||
export type Consumer = z.infer<typeof schema.consumerSelectSchema>
|
||||
export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
||||
|
@ -51,7 +61,13 @@ export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
|||
Tables['consumers'],
|
||||
{ with: { project: true; deployment: true } }
|
||||
>
|
||||
export type RawConsumer = InferSelectModel<typeof schema.consumers>
|
||||
export type RawConsumer = Simplify<
|
||||
InferSelectModel<typeof schema.consumers> & {
|
||||
user?: RawUser | null // TODO: remove null (requires drizzle-orm changes)
|
||||
project?: RawProject | null // TODO: remove null (requires drizzle-orm changes)
|
||||
deployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
|
||||
}
|
||||
>
|
||||
export type ConsumerUpdate = Partial<
|
||||
Omit<
|
||||
InferInsertModel<typeof schema.consumers>,
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
import { db, eq, type RawDeployment, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { assert } from '@/lib/utils'
|
||||
|
||||
import type { AuthenticatedContext } from '../types'
|
||||
import { resolveDeploymentVersion } from './resolve-deployment-version'
|
||||
|
||||
export async function publishDeployment(
|
||||
ctx: AuthenticatedContext,
|
||||
{
|
||||
deployment,
|
||||
version: rawVersion
|
||||
}: {
|
||||
deployment: RawDeployment
|
||||
version: string
|
||||
}
|
||||
): Promise<RawDeployment> {
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, deployment.projectId),
|
||||
with: {
|
||||
lastPublishedDeployment: true
|
||||
}
|
||||
})
|
||||
assert(project, 404, `Project not found "${deployment.projectId}"`)
|
||||
await acl(ctx, project, { label: 'Project' })
|
||||
|
||||
const version = resolveDeploymentVersion({
|
||||
deploymentId: deployment.id,
|
||||
project,
|
||||
version: rawVersion
|
||||
})
|
||||
|
||||
// TODO: enforce certain semver constraints
|
||||
// - pricing changes require major version update
|
||||
// - deployment shouldn't already be published?
|
||||
// - any others?
|
||||
|
||||
// Update the deployment and project together in a transaction
|
||||
const [[updatedDeployment]] = await db.transaction(async (tx) => {
|
||||
return Promise.all([
|
||||
// Update the deployment
|
||||
tx
|
||||
.update(schema.deployments)
|
||||
.set({
|
||||
published: true,
|
||||
version
|
||||
})
|
||||
.where(eq(schema.deployments.id, deployment.id))
|
||||
.returning(),
|
||||
|
||||
tx
|
||||
.update(schema.projects)
|
||||
.set({
|
||||
lastPublishedDeploymentId: deployment.id
|
||||
})
|
||||
.where(eq(schema.projects.id, project.id))
|
||||
|
||||
// TODO: add publishDeploymentLogEntry
|
||||
])
|
||||
})
|
||||
assert(
|
||||
updatedDeployment,
|
||||
500,
|
||||
`Failed to update deployment "${deployment.id}"`
|
||||
)
|
||||
|
||||
return updatedDeployment
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
import semver from 'semver'
|
||||
|
||||
import type { RawProject } from '@/db/types'
|
||||
import { assert } from '@/lib/utils'
|
||||
|
||||
export function resolveDeploymentVersion({
|
||||
deploymentId,
|
||||
version: rawVersion,
|
||||
project
|
||||
}: {
|
||||
deploymentId: string
|
||||
version: string
|
||||
project: RawProject
|
||||
}): string | undefined {
|
||||
const version = semver.clean(rawVersion)
|
||||
assert(version, 400, `Invalid semver version "${rawVersion}"`)
|
||||
|
||||
assert(
|
||||
semver.valid(version),
|
||||
400,
|
||||
`Invalid semver version "${version}" for deployment "${deploymentId}"`
|
||||
)
|
||||
|
||||
const lastPublishedVersion = project.lastPublishedDeployment?.version
|
||||
assert(
|
||||
!lastPublishedVersion || semver.gt(version, lastPublishedVersion),
|
||||
400,
|
||||
`Semver version "${version}" must be greater than the current published version "${lastPublishedVersion}" for deployment "${deploymentId}"`
|
||||
)
|
||||
|
||||
return version
|
||||
}
|
|
@ -24,6 +24,8 @@
|
|||
- https://github.com/NangoHQ/nango
|
||||
- https://github.com/transitive-bullshit?submit=Search&q=oauth&tab=stars&type=&sort=&direction=&submit=Search
|
||||
- clerk / workos / auth0
|
||||
- db
|
||||
- replace `enabled` with soft `deletedAt` timestamp
|
||||
|
||||
## License
|
||||
|
||||
|
|
Ładowanie…
Reference in New Issue