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
+102
-6
@@ -14,6 +14,7 @@
|
|||||||
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
||||||
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
|
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
|
||||||
import { getJwt, invalidateJwt } from "./mimo-auth";
|
import { getJwt, invalidateJwt } from "./mimo-auth";
|
||||||
|
import { parseDSML, looksLikeDSML } from "./dsml-parser";
|
||||||
|
|
||||||
|
|
||||||
// --- Types -------------------------------------------------------------------
|
// --- Types -------------------------------------------------------------------
|
||||||
@@ -29,6 +30,8 @@ export interface OpenAIRequest {
|
|||||||
stop?: string | string[];
|
stop?: string | string[];
|
||||||
presence_penalty?: number;
|
presence_penalty?: number;
|
||||||
frequency_penalty?: number;
|
frequency_penalty?: number;
|
||||||
|
tools?: unknown[];
|
||||||
|
tool_choice?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackendConfig {
|
export interface BackendConfig {
|
||||||
@@ -175,7 +178,7 @@ function buildBackendRequest(
|
|||||||
req: OpenAIRequest,
|
req: OpenAIRequest,
|
||||||
config: BackendConfig,
|
config: BackendConfig,
|
||||||
): { url: string; init: RequestInit & { proxy?: string } } {
|
): { url: string; init: RequestInit & { proxy?: string } } {
|
||||||
const body =
|
const body: any =
|
||||||
config.adaptRequest?.(req) ?? {
|
config.adaptRequest?.(req) ?? {
|
||||||
model: req.model,
|
model: req.model,
|
||||||
messages: req.messages,
|
messages: req.messages,
|
||||||
@@ -186,6 +189,9 @@ function buildBackendRequest(
|
|||||||
stop: req.stop,
|
stop: req.stop,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (req.tools?.length) body.tools = req.tools;
|
||||||
|
if (req.tool_choice !== undefined) body.tool_choice = req.tool_choice;
|
||||||
|
|
||||||
const init: RequestInit & { proxy?: string } = {
|
const init: RequestInit & { proxy?: string } = {
|
||||||
method: config.method ?? "POST",
|
method: config.method ?? "POST",
|
||||||
headers: config.headers,
|
headers: config.headers,
|
||||||
@@ -222,6 +228,37 @@ function parseJSONResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Default fallback -- assume raw text is the content
|
// Default fallback -- assume raw text is the content
|
||||||
|
const parsedDSML = parseDSML(text);
|
||||||
|
|
||||||
|
if (parsedDSML && parsedDSML.toolCalls.length > 0) {
|
||||||
|
// DSML detected — convert to structured tool_calls
|
||||||
|
return {
|
||||||
|
id: `chatcmpl-${Date.now()}`,
|
||||||
|
object: "chat.completion",
|
||||||
|
created: Math.floor(Date.now() / 1000),
|
||||||
|
model: req.model,
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
content: parsedDSML.textBefore || null,
|
||||||
|
tool_calls: parsedDSML.toolCalls.map((tc) => ({
|
||||||
|
id: `call_${crypto.randomUUID().slice(0, 12)}`,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: JSON.stringify(tc.args),
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `chatcmpl-${Date.now()}`,
|
id: `chatcmpl-${Date.now()}`,
|
||||||
object: "chat.completion",
|
object: "chat.completion",
|
||||||
@@ -495,6 +532,10 @@ function transformStream(
|
|||||||
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
const KEEPALIVE_INTERVAL_MS = 15_000;
|
const KEEPALIVE_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
|
// DSML accumulation: buffer text deltas to detect DSML across chunks
|
||||||
|
let dsmlAccumulated = "";
|
||||||
|
let dsmlDetecting = true;
|
||||||
|
|
||||||
function startKeepalive(controller: ReadableStreamDefaultController) {
|
function startKeepalive(controller: ReadableStreamDefaultController) {
|
||||||
if (keepaliveTimer) return;
|
if (keepaliveTimer) return;
|
||||||
keepaliveTimer = setInterval(() => {
|
keepaliveTimer = setInterval(() => {
|
||||||
@@ -540,6 +581,45 @@ function transformStream(
|
|||||||
controller.enqueue(encoder.encode(remaining + "\n\n"));
|
controller.enqueue(encoder.encode(remaining + "\n\n"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check accumulated text for DSML
|
||||||
|
if (dsmlDetecting) {
|
||||||
|
const parsed = parseDSML(dsmlAccumulated);
|
||||||
|
if (parsed && parsed.toolCalls.length > 0) {
|
||||||
|
// Emit tool_calls delta events
|
||||||
|
for (const tc of parsed.toolCalls) {
|
||||||
|
const toolCallsDelta = {
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {
|
||||||
|
tool_calls: [{
|
||||||
|
index: 0,
|
||||||
|
id: `call_${crypto.randomUUID().slice(0, 12)}`,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: tc.name,
|
||||||
|
arguments: JSON.stringify(tc.args),
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(toolCallsDelta)}\n\n`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final finish event with tool_calls
|
||||||
|
const finishEvent = {
|
||||||
|
choices: [{
|
||||||
|
index: 0,
|
||||||
|
delta: {},
|
||||||
|
finish_reason: "tool_calls",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(finishEvent)}\n\n`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||||
controller.close();
|
controller.close();
|
||||||
return;
|
return;
|
||||||
@@ -549,14 +629,30 @@ function transformStream(
|
|||||||
const lines = lineBuffer.add(chunk);
|
const lines = lineBuffer.add(chunk);
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
|
let outputLine = line;
|
||||||
if (config.adaptStreamLine) {
|
if (config.adaptStreamLine) {
|
||||||
const adapted = config.adaptStreamLine(line, req);
|
const adapted = config.adaptStreamLine(line, req);
|
||||||
if (adapted) {
|
if (!adapted) continue;
|
||||||
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
outputLine = adapted;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
controller.enqueue(encoder.encode(line + "\n\n"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accumulate text content for DSML detection
|
||||||
|
if (dsmlDetecting && outputLine.startsWith("data: ")) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(outputLine.slice(6));
|
||||||
|
const content = data.choices?.[0]?.delta?.content;
|
||||||
|
if (typeof content === "string") {
|
||||||
|
dsmlAccumulated += content;
|
||||||
|
if (!looksLikeDSML(dsmlAccumulated) && dsmlAccumulated.length > 1000) {
|
||||||
|
dsmlDetecting = false; // Not DSML, stop checking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, skip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.enqueue(encoder.encode(outputLine + "\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
chunksProcessed++;
|
chunksProcessed++;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { test, expect, describe } from "bun:test";
|
import { test, expect, describe } from "bun:test";
|
||||||
import { anthropicToBackend } from "./anthropic-proxy";
|
import { anthropicToBackend, backendToAnthropicResponse, generateToolUseId } from "./anthropic-proxy";
|
||||||
import type { BackendConfig } from "./ai-proxy";
|
import type { BackendConfig } from "./ai-proxy";
|
||||||
|
|
||||||
const passthroughConfig: BackendConfig = {
|
const passthroughConfig: BackendConfig = {
|
||||||
@@ -216,4 +216,138 @@ describe("anthropicToBackend", () => {
|
|||||||
expect(body.messages[0].role).toBe("system");
|
expect(body.messages[0].role).toBe("system");
|
||||||
expect(body.messages[0].content).toBe("You are a helpful assistant.");
|
expect(body.messages[0].content).toBe("You are a helpful assistant.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should forward tools in request body", () => {
|
||||||
|
const result = anthropicToBackend(
|
||||||
|
{
|
||||||
|
model: "deepseek-v4-flash-free",
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{ role: "user", content: "Hi" }],
|
||||||
|
tools: [{ name: "test_tool", input_schema: { type: "object" } }],
|
||||||
|
},
|
||||||
|
passthroughConfig,
|
||||||
|
"deepseek-v4-flash-free",
|
||||||
|
);
|
||||||
|
const body = result.body as any;
|
||||||
|
expect(body.tools).toBeDefined();
|
||||||
|
expect(body.tools).toHaveLength(1);
|
||||||
|
expect(body.tools[0].name).toBe("test_tool");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should forward tool_choice in request body", () => {
|
||||||
|
const result = anthropicToBackend(
|
||||||
|
{
|
||||||
|
model: "deepseek-v4-flash-free",
|
||||||
|
max_tokens: 100,
|
||||||
|
messages: [{ role: "user", content: "Hi" }],
|
||||||
|
tool_choice: { type: "tool", name: "test_tool" },
|
||||||
|
},
|
||||||
|
passthroughConfig,
|
||||||
|
"deepseek-v4-flash-free",
|
||||||
|
);
|
||||||
|
const body = result.body as any;
|
||||||
|
expect(body.tool_choice).toBeDefined();
|
||||||
|
expect(body.tool_choice.type).toBe("tool");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("backendToAnthropicResponse", () => {
|
||||||
|
test("should convert plain text to text content block", () => {
|
||||||
|
const raw = {
|
||||||
|
id: "test_1",
|
||||||
|
choices: [{ message: { content: "Hello world" }, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||||
|
};
|
||||||
|
const result = backendToAnthropicResponse(raw, "deepseek-v4-flash-free");
|
||||||
|
expect(result.content).toHaveLength(1);
|
||||||
|
expect(result.content[0].type).toBe("text");
|
||||||
|
expect((result.content[0] as any).text).toBe("Hello world");
|
||||||
|
expect(result.stop_reason).toBe("end_turn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert DSML to tool_use content blocks", () => {
|
||||||
|
const raw = {
|
||||||
|
id: "test_2",
|
||||||
|
choices: [{
|
||||||
|
message: { content: `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls -la</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>` },
|
||||||
|
finish_reason: "stop",
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||||
|
};
|
||||||
|
const result = backendToAnthropicResponse(raw, "deepseek-v4-flash-free");
|
||||||
|
expect(result.content).toHaveLength(1);
|
||||||
|
expect(result.content[0].type).toBe("tool_use");
|
||||||
|
const toolUse = result.content[0] as any;
|
||||||
|
expect(toolUse.name).toBe("Bash");
|
||||||
|
expect(toolUse.input.command).toBe("ls -la");
|
||||||
|
expect(toolUse.id).toBeTruthy();
|
||||||
|
expect(result.stop_reason).toBe("tool_use");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should convert text before DSML as separate text block", () => {
|
||||||
|
const raw = {
|
||||||
|
id: "test_3",
|
||||||
|
choices: [{
|
||||||
|
message: { content: "Let me check.\n<tool_calls>\n<invoke name=\"Read\">\n<parameter name=\"path\">/tmp/test</parameter>\n</invoke>\n</tool_calls>" },
|
||||||
|
finish_reason: "stop",
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 5, completion_tokens: 15 },
|
||||||
|
};
|
||||||
|
const result = backendToAnthropicResponse(raw, "deepseek-v4-flash-free");
|
||||||
|
expect(result.content).toHaveLength(2);
|
||||||
|
expect(result.content[0].type).toBe("text");
|
||||||
|
expect(((result.content[0] as any).text).trim()).toBe("Let me check.");
|
||||||
|
expect(result.content[1].type).toBe("tool_use");
|
||||||
|
expect((result.content[1] as any).name).toBe("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple tool calls in DSML", () => {
|
||||||
|
const raw = {
|
||||||
|
id: "test_4",
|
||||||
|
choices: [{
|
||||||
|
message: { content: `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls</parameter>
|
||||||
|
</invoke>
|
||||||
|
<invoke name="Read">
|
||||||
|
<parameter name="file_path">test.txt</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>` },
|
||||||
|
finish_reason: "stop",
|
||||||
|
}],
|
||||||
|
usage: { prompt_tokens: 5, completion_tokens: 15 },
|
||||||
|
};
|
||||||
|
const result = backendToAnthropicResponse(raw, "deepseek-v4-flash-free");
|
||||||
|
expect(result.content).toHaveLength(2);
|
||||||
|
expect(result.content[0].type).toBe("tool_use");
|
||||||
|
expect((result.content[0] as any).name).toBe("Bash");
|
||||||
|
expect(result.content[1].type).toBe("tool_use");
|
||||||
|
expect((result.content[1] as any).name).toBe("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return plain text when no DSML in response", () => {
|
||||||
|
const raw = {
|
||||||
|
id: "test_5",
|
||||||
|
choices: [{ message: { content: "Hello" }, finish_reason: "stop" }],
|
||||||
|
usage: { prompt_tokens: 5, completion_tokens: 10 },
|
||||||
|
};
|
||||||
|
const result = backendToAnthropicResponse(raw, "deepseek-v4-flash-free");
|
||||||
|
expect(result.content).toHaveLength(1);
|
||||||
|
expect(result.content[0].type).toBe("text");
|
||||||
|
expect(result.stop_reason).toBe("end_turn");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateToolUseId", () => {
|
||||||
|
test("should generate unique IDs with toolu_ prefix", () => {
|
||||||
|
const id1 = generateToolUseId();
|
||||||
|
const id2 = generateToolUseId();
|
||||||
|
expect(id1).toMatch(/^toolu_/);
|
||||||
|
expect(id2).toMatch(/^toolu_/);
|
||||||
|
expect(id1).not.toBe(id2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+269
-45
@@ -13,17 +13,40 @@
|
|||||||
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
import type { ProxyPool, SessionProxyPool } from "./proxy-pool";
|
||||||
import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy";
|
import { MODEL_ROUTES, type BackendConfig } from "./ai-proxy";
|
||||||
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
|
import { fetchWithRetry, fetchWithSessionRetry, SSELineBuffer, isDevMode, trackReader, releaseReader, wrapStreamWithCleanup, type FetchWithRetryResult } from "./fetch-utils";
|
||||||
|
import { parseDSML, looksLikeDSML, isCompleteDSML } from "./dsml-parser";
|
||||||
|
|
||||||
// --- Types -------------------------------------------------------------------
|
// --- Types -------------------------------------------------------------------
|
||||||
|
|
||||||
export interface AnthropicContentBlock {
|
/** Anthropic tool definition (from request `tools` parameter). */
|
||||||
type: "text";
|
export interface AnthropicToolDef {
|
||||||
text: string;
|
name: string;
|
||||||
cache_control?: { type: "ephemeral" };
|
description?: string;
|
||||||
|
input_schema: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Tool_use content block for Anthropic response. */
|
||||||
|
export interface ToolUseBlock {
|
||||||
|
type: "tool_use";
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
input: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tool_result content block (user role after tool execution). */
|
||||||
|
interface ToolResultBlock {
|
||||||
|
type: "tool_result";
|
||||||
|
tool_use_id: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unified content block type: text, tool_use, or tool_result. */
|
||||||
|
export type AnthropicContentBlock =
|
||||||
|
| { type: "text"; text: string; cache_control?: { type: "ephemeral" } }
|
||||||
|
| ToolUseBlock
|
||||||
|
| ToolResultBlock;
|
||||||
|
|
||||||
export interface AnthropicMessage {
|
export interface AnthropicMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant" | "tool";
|
||||||
content: string | AnthropicContentBlock[];
|
content: string | AnthropicContentBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +66,8 @@ export interface AnthropicRequest {
|
|||||||
top_k?: number;
|
top_k?: number;
|
||||||
stop_sequences?: string[];
|
stop_sequences?: string[];
|
||||||
system?: string | AnthropicSystemBlock[];
|
system?: string | AnthropicSystemBlock[];
|
||||||
|
tools?: AnthropicToolDef[];
|
||||||
|
tool_choice?: unknown;
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,9 +75,9 @@ interface AnthropicResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
type: "message";
|
type: "message";
|
||||||
role: "assistant";
|
role: "assistant";
|
||||||
content: Array<{ type: "text"; text: string }>;
|
content: AnthropicContentBlock[];
|
||||||
model: string;
|
model: string;
|
||||||
stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | null;
|
stop_reason: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | null;
|
||||||
stop_sequence: string | null;
|
stop_sequence: string | null;
|
||||||
usage: {
|
usage: {
|
||||||
input_tokens: number;
|
input_tokens: number;
|
||||||
@@ -62,6 +87,11 @@ interface AnthropicResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Generate a unique tool_use ID. @internal Exported for testing. */
|
||||||
|
export function generateToolUseId(): string {
|
||||||
|
return `toolu_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Model resolution ----------------------------------------------------------
|
// --- Model resolution ----------------------------------------------------------
|
||||||
|
|
||||||
/** Resolve a model name to a backend config (uses MODEL_ROUTES directly). */
|
/** Resolve a model name to a backend config (uses MODEL_ROUTES directly). */
|
||||||
@@ -89,6 +119,8 @@ interface BackendBody {
|
|||||||
top_k?: number;
|
top_k?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
stop?: string | string[];
|
stop?: string | string[];
|
||||||
|
tools?: AnthropicToolDef[];
|
||||||
|
tool_choice?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,26 +154,75 @@ export function anthropicToBackend(
|
|||||||
// Convert Anthropic content blocks for OpenAI-compatible backend.
|
// Convert Anthropic content blocks for OpenAI-compatible backend.
|
||||||
// When a content block has cache_control, we keep it as a structured
|
// When a content block has cache_control, we keep it as a structured
|
||||||
// content part so the backend (or downstream cache layer) can use it.
|
// content part so the backend (or downstream cache layer) can use it.
|
||||||
const messages: Array<{ role: string; content: string | Array<Record<string, unknown>> }> = anthReq.messages.map((m) => {
|
const messages: Array<{ role: string; content: string | Array<Record<string, unknown>>; tool_calls?: unknown[]; tool_call_id?: string }> = anthReq.messages.map((m) => {
|
||||||
|
// Tool-role messages (OpenAI format after tool_use was executed)
|
||||||
|
if (m.role === "tool") {
|
||||||
|
const msg = m as any;
|
||||||
|
return {
|
||||||
|
role: "tool",
|
||||||
|
content: typeof m.content === "string" ? m.content : "",
|
||||||
|
tool_call_id: msg.tool_use_id ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof m.content === "string") {
|
if (typeof m.content === "string") {
|
||||||
return { role: m.role, content: m.content };
|
return { role: m.role, content: m.content };
|
||||||
}
|
}
|
||||||
// Check if any block has cache_control — if so, preserve as structured array
|
|
||||||
const hasCacheControl = m.content.some((c) => c.cache_control);
|
// Content is an array of blocks — may contain text, tool_use, tool_result
|
||||||
if (hasCacheControl) {
|
const textBlocks: string[] = [];
|
||||||
return {
|
const toolCalls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = [];
|
||||||
role: m.role,
|
let isToolResult = false;
|
||||||
content: m.content.map((c) => {
|
let toolResultContent = "";
|
||||||
const part: Record<string, unknown> = { type: "text", text: c.text };
|
let toolResultId = "";
|
||||||
if (c.cache_control) {
|
|
||||||
part.cache_control = c.cache_control;
|
for (const block of m.content) {
|
||||||
}
|
if (block.type === "text") {
|
||||||
return part;
|
textBlocks.push((block as any).text);
|
||||||
}),
|
} else if (block.type === "tool_use") {
|
||||||
};
|
toolCalls.push({
|
||||||
|
id: (block as any).id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: (block as any).name,
|
||||||
|
arguments: JSON.stringify((block as any).input ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (block.type === "tool_result") {
|
||||||
|
isToolResult = true;
|
||||||
|
toolResultContent = typeof (block as any).content === "string"
|
||||||
|
? (block as any).content
|
||||||
|
: JSON.stringify((block as any).content);
|
||||||
|
toolResultId = (block as any).tool_use_id ?? "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// No cache_control — flatten to plain text for simpler backend processing
|
|
||||||
return { role: m.role, content: m.content.map((c) => c.text).join("") };
|
// tool_result blocks come in user-role messages (Anthropic convention)
|
||||||
|
if (isToolResult) {
|
||||||
|
return { role: "tool", content: toolResultContent, tool_call_id: toolResultId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg: any = { role: m.role };
|
||||||
|
const hasCacheControl = m.content.some((c) => (c as any).cache_control);
|
||||||
|
|
||||||
|
if (toolCalls.length > 0) {
|
||||||
|
// Assistant with tool calls: content is text, tool_calls separate
|
||||||
|
msg.content = textBlocks.join("");
|
||||||
|
msg.tool_calls = toolCalls;
|
||||||
|
} else if (hasCacheControl) {
|
||||||
|
msg.content = m.content.map((c) => {
|
||||||
|
const part: Record<string, unknown> = { type: "text", text: (c as any).text };
|
||||||
|
if ((c as any).cache_control) {
|
||||||
|
part.cache_control = (c as any).cache_control;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No cache_control — flatten to plain text for simpler backend processing
|
||||||
|
msg.content = textBlocks.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prepend system prompt as a system message if present.
|
// Prepend system prompt as a system message if present.
|
||||||
@@ -179,6 +260,13 @@ export function anthropicToBackend(
|
|||||||
stream: anthReq.stream,
|
stream: anthReq.stream,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (anthReq.tools?.length) {
|
||||||
|
base.tools = anthReq.tools;
|
||||||
|
}
|
||||||
|
if (anthReq.tool_choice !== undefined) {
|
||||||
|
base.tool_choice = anthReq.tool_choice;
|
||||||
|
}
|
||||||
|
|
||||||
if (anthReq.stop_sequences?.length) {
|
if (anthReq.stop_sequences?.length) {
|
||||||
base.stop =
|
base.stop =
|
||||||
anthReq.stop_sequences.length === 1
|
anthReq.stop_sequences.length === 1
|
||||||
@@ -191,19 +279,22 @@ export function anthropicToBackend(
|
|||||||
if (anthropicVersion) {
|
if (anthropicVersion) {
|
||||||
headers["anthropic-version"] = anthropicVersion;
|
headers["anthropic-version"] = anthropicVersion;
|
||||||
}
|
}
|
||||||
|
const adaptedReq: any = {
|
||||||
|
model: backendModel,
|
||||||
|
messages,
|
||||||
|
temperature: anthReq.temperature,
|
||||||
|
max_tokens: anthReq.max_tokens,
|
||||||
|
top_p: anthReq.top_p,
|
||||||
|
top_k: anthReq.top_k,
|
||||||
|
stream: anthReq.stream,
|
||||||
|
stop: anthReq.stop_sequences?.length === 1
|
||||||
|
? anthReq.stop_sequences[0]
|
||||||
|
: anthReq.stop_sequences,
|
||||||
|
};
|
||||||
|
if (anthReq.tools?.length) adaptedReq.tools = anthReq.tools;
|
||||||
|
if (anthReq.tool_choice !== undefined) adaptedReq.tool_choice = anthReq.tool_choice;
|
||||||
return {
|
return {
|
||||||
body: config.adaptRequest({
|
body: config.adaptRequest(adaptedReq),
|
||||||
model: backendModel,
|
|
||||||
messages,
|
|
||||||
temperature: anthReq.temperature,
|
|
||||||
max_tokens: anthReq.max_tokens,
|
|
||||||
top_p: anthReq.top_p,
|
|
||||||
top_k: anthReq.top_k,
|
|
||||||
stream: anthReq.stream,
|
|
||||||
stop: anthReq.stop_sequences?.length === 1
|
|
||||||
? anthReq.stop_sequences[0]
|
|
||||||
: anthReq.stop_sequences,
|
|
||||||
}),
|
|
||||||
headers,
|
headers,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -241,23 +332,60 @@ function extractUsage(raw: any): AnthropicResponse["usage"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert a backend JSON response body into Anthropic Messages format.
|
* Convert a backend JSON response body into Anthropic Messages format,
|
||||||
* Extracts token usage and cache metrics from the backend response.
|
* including DSML tool call detection.
|
||||||
|
*
|
||||||
|
* If the backend text response contains DSML markup (`<tool_calls>`), it is
|
||||||
|
* parsed and converted into Anthropic tool_use content blocks. The text
|
||||||
|
* portion before the DSML remains as a text block. Extracts token usage
|
||||||
|
* and cache metrics from the backend response.
|
||||||
*/
|
*/
|
||||||
function backendToAnthropicResponse(
|
export function backendToAnthropicResponse(
|
||||||
raw: any,
|
raw: any,
|
||||||
model: string,
|
model: string,
|
||||||
): AnthropicResponse {
|
): AnthropicResponse {
|
||||||
const text =
|
const text =
|
||||||
raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "";
|
raw.choices?.[0]?.message?.content ?? raw.content ?? raw.text ?? "";
|
||||||
|
|
||||||
|
const content: AnthropicContentBlock[] = [];
|
||||||
|
|
||||||
|
// Check for DSML in the text
|
||||||
|
const parsedDSML = text ? parseDSML(text) : null;
|
||||||
|
|
||||||
|
if (parsedDSML && parsedDSML.toolCalls.length > 0) {
|
||||||
|
// Add text before DSML if non-empty
|
||||||
|
if (parsedDSML.textBefore) {
|
||||||
|
content.push({ type: "text", text: parsedDSML.textBefore });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a tool_use block for each parsed tool call
|
||||||
|
for (const tc of parsedDSML.toolCalls) {
|
||||||
|
content.push({
|
||||||
|
type: "tool_use",
|
||||||
|
id: generateToolUseId(),
|
||||||
|
name: tc.name,
|
||||||
|
input: tc.args,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add text after DSML if non-empty
|
||||||
|
if (parsedDSML.textAfter) {
|
||||||
|
content.push({ type: "text", text: parsedDSML.textAfter });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No DSML — plain text response
|
||||||
|
content.push({ type: "text", text });
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: raw.id ?? `msg_${Date.now()}`,
|
id: raw.id ?? `msg_${Date.now()}`,
|
||||||
type: "message",
|
type: "message",
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text }],
|
content,
|
||||||
model,
|
model,
|
||||||
stop_reason: raw.choices?.[0]?.finish_reason === "stop" ? "end_turn" : null,
|
stop_reason: parsedDSML
|
||||||
|
? "tool_use"
|
||||||
|
: (raw.choices?.[0]?.finish_reason === "stop" ? "end_turn" : null),
|
||||||
stop_sequence: raw.stop_sequence ?? null,
|
stop_sequence: raw.stop_sequence ?? null,
|
||||||
usage: extractUsage(raw),
|
usage: extractUsage(raw),
|
||||||
};
|
};
|
||||||
@@ -483,16 +611,47 @@ function emitDoneEvents(
|
|||||||
encoder: TextEncoder,
|
encoder: TextEncoder,
|
||||||
usage: AnthropicResponse["usage"],
|
usage: AnthropicResponse["usage"],
|
||||||
outputCounter: OutputCounter,
|
outputCounter: OutputCounter,
|
||||||
|
dsmlText?: string | null,
|
||||||
): void {
|
): void {
|
||||||
if (usage.output_tokens === 0 && outputCounter.chars > 0) {
|
if (usage.output_tokens === 0 && outputCounter.chars > 0) {
|
||||||
usage.output_tokens = Math.max(1, Math.round(outputCounter.chars / 4));
|
usage.output_tokens = Math.max(1, Math.round(outputCounter.chars / 4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check DSML buffer for tool calls
|
||||||
|
const dsmlResult = dsmlText ? parseDSML(dsmlText) : null;
|
||||||
|
const hasToolUse = dsmlResult !== null && dsmlResult.toolCalls.length > 0;
|
||||||
|
|
||||||
|
// Close the current text content block
|
||||||
controller.enqueue(encoder.encode('event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n'));
|
controller.enqueue(encoder.encode('event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n'));
|
||||||
|
|
||||||
|
// Emit tool_use blocks if DSML was found
|
||||||
|
if (hasToolUse) {
|
||||||
|
let blockIndex = 1;
|
||||||
|
for (const tc of dsmlResult!.toolCalls) {
|
||||||
|
const id = generateToolUseId();
|
||||||
|
// content_block_start for tool_use
|
||||||
|
controller.enqueue(encoder.encode(
|
||||||
|
`event: content_block_start\ndata: ${JSON.stringify({
|
||||||
|
type: "content_block_start",
|
||||||
|
index: blockIndex,
|
||||||
|
content_block: { type: "tool_use", id, name: tc.name, input: tc.args },
|
||||||
|
})}\n\n`,
|
||||||
|
));
|
||||||
|
// content_block_stop (full input available, no incremental delta needed)
|
||||||
|
controller.enqueue(encoder.encode(
|
||||||
|
`event: content_block_stop\ndata: ${JSON.stringify({
|
||||||
|
type: "content_block_stop",
|
||||||
|
index: blockIndex,
|
||||||
|
})}\n\n`,
|
||||||
|
));
|
||||||
|
blockIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
controller.enqueue(encoder.encode(
|
controller.enqueue(encoder.encode(
|
||||||
`event: message_delta\ndata: ${JSON.stringify({
|
`event: message_delta\ndata: ${JSON.stringify({
|
||||||
type: "message_delta",
|
type: "message_delta",
|
||||||
delta: { stop_reason: "end_turn", stop_sequence: null },
|
delta: { stop_reason: hasToolUse ? "tool_use" : "end_turn", stop_sequence: null },
|
||||||
usage: { output_tokens: usage.output_tokens },
|
usage: { output_tokens: usage.output_tokens },
|
||||||
})}\n\n`,
|
})}\n\n`,
|
||||||
));
|
));
|
||||||
@@ -519,6 +678,8 @@ function emitErrorEvent(
|
|||||||
/**
|
/**
|
||||||
* Process one chunk from the upstream reader through the SSE line buffer
|
* Process one chunk from the upstream reader through the SSE line buffer
|
||||||
* and emit adapted Anthropic SSE events for each complete line.
|
* and emit adapted Anthropic SSE events for each complete line.
|
||||||
|
* When a DSML buffer is provided, text that looks like DSML is held back
|
||||||
|
* instead of being emitted as text deltas.
|
||||||
* Returns true if the stream is done (reader returned done=true).
|
* Returns true if the stream is done (reader returned done=true).
|
||||||
*/
|
*/
|
||||||
async function processStreamChunk(
|
async function processStreamChunk(
|
||||||
@@ -531,6 +692,7 @@ async function processStreamChunk(
|
|||||||
config: BackendConfig,
|
config: BackendConfig,
|
||||||
usage: AnthropicResponse["usage"],
|
usage: AnthropicResponse["usage"],
|
||||||
outputCounter: OutputCounter,
|
outputCounter: OutputCounter,
|
||||||
|
dsmlBuffer?: DSMLStreamBuffer,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) {
|
if (done) {
|
||||||
@@ -538,7 +700,14 @@ async function processStreamChunk(
|
|||||||
const remaining = lineBuffer.flush();
|
const remaining = lineBuffer.flush();
|
||||||
if (remaining.length > 0) {
|
if (remaining.length > 0) {
|
||||||
const adapted = backendLineToAnthropicSSE(remaining, model, config, usage, outputCounter);
|
const adapted = backendLineToAnthropicSSE(remaining, model, config, usage, outputCounter);
|
||||||
if (adapted) controller.enqueue(encoder.encode(adapted + "\n\n"));
|
if (adapted) {
|
||||||
|
const text = extractTextFromSSEEvent(adapted);
|
||||||
|
if (dsmlBuffer && text) {
|
||||||
|
dsmlBuffer.push(text);
|
||||||
|
} else if (adapted) {
|
||||||
|
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -548,11 +717,64 @@ async function processStreamChunk(
|
|||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const adapted = backendLineToAnthropicSSE(line, model, config, usage, outputCounter);
|
const adapted = backendLineToAnthropicSSE(line, model, config, usage, outputCounter);
|
||||||
if (adapted) controller.enqueue(encoder.encode(adapted + "\n\n"));
|
if (adapted) {
|
||||||
|
const text = extractTextFromSSEEvent(adapted);
|
||||||
|
if (dsmlBuffer && text && (dsmlBuffer.isActive || looksLikeDSML(text))) {
|
||||||
|
dsmlBuffer.push(text);
|
||||||
|
} else {
|
||||||
|
controller.enqueue(encoder.encode(adapted + "\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Buffer for accumulating DSML content during streaming. */
|
||||||
|
interface DSMLStreamBuffer {
|
||||||
|
text: string;
|
||||||
|
isActive: boolean;
|
||||||
|
push(chunk: string): void;
|
||||||
|
flush(): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDSMLStreamBuffer(): DSMLStreamBuffer {
|
||||||
|
let buffer = "";
|
||||||
|
let active = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
get text() { return buffer; },
|
||||||
|
get isActive() { return active; },
|
||||||
|
|
||||||
|
push(chunk: string) {
|
||||||
|
if (!active && looksLikeDSML(chunk)) {
|
||||||
|
active = true;
|
||||||
|
}
|
||||||
|
buffer += chunk;
|
||||||
|
},
|
||||||
|
|
||||||
|
flush() {
|
||||||
|
if (!buffer) return null;
|
||||||
|
const text = buffer;
|
||||||
|
buffer = "";
|
||||||
|
active = false;
|
||||||
|
return isCompleteDSML(text) ? text : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract plain text from a formatted content_block_delta SSE event. */
|
||||||
|
function extractTextFromSSEEvent(event: string): string | null {
|
||||||
|
if (!event.startsWith("event: content_block_delta")) return null;
|
||||||
|
const dataMatch = event.match(/data:\s*(\{.*\})/);
|
||||||
|
if (!dataMatch) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(dataMatch[1]);
|
||||||
|
return parsed.delta?.text ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Stream transformer --------------------------------------------------------
|
// --- Stream transformer --------------------------------------------------------
|
||||||
|
|
||||||
function transformAnthropicStream(
|
function transformAnthropicStream(
|
||||||
@@ -568,6 +790,7 @@ function transformAnthropicStream(
|
|||||||
let phase: "init" | "block" | "done" = "init";
|
let phase: "init" | "block" | "done" = "init";
|
||||||
const outputCounter: OutputCounter = { chars: 0 };
|
const outputCounter: OutputCounter = { chars: 0 };
|
||||||
const usage: AnthropicResponse["usage"] = { input_tokens: 0, output_tokens: 0 };
|
const usage: AnthropicResponse["usage"] = { input_tokens: 0, output_tokens: 0 };
|
||||||
|
const dsmlBuffer = createDSMLStreamBuffer();
|
||||||
|
|
||||||
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
const KEEPALIVE_INTERVAL_MS = 15_000;
|
const KEEPALIVE_INTERVAL_MS = 15_000;
|
||||||
@@ -597,7 +820,7 @@ function transformAnthropicStream(
|
|||||||
let chunksProcessed = 0;
|
let chunksProcessed = 0;
|
||||||
|
|
||||||
while (phase === "block" && chunksProcessed < BATCH_SIZE) {
|
while (phase === "block" && chunksProcessed < BATCH_SIZE) {
|
||||||
const isDone = await processStreamChunk(reader, lineBuffer, decoder, controller, encoder, model, config, usage, outputCounter);
|
const isDone = await processStreamChunk(reader, lineBuffer, decoder, controller, encoder, model, config, usage, outputCounter, dsmlBuffer);
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
releaseReader(reader);
|
releaseReader(reader);
|
||||||
@@ -613,7 +836,8 @@ function transformAnthropicStream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (phase === "done") {
|
if (phase === "done") {
|
||||||
emitDoneEvents(controller, encoder, usage, outputCounter);
|
const dsmlText = dsmlBuffer.flush();
|
||||||
|
emitDoneEvents(controller, encoder, usage, outputCounter, dsmlText);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stopKeepalive();
|
stopKeepalive();
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { test, expect, describe } from "bun:test";
|
||||||
|
import {
|
||||||
|
parseDSML,
|
||||||
|
stripDSML,
|
||||||
|
looksLikeDSML,
|
||||||
|
isCompleteDSML,
|
||||||
|
createDSMLAccumulator,
|
||||||
|
} from "./dsml-parser";
|
||||||
|
|
||||||
|
describe("parseDSML", () => {
|
||||||
|
test("returns null for plain text without DSML", () => {
|
||||||
|
const result = parseDSML("Hello, world!");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null for empty string", () => {
|
||||||
|
expect(parseDSML("")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses single tool call with string args", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls -la</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(result!.toolCalls[0].name).toBe("Bash");
|
||||||
|
expect(result!.toolCalls[0].args.command).toBe("ls -la");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses single tool call with JSON args", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Read">
|
||||||
|
<parameter name="file_path">/path/to/file.ts</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(result!.toolCalls[0].name).toBe("Read");
|
||||||
|
expect(result!.toolCalls[0].args.file_path).toBe("/path/to/file.ts");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses multiple tool calls", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls</parameter>
|
||||||
|
</invoke>
|
||||||
|
<invoke name="Read">
|
||||||
|
<parameter name="file_path">/tmp/test.txt</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls).toHaveLength(2);
|
||||||
|
expect(result!.toolCalls[0].name).toBe("Bash");
|
||||||
|
expect(result!.toolCalls[1].name).toBe("Read");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts textBefore when there is thinking content before DSML", () => {
|
||||||
|
const text = `<thinking>Let me check the file system.</thinking>
|
||||||
|
<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls -la</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.textBefore).toContain("Let me check the file system");
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(result!.toolCalls[0].name).toBe("Bash");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts textAfter when there is content after DSML", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>
|
||||||
|
Some follow-up text.`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.textAfter.trim()).toBe("Some follow-up text.");
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles multiline parameter values", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Edit">
|
||||||
|
<parameter name="file_path">/path/to/file.ts</parameter>
|
||||||
|
<parameter name="old_string">line 1
|
||||||
|
line 2
|
||||||
|
line 3</parameter>
|
||||||
|
<parameter name="new_string">new line 1
|
||||||
|
new line 2</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(result!.toolCalls[0].name).toBe("Edit");
|
||||||
|
expect(result!.toolCalls[0].args.old_string).toBe("line 1\nline 2\nline 3");
|
||||||
|
expect(result!.toolCalls[0].args.new_string).toBe("new line 1\nnew line 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles text that looks like XML but is not DSML", () => {
|
||||||
|
const result = parseDSML("<div>hello</div>");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles partial DSML (missing close tag)", () => {
|
||||||
|
const result = parseDSML("<tool_calls><invoke name=\"Bash\">");
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles CDATA sections in parameter values", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command"><![CDATA[echo "hello world"]]></parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(result!.toolCalls[0].args.command).toBe('echo "hello world"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses numeric parameter values in DSML", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Read">
|
||||||
|
<parameter name="max_tokens">1000</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const result = parseDSML(text);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls[0].args.max_tokens).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stripDSML", () => {
|
||||||
|
test("strips DSML markup from text", () => {
|
||||||
|
const text = `<thinking>Let me think</thinking>
|
||||||
|
<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
const stripped = stripDSML(text);
|
||||||
|
expect(stripped).not.toContain("<tool_calls>");
|
||||||
|
expect(stripped).not.toContain("<invoke");
|
||||||
|
expect(stripped).not.toContain("<parameter");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preserves non-DSML text", () => {
|
||||||
|
const text = "Hello world";
|
||||||
|
expect(stripDSML(text)).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles empty string", () => {
|
||||||
|
expect(stripDSML("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("looksLikeDSML", () => {
|
||||||
|
test("detects <tool_calls> opening tag", () => {
|
||||||
|
expect(looksLikeDSML("<tool_calls>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects <invoke name=...> tag", () => {
|
||||||
|
expect(looksLikeDSML('<invoke name="Bash">')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for plain text", () => {
|
||||||
|
expect(looksLikeDSML("Hello world")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects closing tags", () => {
|
||||||
|
expect(looksLikeDSML("</tool_calls>")).toBe(true);
|
||||||
|
expect(looksLikeDSML("</invoke>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("detects <parameter> tag", () => {
|
||||||
|
expect(looksLikeDSML('<parameter name="x">')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isCompleteDSML", () => {
|
||||||
|
test("returns true for complete DSML block", () => {
|
||||||
|
const text = `<tool_calls>
|
||||||
|
<invoke name="Bash">
|
||||||
|
<parameter name="command">ls</parameter>
|
||||||
|
</invoke>
|
||||||
|
</tool_calls>`;
|
||||||
|
expect(isCompleteDSML(text)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for incomplete DSML (no close)", () => {
|
||||||
|
expect(isCompleteDSML("<tool_calls><invoke name=\"Bash\">")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns false for empty string", () => {
|
||||||
|
expect(isCompleteDSML("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DSMLAccumulator", () => {
|
||||||
|
test("accumulates partial DSML across chunks", () => {
|
||||||
|
const acc = createDSMLAccumulator();
|
||||||
|
|
||||||
|
// Chunk 1: partial
|
||||||
|
const r1 = acc.add("<tool_calls>\n<invoke name=\"Bash\">\n");
|
||||||
|
expect(r1.result).toBeNull();
|
||||||
|
expect(r1.consumed).toBe(0);
|
||||||
|
|
||||||
|
// Chunk 2: still partial
|
||||||
|
const r2 = acc.add('<parameter name="command">ls -la</parameter>\n');
|
||||||
|
expect(r2.result).toBeNull();
|
||||||
|
expect(r2.consumed).toBe(0);
|
||||||
|
|
||||||
|
// Chunk 3: complete
|
||||||
|
const r3 = acc.add("</invoke>\n</tool_calls>");
|
||||||
|
expect(r3.result).not.toBeNull();
|
||||||
|
expect(r3.result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(r3.result!.toolCalls[0].name).toBe("Bash");
|
||||||
|
expect(r3.result!.toolCalls[0].args.command).toBe("ls -la");
|
||||||
|
expect(r3.consumed).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flush returns null when no DSML accumulated", () => {
|
||||||
|
const acc = createDSMLAccumulator();
|
||||||
|
acc.add("Hello world");
|
||||||
|
expect(acc.flush()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("flush returns parsed DSML when add left unparsed content", () => {
|
||||||
|
const acc = createDSMLAccumulator();
|
||||||
|
// Simulate a scenario where DSML was partially buffered but not complete via add
|
||||||
|
acc.add("<tool_calls><invoke name=\"Test\">");
|
||||||
|
|
||||||
|
// Now complete it with a direct accumulation (simulating stream end)
|
||||||
|
const result = acc.flush();
|
||||||
|
expect(result).toBeNull(); // not complete yet — missing close tags
|
||||||
|
});
|
||||||
|
|
||||||
|
test("add returns parsed DSML when complete in single chunk", () => {
|
||||||
|
const acc = createDSMLAccumulator();
|
||||||
|
const result = acc.add("<tool_calls><invoke name=\"Test\"><parameter name=\"x\">y</parameter></invoke></tool_calls>").result;
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(result!.toolCalls[0].name).toBe("Test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles non-DSML text then DSML", () => {
|
||||||
|
const acc = createDSMLAccumulator();
|
||||||
|
|
||||||
|
// Non-DSML chunks first
|
||||||
|
const r1 = acc.add("Hello ");
|
||||||
|
expect(r1.result).toBeNull();
|
||||||
|
const r2 = acc.add("world");
|
||||||
|
expect(r2.result).toBeNull();
|
||||||
|
|
||||||
|
// DSML starts
|
||||||
|
const r3 = acc.add("<tool_calls><invoke name=\"Bash\"><parameter name=\"cmd\">ls</parameter></invoke></tool_calls>");
|
||||||
|
expect(r3.result).not.toBeNull();
|
||||||
|
expect(r3.result!.toolCalls).toHaveLength(1);
|
||||||
|
expect(r3.result!.textBefore).toBe("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buffer overflow flushes as non-DSML", () => {
|
||||||
|
const acc = createDSMLAccumulator();
|
||||||
|
const bigString = "a".repeat(60_000);
|
||||||
|
const result = acc.add(bigString);
|
||||||
|
expect(result.result).toBeNull();
|
||||||
|
expect(result.consumed).toBe(60_000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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