From 50896699d3c3919504e3411f72795cdd73294f49 Mon Sep 17 00:00:00 2001
From: Diego Rodrigues de Sa e Souza
<8016841+diegosouzapw@users.noreply.github.com>
Date: Wed, 3 Jun 2026 18:29:26 -0300
Subject: [PATCH] fix+feat(sse): per-model 403 lockout (#3027) + deepseek-web
memory (#2942) & tool-calls (#2820) (#3101)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(sse): per-model 403 on passthrough providers locks the model, not the connection (#3027)
A per-model subscription 403 from a passthrough / per-model-quota provider
(e.g. ollama-cloud "this model requires a subscription, upgrade for access" on
deepseek-v4-pro) cooled down the ENTIRE connection instead of locking out only
the paid model, knocking out the free models on the same key and escalating an
exponential connection-wide backoff on repeats.
markAccountUnavailable's per-model lockout gate only covered 404/429/>=500, and
the only 403 -> model-lockout special case was hard-coded to grok-web. Generalize
it: a model-scoped 403 on an isPerModelQuotaProvider becomes a model lockout
(connection stays active). Terminal whole-key 403s (permanent/banned, account
deactivated, credits exhausted, project-route) keep their connection-level path.
Test-first: reproduction (paid model locked, connection active, free model still
eligible), regression guard (deactivated key still terminal -> banned, not
downgraded), and backoff guard (repeated 403s do not escalate connection backoff).
* feat(sse): persistent session + rolling-window memory for deepseek-web (#2942)
The DeepSeek web API takes only a single `prompt` string (no messages array) and
the executor created a fresh chat session per request, deleting it afterward — so
agentic multi-turn clients got per-turn amnesia.
Add two opt-in, per-connection settings (providerSpecificData), both defaulting to
the legacy behavior so plain-chat users are unaffected:
- historyWindow (number, default 0): when > 0, messagesToPrompt stitches the last N
non-system turns into a role-tagged transcript ("User:"/"Assistant:") so context
carries across turns within the single prompt string.
- persistSession (bool, default false): reuse one upstream chat session per userToken
(cached in the existing sessionCache) instead of creating/deleting one per request.
A reused session that fails is treated as stale (deleted in the DeepSeek UI): the
cache entry is dropped, a fresh session is created, and the completion is retried
once. Error paths invalidate the cached session so the next turn self-heals.
Test-first: pure prompt-builder window semantics (legacy/window/cap/empty) and
execute()-level session behavior via mocked fetch (fresh-per-request default, reuse,
stale-session fresh retry, history threaded into the prompt). Full existing
deepseek-web suite (35 tests) still green.
Note: chat.deepseek.com is an unofficial reverse-engineered surface; the live
round-trip can't be exercised in CI (no userToken/session). Treated as best-effort
per the issue; the prompt/session logic is unit-tested in isolation.
* feat(sse): tool-call translation for deepseek-web (#2820)
deepseek-web previously hard-failed any request carrying tools[] with a 400 (#2848),
so agentic clients could not use it for function calling at all. Add a bidirectional
translation layer (new open-sse/translator/webTools.ts, reusable by the other
web-cookie executors):
- Request: serializeToolsToPrompt() turns the OpenAI tools[] into a {...}
prompt contract, injected as a leading system message.
- Response: parseToolCallsFromText() extracts the model's blocks into OpenAI
tool_calls (arguments as a JSON string), strips them from content, and the executor
emits finish_reason "tool_calls" — for both non-stream and stream clients (the reply
is buffered since a tool block must be parsed whole).
A plain reply (no block) still streams normally with finish_reason "stop".
The superseded #2848 400-contract test is updated to assert the new translation.
Test-first: pure serializer/parser units + execute() round-trip via mocked fetch
(no-400, prompt serialization, non-stream tool_calls, stream tool_calls, plain reply).
Note: chat.deepseek.com is an unofficial reverse-engineered surface and the exact
emission depends on the model following the injected contract; the live
round-trip can't be exercised in CI. Best-effort per the issue; translation logic is
unit-tested in isolation.
* fix(sse): drop duplicate per-model 403 block — already in release via #3096
The release branch already scopes per-model 403 to model lockout (PR #3096,
commit 7042d562c) with the canonical !terminalStatus guard + exactCooldownMs
upstream hint. This PR's separate isTerminalOrRoute403 block shadowed it and
omitted the retry hint. Keep only the deepseek-web (#2942) + webTools (#2820)
changes here; the #3027 regression test is retained as coverage for #3096.
---
open-sse/executors/deepseek-web.ts | 333 ++++++++++++++----
open-sse/translator/webTools.ts | 109 ++++++
...th-ollama-cloud-per-model-403-3027.test.ts | 137 +++++++
.../deepseek-web-rolling-window-2942.test.ts | 61 ++++
...pseek-web-session-persistence-2942.test.ts | 189 ++++++++++
.../deepseek-web-tools-execute-2820.test.ts | 186 ++++++++++
tests/unit/deepseek-web.test.ts | 47 +--
tests/unit/web-tools-translation-2820.test.ts | 75 ++++
8 files changed, 1030 insertions(+), 107 deletions(-)
create mode 100644 open-sse/translator/webTools.ts
create mode 100644 tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts
create mode 100644 tests/unit/deepseek-web-rolling-window-2942.test.ts
create mode 100644 tests/unit/deepseek-web-session-persistence-2942.test.ts
create mode 100644 tests/unit/deepseek-web-tools-execute-2820.test.ts
create mode 100644 tests/unit/web-tools-translation-2820.test.ts
diff --git a/open-sse/executors/deepseek-web.ts b/open-sse/executors/deepseek-web.ts
index a1cae36300..2cb4bd9423 100644
--- a/open-sse/executors/deepseek-web.ts
+++ b/open-sse/executors/deepseek-web.ts
@@ -1,5 +1,10 @@
import { BaseExecutor, type ExecuteInput } from "./base.ts";
import { solveDeepSeekPowAsync } from "../lib/deepseek-pow.ts";
+import {
+ serializeToolsToPrompt,
+ parseToolCallsFromText,
+ type OpenAIToolCall,
+} from "../translator/webTools.ts";
export const DEEPSEEK_WEB_BASE = "https://chat.deepseek.com";
const DEEPSEEK_API_BASE = `${DEEPSEEK_WEB_BASE}/api`;
@@ -481,28 +486,44 @@ async function collectSSEContent(
// ── Prompt builder (DeepSeek native format, matches Chat2API) ────────────
-function messagesToPrompt(messages: Array<{ role: string; content: string }>): string {
- const extractText = (content: unknown): string => {
- if (Array.isArray(content)) {
- return (content as any[])
- .filter((item: any) => item.type === "text")
- .map((item: any) => item.text)
- .join("\n");
- }
- return String(content || "");
- };
+function extractMessageText(content: unknown): string {
+ if (Array.isArray(content)) {
+ return (content as any[])
+ .filter((item: any) => item.type === "text")
+ .map((item: any) => item.text)
+ .join("\n");
+ }
+ return String(content || "");
+}
+/**
+ * Build the single prompt string the DeepSeek web API accepts.
+ *
+ * The web endpoint (`/api/v0/chat/completion`) takes only a `prompt` string, not a
+ * `messages` array. With `historyWindow <= 0` (default) we keep the legacy behavior —
+ * system prompt(s) + the last user message only — which is fine for plain chat.
+ *
+ * With `historyWindow > 0` we stitch the last N non-system messages into a role-tagged
+ * transcript so agentic multi-turn clients keep context across turns (rolling-window
+ * memory, #2942). The system prompt(s) still lead the prompt and the newest user turn
+ * is the last line of the transcript.
+ */
+export function messagesToPrompt(
+ messages: Array<{ role: string; content: string }>,
+ historyWindow = 0
+): string {
if (messages.length === 0) return "";
- // Collect system prompt(s) and find the last user message
const systemParts: string[] = [];
+ const conversation: Array<{ role: string; text: string }> = [];
let lastUserContent = "";
for (const m of messages) {
+ const text = extractMessageText(m.content).trim();
if (m.role === "system") {
- const text = extractText(m.content).trim();
if (text) systemParts.push(text);
- } else if (m.role === "user") {
- lastUserContent = extractText(m.content).trim();
+ } else if (m.role === "user" || m.role === "assistant") {
+ if (text) conversation.push({ role: m.role, text });
+ if (m.role === "user") lastUserContent = text;
}
}
@@ -510,7 +531,15 @@ function messagesToPrompt(messages: Array<{ role: string; content: string }>): s
if (systemParts.length > 0) {
parts.push(systemParts.join("\n\n"));
}
- if (lastUserContent) {
+
+ if (historyWindow > 0 && conversation.length > 1) {
+ // Rolling-window transcript of the most recent turns (#2942).
+ const recent = conversation.slice(-historyWindow);
+ const transcript = recent
+ .map((turn) => `${turn.role === "assistant" ? "Assistant" : "User"}: ${turn.text}`)
+ .join("\n\n");
+ parts.push(transcript);
+ } else if (lastUserContent) {
parts.push(lastUserContent);
}
@@ -663,6 +692,108 @@ async function getPowChallenge(
return bizData.challenge as PowChallenge;
}
+// ── Tool-call response builder (#2820) ──────────────────────────────────
+
+/**
+ * Build the executor result for a tool-translated reply. Emits OpenAI `tool_calls`
+ * with `finish_reason: "tool_calls"` when tool calls were parsed, otherwise plain
+ * content. Supports both streaming (synthetic SSE) and non-streaming clients.
+ */
+function buildToolAwareResult(opts: {
+ stream: boolean;
+ clientModel: string;
+ content: string;
+ reasoningContent?: string;
+ toolCalls: OpenAIToolCall[] | null;
+ reqHeaders: Record;
+ requestPayload: unknown;
+}) {
+ const { stream, clientModel, content, reasoningContent, toolCalls, reqHeaders, requestPayload } =
+ opts;
+ const hasCalls = !!toolCalls && toolCalls.length > 0;
+ const finishReason = hasCalls ? "tool_calls" : "stop";
+ const id = `chatcmpl-${Date.now()}`;
+ const created = Math.floor(Date.now() / 1000);
+
+ if (stream) {
+ const encoder = new TextEncoder();
+ const emit = (
+ controller: ReadableStreamDefaultController,
+ delta: object,
+ finish: string | null
+ ) => {
+ controller.enqueue(
+ encoder.encode(
+ `data: ${JSON.stringify({
+ id,
+ object: "chat.completion.chunk",
+ created,
+ model: clientModel,
+ choices: [{ index: 0, delta, finish_reason: finish }],
+ })}\n\n`
+ )
+ );
+ };
+ const sse = new ReadableStream({
+ start(controller) {
+ emit(controller, { role: "assistant", content: "" }, null);
+ if (hasCalls) {
+ emit(
+ controller,
+ {
+ tool_calls: toolCalls!.map((tc, i) => ({
+ index: i,
+ id: tc.id,
+ type: "function",
+ function: { name: tc.function.name, arguments: tc.function.arguments },
+ })),
+ },
+ null
+ );
+ } else if (content) {
+ emit(controller, { content }, null);
+ }
+ emit(controller, {}, finishReason);
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
+ controller.close();
+ },
+ });
+ return {
+ response: new Response(sse, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" },
+ }),
+ url: COMPLETION_URL,
+ headers: reqHeaders,
+ transformedBody: requestPayload,
+ };
+ }
+
+ const message: Record = { role: "assistant", content: content || "" };
+ if (reasoningContent) message.reasoning_content = reasoningContent;
+ if (hasCalls) {
+ message.tool_calls = toolCalls;
+ if (!content) message.content = null;
+ }
+ const openaiResponse = {
+ id,
+ object: "chat.completion",
+ created,
+ model: clientModel,
+ choices: [{ index: 0, message, finish_reason: finishReason }],
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
+ };
+ return {
+ response: new Response(JSON.stringify(openaiResponse), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ url: COMPLETION_URL,
+ headers: reqHeaders,
+ transformedBody: requestPayload,
+ };
+}
+
// ── Executor ─────────────────────────────────────────────────────────────
export class DeepSeekWebExecutor extends BaseExecutor {
@@ -688,32 +819,21 @@ export class DeepSeekWebExecutor extends BaseExecutor {
const bodyObj = (body || {}) as Record;
// chat.deepseek.com's web API only accepts {prompt, ref_file_ids,
- // thinking_enabled, search_enabled} - no tools field. Silently dropping
- // tools[] is misleading: models reply as if no tools were ever offered,
- // causing agentic clients (OpenAI-compatible) to hallucinate "I don't
- // have that tool". Fail fast with a clear error so callers route to the
- // official DeepSeek API (provider: 'deepseek') or a different provider
- // for tool-using requests. See #2848.
+ // thinking_enabled, search_enabled} - no native tools field. Instead of failing
+ // tool-using requests, translate them (#2820): serialize the OpenAI tools[] into a
+ // ... prompt contract on the way in, and parse the model's text reply
+ // back into OpenAI tool_calls on the way out.
const requestedTools = bodyObj.tools;
- if (Array.isArray(requestedTools) && requestedTools.length > 0) {
- return {
- response: errorResponse(
- 400,
- "deepseek-web upstream (chat.deepseek.com) does not support function calling. " +
- "The web interface only accepts text + thinking_enabled + search_enabled flags. " +
- "Use provider 'deepseek' (official api.deepseek.com) for tool-using requests, " +
- "or route through a different provider."
- ),
- url: COMPLETION_URL,
- headers: {},
- transformedBody: body,
- };
- }
+ const hasTools = Array.isArray(requestedTools) && requestedTools.length > 0;
+ const toolSystemPrompt = hasTools ? serializeToolsToPrompt(requestedTools) : "";
const messages = (Array.isArray(bodyObj.messages) ? bodyObj.messages : []) as Array<{
role: string;
content: string;
}>;
+ const promptMessages = toolSystemPrompt
+ ? [{ role: "system", content: toolSystemPrompt }, ...messages]
+ : messages;
const rawCreds = credentials as unknown as Record;
const userToken = extractUserToken(rawCreds);
@@ -735,67 +855,99 @@ export class DeepSeekWebExecutor extends BaseExecutor {
bodyObj
);
+ // Per-connection memory config (#2942). Defaults preserve the legacy
+ // fresh-session-per-request, last-user-message-only behavior.
+ const psd = (rawCreds.providerSpecificData ?? {}) as Record;
+ const persistSession = psd.persistSession === true;
+ const historyWindow =
+ typeof psd.historyWindow === "number" && psd.historyWindow > 0 ? psd.historyWindow : 0;
+
try {
let t0 = Date.now();
const accessToken = await acquireAccessToken(userToken, signal, log);
log?.info?.("DEEPSEEK-WEB", `Token acquired in ${Date.now() - t0}ms`);
- // Always create a fresh session per request (matches Chat2API behavior).
- // Avoids all stale-session issues when user deletes chats from DeepSeek UI.
- t0 = Date.now();
- const sessionId = await createSession(accessToken, signal);
- log?.info?.("DEEPSEEK-WEB", `Session created in ${Date.now() - t0}ms`);
-
- t0 = Date.now();
- const powChallenge = await getPowChallenge(accessToken, signal);
- log?.info?.(
- "DEEPSEEK-WEB",
- `PoW challenge fetched in ${Date.now() - t0}ms (difficulty=${powChallenge.difficulty})`
- );
- t0 = Date.now();
- const powAnswer = await solvePow(powChallenge);
- log?.info?.("DEEPSEEK-WEB", `PoW solved in ${Date.now() - t0}ms`);
-
- const prompt = messagesToPrompt(messages);
+ const prompt = messagesToPrompt(promptMessages, historyWindow);
const refFileIds = Array.isArray(bodyObj.ref_file_ids) ? bodyObj.ref_file_ids : [];
log?.info?.(
"DEEPSEEK-WEB",
- `model_type=${modelType}, thinking=${thinkingEnabled}, search=${searchEnabled}, files=${refFileIds.length}, stream=${stream !== false}`
+ `model_type=${modelType}, thinking=${thinkingEnabled}, search=${searchEnabled}, files=${refFileIds.length}, stream=${stream !== false}, persist=${persistSession}, window=${historyWindow}`
);
- const reqHeaders: Record = {
- ...FAKE_HEADERS,
- "Content-Type": "application/json",
- Authorization: `Bearer ${accessToken}`,
- "X-Ds-Pow-Response": powAnswer,
- "X-Client-Timezone-Offset": String(new Date().getTimezoneOffset() * -60),
- Cookie: generateFakeCookie(),
+ // One completion attempt against a given session id (fresh PoW per attempt).
+ const performCompletion = async (sid: string) => {
+ const powChallenge = await getPowChallenge(accessToken, signal);
+ const powAnswer = await solvePow(powChallenge);
+ const reqHeaders: Record = {
+ ...FAKE_HEADERS,
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${accessToken}`,
+ "X-Ds-Pow-Response": powAnswer,
+ "X-Client-Timezone-Offset": String(new Date().getTimezoneOffset() * -60),
+ Cookie: generateFakeCookie(),
+ };
+ const requestPayload = {
+ chat_session_id: sid,
+ parent_message_id: null,
+ model_type: modelType,
+ prompt,
+ ref_file_ids: refFileIds,
+ thinking_enabled: thinkingEnabled,
+ search_enabled: searchEnabled,
+ preempt: false,
+ };
+ const resp = await fetch(COMPLETION_URL, {
+ method: "POST",
+ headers: reqHeaders,
+ body: JSON.stringify(requestPayload),
+ signal: signal ?? undefined,
+ });
+ return { resp, reqHeaders, requestPayload };
};
- const requestPayload = {
- chat_session_id: sessionId,
- parent_message_id: null,
- model_type: modelType,
- prompt,
- ref_file_ids: refFileIds,
- thinking_enabled: thinkingEnabled,
- search_enabled: searchEnabled,
- preempt: false,
+ // Acquire a session. With persistSession we reuse one upstream session per
+ // userToken (rolling-window memory); otherwise we create a fresh one per
+ // request (legacy behavior — dodges stale sessions when the user deletes
+ // chats in the DeepSeek UI). (#2942)
+ const acquireSession = async (): Promise<{ sessionId: string; reused: boolean }> => {
+ if (persistSession) {
+ const cached = sessionCache.get(userToken);
+ if (cached) return { sessionId: cached.sessionId, reused: true };
+ const created = await createSession(accessToken, signal);
+ evictOldest(sessionCache);
+ sessionCache.set(userToken, { sessionId: created, createdAt: Date.now() });
+ return { sessionId: created, reused: false };
+ }
+ return { sessionId: await createSession(accessToken, signal), reused: false };
};
+ t0 = Date.now();
+ let { sessionId, reused: reusedSession } = await acquireSession();
+ log?.info?.(
+ "DEEPSEEK-WEB",
+ `Session ${reusedSession ? "reused" : "created"} in ${Date.now() - t0}ms`
+ );
+
t0 = Date.now();
log?.info?.("DEEPSEEK-WEB", `POST ${COMPLETION_URL}`);
- const resp = await fetch(COMPLETION_URL, {
- method: "POST",
- headers: reqHeaders,
- body: JSON.stringify(requestPayload),
- signal: signal ?? undefined,
- });
+ let { resp, reqHeaders, requestPayload } = await performCompletion(sessionId);
log?.info?.(
"DEEPSEEK-WEB",
`Completion response in ${Date.now() - t0}ms, status=${resp.status}`
);
+ // A reused session that fails is likely stale (user deleted the chat in the
+ // DeepSeek UI). Drop it, create a fresh session, and retry once. (#2942)
+ if (!resp.ok && persistSession && reusedSession) {
+ log?.warn?.("DEEPSEEK-WEB", "Reused session failed — retrying with a fresh session");
+ sessionCache.delete(userToken);
+ sessionId = await createSession(accessToken, signal);
+ evictOldest(sessionCache);
+ sessionCache.set(userToken, { sessionId, createdAt: Date.now() });
+ reusedSession = false;
+ ({ resp, reqHeaders, requestPayload } = await performCompletion(sessionId));
+ }
+
if (!resp.ok) {
const status = resp.status;
let errMsg = `DeepSeek API error (${status})`;
@@ -816,6 +968,7 @@ export class DeepSeekWebExecutor extends BaseExecutor {
/* ignore */
}
+ if (persistSession) sessionCache.delete(userToken);
deleteSessionOnDeepSeek(accessToken, sessionId).catch(() => {});
return {
response: errorResponse(status, errMsg),
@@ -838,6 +991,7 @@ export class DeepSeekWebExecutor extends BaseExecutor {
if (parsed.code === 40003) {
tokenCache.delete(userToken);
}
+ if (persistSession) sessionCache.delete(userToken);
deleteSessionOnDeepSeek(accessToken, sessionId).catch(() => {});
return {
response: errorResponse(status, errMsg, parsed.code),
@@ -846,7 +1000,7 @@ export class DeepSeekWebExecutor extends BaseExecutor {
transformedBody: requestPayload,
};
}
- deleteSessionOnDeepSeek(accessToken, sessionId).catch(() => {});
+ if (!persistSession) deleteSessionOnDeepSeek(accessToken, sessionId).catch(() => {});
return {
response: new Response(JSON.stringify(json), {
status: 200,
@@ -861,10 +1015,35 @@ export class DeepSeekWebExecutor extends BaseExecutor {
}
}
- const cleanupFn = () => deleteSessionOnDeepSeek(accessToken, sessionId);
+ // Persistent sessions are kept across requests for reuse; only delete the
+ // upstream chat session when persistence is off (legacy behavior). (#2942)
+ const cleanupFn = persistSession
+ ? async () => {}
+ : () => deleteSessionOnDeepSeek(accessToken, sessionId);
const clientModel = typeof model === "string" && model.trim() ? model.trim() : "deepseek-web";
+ // Tool-call translation: buffer the full reply and parse blocks into
+ // OpenAI tool_calls. Buffering (even for stream clients) is acceptable because
+ // tool invocations are short and need the complete block to parse. (#2820)
+ if (hasTools) {
+ const { content, reasoningContent } = await collectSSEContent(resp.body!, clientModel);
+ await cleanupFn();
+ const { content: cleanedContent, toolCalls } = parseToolCallsFromText(
+ content,
+ `call-${Date.now()}`
+ );
+ return buildToolAwareResult({
+ stream: stream !== false,
+ clientModel,
+ content: cleanedContent,
+ reasoningContent,
+ toolCalls,
+ reqHeaders,
+ requestPayload,
+ });
+ }
+
if (stream !== false) {
const openaiStream = transformSSE(resp.body!, clientModel);
const wrappedStream = wrapStreamWithCleanup(openaiStream, cleanupFn);
diff --git a/open-sse/translator/webTools.ts b/open-sse/translator/webTools.ts
new file mode 100644
index 0000000000..78caf326c7
--- /dev/null
+++ b/open-sse/translator/webTools.ts
@@ -0,0 +1,109 @@
+// Tool-call translation for web-cookie providers (deepseek-web, chatgpt-web, ...).
+//
+// The web UIs accept only a single plain prompt string and have no native function
+// calling — they reply with tool invocations as raw text. To let agentic clients use
+// these providers we (a) serialize the OpenAI `tools` array into a system-prompt
+// contract on the request side, and (b) parse the upstream `{...}` text
+// back into OpenAI `tool_calls` on the response side. (#2820)
+
+export interface OpenAIToolCall {
+ id: string;
+ type: "function";
+ function: { name: string; arguments: string };
+}
+
+interface OpenAIToolDef {
+ type?: string;
+ function?: {
+ name?: string;
+ description?: string;
+ parameters?: unknown;
+ };
+}
+
+const TOOL_BLOCK_RE = /\s*([\s\S]*?)\s*<\/tool>/g;
+
+/**
+ * Serialize an OpenAI `tools` array into a system-prompt block that instructs the
+ * web UI model how to invoke a tool (emit a `{...}` block). Returns an
+ * empty string when there are no usable tools.
+ */
+export function serializeToolsToPrompt(tools: unknown): string {
+ if (!Array.isArray(tools) || tools.length === 0) return "";
+
+ const lines: string[] = [];
+ for (const t of tools as OpenAIToolDef[]) {
+ const fn = t?.function;
+ if (!fn?.name) continue;
+ const desc = typeof fn.description === "string" && fn.description ? fn.description : "";
+ let params = "";
+ try {
+ params = fn.parameters ? JSON.stringify(fn.parameters) : "";
+ } catch {
+ params = "";
+ }
+ lines.push(`- ${fn.name}${desc ? `: ${desc}` : ""}${params ? `\n parameters: ${params}` : ""}`);
+ }
+
+ if (lines.length === 0) return "";
+
+ return [
+ "You can call tools. To call a tool, reply with a single line containing a block",
+ 'with JSON: {"name": "", "arguments": { ... }}',
+ "Only emit the block when you actually want to call a tool; otherwise answer normally.",
+ "",
+ "Available tools:",
+ ...lines,
+ ].join("\n");
+}
+
+/**
+ * Parse `{...}` blocks out of upstream text into OpenAI `tool_calls`.
+ * Returns the content with the blocks stripped, plus the tool calls (or null when
+ * there are none). `arguments` is always a JSON *string*, matching the OpenAI API.
+ *
+ * `idSeed` makes generated ids deterministic for callers that need stability; when
+ * omitted, ids are still unique within a single call (index-based).
+ */
+export function parseToolCallsFromText(
+ text: string,
+ idSeed = "call"
+): { content: string; toolCalls: OpenAIToolCall[] | null } {
+ if (typeof text !== "string" || !text.includes("")) {
+ return { content: text ?? "", toolCalls: null };
+ }
+
+ const toolCalls: OpenAIToolCall[] = [];
+ let match: RegExpExecArray | null;
+ TOOL_BLOCK_RE.lastIndex = 0;
+ while ((match = TOOL_BLOCK_RE.exec(text)) !== null) {
+ const raw = match[1].trim();
+ let parsed: { name?: unknown; arguments?: unknown } | null = null;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ parsed = null;
+ }
+ const name = parsed && typeof parsed.name === "string" ? parsed.name : null;
+ if (!name) continue;
+ let args = "{}";
+ if (parsed && parsed.arguments !== undefined) {
+ args =
+ typeof parsed.arguments === "string"
+ ? parsed.arguments
+ : JSON.stringify(parsed.arguments);
+ }
+ toolCalls.push({
+ id: `${idSeed}_${toolCalls.length}`,
+ type: "function",
+ function: { name, arguments: args },
+ });
+ }
+
+ if (toolCalls.length === 0) {
+ return { content: text, toolCalls: null };
+ }
+
+ const content = text.replace(TOOL_BLOCK_RE, "").replace(/\n{3,}/g, "\n\n").trim();
+ return { content, toolCalls };
+}
diff --git a/tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts b/tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts
new file mode 100644
index 0000000000..177989419d
--- /dev/null
+++ b/tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts
@@ -0,0 +1,137 @@
+// #3027 — A per-model subscription 403 on a passthrough / per-model-quota provider
+// (e.g. ollama-cloud "this model requires a subscription") must lock out ONLY the paid
+// model, not cool down the whole connection (which would knock out the free models on
+// the same key). Terminal whole-key 403s (account deactivated / credits exhausted) must
+// still take the connection-level terminal path and NOT be downgraded to a model lockout.
+import test from "node:test";
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-ollama-403-"));
+process.env.DATA_DIR = TEST_DATA_DIR;
+
+const core = await import("../../src/lib/db/core.ts");
+const providersDb = await import("../../src/lib/db/providers.ts");
+const auth = await import("../../src/sse/services/auth.ts");
+const accountFallback = await import("../../open-sse/services/accountFallback.ts");
+
+const SUBSCRIPTION_403 =
+ "this model requires a subscription, upgrade for access: https://ollama.com/upgrade";
+
+async function resetStorage() {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+ fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
+}
+
+async function seedOllamaCloud() {
+ return providersDb.createProviderConnection({
+ provider: "ollama-cloud",
+ authType: "apikey",
+ apiKey: "ollama-key",
+ isActive: true,
+ testStatus: "active",
+ });
+}
+
+test.after(() => {
+ core.resetDbInstance();
+ fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
+});
+
+test("per-model subscription 403 locks only the paid model, connection stays active", async () => {
+ await resetStorage();
+ const conn = await seedOllamaCloud();
+
+ const result = await auth.markAccountUnavailable(
+ (conn as any).id,
+ 403,
+ SUBSCRIPTION_403,
+ "ollama-cloud",
+ "deepseek-v4-pro"
+ );
+
+ assert.equal(result.shouldFallback, true);
+
+ // Connection must NOT be cooled down — free models on the same key keep serving.
+ const after = await providersDb.getProviderConnectionById((conn as any).id);
+ assert.equal(after.testStatus, "active");
+ assert.ok(!after.rateLimitedUntil, "connection must not be rate-limited");
+
+ // The paid model is locked out for this connection...
+ const paidLockout = accountFallback.getModelLockoutInfo(
+ "ollama-cloud",
+ (conn as any).id,
+ "deepseek-v4-pro"
+ );
+ assert.equal(paidLockout?.reason, "forbidden");
+
+ // ...but a free model on the same connection is still eligible.
+ const freeLockout = accountFallback.getModelLockoutInfo(
+ "ollama-cloud",
+ (conn as any).id,
+ "gemma4:31b"
+ );
+ assert.equal(freeLockout, null);
+});
+
+test("genuine whole-key 403 (account deactivated) still terminates the connection", async () => {
+ await resetStorage();
+ const conn = await seedOllamaCloud();
+
+ const result = await auth.markAccountUnavailable(
+ (conn as any).id,
+ 403,
+ "this account is deactivated",
+ "ollama-cloud",
+ "deepseek-v4-pro"
+ );
+ assert.equal(result.shouldFallback, true);
+
+ const after = await providersDb.getProviderConnectionById((conn as any).id);
+ // Terminal status (not a model-scoped lockout downgrade). A deactivated key is a
+ // permanent failure -> "banned" via resolveTerminalConnectionStatus.
+ assert.equal(after.testStatus, "banned");
+
+ const lockout = accountFallback.getModelLockoutInfo(
+ "ollama-cloud",
+ (conn as any).id,
+ "deepseek-v4-pro"
+ );
+ assert.equal(lockout, null);
+});
+
+test("repeated subscription 403s do not escalate a connection-wide backoff", async () => {
+ await resetStorage();
+ const conn = await seedOllamaCloud();
+
+ await auth.markAccountUnavailable(
+ (conn as any).id,
+ 403,
+ SUBSCRIPTION_403,
+ "ollama-cloud",
+ "deepseek-v4-pro"
+ );
+ await auth.markAccountUnavailable(
+ (conn as any).id,
+ 403,
+ SUBSCRIPTION_403,
+ "ollama-cloud",
+ "deepseek-v4-pro"
+ );
+
+ const after = await providersDb.getProviderConnectionById((conn as any).id);
+ assert.equal(after.testStatus, "active");
+ assert.ok(!after.rateLimitedUntil, "connection must not be rate-limited");
+ assert.equal(after.backoffLevel ?? 0, 0, "connection backoff must not escalate");
+
+ // The model lockout itself still exists.
+ const paidLockout = accountFallback.getModelLockoutInfo(
+ "ollama-cloud",
+ (conn as any).id,
+ "deepseek-v4-pro"
+ );
+ assert.equal(paidLockout?.reason, "forbidden");
+});
diff --git a/tests/unit/deepseek-web-rolling-window-2942.test.ts b/tests/unit/deepseek-web-rolling-window-2942.test.ts
new file mode 100644
index 0000000000..00320158d3
--- /dev/null
+++ b/tests/unit/deepseek-web-rolling-window-2942.test.ts
@@ -0,0 +1,61 @@
+// #2942 — rolling-window prompt memory for deepseek-web. The web API takes only a single
+// `prompt` string, so multi-turn context must be stitched into that prompt. With the
+// window disabled (default) the legacy behavior (system + last user only) is preserved;
+// with a window > 0, the last N turns are stitched into a role-tagged transcript.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { messagesToPrompt } = await import("../../open-sse/executors/deepseek-web.ts");
+
+const CONVO = [
+ { role: "system", content: "You are helpful." },
+ { role: "user", content: "first question" },
+ { role: "assistant", content: "first answer" },
+ { role: "user", content: "second question" },
+];
+
+test("window 0 (default) keeps legacy behavior: system + last user only", () => {
+ const prompt = messagesToPrompt(CONVO, 0);
+ assert.ok(prompt.includes("You are helpful."), "system prompt present");
+ assert.ok(prompt.includes("second question"), "last user message present");
+ assert.ok(!prompt.includes("first question"), "earlier user turn must be dropped");
+ assert.ok(!prompt.includes("first answer"), "assistant turn must be dropped");
+});
+
+test("default call (no window arg) behaves like window 0", () => {
+ const prompt = messagesToPrompt(CONVO);
+ assert.ok(prompt.includes("second question"));
+ assert.ok(!prompt.includes("first answer"));
+});
+
+test("window > 0 stitches recent turns into a role-tagged transcript", () => {
+ const prompt = messagesToPrompt(CONVO, 10);
+ assert.ok(prompt.includes("You are helpful."), "system prompt still leads");
+ assert.ok(prompt.includes("first question"), "earlier user turn carried");
+ assert.ok(prompt.includes("first answer"), "assistant turn carried");
+ assert.ok(prompt.includes("second question"), "latest user turn carried");
+ assert.ok(/User:\s*first question/.test(prompt), "user turns role-tagged");
+ assert.ok(/Assistant:\s*first answer/.test(prompt), "assistant turns role-tagged");
+});
+
+test("window caps to the last N non-system turns", () => {
+ const long = [
+ { role: "system", content: "sys" },
+ { role: "user", content: "u1" },
+ { role: "assistant", content: "a1" },
+ { role: "user", content: "u2" },
+ { role: "assistant", content: "a2" },
+ { role: "user", content: "u3" },
+ ];
+ const prompt = messagesToPrompt(long, 2);
+ // last 2 non-system turns are a2 + u3
+ assert.ok(prompt.includes("a2"), "second-to-last turn present");
+ assert.ok(prompt.includes("u3"), "last turn present");
+ assert.ok(!prompt.includes("u1"), "older turn dropped");
+ assert.ok(!prompt.includes("a1"), "older turn dropped");
+ assert.ok(prompt.includes("sys"), "system prompt always present");
+});
+
+test("empty messages -> empty prompt", () => {
+ assert.equal(messagesToPrompt([], 10), "");
+});
diff --git a/tests/unit/deepseek-web-session-persistence-2942.test.ts b/tests/unit/deepseek-web-session-persistence-2942.test.ts
new file mode 100644
index 0000000000..9a15d44685
--- /dev/null
+++ b/tests/unit/deepseek-web-session-persistence-2942.test.ts
@@ -0,0 +1,189 @@
+// @ts-nocheck
+// #2942 — persistent session for deepseek-web. Default (no config) keeps the legacy
+// fresh-session-per-request behavior. With providerSpecificData.persistSession=true the
+// executor reuses one upstream chat session across requests (keyed by userToken), does
+// not delete it, and on a reused-session failure falls back to a fresh session + retries
+// once. historyWindow is threaded into the prompt builder.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
+const { DeepSeekWebExecutor } = dsMod;
+
+const POW_CHALLENGE = {
+ algorithm: "DeepSeekHashV1",
+ challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
+ salt: "1122334455667788",
+ signature: "sig123",
+ difficulty: 1,
+ expire_at: 1778891543095,
+ expire_after: 300000,
+ target_path: "/api/v0/chat/completion",
+};
+
+const OK_SSE = [
+ "event: ready\n",
+ 'data: {"request_message_id":1,"response_message_id":2}\n',
+ "\n",
+ 'data: {"v":{"response":{"message_id":2,"fragments":[{"id":1,"type":"RESPONSE","content":"Hi"}]}}}\n',
+ "\n",
+ 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
+ "\n",
+ "event: close\n",
+ 'data: {"click_behavior":"none"}\n',
+].join("");
+
+/**
+ * Mock the DeepSeek web API. `completionOutcomes` is consumed one per completion call:
+ * "ok" -> 200 SSE stream
+ * "fail" -> 500 error
+ * When exhausted, defaults to "ok".
+ */
+function installMock(completionOutcomes = []) {
+ const original = globalThis.fetch;
+ const calls = { create: 0, delete: 0, completion: 0, completionBodies: [] };
+ let outcomeIdx = 0;
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+
+ globalThis.fetch = async (url, opts = {}) => {
+ const u = String(url);
+ if (u.includes("/users/current")) {
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ }
+ if (u.includes("/chat_session/create")) {
+ calls.create += 1;
+ return new Response(
+ JSON.stringify({
+ code: 0,
+ data: { biz_data: { chat_session: { id: `session-${calls.create}` } } },
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ }
+ if (u.includes("/chat_session/delete")) {
+ calls.delete += 1;
+ return new Response(JSON.stringify({ code: 0 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (u.includes("/create_pow_challenge")) {
+ return new Response(JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ if (u.includes("/chat/completion")) {
+ calls.completion += 1;
+ try {
+ calls.completionBodies.push(JSON.parse(opts.body));
+ } catch {
+ calls.completionBodies.push(null);
+ }
+ const outcome = completionOutcomes[outcomeIdx++] ?? "ok";
+ if (outcome === "fail") {
+ return new Response(JSON.stringify({ error: "server" }), {
+ status: 500,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+ return new Response(new TextEncoder().encode(OK_SSE), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+ }
+ return new Response("not found", { status: 404 });
+ };
+
+ return {
+ calls,
+ restore: () => {
+ globalThis.fetch = original;
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ },
+ };
+}
+
+async function run(executor, { persistSession, historyWindow, messages, token } = {}) {
+ const providerSpecificData = {};
+ if (persistSession !== undefined) providerSpecificData.persistSession = persistSession;
+ if (historyWindow !== undefined) providerSpecificData.historyWindow = historyWindow;
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: messages ?? [{ role: "user", content: "hi" }] },
+ stream: false,
+ credentials: { apiKey: token ?? "user-token-1", providerSpecificData },
+ signal: AbortSignal.timeout(10000),
+ });
+ // drain so the stream-cleanup (delete) fires deterministically
+ await result.response.text();
+ return result;
+}
+
+test("default (no config): fresh session per request + deletes it", async () => {
+ const mock = installMock();
+ try {
+ const executor = new DeepSeekWebExecutor();
+ await run(executor, { token: "tkn-default" });
+ await run(executor, { token: "tkn-default" });
+ assert.equal(mock.calls.create, 2, "creates a fresh session each request");
+ assert.ok(mock.calls.delete >= 1, "deletes the session (no persistence)");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("persistSession=true: reuses cached session across requests, does not delete", async () => {
+ const mock = installMock();
+ try {
+ const executor = new DeepSeekWebExecutor();
+ await run(executor, { persistSession: true, token: "tkn-persist" });
+ await run(executor, { persistSession: true, token: "tkn-persist" });
+ assert.equal(mock.calls.create, 1, "session created once and reused");
+ assert.equal(mock.calls.delete, 0, "persistent session is not deleted");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("persistSession=true: reused-session failure falls back to a fresh session and retries once", async () => {
+ // call 1 -> create+cache (ok). call 2 -> reuse cached -> completion 500 -> create fresh -> retry ok.
+ const mock = installMock(["ok", "fail", "ok"]);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const r1 = await run(executor, { persistSession: true, token: "tkn-heal" });
+ assert.ok(r1.response.ok, "first call ok");
+ const r2 = await run(executor, { persistSession: true, token: "tkn-heal" });
+ assert.ok(r2.response.ok, "second call recovers via fresh-session retry");
+ assert.equal(mock.calls.create, 2, "one initial + one fresh-session retry");
+ assert.equal(mock.calls.completion, 3, "1 (call1) + 1 failed reuse + 1 retry");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("historyWindow is threaded into the completion prompt", async () => {
+ const mock = installMock();
+ try {
+ const executor = new DeepSeekWebExecutor();
+ await run(executor, {
+ historyWindow: 10,
+ token: "tkn-hist",
+ messages: [
+ { role: "user", content: "earlier-turn-marker" },
+ { role: "assistant", content: "ack" },
+ { role: "user", content: "latest-turn" },
+ ],
+ });
+ const body = mock.calls.completionBodies[0];
+ assert.ok(body.prompt.includes("earlier-turn-marker"), "earlier turn carried into prompt");
+ assert.ok(body.prompt.includes("latest-turn"), "latest turn present");
+ } finally {
+ mock.restore();
+ }
+});
diff --git a/tests/unit/deepseek-web-tools-execute-2820.test.ts b/tests/unit/deepseek-web-tools-execute-2820.test.ts
new file mode 100644
index 0000000000..079fb1777a
--- /dev/null
+++ b/tests/unit/deepseek-web-tools-execute-2820.test.ts
@@ -0,0 +1,186 @@
+// @ts-nocheck
+// #2820 — deepseek-web execute() now translates tool calls instead of 400-ing on tools[].
+// Request: the OpenAI tools[] is serialized into the prompt. Response: the upstream
+// {...} text is parsed into OpenAI tool_calls with finish_reason "tool_calls".
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const dsMod = await import("../../open-sse/executors/deepseek-web.ts");
+const { DeepSeekWebExecutor } = dsMod;
+
+const POW_CHALLENGE = {
+ algorithm: "DeepSeekHashV1",
+ challenge: "311b26ae1e0fe7375e242958ce46db5552a6c67fea3f96880dcd846c63a74286",
+ salt: "1122334455667788",
+ signature: "sig123",
+ difficulty: 1,
+ expire_at: 1778891543095,
+ expire_after: 300000,
+ target_path: "/api/v0/chat/completion",
+};
+
+function sseWithContent(text) {
+ return [
+ "event: ready\n",
+ 'data: {"request_message_id":1,"response_message_id":2}\n',
+ "\n",
+ `data: ${JSON.stringify({ v: { response: { message_id: 2, fragments: [{ id: 1, type: "RESPONSE", content: text }] } } })}\n`,
+ "\n",
+ 'data: {"p":"response/status","o":"SET","v":"FINISHED"}\n',
+ "\n",
+ "event: close\n",
+ 'data: {"click_behavior":"none"}\n',
+ ].join("");
+}
+
+function installMock(completionText) {
+ const original = globalThis.fetch;
+ const calls = { completionBodies: [] };
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ globalThis.fetch = async (url, opts = {}) => {
+ const u = String(url);
+ if (u.includes("/users/current"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { token: "access-token-xyz" } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat_session/create"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { chat_session: { id: "s-1" } } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat_session/delete"))
+ return new Response(JSON.stringify({ code: 0 }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+ if (u.includes("/create_pow_challenge"))
+ return new Response(
+ JSON.stringify({ code: 0, data: { biz_data: { challenge: POW_CHALLENGE } } }),
+ { status: 200, headers: { "Content-Type": "application/json" } }
+ );
+ if (u.includes("/chat/completion")) {
+ try {
+ calls.completionBodies.push(JSON.parse(opts.body));
+ } catch {
+ calls.completionBodies.push(null);
+ }
+ return new Response(new TextEncoder().encode(sseWithContent(completionText)), {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream" },
+ });
+ }
+ return new Response("not found", { status: 404 });
+ };
+ return {
+ calls,
+ restore: () => {
+ globalThis.fetch = original;
+ dsMod.tokenCache?.clear();
+ dsMod.sessionCache?.clear();
+ },
+ };
+}
+
+const TOOLS = [
+ {
+ type: "function",
+ function: {
+ name: "get_weather",
+ description: "Get weather",
+ parameters: { type: "object", properties: { city: { type: "string" } } },
+ },
+ },
+];
+
+const TOOL_REPLY = '{"name": "get_weather", "arguments": {"city": "Paris"}}';
+
+test("execute with tools[] no longer returns 400 and serializes tools into the prompt", async () => {
+ const mock = installMock(TOOL_REPLY);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather in Paris?" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-tools" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.notEqual(result.response.status, 400, "tools[] must not hard-fail anymore");
+ const body = mock.calls.completionBodies[0];
+ assert.ok(body.prompt.includes("get_weather"), "tool schema serialized into prompt");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("execute (non-stream) parses reply into OpenAI tool_calls", async () => {
+ const mock = installMock(TOOL_REPLY);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-tools-ns" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok);
+ const json = JSON.parse(await result.response.text());
+ const choice = json.choices[0];
+ assert.equal(choice.finish_reason, "tool_calls");
+ assert.equal(choice.message.tool_calls.length, 1);
+ assert.equal(choice.message.tool_calls[0].function.name, "get_weather");
+ assert.deepEqual(JSON.parse(choice.message.tool_calls[0].function.arguments), { city: "Paris" });
+ assert.ok(
+ !String(choice.message.content || "").includes(""),
+ "raw tool block stripped from content"
+ );
+ } finally {
+ mock.restore();
+ }
+});
+
+test("execute (stream) emits tool_calls + finish_reason tool_calls in the SSE", async () => {
+ const mock = installMock(TOOL_REPLY);
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "weather?" }], tools: TOOLS },
+ stream: true,
+ credentials: { apiKey: "tkn-tools-stream" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok);
+ const text = await result.response.text();
+ assert.ok(text.includes("tool_calls"), "stream carries tool_calls");
+ assert.ok(text.includes("get_weather"), "stream carries the tool name");
+ assert.ok(text.includes('"finish_reason":"tool_calls"'), "finish_reason tool_calls");
+ assert.ok(text.includes("[DONE]"), "stream terminates");
+ } finally {
+ mock.restore();
+ }
+});
+
+test("execute with tools[] but a plain reply still returns normal content", async () => {
+ const mock = installMock("Just a normal answer, no tool needed.");
+ try {
+ const executor = new DeepSeekWebExecutor();
+ const result = await executor.execute({
+ model: "default",
+ body: { messages: [{ role: "user", content: "hi" }], tools: TOOLS },
+ stream: false,
+ credentials: { apiKey: "tkn-tools-plain" },
+ signal: AbortSignal.timeout(10000),
+ });
+ assert.ok(result.response.ok);
+ const json = JSON.parse(await result.response.text());
+ assert.equal(json.choices[0].finish_reason, "stop");
+ assert.ok(json.choices[0].message.content.includes("normal answer"));
+ assert.ok(!json.choices[0].message.tool_calls, "no tool_calls on a plain reply");
+ } finally {
+ mock.restore();
+ }
+});
diff --git a/tests/unit/deepseek-web.test.ts b/tests/unit/deepseek-web.test.ts
index 3271b65c96..eab5be7703 100644
--- a/tests/unit/deepseek-web.test.ts
+++ b/tests/unit/deepseek-web.test.ts
@@ -7,6 +7,7 @@ const { DeepSeekWebExecutor, DEEPSEEK_WEB_BASE } =
const { DeepSeekWebWithAutoRefreshExecutor } =
await import("../../open-sse/executors/deepseek-web-with-auto-refresh.ts");
const { getExecutor, hasSpecializedExecutor } = await import("../../open-sse/executors/index.ts");
+const { serializeToolsToPrompt } = await import("../../open-sse/translator/webTools.ts");
const COMPLETION_URL = `${DEEPSEEK_WEB_BASE}/api/v0/chat/completion`;
@@ -58,37 +59,23 @@ test("execute returns 400 with empty apiKey", async () => {
assert.equal(result.response.status, 400);
});
-test("execute returns 400 when client sends non-empty tools[] (chat.deepseek.com has no tool support) - issue #2848", async () => {
- const executor = new DeepSeekWebExecutor();
- const result = await executor.execute({
- model: "default",
- body: {
- messages: [{ role: "user", content: "call my_tool" }],
- tools: [
- {
- type: "function",
- function: {
- name: "my_tool",
- description: "test",
- parameters: { type: "object", properties: {} },
- },
- },
- ],
+test("tools[] is translated into a prompt contract, not rejected - issue #2820 (supersedes #2848)", () => {
+ // #2848 made deepseek-web hard-fail tool requests with a 400. #2820 reverses that:
+ // tools[] is now serialized into a prompt contract and the model's text reply
+ // is parsed back into OpenAI tool_calls. Full execute() round-trip coverage lives in
+ // deepseek-web-tools-execute-2820.test.ts.
+ const prompt = serializeToolsToPrompt([
+ {
+ type: "function",
+ function: {
+ name: "my_tool",
+ description: "test tool",
+ parameters: { type: "object", properties: {} },
+ },
},
- stream: false,
- credentials: { apiKey: "any-valid-looking-token" },
- signal: AbortSignal.timeout(5000),
- });
- assert.equal(result.response.status, 400);
- const text = await result.response.text();
- assert.ok(
- /does not support function calling/i.test(text),
- `expected explanatory error body, got: ${text}`
- );
- assert.ok(
- /provider 'deepseek'|api\.deepseek\.com/i.test(text),
- `expected guidance to use official deepseek provider, got: ${text}`
- );
+ ]);
+ assert.ok(prompt.includes("my_tool"), "tool name serialized into the prompt");
+ assert.ok(prompt.includes(""), "invocation contract present");
});
test("execute does NOT 400 on tools[]=[] (empty array, equivalent to no tools)", async () => {
diff --git a/tests/unit/web-tools-translation-2820.test.ts b/tests/unit/web-tools-translation-2820.test.ts
new file mode 100644
index 0000000000..d8d152a3d7
--- /dev/null
+++ b/tests/unit/web-tools-translation-2820.test.ts
@@ -0,0 +1,75 @@
+// #2820 — tool-call translation for web-cookie providers (deepseek-web first).
+// The web UIs accept only a plain prompt string and reply with tool invocations as
+// raw text. These pure helpers (a) serialize the OpenAI `tools` array into a
+// system-prompt contract, and (b) parse the upstream `{...}` text back
+// into OpenAI `tool_calls`.
+import test from "node:test";
+import assert from "node:assert/strict";
+
+const { serializeToolsToPrompt, parseToolCallsFromText } = await import(
+ "../../open-sse/translator/webTools.ts"
+);
+
+const TOOLS = [
+ {
+ type: "function",
+ function: {
+ name: "get_weather",
+ description: "Get the current weather for a city",
+ parameters: {
+ type: "object",
+ properties: { city: { type: "string" } },
+ required: ["city"],
+ },
+ },
+ },
+];
+
+test("serializeToolsToPrompt lists the tool and the invocation contract", () => {
+ const prompt = serializeToolsToPrompt(TOOLS);
+ assert.ok(prompt.includes("get_weather"), "tool name present");
+ assert.ok(prompt.includes("Get the current weather"), "tool description present");
+ assert.ok(prompt.includes(""), "invocation contract mentions the tag");
+});
+
+test("serializeToolsToPrompt returns empty string for no tools", () => {
+ assert.equal(serializeToolsToPrompt([]), "");
+ assert.equal(serializeToolsToPrompt(undefined), "");
+});
+
+test("parseToolCallsFromText extracts a single tool call and strips it from content", () => {
+ const text =
+ 'Sure, let me check.\n{"name": "get_weather", "arguments": {"city": "Paris"}}';
+ const { content, toolCalls } = parseToolCallsFromText(text);
+ assert.ok(toolCalls && toolCalls.length === 1, "one tool call parsed");
+ assert.equal(toolCalls[0].type, "function");
+ assert.equal(toolCalls[0].function.name, "get_weather");
+ // OpenAI tool_calls arguments is a JSON *string*.
+ assert.equal(typeof toolCalls[0].function.arguments, "string");
+ assert.deepEqual(JSON.parse(toolCalls[0].function.arguments), { city: "Paris" });
+ assert.ok(toolCalls[0].id, "tool call has an id");
+ assert.ok(!content.includes(""), "the raw block is stripped from content");
+ assert.ok(content.includes("Sure, let me check."), "surrounding text preserved");
+});
+
+test("parseToolCallsFromText returns null toolCalls when there is no tool block", () => {
+ const { content, toolCalls } = parseToolCallsFromText("just a normal answer");
+ assert.equal(toolCalls, null);
+ assert.equal(content, "just a normal answer");
+});
+
+test("parseToolCallsFromText parses multiple tool calls", () => {
+ const text =
+ '{"name": "a", "arguments": {"x": 1}}\n{"name": "b", "arguments": {}}';
+ const { toolCalls } = parseToolCallsFromText(text);
+ assert.equal(toolCalls?.length, 2);
+ assert.equal(toolCalls[0].function.name, "a");
+ assert.equal(toolCalls[1].function.name, "b");
+});
+
+test("parseToolCallsFromText tolerates a tool block with no arguments", () => {
+ const { toolCalls } = parseToolCallsFromText('{"name": "ping"}');
+ assert.equal(toolCalls?.length, 1);
+ assert.equal(toolCalls[0].function.name, "ping");
+ assert.equal(toolCalls[0].function.arguments, "{}");
+});