fix+feat(sse): per-model 403 lockout (#3027) + deepseek-web memory (#2942) & tool-calls (#2820) (#3101)

* 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 <tool>{...}</tool>
  prompt contract, injected as a leading system message.
- Response: parseToolCallsFromText() extracts the model's <tool> 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 <tool> 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
<tool> 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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-03 18:29:26 -03:00
committed by GitHub
parent 0594af6a6c
commit 50896699d3
8 changed files with 1030 additions and 107 deletions

View File

@@ -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<string, string>;
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<string, unknown> = { 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<string, unknown>;
// 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
// <tool>...</tool> 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<string, unknown>;
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<string, unknown>;
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<string, string> = {
...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<string, string> = {
...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 <tool> 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);

View File

@@ -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 `<tool>{...}</tool>` 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 = /<tool>\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 `<tool>{...}</tool>` 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 <tool> block",
'with JSON: <tool>{"name": "<tool_name>", "arguments": { ... }}</tool>',
"Only emit the <tool> block when you actually want to call a tool; otherwise answer normally.",
"",
"Available tools:",
...lines,
].join("\n");
}
/**
* Parse `<tool>{...}</tool>` 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("<tool>")) {
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 };
}

View File

@@ -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");
});

View File

@@ -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), "");
});

View File

@@ -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();
}
});

View File

@@ -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
// <tool>{...}</tool> 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 = '<tool>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool>';
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 <tool> 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("<tool>"),
"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();
}
});

View File

@@ -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 <tool> 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 <tool> 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("<tool>"), "invocation contract present");
});
test("execute does NOT 400 on tools[]=[] (empty array, equivalent to no tools)", async () => {

View File

@@ -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 `<tool>{...}</tool>` 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 <tool> 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("<tool>"), "invocation contract mentions the <tool> 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<tool>{"name": "get_weather", "arguments": {"city": "Paris"}}</tool>';
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("<tool>"), "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 =
'<tool>{"name": "a", "arguments": {"x": 1}}</tool>\n<tool>{"name": "b", "arguments": {}}</tool>';
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('<tool>{"name": "ping"}</tool>');
assert.equal(toolCalls?.length, 1);
assert.equal(toolCalls[0].function.name, "ping");
assert.equal(toolCalls[0].function.arguments, "{}");
});