Skip to content

Commit 793de91

Browse files
Add limited Freebuff DeepSeek access (#657)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent 61db07c commit 793de91

24 files changed

Lines changed: 4345 additions & 297 deletions

File tree

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

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
55
import { Button } from './button'
66
import {
77
FALLBACK_FREEBUFF_MODEL_ID,
8-
FREEBUFF_MODELS,
98
getFreebuffDeploymentAvailabilityLabel,
9+
getFreebuffModelsForAccessTier,
1010
isFreebuffModelAvailable,
1111
isFreebuffPremiumModelId,
1212
} from '@codebuff/common/constants/freebuff-models'
@@ -26,39 +26,18 @@ import {
2626
import type { FreebuffModelOption } from '@codebuff/common/constants/freebuff-models'
2727
import type { KeyEvent } from '@opentui/core'
2828

29-
const FREEBUFF_MODEL_IDS = FREEBUFF_MODELS.map((m) => m.id)
30-
3129
// Section grouping: premium models share one quota pool, unlimited has none.
3230
// Putting the tier on a section header lets each row drop its redundant
3331
// "Premium"/"Unlimited" chip. The shared 0/5 counter lives in the page title
3432
// (rendered by the parent), not the section header — this picker is purely a
3533
// list of choices grouped by tier. Empty sections are filtered so a model set
3634
// with no premium (or no unlimited) entries doesn't render an orphan header.
3735
type Section = {
38-
key: 'premium' | 'unlimited'
36+
key: 'premium' | 'unlimited' | 'limited'
3937
label: string
4038
models: readonly FreebuffModelOption[]
4139
}
4240

43-
const SECTIONS: readonly Section[] = (
44-
[
45-
{
46-
key: 'premium',
47-
label: 'PREMIUM',
48-
models: FREEBUFF_MODELS.filter((m) =>
49-
isFreebuffPremiumModelId(m.id),
50-
),
51-
},
52-
{
53-
key: 'unlimited',
54-
label: 'UNLIMITED',
55-
models: FREEBUFF_MODELS.filter(
56-
(m) => !isFreebuffPremiumModelId(m.id),
57-
),
58-
},
59-
] satisfies readonly Section[]
60-
).filter((section) => section.models.length > 0)
61-
6241
/**
6342
* Dual-purpose model picker:
6443
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -86,6 +65,8 @@ export const FreebuffModelSelector: React.FC = () => {
8665
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
8766
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
8867
const session = useFreebuffSessionStore((s) => s.session)
68+
const accessTier =
69+
session && 'accessTier' in session ? session.accessTier : 'full'
8970
const now = useNow(60_000)
9071
const deploymentAvailabilityLabel = useMemo(
9172
() => getFreebuffDeploymentAvailabilityLabel(new Date(now)),
@@ -98,9 +79,48 @@ export const FreebuffModelSelector: React.FC = () => {
9879
// selected model whenever the selection changes (after a successful switch
9980
// or an external selectedModel update).
10081
const [focusedId, setFocusedId] = useState<string>(selectedModel)
82+
const availableModels = useMemo(
83+
() => getFreebuffModelsForAccessTier(accessTier),
84+
[accessTier],
85+
)
86+
const availableModelIds = useMemo(
87+
() => availableModels.map((m) => m.id),
88+
[availableModels],
89+
)
90+
const sections = useMemo(() => {
91+
if (accessTier === 'limited') {
92+
return [
93+
{
94+
key: 'limited',
95+
label: 'LIMITED',
96+
models: availableModels,
97+
},
98+
] satisfies readonly Section[]
99+
}
100+
return (
101+
[
102+
{
103+
key: 'premium',
104+
label: 'PREMIUM',
105+
models: availableModels.filter((m) => isFreebuffPremiumModelId(m.id)),
106+
},
107+
{
108+
key: 'unlimited',
109+
label: 'UNLIMITED',
110+
models: availableModels.filter(
111+
(m) => !isFreebuffPremiumModelId(m.id),
112+
),
113+
},
114+
] satisfies readonly Section[]
115+
).filter((section) => section.models.length > 0)
116+
}, [accessTier, availableModels])
101117
useEffect(() => {
102-
setFocusedId(selectedModel)
103-
}, [selectedModel])
118+
setFocusedId(
119+
availableModelIds.includes(selectedModel)
120+
? selectedModel
121+
: availableModelIds[0]!,
122+
)
123+
}, [availableModelIds, selectedModel])
104124

105125
useEffect(() => {
106126
// Landing-screen safety net: if the in-memory selection becomes
@@ -110,11 +130,12 @@ export const FreebuffModelSelector: React.FC = () => {
110130
// preference (e.g. Kimi or DeepSeek) is preserved for the next launch.
111131
if (
112132
(session?.status === 'none' || !session) &&
113-
!isFreebuffModelAvailable(selectedModel, new Date(now))
133+
(!availableModelIds.includes(selectedModel) ||
134+
!isFreebuffModelAvailable(selectedModel, new Date(now)))
114135
) {
115-
setSelectedModel(FALLBACK_FREEBUFF_MODEL_ID)
136+
setSelectedModel(availableModelIds[0] ?? FALLBACK_FREEBUFF_MODEL_ID)
116137
}
117-
}, [now, selectedModel, session, setSelectedModel])
138+
}, [availableModelIds, now, selectedModel, session, setSelectedModel])
118139

119140
const committedModelId = session?.status === 'queued' ? session.model : null
120141
const rateLimitsByModel = getRateLimitsByModel(session)
@@ -128,7 +149,7 @@ export const FreebuffModelSelector: React.FC = () => {
128149
// terminals where the secondary details spill to an indented second line.
129150
const { wrapDetails, buttonOuterWidth, nameColumnWidth } = useMemo(() => {
130151
const nameLen = (m: FreebuffModelOption) => m.displayName.length
131-
const maxNameLen = Math.max(...FREEBUFF_MODELS.map(nameLen))
152+
const maxNameLen = Math.max(...availableModels.map(nameLen))
132153

133154
const detailsParts = (model: FreebuffModelOption): number[] => {
134155
const parts = [model.tagline.length]
@@ -149,8 +170,7 @@ export const FreebuffModelSelector: React.FC = () => {
149170
joinedLen(detailsParts(model))
150171

151172
const maxOneLineOuter =
152-
Math.max(...FREEBUFF_MODELS.map(oneLineLen)) +
153-
BUTTON_CHROME
173+
Math.max(...availableModels.map(oneLineLen)) + BUTTON_CHROME
154174
if (maxOneLineOuter <= contentMaxWidth) {
155175
return {
156176
wrapDetails: false,
@@ -173,7 +193,7 @@ export const FreebuffModelSelector: React.FC = () => {
173193
return parts.length === 0 ? 0 : 2 /* indent */ + joinedLen(parts)
174194
}
175195
const maxTwoLineInner = Math.max(
176-
...FREEBUFF_MODELS.map((m) =>
196+
...availableModels.map((m) =>
177197
Math.max(labelLineLen(m), detailsLineLen(m)),
178198
),
179199
)
@@ -185,7 +205,7 @@ export const FreebuffModelSelector: React.FC = () => {
185205
),
186206
nameColumnWidth: maxNameLen,
187207
}
188-
}, [contentMaxWidth, deploymentAvailabilityLabel])
208+
}, [availableModels, contentMaxWidth, deploymentAvailabilityLabel])
189209

