mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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
@@ -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),
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user