Skip to content

Commit 847b54b

Browse files
committed
fix(webuiapps): parse inline local-model tool calls
1 parent 7b22f6d commit 847b54b

2 files changed

Lines changed: 85 additions & 2 deletions

File tree

apps/webuiapps/src/lib/__tests__/llmClient.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,26 @@ describe('chat()', () => {
459459

460460
expect(result.content).toBe('Hello there');
461461
});
462+
463+
it('converts inline XML-style tool call content into structured tool calls', async () => {
464+
const inlineToolContent = `<tool_call>
465+
respond_to_user
466+
<arg_key>character_expression</arg_key>
467+
<arg_value>{"content":"What? Did I catch you off guard?","emotion":"happy"}</arg_value>
468+
<arg_key>user_interaction</arg_key>
469+
<arg_value>{"suggested_replies":["Just hanging around","What reunion?","Tell me more"]}</arg_value>
470+
</tool_call>`;
471+
globalThis.fetch = vi.fn().mockResolvedValueOnce(makeOpenAIResponse(inlineToolContent));
472+
473+
const result = await chat(MOCK_MESSAGES, MOCK_TOOLS, MOCK_LLAMACPP_CONFIG);
474+
475+
expect(result.content).toBe('');
476+
expect(result.toolCalls).toHaveLength(1);
477+
expect(result.toolCalls[0].function.name).toBe('respond_to_user');
478+
expect(result.toolCalls[0].function.arguments).toBe(
479+
'{"character_expression":{"content":"What? Did I catch you off guard?","emotion":"happy"},"user_interaction":{"suggested_replies":["Just hanging around","What reunion?","Tell me more"]}}',
480+
);
481+
});
462482
});
463483

464484
describe('Anthropic provider', () => {

apps/webuiapps/src/lib/llmClient.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,73 @@ interface LLMResponse {
8888
toolCalls: ToolCall[];
8989
}
9090

91+
interface InlineToolParseResult {
92+
content: string;
93+
toolCalls: ToolCall[];
94+
}
95+
9196
function stripThinkTags(content: string): string {
9297
const withoutBlocks = content
9398
.replace(/<think\b[^>]*>[\s\S]*?<\/think>/gi, '')
9499
.replace(/<\/?think\b[^>]*>/gi, '');
95100
return withoutBlocks === content ? content : withoutBlocks.trim();
96101
}
97102

103+
function parseInlineArgValue(rawValue: string): unknown {
104+
const trimmed = rawValue.trim();
105+
if (!trimmed) return '';
106+
try {
107+
return JSON.parse(trimmed);
108+
} catch {
109+
return trimmed;
110+
}
111+
}
112+
113+
function extractInlineToolCalls(rawContent: string): InlineToolParseResult {
114+
const content = stripThinkTags(rawContent);
115+
if (!content.includes('<arg_key>') || !content.includes('<arg_value>')) {
116+
return { content, toolCalls: [] };
117+
}
118+
119+
const blockRegex = /(?:<tool_call>\s*|\()([a-zA-Z0-9_.-]+)\s*([\s\S]*?)<\/tool_call>/g;
120+
const toolCalls: ToolCall[] = [];
121+
let cleanedContent = content;
122+
let matchIndex = 0;
123+
124+
for (const match of content.matchAll(blockRegex)) {
125+
const toolName = match[1]?.trim();
126+
const body = match[2] ?? '';
127+
if (!toolName) continue;
128+
129+
const args: Record<string, unknown> = {};
130+
const pairRegex =
131+
/<arg_key>\s*([\s\S]*?)\s*<\/arg_key>\s*<arg_value>\s*([\s\S]*?)\s*<\/arg_value>/g;
132+
133+
for (const pair of body.matchAll(pairRegex)) {
134+
const key = pair[1]?.trim();
135+
if (!key) continue;
136+
args[key] = parseInlineArgValue(pair[2] ?? '');
137+
}
138+
139+
if (Object.keys(args).length === 0) continue;
140+
141+
toolCalls.push({
142+
id: `inline_tool_${matchIndex++}`,
143+
type: 'function',
144+
function: {
145+
name: toolName,
146+
arguments: JSON.stringify(args),
147+
},
148+
});
149+
cleanedContent = cleanedContent.replace(match[0], '');
150+
}
151+
152+
return {
153+
content: cleanedContent.trim(),
154+
toolCalls,
155+
};
156+
}
157+
98158
function hasVersionSuffix(url: string): boolean {
99159
return /\/v\d+\/?$/.test(url);
100160
}
@@ -193,7 +253,8 @@ async function chatOpenAI(
193253

194254
const data = JSON.parse(text);
195255
const choice = data.choices?.[0]?.message;
196-
const toolCalls = choice?.tool_calls || [];
256+
const parsedInline = extractInlineToolCalls(choice?.content || '');
257+
const toolCalls = choice?.tool_calls?.length ? choice.tool_calls : parsedInline.toolCalls;
197258
const calledNames = toolCalls
198259
.map((tc: { function?: { name?: string } }) => tc.function?.name)
199260
.filter(Boolean);
@@ -205,7 +266,9 @@ async function chatOpenAI(
205266
calledNames,
206267
);
207268
return {
208-
content: stripThinkTags(choice?.content || ''),
269+
content: choice?.tool_calls?.length
270+
? stripThinkTags(choice?.content || '')
271+
: parsedInline.content,
209272
toolCalls,
210273
};
211274
}

0 commit comments

Comments
 (0)