190210
const isJoinable = useCallback(
191211
(modelId: string) => {
@@ -228,7 +248,7 @@ export const FreebuffModelSelector: React.FC = () => {
228248
}
229249
if (!direction) return
230250
const targetId = nextFreebuffModelId({
231-
modelIds: FREEBUFF_MODEL_IDS,
251+
modelIds: availableModelIds,
232252
focusedId,
233253
direction,
234254
})
@@ -238,7 +258,14 @@ export const FreebuffModelSelector: React.FC = () => {
238258
setFocusedId(targetId)
239259
}
240260
},
241-
[pending, pick, focusedId, committedModelId, isJoinable],
261+
[
262+
pending,
263+
pick,
264+
focusedId,
265+
committedModelId,
266+
isJoinable,
267+
availableModelIds,
268+
],
242269
),
243270
)
244271

@@ -345,7 +372,7 @@ export const FreebuffModelSelector: React.FC = () => {
345372
gap: 0,
346373
}}
347374
>
348-
{SECTIONS.map((section, sectionIdx) => (
375+
{sections.map((section, sectionIdx) => (
349376
<box
350377
key={section.key}
351378
style={{

cli/src/components/session-ended-banner.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,13 @@ export const SessionEndedBanner: React.FC<SessionEndedBannerProps> = ({
4444
const isQuotaExhausted = premiumQuota
4545
? premiumQuota.recentCount >= premiumQuota.limit
4646
: false
47+
const accessTier = useFreebuffSessionStore((s) =>
48+
s.session && 'accessTier' in s.session ? s.session.accessTier : 'full',
49+
)
50+
const quotaLabel =
51+
accessTier === 'limited' ? 'limited sessions' : 'premium sessions'
4752
const bannerTitle = premiumQuota
48-
? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} premium sessions used today`
53+
? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} ${quotaLabel} used today`
4954
: 'Session ended'
5055

5156
// While a request is still streaming, restart is disabled: it would

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

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ import {
2424
} from '../utils/freebuff-premium-reset'
2525
import { formatSessionUnits } from '../utils/format-session-units'
2626
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
27-
import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models'
27+
import {
28+
FREEBUFF_LIMITED_SESSION_LIMIT,
29+
FREEBUFF_PREMIUM_SESSION_LIMIT,
30+
} from '@codebuff/common/constants/freebuff-models'
2831
import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session'
2932

3033
import type { FreebuffSessionResponse } from '../types/freebuff-session'
@@ -255,6 +258,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
255258
const [exitHover, setExitHover] = useState(false)
256259

257260
const isQueued = session?.status === 'queued'
261+
const accessTier =
262+
session && 'accessTier' in session ? session.accessTier : 'full'
258263
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
259264
// state: show the picker with live N-in-line hints and a prompt. Picking a
260265
// model triggers joinFreebuffQueue, which POSTs and transitions us to
@@ -280,14 +285,22 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
280285
: undefined
281286
const sharedPremiumUsed = premiumRateLimit?.recentCount ?? 0
282287
const isPremiumExhausted =
283-
sharedPremiumUsed >= FREEBUFF_PREMIUM_SESSION_LIMIT
288+
sharedPremiumUsed >=
289+
(accessTier === 'limited'
290+
? FREEBUFF_LIMITED_SESSION_LIMIT
291+
: FREEBUFF_PREMIUM_SESSION_LIMIT)
284292
const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted
285293
// Pad the used count so the title's centered container doesn't shift width
286294
// as the count ticks from "0" → "1.3" → "2" while loading.
287-
const sessionUnitWidth = String(FREEBUFF_PREMIUM_SESSION_LIMIT).length + 2
288-
const formattedSharedPremiumUsed = formatSessionUnits(
289-
sharedPremiumUsed,
290-
).padStart(sessionUnitWidth)
295+
const sessionLimit =
296+
accessTier === 'limited'
297+
? FREEBUFF_LIMITED_SESSION_LIMIT
298+
: FREEBUFF_PREMIUM_SESSION_LIMIT
299+
const sessionLabel =
300+
accessTier === 'limited' ? 'limited sessions' : 'premium sessions'
301+
const sessionUnitWidth = String(sessionLimit).length + 2
302+
const formattedSharedPremiumUsed =
303+
formatSessionUnits(sharedPremiumUsed).padStart(sessionUnitWidth)
291304
const premiumResetAt = getFreebuffPremiumResetAt({
292305
rateLimitsByModel,
293306
nowMs: now,
@@ -399,8 +412,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
399412
style={{ fg: theme.muted, marginBottom: 1, wrapMode: 'word' }}
400413
>
401414
<span fg={premiumUsedColor}>
402-
{formattedSharedPremiumUsed} of{' '}
403-
{FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used
415+
{formattedSharedPremiumUsed} of {sessionLimit} {sessionLabel}{' '}
416+
used
404417
</span>
405418
<span fg={theme.muted}>
406419
{' · '}
@@ -540,7 +553,10 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
540553
<span fg={theme.foreground}>
541554
{formatSessionUnits(session.recentCount)} of {session.limit}
542555
</span>{' '}
543-
premium sessions today. Try again in{' '}
556+
{session.accessTier === 'limited'
557+
? 'limited sessions'
558+
: 'premium sessions'}{' '}
559+
today. Try again in{' '}
544560
<span fg={theme.foreground}>
545561
{formatRetryAfter(session.retryAfterMs)}
546562
</span>

cli/src/hooks/helpers/send-message.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ export type ResetEarlyReturnStateParams = {
5555
isQueuePausedRef?: MutableRefObject<boolean>
5656
}
5757

58-
export const resetEarlyReturnState = (params: ResetEarlyReturnStateParams): void => {
58+
export const resetEarlyReturnState = (
59+
params: ResetEarlyReturnStateParams,
60+
): void => {
5961
const {
6062
setCanProcessQueue,
6163
updateChainInProgress,
@@ -186,11 +188,12 @@ export const prepareUserMessage = async (params: {
186188
}
187189
}
188190

189-
const { attachments: imageAttachments, messageContent } = await processImagesForMessage({
190-
content: finalContent,
191-
pendingImages,
192-
projectRoot: getProjectRoot(),
193-
})
191+
const { attachments: imageAttachments, messageContent } =
192+
await processImagesForMessage({
193+
content: finalContent,
194+
pendingImages,
195+
projectRoot: getProjectRoot(),
196+
})
194197

195198
const shouldInsertDivider =
196199
lastMessageMode === null || lastMessageMode !== agentMode
@@ -214,7 +217,12 @@ export const prepareUserMessage = async (params: {
214217
}))
215218

216219
// Pass original content (not finalContent) for display, but finalContent goes to agent
217-
const userMessage = getUserMessage(content, imageAttachments, textAttachmentsForMessage, fileAttachmentsForMessage)
220+
const userMessage = getUserMessage(
221+
content,
222+
imageAttachments,
223+
textAttachmentsForMessage,
224+
fileAttachmentsForMessage,
225+
)
218226
const userMessageId = userMessage.id
219227
if (imageAttachments.length > 0) {
220228
userMessage.attachments = imageAttachments
@@ -381,7 +389,6 @@ export const handleRunCompletion = (params: {
381389
}
382390

383391
if (output.type === 'error') {
384-
385392
if (isOutOfCreditsError(output)) {
386393
updater.setError(OUT_OF_CREDITS_MESSAGE)
387394
useChatStore.getState().setInputMode('outOfCredits')
@@ -527,6 +534,7 @@ function handleFreebuffGateError(
527534
switch (kind) {
528535
case 'session_expired':
529536
case 'waiting_room_required':
537+
case 'session_model_mismatch':
530538
// Our seat is gone mid-chat. Finalize the AI message so its streaming
531539
// indicator stops — otherwise `isComplete` stays false and the message
532540
// keeps rendering a blinking cursor forever, making the user think the

0 commit comments

Comments
 (0)