diff --git a/changelog.d/features/openai-compatible-web-provider-tool-calling.md b/changelog.d/features/openai-compatible-web-provider-tool-calling.md
new file mode 100644
index 000000000000..7bd8e383d970
--- /dev/null
+++ b/changelog.d/features/openai-compatible-web-provider-tool-calling.md
@@ -0,0 +1 @@
+- **feat(providers):** DeepSeek-web tool-call parsing now recognizes native DSML `invoke`/`parameter` tokens (both the printable-ASCII and full-width tokenizer spellings) in addition to the existing `{json}` shapes, and preserves parallel tool calls and stable call IDs/indexes; custom OpenAI-compatible image/video generation providers now fail closed on a missing base URL instead of silently falling back to a built-in provider endpoint ([#14103](https://github.com/diegosouzapw/OmniRoute/pull/14103)) — thanks @arashashrafii
diff --git a/docs/reference/API_REFERENCE.md b/docs/reference/API_REFERENCE.md
index da2692c0e323..14b2e93f038d 100644
--- a/docs/reference/API_REFERENCE.md
+++ b/docs/reference/API_REFERENCE.md
@@ -10,6 +10,9 @@ lastUpdated: 2026-08-31
Core reference for the OmniRoute API. It covers the public `/v1` surface and the most-used management endpoints; the machine-readable [`docs/openapi.yaml`](../openapi.yaml) and the route tree under `src/app/api/` are the exhaustive sources.
+For the focused OpenAI-compatible protocol and provider capability matrix, see
+[`OPENAI_COMPATIBILITY.md`](./OPENAI_COMPATIBILITY.md).
+
---
## Table of Contents
diff --git a/docs/reference/OPENAI_COMPATIBILITY.md b/docs/reference/OPENAI_COMPATIBILITY.md
new file mode 100644
index 000000000000..89b3d2102ed5
--- /dev/null
+++ b/docs/reference/OPENAI_COMPATIBILITY.md
@@ -0,0 +1,139 @@
+---
+title: "OpenAI-compatible protocol"
+version: 3.8.51
+lastUpdated: 2026-09-18
+---
+
+
+
+# OpenAI-compatible protocol
+
+OmniRoute exposes a client-facing OpenAI-shaped API at `/v1`. Authenticate with
+`Authorization: Bearer ` and use a model id returned by `GET /v1/models`.
+The model id normally has the form `/`; combos are
+also valid model targets.
+
+This page describes the public contract. Provider capability is not uniform:
+the gateway translates requests where it has a supported translator, but an
+upstream may still reject a feature it does not implement. Treat the live
+`/v1/models` record and the provider's capability metadata as authoritative for
+model selection.
+
+## Endpoint and capability matrix
+
+| Endpoint | Wire format | Streaming | Main input | Capability notes | Automated coverage |
+| ------------------------------- | ----------------------- | ------------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `POST /v1/chat/completions` | OpenAI Chat Completions | `stream: true` returns SSE | `messages` (or compatible `input`/`prompt` forms accepted by the shared schema) | Text, tool calls, reasoning controls, structured-output fields, and multimodal message content are translated when the selected model/provider supports them. | [`chat-completions-route-shape-gate.test.ts`](../../tests/unit/chat-completions-route-shape-gate.test.ts), [`openai-compatible-tools.test.ts`](../../tests/unit/openai-compatible-tools.test.ts) |
+| `POST /v1/responses` | OpenAI Responses | SSE event stream when `stream: true` | `input` | Responses input, tools, reasoning, and output events are translated for providers that expose a compatible path. | [`responses-passthrough-openai-compatible.test.ts`](../../tests/unit/responses-passthrough-openai-compatible.test.ts), [`openclaw-omniroute-tool-boundary.live.test.ts`](../../tests/boundary/openclaw-omniroute-tool-boundary.live.test.ts) |
+| `POST /v1/embeddings` | OpenAI Embeddings | No | `input` | Text embeddings are broadly supported; multimodal input is limited to models advertising it. Inline media is capped at 8 MiB per item and 16 MiB per request. | [`v1-contracts-behavior.test.ts`](../../tests/integration/v1-contracts-behavior.test.ts) |
+| `POST /v1/images/generations` | OpenAI Images | No | `prompt` | Image-capable models only; response format may be `url` or `b64_json`. | [`v1-contracts-behavior.test.ts`](../../tests/integration/v1-contracts-behavior.test.ts) |
+| `POST /v1/images/edits` | OpenAI Images edit | No | multipart image + prompt | Supported for the providers/models that implement image editing. | [`image-generation-handler.test.ts`](../../tests/unit/image-generation-handler.test.ts) |
+| `POST /v1/audio/transcriptions` | OpenAI Audio | No | multipart audio | Speech-to-text providers/models only. | [`audio-transcription-handler.test.ts`](../../tests/unit/audio-transcription-handler.test.ts) |
+| `POST /v1/audio/speech` | OpenAI Audio | No | `input`, `voice`, `model` | Text-to-speech providers/models only; returns an audio body. | [`audio-speech-handler.test.ts`](../../tests/unit/audio-speech-handler.test.ts) |
+| `GET /v1/models` | OpenAI model list | No | — | Lists visible chat, media, embedding models, and combos. Use `?prefix=alias`, `dual`, or `canonical` to select id presentation. | [`v1-contracts-behavior.test.ts`](../../tests/integration/v1-contracts-behavior.test.ts), [`provider-journey.contract.test.ts`](../../tests/integration/provider-journey.contract.test.ts) |
+
+Other compatibility surfaces are listed in [API Reference](./API_REFERENCE.md),
+including `/v1/messages`, `/v1/rerank`, `/v1/moderations`, video/music routes,
+Files, Batches, and the tokenized VS Code aliases.
+
+## Chat request fields
+
+The chat route requires `model` and at least one of `messages`, `input`, or
+`prompt`. The schema is intentionally forward-compatible and preserves unknown
+OpenAI-style fields for translation; the canonical fields used by OmniRoute
+include:
+
+| Field | Behavior |
+| ----------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `messages` | Conversation messages. Assistant `tool_calls` are paired with subsequent `role: "tool"` results. |
+| `tools` / `tool_choice` | Function/tool definitions and selection policy. They are translated to the selected upstream format when possible. |
+| `parallel_tool_calls` | Passed through as a client preference. It does not make a model capable of parallel calls; the model/provider must support that capability. |
+| `stream` | Enables SSE streaming. The client must parse `data:` events and stop at `data: [DONE]` on Chat Completions. |
+| `stream_options.include_usage` | Supported on Chat Completions where usage is available. It is removed when translating to Responses upstreams that reject this field. |
+| `response_format` | Structured/JSON response controls are provider-dependent. |
+| `reasoning_effort`, `reasoning`, `thinking`, `effort` | Normalized into the selected provider's reasoning controls; unsupported effort values may be clamped or rejected by the upstream. |
+| multimodal content | Use the OpenAI message content parts supported by the target model. Do not assume that a text model accepts image, audio, video, or document parts. |
+
+Tool calls can be emitted as one or more calls in a single assistant turn.
+When a provider does not natively use OpenAI tool-call objects, OmniRoute's
+translator maps the provider representation back to `tool_calls` in the
+OpenAI response. The application remains responsible for executing tools and
+sending the matching tool results in the next request.
+
+## Streaming and errors
+
+Chat and Responses streams are long-lived HTTP responses. Keep the connection
+open until the terminal event, propagate client cancellation, and treat a
+non-2xx response before the stream starts as an ordinary JSON error. A provider
+failure during dispatch is returned through OmniRoute's sanitized error shape;
+raw upstream stacks and credential material are not exposed.
+
+Typical client-visible statuses are `400` for validation or unsupported request
+shape, `401` for authentication, `403` for policy/key permission failures,
+`404` for unknown routes or models, `409` for request conflicts, `429` for rate
+limits/capacity, and `5xx` for gateway/upstream failures. The exact JSON error
+fields are part of the route's sanitized error helper and should be handled by
+`status` plus the returned error `code`/`type`, not by matching prose.
+
+## Media limitations
+
+Media support is endpoint- and model-specific. `/v1/models` may include media
+models that cannot answer chat requests; select the endpoint that matches the
+model modality. For embedding media, remote sources must be safe public HTTPS
+URLs; inline sources use base64 and are bounded to 8 MiB per item and 16 MiB
+total. ChatGPT Web input images are a narrower case: that backend accepts
+inline `data:` image URLs, not remote image URLs or file-id references.
+
+## Generic OpenAI-compatible provider nodes
+
+An operator can add a provider node with `type: "openai-compatible"`, a
+`baseUrl`, a `prefix`, and one `apiType`:
+
+```json
+{
+ "name": "Local gateway",
+ "prefix": "local",
+ "type": "openai-compatible",
+ "apiType": "chat",
+ "baseUrl": "http://127.0.0.1:1234/v1"
+}
+```
+
+Supported `apiType` values are `chat`, `responses`, `embeddings`,
+`audio-transcriptions`, `audio-speech`, and `images-generations`. OmniRoute
+appends the corresponding standard path (`/chat/completions`, `/responses`,
+`/embeddings`, `/audio/transcriptions`, `/audio/speech`, or
+`/images/generations`) to the configured base URL. A trailing slash is removed.
+The provider-node schema does not promise that every upstream accepts every
+OpenAI field; configure only the endpoint type the upstream actually serves.
+
+## Client examples
+
+```bash
+export OMNIROUTE_BASE_URL="http://localhost:20128/v1"
+export OMNIROUTE_API_KEY="your-api-key"
+
+curl "$OMNIROUTE_BASE_URL/models" \
+ -H "Authorization: Bearer $OMNIROUTE_API_KEY"
+
+curl "$OMNIROUTE_BASE_URL/chat/completions" \
+ -H "Authorization: Bearer $OMNIROUTE_API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{"model":"auto","messages":[{"role":"user","content":"Hello"}]}'
+```
+
+For an OpenAI SDK, set its base URL to `http://localhost:20128/v1` (the `/v1`
+root, not `/v1/chat/completions`) and supply the OmniRoute key as the API key.
+The same root works for clients that use Chat Completions or Responses, subject
+to the client's own wire-format setting.
+
+## Verified implementation sources
+
+- [Public API reference](./API_REFERENCE.md)
+- [OpenAI-compatible provider routing](../../open-sse/services/provider.ts)
+- [OpenAI v1 schemas](../../src/shared/validation/schemas/apiV1.ts)
+- [Provider model capability metadata](../../src/shared/constants/modelSpecs.ts)
+- [Provider-node validation](../../src/shared/validation/schemas/provider.ts)
+
+`/v1/realtime` is not implemented. Clients must treat it as unsupported rather than
+assuming that the chat or Responses endpoints provide a realtime WebSocket contract.
diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts
index 2a4d74495b0d..b138368b01de 100644
--- a/open-sse/handlers/imageGeneration.ts
+++ b/open-sse/handlers/imageGeneration.ts
@@ -184,6 +184,27 @@ const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([
const IMAGE_ASPECT_RATIO_PATTERN = /^\d+:\d+$/;
const IMAGE_SIZE_PATTERN = /^(?:1K|2K|4K)$/;
+/**
+ * Read the configured node base URL from a custom provider's credentials:
+ * `providerSpecificData.baseUrl` first, then the legacy top-level
+ * `credentials.baseUrl`. Returns null when neither is set, so callers can
+ * fall back to a default or fail closed.
+ */
+function pickConfiguredNodeBaseUrl(
+ credentials:
+ { baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined
+): string | null {
+ const psd = credentials?.providerSpecificData;
+ const psdBaseUrl =
+ psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim()
+ ? psd.baseUrl.trim()
+ : null;
+ if (psdBaseUrl) return psdBaseUrl;
+ return typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim()
+ ? credentials.baseUrl.trim()
+ : null;
+}
+
/**
* Resolve the upstream images endpoint for a custom (OpenAI-compatible) image
* provider node (#3205).
@@ -192,7 +213,8 @@ const IMAGE_SIZE_PATTERN = /^(?:1K|2K|4K)$/;
* in `credentials.providerSpecificData.baseUrl` (e.g. `https://example.com/v1`),
* NOT as a top-level `credentials.baseUrl`. Older callers may still pass a
* top-level `baseUrl`, so we honor that as a secondary source. When neither is
- * present we fall back to `fallback` (the built-in Gemini OpenAI endpoint).
+ * present the caller may use its explicit fallback. Custom-node callers use
+ * failClosed so they never route to a built-in provider endpoint.
*
* Resolution order: providerSpecificData.baseUrl → credentials.baseUrl → fallback.
*
@@ -207,20 +229,12 @@ export function resolveImageBaseUrl(
credentials:
{ baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined,
fallback: string,
- endpoint: "generations" | "edits" = "generations"
+ endpoint: "generations" | "edits" = "generations",
+ failClosed = false
): string {
- const psd = credentials?.providerSpecificData;
- const psdBaseUrl =
- psd && typeof psd === "object" && typeof psd.baseUrl === "string" && psd.baseUrl.trim()
- ? psd.baseUrl.trim()
- : null;
- const topLevelBaseUrl =
- typeof credentials?.baseUrl === "string" && credentials.baseUrl.trim()
- ? credentials.baseUrl.trim()
- : null;
- const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
+ const nodeBaseUrl = pickConfiguredNodeBaseUrl(credentials);
- if (!nodeBaseUrl) return fallback;
+ if (!nodeBaseUrl) return failClosed ? "" : fallback;
// A single configured node serves both image routes: honor a base URL that already
// points at the requested OpenAI image path, and rewrite one that points at the other
@@ -486,15 +500,20 @@ export async function handleImageGeneration({
// Previously only the (always-absent) top-level credentials.baseUrl was
// read, so every custom image node fell back to the Gemini endpoint and
// returned "Please pass a valid API key".
- baseUrl: resolveImageBaseUrl(
- credentials,
- `https://generativelanguage.googleapis.com/v1beta/openai/images/generations`
- ),
+ baseUrl: resolveImageBaseUrl(credentials, "", "generations", true),
authType: "apikey",
authHeader: "bearer",
format: "openai",
};
+ if (!syntheticConfig.baseUrl) {
+ return {
+ success: false,
+ status: 501,
+ error: `Image generation is not configured for custom provider: ${provider}`,
+ };
+ }
+
return handleOpenAIImageGeneration({
model,
provider,
diff --git a/open-sse/handlers/videoGeneration.ts b/open-sse/handlers/videoGeneration.ts
index 13f5589b6740..5638410bb065 100644
--- a/open-sse/handlers/videoGeneration.ts
+++ b/open-sse/handlers/videoGeneration.ts
@@ -61,7 +61,8 @@ import { handleFalVideoGeneration } from "./mediaGeneration/fal.ts";
export function resolveVideoBaseUrl(
credentials:
{ baseUrl?: unknown; providerSpecificData?: { baseUrl?: unknown } | null } | null | undefined,
- fallback: string
+ fallback: string,
+ failClosed = false
): string {
const psd = credentials?.providerSpecificData;
const psdBaseUrl =
@@ -74,7 +75,7 @@ export function resolveVideoBaseUrl(
: null;
const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
- if (!nodeBaseUrl) return fallback;
+ if (!nodeBaseUrl) return failClosed ? "" : fallback;
// Trim trailing slashes
let normalized = nodeBaseUrl;
@@ -193,14 +194,19 @@ export async function handleVideoGeneration({ body, credentials, log, resolvedPr
log.info("VIDEO", `Custom model ${provider}/${model} — using OpenAI-compatible handler`);
const syntheticConfig = {
id: provider,
- baseUrl: resolveVideoBaseUrl(
- credentials,
- "http://generative.language.googleapis.com/v1beta/openai/videos/generations"
- ),
+ baseUrl: resolveVideoBaseUrl(credentials, "", true),
authType: "apikey",
authHeader: "bearer",
format: "openai-video",
};
+ if (!syntheticConfig.baseUrl) {
+ return {
+ success: false,
+ status: 501,
+ error: `Video generation is not configured for custom provider: ${provider}`,
+ };
+ }
+
return handleOpenAIVideoGeneration({
model,
body,
diff --git a/open-sse/translator/deepseekWebTools.ts b/open-sse/translator/deepseekWebTools.ts
index d42f7acb0fd3..e6598986a793 100644
--- a/open-sse/translator/deepseekWebTools.ts
+++ b/open-sse/translator/deepseekWebTools.ts
@@ -701,6 +701,169 @@ function normalizeDeepSeekMarkup(text: string): string {
return text;
}
+// WMAdapter-compatible native DeepSeek output. DeepSeek has emitted both the
+// printable ASCII token spelling and the full-width tokenizer spelling. Keep
+// this parser separate from the generic XML-like parser. It is applied to
+// every DeepSeek request carrying OpenAI-compatible tools[], regardless of
+// which client/agent initiated the request. Malformed or unknown native output
+// must remain visible and must never be promoted by a fallback.
+const DSML_INVOKE_RE =
+ /<(?:(?:\|DSML\|)|(?:||DSML||))invoke\b([^>]*)>([\s\S]*?)<\/(?:(?:\|DSML\|)|(?:||DSML||))invoke\s*>/gi;
+const DSML_PARAMETER_RE =
+ /<(?:(?:\|DSML\|)|(?:||DSML||))parameter\b([^>]*)>([\s\S]*?)(?:<\/(?:(?:\|DSML\|)|(?:||DSML||))parameter\s*>|<(?:(?:\|DSML\|)|(?:||DSML||))parameter\s*>)/gi;
+const DSML_TOKEN_RE =
+ /<(?:(?:\|DSML\|)|(?:||DSML||))(?:calls|call|invoke|parameter)\b[^>]*>[^]*?<\/(?:(?:\|DSML\|)|(?:||DSML||))(?:calls|call|invoke|parameter)\s*>/i;
+
+type DsmlRange = { start: number; end: number };
+
+const NATIVE_TOOL_CALL_TOKEN_RE =
+ /(?:<\|tool_call_begin\|>|<|tool▁call▁begin|>)[\s\n]*([^<\s]+)[\s\n]*(?:<\|tool_call_argument_begin\|>|<|tool▁call▁argument▁begin|>)([\s\S]*?)(?:<\|tool_call_argument_end\|>|<|tool▁call▁argument▁end|>)[\s\n]*(?:<\|tool_call_end\|>|<|tool▁call▁end|>)/g;
+
+/**
+ * Parse the printable-ASCII / full-width `<|tool_call_begin|>...` native token
+ * shape, appending any recognized calls (and their source ranges) in place.
+ */
+function collectNativeToolCallTokens(
+ text: string,
+ idSeed: string,
+ requested: RequestedToolName[],
+ requestedTools: unknown,
+ calls: OpenAIToolCall[],
+ ranges: DsmlRange[]
+): void {
+ NATIVE_TOOL_CALL_TOKEN_RE.lastIndex = 0;
+ let nativeMatch: RegExpExecArray | null;
+ while ((nativeMatch = NATIVE_TOOL_CALL_TOKEN_RE.exec(text)) !== null) {
+ const resolved = resolveRequestedToolName(nativeMatch[1], requested);
+ const parsed = parseJsonOrNull(nativeMatch[2].trim());
+ if (!resolved || !parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue;
+ const normalized = _normalizeDeepSeekNativeArguments(
+ parsed as Record,
+ resolved,
+ requestedTools
+ );
+ calls.push({
+ id: `${idSeed}_${calls.length}`,
+ type: "function",
+ function: { name: resolved, arguments: JSON.stringify(normalized) },
+ });
+ ranges.push({ start: nativeMatch.index, end: nativeMatch.index + nativeMatch[0].length });
+ }
+}
+
+function parseJsonOrNull(raw: string): unknown {
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+/** Parse one `...` block's args. */
+function collectDsmlInvokeParameters(body: string): {
+ args: Record;
+ found: boolean;
+} {
+ const args: Record = {};
+ let found = false;
+ DSML_PARAMETER_RE.lastIndex = 0;
+ let parameterMatch: RegExpExecArray | null;
+ while ((parameterMatch = DSML_PARAMETER_RE.exec(body)) !== null) {
+ const parameterName = getAttr(parameterMatch[1] || "", "name");
+ if (!parameterName) continue;
+ const raw = parameterMatch[2].trim();
+ args[parameterName] = parseJsonOrNull(raw) ?? raw;
+ found = true;
+ }
+ return { args, found };
+}
+
+/**
+ * Parse the `...` shape, appending any
+ * recognized calls (and their source ranges) in place.
+ */
+function collectDsmlInvokeCalls(
+ text: string,
+ idSeed: string,
+ requested: RequestedToolName[],
+ requestedTools: unknown,
+ calls: OpenAIToolCall[],
+ ranges: DsmlRange[]
+): void {
+ DSML_INVOKE_RE.lastIndex = 0;
+ let match: RegExpExecArray | null;
+ while ((match = DSML_INVOKE_RE.exec(text)) !== null) {
+ const name = getAttr(match[1] || "", "name");
+ const resolved = name ? resolveRequestedToolName(name, requested) : null;
+ if (!resolved) continue;
+ const { args, found } = collectDsmlInvokeParameters(match[2]);
+ // An invoke with no parameters is valid; a malformed body is not.
+ if (!found && match[2].trim()) continue;
+ const normalized = _normalizeDeepSeekNativeArguments(args, resolved, requestedTools);
+ calls.push({
+ id: `${idSeed}_${calls.length}`,
+ type: "function",
+ function: { name: resolved, arguments: JSON.stringify(normalized) },
+ });
+ ranges.push({ start: match.index, end: match.index + match[0].length });
+ }
+}
+
+/** Extend a recognized call's stripped range to cover its enclosing `` wrapper. */
+function extendRangesForDsmlWrapper(text: string, ranges: DsmlRange[]): void {
+ const wrapperRe =
+ /<(?:(?:\|DSML\|)|(?:||DSML||))calls\s*>[\s\S]*?<\/(?:(?:\|DSML\|)|(?:||DSML||))calls\s*>/gi;
+ let wrapper: RegExpExecArray | null;
+ while ((wrapper = wrapperRe.exec(text)) !== null) {
+ const start = wrapper.index;
+ const end = start + wrapper[0].length;
+ if (ranges.some((range) => range.start >= start && range.end <= end))
+ ranges.push({ start, end });
+ }
+}
+
+function parseNativeDeepSeekCalls(
+ text: string,
+ idSeed: string,
+ requestedTools: unknown
+): { content: string; toolCalls: OpenAIToolCall[] | null; recognized: boolean } {
+ const requested = getRequestedToolNames(requestedTools);
+ const calls: OpenAIToolCall[] = [];
+ const ranges: DsmlRange[] = [];
+ collectNativeToolCallTokens(text, idSeed, requested, requestedTools, calls, ranges);
+ collectDsmlInvokeCalls(text, idSeed, requested, requestedTools, calls, ranges);
+ if (calls.length > 0) extendRangesForDsmlWrapper(text, ranges);
+ const recognized =
+ DSML_TOKEN_RE.test(text) || /<(?:(?:\|tool_call_)|(?:|tool▁call▁))/.test(text);
+ if (calls.length === 0) return { content: text, toolCalls: null, recognized };
+ return { content: stripRanges(text, ranges), toolCalls: calls, recognized };
+}
+
+function _normalizeDeepSeekNativeArguments(
+ args: Record,
+ name: string,
+ tools: unknown
+): Record {
+ // Match WMAdapter's schema-guided string coercion locally, without changing
+ // the generic web-tools behavior.
+ const tool = Array.isArray(tools)
+ ? (tools as OpenAIToolDef[]).find((t) => t?.function?.name === name)
+ : undefined;
+ const properties = (
+ tool?.function?.parameters as
+ | {
+ properties?: Record;
+ }
+ | undefined
+ )?.properties;
+ if (!properties || typeof properties !== "object") return args;
+ for (const [key, value] of Object.entries(args)) {
+ if (properties[key]?.type === "string" && typeof value !== "string")
+ args[key] = JSON.stringify(value);
+ }
+ return args;
+}
+
// ── Public parser ─────────────────────────────────────────────────────────────
/**
@@ -724,12 +887,28 @@ export function parseDeepSeekToolCalls(
text = text.replace(/\s*This response is AI-generated, for reference only\.\s*$/i, "");
const requested = getRequestedToolNames(requestedTools);
- // Native DSML dialect first, on the ORIGINAL text: real replies close blocks sloppily
- // (`<||DSML|| invoke>` with no slash), which normalizeDsmlInvokeMarkup would turn into
- // unbalanced `` opens. When it yields no calls, fall through to the normalizing path.
+ // Upstream's double-pipe DSML dialect first, on the ORIGINAL text: real replies close blocks
+ // sloppily (`<||DSML|| invoke>` with no slash), which normalizeDsmlInvokeMarkup would turn into
+ // unbalanced `` opens. When it yields no calls, fall through to this fork's dialects.
const dsmlParsed = hasDsmlTags(text) ? parseDsmlBlocks(text, requested) : null;
const dsml = dsmlParsed && dsmlParsed.calls.length > 0 ? dsmlParsed : null;
- if (!dsml) text = normalizeDeepSeekMarkup(text);
+ if (!dsml) {
+ // Dialects upstream's double-pipe parser does not claim: the single-pipe ASCII |DSML| form
+ // (with string="" attributes) and the WMAdapter ▁-token spelling.
+ // A recognised-but-empty result may only claim the turn when there is no block left to
+ // try. Otherwise a valid `{json}` body followed by malformed DSML noise (production
+ // content for #14628) would be answered with null instead of reaching the tag/salvage path.
+ const hasTagBlock = /
+ (r.toolCalls?.length ?? 0) > 0 || !hasTagBlock;
+
+ const forkDsml = parseFullWidthDsmlCalls(text, idSeed, requestedTools);
+ if (forkDsml.recognized && claims(forkDsml)) return forkDsml;
+
+ const native = parseNativeDeepSeekCalls(text, idSeed, requestedTools);
+ if (native.recognized && claims(native)) return native;
+ text = normalizeDeepSeekMarkup(text);
+ }
const tokens = tokenizeToolTags(text);
if (tokens.length === 0 && !dsml) {
@@ -842,3 +1021,71 @@ export function parseDeepSeekToolCalls(
return { content, toolCalls };
}
+
+/**
+ * DeepSeek Web sometimes emits its internal full-width DSML envelope instead of the
+ * requested `{json}` contract. Convert every invocation in the envelope to
+ * an OpenAI tool call. This is deliberately separate from the generic tag parser because
+ * the DSML delimiters contain full-width Unicode characters and may contain several calls
+ * in one response.
+ */
+/** Parse a full-width-DSML invoke body's `` children, falling back to bare JSON. */
+function extractFullWidthDsmlArgs(body: string): Record {
+ const args: Record = {};
+ const paramRe =
+ /<(?:||DSML|||\|DSML\|)\s*parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)(?:<\/?(?:||DSML|||\|DSML\|)\s*parameter\s*>|(?=<(?:||DSML|||\|DSML\|)\s*parameter\s+name=|<\/(?:||DSML|||\|DSML\|)\s*invoke\s*>))/g;
+ let param: RegExpExecArray | null;
+ while ((param = paramRe.exec(body)) !== null) {
+ const raw = param[2].trim();
+ args[param[1]] = parseJsonOrNull(raw) ?? raw;
+ }
+ if (Object.keys(args).length > 0) return args;
+
+ // No children: the whole body may itself be a bare JSON object.
+ const parsed = parseJsonOrNull(body.trim() || "{}");
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) Object.assign(args, parsed);
+ return args;
+}
+
+function parseFullWidthDsmlCalls(
+ text: string,
+ idSeed: string,
+ requestedTools?: unknown
+): { content: string; toolCalls: OpenAIToolCall[] | null; recognized: boolean } {
+ const marker = /<(?:(?:||DSML||)|(?:\|DSML\|))/;
+ if (!marker.test(text)) return { content: text, toolCalls: null, recognized: false };
+
+ const requested = getRequestedToolNames(requestedTools);
+ const calls: OpenAIToolCall[] = [];
+ const ranges: DsmlRange[] = [];
+ const invokeRe =
+ /<(?||DSML|||\|DSML\|)\s*invoke\s+name=["']([^"']+)["']\s*>([\s\S]*?)<\/\k\s*invoke\s*>/g;
+ let match: RegExpExecArray | null;
+ // A well-formed `invoke name="...">...` pair marks the response as a genuine
+ // (if possibly unresolved) DSML tool call, distinct from stray/corrupted DSML delimiter
+ // debris trailing an unrelated block (#14103 salvage regression) — only the former should
+ // block the canonical ``/salvage fallback below.
+ let sawInvokeTag = false;
+ while ((match = invokeRe.exec(text)) !== null) {
+ sawInvokeTag = true;
+ const resolvedName = resolveRequestedToolName(match[2], requested);
+ if (requested.length > 0 && !resolvedName) continue;
+ const name = resolvedName ?? match[2];
+ const args = extractFullWidthDsmlArgs(match[3]);
+ calls.push({
+ id: `${idSeed}_${calls.length}`,
+ type: "function",
+ function: { name, arguments: JSON.stringify(args) },
+ });
+ ranges.push({ start: match.index, end: invokeRe.lastIndex });
+ }
+
+ if (calls.length === 0) return { content: text, toolCalls: null, recognized: sawInvokeTag };
+
+ const callsEnvelope = /<\/?(?:||DSML|||\|DSML\|)\s*calls\s*>/g;
+ let envelope: RegExpExecArray | null;
+ while ((envelope = callsEnvelope.exec(text)) !== null) {
+ ranges.push({ start: envelope.index, end: callsEnvelope.lastIndex });
+ }
+ return { content: stripRanges(text, ranges), toolCalls: calls, recognized: true };
+}
diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts
index c7aeb41f8f3c..e96cadc84b3d 100644
--- a/open-sse/translator/webTools.ts
+++ b/open-sse/translator/webTools.ts
@@ -33,6 +33,40 @@ const TOOL_CLOSE_RE = /<\/tool>/g;
const TOOL_CALL_OPEN_RE = /]*)?>/g;
const TOOL_CALL_CLOSE_RE = /<\/tool_call>/g;
+const DSML_INVOKE_RE =
+ /<(?||DSML|||\|DSML\|)\s*invoke\s+name=["']([^"']+)["']\s*>([\s\S]*?)<\/\k\s*invoke\s*>/g;
+
+function normalizeDsmlToolCalls(text: string): string {
+ DSML_INVOKE_RE.lastIndex = 0;
+ return text
+ .replace(DSML_INVOKE_RE, (_match, _dsml: string, name: string, body: string) => {
+ const parameters: Record = {};
+ const parameterRe =
+ /<(?:||DSML|||\|DSML\|)\s*parameter\s+name=["']([^"']+)["'][^>]*>([\s\S]*?)(?:<\/?(?:||DSML|||\|DSML\|)\s*parameter\s*>|(?=<(?:||DSML|||\|DSML\|)\s*parameter\s+name=|<\/(?:||DSML|||\|DSML\|)\s*invoke\s*>))/g;
+ let parameter: RegExpExecArray | null;
+ while ((parameter = parameterRe.exec(body)) !== null) {
+ const raw = parameter[2].trim();
+ try {
+ parameters[parameter[1]] = JSON.parse(raw);
+ } catch {
+ parameters[parameter[1]] = raw;
+ }
+ }
+ if (Object.keys(parameters).length === 0) {
+ try {
+ const parsed = JSON.parse(body.trim() || "{}");
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
+ Object.assign(parameters, parsed);
+ }
+ } catch {
+ // Leave malformed arguments empty; the surrounding parser remains fail-safe.
+ }
+ }
+ return `{"name":${JSON.stringify(name)},"arguments":${JSON.stringify(parameters)}}`;
+ })
+ .replace(/<\/?(?:||DSML|||\|DSML\|)\s*calls\s*>/g, "");
+}
+
// Per-request nonce binding for tool envelopes (#9343). Associates a random nonce
// with each tools[] array reference so the serializer and parser can share it
// without threading extra parameters through executor call chains.
@@ -435,17 +469,23 @@ export function parseToolCallsFromText(
idSeed = "call",
requestedTools?: unknown
): { content: string; toolCalls: OpenAIToolCall[] | null } {
+ const normalizedText = typeof text === "string" ? normalizeDsmlToolCalls(text) : text;
const requestedToolNames = getRequestedToolNames(requestedTools);
- if (typeof text !== "string" || (!text.includes("") && !text.includes("") && !normalizedText.includes(" blocks first, so both shapes are collected.
for (const block of [
- ...findTagBlocks(text, TOOL_OPEN_RE, TOOL_CLOSE_RE),
- ...findTagBlocks(text, TOOL_CALL_OPEN_RE, TOOL_CALL_CLOSE_RE),
+ ...findTagBlocks(normalizedText, TOOL_OPEN_RE, TOOL_CLOSE_RE),
+ ...findTagBlocks(normalizedText, TOOL_CALL_OPEN_RE, TOOL_CALL_CLOSE_RE),
]) {
candidates.push({
raw: block.inner.trim(),
@@ -493,7 +533,7 @@ export function parseToolCallsFromText(
return { content: text, toolCalls: null };
}
- const content = stripRanges(text, acceptedRanges);
+ const content = stripRanges(normalizedText, acceptedRanges);
return { content, toolCalls };
}
diff --git a/tests/boundary/openclaw-omniroute-tool-boundary.live.test.ts b/tests/boundary/openclaw-omniroute-tool-boundary.live.test.ts
index 744ec4a09ed7..456e6b89f1a6 100644
--- a/tests/boundary/openclaw-omniroute-tool-boundary.live.test.ts
+++ b/tests/boundary/openclaw-omniroute-tool-boundary.live.test.ts
@@ -13,14 +13,14 @@
import test from "node:test";
import assert from "node:assert/strict";
-const BASE = process.env.OMNIROUTE_TEST_BASE || "http://localhost:20128/v1";
-const AUTH = process.env.OMNIROUTE_TEST_BEARER
- ? `Bearer ${process.env.OMNIROUTE_TEST_BEARER}`
- : "";
+const BASE =
+ process.env.OMNIROUTE_TEST_BASE || `${process.env.OMNIROUTE_URL || "http://localhost:20128"}/v1`;
+const TEST_BEARER = process.env.OMNIROUTE_TEST_BEARER || process.env.OMNIROUTE_API_KEY || "";
+const AUTH = TEST_BEARER ? `Bearer ${TEST_BEARER}` : "";
+const TEST_MODEL = process.env.OPENCLAW_TEST_MODEL || "cx/gpt-5.6-sol";
// Cookie obtained via INITIAL_PASSWORD login
-const COOKIE =
- process.env.OMNIROUTE_TEST_COOKIE || "";
+const COOKIE = process.env.OMNIROUTE_TEST_COOKIE || "";
// Only the tests that call the live remote OmniRoute API need this gate — the
// pure parsing/stopReason-simulation tests at the bottom of the file run locally.
@@ -120,135 +120,147 @@ async function sendResponsesApiJson(
return (await response.json()) as Record;
}
-test("OmniRoute boundary: tool call arguments with newlines survive Responses API (non-streaming)", { skip }, async () => {
- const body = {
- model: "gemini/gemma-4-26b-a4b-it",
- input: [
- {
- role: "user",
- content: [
- {
- type: "input_text",
- text: "Write a file /tmp/test_boundary.txt with content:\nhello\nworld\ntest",
- },
- ],
- },
- ],
- tools: [
- {
- type: "function",
- name: "write",
- description: "Write content to a file at the specified path",
- parameters: {
- type: "object",
- properties: {
- path: { type: "string", description: "File path" },
- content: { type: "string", description: "File content" },
+test(
+ "OmniRoute boundary: tool call arguments with newlines survive Responses API (non-streaming)",
+ { skip },
+ async () => {
+ const body = {
+ model: TEST_MODEL,
+ input: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: "Write a file /tmp/test_boundary.txt with content:\nhello\nworld\ntest",
+ },
+ ],
+ },
+ ],
+ tools: [
+ {
+ type: "function",
+ name: "write",
+ description: "Write content to a file at the specified path",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string", description: "File path" },
+ content: { type: "string", description: "File content" },
+ },
+ required: ["path", "content"],
+ additionalProperties: false,
},
- required: ["path", "content"],
+ strict: true,
},
- strict: true,
- },
- ],
- temperature: 0.1,
- max_output_tokens: 4096,
- };
+ ],
+ max_output_tokens: 4096,
+ };
- const response = (await sendResponsesApiJson(body)) as ResponsesJsonBody;
+ const response = (await sendResponsesApiJson(body)) as ResponsesJsonBody;
- assert.equal(response.status, "completed");
- assert.ok(Array.isArray(response.output), "should have output array");
+ assert.equal(response.status, "completed");
+ assert.ok(Array.isArray(response.output), "should have output array");
- const toolCalls = (response.output || []).filter((item) => item.type === "function_call");
+ const toolCalls = (response.output || []).filter((item) => item.type === "function_call");
- assert.ok(toolCalls.length > 0, "should have at least one function_call");
- assert.equal(toolCalls[0].name, "write");
+ assert.ok(toolCalls.length > 0, "should have at least one function_call");
+ assert.equal(toolCalls[0].name, "write");
- const args = JSON.parse(toolCalls[0].arguments || "{}");
- assert.equal(typeof args.content, "string", "content should be a string");
- assert.ok(args.content.includes("\n"), "content should have actual newlines (0x0A)");
+ const args = JSON.parse(toolCalls[0].arguments || "{}");
+ assert.equal(typeof args.content, "string", "content should be a string");
+ assert.ok(args.content.includes("\n"), "content should have actual newlines (0x0A)");
- // Verify no literal backslash-n contamination
- const literalBSN = (args.content as string).match(/\\n/g);
- assert.equal(literalBSN, null, "no literal backslash-n in content");
+ // Verify no literal backslash-n contamination
+ const literalBSN = (args.content as string).match(/\\n/g);
+ assert.equal(literalBSN, null, "no literal backslash-n in content");
- // Verify newlines are actual 0x0A bytes
- for (let i = 0; i < args.content.length; i++) {
- if (args.content[i] === "\n") {
- assert.equal(
- args.content.charCodeAt(i),
- 0x0a,
- `byte at position ${i} should be 0x0A not 0x5C 0x6E`
- );
+ // Verify newlines are actual 0x0A bytes
+ for (let i = 0; i < args.content.length; i++) {
+ if (args.content[i] === "\n") {
+ assert.equal(
+ args.content.charCodeAt(i),
+ 0x0a,
+ `byte at position ${i} should be 0x0A not 0x5C 0x6E`
+ );
+ }
}
}
-});
-
-test("OmniRoute boundary: tool call arguments with newlines survive Responses API (streaming)", { skip }, async () => {
- const body = {
- model: "gemini/gemma-4-26b-a4b-it",
- input: [
- {
- role: "user",
- content: [
- {
- type: "input_text",
- text: "Write a file /tmp/test_stream.txt with content:\nline A\nline B\nline C\nusing the write tool",
- },
- ],
- },
- ],
- tools: [
- {
- type: "function",
- name: "write",
- description: "Write content to a file",
- parameters: {
- type: "object",
- properties: {
- path: { type: "string" },
- content: { type: "string" },
+);
+
+test(
+ "OmniRoute boundary: tool call arguments with newlines survive Responses API (streaming)",
+ { skip },
+ async () => {
+ const body = {
+ model: TEST_MODEL,
+ input: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: "Write a file /tmp/test_stream.txt with content:\nline A\nline B\nline C\nusing the write tool",
+ },
+ ],
+ },
+ ],
+ tools: [
+ {
+ type: "function",
+ name: "write",
+ description: "Write content to a file",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string" },
+ content: { type: "string" },
+ },
+ required: ["path", "content"],
+ additionalProperties: false,
},
- required: ["path", "content"],
+ strict: true,
},
- strict: true,
- },
- ],
- temperature: 0.1,
- max_output_tokens: 4096,
- stream: true,
- };
-
- const events = await sendResponsesApiStream(body);
-
- // Find the function call output_item.done event
- const doneEvent = events.find(
- (e) => e.event === "response.output_item.done" && eventItem(e)?.type === "function_call"
- );
- assert.ok(doneEvent, "should have output_item.done for function_call");
-
- const item = eventItem(doneEvent as SseEvent) as ResponseFunctionCallItem;
- assert.equal(item.name, "write");
-
- const args = JSON.parse(item.arguments || "{}");
- assert.equal(typeof args.content, "string", "content should be a string");
- assert.ok(args.content.includes("\n"), "content should have actual newlines");
-
- // Check completed event
- const completed = events.find((e) => e.event === "response.completed");
- assert.ok(completed, "should have response.completed event");
- const completedResponse = completed?.data?.response as ResponsesJsonBody | undefined;
- const outputItems = completedResponse?.output || [];
- const completedFc = outputItems.find((item) => item.type === "function_call");
- assert.ok(completedFc, "completed output should have function_call");
-
- const completedArgs = JSON.parse(completedFc?.arguments || "{}");
- assert.equal(completedArgs.content, args.content, "completed arguments should match done event");
-});
+ ],
+ max_output_tokens: 4096,
+ stream: true,
+ };
+
+ const events = await sendResponsesApiStream(body);
+
+ // Find the function call output_item.done event
+ const doneEvent = events.find(
+ (e) => e.event === "response.output_item.done" && eventItem(e)?.type === "function_call"
+ );
+ assert.ok(doneEvent, "should have output_item.done for function_call");
+
+ const item = eventItem(doneEvent as SseEvent) as ResponseFunctionCallItem;
+ assert.equal(item.name, "write");
+
+ const args = JSON.parse(item.arguments || "{}");
+ assert.equal(typeof args.content, "string", "content should be a string");
+ assert.ok(args.content.includes("\n"), "content should have actual newlines");
+
+ // Check completed event
+ const completed = events.find((e) => e.event === "response.completed");
+ assert.ok(completed, "should have response.completed event");
+ const completedResponse = completed?.data?.response as ResponsesJsonBody | undefined;
+ const outputItems = completedResponse?.output || [];
+ const completedFc = outputItems.find((item) => item.type === "function_call");
+ assert.ok(completedFc, "completed output should have function_call");
+
+ const completedArgs = JSON.parse(completedFc?.arguments || "{}");
+ assert.equal(
+ completedArgs.content,
+ args.content,
+ "completed arguments should match done event"
+ );
+ }
+);
test("OmniRoute boundary: exec tool call arguments survive Responses API", { skip }, async () => {
const body = {
- model: "gemini/gemma-4-26b-a4b-it",
+ model: TEST_MODEL,
input: [
{
role: "user",
@@ -271,11 +283,11 @@ test("OmniRoute boundary: exec tool call arguments survive Responses API", { ski
command: { type: "string", description: "The command to execute" },
},
required: ["command"],
+ additionalProperties: false,
},
strict: true,
},
],
- temperature: 0.1,
max_output_tokens: 4096,
stream: true,
};
@@ -302,7 +314,7 @@ test("OmniRoute boundary: exec tool call arguments survive Responses API", { ski
test("OmniRoute boundary: parallel tool calls survive Responses API", { skip }, async () => {
const body = {
- model: "gemini/gemma-4-26b-a4b-it",
+ model: TEST_MODEL,
input: [
{
role: "user",
@@ -326,11 +338,11 @@ test("OmniRoute boundary: parallel tool calls survive Responses API", { skip },
content: { type: "string" },
},
required: ["path", "content"],
+ additionalProperties: false,
},
strict: true,
},
],
- temperature: 0.1,
max_output_tokens: 4096,
stream: true,
};
@@ -361,7 +373,7 @@ test("OmniRoute boundary: parallel tool calls survive Responses API", { skip },
test("OmniRoute boundary: tool call through default combo (fill-first)", { skip }, async () => {
const body = {
- model: "default",
+ model: TEST_MODEL,
input: [
{
role: "user",
@@ -384,11 +396,11 @@ test("OmniRoute boundary: tool call through default combo (fill-first)", { skip
command: { type: "string" },
},
required: ["command"],
+ additionalProperties: false,
},
strict: true,
},
],
- temperature: 0.1,
max_output_tokens: 4096,
stream: true,
};
@@ -405,169 +417,180 @@ test("OmniRoute boundary: tool call through default combo (fill-first)", { skip
assert.ok(args.command.length > 0);
});
-test("OmniRoute boundary: multi-line Python code survives tool call arguments", { skip }, async () => {
- const pythonCode = [
- "import json",
- "import random",
- "",
- "data = {",
- ' "numbers": [random.randint(1, 100) for _ in range(5)]',
- "}",
- "",
- "with open('/tmp/boundary_test.json', 'w') as f:",
- " json.dump(data, f, indent=2)",
- "",
- 'print("Done")',
- "",
- ].join("\n");
-
- const body = {
- model: "gemini/gemma-4-26b-a4b-it",
- input: [
- {
- role: "user",
- content: [
- {
- type: "input_text",
- text: `Write a Python script at /tmp/boundary_test.py that generates random numbers and saves to JSON, then run it.
+test(
+ "OmniRoute boundary: multi-line Python code survives tool call arguments",
+ { skip },
+ async () => {
+ const pythonCode = [
+ "import json",
+ "import random",
+ "",
+ "data = {",
+ ' "numbers": [random.randint(1, 100) for _ in range(5)]',
+ "}",
+ "",
+ "with open('/tmp/boundary_test.json', 'w') as f:",
+ " json.dump(data, f, indent=2)",
+ "",
+ 'print("Done")',
+ "",
+ ].join("\n");
+
+ const body = {
+ model: TEST_MODEL,
+ input: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: `Write a Python script at /tmp/boundary_test.py that generates random numbers and saves to JSON, then run it.
Script content:
${pythonCode}`,
+ },
+ ],
+ },
+ ],
+ tools: [
+ {
+ type: "function",
+ name: "write",
+ description: "Write content to a file",
+ parameters: {
+ type: "object",
+ properties: {
+ path: { type: "string" },
+ content: { type: "string" },
+ },
+ required: ["path", "content"],
+ additionalProperties: false,
},
- ],
- },
- ],
- tools: [
- {
- type: "function",
- name: "write",
- description: "Write content to a file",
- parameters: {
- type: "object",
- properties: {
- path: { type: "string" },
- content: { type: "string" },
- },
- required: ["path", "content"],
+ strict: true,
},
- strict: true,
- },
- {
- type: "function",
- name: "exec",
- description: "Execute a shell command",
- parameters: {
- type: "object",
- properties: {
- command: { type: "string" },
+ {
+ type: "function",
+ name: "exec",
+ description: "Execute a shell command",
+ parameters: {
+ type: "object",
+ properties: {
+ command: { type: "string" },
+ },
+ required: ["command"],
+ additionalProperties: false,
},
- required: ["command"],
+ strict: true,
},
- strict: true,
- },
- ],
- temperature: 0.1,
- max_output_tokens: 4096,
- stream: true,
- };
+ ],
+ max_output_tokens: 4096,
+ stream: true,
+ };
- const events = await sendResponsesApiStream(body);
+ const events = await sendResponsesApiStream(body);
- const writeDoneEvent = events.find(
- (e) => e.event === "response.output_item.done" && eventItem(e)?.name === "write"
- );
+ const writeDoneEvent = events.find(
+ (e) => e.event === "response.output_item.done" && eventItem(e)?.name === "write"
+ );
- if (writeDoneEvent) {
- const args = JSON.parse(eventItem(writeDoneEvent)?.arguments || "{}");
- assert.equal(typeof args.content, "string");
- assert.ok(args.content.includes("\n"), "Python code should have newlines");
+ if (writeDoneEvent) {
+ const args = JSON.parse(eventItem(writeDoneEvent)?.arguments || "{}");
+ assert.equal(typeof args.content, "string");
+ assert.ok(args.content.includes("\n"), "Python code should have newlines");
- // CRITICAL CHECK: Python code with `:\n ` pattern should not have literal backslash-n
- const colonBackslashN = args.content.match(/:\\n/g);
- const colonNewline = args.content.match(/: *\n/g);
+ // CRITICAL CHECK: Python code with `:\n ` pattern should not have literal backslash-n
+ const colonBackslashN = args.content.match(/:\\n/g);
+ const colonNewline = args.content.match(/: *\n/g);
- // If the model uses the colon-newline-indent pattern, it should be actual newline
- if (colonNewline && colonNewline.length > 0) {
- // This section has proper newlines - good
- }
- if (colonBackslashN && colonBackslashN.length > 0) {
- console.log(
- `WARNING: Found ${colonBackslashN.length} literal backslash-n after colon patterns`
- );
- console.log(" This indicates Gemma4 model is generating malformed content");
- }
+ // If the model uses the colon-newline-indent pattern, it should be actual newline
+ if (colonNewline && colonNewline.length > 0) {
+ // This section has proper newlines - good
+ }
+ if (colonBackslashN && colonBackslashN.length > 0) {
+ console.log(
+ `WARNING: Found ${colonBackslashN.length} literal backslash-n after colon patterns`
+ );
+ console.log(" This indicates Gemma4 model is generating malformed content");
+ }
- // The test: content should NOT have literal backslash-n in critical positions
- // (the model may still have this bug, so we document rather than hard-fail)
- const hasLiteralBSNAfterColon = (args.content as string).includes(":\\n");
- if (hasLiteralBSNAfterColon) {
- console.log("NOTE: Gemma4 model generated literal backslash-n after colon - known model bug");
+ // The test: content should NOT have literal backslash-n in critical positions
+ // (the model may still have this bug, so we document rather than hard-fail)
+ const hasLiteralBSNAfterColon = (args.content as string).includes(":\\n");
+ if (hasLiteralBSNAfterColon) {
+ console.log(
+ "NOTE: Gemma4 model generated literal backslash-n after colon - known model bug"
+ );
+ }
}
- }
-
- // At minimum, we should have at least one function_call event
- const anyDone = events.find(
- (e) => e.event === "response.output_item.done" && eventItem(e)?.type === "function_call"
- );
- assert.ok(anyDone, "should have at least one function_call");
-});
-test("OmniRoute boundary: model responds with proper JSON args when given clear tool defs", { skip }, async () => {
- // Test that the model can correctly produce tool calls with simple args
- const body = {
- model: "gemini/gemma-4-26b-a4b-it",
- input: [
- {
- role: "user",
- content: [
- {
- type: "input_text",
- text: "What is 2+2? Use the calculator tool.",
- },
- ],
- },
- ],
- tools: [
- {
- type: "function",
- name: "calculate",
- description: "Calculate a math expression",
- parameters: {
- type: "object",
- properties: {
- expression: {
- type: "string",
- description: "The math expression to evaluate",
+ // At minimum, we should have at least one function_call event
+ const anyDone = events.find(
+ (e) => e.event === "response.output_item.done" && eventItem(e)?.type === "function_call"
+ );
+ assert.ok(anyDone, "should have at least one function_call");
+ }
+);
+
+test(
+ "OmniRoute boundary: model responds with proper JSON args when given clear tool defs",
+ { skip },
+ async () => {
+ // Test that the model can correctly produce tool calls with simple args
+ const body = {
+ model: TEST_MODEL,
+ input: [
+ {
+ role: "user",
+ content: [
+ {
+ type: "input_text",
+ text: "What is 2+2? Use the calculator tool.",
},
+ ],
+ },
+ ],
+ tools: [
+ {
+ type: "function",
+ name: "calculate",
+ description: "Calculate a math expression",
+ parameters: {
+ type: "object",
+ properties: {
+ expression: {
+ type: "string",
+ description: "The math expression to evaluate",
+ },
+ },
+ required: ["expression"],
+ additionalProperties: false,
},
- required: ["expression"],
+ strict: true,
},
- strict: true,
- },
- ],
- temperature: 0.1,
- max_output_tokens: 4096,
- stream: true,
- };
-
- const events = await sendResponsesApiStream(body);
-
- const doneEvent = events.find(
- (e) => e.event === "response.output_item.done" && eventItem(e)?.type === "function_call"
- );
-
- if (doneEvent) {
- const rawArguments = eventItem(doneEvent)?.arguments || "{}";
- const args = JSON.parse(rawArguments);
- assert.equal(typeof args.expression, "string");
- assert.ok(args.expression.includes("2+2") || args.expression.includes("2 + 2"));
-
- // Verify the arguments JSON is valid
- assert.doesNotThrow(() => {
- JSON.parse(rawArguments);
- });
+ ],
+ max_output_tokens: 4096,
+ stream: true,
+ };
+
+ const events = await sendResponsesApiStream(body);
+
+ const doneEvent = events.find(
+ (e) => e.event === "response.output_item.done" && eventItem(e)?.type === "function_call"
+ );
+
+ if (doneEvent) {
+ const rawArguments = eventItem(doneEvent)?.arguments || "{}";
+ const args = JSON.parse(rawArguments);
+ assert.equal(typeof args.expression, "string");
+ assert.ok(args.expression.includes("2+2") || args.expression.includes("2 + 2"));
+
+ // Verify the arguments JSON is valid
+ assert.doesNotThrow(() => {
+ JSON.parse(rawArguments);
+ });
+ }
}
-});
+);
test("OpenClaw boundary: processResponsesStream correctly parses tool call args with newlines", () => {
// Simulate what OpenClaw's processResponsesStream does with tool call args
diff --git a/tests/integration/provider-journey.contract.test.ts b/tests/integration/provider-journey.contract.test.ts
index 9e8bb53743cf..0056b1f0d551 100644
--- a/tests/integration/provider-journey.contract.test.ts
+++ b/tests/integration/provider-journey.contract.test.ts
@@ -372,6 +372,7 @@ test.describe("provider journey — in-process contract (#8330)", () => {
const LIVE_ENABLED = process.env.RUN_CONTRACT_INT === "1";
const LIVE_SKIP_REASON = "Set RUN_CONTRACT_INT=1 to run the live over-the-wire contract journey";
const LIVE_BASE_URL = process.env.OMNIROUTE_TEST_URL ?? "http://localhost:20128";
+const LIVE_API_KEY = process.env.OMNIROUTE_API_KEY ?? process.env.ROUTER_API_KEY;
function liveMaybeSkip(t: { skip: (reason?: string) => void }): boolean {
if (!LIVE_ENABLED) {
@@ -390,6 +391,9 @@ async function liveFetch(
method,
headers: {
...(init.body !== undefined ? { "Content-Type": "application/json" } : {}),
+ ...(LIVE_API_KEY
+ ? { Authorization: `Bearer ${LIVE_API_KEY}`, "x-api-key": LIVE_API_KEY }
+ : {}),
...(init.headers ?? {}),
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
diff --git a/tests/unit/deepseek-web-tools-variants.test.ts b/tests/unit/deepseek-web-tools-variants.test.ts
index 73b3b96845fe..034f4b2a3ecc 100644
--- a/tests/unit/deepseek-web-tools-variants.test.ts
+++ b/tests/unit/deepseek-web-tools-variants.test.ts
@@ -30,6 +30,58 @@ function firstCall(text: string) {
}
describe("deepseekWebTools — variants", () => {
+ test("full-width DeepSeek DSML preserves multiple parallel invocations", () => {
+ const text = `<||DSML|| calls><||DSML|| invoke name="bash"><||DSML|| parameter name="command" string="true">pwd<||DSML|| parameter>||DSML|| invoke><||DSML|| invoke name="bash"><||DSML|| parameter name="command" string="true">whoami<||DSML|| parameter>||DSML|| invoke>||DSML|| calls>`;
+ const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
+ assert.equal(content, "");
+ assert.equal(toolCalls?.length, 2);
+ assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { command: "pwd" });
+ assert.deepEqual(JSON.parse(toolCalls![1].function.arguments), { command: "whoami" });
+ });
+
+ test("full-width DeepSeek DSML preserves three calls and JSON arguments", () => {
+ const text = `<||DSML|| calls><||DSML|| invoke name="bash"><||DSML|| parameter name="command" string="true">pwd<||DSML|| parameter>||DSML|| invoke><||DSML|| invoke name="bash"><||DSML|| parameter name="command" string="true">whoami<||DSML|| parameter>||DSML|| invoke><||DSML|| invoke name="bash"><||DSML|| parameter name="command" string="true">printf TOOL_OK<||DSML|| parameter>||DSML|| invoke>||DSML|| calls>`;
+ const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
+ assert.equal(toolCalls?.length, 3);
+ assert.deepEqual(
+ toolCalls?.map((call) => JSON.parse(call.function.arguments)),
+ [{ command: "pwd" }, { command: "whoami" }, { command: "printf TOOL_OK" }]
+ );
+ });
+
+ test("DSML tool call with string attributes works for any requested tool", () => {
+ const text = `<|DSML|calls><|DSML|invoke name="browser"><|DSML|parameter name="action" string="true">act<|DSML|parameter><|DSML|parameter name="kind" string="false">click<|DSML|parameter>|DSML|invoke>|DSML|calls>`;
+ const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", [
+ { type: "function", function: { name: "browser" } },
+ ]);
+ assert.equal(toolCalls?.length, 1);
+ assert.equal(toolCalls![0].function.name, "browser");
+ assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), {
+ action: "act",
+ kind: "click",
+ });
+ assert.equal(content, "");
+ });
+
+ test("WMAdapter native token fixture", () => {
+ const text = `<|tool▁call▁begin|>browser<|tool▁call▁argument▁begin|>{"action":"screenshot"}<|tool▁call▁argument▁end|><|tool▁call▁end|>`;
+ const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", [
+ { type: "function", function: { name: "browser" } },
+ ]);
+ assert.equal(toolCalls?.length, 1);
+ assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { action: "screenshot" });
+ assert.equal(content, "");
+ });
+
+ test("malformed or unknown native markers remain content", () => {
+ const text = `<|DSML|calls><|DSML|invoke name="unknown"><|DSML|parameter name="x">1<|DSML|parameter>|DSML|invoke>|DSML|calls>`;
+ const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", [
+ { type: "function", function: { name: "browser" } },
+ ]);
+ assert.equal(toolCalls, null);
+ assert.equal(content, text);
+ });
+
test("Ex1: {json} — name in tag suffix, body is the arguments", () => {
const text = `I'll write a script.\n\n\n{"todos": [{"content": "a", "status": "in_progress", "priority": "high"}]}\n`;
const { content, toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS);
@@ -161,7 +213,7 @@ describe("deepseekWebTools — strict prompt", () => {
const prompt = serializeDeepSeekToolPrompt(TOOLS);
assert.ok(prompt.includes("todowrite"));
assert.ok(prompt.includes("get_weather"));
- assert.ok(prompt.includes('_nonce'), "includes nonce binding");
+ assert.ok(prompt.includes("_nonce"), "includes nonce binding");
assert.ok(prompt.includes('{"name"'), "shows the canonical format");
assert.ok(/never|not|do not/i.test(prompt), "warns against alternative formats");
});
diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts
index 3ca18f415978..e83ceb4f0b51 100644
--- a/tests/unit/image-generation-handler.test.ts
+++ b/tests/unit/image-generation-handler.test.ts
@@ -1362,37 +1362,20 @@ test("handleImageGeneration returns provider errors when Imagen3 fetch throws",
}
});
-test("handleImageGeneration uses the default synthetic base URL for resolved custom providers without baseUrl", async () => {
- const originalFetch = globalThis.fetch;
- let capturedUrl;
-
- globalThis.fetch = async (url) => {
- capturedUrl = String(url);
- return new Response(JSON.stringify({ data: [{ b64_json: "ZmFrZS1jdXN0b20=" }] }), {
- status: 200,
- headers: { "content-type": "application/json" },
- });
- };
-
- try {
- const result = await handleImageGeneration({
- body: {
- model: "custom-provider/super-image",
- prompt: "fallback base url",
- },
- credentials: { apiKey: "custom-key" },
- resolvedProvider: "custom-provider",
- log: null,
- });
+test("handleImageGeneration rejects custom providers without a configured baseUrl", async () => {
+ const result = await handleImageGeneration({
+ body: {
+ model: "custom-provider/super-image",
+ prompt: "fallback base url",
+ },
+ credentials: { apiKey: "custom-key" },
+ resolvedProvider: "custom-provider",
+ log: null,
+ });
- assert.equal(result.success, true);
- assert.equal(
- capturedUrl,
- "https://generativelanguage.googleapis.com/v1beta/openai/images/generations"
- );
- } finally {
- globalThis.fetch = originalFetch;
- }
+ assert.equal(result.success, false);
+ assert.equal(result.status, 501);
+ assert.match(result.error, /not configured/);
});
test("handleImageGeneration logs OpenAI-compatible upstream failures and transport errors", async () => {
diff --git a/tests/unit/web-tools-translation-2820.test.ts b/tests/unit/web-tools-translation-2820.test.ts
index b45e50c7daaa..1b6bdc3d3a5e 100644
--- a/tests/unit/web-tools-translation-2820.test.ts
+++ b/tests/unit/web-tools-translation-2820.test.ts
@@ -6,9 +6,8 @@
import test from "node:test";
import assert from "node:assert/strict";
-const { serializeToolsToPrompt, parseToolCallsFromText } = await import(
- "../../open-sse/translator/webTools.ts"
-);
+const { serializeToolsToPrompt, parseToolCallsFromText } =
+ await import("../../open-sse/translator/webTools.ts");
const TOOLS = [
{
@@ -75,7 +74,8 @@ test("parseToolCallsFromText does not parse bare JSON without requested tools",
});
test("parseToolCallsFromText does NOT promote Python-dict-ish bare JSON (#9343)", () => {
- const text = "{'command': 'get_weather', 'arguments': {'city': 'Paris', 'units': 'metric', 'fresh': True}}";
+ const text =
+ "{'command': 'get_weather', 'arguments': {'city': 'Paris', 'units': 'metric', 'fresh': True}}";
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls, null, "bare JSON must not be promoted");
@@ -101,7 +101,8 @@ test("parseToolCallsFromText does NOT promote fuzzy-matched bare JSON (#9343)",
});
test("parseToolCallsFromText does NOT strip bare JSON from surrounding text (#9343)", () => {
- const text = 'I will check now.\n{"name":"get_weather","arguments":"{\\"city\\":\\"Paris\\"}"}\nDone.';
+ const text =
+ 'I will check now.\n{"name":"get_weather","arguments":"{\\"city\\":\\"Paris\\"}"}\nDone.';
const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
assert.equal(toolCalls, null, "bare JSON must not be promoted");
@@ -125,6 +126,17 @@ test("parseToolCallsFromText parses multiple tool calls", () => {
assert.equal(toolCalls[1].function.name, "b");
});
+test("parseToolCallsFromText converts full-width DSML parallel calls", () => {
+ const text =
+ '<||DSML|| calls><||DSML|| invoke name="get_weather">{"city":"東京\\n中心"}||DSML|| invoke>' +
+ '<||DSML|| invoke name="get_weather">{"city":"München — ✓"}||DSML|| invoke>||DSML|| calls>';
+ const { content, toolCalls } = parseToolCallsFromText(text, "call", TOOLS);
+ assert.equal(toolCalls?.length, 2);
+ assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { city: "東京\n中心" });
+ assert.deepEqual(JSON.parse(toolCalls![1].function.arguments), { city: "München — ✓" });
+ assert.equal(content, "");
+});
+
test("parseToolCallsFromText tolerates a tool block with no arguments", () => {
const { toolCalls } = parseToolCallsFromText('{"name": "ping"}');
assert.equal(toolCalls?.length, 1);