mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 03:43:11 +08:00
fix(api): keep provider image rejections from poisoning the session
When a text-only model rejects an image with wording the classifier doesn't recognize, the 400 fell through to a generic API error with no businessErrorCode, so normalizeMessagesForAPI never stripped the image and every later turn on that model re-failed the same way. And when the wording did match, the strip anchor applied forever — switching to a vision-capable model still replayed history with the image removed. Classify any 400/422 on a request that actually carried image blocks as image_unsupported when no more specific classifier matched, and gate the error-anchored strip to the model that produced the error (sourceModel): the same model keeps stripping and heals, a different model replays the image, and a misclassified anchor only ever affects its own model.
This commit is contained in:
@@ -1413,7 +1413,11 @@ async function* queryModel(
|
||||
});
|
||||
|
||||
queryCheckpoint("query_message_normalization_start");
|
||||
let messagesForAPI = normalizeMessagesForAPI(messages, filteredTools);
|
||||
let messagesForAPI = normalizeMessagesForAPI(
|
||||
messages,
|
||||
filteredTools,
|
||||
options.model,
|
||||
);
|
||||
queryCheckpoint("query_message_normalization_end");
|
||||
|
||||
// Model-specific post-processing: strip tool-search-specific fields if the
|
||||
|
||||
@@ -35,11 +35,110 @@ describe('image unsupported API errors', () => {
|
||||
expect(msg.isApiErrorMessage).toBe(true)
|
||||
expect(msg.businessErrorCode).toBe(BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED)
|
||||
expect(msg.errorDetails).toBe('This model does not support image blocks')
|
||||
expect(msg.sourceModel).toBe('mimo-v2.5-pro')
|
||||
expect(msg.message.content[0]).toMatchObject({
|
||||
type: 'text',
|
||||
text: getImageUnsupportedErrorMessage(),
|
||||
})
|
||||
})
|
||||
|
||||
test('falls back to image_unsupported when a 400 with unrecognized wording hit a request carrying images', () => {
|
||||
const message = 'unsupported content block type: only text is allowed for this model'
|
||||
const error = new APIError(
|
||||
400,
|
||||
{
|
||||
type: 'error',
|
||||
error: { type: 'invalid_request_error', message },
|
||||
},
|
||||
message,
|
||||
undefined,
|
||||
)
|
||||
const messagesForAPI = [
|
||||
{
|
||||
type: 'user' as const,
|
||||
message: {
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{ type: 'text', text: 'look at this' },
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'AAA' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// The wording alone must not match the text classifier, otherwise this
|
||||
// test stops exercising the request-context fallback.
|
||||
expect(isUnsupportedImageInputErrorMessage(message)).toBe(false)
|
||||
|
||||
const msg = getAssistantMessageFromError(error, 'deepseek-v4-pro', {
|
||||
messagesForAPI: messagesForAPI as never,
|
||||
})
|
||||
|
||||
expect(msg.isApiErrorMessage).toBe(true)
|
||||
expect(msg.businessErrorCode).toBe(BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED)
|
||||
expect(msg.sourceModel).toBe('deepseek-v4-pro')
|
||||
})
|
||||
|
||||
test('does not fall back to image_unsupported when the failed request carried no images', () => {
|
||||
const message = 'unsupported content block type: only text is allowed for this model'
|
||||
const error = new APIError(
|
||||
400,
|
||||
{
|
||||
type: 'error',
|
||||
error: { type: 'invalid_request_error', message },
|
||||
},
|
||||
message,
|
||||
undefined,
|
||||
)
|
||||
const messagesForAPI = [
|
||||
{
|
||||
type: 'user' as const,
|
||||
message: { role: 'user' as const, content: 'plain text only' },
|
||||
},
|
||||
]
|
||||
|
||||
const msg = getAssistantMessageFromError(error, 'deepseek-v4-pro', {
|
||||
messagesForAPI: messagesForAPI as never,
|
||||
})
|
||||
|
||||
expect(msg.isApiErrorMessage).toBe(true)
|
||||
expect(msg.businessErrorCode).toBeUndefined()
|
||||
})
|
||||
|
||||
test('does not fall back for non-400/422 API errors even when images were sent', () => {
|
||||
const error = new APIError(
|
||||
500,
|
||||
{
|
||||
type: 'error',
|
||||
error: { type: 'api_error', message: 'internal error' },
|
||||
},
|
||||
'internal error',
|
||||
undefined,
|
||||
)
|
||||
const messagesForAPI = [
|
||||
{
|
||||
type: 'user' as const,
|
||||
message: {
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'AAA' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const msg = getAssistantMessageFromError(error, 'deepseek-v4-pro', {
|
||||
messagesForAPI: messagesForAPI as never,
|
||||
})
|
||||
|
||||
expect(msg.businessErrorCode).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('context overflow errors', () => {
|
||||
|
||||
@@ -493,6 +493,25 @@ function isOpenAIImageUrlTextOnlySchemaError(raw: string): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Deep-walk a request payload looking for image blocks. Images can sit at the
|
||||
// top level of a user message or nested inside a tool_result's content, and
|
||||
// the payloads here are { type, message: { content } } wrappers, so walk all
|
||||
// object values rather than only `content`.
|
||||
function messagesContainImageBlock(messages: readonly unknown[]): boolean {
|
||||
const walk = (value: unknown): boolean => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(walk)
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>
|
||||
if (record.type === 'image') return true
|
||||
return Object.values(record).some(walk)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return walk(messages)
|
||||
}
|
||||
|
||||
export function getAssistantMessageFromError(
|
||||
error: unknown,
|
||||
model: string,
|
||||
@@ -537,6 +556,7 @@ export function getAssistantMessageFromError(
|
||||
error: 'invalid_request',
|
||||
errorDetails: error.message,
|
||||
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
||||
sourceModel: model,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1017,6 +1037,30 @@ export function getAssistantMessageFromError(
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback for image rejections with unrecognized wording. The wording
|
||||
// classifier above can't enumerate every provider/gateway phrasing, and a
|
||||
// miss used to poison the session: the rejected image stayed in history and
|
||||
// every later turn re-failed with the same 400. When a 400/422 lands on a
|
||||
// request that actually carried image blocks and no more specific classifier
|
||||
// matched (context overflow, PDF, image size, auth, 404 all handled above),
|
||||
// treat it as an image rejection so the image is stripped from later turns.
|
||||
// The strip is gated to sourceModel, so a false positive only affects the
|
||||
// exact model that failed — switching models replays the image.
|
||||
if (
|
||||
error instanceof APIError &&
|
||||
(error.status === 400 || error.status === 422) &&
|
||||
options?.messagesForAPI &&
|
||||
messagesContainImageBlock(options.messagesForAPI)
|
||||
) {
|
||||
return createAssistantAPIErrorMessage({
|
||||
content: getImageUnsupportedErrorMessage(),
|
||||
error: 'invalid_request',
|
||||
errorDetails: error.message,
|
||||
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
||||
sourceModel: model,
|
||||
})
|
||||
}
|
||||
|
||||
// Connection errors (non-timeout) — use formatAPIError for detailed messages
|
||||
if (error instanceof APIConnectionError) {
|
||||
return createAssistantAPIErrorMessage({
|
||||
|
||||
@@ -377,6 +377,7 @@ function baseCreateAssistantMessage({
|
||||
error,
|
||||
errorDetails,
|
||||
businessErrorCode,
|
||||
sourceModel,
|
||||
isVirtual,
|
||||
usage = {
|
||||
input_tokens: 0,
|
||||
@@ -400,6 +401,7 @@ function baseCreateAssistantMessage({
|
||||
error?: SDKAssistantMessageError
|
||||
errorDetails?: string
|
||||
businessErrorCode?: BusinessErrorCode
|
||||
sourceModel?: string
|
||||
isVirtual?: true
|
||||
usage?: Usage
|
||||
}): AssistantMessage {
|
||||
@@ -424,6 +426,7 @@ function baseCreateAssistantMessage({
|
||||
error,
|
||||
errorDetails,
|
||||
businessErrorCode,
|
||||
sourceModel,
|
||||
isApiErrorMessage,
|
||||
isVirtual,
|
||||
}
|
||||
@@ -459,12 +462,14 @@ export function createAssistantAPIErrorMessage({
|
||||
error,
|
||||
errorDetails,
|
||||
businessErrorCode,
|
||||
sourceModel,
|
||||
}: {
|
||||
content: string
|
||||
apiError?: AssistantMessage['apiError']
|
||||
error?: SDKAssistantMessageError
|
||||
errorDetails?: string
|
||||
businessErrorCode?: BusinessErrorCode
|
||||
sourceModel?: string
|
||||
}): AssistantMessage {
|
||||
return baseCreateAssistantMessage({
|
||||
content: [
|
||||
@@ -478,6 +483,7 @@ export function createAssistantAPIErrorMessage({
|
||||
error,
|
||||
errorDetails,
|
||||
businessErrorCode,
|
||||
sourceModel,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2016,6 +2022,7 @@ function relocateToolReferenceSiblings(
|
||||
export function normalizeMessagesForAPI(
|
||||
messages: Message[],
|
||||
tools: Tools = [],
|
||||
currentModel?: string,
|
||||
): (UserMessage | AssistantMessage)[] {
|
||||
// Build set of available tool names for filtering unavailable tool references
|
||||
const availableToolNames = new Set(tools.map(t => t.name))
|
||||
@@ -2047,6 +2054,19 @@ export function normalizeMessagesForAPI(
|
||||
if (!isSyntheticApiErrorMessage(msg)) {
|
||||
continue
|
||||
}
|
||||
// Error-anchored stripping is scoped to the model that produced the
|
||||
// error. After the user switches to a different (e.g. vision-capable)
|
||||
// model, the rejected media must replay normally instead of staying
|
||||
// stripped forever. Anchors without a sourceModel (legacy transcripts)
|
||||
// keep the historical strip behavior.
|
||||
const anchorModel = (msg as { sourceModel?: unknown }).sourceModel
|
||||
if (
|
||||
currentModel &&
|
||||
typeof anchorModel === 'string' &&
|
||||
anchorModel !== currentModel
|
||||
) {
|
||||
continue
|
||||
}
|
||||
let blockTypesToStrip: Set<string> | undefined
|
||||
const blockTypesFromCode =
|
||||
typeof msg.businessErrorCode === 'string'
|
||||
|
||||
@@ -70,6 +70,77 @@ describe('media error recovery', () => {
|
||||
expect(serialized).toContain('describe this screenshot')
|
||||
expect(serialized).toContain('continue with text only')
|
||||
})
|
||||
|
||||
test('keeps stripping while the failing model is still selected', () => {
|
||||
const imageUser = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this screenshot' },
|
||||
imageBlock('base64-image-payload'),
|
||||
],
|
||||
uuid: '00000000-0000-4000-8000-000000000005',
|
||||
})
|
||||
const unsupported = createAssistantAPIErrorMessage({
|
||||
content: 'localized display text',
|
||||
error: 'invalid_request',
|
||||
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
||||
sourceModel: 'nonvision-model',
|
||||
})
|
||||
|
||||
const normalized = normalizeMessagesForAPI(
|
||||
[imageUser, unsupported],
|
||||
[],
|
||||
'nonvision-model',
|
||||
)
|
||||
|
||||
expect(JSON.stringify(normalized)).not.toContain('base64-image-payload')
|
||||
})
|
||||
|
||||
test('replays the image after switching to a different model', () => {
|
||||
const imageUser = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this screenshot' },
|
||||
imageBlock('base64-image-payload'),
|
||||
],
|
||||
uuid: '00000000-0000-4000-8000-000000000006',
|
||||
})
|
||||
const unsupported = createAssistantAPIErrorMessage({
|
||||
content: 'localized display text',
|
||||
error: 'invalid_request',
|
||||
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
||||
sourceModel: 'nonvision-model',
|
||||
})
|
||||
|
||||
const normalized = normalizeMessagesForAPI(
|
||||
[imageUser, unsupported],
|
||||
[],
|
||||
'vision-model',
|
||||
)
|
||||
|
||||
expect(JSON.stringify(normalized)).toContain('base64-image-payload')
|
||||
})
|
||||
|
||||
test('legacy anchors without sourceModel strip regardless of current model', () => {
|
||||
const imageUser = createUserMessage({
|
||||
content: [
|
||||
{ type: 'text', text: 'describe this screenshot' },
|
||||
imageBlock('base64-image-payload'),
|
||||
],
|
||||
uuid: '00000000-0000-4000-8000-000000000007',
|
||||
})
|
||||
const unsupported = createAssistantAPIErrorMessage({
|
||||
content: 'localized display text',
|
||||
error: 'invalid_request',
|
||||
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED,
|
||||
})
|
||||
|
||||
const normalized = normalizeMessagesForAPI(
|
||||
[imageUser, unsupported],
|
||||
[],
|
||||
'vision-model',
|
||||
)
|
||||
|
||||
expect(JSON.stringify(normalized)).not.toContain('base64-image-payload')
|
||||
})
|
||||
})
|
||||
|
||||
describe('media context estimation', () => {
|
||||
|
||||
Reference in New Issue
Block a user