claude-code-best/src/costrict/provider/index.test.ts
IronRookieCoder b36e8e4ff2 fix(costrict): handle image input for model compatibility
## Bug 详情
CoStrict 在读取图片后会把图片放在 tool_result.content 中,OpenAI 兼容层只保留文本,导致模型没有收到真实图片内容。

非多模态模型收到图片请求时,后端会返回底层 400 错误,例如 not a multimodal model。

## 根因
OpenAI 消息转换逻辑没有处理 tool_result 中嵌套的 image block。

CoStrict 查询路径没有在发送请求前检查模型的图片输入能力,也没有归一化非多模态模型错误。

## 修复方案
将 tool_result 中的图片转换为后续 user 多模态消息,确保兼容层能传递 image_url。

在 CoStrict 模型元数据明确声明 supportsImages=false 时,提前返回英文错误提示;同时对后端非多模态错误做英文提示归一化。

## 变更要点
- Preserve image blocks nested in tool_result for OpenAI-compatible providers
- Add CoStrict image-capability gate based on supportsImages metadata
- Normalize non-multimodal backend errors to a clear English message
- Add regression tests for nested tool_result images and non-multimodal model handling

## 自测
- bun test src/costrict/provider/index.test.ts
- bun test packages/@ant/model-provider/src/shared/__tests__/openaiConvertMessages.test.ts
2026-05-19 11:38:16 +08:00

