Refactor
This commit is contained in:
@@ -89,7 +89,7 @@ exports[`GOLDEN request: OpenAI → Claude > full body (system/image/tool/tool_r
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → thinking budget 1`] = `
|
||||
exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive output_config (claude 4.6+) 1`] = `
|
||||
{
|
||||
"max_tokens": 64000,
|
||||
"messages": [
|
||||
@@ -104,6 +104,9 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → thinking budge
|
||||
},
|
||||
],
|
||||
"model": "claude-opus-4-6",
|
||||
"output_config": {
|
||||
"effort": "high",
|
||||
},
|
||||
"stream": true,
|
||||
"system": [
|
||||
{
|
||||
@@ -115,10 +118,6 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → thinking budge
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"thinking": {
|
||||
"budget_tokens": 16384,
|
||||
"type": "enabled",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("GOLDEN request: OpenAI → Claude", () => {
|
||||
expect(clean(out)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("reasoning_effort → thinking budget", () => {
|
||||
it("reasoning_effort → adaptive output_config (claude 4.6+)", () => {
|
||||
const body = { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" };
|
||||
const out = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, "claude-opus-4-6", body, true, { apiKey: "sk-x" }, "anthropic");
|
||||
expect(clean(out)).toMatchSnapshot();
|
||||
|
||||
168
tests/translator/real/file-base64-survey.real.test.js
Normal file
168
tests/translator/real/file-base64-survey.real.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
// REAL survey: send PDF/DOCX as base64 to every active provider, using the file/document
|
||||
// shape NATIVE to each provider's format (claude=document, gemini=inlineData, openai=file).
|
||||
// Purpose: discover which providers actually accept inline base64 documents and how they
|
||||
// reject DOCX. Survey-only: logs a grouped table, never asserts on accept/reject outcome.
|
||||
//
|
||||
// RUN_REAL=1 npx vitest run --config tests/vitest.config.js tests/translator/real/file-base64-survey.real.test.js
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
|
||||
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
|
||||
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
|
||||
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
|
||||
import { getTargetFormat } from "../../../open-sse/services/provider.js";
|
||||
|
||||
const RUN_REAL = process.env.RUN_REAL === "1";
|
||||
const TIMEOUT_MS = 90000;
|
||||
const CRED_ISSUE = [401, 402, 403, 429];
|
||||
const CRED_MSG_RE = /subscription|unauthorized|invalid api key|invalid access token|insufficient|credits|payment|spending|organization policy|disallowed|quota|exhausted|not supported when using|not available for integrator|requires a subscription|model.*not found|does not exist|not yet known|requires a role|invalid model id/i;
|
||||
const NON_CHAT_KINDS = new Set(["embedding", "image", "imageToText", "tts", "stt", "video", "music", "webSearch"]);
|
||||
|
||||
const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
const PDF_B64 = "JVBERi0xLjEKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjAwIDIwMF0+PmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1MiAwMDAwMCBuIAowMDAwMDAwMTAxIDAwMDAwIG4gCnRyYWlsZXI8PC9TaXplIDQvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgoxNjYKJSVFT0Y=";
|
||||
const DOCX_B64 = "UEsDBBQAAAAIAMZiz1yRzx8FvQAAACkBAAATABwAW0NvbnRlbnRfVHlwZXNdLnhtbFVUCQADA4wvagOML2p1eAsAAQT1AQAABAAAAAB9kL0OwjAMhF8lyoqoCwMDassArMDAC1ipWyKaHyXm7+1xATEwMNrf3fnkanV3g7pSyjb4Ws+KUq+a6viIlJUQn2t9Yo5LgGxO5DAXIZIX0oXkkGVMPUQ0Z+wJ5mW5ABM8k+cpjxm6qTbU4WVgtb3L+n1F7Fqt37rxVK0xxsEaZMEwUmiqvZRKtiV1wMQ7dKKCW0gttMFcnDiL/zFX3/50nYaus4a+/jEtpmAoZ+t7NxRf4tD6yacHvJ7RPAFQSwMECgAAAAAAxmLPXAAAAAAAAAAAAAAAAAUAHAB3b3JkL1VUCQADA4wvagOML2p1eAsAAQT1AQAABAAAAABQSwMEFAAAAAgAxmLPXD1WbTiKAAAAwAAAABEAHAB3b3JkL2RvY3VtZW50LnhtbFVUCQADA4wvagOML2p1eAsAAQT1AQAABAAAAABFjtEOgjAMRX9l2QdQ9MEHMuDV30BWgWRbl7aK/r0bxvhymuakt9eNrxjME1k2Sr09Na0dB7d3nuZHxKSm6CTd3ttVNXcAMq8YJ2koYyruThwnLSsvsBP7zDSjyJaWGODctheI05ZsjbyRf9eZK7hChyuGQKYcBm8URc3vr4OqK/lgPviNgH+94QNQSwECHgMUAAAACADGYs9ckc8fBb0AAAApAQAAEwAYAAAAAAABAAAApIEAAAAAW0NvbnRlbnRfVHlwZXNdLnhtbFVUBQADA4wvanV4CwABBPUBAAAEAAAAAFBLAQIeAwoAAAAAAMZiz1wAAAAAAAAAAAAAAAAFABgAAAAAAAAAEADtQQoBAAB3b3JkL1VUBQADA4wvanV4CwABBPUBAAAEAAAAAFBLAQIeAxQAAAAIAMZiz1w9Vm04igAAAMAAAAARABgAAAAAAAEAAACkgUkBAAB3b3JkL2RvY3VtZW50LnhtbFVUBQADA4wvanV4CwABBPUBAAAEAAAAAFBLBQYAAAAAAwADAPsAAAAeAgAAAAA=";
|
||||
|
||||
const PDF_MIME = "application/pdf";
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
// Build a native-format request body carrying one base64 document.
|
||||
// fmt = provider target format; returns { sourceFormat, body } or null if format unsupported here.
|
||||
function buildFileBody(fmt, mime, b64) {
|
||||
const ask = "Summarize this document in one word.";
|
||||
if (fmt === "claude") {
|
||||
return { sourceFormat: "claude", body: {
|
||||
max_tokens: 64, stream: true,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: ask },
|
||||
{ type: "document", source: { type: "base64", media_type: mime, data: b64 } },
|
||||
] }],
|
||||
} };
|
||||
}
|
||||
if (fmt === "gemini" || fmt === "gemini-cli" || fmt === "antigravity") {
|
||||
const gem = {
|
||||
contents: [{ role: "user", parts: [
|
||||
{ text: ask },
|
||||
{ inlineData: { mimeType: mime, data: b64 } },
|
||||
] }],
|
||||
generationConfig: { maxOutputTokens: 64 },
|
||||
};
|
||||
return fmt === "antigravity"
|
||||
? { sourceFormat: "antigravity", body: { request: gem, userAgent: "antigravity" } }
|
||||
: { sourceFormat: fmt, body: gem };
|
||||
}
|
||||
// openai + openai-compat: Chat Completions file block (file_data data URI).
|
||||
return { sourceFormat: "openai", body: {
|
||||
max_tokens: 64, stream: true,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: ask },
|
||||
{ type: "file", file: { filename: `doc.${mime === PDF_MIME ? "pdf" : "docx"}`, file_data: `data:${mime};base64,${b64}` } },
|
||||
] }],
|
||||
} };
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function chatModels(providerId) {
|
||||
return getModelsByProviderId(providerId).filter((m) => !NON_CHAT_KINDS.has(m.kind || m.type || "llm"));
|
||||
}
|
||||
|
||||
function targetProviders() {
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const dbPath = process.env.DATA_DIR
|
||||
? path.join(process.env.DATA_DIR, "db", "data.sqlite")
|
||||
: path.join(os.homedir(), ".9router", "db", "data.sqlite");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all();
|
||||
db.close();
|
||||
let list = rows.map((r) => r.provider).sort();
|
||||
if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p));
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// One model per provider is enough to learn provider-level document support (saves quota).
|
||||
const FILE_TYPES = [["pdf", PDF_MIME, PDF_B64], ["docx", DOCX_MIME, DOCX_B64]];
|
||||
|
||||
describe.skipIf(!RUN_REAL)("REAL file base64 survey", () => {
|
||||
const providers = RUN_REAL ? targetProviders() : [];
|
||||
|
||||
it("has active providers in DB", () => {
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const providerId of providers) {
|
||||
const model = (RUN_REAL ? chatModels(providerId)[0]?.id : null);
|
||||
if (!model) continue;
|
||||
for (const [kind, mime, b64] of FILE_TYPES) {
|
||||
it.concurrent(`${kind} | ${providerId} / ${model}`, async () => {
|
||||
const fmt = getTargetFormat(providerId);
|
||||
const { sourceFormat, body } = buildFileBody(fmt, mime, b64);
|
||||
|
||||
const credentials = await getProviderCredentials(providerId, new Set(), model);
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
results.push({ kind, providerId, model, fmt, status: "no-cred", verdict: "skip" });
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
const refreshed = await checkAndRefreshToken(providerId, credentials);
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: { ...body, model: `${providerId}/${model}` },
|
||||
modelInfo: { provider: providerId, model },
|
||||
credentials: refreshed,
|
||||
connectionId: credentials.connectionId,
|
||||
sourceFormatOverride: sourceFormat,
|
||||
});
|
||||
|
||||
const status = Number(result.status) || (result.success ? 200 : 0);
|
||||
const errMsg = result.success ? "" : String(result.error || "");
|
||||
const credIssue = CRED_ISSUE.includes(status) || CRED_MSG_RE.test(errMsg);
|
||||
const ok = result.success;
|
||||
|
||||
let verdict;
|
||||
if (credIssue) verdict = "skip-cred";
|
||||
else if (ok) verdict = `ok-${kind}`;
|
||||
else verdict = `reject-${kind}`;
|
||||
|
||||
if (ok) await drainSSE(result.response).catch(() => {});
|
||||
results.push({ kind, providerId, model, fmt, status, verdict, error: ok ? "" : errMsg.slice(0, 90) });
|
||||
return expect(true).toBe(true);
|
||||
}, TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
const w = (s) => process.stdout.write(s + "\n");
|
||||
if (!results.length) { w("[file-survey] no results collected"); return; }
|
||||
const rank = (v) => (v.startsWith("ok") ? 0 : v.startsWith("reject") ? 1 : 2);
|
||||
const groups = [...new Set(results.map((r) => r.verdict))].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
w("\n================ FILE BASE64 SURVEY ================");
|
||||
for (const g of groups) {
|
||||
const rows = results.filter((r) => r.verdict === g);
|
||||
if (!rows.length) continue;
|
||||
w(`\n### ${g} (${rows.length})`);
|
||||
for (const r of rows) {
|
||||
w(` [${r.status}] ${r.kind} ${r.providerId}/${r.model} fmt=${r.fmt}${r.error ? ` :: ${r.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
w("\n================ END SURVEY ================\n");
|
||||
});
|
||||
});
|
||||
122
tests/translator/real/thinking.real.test.js
Normal file
122
tests/translator/real/thinking.real.test.js
Normal file
@@ -0,0 +1,122 @@
|
||||
// REAL integration test for thinking normalization: sends a reasoning prompt with
|
||||
// reasoning_effort to every thinking-capable provider that has an active credential,
|
||||
// then asserts the upstream accepted it (no 400) and emitted reasoning output.
|
||||
// Gated by RUN_REAL=1 so the default `vitest run` never touches the network.
|
||||
//
|
||||
// RUN_REAL=1 npx vitest run -c tests/vitest.config.js "tests/translator/real/thinking"
|
||||
// RUN_REAL=1 REAL_PROVIDERS=claude,glm,deepseek npx vitest run -c tests/vitest.config.js "tests/translator/real/thinking"
|
||||
//
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
|
||||
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
|
||||
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
|
||||
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
|
||||
import { getCapabilitiesForModel } from "../../../open-sse/providers/capabilities.js";
|
||||
|
||||
const RUN_REAL = process.env.RUN_REAL === "1";
|
||||
const MAX_TOKENS = 512;
|
||||
const TIMEOUT_MS = 120000;
|
||||
const EFFORT = process.env.THINK_EFFORT || "high";
|
||||
const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
// First plain llm model that the capability registry marks as reasoning-capable.
|
||||
function firstReasoningModel(providerId) {
|
||||
const models = getModelsByProviderId(providerId);
|
||||
for (const m of models) {
|
||||
if ((m.type || "llm") !== "llm") continue;
|
||||
if (/embedding|image|tts|whisper|rerank|vision-model/i.test(m.id)) continue;
|
||||
if (getCapabilitiesForModel(providerId, m.id).reasoning) return m.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
describe.skipIf(!RUN_REAL).concurrent("REAL thinking normalization", () => {
|
||||
it("has active providers in DB", () => {
|
||||
expect(targetProviders().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const providerId of (RUN_REAL ? targetProviders() : [])) {
|
||||
it.concurrent(
|
||||
`${providerId}: accepts reasoning_effort=${EFFORT} and reasons`,
|
||||
async () => {
|
||||
const model = firstReasoningModel(providerId);
|
||||
if (!model) {
|
||||
console.warn(`[skip] ${providerId}: no reasoning-capable model`);
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
|
||||
const credentials = await getProviderCredentials(providerId, new Set(), model);
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
console.warn(`[skip] ${providerId}: no usable credential`);
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
|
||||
const refreshed = await checkAndRefreshToken(providerId, credentials);
|
||||
const result = await handleChatCore({
|
||||
body: {
|
||||
model: `${providerId}/${model}`,
|
||||
stream: true,
|
||||
max_tokens: MAX_TOKENS,
|
||||
reasoning_effort: EFFORT,
|
||||
messages: [{ role: "user", content: "Think step by step, then answer: what is 17 * 23?" }],
|
||||
},
|
||||
modelInfo: { provider: providerId, model },
|
||||
credentials: refreshed,
|
||||
connectionId: credentials.connectionId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// 400 = our thinking payload was rejected → real bug. Other codes = credential/quota.
|
||||
const credIssue = [401, 402, 403, 429].includes(Number(result.status));
|
||||
if (credIssue) {
|
||||
console.warn(`[skip] ${providerId}: ${result.status} (credential/quota)`);
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
console.error(`[REJECT] ${providerId}/${model} ${result.status}:`, JSON.stringify(result.error)?.slice(0, 600));
|
||||
throw new Error(`${providerId}/${model} thinking REJECTED: ${result.status}`);
|
||||
}
|
||||
|
||||
const raw = await drainSSE(result.response);
|
||||
expect(raw.length, `${providerId}: empty response`).toBeGreaterThan(0);
|
||||
const hasReasoning = /reasoning_content|"thinking"|reasoning_details|<think/.test(raw);
|
||||
// Log so the operator can eyeball which providers actually streamed reasoning.
|
||||
console.log(`[ok] ${providerId}/${model} reasoning=${hasReasoning}`);
|
||||
expect(/data:|"delta"|"content"|finish_reason/.test(raw), `${providerId}: not SSE`).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function targetProviders() {
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const dbPath = process.env.DATA_DIR
|
||||
? path.join(process.env.DATA_DIR, "db", "data.sqlite")
|
||||
: path.join(os.homedir(), ".9router", "db", "data.sqlite");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all();
|
||||
db.close();
|
||||
let list = rows.map((r) => r.provider).sort();
|
||||
if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p));
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
|
||||
const PNG_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4I2JDEmIY1TCqYfhqAAAeBCwQ8YdREQAAAABJRU5ErkJggg==";
|
||||
// Tiny silent WAV (44-byte header, no samples) — enough to probe audioInput acceptance.
|
||||
const WAV_B64 = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
||||
// Stable public image URL (probe remote-URL handling vs base64).
|
||||
const IMAGE_REMOTE_URL = "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png";
|
||||
// Tiny valid PDF (probe file/document handling).
|
||||
const PDF_B64 = "JVBERi0xLjEKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjAwIDIwMF0+PmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1MiAwMDAwMCBuIAowMDAwMDAwMTAxIDAwMDAwIG4gCnRyYWlsZXI8PC9TaXplIDQvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgoxNjYKJSVFT0Y=";
|
||||
const PDF_DATA_URI = `data:application/pdf;base64,${PDF_B64}`;
|
||||
|
||||
// Capability probes: each sends one modality-specific content block. cap = capability flag tested.
|
||||
const CAPABILITY_PROBES = {
|
||||
@@ -42,6 +47,20 @@ const CAPABILITY_PROBES = {
|
||||
{ type: "input_audio", input_audio: { data: WAV_B64, format: "wav" } },
|
||||
],
|
||||
},
|
||||
imageUrl: {
|
||||
cap: "vision",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image? One word." },
|
||||
{ type: "image_url", image_url: { url: IMAGE_REMOTE_URL } },
|
||||
],
|
||||
},
|
||||
file: {
|
||||
cap: "pdf",
|
||||
content: [
|
||||
{ type: "text", text: "Summarize this document. One word." },
|
||||
{ type: "file", file: { filename: "doc.pdf", file_data: PDF_DATA_URI } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Set PROBES=vision,audio (default vision only to limit live quota).
|
||||
const ACTIVE_PROBES = (process.env.PROBES || "vision").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
131
tests/translator/thinking-unified.test.js
Normal file
131
tests/translator/thinking-unified.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Unit tests for unified thinking normalization (thinkingUnified.js).
|
||||
// Covers extract, suffix parse, and per-provider apply per MATRIX (.docs/thinking/plan.md).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseSuffix,
|
||||
extractThinking,
|
||||
applyThinking,
|
||||
} from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import { extractReasoningText } from "../../open-sse/translator/concerns/reasoning.js";
|
||||
|
||||
const apply = (targetFormat, model, body, provider) => {
|
||||
const b = JSON.parse(JSON.stringify(body));
|
||||
applyThinking(targetFormat, model, b, provider);
|
||||
return b;
|
||||
};
|
||||
|
||||
describe("parseSuffix", () => {
|
||||
it("parses level suffix", () => {
|
||||
expect(parseSuffix("gpt-5(high)")).toEqual({ cleanModel: "gpt-5", override: { mode: "level", level: "high" } });
|
||||
});
|
||||
it("parses numeric budget suffix", () => {
|
||||
expect(parseSuffix("model(8192)")).toEqual({ cleanModel: "model", override: { mode: "budget", budget: 8192 } });
|
||||
});
|
||||
it("parses auto / none", () => {
|
||||
expect(parseSuffix("m(auto)").override).toEqual({ mode: "auto" });
|
||||
expect(parseSuffix("m(none)").override).toEqual({ mode: "none" });
|
||||
});
|
||||
it("no suffix → passthrough", () => {
|
||||
expect(parseSuffix("claude-opus-4.7")).toEqual({ cleanModel: "claude-opus-4.7", override: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinking", () => {
|
||||
it("claude enabled+budget", () => {
|
||||
expect(extractThinking({ thinking: { type: "enabled", budget_tokens: 4096 } })).toEqual({ mode: "budget", budget: 4096 });
|
||||
});
|
||||
it("claude disabled", () => {
|
||||
expect(extractThinking({ thinking: { type: "disabled" } })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("openai reasoning_effort", () => {
|
||||
expect(extractThinking({ reasoning_effort: "high" })).toEqual({ mode: "level", level: "high" });
|
||||
});
|
||||
it("responses reasoning.effort none", () => {
|
||||
expect(extractThinking({ reasoning: { effort: "none" } })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("gemini thinkingBudget 0 → none", () => {
|
||||
expect(extractThinking({ thinkingConfig: { thinkingBudget: 0 } })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("qwen enable_thinking false", () => {
|
||||
expect(extractThinking({ enable_thinking: false })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("no intent → null", () => {
|
||||
expect(extractThinking({ messages: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyThinking per provider format", () => {
|
||||
it("claude 4.6+ → adaptive output_config (no budget_tokens)", () => {
|
||||
const out = apply("claude", "claude-opus-4.7", { reasoning_effort: "high" }, "claude");
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
expect(out.thinking).toBeUndefined();
|
||||
});
|
||||
it("claude haiku → enabled+budget", () => {
|
||||
const out = apply("claude", "claude-haiku-4.5", { reasoning_effort: "high" }, "claude");
|
||||
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 24576 });
|
||||
});
|
||||
it("gemini-3 → thinkingLevel", () => {
|
||||
const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "medium" }, "gemini");
|
||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("medium");
|
||||
});
|
||||
it("gemini-2.5 → thinkingBudget", () => {
|
||||
const out = apply("gemini", "gemini-2.5-flash", { reasoning_effort: "high" }, "gemini");
|
||||
expect(out.generationConfig.thinkingConfig.thinkingBudget).toBe(24576);
|
||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
it("GLM off → enable_thinking:false (not thinking.disabled)", () => {
|
||||
const out = apply("openai", "glm-4.6", { reasoning_effort: "none" }, "glm");
|
||||
expect(out.enable_thinking).toBe(false);
|
||||
expect(out.thinking).toBeUndefined();
|
||||
});
|
||||
it("Qwen on → enable_thinking + thinking_budget", () => {
|
||||
const out = apply("openai", "qwen3-max", { reasoning_effort: "medium" }, "qwen");
|
||||
expect(out.enable_thinking).toBe(true);
|
||||
expect(out.thinking_budget).toBe(8192);
|
||||
});
|
||||
it("QwQ cannot disable → clamp minimal", () => {
|
||||
const out = apply("openai", "qwq-32b", { reasoning_effort: "none" }, "qwen");
|
||||
expect(out.enable_thinking).toBe(true);
|
||||
});
|
||||
it("DeepSeek → enabled + reasoning_effort high (low→high)", () => {
|
||||
const out = apply("openai", "deepseek-v4-pro", { reasoning_effort: "low" }, "deepseek");
|
||||
expect(out.thinking).toEqual({ type: "enabled" });
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
});
|
||||
it("Kimi on → reasoning_effort", () => {
|
||||
const out = apply("openai", "kimi-k2.6", { reasoning_effort: "high" }, "kimi");
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
});
|
||||
it("MiniMax M3 → adaptive", () => {
|
||||
const out = apply("claude", "MiniMax-M3", { reasoning_effort: "high" }, "minimax");
|
||||
expect(out.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
it("non-reasoning model → strips thinking", () => {
|
||||
const out = apply("openai", "gpt-4o", { reasoning_effort: "high" }, "openai");
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
it("aggregator (siliconflow) GLM model → forced openai reasoning_effort", () => {
|
||||
const out = apply("openai", "zai-org/GLM-5", { reasoning_effort: "high" }, "siliconflow");
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
expect(out.enable_thinking).toBeUndefined();
|
||||
});
|
||||
it("suffix overrides body", () => {
|
||||
const out = apply("openai", "gpt-5(low)", { reasoning_effort: "high" }, "openai");
|
||||
expect(out.reasoning_effort).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractReasoningText (response shapes)", () => {
|
||||
it("reasoning_content (GLM/Qwen/DeepSeek)", () => {
|
||||
expect(extractReasoningText({ reasoning_content: "abc" })).toBe("abc");
|
||||
});
|
||||
it("reasoning fallback", () => {
|
||||
expect(extractReasoningText({ reasoning: "xyz" })).toBe("xyz");
|
||||
});
|
||||
it("reasoning_details[] (MiniMax split)", () => {
|
||||
expect(extractReasoningText({ reasoning_details: [{ text: "a" }, { content: "b" }, "c"] })).toBe("abc");
|
||||
});
|
||||
it("no reasoning → empty", () => {
|
||||
expect(extractReasoningText({ content: "hello" })).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user