mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +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");
|
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
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user