refactor(openai-shim): extract XML and response conversion (#2007)

* refactor(openai-shim): extract XML and response conversion

* fix(openai-shim): align facade tests with XML extraction

* fix(openai-shim): restore shared XML tool-call sequencing

Wire parseXmlToolCalls through the façade sequence counter, reuse the
shared extractBalancedJson helper, and restore production dependency
coverage in conversion and façade tests.

* fix(openai-shim): restore façade coverage and require XML sequencer

Restore non-streaming convert and HY3 JSON-fallback e2e seams so façade
dependency wiring stays exercised, require an injected XML id sequencer,
and assert consecutive shared-sequence ids.

* fix(openai-shim): restore provider coverage and array content guard

Keep relocated openaiShim suite tests in test:provider, and reject
non-object array content parts the same way the inline converter did.

* fix(openai-shim): preserve mixed XML/HY3 tool-call order

Sort HY3 and standard XML candidates by source offset before ID
assignment, isolate focused test sequencers, and restore fetch after
the Gemini non-streaming façade test.
This commit is contained in:
JATMN
2026-07-28 13:44:56 +08:00
committed by GitHub
parent 83440a6fe6
commit ca29d4454f
7 changed files with 708 additions and 440 deletions
+1 -1
View File
@@ -68,7 +68,7 @@
"install:verify": "bun run scripts/verify-clean-install.ts",
"install:verify:published": "bun run scripts/verify-clean-install.ts --published",
"build:verified": "bun run build && bun run verify:privacy",
"test:provider": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1 src/services/api/*.test.ts src/utils/context.test.ts",
"test:provider": "bun test --feature=UNATTENDED_RETRY --max-concurrency=1 src/services/api/*.test.ts src/services/api/openaiShim/*.test.ts src/utils/context.test.ts",
"doctor:runtime": "bun run scripts/system-check.ts",
"doctor:runtime:json": "bun run scripts/system-check.ts --json",
"doctor:report": "bun run scripts/system-check.ts --out reports/doctor-runtime.json",
+62 -49
View File
@@ -14,7 +14,12 @@ import {
extractOpenAICategoryMarker,
isOpenAIRequestNonReplayable,
} from './openaiErrorClassification.ts'
import { createOpenAIShimClient, hasMistralApiHost, parseTextToolCalls, parseXmlToolCalls } from './openaiShim.ts'
import {
createOpenAIShimClient,
hasMistralApiHost,
parseTextToolCalls,
parseXmlToolCalls,
} from './openaiShim.ts'
import * as realCodexShim from './codexShim.js'
import * as realGithubModelsCredentials from '../../utils/githubModelsCredentials.js'
@@ -5271,60 +5276,65 @@ test('converts Gemini raw tool-call text into streaming tool_use blocks', async
// openaiShim test extraction seam 092 start: converts Gemini raw tool-call text into non-streaming tool_use blocks
test('converts Gemini raw tool-call text into non-streaming tool_use blocks', async () => {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-raw-tool',
model: 'google/gemini-3.1-flash-lite',
choices: [
{
message: {
role: 'assistant',
content:
'Tool calls requested:\n- Agent({"description":"Verify the todo list application functionality.","prompt":"Check files.","subagent_type":"verification"}) [id: call9a8b7c6d5e4f3a2b1c0d9e8f]',
const previousFetch = globalThis.fetch
try {
globalThis.fetch = (async (_input, _init) => {
return new Response(
JSON.stringify({
id: 'chatcmpl-raw-tool',
model: 'google/gemini-3.1-flash-lite',
choices: [
{
message: {
role: 'assistant',
content:
'Tool calls requested:\n- Agent({"description":"Verify the todo list application functionality.","prompt":"Check files.","subagent_type":"verification"}) [id: call9a8b7c6d5e4f3a2b1c0d9e8f]',
},
finish_reason: 'stop',
},
finish_reason: 'stop',
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
{
headers: {
'Content-Type': 'application/json',
},
],
usage: {
prompt_tokens: 12,
completion_tokens: 4,
total_tokens: 16,
},
}),
)
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const message = await client.beta.messages.create({
model: 'google/gemini-3.1-flash-lite',
messages: [{ role: 'user', content: 'Verify' }],
max_tokens: 64,
stream: false,
}) as {
stop_reason?: string
content?: Array<Record<string, unknown>>
}
expect(message.stop_reason).toBe('tool_use')
expect(message.content).toEqual([
{
headers: {
'Content-Type': 'application/json',
type: 'tool_use',
id: 'call9a8b7c6d5e4f3a2b1c0d9e8f',
name: 'Agent',
input: {
description: 'Verify the todo list application functionality.',
prompt: 'Check files.',
subagent_type: 'verification',
},
},
)
}) as unknown as FetchType
const client = createOpenAIShimClient({}) as OpenAIShimClient
const message = await client.beta.messages.create({
model: 'google/gemini-3.1-flash-lite',
messages: [{ role: 'user', content: 'Verify' }],
max_tokens: 64,
stream: false,
}) as {
stop_reason?: string
content?: Array<Record<string, unknown>>
])
} finally {
globalThis.fetch = previousFetch
}
expect(message.stop_reason).toBe('tool_use')
expect(message.content).toEqual([
{
type: 'tool_use',
id: 'call9a8b7c6d5e4f3a2b1c0d9e8f',
name: 'Agent',
input: {
description: 'Verify the todo list application functionality.',
prompt: 'Check files.',
subagent_type: 'verification',
},
},
])
})
// openaiShim test extraction seam 092 end
@@ -6626,7 +6636,10 @@ test('raw-text and XML fallback tool calls use one unique sequence', () => {
const xml = parseXmlToolCalls('<tool_call>{"name":"from_xml","arguments":{}}</tool_call>')
expect(text.calls[0]?.id).toMatch(/^ollama_tc_\d+$/)
expect(xml.calls[0]?.id).toMatch(/^xml_tc_\d+$/)
expect(text.calls[0]?.id?.replace(/^\D+/, '')).not.toBe(xml.calls[0]?.id?.replace(/^\D+/, ''))
const textNum = Number(text.calls[0]?.id?.replace(/^\D+/, ''))
const xmlNum = Number(xml.calls[0]?.id?.replace(/^\D+/, ''))
// Same session counter: the second mint must be exactly one greater than the first.
expect(xmlNum).toBe(textNum + 1)
})
// ---------------------------------------------------------------------------
+32 -388
View File
@@ -131,6 +131,16 @@ import {
getStreamStats,
} from '../../utils/streamingOptimizer.js'
import { stableStringifyJson } from '../../utils/stableStringify.js'
import {
findXmlToolCallOpener as findXmlToolCallOpenerModule,
isHy3Model as isHy3ModelModule,
parseXmlToolCalls as parseXmlToolCallsModule,
trailingXmlOpenerPrefixLen as trailingXmlOpenerPrefixLenModule,
} from './openaiShim/xmlToolCallParsing.js'
import {
convertNonStreamingResponseToAnthropicMessage as convertResponseToAnthropicMessage,
type NonStreamingOpenAIResponse,
} from './openaiShim/responseConversion.js'
import {
CredentialPool,
type CredentialLease,
@@ -869,244 +879,27 @@ function nextTextToolCallSequence(): number {
}
// ---------------------------------------------------------------------------
// XML tool call parser (GLM / Qwen / DeepSeek family)
//
// Several models routed through OpenAI-compatible gateways emit tool calls as
// XML text inside the assistant message rather than as structured `tool_calls`.
// Without recovery these leak into visible prose and never execute — the turn
// then ends with no tool_use block, so the agent appears to "forget" and stop
// mid-task. We support the four dialects seen in the wild:
// A. <tool_call><function=NAME><parameter=KEY>VALUE</parameter>…</function></tool_call>
// B. <tool_call>NAME<arg_key>KEY</arg_key><arg_value>VALUE</arg_value>…</tool_call> (GLM native)
// C. <tool_call>{"name":"NAME","arguments":{…}}</tool_call> (Hermes JSON)
// D. <tool_calls:ID><tool_call:ID>NAME<parameter name="KEY">VALUE</parameter>… (Tencent HY3)
// XML tool parsing façade. Dialect handling lives in xmlToolCallParsing.ts.
// ---------------------------------------------------------------------------
// The streaming finalize path buffers from this opener onward so the raw XML
// is never surfaced as text before extraction.
const XML_TOOL_CALL_OPEN = '<tool_call>'
const HY3_TOOL_CALLS_OPEN = '<tool_calls:'
const HY3_TOOL_CALL_OPEN = '<tool_call:'
const XML_TOOL_CALL_OPENERS = [
XML_TOOL_CALL_OPEN,
HY3_TOOL_CALLS_OPEN,
HY3_TOOL_CALL_OPEN,
]
// Non-greedy block matcher; the `$` alternative tolerates a truncated final
// block (stream cut off before the closing tag).
const XML_TOOL_CALL_BLOCK_RE = /<tool_call>([\s\S]*?)(?:<\/tool_call>|$)/g
const HY3_TOOL_CALLS_BLOCK_RE = /<tool_calls:[^>\s]+>([\s\S]*?)(?:<\/tool_calls(?::[^>\s]+)?>|$)/g
const HY3_TOOL_CALL_BLOCK_RE = /<tool_call:[^>\s]+>([\s\S]*?)(?:<\/tool_call(?::[^>\s]+)?>|$)/g
const XML_FUNCTION_NAME_RE = /<function=([^>\s]+)\s*>/
const XML_PARAMETER_RE = /<parameter=([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g
const XML_ARG_PAIR_RE = /<arg_key>([\s\S]*?)<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/g
const HY3_PARAMETER_RE = /<parameter\s+name=["']([^"'>\s]+)["']\s*>([\s\S]*?)<\/parameter>/g
const HY3_NAMED_ARGUMENT_LINE_RE = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/gm
const HY3_ARG_PAIR_RE = /<arg_key(?::[^>\s]+)?>([\s\S]*?)<\/arg_key(?::[^>\s]+)?>\s*<arg_value(?::[^>\s]+)?>([\s\S]*?)<\/arg_value(?::[^>\s]+)?>/g
// Parameter/arg values arrive as untyped text. Try JSON first so numbers,
// booleans, and nested objects round-trip; fall back to the raw string.
function coerceXmlToolValue(raw: string): unknown {
const trimmed = raw.trim()
if (trimmed === '') return ''
try {
return JSON.parse(trimmed)
} catch {
return raw
}
}
function parseHy3ToolCallInner(inner: string): {
name?: string
args: Record<string, unknown>
} {
const args: Record<string, unknown> = {}
const trimmed = inner.trim()
const name = trimmed
.split(/[\n<]/, 1)[0]
?.trim()
.replace(/[\s`*_]+$/, '')
let hasStructuredArguments = false
for (const parameter of inner.matchAll(HY3_PARAMETER_RE)) {
const key = parameter[1]
if (key) {
hasStructuredArguments = true
args[key] = coerceXmlToolValue(parameter[2] ?? '')
}
}
for (const line of inner.matchAll(HY3_NAMED_ARGUMENT_LINE_RE)) {
const key = line[1]
if (key) {
hasStructuredArguments = true
args[key] = coerceXmlToolValue(line[2] ?? '')
}
}
for (const pair of inner.matchAll(HY3_ARG_PAIR_RE)) {
const key = pair[1]?.trim()
if (key) {
hasStructuredArguments = true
args[key] = coerceXmlToolValue(pair[2] ?? '')
}
}
// The provider's textual wrapper is not self-authenticating. Requiring a
// normal tool identifier avoids executing or hiding documentation snippets
// that merely demonstrate `<tool_call:...>`, while still allowing every
// valid zero-input tool instead of maintaining a stale name allowlist.
return {
name: name && /^[A-Za-z_][\w.-]*$/.test(name) &&
(hasStructuredArguments || trimmed === name)
? name
: undefined,
args,
}
function findXmlToolCallOpener(text: string, allowHy3: boolean): number {
return findXmlToolCallOpenerModule(text, allowHy3)
}
function isHy3Model(model: string): boolean {
return model.split('?', 1)[0]?.toLowerCase() === 'tencent/hy3'
return isHy3ModelModule(model)
}
/**
* Returns the length of the longest suffix of `s` that is a (proper) prefix of
* the `<tool_call>` opener. Used by the stream to hold back a trailing partial
* opener split across SSE deltas so it is never emitted as visible text.
*/
function trailingXmlOpenerPrefixLen(s: string, allowHy3: boolean): number {
let longest = 0
const openers = allowHy3 ? XML_TOOL_CALL_OPENERS : [XML_TOOL_CALL_OPEN]
for (const opener of openers) {
const max = Math.min(s.length, opener.length - 1)
for (let len = max; len > 0; len--) {
if (opener.startsWith(s.slice(s.length - len))) {
longest = Math.max(longest, len)
break
}
}
}
return longest
export function parseXmlToolCalls(text: string, allowHy3 = false) {
return parseXmlToolCallsModule(text, allowHy3, nextTextToolCallSequence)
}
function findXmlToolCallOpener(text: string, allowHy3: boolean): number {
const openers = allowHy3 ? XML_TOOL_CALL_OPENERS : [XML_TOOL_CALL_OPEN]
return openers.reduce((first, opener) => {
const index = text.indexOf(opener)
return index === -1 ? first : first === -1 ? index : Math.min(first, index)
}, -1)
}
/** Exported for unit testing only. */
export function parseXmlToolCalls(text: string, allowHy3 = false): {
calls: ParsedTextToolCall[]
toolCallRanges: Array<[number, number]>
} {
const results: ParsedTextToolCall[] = []
const seen = new Set<string>()
const ranges: Array<[number, number]> = []
const addCall = (name: string, args: Record<string, unknown>) => {
const dedupKey = `${name}:${JSON.stringify(args)}`
if (seen.has(dedupKey)) return
seen.add(dedupKey)
results.push({ id: `xml_tc_${nextTextToolCallSequence()}`, name, arguments: args })
}
const hy3Blocks = allowHy3
? [...text.matchAll(HY3_TOOL_CALL_BLOCK_RE)].map(block => ({
range: [block.index!, block.index! + block[0].length] as [number, number],
parsed: parseHy3ToolCallInner(block[1] ?? ''),
}))
: []
const hy3WrapperRanges = allowHy3
? [...text.matchAll(HY3_TOOL_CALLS_BLOCK_RE)]
.filter(wrapper => {
const range: [number, number] = [
wrapper.index!,
wrapper.index! + wrapper[0].length,
]
return hy3Blocks.some(
block => block.parsed.name && range[0] <= block.range[0] && block.range[1] <= range[1],
)
})
.map(wrapper => [
wrapper.index!,
wrapper.index! + wrapper[0].length,
] as [number, number])
: []
for (const block of hy3Blocks) {
const { name, args } = block.parsed
if (!name) continue
const range = block.range
if (!hy3WrapperRanges.some(wrapper => wrapper[0] <= range[0] && range[1] <= wrapper[1])) {
ranges.push(range)
}
addCall(name, args)
}
ranges.push(...hy3WrapperRanges)
for (const block of text.matchAll(XML_TOOL_CALL_BLOCK_RE)) {
const inner = block[1] ?? ''
const range: [number, number] = [
block.index!,
block.index! + block[0].length,
]
let name: string | undefined
const args: Record<string, unknown> = {}
const fnMatch = inner.match(XML_FUNCTION_NAME_RE)
if (fnMatch) {
// Dialect A: <function=NAME><parameter=KEY>VALUE</parameter>…
name = fnMatch[1]
for (const p of inner.matchAll(XML_PARAMETER_RE)) {
const key = p[1]
if (key) args[key] = coerceXmlToolValue(p[2] ?? '')
}
} else {
const trimmedInner = inner.trim()
const argPairs = [...inner.matchAll(XML_ARG_PAIR_RE)]
if (argPairs.length > 0 && !trimmedInner.startsWith('{')) {
// Dialect B: leading token is the function name, then arg_key/arg_value.
const nameTok = trimmedInner.split(/[\n<]/, 1)[0]?.trim()
if (nameTok) name = nameTok
for (const p of argPairs) {
const key = (p[1] ?? '').trim()
if (key) args[key] = coerceXmlToolValue(p[2] ?? '')
}
} else {
// Dialect C: a JSON tool-call object inside the tags.
const jsonStart = trimmedInner.indexOf('{')
if (jsonStart !== -1) {
const jsonRaw = extractBalancedJson(trimmedInner, jsonStart)
if (jsonRaw) {
try {
const obj = JSON.parse(jsonRaw) as Record<string, unknown>
if (typeof obj['name'] === 'string') {
name = obj['name'] as string
const rawArgs = obj['arguments']
if (typeof rawArgs === 'string') {
try {
Object.assign(args, JSON.parse(rawArgs))
} catch {}
} else if (rawArgs && typeof rawArgs === 'object') {
Object.assign(args, rawArgs as Record<string, unknown>)
}
}
} catch {}
}
}
}
}
if (!name) continue
ranges.push(range)
addCall(name, args)
}
return { calls: results, toolCallRanges: ranges }
function trailingXmlOpenerPrefixLen(text: string, allowHy3: boolean): number {
return trailingXmlOpenerPrefixLenModule(text, allowHy3)
}
// The streaming finalize path buffers from this opener onward so the raw XML
// is never surfaced as text before extraction.
/**
* Async generator that transforms an OpenAI SSE stream into
* Anthropic-format BetaRawMessageStreamEvent objects.
@@ -1338,171 +1131,22 @@ async function* geminiSseToAnthropic(
// Extraction seam: Gemini streaming | completed response conversion.
type NonStreamingOpenAIResponse = {
id?: string
model?: string
choices?: Array<{
message?: {
role?: string
content?: string | null | Array<{ type?: string; text?: string }>
reasoning_content?: string | null
extra_content?: Record<string, unknown>
tool_calls?: Array<{
id: string
function: { name: string; arguments: string }
extra_content?: Record<string, unknown>
}>
}
finish_reason?: string
}>
usage?: {
prompt_tokens?: number
completion_tokens?: number
prompt_tokens_details?: {
cached_tokens?: number
}
}
}
/**
* Convert an OpenAI-compatible non-streaming chat completion into an
* Anthropic-shaped message. Shared by the `OpenAIShimMessages` non-stream path
* and the `application/json` fallback inside `openaiStreamToAnthropic` so both
* apply the same tool-call extraction, stop-reason mapping, array-content
* normalization, <think>-tag stripping, and raw text tool-call recovery.
*/
function convertNonStreamingResponseToAnthropicMessage(
data: NonStreamingOpenAIResponse,
model: string,
) {
const choice = data.choices?.[0]
const content: Array<Record<string, unknown>> = []
// An empty tool_calls array is still truthy; treat it as "no structured tool
// calls" so raw "Tool calls requested" text recovery is not skipped.
const hasStructuredToolCalls =
(choice?.message?.tool_calls?.length ?? 0) > 0
// Some reasoning models (e.g. GLM-5) put their chain-of-thought in
// reasoning_content while content stays null. Preserve it as a thinking
// block, but do not surface it as visible assistant text.
const reasoningText = choice?.message?.reasoning_content
if (typeof reasoningText === 'string' && reasoningText) {
content.push({ type: 'thinking', thinking: reasoningText })
}
const rawContent =
choice?.message?.content !== '' && choice?.message?.content != null
? choice?.message?.content
: null
const appendTextOrRecoveredToolCalls = (rawText: string) => {
const strippedContent = stripThinkTags(rawText)
if (!hasStructuredToolCalls) {
const { calls: xmlToolCalls, toolCallRanges } = parseXmlToolCalls(
strippedContent,
isHy3Model(model),
)
if (xmlToolCalls.length > 0) {
const visibleText = stripRanges(strippedContent, toolCallRanges).trim()
if (visibleText) content.push({ type: 'text', text: visibleText })
for (const toolCall of xmlToolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: toolCall.arguments,
})
}
return
}
}
const rawToolCalls = hasStructuredToolCalls
? null
: parseRawToolCallsRequestedText(strippedContent)
if (rawToolCalls) {
for (const toolCall of rawToolCalls) {
content.push({
type: 'tool_use',
id: toolCall.id,
name: toolCall.name,
input: JSON.parse(toolCall.argumentsJson),
})
}
} else {
content.push({ type: 'text', text: strippedContent })
}
}
if (typeof rawContent === 'string' && rawContent) {
appendTextOrRecoveredToolCalls(rawContent)
} else if (Array.isArray(rawContent) && rawContent.length > 0) {
const parts: string[] = []
for (const part of rawContent) {
if (
part &&
typeof part === 'object' &&
part.type === 'text' &&
typeof part.text === 'string'
) {
parts.push(part.text)
}
}
const joined = parts.join('\n')
if (joined) {
appendTextOrRecoveredToolCalls(joined)
}
}
if (hasStructuredToolCalls && choice?.message?.tool_calls) {
for (const tc of choice.message.tool_calls) {
const input = normalizeToolArguments(
tc.function.name,
tc.function.arguments,
)
const toolExtraContent = tc.extra_content ?? choice.message.extra_content
const toolSignature =
geminiThoughtSignatureFromExtraContent(tc.extra_content) ??
geminiThoughtSignatureFromExtraContent(choice.message.extra_content)
const mergedToolExtraContent = mergeGeminiThoughtSignature(
toolExtraContent,
toolSignature,
)
content.push({
type: 'tool_use',
id: tc.id,
name: tc.function.name,
input,
...(mergedToolExtraContent ? { extra_content: mergedToolExtraContent } : {}),
...(toolSignature ? { signature: toolSignature } : {}),
})
}
}
const stopReason =
choice?.finish_reason === 'tool_calls' ||
content.some(block => block.type === 'tool_use')
? 'tool_use'
: choice?.finish_reason === 'length'
? 'max_tokens'
: 'end_turn'
if (choice?.finish_reason === 'content_filter' || choice?.finish_reason === 'safety') {
content.push({
type: 'text',
text: '\n\n[Content blocked by provider safety filter]',
})
}
return {
id: data.id ?? makeMessageId(),
type: 'message',
role: 'assistant',
content,
model: data.model ?? model,
stop_reason: stopReason,
stop_sequence: null,
usage: buildAnthropicUsageFromRawUsage(
data.usage as unknown as Record<string, unknown> | undefined,
),
}
return convertResponseToAnthropicMessage(data, model, {
makeMessageId,
buildUsage: usage => buildAnthropicUsageFromRawUsage(usage),
stripThinkTags,
parseXmlToolCalls,
isHy3Model,
stripRanges,
parseRawToolCalls: parseRawToolCallsRequestedText,
normalizeToolArguments,
getGeminiThoughtSignature: geminiThoughtSignatureFromExtraContent,
mergeGeminiThoughtSignature,
})
}
function headersWithRequestUrl(headers: Headers, requestUrl?: string): Headers {
@@ -0,0 +1,228 @@
import { expect, test } from 'bun:test'
import { buildAnthropicUsageFromRawUsage } from '../cacheMetrics.js'
import { normalizeToolArguments } from '../toolArgumentNormalization.js'
import { stripThinkTags } from '../thinkTagSanitizer.js'
import {
geminiThoughtSignatureFromExtraContent,
mergeGeminiThoughtSignature,
} from './providerCompatibility.js'
import {
parseRawToolCallsRequestedText,
stripRanges,
} from './rawToolCallParsing.js'
import {
isHy3Model,
parseXmlToolCalls as parseXmlToolCallsModule,
} from './xmlToolCallParsing.js'
import {
convertNonStreamingResponseToAnthropicMessage,
type NonStreamingOpenAIResponse,
} from './responseConversion.js'
const parseXmlToolCalls = (text: string, allowHy3: boolean) => {
let sequence = 0
return parseXmlToolCallsModule(text, allowHy3, () => ++sequence)
}
const dependencies = {
makeMessageId: () => 'msg-test',
buildUsage: (usage: Record<string, unknown> | undefined) =>
buildAnthropicUsageFromRawUsage(usage),
stripThinkTags,
parseXmlToolCalls,
isHy3Model,
stripRanges,
parseRawToolCalls: parseRawToolCallsRequestedText,
normalizeToolArguments,
getGeminiThoughtSignature: geminiThoughtSignatureFromExtraContent,
mergeGeminiThoughtSignature,
}
function convert(data: NonStreamingOpenAIResponse, model = 'fallback-model') {
return convertNonStreamingResponseToAnthropicMessage(data, model, dependencies)
}
test('recovers a non-streaming Gemini raw tool call without exposing provider text', () => {
const message = convert({
id: 'chatcmpl-raw-tool',
model: 'google/gemini-3.1-flash-lite',
choices: [{
message: {
role: 'assistant',
content:
'Tool calls requested:\n- Agent({"description":"Verify the todo list application functionality.","prompt":"Check files.","subagent_type":"verification"}) [id: call9a8b7c6d5e4f3a2b1c0d9e8f]',
},
finish_reason: 'stop',
}],
usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 },
})
expect(message.content).toEqual([{
type: 'tool_use',
id: 'call9a8b7c6d5e4f3a2b1c0d9e8f',
name: 'Agent',
input: {
description: 'Verify the todo list application functionality.',
prompt: 'Check files.',
subagent_type: 'verification',
},
}])
expect(message.stop_reason).toBe('tool_use')
expect(message.usage).toEqual({
input_tokens: 12,
output_tokens: 4,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
})
})
test('emits reasoning_content as thinking when content is null', () => {
const message = convert({
choices: [{
message: {
role: 'assistant',
content: null,
reasoning_content: 'Let me think about this step by step.',
},
finish_reason: 'stop',
}],
}, 'glm-5')
expect(message.content).toEqual([{
type: 'thinking',
thinking: 'Let me think about this step by step.',
}])
})
test('does not convert empty content into visible reasoning text', () => {
const message = convert({
choices: [{
message: {
role: 'assistant',
content: '',
reasoning_content: 'Chain of thought here.',
},
finish_reason: 'stop',
}],
}, 'glm-5')
expect(message.content).toEqual([{
type: 'thinking',
thinking: 'Chain of thought here.',
}])
})
test('preserves real content alongside reasoning_content', () => {
const message = convert({
choices: [{
message: {
role: 'assistant',
content: 'The answer is 42.',
reasoning_content: 'I need to calculate this.',
},
finish_reason: 'stop',
}],
}, 'glm-5')
expect(message.content).toEqual([
{ type: 'thinking', thinking: 'I need to calculate this.' },
{ type: 'text', text: 'The answer is 42.' },
])
})
test('strips think tags from non-streaming assistant content', () => {
const message = convert({
choices: [{
message: {
role: 'assistant',
content: '<think>respond briefly</think>Hey! How can I help you today?',
},
finish_reason: 'stop',
}],
}, 'gpt-5-mini')
expect(message.content).toEqual([{
type: 'text',
text: 'Hey! How can I help you today?',
}])
})
test('recovers Tencent HY3 XML calls in the JSON fallback conversion', () => {
const message = convert({
id: 'chatcmpl-json-hy3',
model: 'tencent/hy3',
choices: [{
message: {
role: 'assistant',
content:
'<tool_call:call_hy3>TaskCreate\n subject: Verify HY3\n description: Run the live test\n</tool_call:call_hy3>',
},
finish_reason: 'stop',
}],
}, 'tencent/hy3')
expect(message.content).toEqual([{
type: 'tool_use',
id: expect.stringMatching(/^xml_tc_\d+$/),
name: 'TaskCreate',
input: {
subject: 'Verify HY3',
description: 'Run the live test',
},
}])
expect(message.stop_reason).toBe('tool_use')
})
test('preserves structured Gemini signatures and safety terminal responses', () => {
const message = convert({
model: 'gemini',
choices: [{
finish_reason: 'safety',
message: {
tool_calls: [{
id: 'call-2',
function: { name: 'Write', arguments: '{"path":"a.ts"}' },
extra_content: { google: { thought_signature: 'sig-2' } },
}],
},
}],
})
expect(message.content).toEqual([
{
type: 'tool_use',
id: 'call-2',
name: 'Write',
input: { path: 'a.ts' },
extra_content: { google: { thought_signature: 'sig-2' } },
signature: 'sig-2',
},
{ type: 'text', text: '\n\n[Content blocked by provider safety filter]' },
])
expect(message.model).toBe('gemini')
expect(message.stop_reason).toBe('tool_use')
})
test('normalizes array content and length stop reasons', () => {
const functionPart = Object.assign(() => {}, {
type: 'text' as const,
text: 'ignored-fn',
})
const message = convert({
choices: [{
message: {
content: [
{ type: 'text', text: 'first' },
{ type: 'image' },
functionPart,
{ type: 'text', text: 'second' },
],
},
finish_reason: 'length',
}],
})
expect(message.content).toEqual([{ type: 'text', text: 'first\nsecond' }])
expect(message.stop_reason).toBe('max_tokens')
expect(message.id).toBe('msg-test')
})
@@ -0,0 +1,110 @@
export type NonStreamingOpenAIResponse = {
id?: string
model?: string
choices?: Array<{
message?: {
role?: string
content?: string | null | Array<{ type?: string; text?: string }>
reasoning_content?: string | null
extra_content?: Record<string, unknown>
tool_calls?: Array<{
id: string
function: { name: string; arguments: string }
extra_content?: Record<string, unknown>
}>
}
finish_reason?: string
}>
usage?: Record<string, unknown>
}
type Dependencies = {
makeMessageId: () => string
buildUsage: (usage: Record<string, unknown> | undefined) => Record<string, unknown>
stripThinkTags: (text: string) => string
parseXmlToolCalls: (text: string, allowHy3: boolean) => {
calls: Array<{ id: string; name: string; arguments: Record<string, unknown> }>
toolCallRanges: Array<[number, number]>
}
isHy3Model: (model: string) => boolean
stripRanges: (text: string, ranges: Array<[number, number]>) => string
parseRawToolCalls: (text: string) => Array<{ id: string; name: string; argumentsJson: string }> | null
normalizeToolArguments: (name: string, argumentsJson: string) => unknown
getGeminiThoughtSignature: (extraContent: unknown) => string | undefined
mergeGeminiThoughtSignature: (
extraContent: Record<string, unknown> | undefined,
signature: string | undefined,
) => Record<string, unknown> | undefined
}
export function convertNonStreamingResponseToAnthropicMessage(
data: NonStreamingOpenAIResponse,
model: string,
deps: Dependencies,
) {
const choice = data.choices?.[0]
const content: Array<Record<string, unknown>> = []
const hasStructuredToolCalls = (choice?.message?.tool_calls?.length ?? 0) > 0
const reasoning = choice?.message?.reasoning_content
if (typeof reasoning === 'string' && reasoning) content.push({ type: 'thinking', thinking: reasoning })
const appendTextOrRecoveredToolCalls = (rawText: string) => {
const stripped = deps.stripThinkTags(rawText)
if (!hasStructuredToolCalls) {
const { calls, toolCallRanges } = deps.parseXmlToolCalls(stripped, deps.isHy3Model(model))
if (calls.length) {
const visibleText = deps.stripRanges(stripped, toolCallRanges).trim()
if (visibleText) content.push({ type: 'text', text: visibleText })
for (const call of calls) content.push({ type: 'tool_use', id: call.id, name: call.name, input: call.arguments })
return
}
}
const rawToolCalls = hasStructuredToolCalls ? null : deps.parseRawToolCalls(stripped)
if (rawToolCalls) {
for (const call of rawToolCalls) content.push({ type: 'tool_use', id: call.id, name: call.name, input: JSON.parse(call.argumentsJson) })
} else content.push({ type: 'text', text: stripped })
}
const rawContent = choice?.message?.content !== '' && choice?.message?.content != null
? choice.message.content : null
if (typeof rawContent === 'string' && rawContent) appendTextOrRecoveredToolCalls(rawContent)
else if (Array.isArray(rawContent)) {
const text = rawContent
.filter(
part =>
part &&
typeof part === 'object' &&
part.type === 'text' &&
typeof part.text === 'string',
)
.map(part => part.text!)
.join('\n')
if (text) appendTextOrRecoveredToolCalls(text)
}
if (hasStructuredToolCalls && choice?.message?.tool_calls) {
for (const toolCall of choice.message.tool_calls) {
const extraContent = toolCall.extra_content ?? choice.message.extra_content
const signature = deps.getGeminiThoughtSignature(toolCall.extra_content) ??
deps.getGeminiThoughtSignature(choice.message.extra_content)
const merged = deps.mergeGeminiThoughtSignature(extraContent, signature)
content.push({
type: 'tool_use', id: toolCall.id, name: toolCall.function.name,
input: deps.normalizeToolArguments(toolCall.function.name, toolCall.function.arguments),
...(merged ? { extra_content: merged } : {}),
...(signature ? { signature } : {}),
})
}
}
const stopReason = choice?.finish_reason === 'tool_calls' || content.some(block => block.type === 'tool_use')
? 'tool_use' : choice?.finish_reason === 'length' ? 'max_tokens' : 'end_turn'
if (choice?.finish_reason === 'content_filter' || choice?.finish_reason === 'safety') {
content.push({ type: 'text', text: '\n\n[Content blocked by provider safety filter]' })
}
return {
id: data.id ?? deps.makeMessageId(), type: 'message', role: 'assistant', content,
model: data.model ?? model, stop_reason: stopReason, stop_sequence: null,
usage: deps.buildUsage(data.usage),
}
}
@@ -15,7 +15,15 @@
* D. <tool_calls:ID><tool_call:ID>NAME<parameter name="KEY">VALUE</parameter></tool_calls:ID>
*/
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import { createOpenAIShimClient, parseXmlToolCalls } from './openaiShim.js'
import { createOpenAIShimClient } from '../openaiShim.js'
import {
parseXmlToolCalls as parseXmlToolCallsModule,
} from './xmlToolCallParsing.js'
function parseXmlToolCalls(text: string, allowHy3 = false) {
let sequence = 0
return parseXmlToolCallsModule(text, allowHy3, () => ++sequence)
}
type FetchType = typeof globalThis.fetch
@@ -54,7 +62,6 @@ const glmChunk = (content: string, finishReason?: string) => ({
model: 'glm-5.2',
choices: [{ index: 0, delta: { content }, finish_reason: finishReason ?? null }],
})
const glmToolChunk = (toolCalls: unknown[], finishReason?: string) => ({
id: 'chatcmpl-glm',
object: 'chat.completion.chunk',
@@ -130,6 +137,21 @@ describe('parseXmlToolCalls', () => {
expect(calls[0].arguments).toEqual({ command: 'pwd' })
})
test('preserves source order when mixing standard XML and HY3 dialects', () => {
const text =
'before <tool_call>{"name":"xml_first","arguments":{}}</tool_call> ' +
'mid <tool_call:hy3>Hy3Second\nk: v\n</tool_call:hy3> after'
const { calls, toolCallRanges } = parseXmlToolCalls(text, true)
expect(calls.map(call => call.name)).toEqual(['xml_first', 'Hy3Second'])
expect(calls.map(call => call.id)).toEqual(['xml_tc_1', 'xml_tc_2'])
expect(calls[1].arguments).toEqual({ k: 'v' })
expect(toolCallRanges).toEqual([
[text.indexOf('<tool_call>'), text.indexOf('</tool_call>') + '</tool_call>'.length],
[text.indexOf('<tool_call:hy3>'), text.indexOf('</tool_call:hy3>') + '</tool_call:hy3>'.length],
])
})
test('dialect D: Tencent HY3 wrapper with named parameters', () => {
const text =
'<tool_calls:call_1><tool_call:call_1>TaskCreate\n' +
@@ -0,0 +1,251 @@
import { extractBalancedJson } from './rawToolCallParsing.js'
const HY3_PARAMETER_RE = /<parameter\s+name=["']([^"'>\s]+)["']\s*>([\s\S]*?)<\/parameter>/g
const HY3_NAMED_ARGUMENT_LINE_RE = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/gm
const HY3_ARG_PAIR_RE = /<arg_key(?::[^>\s]+)?>([\s\S]*?)<\/arg_key(?::[^>\s]+)?>\s*<arg_value(?::[^>\s]+)?>([\s\S]*?)<\/arg_value(?::[^>\s]+)?>/g
export function coerceXmlToolValue(raw: string): unknown {
const trimmed = raw.trim()
if (trimmed === '') return ''
try {
return JSON.parse(trimmed)
} catch {
return raw
}
}
export function parseHy3ToolCallInner(inner: string): {
name?: string
args: Record<string, unknown>
} {
const args: Record<string, unknown> = {}
const trimmed = inner.trim()
const name = trimmed.split(/[\n<]/, 1)[0]?.trim().replace(/[\s`*_]+$/, '')
let hasStructuredArguments = false
for (const parameter of inner.matchAll(HY3_PARAMETER_RE)) {
const key = parameter[1]
if (key) { hasStructuredArguments = true; args[key] = coerceXmlToolValue(parameter[2] ?? '') }
}
for (const line of inner.matchAll(HY3_NAMED_ARGUMENT_LINE_RE)) {
const key = line[1]
if (key) { hasStructuredArguments = true; args[key] = coerceXmlToolValue(line[2] ?? '') }
}
for (const pair of inner.matchAll(HY3_ARG_PAIR_RE)) {
const key = pair[1]?.trim()
if (key) { hasStructuredArguments = true; args[key] = coerceXmlToolValue(pair[2] ?? '') }
}
return {
name: name && /^[A-Za-z_][\w.-]*$/.test(name) && (hasStructuredArguments || trimmed === name) ? name : undefined,
args,
}
}
export interface ParsedXmlToolCall {
id: string
name: string
arguments: Record<string, unknown>
}
const XML_TOOL_CALL_OPEN = '<tool_call>'
const HY3_TOOL_CALLS_OPEN = '<tool_calls:'
const HY3_TOOL_CALL_OPEN = '<tool_call:'
const XML_TOOL_CALL_OPENERS = [
XML_TOOL_CALL_OPEN,
HY3_TOOL_CALLS_OPEN,
HY3_TOOL_CALL_OPEN,
]
// Non-greedy block matcher; the `$` alternative tolerates a truncated final
// block (stream cut off before the closing tag).
const XML_TOOL_CALL_BLOCK_RE = /<tool_call>([\s\S]*?)(?:<\/tool_call>|$)/g
const HY3_TOOL_CALLS_BLOCK_RE = /<tool_calls:[^>\s]+>([\s\S]*?)(?:<\/tool_calls(?::[^>\s]+)?>|$)/g
const HY3_TOOL_CALL_BLOCK_RE = /<tool_call:[^>\s]+>([\s\S]*?)(?:<\/tool_call(?::[^>\s]+)?>|$)/g
const XML_FUNCTION_NAME_RE = /<function=([^>\s]+)\s*>/
const XML_PARAMETER_RE = /<parameter=([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g
const XML_ARG_PAIR_RE = /<arg_key>([\s\S]*?)<\/arg_key>\s*<arg_value>([\s\S]*?)<\/arg_value>/g
export function isHy3Model(model: string): boolean {
return model.split('?', 1)[0]?.toLowerCase() === 'tencent/hy3'
}
/**
* Returns the length of the longest suffix of `s` that is a (proper) prefix of
* the `<tool_call>` opener. Used by the stream to hold back a trailing partial
* opener split across SSE deltas so it is never emitted as visible text.
*/
export function trailingXmlOpenerPrefixLen(s: string, allowHy3: boolean): number {
let longest = 0
const openers = allowHy3 ? XML_TOOL_CALL_OPENERS : [XML_TOOL_CALL_OPEN]
for (const opener of openers) {
const max = Math.min(s.length, opener.length - 1)
for (let len = max; len > 0; len--) {
if (opener.startsWith(s.slice(s.length - len))) {
longest = Math.max(longest, len)
break
}
}
}
return longest
}
export function findXmlToolCallOpener(text: string, allowHy3: boolean): number {
const openers = allowHy3 ? XML_TOOL_CALL_OPENERS : [XML_TOOL_CALL_OPEN]
return openers.reduce((first, opener) => {
const index = text.indexOf(opener)
return index === -1 ? first : first === -1 ? index : Math.min(first, index)
}, -1)
}
/**
* Parse XML / HY3 tool-call markup from assistant text.
* Callers must inject the session sequencer so XML and raw-text fallbacks
* share one id space (see openaiShim façade `nextTextToolCallSequence`).
*/
function parseStandardXmlToolCallInner(inner: string): {
name?: string
args: Record<string, unknown>
} {
let name: string | undefined
const args: Record<string, unknown> = {}
const fnMatch = inner.match(XML_FUNCTION_NAME_RE)
if (fnMatch) {
// Dialect A: <function=NAME><parameter=KEY>VALUE</parameter>…
name = fnMatch[1]
for (const p of inner.matchAll(XML_PARAMETER_RE)) {
const key = p[1]
if (key) args[key] = coerceXmlToolValue(p[2] ?? '')
}
} else {
const trimmedInner = inner.trim()
const argPairs = [...inner.matchAll(XML_ARG_PAIR_RE)]
if (argPairs.length > 0 && !trimmedInner.startsWith('{')) {
// Dialect B: leading token is the function name, then arg_key/arg_value.
const nameTok = trimmedInner.split(/[\n<]/, 1)[0]?.trim()
if (nameTok) name = nameTok
for (const p of argPairs) {
const key = (p[1] ?? '').trim()
if (key) args[key] = coerceXmlToolValue(p[2] ?? '')
}
} else {
// Dialect C: a JSON tool-call object inside the tags.
const jsonStart = trimmedInner.indexOf('{')
if (jsonStart !== -1) {
const jsonRaw = extractBalancedJson(trimmedInner, jsonStart)
if (jsonRaw) {
try {
const obj = JSON.parse(jsonRaw) as Record<string, unknown>
if (typeof obj['name'] === 'string') {
name = obj['name'] as string
const rawArgs = obj['arguments']
if (typeof rawArgs === 'string') {
try {
Object.assign(args, JSON.parse(rawArgs))
} catch {}
} else if (rawArgs && typeof rawArgs === 'object') {
Object.assign(args, rawArgs as Record<string, unknown>)
}
}
} catch {}
}
}
}
}
return { name, args }
}
export function parseXmlToolCalls(
text: string,
allowHy3: boolean,
nextSequence: () => number,
): {
calls: ParsedXmlToolCall[]
toolCallRanges: Array<[number, number]>
} {
const results: ParsedXmlToolCall[] = []
const seen = new Set<string>()
const ranges: Array<[number, number]> = []
const addCall = (name: string, args: Record<string, unknown>) => {
const dedupKey = `${name}:${JSON.stringify(args)}`
if (seen.has(dedupKey)) return
seen.add(dedupKey)
results.push({ id: `xml_tc_${nextSequence()}`, name, arguments: args })
}
type RangeTaggedCandidate = {
range: [number, number]
name: string
args: Record<string, unknown>
coveredByWrapper: boolean
}
const hy3Blocks = allowHy3
? [...text.matchAll(HY3_TOOL_CALL_BLOCK_RE)].map(block => ({
range: [block.index!, block.index! + block[0].length] as [number, number],
parsed: parseHy3ToolCallInner(block[1] ?? ''),
}))
: []
const hy3WrapperRanges = allowHy3
? [...text.matchAll(HY3_TOOL_CALLS_BLOCK_RE)]
.filter(wrapper => {
const range: [number, number] = [
wrapper.index!,
wrapper.index! + wrapper[0].length,
]
return hy3Blocks.some(
block => block.parsed.name && range[0] <= block.range[0] && block.range[1] <= range[1],
)
})
.map(wrapper => [
wrapper.index!,
wrapper.index! + wrapper[0].length,
] as [number, number])
: []
const candidates: RangeTaggedCandidate[] = []
for (const block of hy3Blocks) {
const { name, args } = block.parsed
if (!name) continue
const range = block.range
candidates.push({
range,
name,
args,
coveredByWrapper: hy3WrapperRanges.some(
wrapper => wrapper[0] <= range[0] && range[1] <= wrapper[1],
),
})
}
for (const block of text.matchAll(XML_TOOL_CALL_BLOCK_RE)) {
const range: [number, number] = [
block.index!,
block.index! + block[0].length,
]
const { name, args } = parseStandardXmlToolCallInner(block[1] ?? '')
if (!name) continue
candidates.push({
range,
name,
args,
coveredByWrapper: false,
})
}
// Preserve provider source order across HY3 and standard XML dialects so
// multi-tool turns execute (and mint xml_tc_* IDs) in markup order.
candidates.sort((left, right) => left.range[0] - right.range[0])
for (const candidate of candidates) {
addCall(candidate.name, candidate.args)
if (!candidate.coveredByWrapper) {
ranges.push(candidate.range)
}
}
ranges.push(...hy3WrapperRanges)
ranges.sort((left, right) => left[0] - right[0])
return { calls: results, toolCallRanges: ranges }
}