@@ -184,6 +184,121 @@ describe('Run Cancellation Handling', () => {
184184 expect ( messageHistory . length ) . toBe ( 3 )
185185 } )
186186
187+ it ( 'extracts error code and message from AI SDK responseBody on 403' , async ( ) => {
188+ spyOn ( databaseModule , 'getUserInfoFromApiKey' ) . mockResolvedValue ( {
189+ id : 'user-123' ,
190+ email : 'test@example.com' ,
191+ discord_id : null ,
192+ referral_code : null ,
193+ stripe_customer_id : null ,
194+ banned : false ,
195+ } )
196+ spyOn ( databaseModule , 'fetchAgentFromDatabase' ) . mockResolvedValue ( null )
197+ spyOn ( databaseModule , 'startAgentRun' ) . mockResolvedValue ( 'run-1' )
198+ spyOn ( databaseModule , 'finishAgentRun' ) . mockResolvedValue ( undefined )
199+ spyOn ( databaseModule , 'addAgentStep' ) . mockResolvedValue ( 'step-1' )
200+
201+ // Simulate AI SDK's AI_APICallError with responseBody (what the server returns for free_mode_unavailable)
202+ const apiError = new Error ( 'Forbidden' ) as Error & { statusCode : number ; responseBody : string }
203+ apiError . statusCode = 403
204+ apiError . responseBody = JSON . stringify ( {
205+ error : 'free_mode_unavailable' ,
206+ message : 'Free mode is not available in your country.' ,
207+ } )
208+
209+ spyOn ( mainPromptModule , 'callMainPrompt' ) . mockRejectedValue ( apiError )
210+
211+ const client = new CodebuffClient ( {
212+ apiKey : 'test-key' ,
213+ } )
214+
215+ const result = await client . run ( {
216+ agent : 'base2' ,
217+ prompt : 'hello' ,
218+ } )
219+
220+ expect ( result . output . type ) . toBe ( 'error' )
221+ const output = result . output as { type : 'error' ; message : string ; statusCode ?: number ; error ?: string }
222+ // Should use the message from the response body, not the generic "Forbidden"
223+ expect ( output . message ) . toBe ( 'Free mode is not available in your country.' )
224+ expect ( output . statusCode ) . toBe ( 403 )
225+ // Should propagate the error code so isFreeModeUnavailableError can match
226+ expect ( output . error ) . toBe ( 'free_mode_unavailable' )
227+ } )
228+
229+ it ( 'extracts error code from responseBody for account_suspended 403' , async ( ) => {
230+ spyOn ( databaseModule , 'getUserInfoFromApiKey' ) . mockResolvedValue ( {
231+ id : 'user-123' ,
232+ email : 'test@example.com' ,
233+ discord_id : null ,
234+ referral_code : null ,
235+ stripe_customer_id : null ,
236+ banned : false ,
237+ } )
238+ spyOn ( databaseModule , 'fetchAgentFromDatabase' ) . mockResolvedValue ( null )
239+ spyOn ( databaseModule , 'startAgentRun' ) . mockResolvedValue ( 'run-1' )
240+ spyOn ( databaseModule , 'finishAgentRun' ) . mockResolvedValue ( undefined )
241+ spyOn ( databaseModule , 'addAgentStep' ) . mockResolvedValue ( 'step-1' )
242+
243+ const apiError = new Error ( 'Forbidden' ) as Error & { statusCode : number ; responseBody : string }
244+ apiError . statusCode = 403
245+ apiError . responseBody = JSON . stringify ( {
246+ error : 'account_suspended' ,
247+ message : 'Your account has been suspended due to billing issues.' ,
248+ } )
249+
250+ spyOn ( mainPromptModule , 'callMainPrompt' ) . mockRejectedValue ( apiError )
251+
252+ const client = new CodebuffClient ( {
253+ apiKey : 'test-key' ,
254+ } )
255+
256+ const result = await client . run ( {
257+ agent : 'base2' ,
258+ prompt : 'hello' ,
259+ } )
260+
261+ const output = result . output as { type : 'error' ; message : string ; statusCode ?: number ; error ?: string }
262+ expect ( output . message ) . toBe ( 'Your account has been suspended due to billing issues.' )
263+ expect ( output . statusCode ) . toBe ( 403 )
264+ expect ( output . error ) . toBe ( 'account_suspended' )
265+ } )
266+
267+ it ( 'falls back to error.message when responseBody is not valid JSON' , async ( ) => {
268+ spyOn ( databaseModule , 'getUserInfoFromApiKey' ) . mockResolvedValue ( {
269+ id : 'user-123' ,
270+ email : 'test@example.com' ,
271+ discord_id : null ,
272+ referral_code : null ,
273+ stripe_customer_id : null ,
274+ banned : false ,
275+ } )
276+ spyOn ( databaseModule , 'fetchAgentFromDatabase' ) . mockResolvedValue ( null )
277+ spyOn ( databaseModule , 'startAgentRun' ) . mockResolvedValue ( 'run-1' )
278+ spyOn ( databaseModule , 'finishAgentRun' ) . mockResolvedValue ( undefined )
279+ spyOn ( databaseModule , 'addAgentStep' ) . mockResolvedValue ( 'step-1' )
280+
281+ const apiError = new Error ( 'Forbidden' ) as Error & { statusCode : number ; responseBody : string }
282+ apiError . statusCode = 403
283+ apiError . responseBody = 'not valid json'
284+
285+ spyOn ( mainPromptModule , 'callMainPrompt' ) . mockRejectedValue ( apiError )
286+
287+ const client = new CodebuffClient ( {
288+ apiKey : 'test-key' ,
289+ } )
290+
291+ const result = await client . run ( {
292+ agent : 'base2' ,
293+ prompt : 'hello' ,
294+ } )
295+
296+ const output = result . output as { type : 'error' ; message : string ; statusCode ?: number ; error ?: string }
297+ expect ( output . message ) . toBe ( 'Forbidden' )
298+ expect ( output . statusCode ) . toBe ( 403 )
299+ expect ( output . error ) . toBeUndefined ( )
300+ } )
301+
187302 it ( 'preserves user message when callMainPrompt throws an error' , async ( ) => {
188303 spyOn ( databaseModule , 'getUserInfoFromApiKey' ) . mockResolvedValue ( {
189304 id : 'user-123' ,
0 commit comments