Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -186,5 +186,10 @@
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"pnpm": {
"patchedDependencies": {
"autumn-js@1.2.9": "patches/autumn-js@1.2.9.patch"
}
}
}
13 changes: 13 additions & 0 deletions patches/autumn-js@1.2.9.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
diff --git a/dist/better-auth/chunk-LRULDM7S.mjs b/dist/better-auth/chunk-LRULDM7S.mjs
index 31334ee65452ccbf47388e1d4cd79ce7d1f908ce..4e24d0ed9013cd9968df667738d47aba98afd2e2 100644
--- a/dist/better-auth/chunk-LRULDM7S.mjs
+++ b/dist/better-auth/chunk-LRULDM7S.mjs
@@ -6,7 +6,7 @@ import {
} from "./chunk-GJAMWZNZ.mjs";

// src/better-auth/utils/createAutumnEndpoint.ts
-import { createAuthEndpoint } from "better-auth/plugins";
+import { createAuthEndpoint } from "better-auth/api";
var getRouteConfig = (routeName) => {
const route = routeConfigs.find((r) => r.route === routeName);
if (!route) throw new Error(`Route not found: ${routeName}`);
2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
patchedDependencies:
autumn-js@1.2.9: patches/autumn-js@1.2.9.patch
63 changes: 58 additions & 5 deletions src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ import {
import { withServerObservability } from "@/lib/with-server-observability";
import { normalizeLegacyToolMessages } from "@/lib/ai/legacy-tool-message-compat";
import type { ReplySelection } from "@/lib/stores/ui-store";
import { getDefaultChatModelId, resolveGatewayModelId } from "@/lib/ai/models";
import {
getDefaultChatModelId,
isPremiumChatModel,
resolveGatewayModelId,
} from "@/lib/ai/models";
import {
buildGatewayProviderOptions,
createGatewayLanguageModel,
Expand Down Expand Up @@ -55,6 +59,17 @@ function getSelectedCardsContext(body: any): string {
return body.selectedCardsContext || "";
}

type AutumnBillingAPI = {
check: (args: {
headers: Awaited<ReturnType<typeof headers>>;
body: { featureId: string };
}) => Promise<{ allowed: boolean }>;
track: (args: {
headers: Awaited<ReturnType<typeof headers>>;
body: { featureId: string; value: number };
}) => Promise<unknown>;
};

/**
* Inject user-selected context (selected cards + reply quotes / workspace passages) into the last user message.
* `custom` is body.metadata.custom from the composer's runConfig (replySelections only).
Expand Down Expand Up @@ -123,9 +138,11 @@ async function handlePOST(req: Request) {
try {
// FIX: Parallelize headers() and req.json() to eliminate waterfall
const [headersObj, body] = await Promise.all([headers(), req.json()]);
const autumnBilling = auth.api as typeof auth.api & AutumnBillingAPI;

// Get authenticated user ID
const session = await auth.api.getSession({ headers: headersObj });
const isAnonymousUser = Boolean(session?.user?.isAnonymous);
userId = session?.user?.id || null;

const { messages = [] }: { messages?: UIMessage[] } = body;
Expand Down Expand Up @@ -188,9 +205,8 @@ async function handlePOST(req: Request) {
// Get pre-formatted selected cards context from client (no DB fetch needed)
const selectedCardsContext = getSelectedCardsContext(body);

const modelId = resolveGatewayModelId(
body.modelId || getDefaultChatModelId(),
);
const rawModelId = body.modelId || getDefaultChatModelId();
const modelId = resolveGatewayModelId(rawModelId);

// Inject selected cards + reply selections into the last user message
injectSelectionContext(
Expand All @@ -199,6 +215,32 @@ async function handlePOST(req: Request) {
selectedCardsContext,
);

if (userId && !isAnonymousUser && isPremiumChatModel(rawModelId)) {

@cubic-dev-ai cubic-dev-ai Bot Apr 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Custom agent: Flag AI Slop and Fabricated Changes

This billing gate changes production chat behavior, but there is still no test covering the credits_exhausted 403 path or the premium_message tracking path. Add a route-level regression test for premium-model requests so the new gating and metering logic is actually exercised.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/app/api/chat/route.ts, line 218:

<comment>This billing gate changes production chat behavior, but there is still no test covering the `credits_exhausted` 403 path or the `premium_message` tracking path. Add a route-level regression test for premium-model requests so the new gating and metering logic is actually exercised.</comment>

<file context>
@@ -199,6 +215,32 @@ async function handlePOST(req: Request) {
       selectedCardsContext,
     );
 
+    if (userId && !isAnonymousUser && isPremiumChatModel(rawModelId)) {
+      try {
+        const checkResult = await autumnBilling.check({
</file context>
Fix with Cubic

try {
const checkResult = await autumnBilling.check({
headers: headersObj,
body: { featureId: "premium_message" },
});

if (!checkResult.allowed) {
return new Response(
JSON.stringify({
error: "credits_exhausted",
message:
"You've used all your premium AI messages for this month. Upgrade to Pro for unlimited access, or switch to another model.",
code: "CREDITS_EXHAUSTED",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
},
);
}
} catch (err) {
console.error("[billing] Autumn check failed, allowing request:", err);
}
}

const posthogClient = getPostHogServerClient();
const baseGatewayModel = createGatewayLanguageModel(modelId);
const tracedModel = posthogClient
Expand Down Expand Up @@ -245,7 +287,7 @@ async function handlePOST(req: Request) {
},
},
experimental_transform: smoothStream({ chunking: "word", delayInMs: 15 }),
onFinish: ({ usage, finishReason }) => {
onFinish: async ({ usage, finishReason }) => {
const usageInfo = {
inputTokens: usage?.inputTokens,
outputTokens: usage?.outputTokens,
Expand All @@ -257,6 +299,17 @@ async function handlePOST(req: Request) {
};

logger.info("📊 [CHAT-API] Final Token Usage:", usageInfo);

if (userId && !isAnonymousUser && isPremiumChatModel(rawModelId)) {
try {
await autumnBilling.track({
headers: headersObj,
body: { featureId: "premium_message", value: 1 },
});
} catch (err) {
console.error("[billing] Autumn track failed:", err);
}
}
Comment on lines +303 to +312

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Fire-and-forget track() unreliable in serverless

autumnBilling.track(...) is launched as a fire-and-forget promise inside onFinish. In Vercel's Node.js serverless runtime, the function can be terminated shortly after the response stream closes — before this un-awaited promise resolves. The result is that a user passes the check (allowed=true), receives the premium response, but the usage is never recorded in Autumn. On the next request check returns allowed again, effectively granting unlimited premium access.

Vercel's recommended fix is to use waitUntil from @vercel/functions to extend the function lifetime for background work:

import { waitUntil } from '@vercel/functions';

// inside onFinish:
if (userId && !isAnonymousUser && isPremiumChatModel(rawModelId)) {
  waitUntil(
    autumnBilling
      .track({
        headers: headersObj,
        body: { featureId: "premium_message", value: 1 },
      })
      .catch((err: unknown) => {
        console.error("[billing] Autumn track failed:", err);
      }),
  );
}

Fix in Cursor

},
onStepFinish: (result) => {
// stepType exists in runtime but may not be in type definitions
Expand Down
52 changes: 52 additions & 0 deletions src/app/billing/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { AnonymousSessionHandler } from "@/components/layout/SessionHandler";
import { Skeleton } from "@/components/ui/skeleton";
import { useSession } from "@/lib/auth-client";

function BillingAccessGate({ children }: { children: React.ReactNode }) {
const { data: session, isPending } = useSession();
const router = useRouter();

useEffect(() => {
if (!isPending && (!session || session.user?.isAnonymous)) {
router.replace("/auth/sign-up?redirect_url=%2Fbilling");
}
}, [isPending, router, session]);

if (isPending || !session || session.user?.isAnonymous) {
return (
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-4 py-12 md:px-6">
<div className="space-y-3 text-center">
<Skeleton className="mx-auto h-10 w-56" />
<Skeleton className="mx-auto h-4 w-72" />
</div>
<div className="space-y-4 rounded-2xl border bg-card p-6">
<Skeleton className="h-7 w-32" />
<Skeleton className="h-4 w-56" />
<Skeleton className="h-2 w-full" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-64 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
</div>
</main>
);
}

return <>{children}</>;
}

export default function BillingLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AnonymousSessionHandler>
<BillingAccessGate>{children}</BillingAccessGate>
</AnonymousSessionHandler>
);
}
Loading