mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Refactor
This commit is contained in:
@@ -12,34 +12,100 @@ export function parseDataUri(url) {
|
||||
return m ? { mimeType: m[1], base64: m[2] } : null;
|
||||
}
|
||||
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { MAX_IMAGE_BYTES, FETCH_TIMEOUT_MS, IMAGE_SIGNATURES, BLOCKED_HOSTS } from "../../config/mediaConfig.js";
|
||||
|
||||
// True if an IPv4/IPv6 address is private/reserved (SSRF target).
|
||||
function isPrivateIp(ip) {
|
||||
if (!ip) return true;
|
||||
// IPv6 loopback / unique-local / link-local
|
||||
if (ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80")) return true;
|
||||
// IPv4-mapped IPv6 (::ffff:a.b.c.d) -> extract tail
|
||||
const v4 = ip.includes(".") ? ip.split(":").pop() : ip;
|
||||
const parts = v4.split(".").map((n) => Number.parseInt(n, 10));
|
||||
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return ip.includes(":") ? false : true;
|
||||
const [a, b] = parts;
|
||||
if (a === 10 || a === 127 || a === 0) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 169 && b === 254) return true; // link-local + cloud metadata
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
||||
return false;
|
||||
}
|
||||
|
||||
// Resolve host and reject if it points at a private/blocked address (SSRF guard).
|
||||
async function assertPublicHost(hostname) {
|
||||
if (!hostname || BLOCKED_HOSTS.has(hostname.toLowerCase())) return false;
|
||||
try {
|
||||
const { address } = await lookup(hostname);
|
||||
return !isPrivateIp(address);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify buffer magic bytes match a known image signature; return its mime or null.
|
||||
function detectImageMime(buf) {
|
||||
for (const { sig, offset, mime, verifyWebp } of IMAGE_SIGNATURES) {
|
||||
if (buf.length < offset + sig.length) continue;
|
||||
let match = true;
|
||||
for (let i = 0; i < sig.length; i++) {
|
||||
if (buf[offset + i] !== sig[i]) { match = false; break; }
|
||||
}
|
||||
if (!match) continue;
|
||||
// WEBP: RIFF....WEBP — bytes 8..11 must be "WEBP".
|
||||
if (verifyWebp && !(buf.length >= 12 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50)) continue;
|
||||
return mime;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a remote image URL and return it as a base64 data URI.
|
||||
* Used when upstream providers (Codex, etc.) require inline base64 images
|
||||
* instead of remote URLs they cannot fetch.
|
||||
* Returns null if fetch fails.
|
||||
* Hardened against SSRF (private/metadata IPs), memory DoS (size cap),
|
||||
* and disguised non-image payloads (magic-byte verification).
|
||||
* Returns null on any failure or rejection.
|
||||
*
|
||||
* @param {string} imageUrl - HTTP(S) URL of the image
|
||||
* @param {object} options - { signal, timeoutMs }
|
||||
* @param {object} options - { signal, timeoutMs, maxBytes }
|
||||
* @returns {Promise<{url: string, mimeType: string}|null>}
|
||||
*/
|
||||
export async function fetchImageAsBase64(imageUrl, options = {}) {
|
||||
const { signal, timeoutMs = 10000 } = options;
|
||||
const { signal, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_IMAGE_BYTES } = options;
|
||||
if (!imageUrl || (!imageUrl.startsWith("http://") && !imageUrl.startsWith("https://"))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let url;
|
||||
try { url = new URL(imageUrl); } catch { return null; }
|
||||
if (!(await assertPublicHost(url.hostname))) return null;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = signal ? null : setTimeout(() => controller.abort(), timeoutMs);
|
||||
const fetchSignal = signal || controller.signal;
|
||||
|
||||
try {
|
||||
const response = await fetch(imageUrl, { signal: fetchSignal });
|
||||
if (!response.ok) return null;
|
||||
// redirect:"manual" prevents a public URL redirecting to a private one (SSRF bypass).
|
||||
const response = await fetch(imageUrl, { signal: fetchSignal, redirect: "manual" });
|
||||
if (!response.ok || !response.body) return null;
|
||||
|
||||
const mimeType = response.headers.get("Content-Type") || "image/jpeg";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(arrayBuffer).toString("base64");
|
||||
return { url: `data:${mimeType};base64,${base64}`, mimeType };
|
||||
// Stream-read with a hard byte cap to avoid loading huge payloads into memory.
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.length;
|
||||
if (total > maxBytes) { try { await reader.cancel(); } catch { /* ignore */ } return null; }
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
|
||||
const mimeType = detectImageMime(buf);
|
||||
if (!mimeType) return null; // not a recognized image — reject disguised payloads
|
||||
|
||||
return { url: `data:${mimeType};base64,${buf.toString("base64")}`, mimeType };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Strip multimodal content blocks a model cannot read, BEFORE translation.
|
||||
// Driven by getCapabilitiesForModel: vision/audioInput/pdf. Replaces removed
|
||||
// media with a short text placeholder so messages never become empty.
|
||||
import { FORMATS } from "../formats.js";
|
||||
|
||||
// Placeholder text inserted where a media block was removed.
|
||||
// Current turn: explain the active model can't read what the user just sent.
|
||||
const PLACEHOLDER_CURRENT = {
|
||||
vision: "[image omitted: model has no vision support]",
|
||||
audioInput: "[audio omitted: model has no audio support]",
|
||||
pdf: "[file omitted: model has no document support]",
|
||||
};
|
||||
// Earlier turns: neutral (a combo may route to a different model each turn).
|
||||
const PLACEHOLDER_PREV = {
|
||||
vision: "[Previous image omitted from context.]",
|
||||
audioInput: "[Previous audio omitted from context.]",
|
||||
pdf: "[Previous file omitted from context.]",
|
||||
};
|
||||
const ph = (cap, isLast) => (isLast ? PLACEHOLDER_CURRENT : PLACEHOLDER_PREV)[cap];
|
||||
|
||||
// Map gemini inlineData/fileData mime prefix -> capability it requires.
|
||||
function capForMime(mime) {
|
||||
if (typeof mime !== "string") return null;
|
||||
if (mime.startsWith("image/")) return "vision";
|
||||
if (mime.startsWith("audio/")) return "audioInput";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
return null;
|
||||
}
|
||||
|
||||
// OpenAI chat content block -> required capability (null = plain text/other, keep).
|
||||
function capForOpenAIBlock(block) {
|
||||
const t = block?.type;
|
||||
if (t === "image_url" || t === "image") return "vision";
|
||||
if (t === "input_audio" || t === "audio_url") return "audioInput";
|
||||
if (t === "file") return "pdf";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Claude content block -> required capability.
|
||||
function capForClaudeBlock(block) {
|
||||
const t = block?.type;
|
||||
if (t === "image") return "vision";
|
||||
if (t === "document") return "pdf";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Filter an array of content blocks; drop unsupported, inject one placeholder per kind.
|
||||
// isLast = block belongs to the current user turn (picks the explanatory placeholder).
|
||||
function filterBlocks(blocks, capOf, caps, removed, isLast) {
|
||||
const out = [];
|
||||
for (const block of blocks) {
|
||||
const cap = capOf(block);
|
||||
if (cap && caps[cap] === false) { removed.add(cap); continue; }
|
||||
out.push(block);
|
||||
}
|
||||
for (const cap of removed) out.push({ type: "text", text: ph(cap, isLast) });
|
||||
return out;
|
||||
}
|
||||
|
||||
// OpenAI / OpenAI-compatible chat messages[].content[].
|
||||
function stripOpenAI(body, caps) {
|
||||
if (!Array.isArray(body.messages)) return;
|
||||
const last = body.messages.length - 1;
|
||||
body.messages.forEach((msg, i) => {
|
||||
if (!Array.isArray(msg.content)) return;
|
||||
const removed = new Set();
|
||||
msg.content = filterBlocks(msg.content, capForOpenAIBlock, caps, removed, i === last);
|
||||
});
|
||||
}
|
||||
|
||||
// Claude messages[].content[].
|
||||
function stripClaude(body, caps) {
|
||||
if (!Array.isArray(body.messages)) return;
|
||||
const last = body.messages.length - 1;
|
||||
body.messages.forEach((msg, i) => {
|
||||
if (!Array.isArray(msg.content)) return;
|
||||
const removed = new Set();
|
||||
msg.content = filterBlocks(msg.content, capForClaudeBlock, caps, removed, i === last);
|
||||
});
|
||||
}
|
||||
|
||||
// OpenAI Responses input[].content[] (input_image / input_file).
|
||||
function stripResponses(body, caps) {
|
||||
if (!Array.isArray(body.input)) return;
|
||||
const last = body.input.length - 1;
|
||||
body.input.forEach((item, i) => {
|
||||
if (!Array.isArray(item.content)) return;
|
||||
const removed = new Set();
|
||||
item.content = item.content.filter((b) => {
|
||||
const cap = b?.type === "input_image" ? "vision" : b?.type === "input_file" ? "pdf" : null;
|
||||
if (cap && caps[cap] === false) { removed.add(cap); return false; }
|
||||
return true;
|
||||
});
|
||||
for (const cap of removed) item.content.push({ type: "input_text", text: ph(cap, i === last) });
|
||||
});
|
||||
}
|
||||
|
||||
// Gemini / gemini-cli contents[].parts[] (inlineData / fileData by mime).
|
||||
function stripGeminiParts(contents, caps) {
|
||||
if (!Array.isArray(contents)) return;
|
||||
const last = contents.length - 1;
|
||||
contents.forEach((c, i) => {
|
||||
if (!Array.isArray(c.parts)) return;
|
||||
const removed = new Set();
|
||||
c.parts = c.parts.filter((p) => {
|
||||
const mime = p?.inlineData?.mimeType || p?.fileData?.mimeType;
|
||||
const cap = capForMime(mime);
|
||||
if (cap && caps[cap] === false) { removed.add(cap); return false; }
|
||||
return true;
|
||||
});
|
||||
for (const cap of removed) c.parts.push({ text: ph(cap, i === last) });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove media blocks the model can't read, in-place on the source-format body.
|
||||
* @param {object} body - request body (source format)
|
||||
* @param {string} sourceFormat - one of FORMATS
|
||||
* @param {object} caps - capabilities from getCapabilitiesForModel
|
||||
* @returns {boolean} true if anything was stripped-eligible (cap false for some modality)
|
||||
*/
|
||||
export function stripUnsupportedModalities(body, sourceFormat, caps) {
|
||||
if (!body || !caps) return false;
|
||||
// Fast exit: model supports everything we'd strip.
|
||||
if (caps.vision !== false && caps.audioInput !== false && caps.pdf !== false) return false;
|
||||
|
||||
switch (sourceFormat) {
|
||||
case FORMATS.OPENAI:
|
||||
case FORMATS.OLLAMA:
|
||||
case FORMATS.KIRO:
|
||||
case FORMATS.CURSOR:
|
||||
case FORMATS.COMMANDCODE:
|
||||
stripOpenAI(body, caps);
|
||||
break;
|
||||
case FORMATS.CLAUDE:
|
||||
stripClaude(body, caps);
|
||||
break;
|
||||
case FORMATS.OPENAI_RESPONSES:
|
||||
case FORMATS.OPENAI_RESPONSE:
|
||||
case FORMATS.CODEX:
|
||||
stripResponses(body, caps);
|
||||
break;
|
||||
case FORMATS.GEMINI:
|
||||
case FORMATS.GEMINI_CLI:
|
||||
case FORMATS.VERTEX:
|
||||
stripGeminiParts(body.contents, caps);
|
||||
break;
|
||||
case FORMATS.ANTIGRAVITY:
|
||||
stripGeminiParts(body?.request?.contents, caps);
|
||||
break;
|
||||
default:
|
||||
stripOpenAI(body, caps);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Pre-fetch remote image URLs into base64 BEFORE translation, for target
|
||||
// formats whose upstream providers cannot fetch remote URLs themselves
|
||||
// (they require inline base64). Runs on the source-format body.
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { fetchImageAsBase64, parseDataUri } from "./image.js";
|
||||
|
||||
// Targets that require inline base64 images (cannot accept remote URLs).
|
||||
const TARGETS_NEED_BASE64 = new Set([
|
||||
FORMATS.GEMINI, FORMATS.GEMINI_CLI, FORMATS.VERTEX,
|
||||
FORMATS.ANTIGRAVITY, FORMATS.OLLAMA, FORMATS.KIRO,
|
||||
]);
|
||||
|
||||
function isRemoteUrl(url) {
|
||||
return typeof url === "string" && (url.startsWith("http://") || url.startsWith("https://"));
|
||||
}
|
||||
|
||||
// Collect {get,set} accessors for every remote image URL in a source body.
|
||||
function collectImageRefs(body, sourceFormat) {
|
||||
const refs = [];
|
||||
const pushOpenAI = (messages) => {
|
||||
for (const msg of messages || []) {
|
||||
if (!Array.isArray(msg.content)) continue;
|
||||
for (const block of msg.content) {
|
||||
if (block?.type === "image_url") {
|
||||
const url = typeof block.image_url === "string" ? block.image_url : block.image_url?.url;
|
||||
if (isRemoteUrl(url)) refs.push({ get: () => url, set: (v) => {
|
||||
if (typeof block.image_url === "string") block.image_url = v; else block.image_url.url = v;
|
||||
} });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const pushGemini = (contents) => {
|
||||
for (const c of contents || []) {
|
||||
for (const p of c.parts || []) {
|
||||
const uri = p?.fileData?.fileUri;
|
||||
if (isRemoteUrl(uri)) refs.push({ get: () => uri, part: p });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
switch (sourceFormat) {
|
||||
case FORMATS.OPENAI:
|
||||
case FORMATS.OLLAMA:
|
||||
case FORMATS.KIRO:
|
||||
case FORMATS.CURSOR:
|
||||
case FORMATS.COMMANDCODE:
|
||||
pushOpenAI(body.messages);
|
||||
break;
|
||||
case FORMATS.CLAUDE:
|
||||
for (const msg of body.messages || []) {
|
||||
if (!Array.isArray(msg.content)) continue;
|
||||
for (const block of msg.content) {
|
||||
if (block?.type === "image" && block.source?.type === "url" && isRemoteUrl(block.source.url)) {
|
||||
refs.push({ get: () => block.source.url, claudeBlock: block });
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case FORMATS.GEMINI:
|
||||
case FORMATS.GEMINI_CLI:
|
||||
case FORMATS.VERTEX:
|
||||
pushGemini(body.contents);
|
||||
break;
|
||||
case FORMATS.ANTIGRAVITY:
|
||||
pushGemini(body?.request?.contents);
|
||||
break;
|
||||
default:
|
||||
pushOpenAI(body.messages);
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace remote image URLs with base64 data when the target needs inline data.
|
||||
* No-op when target accepts remote URLs (e.g. openai, claude) or body has none.
|
||||
* @returns {Promise<number>} count of images converted
|
||||
*/
|
||||
export async function prefetchRemoteImages(body, sourceFormat, targetFormat, options = {}) {
|
||||
if (!body || !TARGETS_NEED_BASE64.has(targetFormat)) return 0;
|
||||
const refs = collectImageRefs(body, sourceFormat);
|
||||
if (!refs.length) return 0;
|
||||
|
||||
let converted = 0;
|
||||
for (const ref of refs) {
|
||||
const url = ref.get();
|
||||
if (parseDataUri(url)) continue; // already inline
|
||||
const fetched = await fetchImageAsBase64(url, options);
|
||||
if (!fetched) continue;
|
||||
if (ref.set) ref.set(fetched.url);
|
||||
else if (ref.part) { delete ref.part.fileData; ref.part.inlineData = { mimeType: fetched.mimeType, data: fetched.url.split(",")[1] }; }
|
||||
else if (ref.claudeBlock) ref.claudeBlock.source = { type: "base64", media_type: fetched.mimeType, data: fetched.url.split(",")[1] };
|
||||
converted++;
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
@@ -6,3 +6,19 @@ export function reasoningDelta(text, withRole = false) {
|
||||
? { role: ROLE.ASSISTANT, reasoning_content: text }
|
||||
: { reasoning_content: text };
|
||||
}
|
||||
|
||||
// Extract reasoning text from a streamed OpenAI-compatible delta across vendor shapes:
|
||||
// - reasoning_content (GLM, Qwen, DeepSeek, Kimi, Step, Hunyuan)
|
||||
// - reasoning (some compat layers)
|
||||
// - reasoning_details[] (MiniMax reasoning_split=true): [{ text|content }]
|
||||
// Returns concatenated reasoning string, or "" when none.
|
||||
export function extractReasoningText(delta) {
|
||||
if (!delta || typeof delta !== "object") return "";
|
||||
if (typeof delta.reasoning_content === "string" && delta.reasoning_content) return delta.reasoning_content;
|
||||
if (typeof delta.reasoning === "string" && delta.reasoning) return delta.reasoning;
|
||||
const details = delta.reasoning_details;
|
||||
if (Array.isArray(details)) {
|
||||
return details.map((d) => (typeof d === "string" ? d : d?.text || d?.content || "")).join("");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,26 +1,50 @@
|
||||
// Concern: reasoning_effort ↔ provider-native thinking config.
|
||||
// Each provider expresses "how much to think" differently — centralize the maps here.
|
||||
// Streaming reasoning delta shape lives in reasoning.js; this file is request-side config only.
|
||||
// Central source of truth for level↔budget maps (web-standard values).
|
||||
// Provider-specific application lives in thinkingUnified.js; this file is maps-only.
|
||||
|
||||
// OpenAI reasoning_effort → Claude thinking.budget_tokens
|
||||
const EFFORT_TO_BUDGET = { none: 0, low: 4096, medium: 8192, high: 16384, xhigh: 32768 };
|
||||
// Discrete effort levels, ordered low→high.
|
||||
export const EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
|
||||
// Returns budget_tokens for a reasoning_effort, or undefined if unknown effort.
|
||||
// 0 means "no thinking" (caller skips enabling); undefined means "effort not recognized".
|
||||
// Web-standard level → budget_tokens (Anthropic/Gemini docs).
|
||||
export const LEVEL_TO_BUDGET = {
|
||||
none: 0,
|
||||
minimal: 512,
|
||||
low: 1024,
|
||||
medium: 8192,
|
||||
high: 24576,
|
||||
xhigh: 32768,
|
||||
max: 128000,
|
||||
};
|
||||
|
||||
// Returns budget_tokens for an effort level, or undefined if unknown.
|
||||
// 0 means "no thinking"; undefined means "effort not recognized".
|
||||
export function effortToBudget(effort) {
|
||||
if (!effort) return undefined;
|
||||
return EFFORT_TO_BUDGET[String(effort).toLowerCase()];
|
||||
return LEVEL_TO_BUDGET[String(effort).toLowerCase()];
|
||||
}
|
||||
|
||||
// OpenAI reasoning_effort → Gemini thinkingLevel (gemini-3 enum: minimal|low|medium|high).
|
||||
// Gemini 3 cannot fully disable thinking; "none"/"off" map to "minimal" (closest to off).
|
||||
// Gemini 3 cannot fully disable thinking; "none"/"off" map to "minimal".
|
||||
export function effortToThinkingLevel(effort) {
|
||||
const e = String(effort).toLowerCase().trim();
|
||||
return e === "none" || e === "off" ? "minimal" : e;
|
||||
if (e === "none" || e === "off") return "minimal";
|
||||
if (e === "xhigh" || e === "max") return "high";
|
||||
return e;
|
||||
}
|
||||
|
||||
// Numeric budget → nearest discrete level (reverse map via thresholds).
|
||||
// Returns null when budget <= 0 (no reasoning).
|
||||
export function budgetToLevel(budget) {
|
||||
const b = Number(budget);
|
||||
if (!b || b <= 0) return null;
|
||||
if (b <= 768) return "minimal";
|
||||
if (b <= 4096) return "low";
|
||||
if (b <= 16384) return "medium";
|
||||
if (b <= 28672) return "high";
|
||||
return "xhigh";
|
||||
}
|
||||
|
||||
// Gemini thinkingBudget (numeric) → OpenAI reasoning_effort (antigravity reverse map).
|
||||
// Returns null when budget <= 0 (no reasoning).
|
||||
export function budgetToEffort(budget) {
|
||||
if (!budget || budget <= 0) return null;
|
||||
if (budget <= 2048) return "low";
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Unified thinking normalization: extract client intent → apply provider-native format.
|
||||
// Config-driven: thinking format/limits come from capabilities.js + registry transport,
|
||||
// never hardcoded per-model here. See .docs/thinking/plan.md MATRIX VI-A.
|
||||
|
||||
import { getCapabilitiesForModel } from "../../providers/capabilities.js";
|
||||
import { PROVIDERS } from "../../providers/index.js";
|
||||
import { LEVEL_TO_BUDGET, budgetToLevel, effortToBudget } from "./thinking.js";
|
||||
|
||||
// Map a target wire-format to its native thinking format (when capability has none).
|
||||
const FORMAT_TO_NATIVE = {
|
||||
openai: "openai",
|
||||
"openai-responses": "openai",
|
||||
"openai-response": "openai",
|
||||
codex: "openai",
|
||||
claude: "claude-budget",
|
||||
gemini: "gemini-budget",
|
||||
"gemini-cli": "gemini-budget",
|
||||
vertex: "gemini-budget",
|
||||
antigravity: "gemini-budget",
|
||||
kiro: "kiro",
|
||||
};
|
||||
|
||||
// Parse model-name suffix "model(value)" → { cleanModel, override }.
|
||||
// value: level name (high) | number (8192) | auto | none. null override when absent.
|
||||
export function parseSuffix(model) {
|
||||
if (typeof model !== "string") return { cleanModel: model, override: null };
|
||||
const m = model.match(/^(.*)\(([^()]+)\)\s*$/);
|
||||
if (!m) return { cleanModel: model, override: null };
|
||||
const cleanModel = m[1].trim();
|
||||
const raw = m[2].trim().toLowerCase();
|
||||
if (raw === "none" || raw === "off") return { cleanModel, override: { mode: "none" } };
|
||||
if (raw === "auto") return { cleanModel, override: { mode: "auto" } };
|
||||
if (/^\d+$/.test(raw)) return { cleanModel, override: { mode: "budget", budget: Number(raw) } };
|
||||
if (LEVEL_TO_BUDGET[raw] !== undefined) return { cleanModel, override: { mode: "level", level: raw } };
|
||||
return { cleanModel, override: null };
|
||||
}
|
||||
|
||||
// Extract unified thinking intent from a request body (post-translation, mixed shapes).
|
||||
// Returns { mode, budget?, level? } or null when no thinking intent present.
|
||||
export function extractThinking(body) {
|
||||
if (!body || typeof body !== "object") return null;
|
||||
|
||||
// Claude shape
|
||||
const t = body.thinking;
|
||||
if (t && typeof t === "object") {
|
||||
if (t.type === "disabled") return { mode: "none" };
|
||||
if (t.type === "adaptive" || t.type === "enabled") {
|
||||
const budget = Number(t.budget_tokens);
|
||||
if (Number.isFinite(budget) && budget > 0) return { mode: "budget", budget };
|
||||
return { mode: "auto" };
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI chat / Responses shape
|
||||
const effort = body.reasoning_effort ?? (typeof body.reasoning === "object" ? body.reasoning?.effort : null);
|
||||
if (typeof effort === "string" && effort) {
|
||||
const e = effort.toLowerCase();
|
||||
if (e === "none" || e === "off") return { mode: "none" };
|
||||
if (e === "auto") return { mode: "auto" };
|
||||
return { mode: "level", level: e };
|
||||
}
|
||||
|
||||
// Gemini shape (top-level, generationConfig, or request envelope)
|
||||
const tc = body.thinkingConfig || body.generationConfig?.thinkingConfig || body.request?.generationConfig?.thinkingConfig;
|
||||
if (tc && typeof tc === "object") {
|
||||
if (typeof tc.thinkingLevel === "string") return { mode: "level", level: tc.thinkingLevel.toLowerCase() };
|
||||
const tb = Number(tc.thinkingBudget);
|
||||
if (Number.isFinite(tb)) {
|
||||
if (tb === 0) return { mode: "none" };
|
||||
if (tb < 0) return { mode: "auto" };
|
||||
return { mode: "budget", budget: tb };
|
||||
}
|
||||
}
|
||||
|
||||
// Qwen shape
|
||||
if (body.enable_thinking === false) return { mode: "none" };
|
||||
if (body.enable_thinking === true) {
|
||||
const tb = Number(body.thinking_budget);
|
||||
if (Number.isFinite(tb) && tb > 0) return { mode: "budget", budget: tb };
|
||||
return { mode: "auto" };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Capture thinking intent from a body. Alias of extractThinking, named for clarity
|
||||
// at the call-site where intent is snapshotted before format translation.
|
||||
export const captureThinking = extractThinking;
|
||||
|
||||
// Resolve thinking format: provider override > capability > derive(targetFormat).
|
||||
function resolveFormat(targetFormat, model, provider) {
|
||||
const providerFmt = provider ? PROVIDERS[provider]?.thinkingFormat : null;
|
||||
if (providerFmt) return providerFmt;
|
||||
const caps = getCapabilitiesForModel(provider, model);
|
||||
if (caps.thinkingFormat) return caps.thinkingFormat;
|
||||
return FORMAT_TO_NATIVE[targetFormat] || "openai";
|
||||
}
|
||||
|
||||
// Convert unified config to a budget number (for budget-based formats).
|
||||
function toBudget(cfg, range) {
|
||||
let budget;
|
||||
if (cfg.mode === "budget") budget = cfg.budget;
|
||||
else if (cfg.mode === "level") budget = effortToBudget(cfg.level);
|
||||
else if (cfg.mode === "auto") return -1;
|
||||
if (!Number.isFinite(budget)) return undefined;
|
||||
if (range) {
|
||||
if (range.min != null && budget < range.min) budget = range.min;
|
||||
if (range.max != null && budget > range.max) budget = range.max;
|
||||
}
|
||||
return budget;
|
||||
}
|
||||
|
||||
// Convert unified config to a discrete level string.
|
||||
function toLevel(cfg) {
|
||||
if (cfg.mode === "level") return cfg.level;
|
||||
if (cfg.mode === "budget") return budgetToLevel(cfg.budget) || "medium";
|
||||
if (cfg.mode === "auto") return "auto";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Gemini nests thinkingConfig under generationConfig. gemini-cli / antigravity wrap
|
||||
// the whole request in a { request: { generationConfig } } envelope — target the
|
||||
// envelope's generationConfig when present, else the top-level one.
|
||||
function setGeminiThinking(body, tc) {
|
||||
const gc = body.request?.generationConfig
|
||||
? body.request.generationConfig
|
||||
: (body.generationConfig && typeof body.generationConfig === "object"
|
||||
? body.generationConfig
|
||||
: (body.generationConfig = {}));
|
||||
gc.thinkingConfig = tc;
|
||||
}
|
||||
|
||||
// Strip every known thinking field from a body (used before re-applying / when unsupported).
|
||||
function stripAll(body) {
|
||||
delete body.thinking;
|
||||
delete body.reasoning_effort;
|
||||
delete body.reasoning;
|
||||
delete body.thinkingConfig;
|
||||
delete body.enable_thinking;
|
||||
delete body.thinking_budget;
|
||||
delete body.output_config;
|
||||
if (body.generationConfig) delete body.generationConfig.thinkingConfig;
|
||||
if (body.request?.generationConfig) delete body.request.generationConfig.thinkingConfig;
|
||||
}
|
||||
|
||||
// Apply unified thinking config to body in the resolved provider-native format.
|
||||
function applyFormat(fmt, body, cfg, caps) {
|
||||
const none = cfg.mode === "none";
|
||||
const canDisable = caps.thinkingCanDisable !== false;
|
||||
// Model cannot disable thinking → clamp "none" to minimal effort instead.
|
||||
const eff = none && !canDisable ? { mode: "level", level: "minimal" } : cfg;
|
||||
|
||||
switch (fmt) {
|
||||
case "openai": {
|
||||
if (none && canDisable) { body.reasoning_effort = "none"; break; }
|
||||
const level = toLevel(eff);
|
||||
if (level) body.reasoning_effort = level;
|
||||
break;
|
||||
}
|
||||
case "claude-adaptive": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
const level = toLevel(eff);
|
||||
body.output_config = { effort: level === "xhigh" ? "high" : level };
|
||||
break;
|
||||
}
|
||||
case "claude-budget": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
const budget = toBudget(eff, caps.thinkingRange);
|
||||
body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 };
|
||||
break;
|
||||
}
|
||||
case "gemini-level": {
|
||||
const level = none ? "minimal" : (toLevel(eff) || "high");
|
||||
setGeminiThinking(body, { thinkingLevel: level, includeThoughts: level !== "minimal" });
|
||||
break;
|
||||
}
|
||||
case "gemini-budget": {
|
||||
if (none && canDisable) { setGeminiThinking(body, { thinkingBudget: 0, includeThoughts: false }); break; }
|
||||
const budget = toBudget(eff, caps.thinkingRange);
|
||||
setGeminiThinking(body, { thinkingBudget: budget ?? -1, includeThoughts: true });
|
||||
break;
|
||||
}
|
||||
case "zai": {
|
||||
// Z.ai ignores thinking.disabled → must use enable_thinking:false to turn off.
|
||||
if (none && canDisable) { body.enable_thinking = false; delete body.thinking; break; }
|
||||
body.thinking = { type: "enabled" };
|
||||
break;
|
||||
}
|
||||
case "qwen": {
|
||||
if (none && canDisable) { body.enable_thinking = false; break; }
|
||||
body.enable_thinking = true;
|
||||
const budget = toBudget(eff, caps.thinkingRange);
|
||||
if (Number.isFinite(budget) && budget > 0) body.thinking_budget = budget;
|
||||
break;
|
||||
}
|
||||
case "deepseek": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
body.thinking = { type: "enabled" };
|
||||
// DeepSeek: low/medium→high, xhigh/max→max.
|
||||
const level = toLevel(eff);
|
||||
body.reasoning_effort = level === "xhigh" || level === "max" ? "max" : "high";
|
||||
break;
|
||||
}
|
||||
case "kimi": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
const level = toLevel(eff);
|
||||
if (level) body.reasoning_effort = level === "max" ? "high" : level;
|
||||
break;
|
||||
}
|
||||
case "minimax": {
|
||||
// M3 adaptive; M2.x cannot disable (handled via canDisable clamp).
|
||||
body.thinking = { type: none && canDisable ? "disabled" : "adaptive" };
|
||||
break;
|
||||
}
|
||||
case "hunyuan": {
|
||||
if (none && canDisable) { body.thinking = { type: "disabled" }; break; }
|
||||
const budget = toBudget(eff, caps.thinkingRange);
|
||||
body.thinking = budget === -1 ? { type: "enabled" } : { type: "enabled", budget_tokens: budget || 8192 };
|
||||
break;
|
||||
}
|
||||
case "step": {
|
||||
if (none && canDisable) break;
|
||||
const level = toLevel(eff);
|
||||
if (level) body.reasoning_effort = level === "xhigh" || level === "max" ? "high" : level;
|
||||
break;
|
||||
}
|
||||
case "kiro":
|
||||
// Kiro thinking handled via system-tag injection in openai-to-kiro.js; no body field here.
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Public entry: normalize thinking for the resolved target format.
|
||||
// Mutates and returns body. No-op when model has no reasoning capability.
|
||||
// `intent` is a pre-captured config (from captureThinking on the original body);
|
||||
// falls back to extracting from the current body when omitted.
|
||||
export function applyThinking(targetFormat, model, body, provider = null, intent = undefined) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
|
||||
const { cleanModel, override } = parseSuffix(model);
|
||||
const cfg = override || intent || extractThinking(body);
|
||||
const caps = getCapabilitiesForModel(provider, cleanModel);
|
||||
|
||||
// Model cannot reason → strip any stray thinking fields.
|
||||
if (!caps.reasoning) {
|
||||
stripAll(body);
|
||||
return body;
|
||||
}
|
||||
if (!cfg) return body;
|
||||
|
||||
const fmt = resolveFormat(targetFormat, cleanModel, provider);
|
||||
stripAll(body);
|
||||
applyFormat(fmt, body, cfg, caps);
|
||||
return body;
|
||||
}
|
||||
Reference in New Issue
Block a user