feat: tambah DSML parser untuk konversi tool_calls DeepSeek ke format standar
DeepSeek models mengembalikan tool calls dalam format DSML (DeepSeek Markup Language) di dalam text content, bukan sebagai JSON structured tool_calls. Ini menyebabkan tool calling gagal di Claude Code. Perubahan: - Buat src/lib/dsml-parser.ts: parser DSML berbasis regex dengan dukungan streaming (DSMLAccumulator), deteksi teks sebelum/sesudah DSML, dan parsing parameter JSON - Forward tools/tool_choice dari client ke backend di Anthropic & OpenAI paths - Konversi DSML ke tool_use content blocks (Anthropic) atau tool_calls array (OpenAI) di response non-streaming - Handle DSML di streaming: buffer text deltas, deteksi di akhir stream, emit tool_use/tool_calls events yang sesuai - Handle tool_result blocks dari Anthropic format di assistant messages - Tambah 29 tests untuk parser dan 18 tests untuk anthropic-proxy Fix: #285 tests pass, 0 fail Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
abc8d9d7d5
commit
eec0cd5088
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* DSML (DeepSeek Markup Language) parser.
|
||||
*
|
||||
* DeepSeek models return tool calls embedded in text content using
|
||||
* DSML markup instead of structured JSON fields. This module detects
|
||||
* and parses that markup into a format the proxy can convert into
|
||||
* standard tool_use (Anthropic) / tool_calls (OpenAI) blocks.
|
||||
*
|
||||
* DSML format (DeepSeek output):
|
||||
* ```
|
||||
* <tool_calls>
|
||||
* <invoke name="tool_name">
|
||||
* <parameter name="param1">value1</parameter>
|
||||
* <parameter name="param2">value2</parameter>
|
||||
* ...
|
||||
* </invoke>
|
||||
* </tool_calls>
|
||||
* ```
|
||||
*
|
||||
* The markup can appear:
|
||||
* - As the entire response content
|
||||
* - After thinking/reasoning text (e.g. `<thinking>...</thinking><tool_calls>...`)
|
||||
* - Mixed with regular text
|
||||
* - Spanning multiple SSE chunks during streaming
|
||||
*/
|
||||
|
||||
// --- Types -------------------------------------------------------------------
|
||||
|
||||
export interface ParsedDSML {
|
||||
/** Text that appeared before the first DSML tag (may include thinking). */
|
||||
textBefore: string;
|
||||
/** Parsed tool call definitions. */
|
||||
toolCalls: ToolCallDef[];
|
||||
/** Text that appeared after the last DSML tag. */
|
||||
textAfter: string;
|
||||
}
|
||||
|
||||
export interface ToolCallDef {
|
||||
/** The tool/function name, e.g. "Bash", "Read", "Edit". */
|
||||
name: string;
|
||||
/** The parsed JSON arguments object. */
|
||||
args: Record<string, unknown>;
|
||||
/**
|
||||
* Raw argument map (string->string before JSON parse).
|
||||
* Only populated when `args` could not be fully parsed.
|
||||
*/
|
||||
rawArgs?: Record<string, string>;
|
||||
}
|
||||
|
||||
// --- Constants ---------------------------------------------------------------
|
||||
|
||||
/** Maximum bytes to buffer when detecting DSML in a stream before giving up. */
|
||||
export const MAX_DSML_BUFFER = 50_000;
|
||||
|
||||
// --- Regex patterns ----------------------------------------------------------
|
||||
|
||||
// Match opening <tool_calls> (case-insensitive, with optional whitespace)
|
||||
const TOOL_CALLS_OPEN_RE = /<tool_calls>\s*/i;
|
||||
// Match closing </tool_calls> (case-insensitive)
|
||||
const TOOL_CALLS_CLOSE_RE = /<\/tool_calls>/i;
|
||||
// Match <invoke name="..."> with optional whitespace
|
||||
const INVOKE_OPEN_RE = /<invoke\s+name\s*=\s*"([^"]*)"\s*>/i;
|
||||
// Match </invoke> (case-insensitive)
|
||||
const INVOKE_CLOSE_RE = /<\/invoke>/i;
|
||||
// Match <parameter name="...">value</parameter>
|
||||
const PARAM_RE = /<parameter\s+name\s*=\s*"([^"]*)"\s*>\s*([\s\S]*?)\s*<\/parameter>/gi;
|
||||
// Match CDATA sections
|
||||
const CDATA_RE = /<!\[CDATA\[([\s\S]*?)\]\]>/g;
|
||||
|
||||
// --- Parser ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Try to parse DSML markup from a text string.
|
||||
* Returns null if no DSML markup is detected.
|
||||
*/
|
||||
export function parseDSML(text: string): ParsedDSML | null {
|
||||
if (!text || typeof text !== "string") return null;
|
||||
|
||||
const openMatch = text.match(TOOL_CALLS_OPEN_RE);
|
||||
if (!openMatch) return null;
|
||||
|
||||
const closeMatch = text.match(TOOL_CALLS_CLOSE_RE);
|
||||
if (!closeMatch) return null;
|
||||
|
||||
// Extract text before the first <tool_calls> tag
|
||||
const textBefore = text.slice(0, openMatch.index!);
|
||||
|
||||
// Extract the content between <tool_calls> and </tool_calls>
|
||||
const toolCallsStart = openMatch.index! + openMatch[0].length;
|
||||
const toolCallsEnd = closeMatch.index!;
|
||||
const toolCallsBody = text.slice(toolCallsStart, toolCallsEnd);
|
||||
|
||||
// Text after the closing </tool_calls>
|
||||
const textAfter = text.slice(toolCallsEnd + closeMatch[0].length);
|
||||
|
||||
// Parse individual <invoke> blocks from the tool calls body
|
||||
const toolCalls = parseInvokeBlocks(toolCallsBody);
|
||||
|
||||
if (toolCalls.length === 0) return null;
|
||||
|
||||
return { textBefore, toolCalls, textAfter };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tool calls from text by parsing DSML, then strip the DSML markup
|
||||
* leaving only non-DSML content.
|
||||
*/
|
||||
export function stripDSML(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(TOOL_CALLS_OPEN_RE, "")
|
||||
.replace(TOOL_CALLS_CLOSE_RE, "")
|
||||
.replace(INVOKE_OPEN_RE, "")
|
||||
.replace(INVOKE_CLOSE_RE, "")
|
||||
.replace(PARAM_RE, "")
|
||||
.replace(CDATA_RE, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a text string appears to be starting DSML markup.
|
||||
* Useful for stream buffering decisions.
|
||||
*/
|
||||
export function looksLikeDSML(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const trimmed = text.trim();
|
||||
return (
|
||||
trimmed.startsWith("<tool_calls") ||
|
||||
trimmed.startsWith("<invoke") ||
|
||||
trimmed.startsWith("</invoke") ||
|
||||
trimmed.startsWith("</tool_calls") ||
|
||||
trimmed.startsWith("<parameter")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if text completes a DSML block (has both open and close tags).
|
||||
* Returns true only if the full <tool_calls>...</tool_calls> structure is present.
|
||||
*/
|
||||
export function isCompleteDSML(text: string): boolean {
|
||||
return TOOL_CALLS_OPEN_RE.test(text) && TOOL_CALLS_CLOSE_RE.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream-ready DSML detection: buffered accumulator.
|
||||
* Accumulate chunks until DSML is complete or buffer max is reached.
|
||||
* Returns { result: ParsedDSML | null, consumed: number } where consumed
|
||||
* is how many bytes of the buffer were consumed by the DSML block.
|
||||
*/
|
||||
export interface DSMLAccumulator {
|
||||
buffer: string;
|
||||
flush(): ParsedDSML | null;
|
||||
add(chunk: string): { result: ParsedDSML | null; consumed: number };
|
||||
}
|
||||
|
||||
export function createDSMLAccumulator(): DSMLAccumulator {
|
||||
let buffer = "";
|
||||
|
||||
return {
|
||||
get buffer() {
|
||||
return buffer;
|
||||
},
|
||||
|
||||
add(chunk: string) {
|
||||
buffer += chunk;
|
||||
|
||||
// If buffer exceeds max without completing DSML, flush as non-DSML
|
||||
if (buffer.length > MAX_DSML_BUFFER) {
|
||||
const saved = buffer;
|
||||
buffer = "";
|
||||
return { result: null, consumed: saved.length };
|
||||
}
|
||||
|
||||
// Only try to parse if we have a complete DSML block
|
||||
if (isCompleteDSML(buffer)) {
|
||||
const result = parseDSML(buffer);
|
||||
if (result) {
|
||||
// Calculate consumed bytes: up to the end of </tool_calls>
|
||||
const closeMatch = buffer.match(TOOL_CALLS_CLOSE_RE);
|
||||
const consumed = closeMatch
|
||||
? closeMatch.index! + closeMatch[0].length
|
||||
: buffer.length;
|
||||
buffer = buffer.slice(consumed);
|
||||
return { result, consumed };
|
||||
}
|
||||
}
|
||||
|
||||
// Not yet complete or not DSML
|
||||
return { result: null, consumed: 0 };
|
||||
},
|
||||
|
||||
flush() {
|
||||
if (!buffer) return null;
|
||||
const result = isCompleteDSML(buffer) ? parseDSML(buffer) : null;
|
||||
buffer = "";
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Internal helpers --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse <invoke> blocks from the body of a <tool_calls> section.
|
||||
* Returns a list of tool call definitions.
|
||||
*/
|
||||
function parseInvokeBlocks(body: string): ToolCallDef[] {
|
||||
const results: ToolCallDef[] = [];
|
||||
let remaining = body;
|
||||
|
||||
// First, unwrap any CDATA sections
|
||||
remaining = remaining.replace(CDATA_RE, (_, content) => content);
|
||||
|
||||
while (remaining.length > 0) {
|
||||
const invokeMatch = remaining.match(INVOKE_OPEN_RE);
|
||||
if (!invokeMatch) break;
|
||||
|
||||
const name = invokeMatch[1];
|
||||
const invokeStart = invokeMatch.index! + invokeMatch[0].length;
|
||||
const closeMatch = remaining.slice(invokeStart).match(INVOKE_CLOSE_RE);
|
||||
|
||||
if (!closeMatch) break; // malformed — no closing </invoke>
|
||||
|
||||
const paramsBody = remaining.slice(invokeStart, invokeStart + closeMatch.index!);
|
||||
|
||||
// Parse parameters
|
||||
const args = parseParameters(paramsBody);
|
||||
|
||||
results.push({ name, args });
|
||||
|
||||
// Advance past this invoke block
|
||||
remaining = remaining.slice(invokeStart + closeMatch.index! + closeMatch[0].length);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse <parameter name="...">value</parameter> blocks into key-value pairs.
|
||||
* Values are attempted as JSON parse, falling back to string.
|
||||
*/
|
||||
function parseParameters(body: string): Record<string, unknown> {
|
||||
const args: Record<string, unknown> = {};
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
// Reset regex state
|
||||
PARAM_RE.lastIndex = 0;
|
||||
|
||||
while ((match = PARAM_RE.exec(body)) !== null) {
|
||||
const key = match[1];
|
||||
let value: unknown = match[2].trim();
|
||||
|
||||
// Try to parse the value as JSON
|
||||
if (value !== "") {
|
||||
try {
|
||||
value = JSON.parse(value as string);
|
||||
} catch {
|
||||
// Keep as string — not JSON
|
||||
}
|
||||
}
|
||||
|
||||
args[key] = value;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
Reference in New Issue
Block a user