mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-03 21:02:40 +02:00
feat: unified credits and its cost calculations
This commit is contained in:
parent
451a98936e
commit
ae9d36d77f
61 changed files with 5835 additions and 272 deletions
|
|
@ -127,7 +127,7 @@ const FAQ_ITEMS = [
|
|||
{
|
||||
question: "What happens after I use my free tokens?",
|
||||
answer:
|
||||
"After your free tokens, create a free SurfSense account to unlock 3 million more premium tokens. Additional tokens can be purchased at $1 per million. Non-premium models remain unlimited for registered users.",
|
||||
"After your free tokens, create a free SurfSense account to unlock $5 of premium credit. Additional credit can be topped up at $1 for $1 of credit, billed at the actual provider cost. Non-premium models remain unlimited for registered users.",
|
||||
},
|
||||
{
|
||||
question: "Is Claude AI available without login?",
|
||||
|
|
@ -329,7 +329,7 @@ export default async function FreeHubPage() {
|
|||
<section className="max-w-3xl mx-auto text-center">
|
||||
<h2 className="text-2xl font-bold mb-3">Want More Features?</h2>
|
||||
<p className="text-muted-foreground mb-6 leading-relaxed">
|
||||
Create a free SurfSense account to unlock 3 million tokens, document uploads with
|
||||
Create a free SurfSense account to unlock $5 of premium credit, document uploads with
|
||||
citations, team collaboration, and integrations with Slack, Google Drive, Notion, and
|
||||
30+ more tools.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
|
|||
export const metadata: Metadata = {
|
||||
title: "Pricing | SurfSense - Free AI Search Plans",
|
||||
description:
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & 3M premium tokens. Use ChatGPT, Claude AI, and premium AI models. Pay-as-you-go tokens at $1 per million.",
|
||||
"Explore SurfSense plans and pricing. Start free with 500 pages & $5 of premium credit. Use ChatGPT, Claude AI, and premium AI models. Pay as you go at provider cost — $1 buys $1 of credit.",
|
||||
alternates: {
|
||||
canonical: "https://surfsense.com/pricing",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
const TABS = [
|
||||
{ id: "pages", label: "Pages" },
|
||||
{ id: "tokens", label: "Premium Tokens" },
|
||||
{ id: "tokens", label: "Premium Credit" },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ type UnifiedPurchase = {
|
|||
kind: PurchaseKind;
|
||||
created_at: string;
|
||||
status: PagePurchaseStatus;
|
||||
/**
|
||||
* Granted units. Interpretation depends on ``kind``:
|
||||
* - ``"pages"`` — integer number of indexed pages.
|
||||
* - ``"tokens"`` — integer micro-USD of credit (1_000_000 = $1.00).
|
||||
* The ``Granted`` column formats accordingly.
|
||||
*/
|
||||
granted: number;
|
||||
amount_total: number | null;
|
||||
currency: string | null;
|
||||
|
|
@ -58,7 +64,7 @@ const KIND_META: Record<
|
|||
iconClass: "text-sky-500",
|
||||
},
|
||||
tokens: {
|
||||
label: "Premium Tokens",
|
||||
label: "Premium Credit",
|
||||
icon: Coins,
|
||||
iconClass: "text-amber-500",
|
||||
},
|
||||
|
|
@ -97,12 +103,25 @@ function normalizeTokenPurchase(p: TokenPurchase): UnifiedPurchase {
|
|||
kind: "tokens",
|
||||
created_at: p.created_at,
|
||||
status: p.status,
|
||||
granted: p.tokens_granted,
|
||||
granted: p.credit_micros_granted,
|
||||
amount_total: p.amount_total,
|
||||
currency: p.currency,
|
||||
};
|
||||
}
|
||||
|
||||
function formatGranted(p: UnifiedPurchase): string {
|
||||
if (p.kind === "tokens") {
|
||||
const dollars = p.granted / 1_000_000;
|
||||
// Premium credit packs are always whole dollars at the moment, but
|
||||
// future fractional grants (refunds, partial top-ups) shouldn't
|
||||
// silently round to "$0".
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)} of credit`;
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)} of credit`;
|
||||
return "$0 of credit";
|
||||
}
|
||||
return p.granted.toLocaleString();
|
||||
}
|
||||
|
||||
export function PurchaseHistoryContent() {
|
||||
const results = useQueries({
|
||||
queries: [
|
||||
|
|
@ -143,7 +162,7 @@ export function PurchaseHistoryContent() {
|
|||
<ReceiptText className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm font-medium">No purchases yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Your page and premium token purchases will appear here after checkout.
|
||||
Your page and premium credit purchases will appear here after checkout.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -177,7 +196,7 @@ export function PurchaseHistoryContent() {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{p.granted.toLocaleString()}
|
||||
{formatGranted(p)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right tabular-nums text-sm">
|
||||
{formatAmount(p.amount_total, p.currency)}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,9 @@ const userQueryFn = () => userApiService.getMe();
|
|||
export const currentUserAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: USER_QUERY_KEY,
|
||||
// Live-changing numeric fields (pages_*, premium_tokens_*) are now
|
||||
// pushed via Zero (queries.user.me()), so /users/me only needs to
|
||||
// fire once per session for the static profile fields.
|
||||
// Live-changing numeric fields (pages_*, premium_credit_micros_*)
|
||||
// are now pushed via Zero (queries.user.me()), so /users/me only
|
||||
// needs to fire once per session for the static profile fields.
|
||||
staleTime: Infinity,
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: userQueryFn,
|
||||
|
|
|
|||
|
|
@ -399,6 +399,19 @@ function formatMessageDate(date: Date): string {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format provider USD cost (in micro-USD) for inline display next to a
|
||||
* token count. Falls back to ``"<$0.001"`` for sub-tenth-of-a-cent
|
||||
* costs so a real-but-tiny figure doesn't render as ``$0.000``.
|
||||
*/
|
||||
function formatTurnCost(micros: number): string {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars >= 0.01) return `$${dollars.toFixed(3)}`;
|
||||
if (dollars > 0) return "<$0.001";
|
||||
return "$0";
|
||||
}
|
||||
|
||||
const MessageInfoDropdown: FC = () => {
|
||||
const messageId = useAuiState(({ message }) => message?.id);
|
||||
const createdAt = useAuiState(({ message }) => message?.createdAt);
|
||||
|
|
@ -451,6 +464,7 @@ const MessageInfoDropdown: FC = () => {
|
|||
{models.length > 0 ? (
|
||||
models.map(([model, counts]) => {
|
||||
const { name, icon } = resolveModel(model);
|
||||
const costMicros = counts.cost_micros;
|
||||
return (
|
||||
<ActionBarMorePrimitive.Item
|
||||
key={model}
|
||||
|
|
@ -463,6 +477,9 @@ const MessageInfoDropdown: FC = () => {
|
|||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{counts.total_tokens.toLocaleString()} tokens
|
||||
{costMicros && costMicros > 0
|
||||
? ` · ${formatTurnCost(costMicros)}`
|
||||
: ""}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
);
|
||||
|
|
@ -474,6 +491,9 @@ const MessageInfoDropdown: FC = () => {
|
|||
>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{usage.total_tokens.toLocaleString()} tokens
|
||||
{usage.cost_micros && usage.cost_micros > 0
|
||||
? ` · ${formatTurnCost(usage.cost_micros)}`
|
||||
: ""}
|
||||
</span>
|
||||
</ActionBarMorePrimitive.Item>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -13,13 +13,30 @@ export interface TokenUsageData {
|
|||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
/**
|
||||
* Total provider USD cost for this assistant turn, in micro-USD
|
||||
* (1_000_000 = $1.00). Populated from LiteLLM's response_cost on
|
||||
* the backend. Optional because pre-cost-credits messages persisted
|
||||
* before the migration won't have it.
|
||||
*/
|
||||
cost_micros?: number;
|
||||
usage?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function QuotaWarningBanner({
|
|||
</p>
|
||||
<p className="text-xs text-red-600 dark:text-red-300">
|
||||
You've used all {limit.toLocaleString()} free tokens. Create a free account to
|
||||
get 3 million tokens and access to all models.
|
||||
get $5 of premium credit and access to all models.
|
||||
</p>
|
||||
<Link
|
||||
href="/register"
|
||||
|
|
@ -69,7 +69,7 @@ export function QuotaWarningBanner({
|
|||
<Link href="/register" className="font-medium underline hover:no-underline">
|
||||
Create an account
|
||||
</Link>{" "}
|
||||
for 5M free tokens.
|
||||
for $5 of premium credit.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,14 @@ import { Progress } from "@/components/ui/progress";
|
|||
import { useIsAnonymous } from "@/contexts/anonymous-mode";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
/**
|
||||
* Premium credit balance shown in the sidebar.
|
||||
*
|
||||
* Values come from Zero (live-replicated from Postgres) and are stored as
|
||||
* integer micro-USD (1_000_000 == $1.00). We render in dollars because
|
||||
* users top up at $1/pack and the credit gets debited at actual provider
|
||||
* cost.
|
||||
*/
|
||||
export function PremiumTokenUsageDisplay() {
|
||||
const isAnonymous = useIsAnonymous();
|
||||
const [me] = useQuery(queries.user.me({}));
|
||||
|
|
@ -12,21 +20,26 @@ export function PremiumTokenUsageDisplay() {
|
|||
if (isAnonymous || !me) return null;
|
||||
|
||||
const usagePercentage = Math.min(
|
||||
(me.premiumTokensUsed / Math.max(me.premiumTokensLimit, 1)) * 100,
|
||||
(me.premiumCreditMicrosUsed / Math.max(me.premiumCreditMicrosLimit, 1)) * 100,
|
||||
100
|
||||
);
|
||||
|
||||
const formatTokens = (n: number) => {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`;
|
||||
return n.toLocaleString();
|
||||
const formatUsd = (micros: number) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
// Sub-dollar balances need extra precision so the bar still tells the
|
||||
// user what's left ("$0.04 of credit") instead of rounding to "$0".
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{formatTokens(me.premiumTokensUsed)} / {formatTokens(me.premiumTokensLimit)} tokens
|
||||
{formatUsd(me.premiumCreditMicrosUsed)} / {formatUsd(me.premiumCreditMicrosLimit)} of
|
||||
credit
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -12,11 +12,11 @@ const demoPlans = [
|
|||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "",
|
||||
billingText: "500 pages + 3M premium tokens included",
|
||||
billingText: "500 pages + $5 of premium credit included",
|
||||
features: [
|
||||
"Self Hostable",
|
||||
"500 pages included to start",
|
||||
"3 million premium tokens to start",
|
||||
"$5 of premium credit to start, billed at provider cost",
|
||||
"Includes access to OpenAI text, audio and image models",
|
||||
"Realtime Collaborative Group Chats with teammates",
|
||||
"Community support on Discord",
|
||||
|
|
@ -35,7 +35,7 @@ const demoPlans = [
|
|||
features: [
|
||||
"Everything in Free",
|
||||
"Buy 1,000-page packs at $1 each",
|
||||
"Buy 1M premium token packs at $1 each",
|
||||
"Top up premium credit at $1 per $1 of credit, billed at provider cost",
|
||||
"Use premium AI models like GPT-5.4, Claude Sonnet 4.6, Gemini 2.5 Pro & 100+ more via OpenRouter",
|
||||
"Priority support on Discord",
|
||||
],
|
||||
|
|
@ -129,27 +129,27 @@ const faqData: FAQSection[] = [
|
|||
],
|
||||
},
|
||||
{
|
||||
title: "Premium Tokens",
|
||||
title: "Premium Credit",
|
||||
items: [
|
||||
{
|
||||
question: 'What are "premium tokens"?',
|
||||
question: 'What is "premium credit"?',
|
||||
answer:
|
||||
"Premium tokens are the billing unit for using premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro in SurfSense. Each AI request consumes tokens based on the length of your conversation. Non-premium models (such as free-tier models available without login) do not consume premium tokens.",
|
||||
"Premium credit is your USD balance for using premium AI models like GPT-5.4, Claude Sonnet 4.6, and Gemini 2.5 Pro in SurfSense. Each AI request debits the actual USD cost the provider charges, so cheap and expensive models bill proportionally. Non-premium models (such as the free-tier models available without login) don't touch your premium credit.",
|
||||
},
|
||||
{
|
||||
question: "How many premium tokens do I get for free?",
|
||||
question: "How much premium credit do I get for free?",
|
||||
answer:
|
||||
"Every registered SurfSense account starts with 3 million premium tokens at no cost. Anonymous users (no login) get 500,000 free tokens across all models. Once your free tokens are used up, you can purchase more at any time.",
|
||||
"Every registered SurfSense account starts with $5 of premium credit at no cost. Anonymous users (no login) get 500,000 free tokens across all free models. Once your free credit runs out, you can top up at any time.",
|
||||
},
|
||||
{
|
||||
question: "How does purchasing premium tokens work?",
|
||||
question: "How does buying premium credit work?",
|
||||
answer:
|
||||
"Just like pages, there's no subscription. You buy 1-million-token packs at $1 each whenever you need more. Purchased tokens are added to your account immediately. You can buy up to 100 packs at a time.",
|
||||
"Just like pages, there's no subscription. Top-ups buy $1 of credit for $1 — every cent you pay is spent at provider cost, no markup. Purchased credit is added to your account immediately. You can buy up to $100 at a time.",
|
||||
},
|
||||
{
|
||||
question: "What happens if I run out of premium tokens?",
|
||||
question: "What happens if I run out of premium credit?",
|
||||
answer:
|
||||
"When your premium token balance runs low (below 20%), you'll see a warning. Once you run out, premium model requests are paused until you purchase more tokens. You can always switch to non-premium models which don't consume premium tokens.",
|
||||
"When your premium credit balance runs low (below 20%), you'll see a warning. Once you run out, premium model requests are paused until you top up. You can always switch to non-premium models, which don't touch your premium credit.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -157,9 +157,9 @@ const faqData: FAQSection[] = [
|
|||
title: "Self-Hosting",
|
||||
items: [
|
||||
{
|
||||
question: "Can I self-host SurfSense with unlimited pages and tokens?",
|
||||
question: "Can I self-host SurfSense with unlimited pages and credit?",
|
||||
answer:
|
||||
"Yes! When self-hosting, you have full control over your page and token limits. The default self-hosted setup gives you effectively unlimited pages and tokens, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
"Yes! When self-hosting, you have full control over your page and premium-credit limits. The default self-hosted setup gives you effectively unlimited pages and premium credit, so you can index as much data and use as many AI queries as your infrastructure supports.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
@ -250,8 +250,8 @@ function PricingFAQ() {
|
|||
Frequently Asked Questions
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Everything you need to know about SurfSense pages, premium tokens, and billing. Can't
|
||||
find what you need? Reach out at{" "}
|
||||
Everything you need to know about SurfSense pages, premium credit, and billing.
|
||||
Can't find what you need? Reach out at{" "}
|
||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||
rohan@surfsense.com
|
||||
</a>
|
||||
|
|
@ -335,7 +335,7 @@ function PricingBasic() {
|
|||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="SurfSense Pricing"
|
||||
description="Start free with 500 pages & 3M premium tokens. Pay as you go."
|
||||
description="Start free with 500 pages & $5 of premium credit. Pay as you go, billed at provider cost."
|
||||
/>
|
||||
<PricingFAQ />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -14,10 +14,23 @@ import { AppError } from "@/lib/error";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { queries } from "@/zero/queries";
|
||||
|
||||
const TOKEN_PACK_SIZE = 1_000_000;
|
||||
// One pack = $1.00 of credit, stored as 1_000_000 micro-USD on the
|
||||
// backend. Premium turns are debited at the actual provider cost
|
||||
// reported by LiteLLM, so $1 of credit always buys $1 of provider
|
||||
// usage at cost.
|
||||
const CREDIT_PER_PACK_MICROS = 1_000_000;
|
||||
const PRICE_PER_PACK_USD = 1;
|
||||
const PRESET_MULTIPLIERS = [1, 2, 5, 10, 25, 50] as const;
|
||||
|
||||
const formatUsd = (micros: number, options?: { compact?: boolean }) => {
|
||||
const dollars = micros / 1_000_000;
|
||||
if (options?.compact && dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars >= 100) return `$${dollars.toFixed(0)}`;
|
||||
if (dollars >= 1) return `$${dollars.toFixed(2)}`;
|
||||
if (dollars > 0) return `$${dollars.toFixed(3)}`;
|
||||
return "$0";
|
||||
};
|
||||
|
||||
export function BuyTokensContent() {
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params?.search_space_id);
|
||||
|
|
@ -29,7 +42,7 @@ export function BuyTokensContent() {
|
|||
queryFn: () => stripeApiService.getTokenStatus(),
|
||||
});
|
||||
|
||||
// Live per-user usage via Zero.
|
||||
// Live per-user balance via Zero.
|
||||
const [me] = useZeroQuery(queries.user.me({}));
|
||||
|
||||
const purchaseMutation = useMutation({
|
||||
|
|
@ -46,44 +59,46 @@ export function BuyTokensContent() {
|
|||
},
|
||||
});
|
||||
|
||||
const totalTokens = quantity * TOKEN_PACK_SIZE;
|
||||
const totalCreditMicros = quantity * CREDIT_PER_PACK_MICROS;
|
||||
const totalPrice = quantity * PRICE_PER_PACK_USD;
|
||||
|
||||
if (tokenStatus && !tokenStatus.token_buying_enabled) {
|
||||
return (
|
||||
<div className="w-full space-y-3 text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Tokens</h2>
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Token purchases are temporarily unavailable.
|
||||
Credit purchases are temporarily unavailable.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const used = me?.premiumTokensUsed ?? 0;
|
||||
const limit = me?.premiumTokensLimit ?? 0;
|
||||
// Mirrors the backend formula in stripe_routes.py:608 (max(0, limit - used)).
|
||||
const used = me?.premiumCreditMicrosUsed ?? 0;
|
||||
const limit = me?.premiumCreditMicrosLimit ?? 0;
|
||||
// Mirrors the backend formula in stripe_routes.py (max(0, limit - used)).
|
||||
const remaining = Math.max(0, limit - used);
|
||||
const usagePercentage = me ? Math.min((used / Math.max(limit, 1)) * 100, 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Tokens</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">$1 per 1M tokens, pay as you go</p>
|
||||
<h2 className="text-xl font-bold tracking-tight">Buy Premium Credit</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
$1 buys $1 of credit, billed at provider cost
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{me && (
|
||||
<div className="rounded-lg border bg-muted/20 p-3 space-y-1.5">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{used.toLocaleString()} / {limit.toLocaleString()} premium tokens
|
||||
{formatUsd(used)} / {formatUsd(limit)} of credit
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
{remaining.toLocaleString()} tokens remaining
|
||||
{formatUsd(remaining)} of credit remaining
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -99,7 +114,7 @@ export function BuyTokensContent() {
|
|||
<Minus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<span className="min-w-32 text-center text-lg font-semibold tabular-nums">
|
||||
{(totalTokens / 1_000_000).toFixed(0)}M tokens
|
||||
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -125,14 +140,14 @@ export function BuyTokensContent() {
|
|||
: "border-border hover:border-purple-500/40 hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{m}M
|
||||
${m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<span className="text-sm font-medium tabular-nums">
|
||||
{(totalTokens / 1_000_000).toFixed(0)}M premium tokens
|
||||
${(totalCreditMicros / 1_000_000).toFixed(0)} of credit
|
||||
</span>
|
||||
<span className="text-sm font-semibold tabular-nums">${totalPrice}</span>
|
||||
</div>
|
||||
|
|
@ -149,7 +164,7 @@ export function BuyTokensContent() {
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
Buy {(totalTokens / 1_000_000).toFixed(0)}M Tokens for ${totalPrice}
|
||||
Buy ${(totalCreditMicros / 1_000_000).toFixed(0)} of credit for ${totalPrice}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -190,7 +190,25 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
|||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
available from your administrator.{" "}
|
||||
{(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
const premium = nonAuto.filter(
|
||||
(g) =>
|
||||
"billing_tier" in g &&
|
||||
(g as { billing_tier?: string }).billing_tier === "premium"
|
||||
).length;
|
||||
const free = nonAuto.length - premium;
|
||||
if (premium > 0 && free > 0) {
|
||||
return `${premium} premium, ${free} free.`;
|
||||
}
|
||||
if (premium > 0) {
|
||||
return `All ${premium} premium — debits your shared credit pool.`;
|
||||
}
|
||||
return `All ${free} free.`;
|
||||
})()}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -371,6 +371,17 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
</SelectLabel>
|
||||
{roleGlobalConfigs.map((config) => {
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
// Read billing_tier from the global config; default to "free"
|
||||
// for legacy YAMLs / Auto stub. Premium gets a purple badge,
|
||||
// free gets an emerald one — same palette as the chat
|
||||
// model selector so the meaning is consistent across
|
||||
// surfaces (issues E, H).
|
||||
const billingTier =
|
||||
("billing_tier" in config &&
|
||||
typeof config.billing_tier === "string" &&
|
||||
config.billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
|
|
@ -382,13 +393,27 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
<span className="truncate text-xs md:text-sm">
|
||||
{config.name}
|
||||
</span>
|
||||
{isAuto && (
|
||||
{isAuto ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
|
|
|
|||
|
|
@ -191,7 +191,25 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
|||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
available from your administrator.{" "}
|
||||
{(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
const premium = nonAuto.filter(
|
||||
(g) =>
|
||||
"billing_tier" in g &&
|
||||
(g as { billing_tier?: string }).billing_tier === "premium"
|
||||
).length;
|
||||
const free = nonAuto.length - premium;
|
||||
if (premium > 0 && free > 0) {
|
||||
return `${premium} premium, ${free} free.`;
|
||||
}
|
||||
if (premium > 0) {
|
||||
return `All ${premium} premium — debits your shared credit pool.`;
|
||||
}
|
||||
return `All ${free} free.`;
|
||||
})()}
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ export function LoginGateProvider({ children }: { children: ReactNode }) {
|
|||
<DialogHeader>
|
||||
<DialogTitle>Create a free account to {feature}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Get 3 million tokens, save chat history, upload documents, use all AI tools, and
|
||||
connect 30+ integrations.
|
||||
Get $5 of premium credit, save chat history, upload documents, use all AI tools,
|
||||
and connect 30+ integrations.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-row">
|
||||
|
|
|
|||
|
|
@ -258,6 +258,8 @@ export const globalImageGenConfig = z.object({
|
|||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
quota_reserve_micros: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
|
||||
|
|
@ -338,6 +340,10 @@ export const globalVisionLLMConfig = z.object({
|
|||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
input_cost_per_token: z.number().nullable().optional(),
|
||||
output_cost_per_token: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export const getPagePurchasesResponse = z.object({
|
|||
purchases: z.array(pagePurchase),
|
||||
});
|
||||
|
||||
// Premium token purchases
|
||||
// Premium credit purchases
|
||||
export const createTokenCheckoutSessionRequest = z.object({
|
||||
quantity: z.number().int().min(1).max(100),
|
||||
search_space_id: z.number().int().min(1),
|
||||
|
|
@ -42,11 +42,16 @@ export const createTokenCheckoutSessionResponse = z.object({
|
|||
checkout_url: z.string(),
|
||||
});
|
||||
|
||||
// Premium credit balance + purchase records.
|
||||
//
|
||||
// The unit is integer micro-USD (1_000_000 == $1.00). The schema names
|
||||
// kept the ``Token`` prefix for API back-compat with pinned clients;
|
||||
// the field names below are authoritative.
|
||||
export const tokenStripeStatusResponse = z.object({
|
||||
token_buying_enabled: z.boolean(),
|
||||
premium_tokens_used: z.number().default(0),
|
||||
premium_tokens_limit: z.number().default(0),
|
||||
premium_tokens_remaining: z.number().default(0),
|
||||
premium_credit_micros_used: z.number().default(0),
|
||||
premium_credit_micros_limit: z.number().default(0),
|
||||
premium_credit_micros_remaining: z.number().default(0),
|
||||
});
|
||||
|
||||
export const tokenPurchaseStatusEnum = pagePurchaseStatusEnum;
|
||||
|
|
@ -56,7 +61,7 @@ export const tokenPurchase = z.object({
|
|||
stripe_checkout_session_id: z.string(),
|
||||
stripe_payment_intent_id: z.string().nullable(),
|
||||
quantity: z.number(),
|
||||
tokens_granted: z.number(),
|
||||
credit_micros_granted: z.number(),
|
||||
amount_total: z.number().nullable(),
|
||||
currency: z.string().nullable(),
|
||||
status: tokenPurchaseStatusEnum,
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export interface RawChatErrorInput {
|
|||
}
|
||||
|
||||
export const PREMIUM_QUOTA_ASSISTANT_MESSAGE =
|
||||
"I can’t continue with the current premium model because your premium tokens are exhausted. Switch to a free model or buy more tokens to continue.";
|
||||
"I can’t continue with the current premium model because your premium credit is exhausted. Switch to a free model or top up your credit to continue.";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
|
|
|
|||
|
|
@ -541,16 +541,23 @@ export type SSEEvent =
|
|||
data: {
|
||||
usage: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
>;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
call_details: Array<{
|
||||
model: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,9 +30,20 @@ export interface TokenUsageSummary {
|
|||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
/**
|
||||
* Total provider USD cost for this assistant turn, in micro-USD
|
||||
* (1_000_000 = $1.00). Optional because rows persisted before the
|
||||
* cost-credits migration won't have it.
|
||||
*/
|
||||
cost_micros?: number;
|
||||
model_breakdown?: Record<
|
||||
string,
|
||||
{ prompt_tokens: number; completion_tokens: number; total_tokens: number }
|
||||
{
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_micros?: number;
|
||||
}
|
||||
> | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
import { number, string, table } from "@rocicorp/zero";
|
||||
|
||||
/**
|
||||
* Live-meter slice of the ``user`` table replicated through Zero.
|
||||
*
|
||||
* ``premiumCreditMicrosLimit`` / ``premiumCreditMicrosUsed`` are stored
|
||||
* as integer micro-USD (1_000_000 == $1.00). UI consumers divide by 1M
|
||||
* when displaying. Sensitive fields (email, hashed_password, oauth, etc.)
|
||||
* are intentionally omitted via the Postgres column-list publication so
|
||||
* they never enter WAL replication.
|
||||
*/
|
||||
export const userTable = table("user")
|
||||
.columns({
|
||||
id: string(),
|
||||
pagesLimit: number().from("pages_limit"),
|
||||
pagesUsed: number().from("pages_used"),
|
||||
premiumTokensLimit: number().from("premium_tokens_limit"),
|
||||
premiumTokensUsed: number().from("premium_tokens_used"),
|
||||
premiumCreditMicrosLimit: number().from("premium_credit_micros_limit"),
|
||||
premiumCreditMicrosUsed: number().from("premium_credit_micros_used"),
|
||||
})
|
||||
.primaryKey("id");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue