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("");
|
||||
});
|
||||
});
|
||||
@@ -9,24 +9,40 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Mock DNS so the SSRF guard treats example.com as public.
|
||||
vi.mock("node:dns/promises", () => ({ lookup: async () => ({ address: "93.184.216.34" }) }));
|
||||
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js";
|
||||
|
||||
const IMAGE_1MB_BYTES = 1024 * 1024;
|
||||
const REMOTE_URL = "https://example.com/big.jpg";
|
||||
const DATA_URI = "data:image/png;base64,iVBORw0KGgo=";
|
||||
// JPEG magic bytes (FF D8 FF) so magic-byte verification passes.
|
||||
const JPEG_MAGIC = [0xff, 0xd8, 0xff];
|
||||
|
||||
function makeImageBuffer(sizeBytes) {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
for (let i = 0; i < sizeBytes; i++) buf[i] = i & 0xff;
|
||||
return buf.buffer;
|
||||
for (let i = 0; i < JPEG_MAGIC.length; i++) buf[i] = JPEG_MAGIC[i];
|
||||
for (let i = JPEG_MAGIC.length; i < sizeBytes; i++) buf[i] = i & 0xff;
|
||||
return buf;
|
||||
}
|
||||
|
||||
function mockImageFetch(sizeBytes, mimeType = "image/jpeg") {
|
||||
// Mock a streaming Response body (getReader) as the hardened fetcher expects.
|
||||
function mockImageFetch(sizeBytes) {
|
||||
const bytes = makeImageBuffer(sizeBytes);
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: (k) => (k === "Content-Type" ? mimeType : null) },
|
||||
arrayBuffer: async () => makeImageBuffer(sizeBytes),
|
||||
body: {
|
||||
getReader() {
|
||||
let sent = false;
|
||||
return {
|
||||
read: async () => sent ? { done: true } : (sent = true, { done: false, value: bytes }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
78
tests/unit/combo-autoswitch.test.js
Normal file
78
tests/unit/combo-autoswitch.test.js
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectRequiredCapabilities, reorderByCapabilities } from "../../open-sse/services/combo.js";
|
||||
|
||||
describe("detectRequiredCapabilities", () => {
|
||||
it("text-only -> empty", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: "hi" }] });
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("openai image_url -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: [
|
||||
{ type: "image_url", image_url: { url: "x" } },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("openai file -> pdf", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: [
|
||||
{ type: "file", file: { file_data: "data:application/pdf;base64,x" } },
|
||||
] }] });
|
||||
expect(r.has("pdf")).toBe(true);
|
||||
});
|
||||
|
||||
it("gemini inlineData image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("antigravity request.contents image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ request: { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "x" } },
|
||||
] }] } });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("web_search tool -> search", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: "q" }], tools: [
|
||||
{ type: "web_search" },
|
||||
] });
|
||||
expect(r.has("search")).toBe(true);
|
||||
});
|
||||
|
||||
it("responses input_image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ input: [{ role: "user", content: [
|
||||
{ type: "input_image", image_url: "x" },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderByCapabilities", () => {
|
||||
it("no required -> unchanged", () => {
|
||||
const models = ["a/x", "b/y"];
|
||||
expect(reorderByCapabilities(models, new Set())).toBe(models);
|
||||
});
|
||||
|
||||
it("floats vision-capable model to front, keeps fallback", () => {
|
||||
// deepseek-chat = no vision; claude-sonnet = vision
|
||||
const models = ["deepseek/deepseek-chat", "anthropic/claude-sonnet-4.6"];
|
||||
const out = reorderByCapabilities(models, new Set(["vision"]));
|
||||
expect(out[0]).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(out).toContain("deepseek/deepseek-chat"); // not dropped
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps order when no model matches", () => {
|
||||
const models = ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"];
|
||||
const out = reorderByCapabilities(models, new Set(["vision"]));
|
||||
expect(out).toBe(models);
|
||||
});
|
||||
|
||||
it("single model -> unchanged", () => {
|
||||
const models = ["a/x"];
|
||||
expect(reorderByCapabilities(models, new Set(["vision"]))).toBe(models);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,26 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
host: "localhost",
|
||||
"x-9r-real-ip": "10.204.111.34",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows loopback peer IP regardless of Host", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote rewritten public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/api/v1/chat/completions", { host: "router.example.com" }));
|
||||
|
||||
@@ -89,6 +109,25 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("rejects remote codex rewrite without API key", async () => {
|
||||
const response = await proxy(request("/codex/x", { host: "router.example.com" }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows remote codex rewrite with valid API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
const response = await proxy(request("/codex/x", {
|
||||
host: "router.example.com",
|
||||
authorization: "Bearer sk-valid",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
|
||||
});
|
||||
|
||||
it("allows remote public LLM API with valid bearer API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
|
||||
57
tests/unit/file-block-routing.test.js
Normal file
57
tests/unit/file-block-routing.test.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertOpenAIContentToParts } from "../../open-sse/translator/formats/gemini.js";
|
||||
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||
import { VALID_OPENAI_CONTENT_TYPES, OPENAI_BLOCK, CLAUDE_BLOCK } from "../../open-sse/translator/schema/index.js";
|
||||
|
||||
const PDF_DATA = "data:application/pdf;base64,JVBERi0xLjE=";
|
||||
const PNG_DATA = "data:image/png;base64,iVBORw0KGgo=";
|
||||
|
||||
describe("file/document block support", () => {
|
||||
it("schema: file is a valid openai content type", () => {
|
||||
expect(VALID_OPENAI_CONTENT_TYPES).toContain(OPENAI_BLOCK.FILE);
|
||||
expect(OPENAI_BLOCK.FILE).toBe("file");
|
||||
expect(CLAUDE_BLOCK.DOCUMENT).toBe("document");
|
||||
});
|
||||
|
||||
it("gemini: openai file block -> inlineData", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: PDF_DATA } },
|
||||
]);
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
expect(inline).toBeTruthy();
|
||||
expect(inline.inlineData.mime_type).toBe("application/pdf");
|
||||
expect(inline.inlineData.data).toBe("JVBERi0xLjE=");
|
||||
});
|
||||
|
||||
it("gemini: ignores non-data-uri file", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: "https://x/d.pdf" } },
|
||||
]);
|
||||
expect(parts.some((p) => p.inlineData)).toBe(false);
|
||||
});
|
||||
|
||||
it("claude: openai file (pdf) -> document block", () => {
|
||||
const out = openaiToClaudeRequest("claude-x", {
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "read" },
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: PDF_DATA } },
|
||||
] }],
|
||||
}, false);
|
||||
const blocks = out.messages[0].content;
|
||||
const doc = blocks.find((b) => b.type === "document");
|
||||
expect(doc).toBeTruthy();
|
||||
expect(doc.source.media_type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("claude: non-pdf file is dropped (not a document)", () => {
|
||||
const out = openaiToClaudeRequest("claude-x", {
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "read" },
|
||||
{ type: "file", file: { filename: "i.png", file_data: PNG_DATA } },
|
||||
] }],
|
||||
}, false);
|
||||
const blocks = out.messages[0].content;
|
||||
expect(blocks.some((b) => b.type === "document")).toBe(false);
|
||||
});
|
||||
});
|
||||
77
tests/unit/image-fetch-hardening.test.js
Normal file
77
tests/unit/image-fetch-hardening.test.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock DNS lookup so we control which host resolves to what IP.
|
||||
const lookupMock = vi.fn();
|
||||
vi.mock("node:dns/promises", () => ({ lookup: (...a) => lookupMock(...a) }));
|
||||
|
||||
import { fetchImageAsBase64 } from "../../open-sse/translator/concerns/image.js";
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function mockFetchOnce(bytes, ok = true) {
|
||||
const body = {
|
||||
getReader() {
|
||||
let sent = false;
|
||||
return {
|
||||
read: async () => sent ? { done: true } : (sent = true, { done: false, value: new Uint8Array(bytes) }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn(async () => ({ ok, body }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
lookupMock.mockResolvedValue({ address: "93.184.216.34" }); // public by default
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
describe("fetchImageAsBase64 hardening", () => {
|
||||
it("rejects non-http url", async () => {
|
||||
expect(await fetchImageAsBase64("ftp://x/y.png")).toBeNull();
|
||||
expect(await fetchImageAsBase64("data:image/png;base64,xx")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects private IP (10.x)", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "10.0.0.5" });
|
||||
expect(await fetchImageAsBase64("http://internal.example/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects cloud metadata 169.254.169.254", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "169.254.169.254" });
|
||||
expect(await fetchImageAsBase64("http://metadata/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects blocked hostname localhost", async () => {
|
||||
expect(await fetchImageAsBase64("http://localhost/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects IPv6 loopback", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "::1" });
|
||||
expect(await fetchImageAsBase64("http://x/y.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts valid PNG from public host", async () => {
|
||||
mockFetchOnce(PNG);
|
||||
const r = await fetchImageAsBase64("https://example.com/a.png");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.mimeType).toBe("image/png");
|
||||
expect(r.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disguised non-image payload (magic byte mismatch)", async () => {
|
||||
mockFetchOnce(Buffer.from("<?php system($_GET[c]); ?>"));
|
||||
expect(await fetchImageAsBase64("https://example.com/evil.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payload over size cap", async () => {
|
||||
mockFetchOnce(Buffer.alloc(1024));
|
||||
expect(await fetchImageAsBase64("https://example.com/big.png", { maxBytes: 100 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetch not ok", async () => {
|
||||
mockFetchOnce(PNG, false);
|
||||
expect(await fetchImageAsBase64("https://example.com/404.png")).toBeNull();
|
||||
});
|
||||
});
|
||||
108
tests/unit/modality-strip.test.js
Normal file
108
tests/unit/modality-strip.test.js
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const NO_VISION = { vision: false, audioInput: true, pdf: true };
|
||||
const NO_AUDIO = { vision: true, audioInput: false, pdf: true };
|
||||
const NO_PDF = { vision: true, audioInput: true, pdf: false };
|
||||
const ALL = { vision: true, audioInput: true, pdf: true };
|
||||
|
||||
describe("stripUnsupportedModalities", () => {
|
||||
it("fast-exits when model supports all modalities", () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }] };
|
||||
expect(stripUnsupportedModalities(body, FORMATS.OPENAI, ALL)).toBe(false);
|
||||
expect(body.messages[0].content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("openai: strips image when vision:false, leaves placeholder", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,xx" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_VISION);
|
||||
const types = body.messages[0].content.map((b) => b.type);
|
||||
expect(types).toContain("text");
|
||||
expect(types).not.toContain("image_url");
|
||||
expect(body.messages[0].content.some((b) => b.type === "text" && /image omitted/.test(b.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("openai: strips input_audio when audioInput:false", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "input_audio", input_audio: { data: "x", format: "wav" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_AUDIO);
|
||||
expect(body.messages[0].content.some((b) => b.type === "input_audio")).toBe(false);
|
||||
expect(body.messages[0].content.some((b) => /audio omitted/.test(b.text || ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("openai: strips file when pdf:false", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: "data:application/pdf;base64,x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_PDF);
|
||||
expect(body.messages[0].content.some((b) => b.type === "file")).toBe(false);
|
||||
});
|
||||
|
||||
it("openai: keeps image when vision:true", () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_AUDIO);
|
||||
expect(body.messages[0].content.some((b) => b.type === "image_url")).toBe(true);
|
||||
});
|
||||
|
||||
it("claude: strips image + document by capability", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "x" } },
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.CLAUDE, { vision: false, audioInput: true, pdf: false });
|
||||
const types = body.messages[0].content.map((b) => b.type);
|
||||
expect(types).not.toContain("image");
|
||||
expect(types).not.toContain("document");
|
||||
expect(types).toContain("text");
|
||||
});
|
||||
|
||||
it("gemini: strips inlineData image by mime when vision:false", () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ text: "hi" },
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.GEMINI, NO_VISION);
|
||||
expect(body.contents[0].parts.some((p) => p.inlineData)).toBe(false);
|
||||
expect(body.contents[0].parts.some((p) => /image omitted/.test(p.text || ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("gemini: keeps inlineData pdf when pdf:true, strips image when vision:false", () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "y" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.GEMINI, NO_VISION);
|
||||
const mimes = body.contents[0].parts.filter((p) => p.inlineData).map((p) => p.inlineData.mimeType);
|
||||
expect(mimes).toEqual(["application/pdf"]);
|
||||
});
|
||||
|
||||
it("antigravity: strips inside request.contents", () => {
|
||||
const body = { request: { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] } };
|
||||
stripUnsupportedModalities(body, FORMATS.ANTIGRAVITY, NO_VISION);
|
||||
expect(body.request.contents[0].parts.some((p) => p.inlineData)).toBe(false);
|
||||
});
|
||||
|
||||
it("responses: strips input_image when vision:false", () => {
|
||||
const body = { input: [{ role: "user", content: [
|
||||
{ type: "input_text", text: "hi" },
|
||||
{ type: "input_image", image_url: "data:image/png;base64,x" },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI_RESPONSES, NO_VISION);
|
||||
expect(body.input[0].content.some((b) => b.type === "input_image")).toBe(false);
|
||||
expect(body.input[0].content.some((b) => b.type === "input_text" && /image omitted/.test(b.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles missing/empty body safely", () => {
|
||||
expect(stripUnsupportedModalities(null, FORMATS.OPENAI, NO_VISION)).toBe(false);
|
||||
expect(stripUnsupportedModalities({}, FORMATS.OPENAI, null)).toBe(false);
|
||||
expect(stripUnsupportedModalities({ messages: [] }, FORMATS.OPENAI, NO_VISION)).toBe(true);
|
||||
});
|
||||
});
|
||||
58
tests/unit/prefetch-images.test.js
Normal file
58
tests/unit/prefetch-images.test.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/translator/concerns/image.js", async (orig) => {
|
||||
const actual = await orig();
|
||||
return {
|
||||
...actual,
|
||||
fetchImageAsBase64: vi.fn(async () => ({ url: "data:image/png;base64,QUJD", mimeType: "image/png" })),
|
||||
};
|
||||
});
|
||||
|
||||
import { prefetchRemoteImages } from "../../open-sse/translator/concerns/prefetch.js";
|
||||
import { fetchImageAsBase64 } from "../../open-sse/translator/concerns/image.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
beforeEach(() => { fetchImageAsBase64.mockClear(); });
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
describe("prefetchRemoteImages", () => {
|
||||
it("no-op for targets that accept remote URLs (openai)", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OPENAI);
|
||||
expect(n).toBe(0);
|
||||
expect(body.messages[0].content[0].image_url.url).toBe("https://x/a.png");
|
||||
});
|
||||
|
||||
it("openai source -> ollama target: converts remote URL to base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OLLAMA);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips data URI (already inline)", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,xx" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OLLAMA);
|
||||
expect(n).toBe(0);
|
||||
expect(fetchImageAsBase64).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gemini source -> gemini target: fileData URL -> inlineData base64", async () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ fileData: { mimeType: "image/png", fileUri: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.GEMINI, FORMATS.GEMINI);
|
||||
expect(n).toBe(1);
|
||||
expect(body.contents[0].parts[0].inlineData).toBeTruthy();
|
||||
expect(body.contents[0].parts[0].fileData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("claude source -> kiro target: source.url -> base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "image", source: { type: "url", url: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.CLAUDE, FORMATS.KIRO);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].source.type).toBe("base64");
|
||||
});
|
||||
});
|
||||
@@ -95,9 +95,9 @@ describe("RTK filters", () => {
|
||||
const input = makeFindOutput();
|
||||
const out = find(input);
|
||||
expect(out).toContain("55 files in 3 dirs:");
|
||||
expect(out).toContain("./src/a/ (30):");
|
||||
expect(out).toContain("./src/b/ (20):");
|
||||
expect(out).toContain("./ (5):");
|
||||
expect(out).toContain("./src/a/ (30)");
|
||||
expect(out).toContain("./src/b/ (20)");
|
||||
expect(out).toContain("./ (5)");
|
||||
expect(out.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user