import { env } from "cloudflare:workers"; import { HttpError, paymentRequired } from "../errors"; import { chatCompletion, chatModel } from "../mergefix/gateway"; import { ModelCallError } from "../mergefix/plan"; import { resolveAiEndpoint, resolveAiEndpointSummary } from "../ai/endpoint"; import { aiMeter } from "../metering/ai"; import { billingSettings, hardCap, periodUsage } from "../metering/quota"; import { randomId } from "../crypto"; import { describeMessages, mockSuggestion, parseSuggestion } from "./prompt"; import type { DescribeInput, PullSuggestion } from "./prompt"; import { planAllowsAi } from "~/lib/plans"; import { captureServerEvent } from "../analytics"; const MAX_OUTPUT_TOKENS = 700; const FEATURE = "pr-describe"; export type AiAccess = { allowed: boolean; /** Platform-billed calls used and allowed this period; null for BYOK accounts or uncapped plans. */ usage: { used: number; limit: number | null } | null; }; /** Whether `accountId` may use AI features: a plan with ai.calls, or its own key (free, never capped). */ export async function aiAccessFor(accountId: string, repoId: string | null = null): Promise { const [settings, byok] = await Promise.all([ billingSettings(accountId), resolveAiEndpointSummary({ accountId, repoId }), ]); if (byok) return { allowed: true, usage: null }; if (!planAllowsAi(settings.plan)) return { allowed: false, usage: null }; const period = await periodUsage(accountId, "ai.calls"); return { allowed: true, usage: { used: period.quantity, limit: hardCap(settings, "ai.calls") } }; } export function aiPaymentRequired(accountId: string): HttpError { return paymentRequired("AI suggestions are available on paid plans", { meter: "ai.calls", reason: "paid_feature", accountId, }); } /** One metered model call that drafts a title and body; the caller has already checked repo access. */ export async function suggestPullDescription( context: { accountId: string; repoId: string }, input: DescribeInput, ): Promise { const byok = await resolveAiEndpoint(context); if (!byok && !planAllowsAi((await billingSettings(context.accountId)).plan)) throw aiPaymentRequired(context.accountId); if (env.MERGEFIX_PROVIDER === "mock") return mockSuggestion(input); const scope = randomId(); const target = { model: env.PR_DESCRIBE_MODEL, byok, maxTokens: MAX_OUTPUT_TOKENS }; const model = chatModel(target); const metered = aiMeter({ accountId: context.accountId, repoId: context.repoId, feature: FEATURE, scope }); const callIndex = await metered.beforeCall(model, byok !== null); const metadata = { account_id: context.accountId, repo_id: context.repoId, scope, feature: FEATURE }; const reply = await chatCompletion({ ...target, metadata }, describeMessages(input)).catch((error: unknown) => { if (error instanceof ModelCallError) throw new HttpError(502, error.message); throw error; }); await metered.afterCall(callIndex, model, reply.usage, byok !== null); captureServerEvent( "pull_description_suggested", context.accountId, { byok: byok !== null }, { ownerAccountId: context.accountId }, ); const suggestion = parseSuggestion(reply.text); if (!suggestion) throw new HttpError(502, "The model did not return a usable suggestion; try again"); return suggestion; }