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:
程序员阿江(Relakkes)
2026-09-14 15:44:22 +08:00
parent 0e7cecd81f
commit 993eb7631d
5 changed files with 239 additions and 1 deletions
+5 -1
View File
@@ -1413,7 +1413,11 @@ async function* queryModel(
}); });
queryCheckpoint("query_message_normalization_start"); queryCheckpoint("query_message_normalization_start");
let messagesForAPI = normalizeMessagesForAPI(messages, filteredTools); let messagesForAPI = normalizeMessagesForAPI(
messages,
filteredTools,
options.model,
);
queryCheckpoint("query_message_normalization_end"); queryCheckpoint("query_message_normalization_end");
// Model-specific post-processing: strip tool-search-specific fields if the // Model-specific post-processing: strip tool-search-specific fields if the
+99
View File
@@ -35,11 +35,110 @@ describe('image unsupported API errors', () => {
expect(msg.isApiErrorMessage).toBe(true) expect(msg.isApiErrorMessage).toBe(true)
expect(msg.businessErrorCode).toBe(BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED) expect(msg.businessErrorCode).toBe(BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED)
expect(msg.errorDetails).toBe('This model does not support image blocks') 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({ expect(msg.message.content[0]).toMatchObject({
type: 'text', type: 'text',
text: getImageUnsupportedErrorMessage(), 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', () => { describe('context overflow errors', () => {
+44
View File
@@ -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( export function getAssistantMessageFromError(
error: unknown, error: unknown,
model: string, model: string,
@@ -537,6 +556,7 @@ export function getAssistantMessageFromError(
error: 'invalid_request', error: 'invalid_request',
errorDetails: error.message, errorDetails: error.message,
businessErrorCode: BUSINESS_ERROR_CODES.IMAGE_UNSUPPORTED, 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 // Connection errors (non-timeout) — use formatAPIError for detailed messages
if (error instanceof APIConnectionError) { if (error instanceof APIConnectionError) {
return createAssistantAPIErrorMessage({ return createAssistantAPIErrorMessage({
+20
View File
@@ -377,6 +377,7 @@ function baseCreateAssistantMessage({
error, error,
errorDetails, errorDetails,
businessErrorCode, businessErrorCode,
sourceModel,
isVirtual, isVirtual,
usage = { usage = {
input_tokens: 0, input_tokens: 0,
@@ -400,6 +401,7 @@ function baseCreateAssistantMessage({
error?: SDKAssistantMessageError error?: SDKAssistantMessageError
errorDetails?: string errorDetails?: string
businessErrorCode?: BusinessErrorCode businessErrorCode?: BusinessErrorCode
sourceModel?: string
isVirtual?: true isVirtual?: true
usage?: Usage usage?: Usage
}): AssistantMessage { }): AssistantMessage {
@@ -424,6 +426,7 @@ function baseCreateAssistantMessage({
error, error,
errorDetails, errorDetails,
businessErrorCode, businessErrorCode,
sourceModel,
isApiErrorMessage, isApiErrorMessage,
isVirtual, isVirtual,
} }
@@ -459,12 +462,14 @@ export function createAssistantAPIErrorMessage({
error, error,
errorDetails, errorDetails,
businessErrorCode, businessErrorCode,
sourceModel,
}: { }: {
content: string content: string
apiError?: AssistantMessage['apiError'] apiError?: AssistantMessage['apiError']
error?: SDKAssistantMessageError error?: SDKAssistantMessageError
errorDetails?: string errorDetails?: string
businessErrorCode?: BusinessErrorCode businessErrorCode?: BusinessErrorCode
sourceModel?: string
}): AssistantMessage { }): AssistantMessage {
return baseCreateAssistantMessage({ return baseCreateAssistantMessage({
content: [ content: [
@@ -478,6 +483,7 @@ export function createAssistantAPIErrorMessage({
error, error,
errorDetails, errorDetails,
businessErrorCode, businessErrorCode,
sourceModel,
}) })
} }
@@ -2016,6 +2022,7 @@ function relocateToolReferenceSiblings(
export function normalizeMessagesForAPI( export function normalizeMessagesForAPI(
messages: Message[], messages: Message[],
tools: Tools = [], tools: Tools = [],
currentModel?: string,
): (UserMessage | AssistantMessage)[] { ): (UserMessage | AssistantMessage)[] {
// Build set of available tool names for filtering unavailable tool references // Build set of available tool names for filtering unavailable tool references
const availableToolNames = new Set(tools.map(t => t.name)) const availableToolNames = new Set(tools.map(t => t.name))
@@ -2047,6 +2054,19 @@ export function normalizeMessagesForAPI(
if (!isSyntheticApiErrorMessage(msg)) { if (!isSyntheticApiErrorMessage(msg)) {
continue 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 let blockTypesToStrip: Set<string> | undefined
const blockTypesFromCode = const blockTypesFromCode =
typeof msg.businessErrorCode === 'string' typeof msg.businessErrorCode === 'string'
+71
View File
@@ -70,6 +70,77 @@ describe('media error recovery', () => {
expect(serialized).toContain('describe this screenshot') expect(serialized).toContain('describe this screenshot')
expect(serialized).toContain('continue with text only') 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', () => { describe('media context estimation', () => {