Files
9router/tests/translator/real/deepseek-official-responses.real.test.js
T
KiMelody 7f5bd15518 fix(tools): dedupe same-name tools for DeepSeek models (#3333)
DeepSeek upstream rejects duplicate tool names with 400 'Tool names must
be unique' on every endpoint (api.deepseek.com, opencode.go, LiteLLM).
dedupeTools now takes {clientTool, model}: MCP-equivalent built-in dedup
stays Claude-only, exact same-name dedup applies to any provider serving
a deepseek-* model (first definition wins; tool_choice and history
references are by name/id so nothing breaks). Adds an offline endpoint
routing matrix and live upstream evidence tests.
2026-09-28 12:44:30 +07:00

239 lines
11 KiB
JavaScript

// REAL matrix: DeepSeek OFFICIAL Responses API — direct behavior vs 9router translation.
//
// Context: DeepSeek recently shipped an OpenAI-Responses-compatible endpoint
// (https://api.deepseek.com/responses) but the 9router registry does not declare it —
// responses-format requests are translated to /chat/completions. This suite answers:
//
// 1. DIRECT: does the official /responses endpoint demand reasoning pass-back
// (like official /chat/completions does: "reasoning_content must be passed back")?
// 2. 9ROUTER: when a responses-format client hits 9router, what does the translation
// to /chat/completions do with reasoning items — and does multi-turn survive the
// pass-back requirement on the chat endpoint?
//
// RUN_REAL=1 npx vitest run --config tests/vitest.config.js tests/translator/real/deepseek-official-responses.real.test.js
//
// Reads the DeepSeek API key from the local 9router DB (connection id
// a91b07f2-878a-45b0-beb5-56981409ab0c). Uses handleChatCore for the 9router half.
import { describe, it, expect } from "vitest";
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
import { openaiResponsesToOpenAIRequest } from "../../../open-sse/translator/request/openai-responses.js";
const RUN_REAL = process.env.RUN_REAL === "1";
const PROVIDER = "deepseek";
const MODEL = "deepseek-reasoner";
const TIMEOUT_MS = 120000;
const CRED_ISSUE = [401, 402, 403, 429];
// API key from the local DB connection (no dashboard/DB writes — read-only).
function readApiKey() {
const Database = require("better-sqlite3");
const path = require("path");
const dbPath = path.join(process.env.APPDATA, "9router", "db", "data.sqlite");
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare(
"SELECT data FROM providerConnections WHERE provider = ? AND isActive = 0 ORDER BY updatedAt DESC LIMIT 1"
).all("deepseek");
db.close();
if (!rows.length) return null;
try {
const data = JSON.parse(rows[0].data);
return data.apiKey || null;
} catch { return null; }
}
// ---- DIRECT half: raw fetch to the official /responses endpoint ----
async function directResponses(body) {
const res = await fetch("https://api.deepseek.com/responses", {
method: "POST",
headers: { "Authorization": `Bearer ${process.env.DS_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch { /* keep null */ }
return { status: res.status, json, raw: text };
}
const TURN1_USER = { type: "message", role: "user", content: [{ type: "input_text", text: "Think step by step about 17+26, then reply with ONLY the number." }] };
const TURN2_USER = { type: "message", role: "user", content: [{ type: "input_text", text: "What was your final answer? Reply with just the number." }] };
async function directTurn1() {
const out = await directResponses({
model: MODEL, stream: false, max_output_tokens: 512,
reasoning: { effort: "high" },
input: [TURN1_USER],
});
return out;
}
describe.skipIf(!RUN_REAL)("DIRECT: DeepSeek official /responses endpoint", () => {
it("has a DeepSeek API key in the local DB", () => {
expect(process.env.DS_KEY && process.env.DS_KEY.startsWith("sk-")).toBe(true);
});
it("single-turn works and returns a Responses-shape payload", async () => {
const out = await directResponses({ model: MODEL, stream: false, input: [TURN1_USER] });
console.log(`[direct single] status=${out.status}`);
expect(out.status).toBe(200);
expect(out.json?.object).toBe("response");
expect(out.json?.output?.some((o) => o.type === "message")).toBe(true);
});
it("turn1 with reasoning returns a reasoning item", async () => {
const out = await directTurn1();
expect(out.status).toBe(200);
const types = (out.json?.output || []).map((o) => o.type);
console.log(`[direct turn1] output types=${types.join(",")} reasoning_tokens=${out.json?.usage?.output_tokens_details?.reasoning_tokens}`);
expect(types).toContain("reasoning");
});
it("turn2 WITHOUT reasoning item is accepted (no pass-back requirement)", async () => {
const t1 = await directTurn1();
const outMsg = (t1.json?.output || []).find((o) => o.type === "message");
const out = await directResponses({
model: MODEL, stream: false, max_output_tokens: 256,
input: [TURN1_USER, { type: "message", role: "assistant", content: outMsg?.content }, TURN2_USER],
});
console.log(`[direct turn2 no-reasoning] status=${out.status}`);
expect(out.status).toBe(200);
});
it("turn2 WITH reasoning item is accepted", async () => {
const t1 = await directTurn1();
const reas = (t1.json?.output || []).find((o) => o.type === "reasoning");
const outMsg = (t1.json?.output || []).find((o) => o.type === "message");
const out = await directResponses({
model: MODEL, stream: false, max_output_tokens: 256,
input: [TURN1_USER, reas, { type: "message", role: "assistant", content: outMsg?.content }, TURN2_USER],
});
console.log(`[direct turn2 with-reasoning] status=${out.status}`);
expect(out.status).toBe(200);
});
it("streaming single-turn returns Responses SSE events", async () => {
const res = await fetch("https://api.deepseek.com/responses", {
method: "POST",
headers: { "Authorization": `Bearer ${process.env.DS_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ model: MODEL, stream: true, max_output_tokens: 256, input: [TURN1_USER] }),
});
expect(res.status).toBe(200);
const text = await res.text();
const events = (text.match(/event: ([a-z_.]+)/g) || []).map((e) => e.slice(7));
const hasCreated = events.includes("response.created");
const hasCompleted = events.includes("response.completed");
console.log(`[direct stream] status=200 events=${events.join(",")}`);
expect(hasCreated).toBe(true);
expect(hasCompleted).toBe(true);
}, TIMEOUT_MS);
});
// ---- UNIT half: what the responses→chat translator does with reasoning ----
describe("UNIT: responses→chat translator reasoning handling", () => {
it("attaches reasoning item text as reasoning_content on the assistant message", () => {
const body = {
input: [
TURN1_USER,
{ type: "reasoning", id: "rs_1", content: [{ type: "reasoning_text", text: "17 + 26 = 43" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "43" }] },
TURN2_USER,
],
};
const result = openaiResponsesToOpenAIRequest(MODEL, body, false, null);
const assistant = result.messages.find((m) => m.role === "assistant");
expect(assistant.reasoning_content).toContain("43");
expect(result.messages.length).toBe(3);
});
it("leaves assistant messages bare when no reasoning item is present", () => {
const body = {
input: [TURN1_USER, { type: "message", role: "assistant", content: [{ type: "output_text", text: "43" }] }, TURN2_USER],
};
const result = openaiResponsesToOpenAIRequest(MODEL, body, false, null);
const assistant = result.messages.find((m) => m.role === "assistant");
expect(assistant.reasoning_content).toBeUndefined();
});
});
// ---- 9ROUTER half: handleChatCore with responses sourceFormat ----
async function drainSSE(response) {
if (!response?.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let out = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out;
}
async function via9router(body) {
const credentials = {
apiKey: process.env.DS_KEY,
connectionId: "a91b07f2-878a-45b0-beb5-56981409ab0c",
providerSpecificData: { connectionProxyEnabled: false, connectionProxyUrl: "", connectionNoProxy: "" },
};
const result = await handleChatCore({
body: { ...body, model: `${PROVIDER}/${MODEL}` },
modelInfo: { provider: PROVIDER, model: MODEL },
credentials,
connectionId: credentials.connectionId,
sourceFormatOverride: "openai-responses",
});
if (!result.success) {
const status = Number(result.status);
if (CRED_ISSUE.includes(status) || (status >= 500) || status === 406) return { skip: true };
return { ok: false, status: status || "n/a", raw: String(result.error || "") };
}
return { ok: true, status: 200, raw: await drainSSE(result.response) };
}
describe.skipIf(!RUN_REAL)(`9ROUTER: responses-format → ${PROVIDER} translation`, () => {
it("single-turn responses request succeeds via /chat/completions", async () => {
const out = await via9router({
stream: true, max_output_tokens: 128, instructions: "You are concise.",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Reply with the single word: hi" }] }],
});
if (out.skip) return expect(true).toBe(true);
// 9router routes the request to /chat/completions but re-encodes the stream
// back to the client's source format (Responses SSE shape).
const isResponsesShape = /event: response\.|"type"\s*:\s*"response|"type":"response/.test(out.raw || "");
const isChatShape = /chat\.completion\.chunk|"delta"/.test(out.raw || "");
console.log(`[9r single] status=${out.status} bytes=${out.raw?.length} responsesShape=${isResponsesShape} chatShape=${isChatShape}`);
expect(out.ok).toBe(true);
expect(isResponsesShape || isChatShape).toBe(true);
}, TIMEOUT_MS);
it("multi-turn WITH reasoning item in history succeeds (translator attaches reasoning_content)", async () => {
const out = await via9router({
stream: true, max_output_tokens: 128,
input: [
TURN1_USER,
{ type: "reasoning", id: "rs_1", content: [{ type: "reasoning_text", text: "17 + 26 = 43" }] },
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "43" }] },
TURN2_USER,
],
});
if (out.skip) return expect(true).toBe(true);
console.log(`[9r multi with-reasoning] status=${out.status} bytes=${out.raw?.length}`);
expect(out.ok).toBe(true);
}, TIMEOUT_MS);
it("multi-turn WITHOUT reasoning item in history (diagnostic: chat endpoint pass-back)", async () => {
const out = await via9router({
stream: true, max_output_tokens: 128,
input: [
TURN1_USER,
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "43" }] },
TURN2_USER,
],
});
if (out.skip) return expect(true).toBe(true);
console.log(`[9r multi no-reasoning] status=${out.status} raw=${out.raw?.slice?.(0, 200)}`);
// Diagnostic: the official chat endpoint requires reasoning_content pass-back;
// a 400 here proves the translation path needs the reasoning item to survive.
expect(out.skip).not.toBe(true);
}, TIMEOUT_MS);
});