claude-code-best/src/costrict/provider/index.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

382 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* CoStrict 查询入口
* 复用 OpenAI 兼容路径,注入 CoStrict 自定义 fetch 和 baseURL
*/
import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
import type { SDKAssistantMessageError } from '../../entrypoints/agentSdkTypes.js'
import type { SystemPrompt } from '../../utils/systemPromptType.js'
import type {
Message,
StreamEvent,
SystemAPIErrorMessage,
AssistantMessage,
} from '../../types/message.js'
import type { Tools } from '../../Tool.js'
import type { Options } from '../../services/api/claude.js'
import OpenAI from 'openai'
import { getProxyFetchOptions } from '../../utils/proxy.js'
import { getUserAgent } from '../../utils/http.js'
import {
anthropicMessagesToOpenAI,
anthropicToolsToOpenAI,
anthropicToolChoiceToOpenAI,
adaptOpenAIStreamToAnthropic,
} from '@ant/model-provider'
import { normalizeMessagesForAPI } from '../../utils/messages.js'
import { toolToAPISchema } from '../../utils/api.js'
import { logForDebugging } from '../../utils/debug.js'
import { addToTotalSessionCost } from '../../cost-tracker.js'
import { calculateUSDCost } from '../../utils/modelCost.js'
import {
createAssistantAPIErrorMessage,
normalizeContentFromAPI,
} from '../../utils/messages.js'
import { randomUUID } from 'crypto'
import { createCoStrictFetch } from './fetch.js'
import { resolveCoStrictModel } from './modelMapping.js'
import { getCoStrictBaseURL } from './auth.js'
import { loadCoStrictCredentials } from './credentials.js'
import {
isOpenAIThinkingEnabled,
resolveOpenAIMaxTokens,
} from '../../services/api/openai/requestBody.js'
import { fetchCoStrictModels, type CoStrictModel } from './models.js'
import {
getMainThreadAgentType,
getActiveSkillName,
} from '../../bootstrap/state.js'
import { getModelMaxOutputTokens } from '../../utils/context.js'
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object'
}
function contentContainsImage(content: unknown): boolean {
if (!Array.isArray(content)) return false
return content.some(block => {
if (!isRecord(block)) return false
if (block.type === 'image') return true
if (block.type === 'tool_result') {
return contentContainsImage(block.content)
}
return false
})
}
function messagesContainImages(messages: Message[]): boolean {
return messages.some(message => {
if (message.type !== 'user') return false
if (!message.message) return false
return contentContainsImage(message.message.content)
})
}
function isNonMultimodalModelError(message: string): boolean {
const normalized = message.toLowerCase()
return (
normalized.includes('not a multimodal model') ||
normalized.includes('does not support image') ||
normalized.includes('does not support images')
)
}
/**
* CoStrict 查询路径
* 与 queryModelOpenAI 结构相同,使用 CoStrict 自定义 fetch 和 baseURL
*/
export async function* queryModelCoStrict(
messages: Message[],
systemPrompt: SystemPrompt,
tools: Tools,
signal: AbortSignal,
options: Options,
): AsyncGenerator<
StreamEvent | AssistantMessage | SystemAPIErrorMessage,
void
> {
try {
// 1. 解析模型名
const costrictModel = resolveCoStrictModel(options.model)
// 2. 获取 CoStrict base URL
const creds = await loadCoStrictCredentials()
const baseUrl = getCoStrictBaseURL(creds?.base_url)
const chatBaseURL = `${baseUrl}/chat-rag/api/v1`
// 3. 从模型列表获取 maxTokens 相关参数
let defaultMaxTokens = getModelMaxOutputTokens(costrictModel).upperLimit
let maxTokensParamKey: string = 'max_tokens'
let modelInfo: CoStrictModel | undefined
if (creds?.access_token) {
try {
const modelList = await fetchCoStrictModels(baseUrl, creds.access_token)
modelInfo = modelList.find(m => m.id === costrictModel)
if (modelInfo) {
maxTokensParamKey = modelInfo.maxTokensKey || 'max_tokens'
if (modelInfo.maxTokens != null) {
defaultMaxTokens = modelInfo.maxTokens
}
}
} catch {
// 获取模型列表失败,使用默认值
}
}
const maxTokensValue = resolveOpenAIMaxTokens(
defaultMaxTokens,
options.maxOutputTokensOverride,
)
// 4. 规范化消息
const messagesForAPI = normalizeMessagesForAPI(messages, tools)
if (
modelInfo?.supportsImages === false &&
messagesContainImages(messagesForAPI)
) {
yield createAssistantAPIErrorMessage({
content: `CoStrict API Error: Model ${costrictModel} does not support image input. Switch to a multimodal or vision-capable model and try again.`,
apiError: 'api_error',
})
return
}
// 5. 构建工具 schema
const toolSchemas = await Promise.all(
tools.map(tool =>
toolToAPISchema(tool, {
getToolPermissionContext: options.getToolPermissionContext,
tools,
agents: options.agents,
allowedAgentTypes: options.allowedAgentTypes,
model: options.model,
}),
),
)
const standardTools = toolSchemas.filter(
(t): t is BetaToolUnion & { type: string } => {
const anyT = t as unknown as Record<string, unknown>
return (
anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124'
)
},
)
// 6. 转换为 OpenAI 格式
// 根据模型名称自动检测是否启用thinking模式
const enableThinking = isOpenAIThinkingEnabled(costrictModel)
const openaiMessages = anthropicMessagesToOpenAI(
messagesForAPI,
systemPrompt,
{ enableThinking },
)
const openaiTools = anthropicToolsToOpenAI(standardTools)
const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice)
// 7. 创建专用的 CoStrict OpenAI 客户端(不缓存,每次使用新的 fetch
const costrictFetch = createCoStrictFetch({
agentType: getMainThreadAgentType() ?? getActiveSkillName(),
})
const client = new OpenAI({
apiKey: 'costrict-managed', // 实际 token 由 createCoStrictFetch 注入
baseURL: chatBaseURL,
maxRetries: 0,
timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10),
dangerouslyAllowBrowser: true,
fetchOptions: getProxyFetchOptions({
forAnthropicAPI: false,
}) as any,
fetch: costrictFetch as any,
defaultHeaders: { 'User-Agent': getUserAgent() },
})
logForDebugging(
`[CoStrict] model=${costrictModel}, baseURL=${chatBaseURL}, messages=${openaiMessages.length}, tools=${openaiTools.length}`,
)
// 8. 调用 API流式
const requestBody: Record<string, unknown> = {
model: costrictModel,
messages: openaiMessages,
...(openaiTools.length > 0 && {
tools: openaiTools,
...(openaiToolChoice && {
tool_choice:
openaiToolChoice as OpenAI.Chat.Completions.ChatCompletionToolChoiceOption,
}),
}),
stream: true,
stream_options: { include_usage: true },
[maxTokensParamKey]: maxTokensValue,
...(enableThinking && {
thinking: { type: 'enabled' },
enable_thinking: true,
chat_template_kwargs: { thinking: true },
}),
...(!enableThinking &&
options.temperatureOverride !== undefined && {
temperature: options.temperatureOverride,
}),
}
const stream = await client.chat.completions.create(
requestBody as unknown as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming,
{ signal },
)
// 9. 转换流并 yield 事件
const adaptedStream = adaptOpenAIStreamToAnthropic(stream, costrictModel)
const contentBlocks: Record<number, any> = {}
let partialMessage: any
let stopReason: string | null = null
let usage = {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}
let ttftMs = 0
const start = Date.now()
const assembleFinalAssistantOutputs = (): (
| AssistantMessage
| SystemAPIErrorMessage
)[] => {
const outputs: (AssistantMessage | SystemAPIErrorMessage)[] = []
if (!partialMessage) return outputs
const allBlocks = Object.keys(contentBlocks)
.sort((a, b) => Number(a) - Number(b))
.map(k => contentBlocks[Number(k)])
.filter(Boolean)
if (allBlocks.length > 0) {
outputs.push({
message: {
...partialMessage,
content: normalizeContentFromAPI(allBlocks, tools, options.agentId),
usage,
stop_reason: stopReason,
stop_sequence: null,
},
requestId: undefined,
type: 'assistant',
uuid: randomUUID(),
timestamp: new Date().toISOString(),
} as AssistantMessage)
}
if (stopReason === 'max_tokens') {
outputs.push(
createAssistantAPIErrorMessage({
content:
`Output truncated: response exceeded the ${maxTokensValue} token limit. ` +
`Set OPENAI_MAX_TOKENS or CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`,
apiError: 'max_output_tokens',
error: 'max_output_tokens',
}),
)
}
return outputs
}
for await (const event of adaptedStream) {
switch (event.type) {
case 'message_start': {
partialMessage = (event as any).message
ttftMs = Date.now() - start
if ((event as any).message?.usage) {
usage = { ...usage, ...(event as any).message.usage }
}
break
}
case 'content_block_start': {
const idx = (event as any).index
const cb = (event as any).content_block
if (cb.type === 'tool_use') {
contentBlocks[idx] = { ...cb, input: '' }
} else if (cb.type === 'text') {
contentBlocks[idx] = { ...cb, text: '' }
} else if (cb.type === 'thinking') {
contentBlocks[idx] = { ...cb, thinking: '', signature: '' }
} else {
contentBlocks[idx] = { ...cb }
}
break
}
case 'content_block_delta': {
const idx = (event as any).index
const delta = (event as any).delta
const block = contentBlocks[idx]
if (!block) break
if (delta.type === 'text_delta') {
block.text = (block.text || '') + delta.text
} else if (delta.type === 'input_json_delta') {
block.input = (block.input || '') + delta.partial_json
} else if (delta.type === 'thinking_delta') {
block.thinking = (block.thinking || '') + delta.thinking
} else if (delta.type === 'signature_delta') {
block.signature = delta.signature
}
break
}
case 'content_block_stop': {
// Block accumulation is complete; emit one AssistantMessage at
// message_stop so reasoning/text/tool blocks stay in a single turn.
break
}
case 'message_delta': {
const deltaUsage = (event as any).usage
if (deltaUsage) usage = { ...usage, ...deltaUsage }
if ((event as any).delta?.stop_reason != null) {
stopReason = (event as any).delta.stop_reason
}
break
}
case 'message_stop': {
if (partialMessage) {
for (const output of assembleFinalAssistantOutputs()) {
yield output
}
partialMessage = null
}
break
}
}
if (
event.type === 'message_stop' &&
usage.input_tokens + usage.output_tokens > 0
) {
const costUSD = calculateUSDCost(costrictModel, usage as any)
addToTotalSessionCost(costUSD, usage as any, options.model)
}
yield {
type: 'stream_event',
event,
...(event.type === 'message_start' ? { ttftMs } : undefined),
} as StreamEvent
}
if (partialMessage) {
for (const output of assembleFinalAssistantOutputs()) {
yield output
}
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error)
logForDebugging(`[CoStrict] Error: ${errorMsg}`, { level: 'error' })
yield createAssistantAPIErrorMessage({
content: isNonMultimodalModelError(errorMsg)
? 'CoStrict API Error: The current model does not support image input. Switch to a multimodal or vision-capable model and try again.'
: `CoStrict API Error: ${errorMsg}`,
apiError: 'api_error',
error:
error instanceof Error
? (error as unknown as SDKAssistantMessageError)
: undefined,
})
}
}