mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
committed by
GitHub
parent
0594af6a6c
commit
50896699d3
@@ -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);
|
||||
|
||||
109
open-sse/translator/webTools.ts
Normal file
109
open-sse/translator/webTools.ts
Normal 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 };
|
||||
}
|
||||
137
tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts
Normal file
137
tests/unit/auth-ollama-cloud-per-model-403-3027.test.ts
Normal 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");
|
||||
});
|
||||
61
tests/unit/deepseek-web-rolling-window-2942.test.ts
Normal file
61
tests/unit/deepseek-web-rolling-window-2942.test.ts
Normal 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), "");
|
||||
});
|
||||
189
tests/unit/deepseek-web-session-persistence-2942.test.ts
Normal file
189
tests/unit/deepseek-web-session-persistence-2942.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
186
tests/unit/deepseek-web-tools-execute-2820.test.ts
Normal file
186
tests/unit/deepseek-web-tools-execute-2820.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
75
tests/unit/web-tools-translation-2820.test.ts
Normal file
75
tests/unit/web-tools-translation-2820.test.ts
Normal 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, "{}");
|
||||
});
|
||||
Reference in New Issue
Block a user