Skip to content

Commit aa27ace

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@251b13a5a88d199924cca1e512eefbe99435d94e
1 parent d57cb6f commit aa27ace

4 files changed

Lines changed: 149 additions & 4 deletions

File tree

‎cli/src/hooks/helpers/__tests__/send-message.test.ts‎

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test'
1+
import {
2+
describe,
3+
expect,
4+
test,
5+
mock,
6+
beforeEach,
7+
afterEach,
8+
spyOn,
9+
} from 'bun:test'
210
import { FREEBUFF_PROVIDER_USAGE_MESSAGE } from '@codebuff/common/constants/freebuff-errors'
311

412
import type { ChatMessage } from '../../../types/chat'
@@ -39,6 +47,9 @@ const {
3947
} = await import('../send-message')
4048
const { createBatchedMessageUpdater } =
4149
await import('../../../utils/message-updater')
50+
const freebuffSession = await import('../../use-freebuff-session')
51+
const { FREEBUFF_MODEL_UNAVAILABLE_MESSAGE } =
52+
await import('../../../utils/error-handling')
4253
import { createPaymentRequiredError } from '@codebuff/sdk'
4354
import type { RunState } from '@codebuff/sdk'
4455

@@ -2122,4 +2133,88 @@ describe('freebuff gate errors', () => {
21222133
expect(messages[0].userError).toContain('Your free session ended')
21232134
expect(messages[0].userError).not.toContain('server said so')
21242135
})
2136+
2137+
describe('model_unavailable', () => {
2138+
let sessionSpies: Array<ReturnType<typeof spyOn>> = []
2139+
beforeEach(() => {
2140+
sessionSpies = [
2141+
spyOn(freebuffSession, 'markFreebuffSessionEnded'),
2142+
spyOn(freebuffSession, 'markFreebuffSessionSuperseded'),
2143+
spyOn(freebuffSession, 'refreshFreebuffSession').mockResolvedValue(
2144+
undefined,
2145+
),
2146+
]
2147+
})
2148+
afterEach(() => {
2149+
for (const spy of sessionSpies) spy.mockRestore()
2150+
})
2151+
2152+
const runCompletionWith = (output: Record<string, unknown>) => {
2153+
const messages = baseMessage()
2154+
const updater = makeUpdater(messages)
2155+
handleRunCompletion({
2156+
runState: {
2157+
traceSessionId: 'trace-test',
2158+
sessionState: undefined as any,
2159+
output: output as any,
2160+
},
2161+
actualCredits: undefined,
2162+
agentMode: 'LITE',
2163+
timerController: createMockTimerController(),
2164+
updater,
2165+
aiMessageId: 'ai-1',
2166+
wasAbortedByUser: false,
2167+
setStreamStatus: () => {},
2168+
setCanProcessQueue: () => {},
2169+
updateChainInProgress: () => {},
2170+
setHasReceivedPlanResponse: () => {},
2171+
})
2172+
updater.flush()
2173+
return messages
2174+
}
2175+
2176+
test('handleRunCompletion shows the server message and keeps the session', () => {
2177+
const serverMessage =
2178+
'DeepSeek V4 Pro has been withdrawn from Freebuff. Switch to GLM 5.3 Flash to continue.'
2179+
const messages = runCompletionWith({
2180+
type: 'error',
2181+
message: serverMessage,
2182+
error: 'model_unavailable',
2183+
statusCode: 410,
2184+
})
2185+
expect(messages[0].userError).toBe(serverMessage)
2186+
expect(messages[0].isComplete).toBe(true)
2187+
for (const spy of sessionSpies) expect(spy).not.toHaveBeenCalled()
2188+
})
2189+
2190+
test('a session-ending code still ends the session (spy control)', () => {
2191+
runCompletionWith({
2192+
type: 'error',
2193+
message: 'server said so',
2194+
error: 'session_expired',
2195+
statusCode: 410,
2196+
})
2197+
expect(sessionSpies[0]).toHaveBeenCalledTimes(1)
2198+
})
2199+
2200+
test('handleRunError falls back to a default when the server sent no message', () => {
2201+
const messages = baseMessage()
2202+
const updater = makeUpdater(messages)
2203+
handleRunError({
2204+
error: Object.assign(new Error(''), {
2205+
error: 'model_unavailable',
2206+
statusCode: 410,
2207+
}),
2208+
timerController: createMockTimerController(),
2209+
updater,
2210+
setIsRetrying: () => {},
2211+
setStreamStatus: () => {},
2212+
setCanProcessQueue: () => {},
2213+
updateChainInProgress: () => {},
2214+
})
2215+
updater.flush()
2216+
expect(messages[0].userError).toBe(FREEBUFF_MODEL_UNAVAILABLE_MESSAGE)
2217+
for (const spy of sessionSpies) expect(spy).not.toHaveBeenCalled()
2218+
})
2219+
})
21252220
})

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
isOutOfCreditsError,
2222
isFreebuffProviderUsageError,
2323
isFreeModeUnavailableError,
24+
FREEBUFF_MODEL_UNAVAILABLE_MESSAGE,
2425
OUT_OF_CREDITS_MESSAGE,
2526
} from '../../utils/error-handling'
2627
import { formatElapsedTime } from '../../utils/format-elapsed-time'
@@ -45,6 +46,7 @@ import type {
4546
} from '../../types/store'
4647
import type { ChatMessage } from '../../types/chat'
4748
import type { AgentMode } from '../../utils/constants'
49+
import type { FreebuffGateErrorKind } from '../../utils/error-handling'
4850
import type { SendMessageTimerController } from '../../utils/send-message-timer'
4951
import type { StreamController } from '../stream-state'
5052
import type { StreamStatus } from '../use-message-queue'
@@ -435,6 +437,7 @@ export const handleRunCompletion = (params: {
435437
if (!isByokRun && gateKind) {
436438
handleFreebuffGateError(gateKind, updater, {
437439
messageWasDropped: params.hasReceivedContent === false,
440+
serverMessage: output.message,
438441
})
439442
finalizeAfterError()
440443
return
@@ -562,6 +565,7 @@ export const handleRunError = (params: {
562565
if (!isByokRun && gateKind) {
563566
handleFreebuffGateError(gateKind, updater, {
564567
messageWasDropped: hasReceivedContent === false,
568+
serverMessage: errorInfo.message,
565569
})
566570
return
567571
}
@@ -585,9 +589,9 @@ export const handleRunError = (params: {
585589
* the UI reflects reality and we stop sending requests until we re-admit.
586590
*/
587591
function handleFreebuffGateError(
588-
kind: ReturnType<typeof getFreebuffGateErrorKind>,
592+
kind: FreebuffGateErrorKind,
589593
updater: BatchedMessageUpdater,
590-
opts: { messageWasDropped?: boolean } = {},
594+
opts: { messageWasDropped?: boolean; serverMessage?: string } = {},
591595
) {
592596
switch (kind) {
593597
case 'session_expired':
@@ -632,7 +636,24 @@ function handleFreebuffGateError(
632636
// so we don't silently fight the other instance for the seat.
633637
markFreebuffSessionSuperseded()
634638
return
635-
default:
639+
case 'model_unavailable':
640+
// The model was withdrawn from free mode. Terminal for this request
641+
// only: the session survives (`endsTheSession: false`), so neither end it
642+
// nor re-admit — that is the re-admission loop the code exists to avoid.
643+
// The server's message names the replacement model.
644+
updater.setError(
645+
opts.serverMessage?.trim() || FREEBUFF_MODEL_UNAVAILABLE_MESSAGE,
646+
)
647+
return
648+
default: {
649+
// A new gate code must get its own case, never a silent no-op.
650+
const unhandled: never = kind
651+
updater.setError(
652+
opts.serverMessage?.trim() ||
653+
'Freebuff could not run this turn on your free session.',
654+
)
655+
logger.warn({ kind: unhandled }, 'Unhandled freebuff gate error kind')
636656
return
657+
}
637658
}
638659
}

‎cli/src/utils/__tests__/error-handling.test.ts‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@codebuff/common/constants/freebuff-errors'
77

88
import {
9+
getFreebuffGateErrorKind,
910
getFreebuffRateLimitErrorMessage,
1011
getFreeModeUnavailableErrorMessage,
1112
isOutOfCreditsError,
@@ -693,3 +694,27 @@ describe('error-handling', () => {
693694
})
694695
})
695696
})
697+
698+
describe('getFreebuffGateErrorKind', () => {
699+
test('classifies a 410 model_unavailable refusal', () => {
700+
expect(
701+
getFreebuffGateErrorKind({ error: 'model_unavailable', statusCode: 410 }),
702+
).toBe('model_unavailable')
703+
})
704+
705+
test('requires the code and its status to match', () => {
706+
expect(
707+
getFreebuffGateErrorKind({ error: 'model_unavailable', statusCode: 409 }),
708+
).toBe(null)
709+
expect(getFreebuffGateErrorKind({ error: 'model_unavailable' })).toBe(null)
710+
})
711+
712+
test('leaves session_limit_reached to its own handler', () => {
713+
expect(
714+
getFreebuffGateErrorKind({
715+
error: 'session_limit_reached',
716+
statusCode: 409,
717+
}),
718+
).toBe(null)
719+
})
720+
})

‎cli/src/utils/error-handling.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,10 @@ export const OUT_OF_CREDITS_MESSAGE = `Out of credits. Please add credits at ${d
249249
export const FREEBUFF_RATE_LIMIT_MESSAGE =
250250
'Freebuff is temporarily busy. Please try again in a moment.'
251251

252+
/** Fallback for a `model_unavailable` gate refusal without a server message. */
253+
export const FREEBUFF_MODEL_UNAVAILABLE_MESSAGE =
254+
'This model is no longer available in Freebuff. Pick another model with /model and send your message again.'
255+
252256
export const FREE_MODE_UNAVAILABLE_MESSAGE = IS_FREEBUFF
253257
? 'Freebuff is not available in your country.'
254258
: 'Free mode is not available in your country. You can use another mode to continue.'

0 commit comments

Comments
 (0)