This commit is contained in:
decolua
2026-06-15 18:18:04 +07:00
parent 8ab5af0052
commit b282f05549
66 changed files with 2328 additions and 213 deletions

View File

@@ -0,0 +1,27 @@
// Central config for remote-media fetching security limits.
// Max bytes accepted from a remote image fetch (reject larger to prevent memory DoS).
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10MB
// Fetch timeout for remote media.
export const FETCH_TIMEOUT_MS = 10000;
// Magic-byte signatures -> mime. Each entry: { sig:[bytes], offset, mime }.
// offset>0 for containers where the signature is not at byte 0 (e.g. webp).
export const IMAGE_SIGNATURES = [
{ sig: [0x89, 0x50, 0x4e, 0x47], offset: 0, mime: "image/png" },
{ sig: [0xff, 0xd8, 0xff], offset: 0, mime: "image/jpeg" },
{ sig: [0x47, 0x49, 0x46, 0x38], offset: 0, mime: "image/gif" },
{ sig: [0x52, 0x49, 0x46, 0x46], offset: 0, mime: "image/webp", verifyWebp: true },
{ sig: [0x42, 0x4d], offset: 0, mime: "image/bmp" },
];
// Hostnames/IPs that must never be fetched (SSRF guard for loopback + cloud metadata).
export const BLOCKED_HOSTS = new Set([
"localhost",
"127.0.0.1",
"0.0.0.0",
"::1",
"169.254.169.254", // AWS/GCP/Azure IMDS
"metadata.google.internal",
]);

View File

