import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireViewer, viewerMiddleware } from "./middleware"; import type { Viewer } from "~/lib/types"; import { forbidden } from "~/server/errors"; import { limitWrite } from "~/server/limits"; import { billingAccountFor } from "~/server/metering/access"; import { canAdministerAccount, requireRepoAccess } from "~/server/repos"; import { AI_ENDPOINT_KINDS, deleteEndpoint, saveEndpoint } from "~/server/ai/endpoint"; import type { AiScope } from "~/server/ai/endpoint"; /** Where an endpoint is saved: a repository (repo admins) or an account (the user, org owners, site admins). */ const target = z.discriminatedUnion("scope", [ z.object({ scope: z.literal("repo"), owner: z.string().min(1).max(100), repo: z.string().min(1).max(100) }), z.object({ scope: z.literal("account"), org: z.string().min(1).max(100).nullable() }), ]); async function resolveTarget( input: z.infer, viewer: Viewer, ): Promise<{ scope: AiScope; ownerId: string }> { if (input.scope === "repo") { const { repo } = await requireRepoAccess(input.owner, input.repo, viewer, "admin"); // The endpoint spends the owner's money and receives repository content, so the owner decides, not any repo admin. if (!(await canAdministerAccount(repo.owner_id, viewer))) { throw forbidden("Only the repository owner can change its AI endpoint"); } return { scope: "repo", ownerId: repo.id }; } const account = await billingAccountFor(viewer, input.org); if (!account.canEdit || !(await canAdministerAccount(account.accountId, viewer))) { throw forbidden("Only the account owner can change its AI endpoint"); } return { scope: "account", ownerId: account.accountId }; } /** Saves an endpoint. The key is sealed and never returned: the response carries only the summary. */ export const saveAiEndpoint = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator( z.object({ target, kind: z.enum(AI_ENDPOINT_KINDS), provider: z.string().trim().max(40).nullable().optional(), model: z.string().trim().min(1).max(120), baseUrl: z.string().trim().max(2048).nullable().optional(), cfAccountId: z.string().trim().max(64).nullable().optional(), cfGatewayId: z.string().trim().max(64).nullable().optional(), apiKey: z.string().trim().min(8).max(512), monthlyTokenCap: z.number().int().positive().nullable().optional(), }), ) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { scope, ownerId } = await resolveTarget(data.target, viewer); const { target: _target, ...input } = data; return saveEndpoint(scope, ownerId, input, viewer.id); }); export const deleteAiEndpoint = createServerFn({ method: "POST" }) .middleware([viewerMiddleware]) .validator(z.object({ target })) .handler(async ({ data, context }) => { const viewer = requireViewer(context.viewer); await limitWrite(viewer); const { scope, ownerId } = await resolveTarget(data.target, viewer); await deleteEndpoint(scope, ownerId); return { ok: true }; });