Skip to content

Commit 0629bdc

Browse files
committed
overhaul freebuff premium sessions
1 parent 7015b88 commit 0629bdc

13 files changed

Lines changed: 3793 additions & 248 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 20 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
FREEBUFF_GEMINI_PRO_MODEL_ID,
99
FREEBUFF_KIMI_MODEL_ID,
1010
FREEBUFF_MODELS,
11+
FREEBUFF_PREMIUM_SESSION_LIMIT,
12+
isFreebuffPremiumModelId,
1113
getFreebuffDeploymentAvailabilityLabel,
1214
isFreebuffModelAvailable,
1315
} from '@codebuff/common/constants/freebuff-models'
@@ -34,6 +36,10 @@ const FREEBUFF_MODEL_SELECTOR_MODELS = [
3436
),
3537
]
3638

39+
function formatSessionUnits(units: number): string {
40+
return Number.isInteger(units) ? String(units) : units.toFixed(1)
41+
}
42+
3743
/**
3844
* Dual-purpose model picker:
3945
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -47,8 +53,7 @@ const FREEBUFF_MODEL_SELECTOR_MODELS = [
4753
* switch. Mouse clicks are still one-step. On the landing screen, pressing
4854
* Enter on the already-focused model also commits — there's nothing to lose.
4955
*
50-
* Each row shows a live "N ahead" count sourced from the server's
51-
* `queueDepthByModel` snapshot so the choice is informed.
56+
* Each row shows session-budget information so the choice is informed.
5257
*/
5358
export const FreebuffModelSelector: React.FC = () => {
5459
const theme = useTheme()
@@ -86,48 +91,10 @@ export const FreebuffModelSelector: React.FC = () => {
8691
}
8792
}, [now, selectedModel, session, setSelectedModel])
8893

89-
// Landing ('none'): depths come from the server snapshot, no "self" to
90-
// subtract. In-queue ('queued'): for the user's queue, "ahead" is
91-
// `position - 1` (themselves don't count); for every other queue, switching
92-
// would land them at the back, so it's that queue's full depth. Null before
93-
// any snapshot so the UI doesn't flash misleading zeros — in particular,
94-
// landing mode after a session ends initially sets status='none' with no
95-
// queueDepthByModel; returning null here keeps the hint blank until the
96-
// fetch lands, instead of showing "No wait" on every row.
97-
const aheadByModel = useMemo<Record<string, number> | null>(() => {
98-
if (session?.status === 'none') {
99-
if (!session.queueDepthByModel) return null
100-
const depths = session.queueDepthByModel
101-
const out: Record<string, number> = {}
102-
for (const { id } of FREEBUFF_MODELS) out[id] = depths[id] ?? 0
103-
return out
104-
}
105-
if (session?.status === 'queued') {
106-
const depths = session.queueDepthByModel ?? {}
107-
const out: Record<string, number> = {}
108-
for (const { id } of FREEBUFF_MODELS) {
109-
out[id] =
110-
id === session.model
111-
? Math.max(0, session.position - 1)
112-
: (depths[id] ?? 0)
113-
}
114-
return out
115-
}
116-
return null
117-
}, [session])
118-
119-
// Pad the trailing hint ("3 ahead", "No wait", "…") to a fixed width so
120-
// buttons don't visibly resize when the queue depth ticks down (12 → 9) or
121-
// when the user's selection moves between queues. The tagline is shown
122-
// inline with the name now, so it's no longer part of this slot.
94+
// Pad the trailing hint to a fixed width so buttons don't visibly resize
95+
// when quota snapshots change.
12396
const hintWidth = useMemo(
124-
() =>
125-
Math.max(
126-
'No wait'.length,
127-
'999 ahead'.length,
128-
'Used today'.length,
129-
'Limit used'.length,
130-
),
97+
() => Math.max('Unlimited'.length, '5/5 used'.length, '4.9/5 used'.length),
13198
[],
13299
)
133100

