From c6ceb647c4f63f23cca322c0089de3fa531bed7d Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 9 Jul 2026 14:27:03 -0700 Subject: [PATCH 1/6] fix(tools): keep HY3 tool schemas inline --- src/integrations/brands/tencent.ts | 2 +- src/integrations/models/tencent.ts | 6 +- src/integrations/tencent.test.ts | 41 +++++ src/services/api/openaiShim.ts | 169 ++++++++++++++---- .../api/openaiShim.xmlToolCalls.test.ts | 94 +++++++++- src/utils/toolSearch.test.ts | 43 ++++- src/utils/toolSearch.ts | 10 +- 7 files changed, 316 insertions(+), 49 deletions(-) create mode 100644 src/integrations/tencent.test.ts diff --git a/src/integrations/brands/tencent.ts b/src/integrations/brands/tencent.ts index 714a4e4ff8..497cbb8c7d 100644 --- a/src/integrations/brands/tencent.ts +++ b/src/integrations/brands/tencent.ts @@ -5,7 +5,7 @@ export default defineBrand({ label: 'Tencent', canonicalVendorId: 'openai', defaultCapabilities: { - supportsVision: true, + supportsVision: false, supportsStreaming: true, supportsFunctionCalling: true, supportsJsonMode: true, diff --git a/src/integrations/models/tencent.ts b/src/integrations/models/tencent.ts index 43ea6477e4..eef348cbd3 100644 --- a/src/integrations/models/tencent.ts +++ b/src/integrations/models/tencent.ts @@ -6,10 +6,10 @@ export default [ label: 'Tencent HY3', brandId: 'tencent', vendorId: 'openai', - classification: ['chat', 'reasoning', 'vision', 'coding'], + classification: ['chat', 'reasoning', 'coding'], defaultModel: 'tencent/hy3', capabilities: { - supportsVision: true, + supportsVision: false, supportsStreaming: true, supportsFunctionCalling: true, supportsJsonMode: true, @@ -17,6 +17,6 @@ export default [ supportsPreciseTokenCount: false, }, contextWindow: 262_144, - maxOutputTokens: 65_536, + maxOutputTokens: 131_072, }), ] diff --git a/src/integrations/tencent.test.ts b/src/integrations/tencent.test.ts new file mode 100644 index 0000000000..ed94753c7e --- /dev/null +++ b/src/integrations/tencent.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from 'bun:test' + +import { + getCatalogEntriesForRoute, + getModel, +} from './index.js' +import { resolveModelRuntimeLimits } from './runtimeMetadata.js' + +describe('Tencent HY3 descriptor', () => { + test('exposes the verified HY3 capabilities and limits to gateway catalogs', () => { + const model = getModel('tencent/hy3') + + expect(model).toBeDefined() + expect(model).toMatchObject({ + id: 'tencent/hy3', + classification: ['chat', 'reasoning', 'coding'], + contextWindow: 262_144, + maxOutputTokens: 131_072, + capabilities: { + supportsVision: false, + supportsStreaming: true, + supportsFunctionCalling: true, + supportsJsonMode: true, + supportsReasoning: true, + }, + }) + + const catalogEntry = getCatalogEntriesForRoute('gitlawb-opengateway').find( + entry => entry.apiName === 'tencent/hy3', + ) + expect(catalogEntry?.modelDescriptorId).toBe(model?.id) + + expect( + resolveModelRuntimeLimits({ + model: 'tencent/hy3', + baseUrl: 'https://opengateway.gitlawb.com/v1', + processEnv: {}, + }), + ).toEqual({ contextWindow: 262_144, maxOutputTokens: 131_072 }) + }) +}) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 124085a20c..a5f67f9ebc 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1817,26 +1817,42 @@ export function parseTextToolCalls(text: string): { // --------------------------------------------------------------------------- // XML tool call parser (GLM / Qwen / DeepSeek family) // -// Several open 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 three dialects seen in the -// wild: +// 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. VALUE… // B. NAMEKEYVALUE… (GLM native) // C. {"name":"NAME","arguments":{…}} (Hermes JSON) +// D. NAMEVALUE… (Tencent HY3) // --------------------------------------------------------------------------- // 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 = '' +const HY3_TOOL_CALLS_OPEN = '([\s\S]*?)(?:<\/tool_call>|$)/g +const HY3_TOOL_CALLS_BLOCK_RE = /\s]+>([\s\S]*?)(?:<\/tool_calls(?::[^>\s]+)?>|$)/g +const HY3_TOOL_CALL_BLOCK_RE = /\s]+>([\s\S]*?)(?:<\/tool_call(?::[^>\s]+)?>|$)/g const XML_FUNCTION_NAME_RE = /\s]+)\s*>/ const XML_PARAMETER_RE = /\s]+)\s*>([\s\S]*?)<\/parameter>/g const XML_ARG_PAIR_RE = /([\s\S]*?)<\/arg_key>\s*([\s\S]*?)<\/arg_value>/g +const HY3_PARAMETER_RE = /\s]+)["']\s*>([\s\S]*?)<\/parameter>/g +const HY3_NAMED_ARGUMENT_LINE_RE = /^\s*([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/gm +const HY3_ZERO_ARGUMENT_TOOL_NAMES = new Set([ + 'EnterPlanMode', + 'ExitPlanMode', + 'TaskList', +]) // Parameter/arg values arrive as untyped text. Try JSON first so numbers, // booleans, and nested objects round-trip; fall back to the raw string. @@ -1850,19 +1866,67 @@ function coerceXmlToolValue(raw: string): unknown { } } +function parseHy3ToolCallInner(inner: string): { + name?: string + args: Record +} { + const args: Record = {} + 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] ?? '') + } + } + + // The provider's textual wrapper is not self-authenticating. Requiring a + // normal tool identifier and at least one named argument avoids executing or + // hiding documentation snippets that merely demonstrate ``. + return { + name: name && /^[A-Za-z_][\w.-]*$/.test(name) && + (hasStructuredArguments || HY3_ZERO_ARGUMENT_TOOL_NAMES.has(name)) + ? name + : undefined, + args, + } +} + /** * Returns the length of the longest suffix of `s` that is a (proper) prefix of * the `` 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): number { - const max = Math.min(s.length, XML_TOOL_CALL_OPEN.length - 1) - for (let len = max; len > 0; len--) { - if (XML_TOOL_CALL_OPEN.startsWith(s.slice(s.length - len))) return len + for (const opener of XML_TOOL_CALL_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))) return len + } } return 0 } +function findXmlToolCallOpener(text: string): number { + return XML_TOOL_CALL_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): { calls: ParsedTextToolCall[] @@ -1872,6 +1936,37 @@ export function parseXmlToolCalls(text: string): { const seen = new Set() const ranges: Array<[number, number]> = [] + const addCall = (name: string, args: Record) => { + const dedupKey = `${name}:${JSON.stringify(args)}` + if (seen.has(dedupKey)) return + seen.add(dedupKey) + results.push({ id: `xml_tc_${++_textToolCallCounter}`, name, arguments: args }) + } + + const hy3WrapperRanges = [...text.matchAll(HY3_TOOL_CALLS_BLOCK_RE)] + .filter(wrapper => + [...(wrapper[1] ?? '').matchAll(HY3_TOOL_CALL_BLOCK_RE)].length > 0, + ) + .map(wrapper => [ + wrapper.index!, + wrapper.index! + wrapper[0].length, + ] as [number, number]) + + for (const block of text.matchAll(HY3_TOOL_CALL_BLOCK_RE)) { + const { name, args } = parseHy3ToolCallInner(block[1] ?? '') + if (!name) continue + const range: [number, number] = [ + block.index!, + block.index! + block[0].length, + ] + 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] = [ @@ -1927,10 +2022,7 @@ export function parseXmlToolCalls(text: string): { if (!name) continue ranges.push(range) - const dedupKey = `${name}:${JSON.stringify(args)}` - if (seen.has(dedupKey)) continue - seen.add(dedupKey) - results.push({ id: `xml_tc_${++_textToolCallCounter}`, name, arguments: args }) + addCall(name, args) } return { calls: results, toolCallRanges: ranges } @@ -2281,8 +2373,25 @@ function convertNonStreamingResponseToAnthropicMessage( choice?.message?.content !== '' && choice?.message?.content != null ? choice?.message?.content : null - if (typeof rawContent === 'string' && rawContent) { - const strippedContent = stripThinkTags(rawContent) + const appendTextOrRecoveredToolCalls = (rawText: string) => { + const strippedContent = stripThinkTags(rawText) + if (!hasStructuredToolCalls) { + const { calls: xmlToolCalls, toolCallRanges } = parseXmlToolCalls(strippedContent) + 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) @@ -2296,11 +2405,11 @@ function convertNonStreamingResponseToAnthropicMessage( }) } } else { - content.push({ - type: 'text', - text: strippedContent, - }) + 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) { @@ -2315,25 +2424,7 @@ function convertNonStreamingResponseToAnthropicMessage( } const joined = parts.join('\n') if (joined) { - const strippedContent = stripThinkTags(joined) - 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, - }) - } + appendTextOrRecoveredToolCalls(joined) } } @@ -2811,7 +2902,7 @@ async function* openaiStreamToAnthropic( // converted to tool_use blocks at finalize; prose before it streams // normally, minus a trailing partial-opener prefix. const combined = xmlHoldback + delta.content - const openIdx = combined.indexOf(XML_TOOL_CALL_OPEN) + const openIdx = findXmlToolCallOpener(combined) if (openIdx !== -1) { const before = combined.slice(0, openIdx) if (before) yield* emitTextDelta(before) diff --git a/src/services/api/openaiShim.xmlToolCalls.test.ts b/src/services/api/openaiShim.xmlToolCalls.test.ts index 1f8c7092a0..7f4a6a3282 100644 --- a/src/services/api/openaiShim.xmlToolCalls.test.ts +++ b/src/services/api/openaiShim.xmlToolCalls.test.ts @@ -7,11 +7,12 @@ * visible prose and never execute, so the turn ends with no tool_use block and * the agent appears to "forget" and stop mid-task (the reported bug). * - * Covers the three dialects seen in the wild plus the streaming/non-streaming + * Covers the four dialects seen in the wild plus the streaming/non-streaming * pipeline integration: * A. VALUE… * B. NAMEKEYVALUE… * C. {"name":"NAME","arguments":{…}} + * D. NAMEVALUE… */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test' import { createOpenAIShimClient, parseXmlToolCalls } from './openaiShim.js' @@ -61,6 +62,13 @@ const glmToolChunk = (toolCalls: unknown[], finishReason?: string) => ({ choices: [{ index: 0, delta: { tool_calls: toolCalls }, finish_reason: finishReason ?? null }], }) +const hy3Chunk = (content: string, finishReason?: string) => ({ + id: 'chatcmpl-hy3', + object: 'chat.completion.chunk', + model: 'tencent/hy3', + choices: [{ index: 0, delta: { content }, finish_reason: finishReason ?? null }], +}) + // --------------------------------------------------------------------------- // Parser unit tests // --------------------------------------------------------------------------- @@ -122,6 +130,59 @@ describe('parseXmlToolCalls', () => { expect(calls[0].arguments).toEqual({ command: 'pwd' }) }) + test('dialect D: Tencent HY3 wrapper with named parameters', () => { + const text = + 'TaskCreate\n' + + 'Verify HY3' + + 'Run the live test' + + '' + const { calls, toolCallRanges } = parseXmlToolCalls(text) + + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe('TaskCreate') + expect(calls[0].arguments).toEqual({ + subject: 'Verify HY3', + description: 'Run the live test', + }) + expect(toolCallRanges).toEqual([[0, text.length]]) + }) + + test('dialect D: Tencent HY3 inline named arguments', () => { + const text = + 'TaskCreate\n' + + ' subject: Verify HY3\n' + + ' description: Run the live test\n' + + ' activeForm: Validating HY3\n' + + '' + const { calls } = parseXmlToolCalls(text) + + expect(calls).toHaveLength(1) + expect(calls[0].name).toBe('TaskCreate') + expect(calls[0].arguments).toEqual({ + subject: 'Verify HY3', + description: 'Run the live test', + activeForm: 'Validating HY3', + }) + }) + + test('does not treat a literal HY3 wrapper example as a tool call', () => { + const text = + 'Documentation: Run this example' + const { calls, toolCallRanges } = parseXmlToolCalls(text) + + expect(calls).toEqual([]) + expect(toolCallRanges).toEqual([]) + }) + + test('allows known zero-argument plan tools', () => { + const { calls } = parseXmlToolCalls( + 'EnterPlanMode', + ) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ name: 'EnterPlanMode', arguments: {} }) + }) + test('multiple tool calls in one message', () => { const text = 'a.ts' + @@ -312,4 +373,35 @@ describe('GLM streaming — XML tool calls', () => { expect(starts).toHaveLength(1) expect((starts[0].content_block as Record).name).toBe('Bash') }) + + test('recovers Tencent HY3 XML calls without exposing wrapper text', async () => { + const events = await run([ + hy3Chunk('TaskCreate\n subject: Verify HY3\n'), + hy3Chunk(' description: Run the live test\n'), + hy3Chunk('', 'stop'), + ]) + const starts = toolStarts(events) + expect(starts).toHaveLength(1) + expect((starts[0].content_block as Record).name).toBe('TaskCreate') + expect(textOf(events)).not.toContain(' event.type === 'content_block_delta' && (event.delta as Record)?.type === 'input_json_delta', + ) + expect(JSON.parse((jsonDelta!.delta as Record).partial_json)).toEqual({ + subject: 'Verify HY3', + description: 'Run the live test', + }) + }) + + test('strips a complete Tencent HY3 wrapper from streaming output', async () => { + const events = await run([ + hy3Chunk('TaskCreate\n subject: Verify HY3\n'), + hy3Chunk(' description: Run the live test\n'), + hy3Chunk('', 'stop'), + ]) + + expect(toolStarts(events)).toHaveLength(1) + expect(textOf(events)).not.toContain(' { + mock.restore() +}) describe('resolveToolSearchMode', () => { test('defaults to tst when nothing is configured', () => { @@ -43,3 +53,32 @@ describe('resolveToolSearchMode', () => { expect(resolveToolSearchMode(env, 'firstParty')).toBe('standard') }) }) + +describe('modelSupportsToolReference', () => { + test('keeps Tencent HY3 on the inline tool-schema path', () => { + expect(modelSupportsToolReference('tencent/hy3')).toBe(false) + expect(modelSupportsToolReference('hy3')).toBe(false) + }) + + test('does not defer TaskCreate for Tencent HY3', async () => { + expect( + await isToolSearchEnabled( + 'tencent/hy3', + [ToolSearchTool, TaskCreateTool], + async () => undefined as never, + [], + ), + ).toBe(false) + }) + + test('keeps built-in HY3 compatibility when feature flags add exceptions', async () => { + mock.module('../services/analytics/growthbook.js', () => ({ + getFeatureValue_CACHED_MAY_BE_STALE: () => ['haiku'], + })) + const freshToolSearch = await import( + `./toolSearch.ts?feature-flags-${Date.now()}` + ) + + expect(freshToolSearch.modelSupportsToolReference('tencent/hy3')).toBe(false) + }) +}) diff --git a/src/utils/toolSearch.ts b/src/utils/toolSearch.ts index fbda1a2d45..cd836e2165 100644 --- a/src/utils/toolSearch.ts +++ b/src/utils/toolSearch.ts @@ -239,11 +239,15 @@ export function getToolSearchMode(): ToolSearchMode { * Default patterns for models that do NOT support tool_reference. * New models are assumed to support tool_reference unless explicitly listed here. */ -const DEFAULT_UNSUPPORTED_MODEL_PATTERNS = ['haiku'] +// HY3 supports standard OpenAI function calls, but it does not reliably follow +// the extra ToolSearch round trip before calling a deferred tool. Keep its +// schemas inline so it can call tools such as TaskCreate on the first attempt. +const DEFAULT_UNSUPPORTED_MODEL_PATTERNS = ['haiku', 'hy3'] /** * Get the list of model patterns that do NOT support tool_reference. - * Can be configured via GrowthBook for live updates without code changes. + * A configured list adds temporary exceptions; it cannot remove built-in + * compatibility exceptions that protect known-unsupported models. */ function getUnsupportedToolReferencePatterns(): string[] { try { @@ -253,7 +257,7 @@ function getUnsupportedToolReferencePatterns(): string[] { null, ) if (patterns && Array.isArray(patterns) && patterns.length > 0) { - return patterns + return [...new Set([...DEFAULT_UNSUPPORTED_MODEL_PATTERNS, ...patterns])] } } catch { // GrowthBook not ready, use defaults From 6e6af0852d28a917ee6eb02472ff05f7e8441835 Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 9 Jul 2026 15:12:34 -0700 Subject: [PATCH 2/6] fix(hy3): parse documented tool wrapper --- src/services/api/openaiShim.ts | 33 ++++++++++++----- .../api/openaiShim.xmlToolCalls.test.ts | 35 +++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index a5f67f9ebc..5404b955f6 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1848,6 +1848,7 @@ const XML_PARAMETER_RE = /\s]+)\s*>([\s\S]*?)<\/parameter>/g const XML_ARG_PAIR_RE = /([\s\S]*?)<\/arg_key>\s*([\s\S]*?)<\/arg_value>/g const HY3_PARAMETER_RE = /\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 = /\s]+)?>([\s\S]*?)<\/arg_key(?::[^>\s]+)?>\s*\s]+)?>([\s\S]*?)<\/arg_value(?::[^>\s]+)?>/g const HY3_ZERO_ARGUMENT_TOOL_NAMES = new Set([ 'EnterPlanMode', 'ExitPlanMode', @@ -1892,6 +1893,13 @@ function parseHy3ToolCallInner(inner: string): { 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 and at least one named argument avoids executing or @@ -1943,22 +1951,29 @@ export function parseXmlToolCalls(text: string): { results.push({ id: `xml_tc_${++_textToolCallCounter}`, name, arguments: args }) } + const hy3Blocks = [...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 = [...text.matchAll(HY3_TOOL_CALLS_BLOCK_RE)] - .filter(wrapper => - [...(wrapper[1] ?? '').matchAll(HY3_TOOL_CALL_BLOCK_RE)].length > 0, - ) + .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 text.matchAll(HY3_TOOL_CALL_BLOCK_RE)) { - const { name, args } = parseHy3ToolCallInner(block[1] ?? '') + for (const block of hy3Blocks) { + const { name, args } = block.parsed if (!name) continue - const range: [number, number] = [ - block.index!, - block.index! + block[0].length, - ] + const range = block.range if (!hy3WrapperRanges.some(wrapper => wrapper[0] <= range[0] && range[1] <= wrapper[1])) { ranges.push(range) } diff --git a/src/services/api/openaiShim.xmlToolCalls.test.ts b/src/services/api/openaiShim.xmlToolCalls.test.ts index 7f4a6a3282..d132d36afc 100644 --- a/src/services/api/openaiShim.xmlToolCalls.test.ts +++ b/src/services/api/openaiShim.xmlToolCalls.test.ts @@ -183,6 +183,22 @@ describe('parseXmlToolCalls', () => { expect(calls[0]).toMatchObject({ name: 'EnterPlanMode', arguments: {} }) }) + test('dialect D: Tencent HY3 official tagged arguments', () => { + const text = + 'TaskCreate' + + 'subjectVerify HY3' + + 'descriptionRun the live test' + + '' + const { calls, toolCallRanges } = parseXmlToolCalls(text) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + name: 'TaskCreate', + arguments: { subject: 'Verify HY3', description: 'Run the live test' }, + }) + expect(toolCallRanges).toEqual([[0, text.length]]) + }) + test('multiple tool calls in one message', () => { const text = 'a.ts' + @@ -404,4 +420,23 @@ describe('GLM streaming — XML tool calls', () => { expect(textOf(events)).not.toContain(' { + const events = await run([ + hy3Chunk('TaskCreatesubject'), + hy3Chunk('Verify HY3descriptionRun the live test'), + hy3Chunk('', 'stop'), + ]) + + expect(toolStarts(events)).toHaveLength(1) + expect((toolStarts(events)[0].content_block as Record).name).toBe('TaskCreate') + expect(textOf(events)).not.toContain(' event.type === 'content_block_delta' && (event.delta as Record)?.type === 'input_json_delta', + ) + expect(JSON.parse((jsonDelta!.delta as Record).partial_json)).toEqual({ + subject: 'Verify HY3', + description: 'Run the live test', + }) + }) }) From d3170c5065a717b19a8ae995163e2b9b9e567b4a Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 9 Jul 2026 15:14:34 -0700 Subject: [PATCH 3/6] fix(hy3): retain split wrapper openers --- src/services/api/openaiShim.ts | 8 ++++++-- src/services/api/openaiShim.xmlToolCalls.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 5404b955f6..7cbe8ee650 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1919,13 +1919,17 @@ function parseHy3ToolCallInner(inner: string): { * opener split across SSE deltas so it is never emitted as visible text. */ function trailingXmlOpenerPrefixLen(s: string): number { + let longest = 0 for (const opener of XML_TOOL_CALL_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))) return len + if (opener.startsWith(s.slice(s.length - len))) { + longest = Math.max(longest, len) + break + } } } - return 0 + return longest } function findXmlToolCallOpener(text: string): number { diff --git a/src/services/api/openaiShim.xmlToolCalls.test.ts b/src/services/api/openaiShim.xmlToolCalls.test.ts index d132d36afc..fd8e3f9603 100644 --- a/src/services/api/openaiShim.xmlToolCalls.test.ts +++ b/src/services/api/openaiShim.xmlToolCalls.test.ts @@ -421,6 +421,18 @@ describe('GLM streaming — XML tool calls', () => { expect(textOf(events)).not.toContain(' { + const events = await run([ + hy3Chunk('TaskCreate\n subject: Verify HY3\n description: Run the live test\n'), + hy3Chunk('', 'stop'), + ]) + + expect(toolStarts(events)).toHaveLength(1) + expect((toolStarts(events)[0].content_block as Record).name).toBe('TaskCreate') + expect(textOf(events)).not.toContain(' { const events = await run([ hy3Chunk('TaskCreatesubject'), From 645e7b4ae2d2c2877506bf4dfa831e0d4e7a29ff Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 9 Jul 2026 15:53:08 -0700 Subject: [PATCH 4/6] fix(hy3): recover zero-argument tool calls --- src/services/api/openaiShim.test.ts | 43 +++++++++++++++++++ src/services/api/openaiShim.ts | 12 ++---- .../api/openaiShim.xmlToolCalls.test.ts | 21 +++++++-- 3 files changed, 65 insertions(+), 11 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 98e274ffc1..99be1822f0 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -10145,6 +10145,49 @@ test('JSON fallback: recovers raw-text tool call into tool_use block', async () }) +test('JSON fallback: recovers Tencent HY3 text tool calls into tool_use blocks', async () => { + const events = await collectFallbackEvents({ + id: 'chatcmpl-json-hy3', + model: 'tencent/hy3', + choices: [ + { + message: { + role: 'assistant', + content: + 'TaskCreate\n subject: Verify HY3\n description: Run the live test\n', + }, + finish_reason: 'stop', + }, + ], + }) + const toolStart = events.find( + event => + event.type === 'content_block_start' && + typeof event.content_block === 'object' && + event.content_block !== null && + (event.content_block as Record).type === 'tool_use', + ) as { content_block?: Record } | undefined + expect(toolStart?.content_block).toMatchObject({ + type: 'tool_use', + name: 'TaskCreate', + }) + const jsonDelta = events.find( + event => + event.type === 'content_block_delta' && + typeof event.delta === 'object' && + event.delta !== null && + (event.delta as Record).type === 'input_json_delta', + ) as { delta?: { partial_json?: string } } | undefined + expect(JSON.parse(jsonDelta?.delta?.partial_json ?? '')).toEqual({ + subject: 'Verify HY3', + description: 'Run the live test', + }) + const stopEvent = events.find(e => e.type === 'message_delta') as + | { delta?: { stop_reason?: string } } + | undefined + expect(stopEvent?.delta?.stop_reason).toBe('tool_use') +}) + test('JSON fallback: empty tool_calls array does not block raw-text recovery', async () => { // tool_calls: [] is truthy; it must be treated as "no structured tool calls" // so the raw "Tool calls requested" recovery still runs. diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 7cbe8ee650..186cb7e100 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1849,11 +1849,6 @@ const XML_ARG_PAIR_RE = /([\s\S]*?)<\/arg_key>\s*([\s\S]*?)< const HY3_PARAMETER_RE = /\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 = /\s]+)?>([\s\S]*?)<\/arg_key(?::[^>\s]+)?>\s*\s]+)?>([\s\S]*?)<\/arg_value(?::[^>\s]+)?>/g -const HY3_ZERO_ARGUMENT_TOOL_NAMES = new Set([ - 'EnterPlanMode', - 'ExitPlanMode', - 'TaskList', -]) // Parameter/arg values arrive as untyped text. Try JSON first so numbers, // booleans, and nested objects round-trip; fall back to the raw string. @@ -1902,11 +1897,12 @@ function parseHy3ToolCallInner(inner: string): { } // The provider's textual wrapper is not self-authenticating. Requiring a - // normal tool identifier and at least one named argument avoids executing or - // hiding documentation snippets that merely demonstrate ``. + // normal tool identifier avoids executing or hiding documentation snippets + // that merely demonstrate ``, 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 || HY3_ZERO_ARGUMENT_TOOL_NAMES.has(name)) + (hasStructuredArguments || trimmed === name) ? name : undefined, args, diff --git a/src/services/api/openaiShim.xmlToolCalls.test.ts b/src/services/api/openaiShim.xmlToolCalls.test.ts index fd8e3f9603..2990cb2272 100644 --- a/src/services/api/openaiShim.xmlToolCalls.test.ts +++ b/src/services/api/openaiShim.xmlToolCalls.test.ts @@ -174,13 +174,13 @@ describe('parseXmlToolCalls', () => { expect(toolCallRanges).toEqual([]) }) - test('allows known zero-argument plan tools', () => { + test('allows zero-argument tools without a name allowlist', () => { const { calls } = parseXmlToolCalls( - 'EnterPlanMode', + 'CronList', ) expect(calls).toHaveLength(1) - expect(calls[0]).toMatchObject({ name: 'EnterPlanMode', arguments: {} }) + expect(calls[0]).toMatchObject({ name: 'CronList', arguments: {} }) }) test('dialect D: Tencent HY3 official tagged arguments', () => { @@ -409,6 +409,21 @@ describe('GLM streaming — XML tool calls', () => { }) }) + test('recovers zero-argument Tencent HY3 calls from streaming output', async () => { + const events = await run([ + hy3Chunk('CronList'), + hy3Chunk('', 'stop'), + ]) + + expect(toolStarts(events)).toHaveLength(1) + expect((toolStarts(events)[0].content_block as Record).name).toBe('CronList') + expect(textOf(events)).not.toContain(' event.type === 'content_block_delta' && (event.delta as Record)?.type === 'input_json_delta', + ) + expect(JSON.parse((jsonDelta!.delta as Record).partial_json)).toEqual({}) + }) + test('strips a complete Tencent HY3 wrapper from streaming output', async () => { const events = await run([ hy3Chunk('TaskCreate\n subject: Verify HY3\n'), From e4d53b7f39ed82715219a756d08eb6103fc8fb6d Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 9 Jul 2026 16:06:35 -0700 Subject: [PATCH 5/6] test(hy3): restore fetch after XML stream tests --- .../api/openaiShim.xmlToolCalls.test.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/services/api/openaiShim.xmlToolCalls.test.ts b/src/services/api/openaiShim.xmlToolCalls.test.ts index 2990cb2272..150fa04bf3 100644 --- a/src/services/api/openaiShim.xmlToolCalls.test.ts +++ b/src/services/api/openaiShim.xmlToolCalls.test.ts @@ -264,20 +264,25 @@ describe('GLM streaming — XML tool calls', () => { }) async function run(chunks: unknown[]): Promise[]> { + const previousFetch = globalThis.fetch globalThis.fetch = (async () => makeSseResponse(makeChunks(chunks))) as unknown as FetchType - const client = createOpenAIShimClient({}) as OpenAIShimClient - const result = await client.beta.messages - .create({ - model: 'glm-5.2', - messages: [{ role: 'user', content: 'do it' }], - max_tokens: 64, - stream: true, - }) - .withResponse() - const events: Record[] = [] - for await (const event of result.data) events.push(event) - return events + try { + const client = createOpenAIShimClient({}) as OpenAIShimClient + const result = await client.beta.messages + .create({ + model: 'glm-5.2', + messages: [{ role: 'user', content: 'do it' }], + max_tokens: 64, + stream: true, + }) + .withResponse() + const events: Record[] = [] + for await (const event of result.data) events.push(event) + return events + } finally { + globalThis.fetch = previousFetch + } } const textOf = (events: Record[]) => From 83ecf4fc085479cc92fa4f67bbcc4dcfd38fdc95 Mon Sep 17 00:00:00 2001 From: jatmn Date: Thu, 9 Jul 2026 16:17:52 -0700 Subject: [PATCH 6/6] fix(hy3): scope compatibility to Tencent --- src/services/api/openaiShim.test.ts | 37 +++++++++- src/services/api/openaiShim.ts | 73 ++++++++++++------- .../api/openaiShim.xmlToolCalls.test.ts | 54 +++++++++++--- src/utils/toolSearch.test.ts | 3 +- src/utils/toolSearch.ts | 7 +- 5 files changed, 135 insertions(+), 39 deletions(-) diff --git a/src/services/api/openaiShim.test.ts b/src/services/api/openaiShim.test.ts index 99be1822f0..067cf44177 100644 --- a/src/services/api/openaiShim.test.ts +++ b/src/services/api/openaiShim.test.ts @@ -9920,6 +9920,7 @@ function makeJsonChatCompletion(body: Record): Response { async function collectFallbackEvents( body: Record, + model = 'fake-model', ): Promise>> { const previousFetch = globalThis.fetch globalThis.fetch = (async () => makeJsonChatCompletion(body)) as unknown as FetchType @@ -9927,7 +9928,7 @@ async function collectFallbackEvents( const client = createOpenAIShimClient({}) as OpenAIShimClient const result = await client.beta.messages .create({ - model: 'fake-model', + model, messages: [{ role: 'user', content: 'hi' }], max_tokens: 64, stream: true, @@ -10159,7 +10160,7 @@ test('JSON fallback: recovers Tencent HY3 text tool calls into tool_use blocks', finish_reason: 'stop', }, ], - }) + }, 'tencent/hy3') const toolStart = events.find( event => event.type === 'content_block_start' && @@ -10188,6 +10189,38 @@ test('JSON fallback: recovers Tencent HY3 text tool calls into tool_use blocks', expect(stopEvent?.delta?.stop_reason).toBe('tool_use') }) +test('JSON fallback: preserves HY3-looking text for non-Tencent model names', async () => { + const text = + 'TaskCreate\nsubject: merely a documentation example\n' + const events = await collectFallbackEvents({ + id: 'chatcmpl-json-non-tencent-hy3', + model: 'other/hy3-documentation', + choices: [ + { + message: { role: 'assistant', content: text }, + finish_reason: 'stop', + }, + ], + }, 'other/hy3-documentation') + const toolStart = events.find( + event => + event.type === 'content_block_start' && + typeof event.content_block === 'object' && + event.content_block !== null && + (event.content_block as Record).type === 'tool_use', + ) + const textDelta = events.find( + event => + event.type === 'content_block_delta' && + typeof event.delta === 'object' && + event.delta !== null && + (event.delta as Record).type === 'text_delta', + ) as { delta?: { text?: string } } | undefined + + expect(toolStart).toBeUndefined() + expect(textDelta?.delta?.text).toBe(text) +}) + test('JSON fallback: empty tool_calls array does not block raw-text recovery', async () => { // tool_calls: [] is truthy; it must be treated as "no structured tool calls" // so the raw "Tool calls requested" recovery still runs. diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index 186cb7e100..40b2f15710 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1909,14 +1909,19 @@ function parseHy3ToolCallInner(inner: string): { } } +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 `` 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): number { +function trailingXmlOpenerPrefixLen(s: string, allowHy3: boolean): number { let longest = 0 - for (const opener of XML_TOOL_CALL_OPENERS) { + 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))) { @@ -1928,15 +1933,16 @@ function trailingXmlOpenerPrefixLen(s: string): number { return longest } -function findXmlToolCallOpener(text: string): number { - return XML_TOOL_CALL_OPENERS.reduce((first, opener) => { +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): { +export function parseXmlToolCalls(text: string, allowHy3 = false): { calls: ParsedTextToolCall[] toolCallRanges: Array<[number, number]> } { @@ -1951,24 +1957,28 @@ export function parseXmlToolCalls(text: string): { results.push({ id: `xml_tc_${++_textToolCallCounter}`, name, arguments: args }) } - const hy3Blocks = [...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 = [...text.matchAll(HY3_TOOL_CALLS_BLOCK_RE)] - .filter(wrapper => { - const range: [number, number] = [ + 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, - ] - 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]) + ] as [number, number]) + : [] for (const block of hy3Blocks) { const { name, args } = block.parsed @@ -2391,7 +2401,10 @@ function convertNonStreamingResponseToAnthropicMessage( const appendTextOrRecoveredToolCalls = (rawText: string) => { const strippedContent = stripThinkTags(rawText) if (!hasStructuredToolCalls) { - const { calls: xmlToolCalls, toolCallRanges } = parseXmlToolCalls(strippedContent) + 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 }) @@ -2513,6 +2526,7 @@ async function* openaiStreamToAnthropic( requestUrl?: string, ): AsyncGenerator { const messageId = makeMessageId() + const allowHy3ToolCalls = isHy3Model(model) let contentBlockIndex = 0 const activeToolCalls = new Map< number, @@ -2917,14 +2931,20 @@ async function* openaiStreamToAnthropic( // converted to tool_use blocks at finalize; prose before it streams // normally, minus a trailing partial-opener prefix. const combined = xmlHoldback + delta.content - const openIdx = findXmlToolCallOpener(combined) + const openIdx = findXmlToolCallOpener( + combined, + allowHy3ToolCalls, + ) if (openIdx !== -1) { const before = combined.slice(0, openIdx) if (before) yield* emitTextDelta(before) xmlHoldback = '' xmlToolCallText = combined.slice(openIdx) } else { - const keep = trailingXmlOpenerPrefixLen(combined) + const keep = trailingXmlOpenerPrefixLen( + combined, + allowHy3ToolCalls, + ) const emit = keep > 0 ? combined.slice(0, combined.length - keep) : combined xmlHoldback = keep > 0 ? combined.slice(combined.length - keep) : '' @@ -3180,7 +3200,10 @@ async function* openaiStreamToAnthropic( if (!isOllamaStream && xmlToolCallText !== null) { const buffered = xmlToolCallText xmlToolCallText = null - const { calls, toolCallRanges } = parseXmlToolCalls(buffered) + const { calls, toolCallRanges } = parseXmlToolCalls( + buffered, + allowHy3ToolCalls, + ) if (calls.length > 0) { const stripped = stripRanges(buffered, toolCallRanges).trim() const strippedVisible = stripThinkTags(stripped).trim() diff --git a/src/services/api/openaiShim.xmlToolCalls.test.ts b/src/services/api/openaiShim.xmlToolCalls.test.ts index 150fa04bf3..fce5495800 100644 --- a/src/services/api/openaiShim.xmlToolCalls.test.ts +++ b/src/services/api/openaiShim.xmlToolCalls.test.ts @@ -136,7 +136,7 @@ describe('parseXmlToolCalls', () => { 'Verify HY3' + 'Run the live test' + '' - const { calls, toolCallRanges } = parseXmlToolCalls(text) + const { calls, toolCallRanges } = parseXmlToolCalls(text, true) expect(calls).toHaveLength(1) expect(calls[0].name).toBe('TaskCreate') @@ -154,7 +154,7 @@ describe('parseXmlToolCalls', () => { ' description: Run the live test\n' + ' activeForm: Validating HY3\n' + '' - const { calls } = parseXmlToolCalls(text) + const { calls } = parseXmlToolCalls(text, true) expect(calls).toHaveLength(1) expect(calls[0].name).toBe('TaskCreate') @@ -168,6 +168,15 @@ describe('parseXmlToolCalls', () => { test('does not treat a literal HY3 wrapper example as a tool call', () => { const text = 'Documentation: Run this example' + const { calls, toolCallRanges } = parseXmlToolCalls(text, true) + + expect(calls).toEqual([]) + expect(toolCallRanges).toEqual([]) + }) + + test('does not recover structured HY3 examples for non-HY3 routes', () => { + const text = + 'TaskCreate\nsubject: merely a documentation example\n' const { calls, toolCallRanges } = parseXmlToolCalls(text) expect(calls).toEqual([]) @@ -177,6 +186,7 @@ describe('parseXmlToolCalls', () => { test('allows zero-argument tools without a name allowlist', () => { const { calls } = parseXmlToolCalls( 'CronList', + true, ) expect(calls).toHaveLength(1) @@ -189,7 +199,7 @@ describe('parseXmlToolCalls', () => { 'subjectVerify HY3' + 'descriptionRun the live test' + '' - const { calls, toolCallRanges } = parseXmlToolCalls(text) + const { calls, toolCallRanges } = parseXmlToolCalls(text, true) expect(calls).toHaveLength(1) expect(calls[0]).toMatchObject({ @@ -263,7 +273,10 @@ describe('GLM streaming — XML tool calls', () => { delete process.env.OPENAI_BASE_URL }) - async function run(chunks: unknown[]): Promise[]> { + async function run( + chunks: unknown[], + model = 'glm-5.2', + ): Promise[]> { const previousFetch = globalThis.fetch globalThis.fetch = (async () => makeSseResponse(makeChunks(chunks))) as unknown as FetchType @@ -271,7 +284,7 @@ describe('GLM streaming — XML tool calls', () => { const client = createOpenAIShimClient({}) as OpenAIShimClient const result = await client.beta.messages .create({ - model: 'glm-5.2', + model, messages: [{ role: 'user', content: 'do it' }], max_tokens: 64, stream: true, @@ -356,6 +369,27 @@ describe('GLM streaming — XML tool calls', () => { expect(textOf(events)).toBe('The tag is how models request tools.') }) + test('does not recover a structured HY3 example for a non-HY3 model', async () => { + const text = + 'TaskCreate\nsubject: merely a documentation example\n' + const events = await run([glmChunk(text), glmChunk('', 'stop')]) + + expect(toolStarts(events)).toHaveLength(0) + expect(textOf(events)).toBe(text) + }) + + test('does not recover a structured HY3 example for a non-Tencent model name', async () => { + const text = + 'TaskCreate\nsubject: merely a documentation example\n' + const events = await run( + [glmChunk(text), glmChunk('', 'stop')], + 'other/hy3-documentation', + ) + + expect(toolStarts(events)).toHaveLength(0) + expect(textOf(events)).toBe(text) + }) + // Locks in current behavior: when a single streamed message contains prose // *between* two XML tool calls, all surviving prose is emitted in one text // block BEFORE the tool_use blocks (the interleave is flattened to @@ -400,7 +434,7 @@ describe('GLM streaming — XML tool calls', () => { hy3Chunk('TaskCreate\n subject: Verify HY3\n'), hy3Chunk(' description: Run the live test\n'), hy3Chunk('', 'stop'), - ]) + ], 'tencent/hy3') const starts = toolStarts(events) expect(starts).toHaveLength(1) expect((starts[0].content_block as Record).name).toBe('TaskCreate') @@ -418,7 +452,7 @@ describe('GLM streaming — XML tool calls', () => { const events = await run([ hy3Chunk('CronList'), hy3Chunk('', 'stop'), - ]) + ], 'tencent/hy3') expect(toolStarts(events)).toHaveLength(1) expect((toolStarts(events)[0].content_block as Record).name).toBe('CronList') @@ -434,7 +468,7 @@ describe('GLM streaming — XML tool calls', () => { hy3Chunk('TaskCreate\n subject: Verify HY3\n'), hy3Chunk(' description: Run the live test\n'), hy3Chunk('', 'stop'), - ]) + ], 'tencent/hy3') expect(toolStarts(events)).toHaveLength(1) expect(textOf(events)).not.toContain(' { hy3Chunk('TaskCreate\n subject: Verify HY3\n description: Run the live test\n'), hy3Chunk('', 'stop'), - ]) + ], 'tencent/hy3') expect(toolStarts(events)).toHaveLength(1) expect((toolStarts(events)[0].content_block as Record).name).toBe('TaskCreate') @@ -458,7 +492,7 @@ describe('GLM streaming — XML tool calls', () => { hy3Chunk('TaskCreatesubject'), hy3Chunk('Verify HY3descriptionRun the live test'), hy3Chunk('', 'stop'), - ]) + ], 'tencent/hy3') expect(toolStarts(events)).toHaveLength(1) expect((toolStarts(events)[0].content_block as Record).name).toBe('TaskCreate') diff --git a/src/utils/toolSearch.test.ts b/src/utils/toolSearch.test.ts index 3207e8ad52..00da01c2dc 100644 --- a/src/utils/toolSearch.test.ts +++ b/src/utils/toolSearch.test.ts @@ -57,7 +57,8 @@ describe('resolveToolSearchMode', () => { describe('modelSupportsToolReference', () => { test('keeps Tencent HY3 on the inline tool-schema path', () => { expect(modelSupportsToolReference('tencent/hy3')).toBe(false) - expect(modelSupportsToolReference('hy3')).toBe(false) + expect(modelSupportsToolReference('tencent/hy3?reasoning=high')).toBe(false) + expect(modelSupportsToolReference('other/hy3-documentation')).toBe(true) }) test('does not defer TaskCreate for Tencent HY3', async () => { diff --git a/src/utils/toolSearch.ts b/src/utils/toolSearch.ts index cd836e2165..8b164091c4 100644 --- a/src/utils/toolSearch.ts +++ b/src/utils/toolSearch.ts @@ -242,7 +242,8 @@ export function getToolSearchMode(): ToolSearchMode { // HY3 supports standard OpenAI function calls, but it does not reliably follow // the extra ToolSearch round trip before calling a deferred tool. Keep its // schemas inline so it can call tools such as TaskCreate on the first attempt. -const DEFAULT_UNSUPPORTED_MODEL_PATTERNS = ['haiku', 'hy3'] +const BUILT_IN_UNSUPPORTED_MODEL_IDS = new Set(['tencent/hy3']) +const DEFAULT_UNSUPPORTED_MODEL_PATTERNS = ['haiku'] /** * Get the list of model patterns that do NOT support tool_reference. @@ -280,6 +281,10 @@ function getUnsupportedToolReferencePatterns(): string[] { */ export function modelSupportsToolReference(model: string): boolean { const normalizedModel = model.toLowerCase() + const canonicalModelId = normalizedModel.split('?', 1)[0] ?? normalizedModel + if (BUILT_IN_UNSUPPORTED_MODEL_IDS.has(canonicalModelId)) { + return false + } const unsupportedPatterns = getUnsupportedToolReferencePatterns() // Check if model matches any unsupported pattern