Skip to content

Commit 48005cb

Browse files
authored
Label Freebuff limited mode (#670)
1 parent 9912dc9 commit 48005cb

7 files changed

Lines changed: 160 additions & 10 deletions

File tree

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

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,8 @@ const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
8080
res_proxy: 'residential proxy',
8181
tor: 'Tor',
8282
vpn: 'VPN',
83+
hosting: 'hosting network',
84+
service: 'privacy service',
8385
}
8486

8587
const formatPrivacySignalList = (
@@ -101,6 +103,38 @@ const formatPrivacySignalList = (
101103
return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`
102104
}
103105

106+
const getLimitedModeReason = (
107+
session: FreebuffSessionResponse | null,
108+
): string | null => {
109+
if (!session || !('countryBlockReason' in session)) {
110+
return 'reduced free model access'
111+
}
112+
113+
const countryCode =
114+
'countryCode' in session &&
115+
session.countryCode &&
116+
session.countryCode !== 'UNKNOWN'
117+
? session.countryCode
118+
: null
119+
120+
switch (session.countryBlockReason) {
121+
case 'anonymous_network':
122+
return `${formatPrivacySignalList(
123+
session.ipPrivacySignals ?? undefined,
124+
)} detected`
125+
case 'country_not_allowed':
126+
return `outside available countries${countryCode ? ` (${countryCode})` : ''}`
127+
case 'anonymized_or_unknown_country':
128+
case 'missing_client_ip':
129+
case 'unresolved_client_ip':
130+
return 'location could not be verified'
131+
case 'ip_privacy_lookup_failed':
132+
return 'network check could not finish'
133+
default:
134+
return 'reduced free model access'
135+
}
136+
}
137+
104138
const TakeoverPrompt: React.FC = () => {
105139
const theme = useTheme()
106140
const [pending, setPending] = useState(false)
@@ -261,6 +295,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
261295
const isQueued = session?.status === 'queued'
262296
const accessTier =
263297
session && 'accessTier' in session ? session.accessTier : 'full'
298+
const limitedModeReason =
299+
accessTier === 'limited' ? getLimitedModeReason(session) : null
264300
// 'none' = user hasn't joined any queue yet. We're in the pre-chat landing
265301
// state: show the picker with live N-in-line hints and a prompt. Picking a
266302
// model triggers joinFreebuffQueue, which POSTs and transitions us to
@@ -337,17 +373,28 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
337373
>
338374
{/* Top-right exit affordance so mouse users have a clear way out even
339375
when they don't know Ctrl+C works. width: '100%' is required for
340-
justifyContent: 'flex-end' to actually push the X to the right. */}
376+
justifyContent to actually push the X to the right. */}
341377
<box
342378
style={{
343379
width: '100%',
344380
flexDirection: 'row',
345-
justifyContent: 'flex-end',
381+
justifyContent: 'space-between',
346382
paddingTop: 1,
383+
paddingLeft: 2,
347384
paddingRight: 2,
348385
flexShrink: 0,
349386
}}
350387
>
388+
<box>
389+
{limitedModeReason && (
390+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
391+
<span fg={theme.secondary} attributes={TextAttributes.BOLD}>
392+
Limited mode
393+
</span>
394+
<span fg={theme.muted}> · {limitedModeReason}</span>
395+
</text>
396+
)}
397+
</box>
351398
<Button
352399
onClick={exitFreebuffCleanly}
353400
onMouseOver={() => setExitHover(true)}

cli/src/hooks/use-freebuff-session.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,12 +226,25 @@ function toLandingSession(
226226
? current.queueDepthByModel
227227
: undefined
228228
const rateLimitsByModel = getRateLimitsByModel(current)
229+
const countryCode =
230+
current && 'countryCode' in current ? current.countryCode : undefined
231+
const countryBlockReason =
232+
current && 'countryBlockReason' in current
233+
? current.countryBlockReason
234+
: undefined
235+
const ipPrivacySignals =
236+
current && 'ipPrivacySignals' in current
237+
? current.ipPrivacySignals
238+
: undefined
229239

230240
return {
231241
status: 'none',
232242
...(accessTier ? { accessTier } : {}),
233243
...(queueDepthByModel ? { queueDepthByModel } : {}),
234244
...(rateLimitsByModel ? { rateLimitsByModel } : {}),
245+
...(countryCode ? { countryCode } : {}),
246+
...(countryBlockReason ? { countryBlockReason } : {}),
247+
...(ipPrivacySignals ? { ipPrivacySignals } : {}),
235248
}
236249
}
237250

@@ -632,6 +645,13 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
632645
rateLimitsByModel:
633646
response.rateLimitsByModel ??
634647
landingSession.rateLimitsByModel,
648+
countryCode: response.countryCode ?? landingSession.countryCode,
649+
countryBlockReason:
650+
response.countryBlockReason ??
651+
landingSession.countryBlockReason,
652+
ipPrivacySignals:
653+
response.ipPrivacySignals ??
654+
landingSession.ipPrivacySignals,
635655
})
636656
}
637657
})

common/src/types/freebuff-session.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,21 @@ export type FreebuffIpPrivacySignal =
6565
| 'hosting'
6666
| 'service'
6767

68+
export interface FreebuffLimitedModeReason {
69+
/** Present for limited access so the model picker can explain why the
70+
* reduced model set is shown without re-running geo/IP logic locally. */
71+
countryCode?: string | null
72+
countryBlockReason?: FreebuffCountryBlockReason | null
73+
ipPrivacySignals?: FreebuffIpPrivacySignal[] | null
74+
}
75+
6876
export type FreebuffSessionServerResponse =
6977
| {
7078
/** Waiting room is globally off; free-mode requests flow through
7179
* unchanged. Client should treat this as "admitted forever". */
7280
status: 'disabled'
7381
}
74-
| {
82+
| ({
7583
/** User has no session row. CLI must POST to (re-)queue. Also returned
7684
* when `getSessionState` notices the user has been swept past the
7785
* grace window. */
@@ -88,8 +96,8 @@ export type FreebuffSessionServerResponse =
8896
* the picker show today's premium-session usage before the user commits
8997
* to a queue. */
9098
rateLimitsByModel?: FreebuffSessionRateLimitByModel
91-
}
92-
| {
99+
} & FreebuffLimitedModeReason)
100+
| ({
93101
status: 'queued'
94102
accessTier: FreebuffAccessTier
95103
instanceId: string
@@ -108,8 +116,8 @@ export type FreebuffSessionServerResponse =
108116
/** Premium-session quota for this model. Absent for unlimited models. */
109117
rateLimit?: FreebuffSessionRateLimit
110118
rateLimitsByModel?: FreebuffSessionRateLimitByModel
111-
}
112-
| {
119+
} & FreebuffLimitedModeReason)
120+
| ({
113121
status: 'active'
114122
accessTier: FreebuffAccessTier
115123
instanceId: string
@@ -121,8 +129,8 @@ export type FreebuffSessionServerResponse =
121129
/** Premium-session quota for this model. Absent for unlimited models. */
122130
rateLimit?: FreebuffSessionRateLimit
123131
rateLimitsByModel?: FreebuffSessionRateLimitByModel
124-
}
125-
| {
132+
} & FreebuffLimitedModeReason)
133+
| ({
126134
/** Session is over. While `instanceId` is present we're inside the
127135
* server-side grace window — chat requests still go through so the
128136
* agent can finish, but the CLI must not accept new prompts. Once
@@ -143,7 +151,7 @@ export type FreebuffSessionServerResponse =
143151
* session ended. Lets the post-session banner show "N of M premium
144152
* sessions used today" without an extra round-trip. */
145153
rateLimitsByModel?: FreebuffSessionRateLimitByModel
146-
}
154+
} & FreebuffLimitedModeReason)
147155
| {
148156
/** Another CLI on the same account rotated our instance id. Polling
149157
* stops and the UI shows a "close the other CLI" screen. The server

web/src/app/api/v1/freebuff/session/__tests__/session.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ describe('POST /api/v1/freebuff/session', () => {
246246
expect(body.status).toBe('queued')
247247
expect(body.accessTier).toBe('limited')
248248
expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID)
249+
expect(body.countryCode).toBe('JP')
250+
expect(body.countryBlockReason).toBe('country_not_allowed')
249251
expect(sessionDeps.rows.get('u1')).toMatchObject({
250252
access_tier: 'limited',
251253
country_code: 'JP',
@@ -341,6 +343,35 @@ describe('GET /api/v1/freebuff/session', () => {
341343
const body = await resp.json()
342344
expect(body.status).toBe('none')
343345
expect(body.accessTier).toBe('limited')
346+
expect(body.countryCode).toBe('JP')
347+
expect(body.countryBlockReason).toBe('country_not_allowed')
348+
expect(body.ipPrivacySignals).toBeNull()
349+
})
350+
351+
test('returns limited-mode privacy reason on GET', async () => {
352+
const sessionDeps = makeSessionDeps()
353+
const resp = await getFreebuffSession(
354+
makeReq('ok', { cfCountry: 'US' }),
355+
makeDeps(sessionDeps, 'u1', {
356+
getCountryAccess: async () => ({
357+
allowed: false,
358+
countryCode: 'US',
359+
blockReason: 'anonymous_network',
360+
cfCountry: 'US',
361+
geoipCountry: null,
362+
ipPrivacy: { signals: ['vpn', 'hosting'] },
363+
hasClientIp: true,
364+
clientIpHash: 'test-ip-hash',
365+
}),
366+
}),
367+
)
368+
expect(resp.status).toBe(200)
369+
const body = await resp.json()
370+
expect(body.status).toBe('none')
371+
expect(body.accessTier).toBe('limited')
372+
expect(body.countryCode).toBe('US')
373+
expect(body.countryBlockReason).toBe('anonymous_network')
374+
expect(body.ipPrivacySignals).toEqual(['vpn', 'hosting'])
344375
})
345376

346377
test('rechecks country on GET so access tier changes are visible immediately', async () => {

web/src/app/api/v1/freebuff/session/_handlers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ function toSessionCountryAccess(
5959
}
6060
}
6161

62+
function toLimitedModeReason(countryAccess: FreeModeCountryAccess) {
63+
if (countryAccess.allowed) return {}
64+
return {
65+
countryCode: countryAccess.countryCode,
66+
countryBlockReason: countryAccess.blockReason,
67+
ipPrivacySignals: countryAccess.ipPrivacy?.signals ?? null,
68+
}
69+
}
70+
6271
/** Header the CLI uses to identify which instance is polling. Used by GET to
6372
* detect when another CLI on the same account has rotated the id. */
6473
export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
@@ -220,6 +229,7 @@ export async function getFreebuffSession(
220229
message: 'Call POST to join the waiting room.',
221230
queueDepthByModel: state.queueDepthByModel,
222231
rateLimitsByModel: state.rateLimitsByModel,
232+
...toLimitedModeReason(countryAccess),
223233
},
224234
{ status: 200 },
225235
)

web/src/server/free-session/__tests__/session-view.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,28 @@ describe('toSessionStateResponse', () => {
7878
})
7979
})
8080

81+
test('limited queued row includes limited-mode reason metadata', () => {
82+
const view = toSessionStateResponse({
83+
row: row({
84+
status: 'queued',
85+
access_tier: 'limited',
86+
country_code: 'US',
87+
country_block_reason: 'anonymous_network',
88+
ip_privacy_signals: ['vpn'],
89+
}),
90+
position: 1,
91+
...baseArgs,
92+
now,
93+
})
94+
expect(view).toMatchObject({
95+
status: 'queued',
96+
accessTier: 'limited',
97+
countryCode: 'US',
98+
countryBlockReason: 'anonymous_network',
99+
ipPrivacySignals: ['vpn'],
100+
})
101+
})
102+
81103
test('active unexpired row maps to active response with remaining ms', () => {
82104
const admittedAt = new Date(now.getTime() - 10 * 60_000)
83105
const expiresAt = new Date(now.getTime() + 50 * 60_000)

web/src/server/free-session/session-view.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import type { InternalSessionRow, SessionStateResponse } from './types'
22

3+
function limitedModeReasonFromRow(row: InternalSessionRow) {
4+
if ((row.access_tier ?? 'full') !== 'limited') return {}
5+
return {
6+
countryCode: row.country_code ?? null,
7+
countryBlockReason: row.country_block_reason ?? null,
8+
ipPrivacySignals: row.ip_privacy_signals ?? null,
9+
}
10+
}
11+
312
/**
413
* Pure function converting an internal session row (or absence thereof) into
514
* the public response shape. Never reads the clock — caller supplies `now` so
@@ -33,6 +42,7 @@ export function toSessionStateResponse(params: {
3342
admittedAt: (row.admitted_at ?? row.created_at).toISOString(),
3443
expiresAt: row.expires_at.toISOString(),
3544
remainingMs: expiresAtMs - nowMs,
45+
...limitedModeReasonFromRow(row),
3646
}
3747
}
3848
const graceEndsMs = expiresAtMs + graceMs
@@ -45,6 +55,7 @@ export function toSessionStateResponse(params: {
4555
expiresAt: row.expires_at.toISOString(),
4656
gracePeriodEndsAt: new Date(graceEndsMs).toISOString(),
4757
gracePeriodRemainingMs: graceEndsMs - nowMs,
58+
...limitedModeReasonFromRow(row),
4859
}
4960
}
5061
}
@@ -60,6 +71,7 @@ export function toSessionStateResponse(params: {
6071
queueDepthByModel,
6172
estimatedWaitMs: estimateWaitMs({ position }),
6273
queuedAt: row.queued_at.toISOString(),
74+
...limitedModeReasonFromRow(row),
6375
}
6476
}
6577

0 commit comments

Comments
 (0)