Skip to content

Commit 8139f16

Browse files
committed
Parse error from aisdk to properly show Forbidden
1 parent 85d963b commit 8139f16

File tree

5 files changed

+131
-17
lines changed

5 files changed

+131
-17
lines changed

common/src/types/session-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const AgentOutputSchema = z.discriminatedUnion('type', [
6868
type: z.literal('error'),
6969
message: z.string(),
7070
statusCode: z.number().optional(),
71+
error: z.string().optional(),
7172
}),
7273
])
7374
export type AgentOutput = z.infer<typeof AgentOutputSchema>

common/src/util/error.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,35 @@ export function unwrapPromptResult<T>(result: PromptResult<T>): T {
187187
return result.value
188188
}
189189

190+
/**
191+
* Parses a JSON response body string from an API error to extract structured error details.
192+
* Used to extract machine-readable error codes and human-readable messages from API responses
193+
* (e.g., AI SDK's APICallError includes a responseBody with the server's JSON response).
194+
*
195+
* Returns extracted fields, or an empty object if the responseBody is not a valid JSON string
196+
* with the expected shape.
197+
*/
198+
export function parseApiErrorResponseBody(responseBody: unknown): {
199+
errorCode?: string
200+
message?: string
201+
} {
202+
if (typeof responseBody !== 'string') return {}
203+
try {
204+
const parsed: unknown = JSON.parse(responseBody)
205+
if (!parsed || typeof parsed !== 'object') return {}
206+
const result: { errorCode?: string; message?: string } = {}
207+
if ('error' in parsed && typeof (parsed as { error: unknown }).error === 'string') {
208+
result.errorCode = (parsed as { error: string }).error
209+
}
210+
if ('message' in parsed && typeof (parsed as { message: unknown }).message === 'string') {
211+
result.message = (parsed as { message: string }).message
212+
}
213+
return result
214+
} catch {
215+
return {}
216+
}
217+
}
218+
190219
// Extended error properties that various libraries add to Error objects
191220
interface ExtendedErrorProperties {
192221
status?: number

packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
mock,
2121
spyOn,
2222
} from 'bun:test'
23+
import { APICallError } from 'ai'
2324
import { z } from 'zod/v4'
2425

2526
import { loopAgentSteps } from '../run-agent-step'
@@ -931,4 +932,89 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () =>
931932
expect(llmCallCount).toBe(0)
932933
})
933934
})
935+
936+
describe('API error handling', () => {
937+
it('should propagate error code and server message from 403 APICallError responseBody', async () => {
938+
const llmOnlyTemplate = {
939+
...mockTemplate,
940+
handleSteps: undefined,
941+
}
942+
943+
const localAgentTemplates = {
944+
'test-agent': llmOnlyTemplate,
945+
}
946+
947+
// Mock promptAiSdkStream to throw an APICallError with a 403 status
948+
// and a responseBody containing the server's structured error
949+
loopAgentStepsBaseParams.promptAiSdkStream = async function* () {
950+
throw new APICallError({
951+
statusCode: 403,
952+
message: 'Forbidden',
953+
url: 'https://api.codebuff.com/v1/chat/completions',
954+
requestBodyValues: {},
955+
responseBody: JSON.stringify({
956+
error: 'free_mode_unavailable',
957+
message: 'Free mode is not available in your country.',
958+
}),
959+
isRetryable: false,
960+
})
961+
}
962+
963+
const result = await loopAgentSteps({
964+
...loopAgentStepsBaseParams,
965+
agentType: 'test-agent',
966+
localAgentTemplates,
967+
})
968+
969+
expect(result.output.type).toBe('error')
970+
if (result.output.type === 'error') {
971+
// Should use the server's message, NOT the generic "Forbidden"
972+
expect(result.output.message).toBe('Free mode is not available in your country.')
973+
// Should NOT have the 'Agent run error: ' prefix since message came from responseBody
974+
expect(result.output.message).not.toContain('Agent run error:')
975+
// Should propagate the error code so the CLI can match on it
976+
expect(result.output.error).toBe('free_mode_unavailable')
977+
// Should propagate the status code
978+
expect(result.output.statusCode).toBe(403)
979+
}
980+
})
981+
982+
it('should prefix with "Agent run error:" when responseBody has no parseable message', async () => {
983+
const llmOnlyTemplate = {
984+
...mockTemplate,
985+
handleSteps: undefined,
986+
}
987+
988+
const localAgentTemplates = {
989+
'test-agent': llmOnlyTemplate,
990+
}
991+
992+
// APICallError with no responseBody
993+
loopAgentStepsBaseParams.promptAiSdkStream = async function* () {
994+
throw new APICallError({
995+
statusCode: 500,
996+
message: 'Internal Server Error',
997+
url: 'https://api.codebuff.com/v1/chat/completions',
998+
requestBodyValues: {},
999+
responseBody: undefined,
1000+
isRetryable: true,
1001+
})
1002+
}
1003+
1004+
const result = await loopAgentSteps({
1005+
...loopAgentStepsBaseParams,
1006+
agentType: 'test-agent',
1007+
localAgentTemplates,
1008+
})
1009+
1010+
expect(result.output.type).toBe('error')
1011+
if (result.output.type === 'error') {
1012+
// Should have the prefix since there's no server message
1013+
expect(result.output.message).toContain('Agent run error:')
1014+
expect(result.output.message).toContain('Internal Server Error')
1015+
// No error code since responseBody wasn't parseable
1016+
expect(result.output.error).toBeUndefined()
1017+
}
1018+
})
1019+
})
9341020
})

