Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { exitFreebuffCleanly } from '../utils/freebuff-exit'
import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'

import type { FreebuffSessionResponse } from '../types/freebuff-session'
import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session'

interface WaitingRoomScreenProps {
session: FreebuffSessionResponse | null
Expand Down Expand Up @@ -55,6 +56,35 @@ const formatRetryAfter = (ms: number): string => {
return rem === 0 ? `${hours}h` : `${hours}h ${rem}m`
}

const PRIVACY_SIGNAL_LABELS: Partial<Record<FreebuffIpPrivacySignal, string>> =
{
anonymous: 'anonymized network',
proxy: 'proxy',
relay: 'relay',
res_proxy: 'residential proxy',
tor: 'Tor',
vpn: 'VPN',
}

const formatPrivacySignalList = (
signals: FreebuffIpPrivacySignal[] | undefined,
): string => {
const labels = Array.from(
new Set(
signals
?.map((signal) => PRIVACY_SIGNAL_LABELS[signal])
.filter((label): label is string => Boolean(label)) ?? [],
),
)

if (labels.length === 0) {
return 'VPN, Tor, proxy, relay, or anonymized network'
}
if (labels.length === 1) return labels[0]
if (labels.length === 2) return `${labels[0]} or ${labels[1]}`
return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`
}

export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
session,
error,
Expand Down Expand Up @@ -263,7 +293,23 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
⚠ Free mode isn't available in your region
</text>
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
{session.countryCode === 'UNKNOWN' ? (
{session.countryBlockReason === 'anonymous_network' ? (
<>
We detected{' '}
{formatPrivacySignalList(session.ipPrivacySignals)} traffic
{session.countryCode === 'UNKNOWN' ? (
''
) : (
<>
{' '}
from{' '}
<span fg={theme.foreground}>{session.countryCode}</span>
</>
)}
. Freebuff can't be used from anonymized networks. Press
Ctrl+C to exit.
</>
) : session.countryCode === 'UNKNOWN' ? (
<>
We couldn't verify an eligible location for this request.
VPN, Tor, proxy, or unknown-location traffic can't use
Expand Down
10 changes: 7 additions & 3 deletions cli/src/hooks/helpers/send-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import { IS_FREEBUFF } from '../../utils/constants'
import { processBashContext } from '../../utils/bash-context-processor'
import { markRunningAgentsAsCancelled } from '../../utils/block-operations'
import {
getCountryCodeFromFreeModeError,
getCountryBlockFromFreeModeError,
getFreebuffGateErrorKind,
isOutOfCreditsError,
isFreeModeUnavailableError,
Expand Down Expand Up @@ -394,7 +394,9 @@ export const handleRunCompletion = (params: {
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
if (IS_FREEBUFF) {
markFreebuffSessionCountryBlocked(
getCountryCodeFromFreeModeError(output) ?? 'UNKNOWN',
getCountryBlockFromFreeModeError(output) ?? {
countryCode: 'UNKNOWN',
},
)
}
finalizeAfterError()
Expand Down Expand Up @@ -494,7 +496,9 @@ export const handleRunError = (params: {
updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE)
if (IS_FREEBUFF) {
markFreebuffSessionCountryBlocked(
getCountryCodeFromFreeModeError(error) ?? 'UNKNOWN',
getCountryBlockFromFreeModeError(error) ?? {
countryCode: 'UNKNOWN',
},
)
}
return
Expand Down
12 changes: 10 additions & 2 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { logger } from '../utils/logger'
import { saveFreebuffModelPreference } from '../utils/settings'

import type { FreebuffSessionResponse } from '../types/freebuff-session'
import type {
FreebuffCountryBlockReason,
FreebuffIpPrivacySignal,
} from '@codebuff/common/types/freebuff-session'

const POLL_INTERVAL_QUEUED_MS = 5_000
const POLL_INTERVAL_ACTIVE_MS = 30_000
Expand Down Expand Up @@ -319,10 +323,14 @@ export function markFreebuffSessionSuperseded(): void {
* Transitioning the session state here unmounts the Chat surface in favor of
* the waiting-room's country_blocked message, so the user can't keep typing
* and sending doomed requests. */
export function markFreebuffSessionCountryBlocked(countryCode: string): void {
export function markFreebuffSessionCountryBlocked(params: {
countryCode: string
countryBlockReason?: FreebuffCountryBlockReason
ipPrivacySignals?: FreebuffIpPrivacySignal[]
}): void {
if (!IS_FREEBUFF) return
controller?.abort()
controller?.apply({ status: 'country_blocked', countryCode })
controller?.apply({ status: 'country_blocked', ...params })
// Best-effort DELETE so we don't hold a waiting-room seat on a session the
// server is already refusing to serve at chat time.
releaseFreebuffSlot().catch(() => {})
Expand Down
63 changes: 59 additions & 4 deletions cli/src/utils/__tests__/error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, test, expect } from 'bun:test'
import {
isOutOfCreditsError,
isFreeModeUnavailableError,
getCountryBlockFromFreeModeError,
OUT_OF_CREDITS_MESSAGE,
FREE_MODE_UNAVAILABLE_MESSAGE,
createErrorMessage,
Expand Down Expand Up @@ -70,7 +71,11 @@ describe('error-handling', () => {

describe('isFreeModeUnavailableError', () => {
test('returns true for error with statusCode 403 and error free_mode_unavailable', () => {
const error = { statusCode: 403, error: 'free_mode_unavailable', message: 'Free mode is not available in your country.' }
const error = {
statusCode: 403,
error: 'free_mode_unavailable',
message: 'Free mode is not available in your country.',
}
expect(isFreeModeUnavailableError(error)).toBe(true)
})

Expand All @@ -80,12 +85,20 @@ describe('error-handling', () => {
})

test('returns false for 403 with different error code', () => {
const error = { statusCode: 403, error: 'account_suspended', message: 'Suspended' }
const error = {
statusCode: 403,
error: 'account_suspended',
message: 'Suspended',
}
expect(isFreeModeUnavailableError(error)).toBe(false)
})

test('returns false for non-403 status with free_mode_unavailable error', () => {
const error = { statusCode: 400, error: 'free_mode_unavailable', message: 'Bad request' }
const error = {
statusCode: 400,
error: 'free_mode_unavailable',
message: 'Bad request',
}
expect(isFreeModeUnavailableError(error)).toBe(false)
})

Expand All @@ -102,9 +115,51 @@ describe('error-handling', () => {
})
})

describe('getCountryBlockFromFreeModeError', () => {
test('extracts country block details from free-mode unavailable errors', () => {
const error = {
statusCode: 403,
error: 'free_mode_unavailable',
countryCode: 'US',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['vpn', 'hosting', 123],
}

expect(getCountryBlockFromFreeModeError(error)).toEqual({
countryCode: 'US',
countryBlockReason: 'anonymous_network',
ipPrivacySignals: ['vpn', 'hosting'],
})
})

test('defaults missing country code to UNKNOWN', () => {
const error = {
statusCode: 403,
error: 'free_mode_unavailable',
}

expect(getCountryBlockFromFreeModeError(error)).toEqual({
countryCode: 'UNKNOWN',
countryBlockReason: undefined,
ipPrivacySignals: undefined,
})
})

test('returns null for non-free-mode errors', () => {
expect(
getCountryBlockFromFreeModeError({
statusCode: 403,
error: 'account_suspended',
}),
).toBe(null)
})
})

describe('FREE_MODE_UNAVAILABLE_MESSAGE', () => {
test('mentions unavailability in country', () => {
expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain('not available in your country')
expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain(
'not available in your country',
)
})
})

Expand Down
46 changes: 34 additions & 12 deletions cli/src/utils/error-handling.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { env } from '@codebuff/common/env'

import type { ChatMessage } from '../types/chat'
import type {
FreebuffCountryBlockReason,
FreebuffIpPrivacySignal,
} from '@codebuff/common/types/freebuff-session'

import { IS_FREEBUFF } from './constants'

Expand Down Expand Up @@ -57,20 +61,38 @@ export const isFreeModeUnavailableError = (error: unknown): boolean => {
return false
}

/**
* Extract the detected countryCode off a free_mode_unavailable error, if the
* server included one. Used to populate the country_blocked screen after the
* chat-completions gate rejects a user whose session-level country check did
* not catch the request first.
*/
export const getCountryCodeFromFreeModeError = (
export const getCountryBlockFromFreeModeError = (
error: unknown,
): string | null => {
): {
countryCode: string
countryBlockReason?: FreebuffCountryBlockReason
ipPrivacySignals?: FreebuffIpPrivacySignal[]
} | null => {
if (!isFreeModeUnavailableError(error)) return null
const candidate = (error as { countryCode?: unknown }).countryCode
return typeof candidate === 'string' && candidate.length > 0
? candidate
: null
const errorDetails = error as {
countryCode?: unknown
countryBlockReason?: unknown
ipPrivacySignals?: unknown
}
const countryCode =
typeof errorDetails.countryCode === 'string' &&
errorDetails.countryCode.length > 0
? errorDetails.countryCode
: 'UNKNOWN'

return {
countryCode,
countryBlockReason:
typeof errorDetails.countryBlockReason === 'string'
? (errorDetails.countryBlockReason as FreebuffCountryBlockReason)
: undefined,
ipPrivacySignals: Array.isArray(errorDetails.ipPrivacySignals)
? errorDetails.ipPrivacySignals.filter(
(signal): signal is FreebuffIpPrivacySignal =>
typeof signal === 'string',
)
: undefined,
}
}
Comment on lines +64 to +96
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 isFreeModeUnavailableError called twice

getCountryBlockFromFreeModeError calls isFreeModeUnavailableError(error) and then delegates to getCountryCodeFromFreeModeError(error), which calls it again. The two functions could be merged or the inner helper inlined to eliminate the double-check:

export const getCountryBlockFromFreeModeError = (
  error: unknown,
): { countryCode: string; countryBlockReason?: string; ipPrivacySignals?: string[] } | null => {
  if (!isFreeModeUnavailableError(error)) return null
  const obj = error as { countryCode?: unknown; countryBlockReason?: unknown; ipPrivacySignals?: unknown }
  const countryCode =
    typeof obj.countryCode === 'string' && obj.countryCode.length > 0
      ? obj.countryCode
      : 'UNKNOWN'
  return {
    countryCode,
    countryBlockReason: typeof obj.countryBlockReason === 'string' ? obj.countryBlockReason : undefined,
    ipPrivacySignals: Array.isArray(obj.ipPrivacySignals)
      ? obj.ipPrivacySignals.filter((s): s is string => typeof s === 'string')
      : undefined,
  }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: cli/src/utils/error-handling.ts
Line: 76-100

Comment:
**`isFreeModeUnavailableError` called twice**

`getCountryBlockFromFreeModeError` calls `isFreeModeUnavailableError(error)` and then delegates to `getCountryCodeFromFreeModeError(error)`, which calls it again. The two functions could be merged or the inner helper inlined to eliminate the double-check:

```ts
export const getCountryBlockFromFreeModeError = (
  error: unknown,
): { countryCode: string; countryBlockReason?: string; ipPrivacySignals?: string[] } | null => {
  if (!isFreeModeUnavailableError(error)) return null
  const obj = error as { countryCode?: unknown; countryBlockReason?: unknown; ipPrivacySignals?: unknown }
  const countryCode =
    typeof obj.countryCode === 'string' && obj.countryCode.length > 0
      ? obj.countryCode
      : 'UNKNOWN'
  return {
    countryCode,
    countryBlockReason: typeof obj.countryBlockReason === 'string' ? obj.countryBlockReason : undefined,
    ipPrivacySignals: Array.isArray(obj.ipPrivacySignals)
      ? obj.ipPrivacySignals.filter((s): s is string => typeof s === 'string')
      : undefined,
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.


/**
Expand Down
19 changes: 19 additions & 0 deletions common/src/types/freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ export interface FreebuffSessionRateLimit {
recentCount: number
}

export type FreebuffCountryBlockReason =
| 'country_not_allowed'
| 'anonymized_or_unknown_country'
| 'anonymous_network'
| 'missing_client_ip'
| 'unresolved_client_ip'

export type FreebuffIpPrivacySignal =
| 'anonymous'
| 'vpn'
| 'proxy'
| 'tor'
| 'relay'
| 'res_proxy'
| 'hosting'
| 'service'

export type FreebuffSessionServerResponse =
| {
/** Waiting room is globally off; free-mode requests flow through
Expand Down Expand Up @@ -106,6 +123,8 @@ export type FreebuffSessionServerResponse =
* screen. `countryCode` is the resolved country, or UNKNOWN. */
status: 'country_blocked'
countryCode: string
countryBlockReason?: FreebuffCountryBlockReason
ipPrivacySignals?: FreebuffIpPrivacySignal[]
}
| {
/** User has an active session bound to a different model. Returned
Expand Down
3 changes: 3 additions & 0 deletions common/src/types/session-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export const AgentOutputSchema = z.discriminatedUnion('type', [
message: z.string(),
statusCode: z.number().optional(),
error: z.string().optional(),
countryCode: z.string().optional(),
countryBlockReason: z.string().optional(),
ipPrivacySignals: z.array(z.string()).optional(),
}),
])
export type AgentOutput = z.infer<typeof AgentOutputSchema>
Expand Down
44 changes: 41 additions & 3 deletions common/src/util/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,18 +198,56 @@ export function unwrapPromptResult<T>(result: PromptResult<T>): T {
export function parseApiErrorResponseBody(responseBody: unknown): {
errorCode?: string
message?: string
countryCode?: string
countryBlockReason?: string
ipPrivacySignals?: string[]
} {
if (typeof responseBody !== 'string') return {}
try {
const parsed: unknown = JSON.parse(responseBody)
if (!parsed || typeof parsed !== 'object') return {}
const result: { errorCode?: string; message?: string } = {}
if ('error' in parsed && typeof (parsed as { error: unknown }).error === 'string') {
const result: {
errorCode?: string
message?: string
countryCode?: string
countryBlockReason?: string
ipPrivacySignals?: string[]
} = {}
if (
'error' in parsed &&
typeof (parsed as { error: unknown }).error === 'string'
) {
result.errorCode = (parsed as { error: string }).error
}
if ('message' in parsed && typeof (parsed as { message: unknown }).message === 'string') {
if (
'message' in parsed &&
typeof (parsed as { message: unknown }).message === 'string'
) {
result.message = (parsed as { message: string }).message
}
if (
'countryCode' in parsed &&
typeof (parsed as { countryCode: unknown }).countryCode === 'string'
) {
result.countryCode = (parsed as { countryCode: string }).countryCode
}
if (
'countryBlockReason' in parsed &&
typeof (parsed as { countryBlockReason: unknown }).countryBlockReason ===
'string'
) {
result.countryBlockReason = (
parsed as { countryBlockReason: string }
).countryBlockReason
}
if ('ipPrivacySignals' in parsed) {
const signals = (parsed as { ipPrivacySignals: unknown }).ipPrivacySignals
if (Array.isArray(signals)) {
result.ipPrivacySignals = signals.filter(
(signal): signal is string => typeof signal === 'string',
)
}
}
return result
} catch {
return {}
Expand Down
Loading
Loading