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 name="bash"><||DSML|| parameter name="command" string="true">whoami<||DSML|| parameter>`; + 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 name="bash"><||DSML|| parameter name="command" string="true">whoami<||DSML|| parameter><||DSML|| invoke name="bash"><||DSML|| parameter name="command" string="true">printf TOOL_OK<||DSML|| parameter>`; + 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>`; + 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>`; + 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 name="get_weather">{"city":"München — ✓"}'; + 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);