packages/agent-runtime/src/run-agent-step.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
22
import { supportsCacheControl } from '@codebuff/common/old-constants'
33
import { TOOLS_WHICH_WONT_FORCE_NEXT_STEP } from '@codebuff/common/tools/constants'
44
import { buildArray } from '@codebuff/common/util/array'
5-
import { AbortError, getErrorObject, isAbortError } from '@codebuff/common/util/error'
5+
import { AbortError, getErrorObject, isAbortError, parseApiErrorResponseBody } from '@codebuff/common/util/error'
66
import { serializeCacheDebugCorrelation } from '@codebuff/common/util/cache-debug'
77
import { systemMessage, userMessage } from '@codebuff/common/util/messages'
88
import { APICallError, type ToolSet } from 'ai'
@@ -1069,8 +1069,16 @@ export async function loopAgentSteps(
10691069
)
10701070

10711071
let errorMessage = ''
1072+
let errorCode: string | undefined
1073+
let hasServerMessage = false
10721074
if (error instanceof APICallError) {
10731075
errorMessage = `${error.message}`
1076+
const parsed = parseApiErrorResponseBody(error.responseBody)
1077+
if (parsed.errorCode) errorCode = parsed.errorCode
1078+
if (parsed.message) {
1079+
errorMessage = parsed.message
1080+
hasServerMessage = true
1081+
}
10741082
} else {
10751083
// Extract clean error message (just the message, not name:message format)
10761084
errorMessage =
@@ -1101,8 +1109,9 @@ export async function loopAgentSteps(
11011109
agentState: currentAgentState,
11021110
output: {
11031111
type: 'error',
1104-
message: 'Agent run error: ' + errorMessage,
1112+
message: hasServerMessage ? errorMessage : 'Agent run error: ' + errorMessage,
11051113
...(statusCode !== undefined && { statusCode }),
1114+
...(errorCode !== undefined && { error: errorCode }),
11061115
},
11071116
}
11081117
}

sdk/src/run.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { toolNames } from '@codebuff/common/tools/constants'
1616
import { clientToolCallSchema } from '@codebuff/common/tools/list'
1717
import { AgentOutputSchema } from '@codebuff/common/types/session-state'
18+
import { parseApiErrorResponseBody } from '@codebuff/common/util/error'
1819
import { cloneDeep } from 'lodash'
1920

2021
import { getErrorStatusCode } from './error-utils'
@@ -516,25 +517,13 @@ async function runOnce({
516517

517518
// Extract structured error details from the API response body
518519
// (e.g., AI SDK's AI_APICallError includes a responseBody with the server's JSON response)
519-
let errorCode: string | undefined
520520
const responseBody =
521521
error && typeof error === 'object' && 'responseBody' in error
522522
? (error as { responseBody: unknown }).responseBody
523523
: undefined
524-
if (typeof responseBody === 'string') {
525-
try {
526-
const parsed: unknown = JSON.parse(responseBody)
527-
if (parsed && typeof parsed === 'object') {
528-
if ('error' in parsed && typeof (parsed as { error: unknown }).error === 'string') {
529-
errorCode = (parsed as { error: string }).error
530-
}
531-
if ('message' in parsed && typeof (parsed as { message: unknown }).message === 'string') {
532-
errorMessage = (parsed as { message: string }).message
533-
}
534-
}
535-
} catch {
536-
// responseBody wasn't valid JSON; keep original errorMessage
537-
}
524+
const { errorCode, message: parsedMessage } = parseApiErrorResponseBody(responseBody)
525+
if (parsedMessage) {
526+
errorMessage = parsedMessage
538527
}
539528

540529
resolve({

0 commit comments

Comments
 (0)