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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue