fix model tester attachment replay and capability gating

This commit is contained in:
cita
2026-03-20 22:56:01 +08:00
parent 8c5ecc26d1
commit 4369aeb1cb
9 changed files with 646 additions and 63 deletions
+131 -10
View File
@@ -1,6 +1,7 @@
import {
decodeAnthropicReasoningSignature,
} from './reasoningTransport.js';
import { toOpenAiChatFileBlock } from './inputFile.js';
import {
consumeThinkTaggedText,
createThinkTagParserState,
@@ -86,6 +87,10 @@ function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function pickFiniteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
@@ -464,6 +469,111 @@ function collectToolCallsFromResponsesPayload(payload: Record<string, unknown>):
return toolCalls;
}
function formatAnthropicBase64DataUrl(mimeType: string, data: string): string {
return `data:${mimeType};base64,${data}`;
}
function parseAnthropicBase64Source(
source: unknown,
): { mimeType: string; data: string } | null {
if (!isRecord(source)) return null;
const sourceType = asTrimmedString(source.type).toLowerCase();
if (sourceType !== 'base64') return null;
const mimeType = (
asTrimmedString(source.media_type)
|| asTrimmedString(source.mime_type)
|| asTrimmedString(source.mediaType)
|| asTrimmedString(source.mimeType)
|| 'application/octet-stream'
);
const data = asTrimmedString(source.data);
if (!data) return null;
return { mimeType, data };
}
function parseAnthropicUrlSource(
source: unknown,
): { url: string; mimeType: string | null } | null {
if (!isRecord(source)) return null;
const sourceType = asTrimmedString(source.type).toLowerCase();
if (sourceType !== 'url') return null;
const url = asTrimmedString(source.url);
if (!url) return null;
const mimeType = (
asTrimmedString(source.media_type)
|| asTrimmedString(source.mime_type)
|| asTrimmedString(source.mediaType)
|| asTrimmedString(source.mimeType)
|| null
);
return { url, mimeType };
}
function toOpenAiContentBlockFromClaudeBlock(
block: Record<string, unknown>,
): Record<string, unknown> | null {
const blockType = asTrimmedString(block.type).toLowerCase();
if (blockType === 'text') {
const text = asTrimmedString(block.text);
return text ? { type: 'text', text } : null;
}
if (blockType !== 'image' && blockType !== 'document') {
return null;
}
const title = asTrimmedString(block.title);
const base64Source = parseAnthropicBase64Source(block.source);
const urlSource = parseAnthropicUrlSource(block.source);
const mimeType = (base64Source?.mimeType || urlSource?.mimeType || '').toLowerCase();
const treatAsImage = blockType === 'image' || mimeType.startsWith('image/');
if (treatAsImage) {
const imageUrl = base64Source
? formatAnthropicBase64DataUrl(base64Source.mimeType, base64Source.data)
: (urlSource?.url || '');
return imageUrl
? {
type: 'image_url',
image_url: { url: imageUrl },
}
: null;
}
if (base64Source) {
return toOpenAiChatFileBlock({
fileData: base64Source.data,
filename: title || undefined,
mimeType: base64Source.mimeType,
});
}
if (urlSource) {
return toOpenAiChatFileBlock({
fileUrl: urlSource.url,
filename: title || undefined,
mimeType: urlSource.mimeType,
});
}
return null;
}
function collapseOpenAiContentBlocks(
blocks: Array<Record<string, unknown>>,
): string | Array<Record<string, unknown>> | null {
if (blocks.length <= 0) return null;
const textOnly = blocks.every((block) => block.type === 'text' && typeof block.text === 'string');
if (!textOnly) return blocks;
const text = blocks
.map((block) => (typeof block.text === 'string' ? block.text : ''))
.join('\n\n')
.trim();
return text || null;
}
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
model: string;
stream: boolean;
@@ -521,14 +631,14 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
// Claude tool blocks need explicit OpenAI mapping:
// - assistant.tool_use -> assistant.tool_calls
// - user.tool_result -> tool messages with tool_call_id
const textParts: string[] = [];
const contentBlocks: Array<Record<string, unknown>> = [];
const toolCalls: Array<Record<string, unknown>> = [];
const flushTextAsMessage = () => {
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
textParts.length = 0;
if (!merged) return;
messages.push({ role: mappedRole, content: merged });
const flushContentAsMessage = () => {
const contentPayload = collapseOpenAiContentBlocks(contentBlocks);
contentBlocks.length = 0;
if (contentPayload === null) return;
messages.push({ role: mappedRole, content: contentPayload });
};
for (const block of content) {
@@ -536,7 +646,7 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
const blockType = typeof block.type === 'string' ? block.type : '';
if (blockType === 'tool_result') {
flushTextAsMessage();
flushContentAsMessage();
appendToolResultMessage(block.tool_use_id, block.content);
continue;
}
@@ -564,11 +674,22 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
continue;
}
const contentBlock = toOpenAiContentBlockFromClaudeBlock(block);
if (contentBlock) {
contentBlocks.push(contentBlock);
continue;
}
const text = parseClaudeMessageContent(block);
if (text) textParts.push(text);
if (text) {
contentBlocks.push({
type: 'text',
text,
});
}
}
const merged = textParts.map((item) => item.trim()).filter(Boolean).join('\n\n');
const merged = collapseOpenAiContentBlocks(contentBlocks);
if (toolCalls.length > 0) {
const assistantMessage: Record<string, unknown> = {
role: 'assistant',
@@ -577,7 +698,7 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
// Keep textual assistant preface when present.
assistantMessage.content = merged || '';
messages.push(assistantMessage);
} else if (merged) {
} else if (merged !== null) {
messages.push({
role: mappedRole,
content: merged,