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