358 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { AssistantMessage, StreamEvent } from '../../types/message.js'
function makeMessageStart(
overrides: Record<string, any> = {},
): BetaRawMessageStreamEvent {
return {
type: 'message_start',
message: {
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [],
model: 'test-model',
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
},
...overrides,
},
} as any
}
function makeContentBlockStart(
index: number,
type: 'text' | 'thinking',
): BetaRawMessageStreamEvent {
return {
type: 'content_block_start',
index,
content_block:
type === 'text'
? { type: 'text', text: '' }
: { type: 'thinking', thinking: '', signature: '' },
} as any
}
function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent {
return {
type: 'content_block_delta',
index,
delta: { type: 'text_delta', text },
} as any
}
function makeThinkingDelta(
index: number,
thinking: string,
): BetaRawMessageStreamEvent {
return {
type: 'content_block_delta',
index,
delta: { type: 'thinking_delta', thinking },
} as any
}
function makeContentBlockStop(index: number): BetaRawMessageStreamEvent {
return { type: 'content_block_stop', index } as any
}
function makeMessageDelta(
stopReason: string,
outputTokens: number,
): BetaRawMessageStreamEvent {
return {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: outputTokens },
} as any
}
function makeMessageStop(): BetaRawMessageStreamEvent {
return { type: 'message_stop' } as any
}
async function* eventStream(events: BetaRawMessageStreamEvent[]) {
for (const event of events) yield event
}
let _nextEvents: BetaRawMessageStreamEvent[] = []
let _lastCreateArgs: Record<string, any> | null = null
let _mockModelMaxTokens: number | undefined
let _mockModelSupportsImages: boolean | undefined
let _mockCreateError: Error | undefined
mock.module('openai', () => ({
default: class OpenAI {
chat = {
completions: {
create: async (args: Record<string, any>) => {
_lastCreateArgs = args
if (_mockCreateError) throw _mockCreateError
return { [Symbol.asyncIterator]: async function* () {} }
},
},
}
},
}))
mock.module('@ant/model-provider', () => ({
anthropicMessagesToOpenAI: () => [],
anthropicToolsToOpenAI: () => [],
anthropicToolChoiceToOpenAI: () => undefined,
adaptOpenAIStreamToAnthropic: () => eventStream(_nextEvents),
}))
mock.module('../../utils/messages.js', () => ({
normalizeMessagesForAPI: (msgs: any) => msgs,
normalizeContentFromAPI: (blocks: any[]) => blocks,
createAssistantAPIErrorMessage: (opts: any) => ({
type: 'assistant',
message: {
content: [{ type: 'text', text: opts.content }],
apiError: opts.apiError,
},
uuid: 'error-uuid',
timestamp: new Date().toISOString(),
}),
}))
mock.module('../../utils/api.js', () => ({
toolToAPISchema: async (tool: any) => tool,
}))
mock.module('../../utils/debug.js', () => ({
logForDebugging: () => {},
}))
mock.module('../../cost-tracker.js', () => ({
addToTotalSessionCost: () => {},
}))
mock.module('../../utils/modelCost.js', () => ({
calculateUSDCost: () => 0,
}))
mock.module('../../utils/proxy.js', () => ({
getProxyFetchOptions: () => ({}),
}))
mock.module('../../utils/http.js', () => ({
getUserAgent: () => 'test-agent',
}))
mock.module('./fetch.js', () => ({
createCoStrictFetch: () => fetch,
}))
mock.module('./modelMapping.js', () => ({
resolveCoStrictModel: (model: string) => model,
}))
mock.module('./auth.js', () => ({
getCoStrictBaseURL: () => 'https://example.test',
}))
mock.module('./credentials.js', () => ({
loadCoStrictCredentials: async () => ({
access_token: 'token',
base_url: 'https://example.test',
}),
}))
mock.module('./models.js', () => ({
fetchCoStrictModels: async () => [
{
id: 'test-model',
maxTokens: _mockModelMaxTokens,
maxTokensKey: 'max_completion_tokens',
supportsImages: _mockModelSupportsImages,
},
],
}))
mock.module('../../services/api/openai/requestBody.js', () => ({
isOpenAIThinkingEnabled: (model: string) => model.includes('deepseek'),
resolveOpenAIMaxTokens: (
upperLimit: number,
maxOutputTokensOverride?: number,
) => maxOutputTokensOverride ?? upperLimit,
}))
mock.module('../../bootstrap/state.js', () => ({
getMainThreadAgentType: () => null,
getActiveSkillName: () => null,
}))
mock.module('../../utils/context.js', () => ({
getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }),
}))
async function runQueryModel(
events: BetaRawMessageStreamEvent[],
optionsOverrides: Record<string, unknown> = {},
messages: any[] = [],
) {
_nextEvents = events
const { queryModelCoStrict } = await import('./index.js')
const assistantMessages: AssistantMessage[] = []
const streamEvents: StreamEvent[] = []
const options: any = {
model: 'test-model',
tools: [],
agents: [],
querySource: 'main_loop',
...optionsOverrides,
}
for await (const item of queryModelCoStrict(
messages,
{ type: 'text', text: '' } as any,
[],
new AbortController().signal,
options,
)) {
if (item.type === 'assistant') {
assistantMessages.push(item as AssistantMessage)
} else if (item.type === 'stream_event') {
streamEvents.push(item as StreamEvent)
}
}
return { assistantMessages, streamEvents }
}
beforeEach(() => {
_nextEvents = []
_lastCreateArgs = null
_mockModelMaxTokens = undefined
_mockModelSupportsImages = undefined
_mockCreateError = undefined
})
afterEach(() => {
_nextEvents = []
_mockModelMaxTokens = undefined
_mockModelSupportsImages = undefined
_mockCreateError = undefined
})
describe('queryModelCoStrict', () => {
test('yields exactly one AssistantMessage for thinking + text content', async () => {
const events = [
makeMessageStart(),
makeContentBlockStart(0, 'thinking'),
makeThinkingDelta(0, 'let me think'),
makeContentBlockStop(0),
makeContentBlockStart(1, 'text'),
makeTextDelta(1, 'answer'),
makeContentBlockStop(1),
makeMessageDelta('end_turn', 12),
makeMessageStop(),
]
const { assistantMessages } = await runQueryModel(events)
expect(assistantMessages).toHaveLength(1)
expect(assistantMessages[0]!.message.stop_reason).toBe('end_turn')
expect(
(assistantMessages[0]!.message.content as any[]).map(block => block.type),
).toEqual(['thinking', 'text'])
})
test('preserves explicit max token override over model metadata default', async () => {
_mockModelMaxTokens = 16384
const events = [makeMessageStart(), makeMessageStop()]
await runQueryModel(events, { maxOutputTokensOverride: 2048 })
expect(_lastCreateArgs).not.toBeNull()
expect(_lastCreateArgs!.max_completion_tokens).toBe(2048)
})
test('returns a clear error before sending images to non-multimodal model', async () => {
_mockModelSupportsImages = false
const messages = [
{
type: 'user',
uuid: 'user-img',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu_img',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
],
},
],
},
},
]
const { assistantMessages } = await runQueryModel([], {}, messages)
expect(_lastCreateArgs).toBeNull()
expect(assistantMessages).toHaveLength(1)
expect((assistantMessages[0]!.message.content as any[])[0].text).toContain(
'does not support image input',
)
})
test('allows images when model metadata declares multimodal support', async () => {
_mockModelSupportsImages = true
const events = [makeMessageStart(), makeMessageStop()]
const messages = [
{
type: 'user',
uuid: 'user-img',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/png',
data: 'iVBORw0KGgo=',
},
},
],
},
},
]
await runQueryModel(events, {}, messages)
expect(_lastCreateArgs).not.toBeNull()
})
test('normalizes backend non-multimodal model errors', async () => {
_mockCreateError = new Error('/mnt/model is not a multimodal model')
const { assistantMessages } = await runQueryModel([], {})
expect(assistantMessages).toHaveLength(1)
expect((assistantMessages[0]!.message.content as any[])[0].text).toBe(
'CoStrict API Error: The current model does not support image input. Switch to a multimodal or vision-capable model and try again.',
)
})
})