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.test.ts b/src/services/api/openaiShim.test.ts index 98e274ffc1..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, @@ -10145,6 +10146,81 @@ 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', + }, + ], + }, 'tencent/hy3') + 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: 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 124085a20c..40b2f15710 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -1817,26 +1817,38 @@ 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_ARG_PAIR_RE = /\s]+)?>([\s\S]*?)<\/arg_key(?::[^>\s]+)?>\s*\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. @@ -1850,21 +1862,87 @@ 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] ?? '') + } + } + 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 ``, 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 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 { - 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 +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 0 + return longest +} + +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]> } { @@ -1872,6 +1950,48 @@ 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 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] = [ @@ -1927,10 +2047,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 +2398,28 @@ 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, + 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) @@ -2296,11 +2433,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 +2452,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) } } @@ -2407,6 +2526,7 @@ async function* openaiStreamToAnthropic( requestUrl?: string, ): AsyncGenerator { const messageId = makeMessageId() + const allowHy3ToolCalls = isHy3Model(model) let contentBlockIndex = 0 const activeToolCalls = new Map< number, @@ -2811,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 = combined.indexOf(XML_TOOL_CALL_OPEN) + 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) : '' @@ -3074,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 1f8c7092a0..fce5495800 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,85 @@ 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, true) + + 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, true) + + 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, 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([]) + expect(toolCallRanges).toEqual([]) + }) + + test('allows zero-argument tools without a name allowlist', () => { + const { calls } = parseXmlToolCalls( + 'CronList', + true, + ) + + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ name: 'CronList', arguments: {} }) + }) + + test('dialect D: Tencent HY3 official tagged arguments', () => { + const text = + 'TaskCreate' + + 'subjectVerify HY3' + + 'descriptionRun the live test' + + '' + const { calls, toolCallRanges } = parseXmlToolCalls(text, true) + + 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' + @@ -186,21 +273,29 @@ 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 - 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, + 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[]) => @@ -274,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 @@ -312,4 +428,81 @@ 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'), + ], 'tencent/hy3') + 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('recovers zero-argument Tencent HY3 calls from streaming output', async () => { + 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') + 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'), + hy3Chunk(' description: Run the live test\n'), + hy3Chunk('', 'stop'), + ], 'tencent/hy3') + + expect(toolStarts(events)).toHaveLength(1) + expect(textOf(events)).not.toContain(' { + const events = await run([ + 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') + expect(textOf(events)).not.toContain(' { + const events = await run([ + 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') + 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', + }) + }) }) diff --git a/src/utils/toolSearch.test.ts b/src/utils/toolSearch.test.ts index 379e0f7408..00da01c2dc 100644 --- a/src/utils/toolSearch.test.ts +++ b/src/utils/toolSearch.test.ts @@ -1,5 +1,15 @@ -import { describe, expect, test } from 'bun:test' -import { resolveToolSearchMode } from './toolSearch.js' +import { afterEach, describe, expect, mock, test } from 'bun:test' +import { + isToolSearchEnabled, + modelSupportsToolReference, + resolveToolSearchMode, +} from './toolSearch.js' +import { TaskCreateTool } from '../tools/TaskCreateTool/TaskCreateTool.js' +import { ToolSearchTool } from '../tools/ToolSearchTool/ToolSearchTool.js' + +afterEach(() => { + mock.restore() +}) describe('resolveToolSearchMode', () => { test('defaults to tst when nothing is configured', () => { @@ -43,3 +53,33 @@ 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('tencent/hy3?reasoning=high')).toBe(false) + expect(modelSupportsToolReference('other/hy3-documentation')).toBe(true) + }) + + 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..8b164091c4 100644 --- a/src/utils/toolSearch.ts +++ b/src/utils/toolSearch.ts @@ -239,11 +239,16 @@ 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. */ +// 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 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. - * 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 +258,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 @@ -276,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