@@ -246,9 +213,10 @@ export const FreebuffModelSelector: React.FC = () => {
246213
const isFocused = focusedId === model.id && !isSelected
247214
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
248215
const rateLimit = rateLimitsByModel?.[model.id]
249-
const isQuotaExhausted =
250-
rateLimit !== undefined && rateLimit.recentCount >= rateLimit.limit
251-
const canJoin = isAvailable && !isQuotaExhausted
216+
const isPremium = isFreebuffPremiumModelId(model.id)
217+
const hasFullPremiumSession =
218+
!rateLimit || rateLimit.recentCount < rateLimit.limit
219+
const canJoin = isAvailable && hasFullPremiumSession
252220
const indicator = isSelected ? '●' : isFocused ? '›' : '○'
253221
const indicatorColor = isSelected
254222
? theme.primary
@@ -263,18 +231,12 @@ export const FreebuffModelSelector: React.FC = () => {
263231
// anything except re-picking the queue we're already in.
264232
const interactable =
265233
!pending && canJoin && model.id !== committedModelId
266-
const ahead = aheadByModel?.[model.id]
267-
const hint = !isAvailable
268-
? 'Closed'
269-
: isQuotaExhausted
270-
? model.id === FREEBUFF_GEMINI_PRO_MODEL_ID
271-
? 'Used today'
272-
: 'Limit used'
273-
: ahead === undefined
274-
? ''
275-
: ahead === 0
276-
? 'No wait'
277-
: `${ahead} ahead`
234+
const quotaHint = rateLimit
235+
? `${formatSessionUnits(rateLimit.recentCount)}/${rateLimit.limit} used`
236+
: isPremium
237+
? `0/${FREEBUFF_PREMIUM_SESSION_LIMIT} used`
238+
: 'Unlimited'
239+
const hint = isAvailable ? quotaHint : 'Closed'
278240
const hintColor = canJoin ? theme.muted : theme.secondary
279241

280242
const borderColor = isSelected

cli/src/components/status-bar.tsx

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ const formatSessionRemaining = (ms: number): string => {
6666
return minutes === 0 ? `${hours}h left` : `${hours}h ${minutes}m left`
6767
}
6868

69+
const formatSessionUnits = (units: number): string =>
70+
Number.isInteger(units) ? String(units) : units.toFixed(1)
71+
6972
interface StatusBarProps {
7073
timerStartTime: number | null
7174
isAtBottom: boolean
@@ -131,7 +134,8 @@ export const StatusBar = ({
131134

132135
case 'clipboard':
133136
// Use green color for feedback success messages
134-
const isFeedbackSuccess = statusIndicatorState.message.includes('Feedback sent')
137+
const isFeedbackSuccess =
138+
statusIndicatorState.message.includes('Feedback sent')
135139
return (
136140
<span fg={isFeedbackSuccess ? theme.success : theme.primary}>
137141
{statusIndicatorState.message}
@@ -142,12 +146,7 @@ export const StatusBar = ({
142146
return <span fg={theme.success}>Reconnected</span>
143147

144148
case 'retrying':
145-
return (
146-
<ShimmerText
147-
text="retrying..."
148-
primaryColor={theme.warning}
149-
/>
150-
)
149+
return <ShimmerText text="retrying..." primaryColor={theme.warning} />
151150

152151
case 'connecting':
153152
return <ShimmerText text="connecting..." />
@@ -180,9 +179,16 @@ export const StatusBar = ({
180179
freebuffSession?.status === 'active'
181180
? getFreebuffModel(freebuffSession.model).displayName
182181
: null
182+
const quotaText =
183+
freebuffSession?.status === 'active' && freebuffSession.rateLimit
184+
? `Premium ${formatSessionUnits(freebuffSession.rateLimit.recentCount)}/${freebuffSession.rateLimit.limit} used · `
185+
: freebuffSession?.status === 'active'
186+
? 'Unlimited · '
187+
: ''
183188
return (
184189
<span fg={isUrgent ? theme.warning : theme.secondary}>
185-
{modelName ? `${modelName} · ` : ''}Free session ·{' '}
190+
{modelName ? `${modelName} · ` : ''}
191+
{quotaText}Free session ·{' '}
186192
{formatSessionRemaining(sessionProgress.remainingMs)}
187193
</span>
188194
)
@@ -260,12 +266,18 @@ export const StatusBar = ({
260266
}}
261267
>
262268
<text style={{ wrapMode: 'none' }}>{elapsedTimeContent}</text>
263-
{onStop && (statusIndicatorState.kind === 'waiting' || statusIndicatorState.kind === 'streaming') && (
264-
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
265-
)}
266-
{onEndSession && statusIndicatorState.kind === 'idle' && freebuffSession?.status === 'active' && (
267-
<StatusActionButton onClick={onEndSession}>✕ End session</StatusActionButton>
268-
)}
269+
{onStop &&
270+
(statusIndicatorState.kind === 'waiting' ||
271+
statusIndicatorState.kind === 'streaming') && (
272+
<StatusActionButton onClick={onStop}>■ Esc</StatusActionButton>
273+
)}
274+
{onEndSession &&
275+
statusIndicatorState.kind === 'idle' &&
276+
freebuffSession?.status === 'active' && (
277+
<StatusActionButton onClick={onEndSession}>
278+
✕ End session
279+
</StatusActionButton>
280+
)}
269281
{sessionProgress !== null &&
270282
sessionProgress.remainingMs < COUNTDOWN_VISIBLE_MS &&
271283
statusIndicatorState.kind !== 'idle' && (

cli/src/components/waiting-room-screen.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ const formatRetryAfter = (ms: number): string => {
5656
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
5757
}
5858

59+
const formatSessionUnits = (units: number): string =>
60+
Number.isInteger(units) ? String(units) : units.toFixed(1)
61+
5962
const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
6063
{
6164
anonymous: 'anonymized network',
@@ -260,17 +263,16 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
260263
<span>Elapsed </span>
261264
{formatElapsed(elapsedMs)}
262265
</text>
263-
{/* Per-model session quota (e.g. Kimi K2.6 caps at 5/12h). Only
264-
rendered for rate-limited models so the Minimax queue stays
265-
clutter-free. */}
266+
{/* Premium session quota. Minimax is unlimited, so it has no
267+
rateLimit payload and skips this line. */}
266268
{session.rateLimit && (
267269
<text style={{ fg: theme.muted, alignSelf: 'flex-start' }}>
268-
<span>Sessions </span>
270+
<span>Premium sessions </span>
269271
<span fg={theme.foreground}>
270-
{session.rateLimit.recentCount} /{' '}
272+
{formatSessionUnits(session.rateLimit.recentCount)} /{' '}
271273
{session.rateLimit.limit}
272274
</span>
273-
<span> used in last {session.rateLimit.windowHours}h</span>
275+
<span> used today</span>
274276
</text>
275277
)}
276278
</box>
@@ -343,8 +345,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
343345
</>
344346
)}
345347

346-
{/* Per-model session quota exhausted (e.g. 5+ Kimi sessions in the
347-
last 12h). Terminal for this run — the user can exit and come
348+
{/* Shared premium-session quota exhausted. Terminal for this run —
349+
the user can exit and come
348350
back once the oldest session in the window rolls off. */}
349351
{session?.status === 'rate_limited' && (
350352
<>
@@ -354,10 +356,9 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
354356
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
355357
You've used{' '}
356358
<span fg={theme.foreground}>
357-
{session.recentCount} of {session.limit}
359+
{formatSessionUnits(session.recentCount)} of {session.limit}
358360
</span>{' '}
359-
hour-long sessions on {session.model} in the last{' '}
360-
{session.windowHours}h. Try again in{' '}
361+
premium sessions today. Try again in{' '}
361362
<span fg={theme.foreground}>
362363
{formatRetryAfter(session.retryAfterMs)}
363364
</span>

common/src/constants/freebuff-models.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const FREEBUFF_GEMINI_PRO_MODEL_ID = 'google/gemini-3.1-pro-preview'
2525
export const FREEBUFF_GLM_MODEL_ID = 'z-ai/glm-5.1'
2626
export const FREEBUFF_KIMI_MODEL_ID = 'moonshotai/kimi-k2.6'
2727
export const FREEBUFF_MINIMAX_MODEL_ID = 'minimax/minimax-m2.7'
28+
export const FREEBUFF_PREMIUM_SESSION_LIMIT = 5
29+
export const FREEBUFF_PREMIUM_SESSION_WINDOW_HOURS = 20
2830
const FREEBUFF_EASTERN_TIMEZONE = 'America/New_York'
2931
const FREEBUFF_PACIFIC_TIMEZONE = 'America/Los_Angeles'
3032

@@ -45,13 +47,13 @@ export const FREEBUFF_MODELS = [
4547
{
4648
id: FREEBUFF_GEMINI_PRO_MODEL_ID,
4749
displayName: 'Gemini 3.1 Pro',
48-
tagline: 'Deepest, 1/day',
50+
tagline: 'Deepest',
4951
availability: 'always',
5052
},
5153
{
5254
id: FREEBUFF_MINIMAX_MODEL_ID,
5355
displayName: 'MiniMax M2.7',
54-
tagline: 'Fastest',
56+
tagline: 'Fastest, unlimited',
5557
availability: 'always',
5658
},
5759
{
@@ -71,6 +73,12 @@ export const LEGACY_FREEBUFF_MODELS = [
7173
},
7274
] as const satisfies readonly FreebuffModelOption[]
7375

76+
export const FREEBUFF_PREMIUM_MODEL_IDS = [
77+
FREEBUFF_GEMINI_PRO_MODEL_ID,
78+
FREEBUFF_KIMI_MODEL_ID,
79+
FREEBUFF_GLM_MODEL_ID,
80+
] as const
81+
7482
export const SUPPORTED_FREEBUFF_MODELS = [
7583
...FREEBUFF_MODELS,
7684
...LEGACY_FREEBUFF_MODELS,
@@ -79,6 +87,7 @@ export const SUPPORTED_FREEBUFF_MODELS = [
7987
export type FreebuffModelId = (typeof FREEBUFF_MODELS)[number]['id']
8088
export type SupportedFreebuffModelId =
8189
(typeof SUPPORTED_FREEBUFF_MODELS)[number]['id']
90+
export type FreebuffPremiumModelId = (typeof FREEBUFF_PREMIUM_MODEL_IDS)[number]
8291

8392
/** What new freebuff users see selected in the picker. May not be currently
8493
* available (Kimi is closed outside deployment hours); callers that need an
@@ -113,6 +122,13 @@ export function isSupportedFreebuffModelId(
113122
return SUPPORTED_FREEBUFF_MODELS.some((m) => m.id === id)
114123
}
115124

125+
export function isFreebuffPremiumModelId(
126+
id: string | null | undefined,
127+
): id is FreebuffPremiumModelId {
128+
if (!id) return false
129+
return FREEBUFF_PREMIUM_MODEL_IDS.some((modelId) => modelId === id)
130+
}
131+
116132
export function resolveSupportedFreebuffModel(
117133
id: string | null | undefined,
118134
): SupportedFreebuffModelId {

common/src/types/freebuff-session.ts

Lines changed: 18 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@
77
*/
88

99
/**
10-
* Per-model usage counter surfaced to the CLI so the waiting-room UI can
11-
* render "N of M sessions used" alongside queue/active state. Present when
12-
* the joined model has a rate limit applied. `recentCount` is the number of
13-
* admissions inside `windowHours` at the time the response was produced —
14-
* see also the standalone `rate_limited` status for the reject path.
10+
* Usage counter surfaced to the CLI so the waiting-room UI can render
11+
* "N of M sessions used" alongside queue/active state. Present when the
12+
* joined model consumes premium Freebuff sessions. `recentCount` is the
13+
* rounded session units inside `windowHours` at the time the response was
14+
* produced — see also the standalone `rate_limited` status for the reject
15+
* path.
1516
*/
1617
export interface FreebuffSessionRateLimit {
1718
model: string
@@ -60,9 +61,9 @@ export type FreebuffSessionServerResponse =
6061
* committing the user to a queue. Present on GET responses; not
6162
* returned from POST (POST never produces `none`). */
6263
queueDepthByModel?: Record<string, number>
63-
/** Current quota snapshots for rate-limited models, keyed by model id.
64-
* Lets the picker show exhausted daily/session caps before the user
65-
* commits to a queue. */
64+
/** Current quota snapshots for premium models, keyed by model id. Lets
65+
* the picker show daily premium-session usage before the user commits
66+
* to a queue. */
6667
rateLimitsByModel?: FreebuffSessionRateLimitByModel
6768
}
6869
| {
@@ -80,9 +81,7 @@ export type FreebuffSessionServerResponse =
8081
queueDepthByModel: Record<string, number>
8182
estimatedWaitMs: number
8283
queuedAt: string
83-
/** Rate-limit quota for rate-limited models. Absent
84-
* for unlimited models or when the status was produced outside the
85-
* rate-limit check path (e.g. pure read via GET). */
84+
/** Premium-session quota for this model. Absent for unlimited models. */
8685
rateLimit?: FreebuffSessionRateLimit
8786
rateLimitsByModel?: FreebuffSessionRateLimitByModel
8887
}
@@ -94,9 +93,7 @@ export type FreebuffSessionServerResponse =
9493
admittedAt: string
9594
expiresAt: string
9695
remainingMs: number
97-
/** Rate-limit quota for rate-limited models. Absent
98-
* for unlimited models or when the status was produced outside the
99-
* rate-limit check path (e.g. pure read via GET). */
96+
/** Premium-session quota for this model. Absent for unlimited models. */
10097
rateLimit?: FreebuffSessionRateLimit
10198
rateLimitsByModel?: FreebuffSessionRateLimitByModel
10299
}
@@ -161,21 +158,20 @@ export type FreebuffSessionServerResponse =
161158
status: 'banned'
162159
}
163160
| {
164-
/** User has used up their per-model admission quota in the rolling
165-
* window. Returned from POST
166-
* /session before the user is placed in the queue. `retryAfterMs` is
167-
* the time until the oldest admission inside the window falls off
168-
* and one quota slot opens up — clients should show the user when
169-
* they can try again. Terminal for the CLI's current poll session;
161+
/** User has used up their shared premium-session quota in the rolling
162+
* window. Returned from POST /session before the user is placed in the
163+
* queue. `retryAfterMs` is the time until enough session units fall out
164+
* of the window to open one quota slot — clients should show the user
165+
* when they can try again. Terminal for the CLI's current poll session;
170166
* the user can exit and come back later. */
171167
status: 'rate_limited'
172168
/** The freebuff model the user tried to join. */
173169
model: string
174-
/** Max admissions permitted per window (e.g. 5). */
170+
/** Max premium session units permitted per window (e.g. 5). */
175171
limit: number
176172
/** Rolling window size in hours (e.g. 20). */
177173
windowHours: number
178-
/** Admission count inside the window at check time — will be ≥ limit. */
174+
/** Premium session units inside the window at check time — will be ≥ limit. */
179175
recentCount: number
180176
/** Milliseconds from now until the oldest admission in the window
181177
* exits and the user regains one quota slot. */
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE "free_session_admit" ADD COLUMN "session_units" numeric(3, 1) DEFAULT '1.0' NOT NULL;

0 commit comments

Comments
 (0)