@@ -21,6 +21,9 @@ import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.j
import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
/**
* Core chat handler - shared between SSE and Worker
@@ -91,6 +94,19 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Expose raw client headers to translators/executors for session-id resolution
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
if (!passthrough) {
const caps = getCapabilitiesForModel(provider, model);
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
}
// Convert remote image URLs to base64 for targets that can't fetch URLs.
try {
const n = await prefetchRemoteImages(body, sourceFormat, targetFormat, { signal: undefined });
if (n > 0) log?.debug?.("MODALITY", `prefetched ${n} remote image(s) for ${targetFormat}`);
} catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); }
}
let translatedBody;
let toolNameMap;
if (passthrough) {

View File

@@ -41,6 +41,11 @@ export const DEFAULT_CAPABILITIES = {
search: false, // built-in web search tool / grounding
tools: true, // function / tool calling
reasoning: false, // thinking / reasoning
// thinking wire format (only meaningful when reasoning:true). null → derive from transport.format.
// enum: openai|claude-adaptive|claude-budget|gemini-level|gemini-budget|zai|qwen|deepseek|kimi|minimax|hunyuan|step
thinkingFormat: null,
thinkingCanDisable: true, // false → model cannot turn thinking off (clamp to min instead of disable)
thinkingRange: null, // { min, max } for budget formats; null = no clamp
// limits (tokens)
contextWindow: 200000,
maxOutput: 64000,
@@ -51,22 +56,22 @@ export const DEFAULT_CAPABILITIES = {
* otherwise mis-match. Only declare deltas vs DEFAULT.
*/
export const MODEL_CAPABILITIES = {
// Claude 4.6/4.7 have 1M context (override generic claude pattern at 200k)
"claude-opus-4.6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.7": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 64000 },
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 64000 },
// Claude 4.6/4.7 have 1M context + adaptive thinking (override generic claude pattern)
"claude-opus-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4.7": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-opus-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 128000 },
"claude-sonnet-4.6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
"claude-sonnet-4-6": { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive", contextWindow: 1000000, maxOutput: 64000 },
// Gemini image-gen / OpenAI image / xai image variants
"gpt-image-1": { imageOutput: true, tools: false },
// GLM vision variant (text GLM has no vision)
"glm-4.6v": { vision: true, reasoning: true, contextWindow: 128000 },
"glm-4.6v": { vision: true, reasoning: true, thinkingFormat: "zai", contextWindow: 128000 },
// Qwen plain coder/text (no vision) — registry "vision-model" / "coder-model" aliases
"vision-model": { vision: true, reasoning: true, contextWindow: 1000000 },
"coder-model": { reasoning: true, contextWindow: 1000000 },
"vision-model": { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
"coder-model": { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 },
};
/**
@@ -81,19 +86,25 @@ export const PROVIDER_CAPABILITIES = {};
* a broad family pattern swallowing an exception (e.g. glm-4.6v vs glm-5).
*/
export const PATTERN_CAPABILITIES = [
// ── Claude (4.x+ = vision + thinking + web search) ───────────────
{ pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*fable*", caps: { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude*mythos*", caps: { vision: true, reasoning: true, search: true, contextWindow: 1000000, maxOutput: 128000 } },
// ── Claude (4.6+ = adaptive thinking; older/haiku = budget) ──────
{ pattern: "*claude*opus-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*opus-4.8*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*sonnet-4.6*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*sonnet-4.7*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-adaptive" } },
{ pattern: "*claude*haiku*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
{ pattern: "*claude*opus*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
{ pattern: "*claude*sonnet*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
{ pattern: "*claude*fable*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude*mythos*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget", contextWindow: 1000000, maxOutput: 128000 } },
{ pattern: "*claude-3*", caps: { vision: true } },
{ pattern: "*claude*", caps: { vision: true, reasoning: true, search: true } },
{ pattern: "*claude*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "claude-budget" } },
// ── Gemini (all 2.0+ multimodal + google_search grounding, 1M ctx) ─
{ pattern: "*gemini*image*", caps: { vision: true, imageOutput: true, contextWindow: 1048576 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, search: true, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-3*pro*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65535 } },
{ pattern: "*gemini-3*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-level", thinkingCanDisable: false, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2.5*", caps: { vision: true, audioInput: true, videoInput: true, reasoning: true, search: true, thinkingFormat: "gemini-budget", thinkingRange: { min: 0, max: 24576 }, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini-2*", caps: { vision: true, audioInput: true, videoInput: true, search: true, contextWindow: 1048576, maxOutput: 65536 } },
{ pattern: "*gemini*", caps: { vision: true, search: true, contextWindow: 1048576 } },
{ pattern: "*gemma*", caps: { vision: true, contextWindow: 128000 } },
@@ -101,58 +112,59 @@ export const PATTERN_CAPABILITIES = [
// ── OpenAI GPT-5.x (vision + thinking + web search) ──────────────
{ pattern: "*gpt-5*image*", caps: { imageOutput: true } },
{ pattern: "*gpt-5*codex*", caps: { reasoning: true, search: true, contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-5*", caps: { vision: true, reasoning: true, search: true, contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-5*codex*", caps: { reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-5*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 400000, maxOutput: 128000 } },
{ pattern: "*gpt-4o*", caps: { vision: true, search: true, contextWindow: 128000, maxOutput: 16384 } },
{ pattern: "*gpt-4.1*", caps: { vision: true, contextWindow: 1000000, maxOutput: 32768 } },
{ pattern: "*gpt-4-turbo*", caps: { vision: true, contextWindow: 128000 } },
{ pattern: "*gpt-4*", caps: { contextWindow: 128000 } },
{ pattern: "*gpt-3.5*", caps: { contextWindow: 16385, maxOutput: 4096 } },
{ pattern: "*gpt-oss*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*gpt-oss*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
// ── OpenAI o-series (reasoning, vision) ──────────────────────────
{ pattern: "*o1-mini*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*o1*", caps: { vision: true, reasoning: true, contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o3*", caps: { vision: true, reasoning: true, contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o4*", caps: { vision: true, reasoning: true, contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o1-mini*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 128000 } },
{ pattern: "*o1*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o3*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
{ pattern: "*o4*", caps: { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 200000, maxOutput: 100000 } },
// ── Grok (vision + Live Search) ──────────────────────────────────
{ pattern: "*grok*image*", caps: { imageOutput: true } },
{ pattern: "*grok-code*", caps: { reasoning: true, contextWindow: 256000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, contextWindow: 256000 } },
{ pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, contextWindow: 131072 } },
{ pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, contextWindow: 256000 } },
{ pattern: "*grok-code*", caps: { reasoning: true, thinkingFormat: "openai", contextWindow: 256000 } },
{ pattern: "*grok-4*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
{ pattern: "*grok-3*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 131072 } },
{ pattern: "*grok*", caps: { vision: true, reasoning: true, search: true, thinkingFormat: "openai", contextWindow: 256000 } },
// ── Qwen (VL = vision; max/plus = vision+1M; coder/text last) ────
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, contextWindow: 262144 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*235b*", caps: { reasoning: true, contextWindow: 262144 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, contextWindow: 1000000 } },
{ pattern: "*qwq*", caps: { reasoning: true, contextWindow: 131072 } },
{ pattern: "*qwen*", caps: { reasoning: true, contextWindow: 262144 } },
// ── Qwen (enable_thinking + thinking_budget; QwQ = thinking-only)
{ pattern: "*qwen*vl*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*max*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*plus*", caps: { vision: true, reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000, maxOutput: 65536 } },
{ pattern: "*qwen*235b*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
{ pattern: "*qwen*coder*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 1000000 } },
{ pattern: "*qwq*", caps: { reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 131072 } },
{ pattern: "*qwen*", caps: { reasoning: true, thinkingFormat: "qwen", contextWindow: 262144 } },
// ── Kimi (K2.x = vision + thinking, 262K) ────────────────────────
{ pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*", caps: { reasoning: true, contextWindow: 262144 } },
// ── Kimi (enabled→reasoning_effort; K2.7-code cannot disable) ─────
{ pattern: "*kimi*k2.7*code*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*k2*", caps: { vision: true, reasoning: true, thinkingFormat: "kimi", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*kimi*", caps: { reasoning: true, thinkingFormat: "kimi", contextWindow: 262144 } },
// ── GLM (4.6V vision handled by exact id; text GLM = reasoning) ───
{ pattern: "*glm-5*", caps: { reasoning: true, contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4.7*", caps: { reasoning: true, contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4*", caps: { reasoning: true, contextWindow: 200000 } },
{ pattern: "*glm*", caps: { reasoning: true, contextWindow: 200000 } },
// ── GLM / Z.ai (thinking.enabled; disable via enable_thinking:false) ─
{ pattern: "*glm-5*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4.7*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000, maxOutput: 128000 } },
{ pattern: "*glm-4*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
{ pattern: "*glm*", caps: { reasoning: true, thinkingFormat: "zai", contextWindow: 200000 } },
// ── DeepSeek (NO vision; v4 = 1M ctx; r1/reasoner = thinking) ────
{ pattern: "*deepseek-v4*", caps: { reasoning: true, contextWindow: 1000000, maxOutput: 384000 } },
{ pattern: "*reasoner*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*deepseek-r*", caps: { reasoning: true, contextWindow: 128000 } },
// ── DeepSeek (thinking.enabled + reasoning_effort; r1 = thinking-only)
{ pattern: "*deepseek-v4*", caps: { reasoning: true, thinkingFormat: "deepseek", contextWindow: 1000000, maxOutput: 384000 } },
{ pattern: "*reasoner*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } },
{ pattern: "*deepseek-r*", caps: { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 128000 } },
{ pattern: "*deepseek*", caps: { contextWindow: 128000 } },
// ── MiniMax (M3 = 1M/512K; M2.x = 200K) ──────────────────────────
// ── MiniMax (M3 = adaptive; M2.x cannot disable) ─────────────────
{ pattern: "*minimax*image*", caps: { imageOutput: true } },
{ pattern: "*minimax-m3*", caps: { reasoning: true, contextWindow: 1048576, maxOutput: 512000 } },
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax*", caps: { reasoning: true, contextWindow: 200000, maxOutput: 131072 } },
{ pattern: "*minimax-m3*", caps: { reasoning: true, thinkingFormat: "minimax", contextWindow: 1048576, maxOutput: 512000 } },
{ pattern: "*minimax-m2.7*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 204800, maxOutput: 131072 } },
{ pattern: "*minimax*", caps: { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 200000, maxOutput: 131072 } },
// ── Xiaomi MiMo (vision, 1M / 262K ctx) ──────────────────────────
{ pattern: "*mimo*v2.5*", caps: { vision: true, contextWindow: 1048576, maxOutput: 131072 } },
@@ -178,9 +190,9 @@ export const PATTERN_CAPABILITIES = [
{ pattern: "*perplexity*", caps: { search: true, contextWindow: 128000 } },
// ── Others ───────────────────────────────────────────────────────
{ pattern: "*hunyuan*", caps: { reasoning: true, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*step-*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*hunyuan*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "hy3*", caps: { reasoning: true, thinkingFormat: "hunyuan", contextWindow: 262144, maxOutput: 262144 } },
{ pattern: "*step-*", caps: { reasoning: true, thinkingFormat: "step", contextWindow: 128000 } },
{ pattern: "*nemotron*", caps: { reasoning: true, contextWindow: 128000 } },
{ pattern: "*ling-*", caps: { reasoning: true, contextWindow: 128000 } },
];

View File

@@ -19,6 +19,7 @@ export default {
category: "apikey",
transport: {
baseUrl: "https://api.blackbox.ai/chat/completions",
thinkingFormat: "openai",
},
models: [
{ id: "gpt-4o", name: "GPT-4o" },

View File

@@ -22,6 +22,7 @@ export default {
hasProviderSpecificData: true,
transport: {
baseUrl: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/v1/chat/completions",
thinkingFormat: "openai",
},
models: [
{ id: "@cf/meta/llama-3.2-1b-instruct", name: "Llama 3.2 1B Instruct" },

View File

@@ -40,6 +40,7 @@ export default {
},
usage: {
url: "https://chatgpt.com/backend-api/wham/usage",
resetCreditsConsumeUrl: "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume",
},
},
models: [

View File

@@ -40,8 +40,6 @@ export default {
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
{ id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
{ id: "gemini-2.0-flash", name: "Gemini 2.0 Flash" },
{ id: "gemini-2.0-flash-lite", name: "Gemini 2.0 Flash Lite" },
{ id: "gemma-4-31b-it", name: "Gemma 4 31B IT" },
{ id: "gemini-embedding-2-preview", name: "Gemini Embedding 2 Preview", kind: "embedding" },
{ id: "gemini-embedding-001", name: "Gemini Embedding 001", kind: "embedding" },

View File

@@ -15,6 +15,7 @@ export default {
category: "oauth",
transport: {
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
thinkingFormat: "openai",
headers: {
"User-Agent": "iFlow-Cli",
},

View File

@@ -17,6 +17,7 @@ export default {
category: "freeTier",
transport: {
baseUrl: "https://openrouter.ai/api/v1/chat/completions",
thinkingFormat: "openai",
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",

View File

@@ -16,6 +16,7 @@ export default {
transport: {
baseUrl: "https://api.siliconflow.com/v1/chat/completions",
validateUrl: "https://api.siliconflow.com/v1/models",
thinkingFormat: "openai",
},
models: [
{ id: "deepseek-ai/DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },

View File

@@ -20,6 +20,7 @@ export default {
category: "apikey",
transport: {
baseUrl: "https://ai-gateway.vercel.sh/v1/chat/completions",
thinkingFormat: "openai",
retry: {
"429": 2,
},

View File

@@ -19,8 +19,12 @@ export const STATUS_MAX_UNTRACKED = 10; // config::limits().status_max_un
export const LS_EXT_SUMMARY_TOP = 5; // top-N extensions in summary
export const LS_NOISE_DIRS = [
"node_modules", ".git", "target", "__pycache__",
".next", "dist", "build", ".venv", "venv",
".cache", ".idea", ".vscode", ".DS_Store"
".next", "dist", "build", ".cache", ".turbo",
".vercel", ".pytest_cache", ".mypy_cache", ".tox",
".venv", "venv",
"env", // Python legacy virtualenv; .env (dotenv) intentionally excluded
"coverage", ".nyc_output", ".DS_Store", "Thumbs.db",
".idea", ".vscode", ".vs", "*.egg-info", ".eggs"
];
// tree filter_tree_output cap (no rust cap, we add one to be safe)

View File

@@ -31,16 +31,15 @@ export function find(input) {
const showDirs = dirs.slice(0, FIND_TOTAL_DIR_MAX);
for (const dir of showDirs) {
const files = byDir.get(dir);
out += `${dir}/ (${files.length}):\n`;
out += `${dir}/ (${files.length})\n`;
const showFiles = files.slice(0, FIND_PER_DIR_MAX);
for (const f of showFiles) out += ` ${f}\n`;
if (files.length > FIND_PER_DIR_MAX) {
out += ` +${files.length - FIND_PER_DIR_MAX}\n`;
}
out += "\n";
}
if (dirs.length > FIND_TOTAL_DIR_MAX) {
out += `+${dirs.length - FIND_TOTAL_DIR_MAX} more dirs\n`;
out += `\n+${dirs.length - FIND_TOTAL_DIR_MAX} more dirs\n`;
}
return out;

View File

@@ -4,6 +4,34 @@
import { checkFallbackError, formatRetryAfter } from "./accountFallback.js";
import { unavailableResponse } from "../utils/error.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
// Hard capabilities = input modalities; missing one drops request data (e.g. image
// stripped). Must be prioritized. Soft (e.g. search) only degrades a feature.
const HARD_CAPS = new Set(["vision", "pdf", "audioInput", "videoInput"]);
// Reorder combo models by capability fit. Stable; never drops a model (fallback intact).
// Tier 0: satisfies all hard + all soft. Tier 1: all hard only. Tier 2: rest.
export function reorderByCapabilities(models, required) {
if (!required || required.size === 0 || !Array.isArray(models) || models.length <= 1) return models;
const hard = [...required].filter((c) => HARD_CAPS.has(c));
const soft = [...required].filter((c) => !HARD_CAPS.has(c));
const tierOf = (m) => {
const slash = typeof m === "string" ? m.indexOf("/") : -1;
const provider = slash > 0 ? m.slice(0, slash) : "";
const model = slash > 0 ? m.slice(slash + 1) : m;
const caps = getCapabilitiesForModel(provider, model);
if (!hard.every((c) => caps[c] === true)) return 2;
return soft.every((c) => caps[c] === true) ? 0 : 1;
};
// Stable sort by tier (Array.prototype.sort is stable in modern engines).
return models
.map((m, i) => ({ m, i, t: tierOf(m) }))
.sort((a, b) => a.t - b.t || a.i - b.i)
.map((x) => x.m);
}
/**
* Track rotation state per combo (for round-robin strategy)
@@ -11,6 +39,53 @@ import { unavailableResponse } from "../utils/error.js";
*/
const comboRotationState = new Map();
// Last array item whose role is "user" (current turn), or the last item when no
// role is present. History media (older turns) must not pin the combo to a vision
// model — those get stripped + placeholdered downstream instead.
function lastUserItem(arr) {
if (!Array.isArray(arr) || arr.length === 0) return null;
for (let i = arr.length - 1; i >= 0; i--) {
if (!arr[i]?.role || arr[i].role === "user") return arr[i];
}
return arr[arr.length - 1];
}
// Detect which capabilities a request needs. Modalities (vision/pdf) are scanned
// only on the current user turn; "search" is request-wide (lives in tools).
// Returns a Set of: "vision" | "pdf" | "search".
export function detectRequiredCapabilities(body) {
const required = new Set();
if (!body || typeof body !== "object") return required;
const scanBlock = (b) => {
if (!b || typeof b !== "object") return;
const t = b.type;
if (t === "image_url" || t === "image" || t === "input_image") required.add("vision");
if (t === "file" || t === "document" || t === "input_file") required.add("pdf");
// gemini parts: inlineData/fileData carry a mime
const mime = b.inlineData?.mimeType || b.fileData?.mimeType;
if (typeof mime === "string" && mime.startsWith("image/")) required.add("vision");
if (mime === "application/pdf") required.add("pdf");
};
const scanContent = (content) => {
if (Array.isArray(content)) for (const b of content) scanBlock(b);
};
// Modalities: current user turn only (last item across each known shape).
const lastMsg = lastUserItem(body.messages); // openai / claude
if (lastMsg) scanContent(lastMsg.content);
const lastInput = lastUserItem(body.input); // responses
if (lastInput) scanContent(lastInput.content);
const contents = body.contents || body.request?.contents; // gemini / antigravity
const lastContent = lastUserItem(contents);
if (lastContent) scanContent(lastContent.parts);
// search: temporarily disabled in auto-switch (feature not wired yet).
return required;
}
function normalizeStickyLimit(stickyLimit) {
const parsed = Number.parseInt(stickyLimit, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1;
@@ -105,9 +180,21 @@ export function getComboModelsFromData(modelStr, combosData) {
* @param {number|string} [options.comboStickyLimit=1] - Requests per combo model before switching
* @returns {Promise<Response>}
*/
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1 }) {
export async function handleComboChat({ body, models, handleSingleModel, log, comboName, comboStrategy, comboStickyLimit = 1, autoSwitch = true }) {
// Apply rotation strategy if enabled
const rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
let rotatedModels = getRotatedModels(models, comboName, comboStrategy, comboStickyLimit);
// Auto-switch: float models that satisfy the request's required capabilities to the front.
if (autoSwitch) {
const required = detectRequiredCapabilities(body);
if (required.size > 0) {
const reordered = reorderByCapabilities(rotatedModels, required);
if (reordered[0] !== rotatedModels[0]) {
log.info("COMBO", `auto-switch for [${[...required].join(",")}] → ${reordered[0]}`);
}
rotatedModels = reordered;
}
}
let lastError = null;
let earliestRetryAfter = null;

View File

@@ -5,7 +5,9 @@
import { getGitHubUsage } from "./usage/github.js";
import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js";
import { getClaudeUsage } from "./usage/claude.js";
import { getCodexUsage } from "./usage/codex.js";
import { getCodexUsage, consumeCodexRateLimitResetCredit } from "./usage/codex.js";
export { consumeCodexRateLimitResetCredit };
import { getKiroUsage } from "./usage/kiro.js";
import { getMiniMaxUsage } from "./usage/minimax.js";
import {

View File

@@ -8,6 +8,7 @@ import { U, parseResetTime, toFiniteNumber } from "./shared.js";
// Codex (OpenAI) API config
const CODEX_CONFIG = {
usageUrl: U("codex").url,
resetCreditsConsumeUrl: U("codex").resetCreditsConsumeUrl,
};
function getCodexRateLimitBody(snapshot) {
@@ -82,6 +83,7 @@ export async function getCodexUsage(accessToken, proxyOptions = null) {
const data = await response.json();
const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {};
const reviewRateLimit = getCodexReviewRateLimit(data);
const availableResetCredits = Math.max(0, toFiniteNumber(data.rate_limit_reset_credits?.available_count, 0));
const quotas = {};
appendCodexQuotaWindows(quotas, "", normalRateLimit);
@@ -91,9 +93,53 @@ export async function getCodexUsage(accessToken, proxyOptions = null) {
plan: data.plan_type || data.summary?.plan || "unknown",
limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false,
reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false,
resetCredits: { availableCount: availableResetCredits },
quotas,
};
} catch (error) {
throw new Error(`Failed to fetch Codex usage: ${error.message}`);
}
}
// Consume one Codex rate-limit reset credit (irreversible, spends 1 credit)
export async function consumeCodexRateLimitResetCredit(accessToken, redeemRequestId, proxyOptions = null) {
if (!accessToken) {
throw new Error("No Codex access token available. Please re-authorize the connection.");
}
if (!redeemRequestId || typeof redeemRequestId !== "string") {
throw new Error("A redeem request id is required to consume a Codex reset credit.");
}
let response;
let data = null;
try {
response = await proxyAwareFetch(CODEX_CONFIG.resetCreditsConsumeUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${accessToken}`,
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({ redeem_request_id: redeemRequestId }),
}, proxyOptions);
const text = await response.text();
data = text ? JSON.parse(text) : null;
} catch (error) {
throw new Error(`Failed to consume Codex reset credit: ${error.message}`);
}
const code = data?.code || null;
const windowsReset = toFiniteNumber(data?.windows_reset, 0);
const success = response.ok && (code === "reset" || windowsReset > 0);
return {
ok: success,
noCredit: response.ok && code === "no_credit",
status: response.status,
code,
windowsReset,
message: data?.message || null,
raw: data,
};
}

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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 "";
}

View File

@@ -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";

View File

@@ -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;
}

View File

@@ -77,6 +77,14 @@ export function convertOpenAIContentToParts(content) {
inlineData: { mime_type: mimeType, data: data }
});
}
} else if (item.type === OPENAI_BLOCK.FILE && item.file?.file_data?.startsWith("data:")) {
const url = item.file.file_data;
const commaIndex = url.indexOf(",");
if (commaIndex !== -1) {
const mimeType = url.substring(5, commaIndex).split(";")[0];
const data = url.substring(commaIndex + 1);
parts.push({ inlineData: { mime_type: mimeType, data: data } });
}
}
}
}

View File

@@ -4,6 +4,7 @@ import { prepareClaudeRequest } from "./formats/claude.js";
import { cloakClaudeTools } from "../utils/claudeCloaking.js";
import { filterToOpenAIFormat } from "./formats/openai.js";
import { normalizeThinkingConfig } from "../services/provider.js";
import { applyThinking, captureThinking } from "./concerns/thinkingUnified.js";
import { AntigravityExecutor } from "../executors/antigravity.js";
import { PROVIDERS } from "../providers/index.js";
@@ -63,6 +64,10 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
// Fix missing tool responses (insert empty tool_result if needed)
fixMissingToolResponses(result);
// Capture thinking intent from the original (pre-translation) body, before any
// format conversion strips/renames the fields. Applied after translation.
const thinkingIntent = captureThinking(result);
// If same format, skip translation steps
if (sourceFormat !== targetFormat) {
// Step 1: source -> openai (if source is not openai)
@@ -84,6 +89,9 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
}
}
// Normalize thinking to the target provider-native format (config-driven, capability-aware)
applyThinking(targetFormat, model, result, provider, thinkingIntent);
// Always normalize to clean OpenAI format when target is OpenAI
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
if (targetFormat === FORMATS.OPENAI) {

View File

@@ -4,7 +4,6 @@ import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js";
import { adjustMaxTokens } from "../formats/maxTokens.js";
import { safeParseJSON } from "../concerns/json.js";
import { parseDataUri } from "../concerns/image.js";
import { effortToBudget } from "../concerns/thinking.js";
import { extractTextContent } from "../formats/gemini.js";
import { ROLE, OPENAI_BLOCK, CLAUDE_BLOCK } from "../schema/index.js";
@@ -175,25 +174,7 @@ Respond ONLY with the JSON object, no other text.`);
result.tool_choice = convertOpenAIToolChoice(body.tool_choice);
}
// Thinking configuration
if (body.thinking) {
result.thinking = {
type: body.thinking.type || "enabled",
...(body.thinking.budget_tokens && { budget_tokens: body.thinking.budget_tokens }),
...(body.thinking.max_tokens && { max_tokens: body.thinking.max_tokens })
};
}
// Map OpenAI reasoning_effort → Claude thinking.budget_tokens
// When client sends reasoning_effort (OpenAI format) but no explicit thinking block,
// translate to Claude's native format.
if (body.reasoning_effort && !result.thinking) {
const budget = effortToBudget(body.reasoning_effort);
if (budget) {
result.thinking = { type: "enabled", budget_tokens: budget };
}
// budget === 0 (none) or undefined (unknown) → no thinking
}
// Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation.
// Attach toolNameMap to result for response translation
if (toolNameMap.size > 0) {
@@ -245,6 +226,16 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map()) {
}
} else if (part.type === OPENAI_BLOCK.IMAGE && part.source) {
blocks.push({ type: CLAUDE_BLOCK.IMAGE, source: part.source });
} else if (part.type === OPENAI_BLOCK.FILE && part.file) {
// OpenAI file block -> Claude document (PDF only; Claude rejects other mimes).
const fileData = part.file.file_data;
const parsed = parseDataUri(fileData);
if (parsed && parsed.mimeType === "application/pdf") {
blocks.push({
type: CLAUDE_BLOCK.DOCUMENT,
source: { type: "base64", media_type: parsed.mimeType, data: parsed.base64 }
});
}
}
}
}

View File

@@ -3,8 +3,6 @@ import { FORMATS } from "../formats.js";
import { DEFAULT_THINKING_AG_SIGNATURE, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
import { effortToThinkingLevel } from "../concerns/thinking.js";
function generateUUID() {
return crypto.randomUUID();
}
@@ -230,23 +228,7 @@ export function openaiToGeminiRequest(model, body, stream) {
// OpenAI -> Gemini CLI (Cloud Code Assist)
export function openaiToGeminiCLIRequest(model, body, stream) {
const gemini = openaiToGeminiBase(model, body, stream, DEFAULT_THINKING_GEMINI_CLI_SIGNATURE);
const isClaude = model.toLowerCase().includes("claude");
// Map reasoning effort → thinkingConfig.thinkingLevel (gemini-3 enum: minimal|low|medium|high)
// Gemini 3 cannot fully disable thinking; "none"/"off" map to "minimal" (closest to no-thinking)
// Accept both OpenAI chat (reasoning_effort) and Responses (reasoning.effort) shapes
const reasoningEffort = body.reasoning_effort ?? body.reasoning?.effort;
if (reasoningEffort) {
const level = effortToThinkingLevel(reasoningEffort);
gemini.generationConfig.thinkingConfig = { thinkingLevel: level, includeThoughts: level !== "minimal" };
}
// Claude-format thinking: disabled → minimal, enabled → high
if (body.thinking?.type === "disabled") {
gemini.generationConfig.thinkingConfig = { thinkingLevel: "minimal", includeThoughts: false };
} else if (body.thinking?.type === "enabled") {
gemini.generationConfig.thinkingConfig = { thinkingLevel: "high", includeThoughts: true };
}
// Thinking is normalized centrally by applyThinking (thinkingUnified.js) after translation.
// Clean schema for tools
if (gemini.tools?.[0]?.functionDeclarations) {

View File

@@ -7,7 +7,7 @@ import { FORMATS } from "../formats.js";
import { buildChunk } from "../concerns/chunk.js";
import { buildUsage } from "../concerns/usage.js";
import { fallbackToolCallId } from "../concerns/toolCall.js";
import { reasoningDelta } from "../concerns/reasoning.js";
import { reasoningDelta, extractReasoningText } from "../concerns/reasoning.js";
import { ROLE, OPENAI_BLOCK, RESPONSES_ITEM, OPENAI_FINISH, MODEL_FALLBACK } from "../schema/index.js";
/**
@@ -62,10 +62,11 @@ export function openaiToOpenAIResponsesResponse(chunk, state) {
});
}
// Handle reasoning_content
if (delta.reasoning_content) {
// Handle reasoning across vendor shapes (reasoning_content / reasoning / reasoning_details)
const reasoningText = extractReasoningText(delta);
if (reasoningText) {
startReasoning(state, emit, idx);
emitReasoningDelta(state, emit, delta.reasoning_content);
emitReasoningDelta(state, emit, reasoningText);
}
// Handle text content

View File

@@ -2,6 +2,7 @@ import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { ROLE, CLAUDE_BLOCK, MODEL_FALLBACK } from "../schema/index.js";
import { fromOpenAIFinish } from "../concerns/finishReason.js";
import { extractReasoningText } from "../concerns/reasoning.js";
// Legacy "proxy_" prefix used by older request translators. Response strips it
// defensively so tool names from such turns resolve back (e.g. proxy_Read → Read
@@ -134,8 +135,8 @@ export function openaiToClaudeResponse(chunk, state) {
});
}
// Handle reasoning_content (thinking) - GLM, DeepSeek, etc.
const reasoningContent = delta?.reasoning_content || delta?.reasoning;
// Handle reasoning (thinking) across vendor shapes - GLM/DeepSeek/Qwen/MiniMax/etc.
const reasoningContent = extractReasoningText(delta);
if (reasoningContent) {
stopTextBlock(state, results);

View File

@@ -7,6 +7,7 @@ export const OPENAI_BLOCK = {
IMAGE: "image",
INPUT_AUDIO: "input_audio",
AUDIO_URL: "audio_url",
FILE: "file",
FUNCTION: "function",
};
@@ -14,6 +15,7 @@ export const OPENAI_BLOCK = {
export const CLAUDE_BLOCK = {
TEXT: "text",
IMAGE: "image",
DOCUMENT: "document",
TOOL_USE: "tool_use",
TOOL_RESULT: "tool_result",
THINKING: "thinking",
@@ -34,7 +36,7 @@ export const RESPONSES_ITEM = {
// Valid OpenAI block types (used by filterToOpenAIFormat).
export const VALID_OPENAI_CONTENT_TYPES = [
OPENAI_BLOCK.TEXT, OPENAI_BLOCK.IMAGE_URL, OPENAI_BLOCK.IMAGE, OPENAI_BLOCK.INPUT_AUDIO, OPENAI_BLOCK.AUDIO_URL,
OPENAI_BLOCK.TEXT, OPENAI_BLOCK.IMAGE_URL, OPENAI_BLOCK.IMAGE, OPENAI_BLOCK.INPUT_AUDIO, OPENAI_BLOCK.AUDIO_URL, OPENAI_BLOCK.FILE,
];
export const VALID_OPENAI_MESSAGE_TYPES = [
OPENAI_BLOCK.TEXT, OPENAI_BLOCK.IMAGE_URL, OPENAI_BLOCK.IMAGE, "tool_calls", CLAUDE_BLOCK.TOOL_RESULT,

View File

@@ -40,8 +40,11 @@ export function cloakClaudeTools(body) {
const clientToolNames = new Set();
const clientDeclarations = [];
// All client tools get renamed with suffix
// All client tools get renamed with suffix.
// Built-in server tools (web_search_20250305, etc.) carry a `type` and require
// an exact reserved `name` — never suffix those or Claude rejects the request.
for (const tool of tools) {
if (tool.type) { clientDeclarations.push(tool); continue; }
const suffixed = suffix(tool.name);
toolNameMap.set(suffixed, tool.name);
clientToolNames.add(tool.name);

View File

@@ -128,7 +128,10 @@ export function createDisconnectAwareStream(transformStream, streamController, o
controller.enqueue(value);
} catch (error) {
const wasConnected = streamController.isConnected();
streamController.handleError(error);
// Controller already closed = downstream ended; not an upstream error, skip noisy log.
const msg0 = error?.message || "";
const isControllerClosed = msg0.includes("already closed") || msg0.includes("Invalid state");
if (!isControllerClosed) streamController.handleError(error);
reader.cancel().catch(() => {});
writer.abort().catch(() => {});