mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 05:31:47 +00:00
Refactor
This commit is contained in:
+22
-2
@@ -61,6 +61,21 @@ const INSTALL_CMD_LATEST = `npm i -g ${APP_NAME}@latest --prefer-online`;
|
||||
|
||||
const DEFAULT_PORT = 20128;
|
||||
const DEFAULT_HOST = "0.0.0.0";
|
||||
|
||||
// First non-internal IPv4 — the address remote peers actually reach when bound to 0.0.0.0.
|
||||
function getLanIp() {
|
||||
for (const ifaces of Object.values(os.networkInterfaces())) {
|
||||
for (const i of ifaces || []) {
|
||||
if (i.family === "IPv4" && !i.internal) return i.address;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Local URL stays "localhost"; warn separately when bound to all interfaces (network-exposed).
|
||||
function getDisplayHost() {
|
||||
return host === DEFAULT_HOST ? "localhost" : host;
|
||||
}
|
||||
const MAX_PORT_ATTEMPTS = 10;
|
||||
// Identifiers for killAllAppProcesses - only kill 9router specifically
|
||||
const PROCESS_IDENTIFIERS = [
|
||||
@@ -501,7 +516,7 @@ async function showInterfaceMenu(latestVersion) {
|
||||
|
||||
clearScreen();
|
||||
|
||||
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
|
||||
const displayHost = getDisplayHost();
|
||||
|
||||
// Detect tunnel/local mode for server URL display
|
||||
let serverUrl;
|
||||
@@ -542,8 +557,13 @@ const MAX_RESTARTS = 2;
|
||||
const RESTART_RESET_MS = 30000; // Reset counter if alive > 30s
|
||||
|
||||
function startServer(latestVersion) {
|
||||
const displayHost = host === DEFAULT_HOST ? "localhost" : host;
|
||||
const displayHost = getDisplayHost();
|
||||
const url = `http://${displayHost}:${port}/dashboard`;
|
||||
// Surface real network exposure when bound to all interfaces (default 0.0.0.0).
|
||||
if (host === DEFAULT_HOST) {
|
||||
const lanIp = getLanIp();
|
||||
if (lanIp) console.log(`\x1b[33m⚠ Network-exposed: reachable at http://${lanIp}:${port} (bound 0.0.0.0). Use --host 127.0.0.1 for local-only.\x1b[0m`);
|
||||
}
|
||||
|
||||
let restartCount = 0;
|
||||
let serverStartTime = Date.now();
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } },
|
||||
];
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -15,6 +15,7 @@ export default {
|
||||
category: "oauth",
|
||||
transport: {
|
||||
baseUrl: "https://apis.iflow.cn/v1/chat/completions",
|
||||
thinkingFormat: "openai",
|
||||
headers: {
|
||||
"User-Agent": "iFlow-Cli",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -20,6 +20,7 @@ export default {
|
||||
category: "apikey",
|
||||
transport: {
|
||||
baseUrl: "https://ai-gateway.vercel.sh/v1/chat/completions",
|
||||
thinkingFormat: "openai",
|
||||
retry: {
|
||||
"429": 2,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"dev": "next dev --webpack --port 20127",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"dev:bun": "bun --bun next dev --webpack --port 20128",
|
||||
"dev:bun": "bun --bun next dev --webpack --port 20127",
|
||||
"build:bun": "bun --bun next build --webpack",
|
||||
"start:bun": "bun ./.next/standalone/server.js",
|
||||
"cli:pack": "npm --prefix cli run pack:cli",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Live test: combo capacity display + auto-switch routing.
|
||||
// Sends text / image / search requests to a combo and reports which member ran.
|
||||
// node scripts/test-combo-autoswitch.mjs
|
||||
const BASE = process.env.BASE_URL || "http://localhost:20127";
|
||||
const KEY = process.env.API_KEY || "sk-6581be4f05a82b6b-uxy6jn-c8190ea8";
|
||||
const COMBO = process.env.COMBO || "haha";
|
||||
|
||||
// 16x16 PNG (valid image so vision providers accept it).
|
||||
const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4I2JDEmIY1TCqYfhqAAAeBCwQ8YdREQAAAABJRU5ErkJggg==";
|
||||
|
||||
function memberFromModel(model) {
|
||||
// Response model usually = upstream id; map back to a combo member by substring.
|
||||
return model || "(none)";
|
||||
}
|
||||
|
||||
async function send(label, content, extra = {}) {
|
||||
const body = {
|
||||
model: COMBO,
|
||||
stream: false,
|
||||
max_tokens: 64,
|
||||
messages: [{ role: "user", content }],
|
||||
...extra,
|
||||
};
|
||||
const t0 = Date.now();
|
||||
let res, json, text;
|
||||
try {
|
||||
res = await fetch(`${BASE}/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${KEY}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
text = await res.text();
|
||||
try { json = JSON.parse(text); } catch { /* keep text */ }
|
||||
} catch (e) {
|
||||
console.log(`\n[${label}] NETWORK ERROR: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
const ms = Date.now() - t0;
|
||||
const model = json?.model || "(no model field)";
|
||||
const ok = res.ok;
|
||||
const snippet = (json?.choices?.[0]?.message?.content || text || "").slice(0, 80).replace(/\n/g, " ");
|
||||
console.log(`\n[${label}] ${ok ? "OK" : "FAIL"} ${res.status} (${ms}ms)`);
|
||||
console.log(` model executed: ${memberFromModel(model)}`);
|
||||
if (!ok) console.log(` error: ${(json?.error?.message || text || "").slice(0, 160)}`);
|
||||
else console.log(` reply: ${snippet}`);
|
||||
}
|
||||
|
||||
async function showCaps() {
|
||||
try {
|
||||
const r = await fetch(`${BASE}/api/models`, { headers: { Authorization: `Bearer ${KEY}` } });
|
||||
if (!r.ok) { console.log("(/api/models needs dashboard auth, skipping caps table)"); return; }
|
||||
const { models } = await r.json();
|
||||
const map = {};
|
||||
for (const m of models || []) if (m.caps) map[m.fullModel] = m.caps;
|
||||
console.log("Capacity of combo members (vision/search):");
|
||||
for (const m of (process.env.MEMBERS || "").split(",").filter(Boolean)) {
|
||||
const c = map[m] || {};
|
||||
console.log(` ${m}: vision=${!!c.vision} search=${!!c.search}`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log(`Testing combo "${COMBO}" @ ${BASE}\n${"=".repeat(50)}`);
|
||||
await showCaps();
|
||||
|
||||
// 1. Text-only: round-robin order (no capability requirement).
|
||||
await send("text-only #1", "Say hello in one word.");
|
||||
await send("text-only #2", "Say hi in one word.");
|
||||
|
||||
// 2. Image: should auto-switch to a vision-capable member.
|
||||
await send("image (needs vision)", [
|
||||
{ type: "text", text: "What color is this image? One word." },
|
||||
{ type: "image_url", image_url: { url: PNG } },
|
||||
]);
|
||||
|
||||
// 3. Search: should auto-switch to a search-capable member.
|
||||
// Claude built-in web search requires a versioned tool type.
|
||||
await send("search (needs search)", "What is the latest news today?", {
|
||||
tools: [{ type: "web_search_20250305", name: "web_search" }],
|
||||
});
|
||||
|
||||
console.log(`\n${"=".repeat(50)}\nDone. Compare 'model executed' across cases to verify auto-switch.`);
|
||||
})();
|
||||
@@ -347,10 +347,10 @@ export default function ClaudeToolCard({
|
||||
<label className="flex items-center gap-1.5 cursor-pointer select-none">
|
||||
<input type="checkbox" checked={ccFilterNaming} onChange={handleCcFilterNamingToggle} className="w-3.5 h-3.5 accent-primary cursor-pointer" />
|
||||
<span className="text-xs text-text-muted">Filter naming requests</span>
|
||||
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
|
||||
</Tooltip>
|
||||
</label>
|
||||
<Tooltip text="Intercepts Claude Code's topic-naming requests and returns a fake response locally, saving API tokens.">
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px] cursor-help">info</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, us
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { restrictToVerticalAxis, restrictToParentElement } from "@dnd-kit/modifiers";
|
||||
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal } from "@/shared/components";
|
||||
import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle, ConfirmModal, CapacityBadges } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function CombosPage() {
|
||||
const [editingCombo, setEditingCombo] = useState(null);
|
||||
const [activeProviders, setActiveProviders] = useState([]);
|
||||
const [comboStrategies, setComboStrategies] = useState({});
|
||||
const [modelCaps, setModelCaps] = useState({});
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
@@ -28,10 +29,11 @@ export default function CombosPage() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [combosRes, providersRes, settingsRes] = await Promise.all([
|
||||
const [combosRes, providersRes, settingsRes, modelsRes] = await Promise.all([
|
||||
fetch("/api/combos"),
|
||||
fetch("/api/providers"),
|
||||
fetch("/api/settings"),
|
||||
fetch("/api/models"),
|
||||
]);
|
||||
const combosData = await combosRes.json();
|
||||
const providersData = await providersRes.json();
|
||||
@@ -42,6 +44,13 @@ export default function CombosPage() {
|
||||
if (providersRes.ok) {
|
||||
setActiveProviders(providersData.connections || []);
|
||||
}
|
||||
if (modelsRes.ok) {
|
||||
const md = await modelsRes.json();
|
||||
// Build fullModel -> caps map for badge lookup
|
||||
const map = {};
|
||||
for (const m of md.models || []) if (m.caps) map[m.fullModel] = m.caps;
|
||||
setModelCaps(map);
|
||||
}
|
||||
setComboStrategies(settingsData.comboStrategies || {});
|
||||
} catch (error) {
|
||||
console.log("Error fetching data:", error);
|
||||
@@ -143,7 +152,7 @@ export default function CombosPage() {
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold">Combos</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Create model combos with fallback support
|
||||
Create model combos with fallback support — auto-adapts per request: routes images to vision models and web search to search-capable models.
|
||||
</p>
|
||||
</div>
|
||||
<Button icon="add" onClick={() => setShowCreateModal(true)} className="w-full sm:w-auto">
|
||||
@@ -171,6 +180,7 @@ export default function CombosPage() {
|
||||
<ComboCard
|
||||
key={combo.id}
|
||||
combo={combo}
|
||||
modelCaps={modelCaps}
|
||||
copied={copied}
|
||||
onCopy={copy}
|
||||
onEdit={() => setEditingCombo(combo)}
|
||||
@@ -214,7 +224,7 @@ export default function CombosPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) {
|
||||
function ComboCard({ combo, modelCaps = {}, copied, onCopy, onEdit, onDelete, roundRobinEnabled, onToggleRoundRobin }) {
|
||||
return (
|
||||
<Card padding="sm" className="group">
|
||||
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
@@ -229,8 +239,9 @@ function ComboCard({ combo, copied, onCopy, onEdit, onDelete, roundRobinEnabled,
|
||||
<span className="text-xs text-text-muted italic">No models</span>
|
||||
) : (
|
||||
combo.models.slice(0, 3).map((model, index) => (
|
||||
<code key={index} className="max-w-full truncate rounded bg-black/5 px-1.5 py-0.5 font-mono text-[10px] text-text-muted dark:bg-white/5 sm:max-w-[220px]">
|
||||
{model}
|
||||
<code key={index} className="inline-flex items-center gap-1 rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-text-muted dark:bg-white/5">
|
||||
<span>{model}</span>
|
||||
<CapacityBadges caps={modelCaps[model]} />
|
||||
</code>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import PropTypes from "prop-types";
|
||||
import { CapacityBadges } from "@/shared/components";
|
||||
|
||||
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable }) {
|
||||
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps }) {
|
||||
const borderColor = testStatus === "ok"
|
||||
? "border-green-500/40"
|
||||
: testStatus === "error"
|
||||
@@ -24,7 +25,10 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<code className="max-w-[72vw] truncate rounded bg-sidebar px-1.5 py-0.5 font-mono text-xs text-text-muted sm:max-w-[360px]">{fullModel}</code>
|
||||
{model.name && <span className="truncate pl-1 text-[9px] italic text-text-muted/70">{model.name}</span>}
|
||||
<span className="flex min-w-0 items-center text-[9px] gap-1 pl-1">
|
||||
{model.name && <span className="truncate text-[9px] italic text-text-muted/70">{model.name}</span>}
|
||||
<CapacityBadges caps={caps} colorOverride="text-text-muted/70" size={12} />
|
||||
</span>
|
||||
</div>
|
||||
{onTest && (
|
||||
<div className="relative shrink-0 group/btn">
|
||||
@@ -92,4 +96,5 @@ ModelRow.propTypes = {
|
||||
onTest: PropTypes.func,
|
||||
isTesting: PropTypes.bool,
|
||||
onDisable: PropTypes.func,
|
||||
caps: PropTypes.object,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthW
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { useModelCaps } from "@/shared/hooks/useModelCaps";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||
import ModelRow from "./ModelRow";
|
||||
@@ -29,6 +30,7 @@ export default function ProviderDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const providerId = params.id;
|
||||
const { getCaps } = useModelCaps();
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [providerNode, setProviderNode] = useState(null);
|
||||
@@ -953,6 +955,7 @@ export default function ProviderDetailPage() {
|
||||
isTesting={testingModelId === model.id}
|
||||
isCustom
|
||||
isFree={false}
|
||||
caps={getCaps(`${providerId}/${model.id}`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -977,6 +980,7 @@ export default function ProviderDetailPage() {
|
||||
isTesting={testingModelId === model.id}
|
||||
isFree={model.isFree}
|
||||
onDisable={() => handleDisableModel(model.id)}
|
||||
caps={getCaps(`${providerId}/${model.id}`)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import QuotaTable from "./QuotaTable";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import Tooltip from "@/shared/components/Tooltip";
|
||||
import {
|
||||
parseQuotaData,
|
||||
calculatePercentage,
|
||||
@@ -34,9 +35,15 @@ import {
|
||||
QUOTA_SORT_OPTIONS,
|
||||
} from "./utils";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { EditConnectionModal } from "@/shared/components";
|
||||
import { ConfirmModal, EditConnectionModal } from "@/shared/components";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function getCodexResetCreditCount(quota) {
|
||||
const value = quota?.raw?.resetCredits?.availableCount;
|
||||
const count = typeof value === "number" ? value : Number(value);
|
||||
return Number.isFinite(count) ? Math.max(0, count) : 0;
|
||||
}
|
||||
|
||||
export default function ProviderLimits() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
@@ -50,6 +57,8 @@ export default function ProviderLimits() {
|
||||
const [connectionsLoading, setConnectionsLoading] = useState(true);
|
||||
const [deletingId, setDeletingId] = useState(null);
|
||||
const [togglingId, setTogglingId] = useState(null);
|
||||
const [resettingLimitId, setResettingLimitId] = useState(null);
|
||||
const [resetConfirmState, setResetConfirmState] = useState(null);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedConnection, setSelectedConnection] = useState(null);
|
||||
const [proxyPools, setProxyPools] = useState([]);
|
||||
@@ -207,6 +216,32 @@ export default function ProviderLimits() {
|
||||
[fetchQuota],
|
||||
);
|
||||
|
||||
const handleResetCodexLimit = useCallback(
|
||||
async (connectionId, provider) => {
|
||||
if (provider !== "codex" || resettingLimitId) return;
|
||||
|
||||
setResettingLimitId(connectionId);
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: null }));
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/usage/${connectionId}/codex-reset-credits`, { method: "POST" });
|
||||
const result = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(result.message || result.error || result.code || "Failed to reset Codex limit");
|
||||
}
|
||||
|
||||
await fetchQuota(connectionId, provider);
|
||||
setLastUpdated(new Date());
|
||||
} catch (error) {
|
||||
setErrors((prev) => ({ ...prev, [connectionId]: error.message || "Failed to reset Codex limit" }));
|
||||
} finally {
|
||||
setResettingLimitId(null);
|
||||
}
|
||||
},
|
||||
[fetchQuota, resettingLimitId],
|
||||
);
|
||||
|
||||
const handleDeleteConnection = useCallback(
|
||||
async (id) => {
|
||||
if (!confirm("Delete this connection?")) return;
|
||||
@@ -791,7 +826,10 @@ export default function ProviderLimits() {
|
||||
|
||||
// Use table layout for all providers
|
||||
const isInactive = conn.isActive === false;
|
||||
const rowBusy = deletingId === conn.id || togglingId === conn.id;
|
||||
const isCodex = conn.provider === "codex";
|
||||
const resetCreditCount = getCodexResetCreditCount(quota);
|
||||
const isResettingLimit = resettingLimitId === conn.id;
|
||||
const rowBusy = deletingId === conn.id || togglingId === conn.id || isResettingLimit;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -822,53 +860,90 @@ export default function ProviderLimits() {
|
||||
{getConnectionLabel(conn)}
|
||||
</p>
|
||||
) : null}
|
||||
{isCodex && (
|
||||
<p className="text-[11px] text-text-muted truncate">
|
||||
Reset eligible: {resetCreditCount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading || rowBusy}
|
||||
aria-label="Refresh quota"
|
||||
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
title="Refresh quota"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
{isCodex && (
|
||||
<Tooltip text={`Codex reset credits remaining: ${resetCreditCount}`}>
|
||||
<div
|
||||
className={`hidden h-8 items-center gap-1 rounded-lg border px-2 text-[11px] sm:flex ${
|
||||
resetCreditCount > 0
|
||||
? "border-primary/30 bg-primary/5 text-primary"
|
||||
: "border-black/10 bg-black/[0.02] text-text-muted dark:border-white/10 dark:bg-white/[0.03]"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">restart_alt</span>
|
||||
<span className="tabular-nums">{resetCreditCount}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
{isCodex && resetCreditCount > 0 && (
|
||||
<Tooltip text={`Use one Codex reset credit. Available: ${resetCreditCount}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetConfirmState({ connection: conn, resetCreditCount })}
|
||||
disabled={isLoading || rowBusy}
|
||||
className="flex h-8 items-center gap-1 rounded-lg border border-primary/30 px-2 text-[11px] text-primary transition-colors hover:bg-primary/10 disabled:opacity-50"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[15px] ${isResettingLimit ? "animate-spin" : ""}`}>
|
||||
{isResettingLimit ? "progress_activity" : "bolt"}
|
||||
</span>
|
||||
<span className="hidden lg:inline">Reset limit</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip text="Refresh quota">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refreshProvider(conn.id, conn.provider)}
|
||||
disabled={isLoading || rowBusy}
|
||||
aria-label="Refresh quota"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
disabled={rowBusy}
|
||||
aria-label="Edit connection"
|
||||
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
|
||||
title="Edit connection"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
edit
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteConnection(conn.id)}
|
||||
disabled={rowBusy}
|
||||
aria-label="Delete connection"
|
||||
className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
|
||||
title="Delete connection"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Edit connection">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedConnection(conn);
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
disabled={rowBusy}
|
||||
aria-label="Edit connection"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
|
||||
>
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
edit
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip text="Delete connection">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteConnection(conn.id)}
|
||||
disabled={rowBusy}
|
||||
aria-label="Delete connection"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
|
||||
>
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="inline-flex items-center pl-0.5"
|
||||
title={
|
||||
@@ -1047,6 +1122,25 @@ export default function ProviderLimits() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={Boolean(resetConfirmState)}
|
||||
onClose={() => {
|
||||
if (!resettingLimitId) setResetConfirmState(null);
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
const connection = resetConfirmState?.connection;
|
||||
if (!connection) return;
|
||||
await handleResetCodexLimit(connection.id, connection.provider);
|
||||
setResetConfirmState(null);
|
||||
}}
|
||||
title="Reset Codex limit?"
|
||||
message={`Use 1 Codex reset credit for ${getConnectionLabel(resetConfirmState?.connection || {}) || "this account"}. This cannot be undone. Remaining credits: ${resetConfirmState?.resetCreditCount ?? 0}.`}
|
||||
confirmText="Reset limit"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
loading={Boolean(resettingLimitId)}
|
||||
/>
|
||||
|
||||
<EditConnectionModal
|
||||
isOpen={showEditModal}
|
||||
connection={selectedConnection}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getModelAliases, setModelAlias } from "@/models";
|
||||
import { getDisabledModels } from "@/lib/disabledModelsDb";
|
||||
import { AI_MODELS } from "@/shared/constants/config";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getCapabilitiesForModel } from "open-sse/providers/capabilities.js";
|
||||
|
||||
// GET /api/models - Get models with aliases
|
||||
export async function GET() {
|
||||
@@ -18,10 +19,12 @@ export async function GET() {
|
||||
})
|
||||
.map((m) => {
|
||||
const fullModel = `${m.provider}/${m.model}`;
|
||||
const c = getCapabilitiesForModel(m.provider, m.model);
|
||||
return {
|
||||
...m,
|
||||
fullModel,
|
||||
alias: modelAliases[fullModel] || m.model,
|
||||
caps: { vision: c.vision, search: c.search, reasoning: c.reasoning },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Ensure proxyFetch is loaded to patch globalThis.fetch
|
||||
import "open-sse/index.js";
|
||||
|
||||
import { getProviderConnectionById } from "@/lib/localDb";
|
||||
import { consumeCodexRateLimitResetCredit } from "open-sse/services/usage.js";
|
||||
import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy";
|
||||
import { refreshAndUpdateCredentials } from "../route.js";
|
||||
|
||||
const AUTH_EXPIRED_PATTERNS = ["expired", "authentication", "unauthorized", "401", "re-authorize"];
|
||||
|
||||
function isAuthExpiredResult(result) {
|
||||
const values = [result?.message, result?.code, result?.raw?.detail, result?.raw?.error]
|
||||
.filter(Boolean)
|
||||
.map((value) => String(value).toLowerCase());
|
||||
return values.some((value) => AUTH_EXPIRED_PATTERNS.some((pattern) => value.includes(pattern)));
|
||||
}
|
||||
|
||||
function getResponseForConsumeResult(result, redeemRequestId) {
|
||||
if (result.ok) {
|
||||
return Response.json({
|
||||
code: result.code,
|
||||
reset: true,
|
||||
windows_reset: result.windowsReset,
|
||||
redeemRequestId,
|
||||
credit: result.raw?.credit || null,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.noCredit) {
|
||||
return Response.json({
|
||||
code: "no_credit",
|
||||
reset: false,
|
||||
windows_reset: result.windowsReset,
|
||||
message: "No Codex reset credits available.",
|
||||
}, { status: 409 });
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
code: result.code || "unknown_response",
|
||||
reset: false,
|
||||
windows_reset: result.windowsReset,
|
||||
message: result.message || "Codex reset credit consume returned an unexpected response.",
|
||||
}, { status: result.status >= 400 && result.status < 500 ? result.status : 502 });
|
||||
}
|
||||
|
||||
export async function POST(request, { params }) {
|
||||
let connection;
|
||||
try {
|
||||
const { connectionId } = await params;
|
||||
connection = await getProviderConnectionById(connectionId);
|
||||
if (!connection) {
|
||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connection.provider !== "codex") {
|
||||
return Response.json({ error: "Codex reset credits are only available for Codex connections." }, { status: 400 });
|
||||
}
|
||||
|
||||
const isOAuth = connection.authType === "oauth";
|
||||
const isAccessToken = connection.authType === "access_token";
|
||||
if (!isOAuth && !isAccessToken) {
|
||||
return Response.json({ error: "Codex reset credits require an OAuth or access-token connection." }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxyConfig = await resolveConnectionProxyConfig(connection.providerSpecificData);
|
||||
const proxyOptions = {
|
||||
connectionProxyEnabled: proxyConfig.connectionProxyEnabled === true,
|
||||
connectionProxyUrl: proxyConfig.connectionProxyUrl || "",
|
||||
connectionNoProxy: proxyConfig.connectionNoProxy || "",
|
||||
vercelRelayUrl: proxyConfig.vercelRelayUrl || "",
|
||||
strictProxy: false,
|
||||
};
|
||||
|
||||
if (isOAuth) {
|
||||
try {
|
||||
const result = await refreshAndUpdateCredentials(connection, false, proxyOptions);
|
||||
connection = result.connection;
|
||||
} catch (refreshError) {
|
||||
console.error("[Codex Reset Credits API] Credential refresh failed:", refreshError);
|
||||
return Response.json({ error: `Credential refresh failed: ${refreshError.message}` }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// Server-generated redeem id prevents client-controlled replay
|
||||
const redeemRequestId = crypto.randomUUID();
|
||||
let consumeResult = await consumeCodexRateLimitResetCredit(connection.accessToken, redeemRequestId, proxyOptions);
|
||||
|
||||
if (isOAuth && isAuthExpiredResult(consumeResult) && connection.refreshToken) {
|
||||
try {
|
||||
const retryResult = await refreshAndUpdateCredentials(connection, true, proxyOptions);
|
||||
connection = retryResult.connection;
|
||||
consumeResult = await consumeCodexRateLimitResetCredit(connection.accessToken, redeemRequestId, proxyOptions);
|
||||
} catch (retryError) {
|
||||
console.warn(`[Codex Reset Credits] force refresh failed: ${retryError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return getResponseForConsumeResult(consumeResult, redeemRequestId);
|
||||
} catch (error) {
|
||||
const provider = connection?.provider ?? "unknown";
|
||||
console.warn(`[Codex Reset Credits] ${provider}: ${error.message}`);
|
||||
return Response.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function isAuthExpiredMessage(usage) {
|
||||
* @param {boolean} force - Skip needsRefresh check and always attempt refresh
|
||||
* @returns Promise<{ connection, refreshed: boolean }>
|
||||
*/
|
||||
async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
|
||||
export async function refreshAndUpdateCredentials(connection, force = false, proxyOptions = null) {
|
||||
const executor = getExecutor(connection.provider);
|
||||
|
||||
// Build credentials object from connection
|
||||
|
||||
@@ -324,6 +324,20 @@ button {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Tailwind v4 dropped default button cursor — restore globally */
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
label[for],
|
||||
summary,
|
||||
select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled,
|
||||
[role="button"][aria-disabled="true"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Animations
|
||||
============================================================ */
|
||||
|
||||
@@ -32,7 +32,7 @@ const PUBLIC_API_PATHS = [
|
||||
];
|
||||
|
||||
// Public top-level prefixes (LLM API endpoints with their own API key auth).
|
||||
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta"];
|
||||
const PUBLIC_PREFIXES = ["/v1", "/v1beta", "/api/v1", "/api/v1beta", "/codex"];
|
||||
|
||||
// Always require JWT token regardless of requireLogin setting
|
||||
const ALWAYS_PROTECTED = [
|
||||
@@ -90,7 +90,14 @@ function isLoopbackHostname(h) {
|
||||
}
|
||||
|
||||
export function isLocalRequest(request) {
|
||||
if (!isLoopbackHostname(request.headers.get("host"))) return false;
|
||||
// Trusted peer IP from TCP socket (custom-server.js); unspoofable. Primary anchor for "local".
|
||||
const realIp = request.headers.get("x-9r-real-ip");
|
||||
if (realIp) {
|
||||
if (!isLoopbackHostname(realIp)) return false;
|
||||
} else if (!isLoopbackHostname(request.headers.get("host"))) {
|
||||
// Fallback for bare server.js (dev) without custom-server: legacy Host-based check.
|
||||
return false;
|
||||
}
|
||||
const origin = request.headers.get("origin");
|
||||
if (origin) {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { CAPACITY_META } from "@/shared/constants/models";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
// Render small icon badges for a model's capabilities (only those set true).
|
||||
// colorOverride: force a single color class for all badges (default: per-cap color).
|
||||
// size: icon font-size in px (default 16).
|
||||
export default function CapacityBadges({ caps, className = "", colorOverride, size = 16 }) {
|
||||
if (!caps) return null;
|
||||
const active = Object.keys(CAPACITY_META).filter((k) => caps[k]);
|
||||
if (active.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-0.5 ${className}`}>
|
||||
{active.map((k) => (
|
||||
<Tooltip key={k} text={`${CAPACITY_META[k].label} — ${CAPACITY_META[k].desc}`}>
|
||||
<span
|
||||
className={`material-symbols-outlined leading-none cursor-help ${colorOverride || CAPACITY_META[k].color}`}
|
||||
style={{ fontSize: `${size}px` }}
|
||||
>
|
||||
{CAPACITY_META[k].icon}
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { useState, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Modal from "./Modal";
|
||||
import ProviderIcon from "./ProviderIcon";
|
||||
import CapacityBadges from "./CapacityBadges";
|
||||
import { useModelCaps } from "@/shared/hooks/useModelCaps";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers";
|
||||
|
||||
@@ -40,6 +42,7 @@ export default function ModelSelectModal({
|
||||
return kinds.includes(kindFilter);
|
||||
});
|
||||
}, [activeProviders, kindFilter]);
|
||||
const { getCaps } = useModelCaps();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [combos, setCombos] = useState([]);
|
||||
const [providerNodes, setProviderNodes] = useState([]);
|
||||
@@ -499,9 +502,13 @@ export default function ModelSelectModal({
|
||||
<>
|
||||
{model.name}
|
||||
<span className="text-[9px] opacity-60 font-normal">custom</span>
|
||||
<CapacityBadges caps={getCaps(model.value)} />
|
||||
</>
|
||||
) : (
|
||||
model.name
|
||||
<>
|
||||
{model.name}
|
||||
<CapacityBadges caps={getCaps(model.value)} />
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -12,10 +12,10 @@ export default function Tooltip({ text, children, position = "top", color }) {
|
||||
const bgClass = color ? "" : "bg-gray-900";
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex group">
|
||||
<div className="relative inline-flex group/tt">
|
||||
{children}
|
||||
<div
|
||||
className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug ${bgClass} text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150 whitespace-normal`}
|
||||
className={`pointer-events-none absolute ${posClass} z-50 w-max max-w-56 rounded px-2 py-1 text-[11px] leading-snug ${bgClass} text-white opacity-0 group-hover/tt:opacity-100 transition-opacity duration-150 whitespace-normal`}
|
||||
style={bgStyle}
|
||||
>
|
||||
{text}
|
||||
|
||||
@@ -36,6 +36,7 @@ export { default as NoAuthProxyCard } from "./NoAuthProxyCard";
|
||||
export { default as SegmentedControl } from "./SegmentedControl";
|
||||
export { default as Tooltip } from "./Tooltip";
|
||||
export { default as ProviderInfoCard } from "./ProviderInfoCard";
|
||||
export { default as CapacityBadges } from "./CapacityBadges";
|
||||
|
||||
// Layouts
|
||||
export * from "./layouts";
|
||||
|
||||
@@ -38,3 +38,10 @@ export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) =>
|
||||
);
|
||||
|
||||
export const getModelKind = (m, fallback = null) => m?.kind || m?.type || fallback;
|
||||
|
||||
// Capacity metadata for UI badges — icon + label + color per capability.
|
||||
export const CAPACITY_META = {
|
||||
vision: { icon: "visibility", label: "Vision", desc: "Supports image input", color: "text-blue-500" },
|
||||
// search: temporarily hidden (feature not wired yet)
|
||||
reasoning: { icon: "neurology", label: "Reasoning", desc: "Supports reasoning / thinking", color: "text-amber-500" },
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Provider definitions
|
||||
import REGISTRY from "open-sse/providers/registry/index.js";
|
||||
import { RISK_NOTICE } from "@/shared/constants/providersDisplay";
|
||||
|
||||
const MEDIA_ENTRY_KEYS = [
|
||||
"serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig",
|
||||
@@ -15,8 +16,10 @@ function buildProviderEntry(r) {
|
||||
for (const k of MEDIA_ENTRY_KEYS) {
|
||||
if (r[k] !== undefined) mediaFields[k] = r[k];
|
||||
}
|
||||
const display = { ...(r.display || {}) };
|
||||
if (display.deprecationNotice === "RISK_NOTICE") display.deprecationNotice = RISK_NOTICE;
|
||||
return {
|
||||
...(r.display || {}),
|
||||
...display,
|
||||
id: r.id,
|
||||
alias: r.uiAlias || r.alias,
|
||||
...(r.hidden ? { hidden: true } : {}),
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// Shared Hooks - Export all
|
||||
export { useTheme } from "./useTheme";
|
||||
export { useModelCaps } from "./useModelCaps";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
// Fetch model capabilities once and expose a lookup by fullModel ("provider/model") or bare model id.
|
||||
export function useModelCaps() {
|
||||
const [byFull, setByFull] = useState({});
|
||||
const [byId, setById] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/models");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const full = {};
|
||||
const id = {};
|
||||
for (const m of data.models || []) {
|
||||
if (!m.caps) continue;
|
||||
if (m.fullModel) full[m.fullModel] = m.caps;
|
||||
if (m.model) id[m.model] = m.caps;
|
||||
}
|
||||
if (alive) { setByFull(full); setById(id); }
|
||||
} catch { /* ignore */ }
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
// Resolve caps from a "provider/model" string or a bare model id.
|
||||
const getCaps = (key) => {
|
||||
if (!key) return null;
|
||||
if (byFull[key]) return byFull[key];
|
||||
const bare = key.includes("/") ? key.slice(key.indexOf("/") + 1) : key;
|
||||
return byId[bare] || null;
|
||||
};
|
||||
|
||||
return { getCaps };
|
||||
}
|
||||
@@ -89,7 +89,7 @@ exports[`GOLDEN request: OpenAI → Claude > full body (system/image/tool/tool_r
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → thinking budget 1`] = `
|
||||
exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → adaptive output_config (claude 4.6+) 1`] = `
|
||||
{
|
||||
"max_tokens": 64000,
|
||||
"messages": [
|
||||
@@ -104,6 +104,9 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → thinking budge
|
||||
},
|
||||
],
|
||||
"model": "claude-opus-4-6",
|
||||
"output_config": {
|
||||
"effort": "high",
|
||||
},
|
||||
"stream": true,
|
||||
"system": [
|
||||
{
|
||||
@@ -115,10 +118,6 @@ exports[`GOLDEN request: OpenAI → Claude > reasoning_effort → thinking budge
|
||||
"type": "text",
|
||||
},
|
||||
],
|
||||
"thinking": {
|
||||
"budget_tokens": 16384,
|
||||
"type": "enabled",
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ describe("GOLDEN request: OpenAI → Claude", () => {
|
||||
expect(clean(out)).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("reasoning_effort → thinking budget", () => {
|
||||
it("reasoning_effort → adaptive output_config (claude 4.6+)", () => {
|
||||
const body = { messages: [{ role: "user", content: "hi" }], reasoning_effort: "high" };
|
||||
const out = translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, "claude-opus-4-6", body, true, { apiKey: "sk-x" }, "anthropic");
|
||||
expect(clean(out)).toMatchSnapshot();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// REAL survey: send PDF/DOCX as base64 to every active provider, using the file/document
|
||||
// shape NATIVE to each provider's format (claude=document, gemini=inlineData, openai=file).
|
||||
// Purpose: discover which providers actually accept inline base64 documents and how they
|
||||
// reject DOCX. Survey-only: logs a grouped table, never asserts on accept/reject outcome.
|
||||
//
|
||||
// RUN_REAL=1 npx vitest run --config tests/vitest.config.js tests/translator/real/file-base64-survey.real.test.js
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
|
||||
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
|
||||
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
|
||||
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
|
||||
import { getTargetFormat } from "../../../open-sse/services/provider.js";
|
||||
|
||||
const RUN_REAL = process.env.RUN_REAL === "1";
|
||||
const TIMEOUT_MS = 90000;
|
||||
const CRED_ISSUE = [401, 402, 403, 429];
|
||||
const CRED_MSG_RE = /subscription|unauthorized|invalid api key|invalid access token|insufficient|credits|payment|spending|organization policy|disallowed|quota|exhausted|not supported when using|not available for integrator|requires a subscription|model.*not found|does not exist|not yet known|requires a role|invalid model id/i;
|
||||
const NON_CHAT_KINDS = new Set(["embedding", "image", "imageToText", "tts", "stt", "video", "music", "webSearch"]);
|
||||
|
||||
const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
const PDF_B64 = "JVBERi0xLjEKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjAwIDIwMF0+PmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1MiAwMDAwMCBuIAowMDAwMDAwMTAxIDAwMDAwIG4gCnRyYWlsZXI8PC9TaXplIDQvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgoxNjYKJSVFT0Y=";
|
||||
const DOCX_B64 = "UEsDBBQAAAAIAMZiz1yRzx8FvQAAACkBAAATABwAW0NvbnRlbnRfVHlwZXNdLnhtbFVUCQADA4wvagOML2p1eAsAAQT1AQAABAAAAAB9kL0OwjAMhF8lyoqoCwMDassArMDAC1ipWyKaHyXm7+1xATEwMNrf3fnkanV3g7pSyjb4Ws+KUq+a6viIlJUQn2t9Yo5LgGxO5DAXIZIX0oXkkGVMPUQ0Z+wJ5mW5ABM8k+cpjxm6qTbU4WVgtb3L+n1F7Fqt37rxVK0xxsEaZMEwUmiqvZRKtiV1wMQ7dKKCW0gttMFcnDiL/zFX3/50nYaus4a+/jEtpmAoZ+t7NxRf4tD6yacHvJ7RPAFQSwMECgAAAAAAxmLPXAAAAAAAAAAAAAAAAAUAHAB3b3JkL1VUCQADA4wvagOML2p1eAsAAQT1AQAABAAAAABQSwMEFAAAAAgAxmLPXD1WbTiKAAAAwAAAABEAHAB3b3JkL2RvY3VtZW50LnhtbFVUCQADA4wvagOML2p1eAsAAQT1AQAABAAAAABFjtEOgjAMRX9l2QdQ9MEHMuDV30BWgWRbl7aK/r0bxvhymuakt9eNrxjME1k2Sr09Na0dB7d3nuZHxKSm6CTd3ttVNXcAMq8YJ2koYyruThwnLSsvsBP7zDSjyJaWGODctheI05ZsjbyRf9eZK7hChyuGQKYcBm8URc3vr4OqK/lgPviNgH+94QNQSwECHgMUAAAACADGYs9ckc8fBb0AAAApAQAAEwAYAAAAAAABAAAApIEAAAAAW0NvbnRlbnRfVHlwZXNdLnhtbFVUBQADA4wvanV4CwABBPUBAAAEAAAAAFBLAQIeAwoAAAAAAMZiz1wAAAAAAAAAAAAAAAAFABgAAAAAAAAAEADtQQoBAAB3b3JkL1VUBQADA4wvanV4CwABBPUBAAAEAAAAAFBLAQIeAxQAAAAIAMZiz1w9Vm04igAAAMAAAAARABgAAAAAAAEAAACkgUkBAAB3b3JkL2RvY3VtZW50LnhtbFVUBQADA4wvanV4CwABBPUBAAAEAAAAAFBLBQYAAAAAAwADAPsAAAAeAgAAAAA=";
|
||||
|
||||
const PDF_MIME = "application/pdf";
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
// Build a native-format request body carrying one base64 document.
|
||||
// fmt = provider target format; returns { sourceFormat, body } or null if format unsupported here.
|
||||
function buildFileBody(fmt, mime, b64) {
|
||||
const ask = "Summarize this document in one word.";
|
||||
if (fmt === "claude") {
|
||||
return { sourceFormat: "claude", body: {
|
||||
max_tokens: 64, stream: true,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: ask },
|
||||
{ type: "document", source: { type: "base64", media_type: mime, data: b64 } },
|
||||
] }],
|
||||
} };
|
||||
}
|
||||
if (fmt === "gemini" || fmt === "gemini-cli" || fmt === "antigravity") {
|
||||
const gem = {
|
||||
contents: [{ role: "user", parts: [
|
||||
{ text: ask },
|
||||
{ inlineData: { mimeType: mime, data: b64 } },
|
||||
] }],
|
||||
generationConfig: { maxOutputTokens: 64 },
|
||||
};
|
||||
return fmt === "antigravity"
|
||||
? { sourceFormat: "antigravity", body: { request: gem, userAgent: "antigravity" } }
|
||||
: { sourceFormat: fmt, body: gem };
|
||||
}
|
||||
// openai + openai-compat: Chat Completions file block (file_data data URI).
|
||||
return { sourceFormat: "openai", body: {
|
||||
max_tokens: 64, stream: true,
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: ask },
|
||||
{ type: "file", file: { filename: `doc.${mime === PDF_MIME ? "pdf" : "docx"}`, file_data: `data:${mime};base64,${b64}` } },
|
||||
] }],
|
||||
} };
|
||||
}
|
||||
|
||||
const results = [];
|
||||
|
||||
async function drainSSE(response) {
|
||||
if (!response?.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function chatModels(providerId) {
|
||||
return getModelsByProviderId(providerId).filter((m) => !NON_CHAT_KINDS.has(m.kind || m.type || "llm"));
|
||||
}
|
||||
|
||||
function targetProviders() {
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const dbPath = process.env.DATA_DIR
|
||||
? path.join(process.env.DATA_DIR, "db", "data.sqlite")
|
||||
: path.join(os.homedir(), ".9router", "db", "data.sqlite");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all();
|
||||
db.close();
|
||||
let list = rows.map((r) => r.provider).sort();
|
||||
if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p));
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// One model per provider is enough to learn provider-level document support (saves quota).
|
||||
const FILE_TYPES = [["pdf", PDF_MIME, PDF_B64], ["docx", DOCX_MIME, DOCX_B64]];
|
||||
|
||||
describe.skipIf(!RUN_REAL)("REAL file base64 survey", () => {
|
||||
const providers = RUN_REAL ? targetProviders() : [];
|
||||
|
||||
it("has active providers in DB", () => {
|
||||
expect(providers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const providerId of providers) {
|
||||
const model = (RUN_REAL ? chatModels(providerId)[0]?.id : null);
|
||||
if (!model) continue;
|
||||
for (const [kind, mime, b64] of FILE_TYPES) {
|
||||
it.concurrent(`${kind} | ${providerId} / ${model}`, async () => {
|
||||
const fmt = getTargetFormat(providerId);
|
||||
const { sourceFormat, body } = buildFileBody(fmt, mime, b64);
|
||||
|
||||
const credentials = await getProviderCredentials(providerId, new Set(), model);
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
results.push({ kind, providerId, model, fmt, status: "no-cred", verdict: "skip" });
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
const refreshed = await checkAndRefreshToken(providerId, credentials);
|
||||
|
||||
const result = await handleChatCore({
|
||||
body: { ...body, model: `${providerId}/${model}` },
|
||||
modelInfo: { provider: providerId, model },
|
||||
credentials: refreshed,
|
||||
connectionId: credentials.connectionId,
|
||||
sourceFormatOverride: sourceFormat,
|
||||
});
|
||||
|
||||
const status = Number(result.status) || (result.success ? 200 : 0);
|
||||
const errMsg = result.success ? "" : String(result.error || "");
|
||||
const credIssue = CRED_ISSUE.includes(status) || CRED_MSG_RE.test(errMsg);
|
||||
const ok = result.success;
|
||||
|
||||
let verdict;
|
||||
if (credIssue) verdict = "skip-cred";
|
||||
else if (ok) verdict = `ok-${kind}`;
|
||||
else verdict = `reject-${kind}`;
|
||||
|
||||
if (ok) await drainSSE(result.response).catch(() => {});
|
||||
results.push({ kind, providerId, model, fmt, status, verdict, error: ok ? "" : errMsg.slice(0, 90) });
|
||||
return expect(true).toBe(true);
|
||||
}, TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
const w = (s) => process.stdout.write(s + "\n");
|
||||
if (!results.length) { w("[file-survey] no results collected"); return; }
|
||||
const rank = (v) => (v.startsWith("ok") ? 0 : v.startsWith("reject") ? 1 : 2);
|
||||
const groups = [...new Set(results.map((r) => r.verdict))].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
||||
w("\n================ FILE BASE64 SURVEY ================");
|
||||
for (const g of groups) {
|
||||
const rows = results.filter((r) => r.verdict === g);
|
||||
if (!rows.length) continue;
|
||||
w(`\n### ${g} (${rows.length})`);
|
||||
for (const r of rows) {
|
||||
w(` [${r.status}] ${r.kind} ${r.providerId}/${r.model} fmt=${r.fmt}${r.error ? ` :: ${r.error}` : ""}`);
|
||||
}
|
||||
}
|
||||
w("\n================ END SURVEY ================\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// REAL integration test for thinking normalization: sends a reasoning prompt with
|
||||
// reasoning_effort to every thinking-capable provider that has an active credential,
|
||||
// then asserts the upstream accepted it (no 400) and emitted reasoning output.
|
||||
// Gated by RUN_REAL=1 so the default `vitest run` never touches the network.
|
||||
//
|
||||
// RUN_REAL=1 npx vitest run -c tests/vitest.config.js "tests/translator/real/thinking"
|
||||
// RUN_REAL=1 REAL_PROVIDERS=claude,glm,deepseek npx vitest run -c tests/vitest.config.js "tests/translator/real/thinking"
|
||||
//
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
|
||||
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
|
||||
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
|
||||
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
|
||||
import { getCapabilitiesForModel } from "../../../open-sse/providers/capabilities.js";
|
||||
|
||||
const RUN_REAL = process.env.RUN_REAL === "1";
|
||||
const MAX_TOKENS = 512;
|
||||
const TIMEOUT_MS = 120000;
|
||||
const EFFORT = process.env.THINK_EFFORT || "high";
|
||||
const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
// First plain llm model that the capability registry marks as reasoning-capable.
|
||||
function firstReasoningModel(providerId) {
|
||||
const models = getModelsByProviderId(providerId);
|
||||
for (const m of models) {
|
||||
if ((m.type || "llm") !== "llm") continue;
|
||||
if (/embedding|image|tts|whisper|rerank|vision-model/i.test(m.id)) continue;
|
||||
if (getCapabilitiesForModel(providerId, m.id).reasoning) return m.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function drainSSE(response) {
|
||||
if (!response?.body) return "";
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let out = "";
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
out += decoder.decode(value, { stream: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe.skipIf(!RUN_REAL).concurrent("REAL thinking normalization", () => {
|
||||
it("has active providers in DB", () => {
|
||||
expect(targetProviders().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
for (const providerId of (RUN_REAL ? targetProviders() : [])) {
|
||||
it.concurrent(
|
||||
`${providerId}: accepts reasoning_effort=${EFFORT} and reasons`,
|
||||
async () => {
|
||||
const model = firstReasoningModel(providerId);
|
||||
if (!model) {
|
||||
console.warn(`[skip] ${providerId}: no reasoning-capable model`);
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
|
||||
const credentials = await getProviderCredentials(providerId, new Set(), model);
|
||||
if (!credentials || credentials.allRateLimited) {
|
||||
console.warn(`[skip] ${providerId}: no usable credential`);
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
|
||||
const refreshed = await checkAndRefreshToken(providerId, credentials);
|
||||
const result = await handleChatCore({
|
||||
body: {
|
||||
model: `${providerId}/${model}`,
|
||||
stream: true,
|
||||
max_tokens: MAX_TOKENS,
|
||||
reasoning_effort: EFFORT,
|
||||
messages: [{ role: "user", content: "Think step by step, then answer: what is 17 * 23?" }],
|
||||
},
|
||||
modelInfo: { provider: providerId, model },
|
||||
credentials: refreshed,
|
||||
connectionId: credentials.connectionId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// 400 = our thinking payload was rejected → real bug. Other codes = credential/quota.
|
||||
const credIssue = [401, 402, 403, 429].includes(Number(result.status));
|
||||
if (credIssue) {
|
||||
console.warn(`[skip] ${providerId}: ${result.status} (credential/quota)`);
|
||||
return expect(true).toBe(true);
|
||||
}
|
||||
console.error(`[REJECT] ${providerId}/${model} ${result.status}:`, JSON.stringify(result.error)?.slice(0, 600));
|
||||
throw new Error(`${providerId}/${model} thinking REJECTED: ${result.status}`);
|
||||
}
|
||||
|
||||
const raw = await drainSSE(result.response);
|
||||
expect(raw.length, `${providerId}: empty response`).toBeGreaterThan(0);
|
||||
const hasReasoning = /reasoning_content|"thinking"|reasoning_details|<think/.test(raw);
|
||||
// Log so the operator can eyeball which providers actually streamed reasoning.
|
||||
console.log(`[ok] ${providerId}/${model} reasoning=${hasReasoning}`);
|
||||
expect(/data:|"delta"|"content"|finish_reason/.test(raw), `${providerId}: not SSE`).toBe(true);
|
||||
},
|
||||
TIMEOUT_MS
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function targetProviders() {
|
||||
try {
|
||||
const Database = require("better-sqlite3");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const dbPath = process.env.DATA_DIR
|
||||
? path.join(process.env.DATA_DIR, "db", "data.sqlite")
|
||||
: path.join(os.homedir(), ".9router", "db", "data.sqlite");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all();
|
||||
db.close();
|
||||
let list = rows.map((r) => r.provider).sort();
|
||||
if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p));
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,11 @@ const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
|
||||
const PNG_DATA_URI = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAFklEQVR4nGO4I2JDEmIY1TCqYfhqAAAeBCwQ8YdREQAAAABJRU5ErkJggg==";
|
||||
// Tiny silent WAV (44-byte header, no samples) — enough to probe audioInput acceptance.
|
||||
const WAV_B64 = "UklGRiQAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=";
|
||||
// Stable public image URL (probe remote-URL handling vs base64).
|
||||
const IMAGE_REMOTE_URL = "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png";
|
||||
// Tiny valid PDF (probe file/document handling).
|
||||
const PDF_B64 = "JVBERi0xLjEKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgMjAwIDIwMF0+PmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1MiAwMDAwMCBuIAowMDAwMDAwMTAxIDAwMDAwIG4gCnRyYWlsZXI8PC9TaXplIDQvUm9vdCAxIDAgUj4+CnN0YXJ0eHJlZgoxNjYKJSVFT0Y=";
|
||||
const PDF_DATA_URI = `data:application/pdf;base64,${PDF_B64}`;
|
||||
|
||||
// Capability probes: each sends one modality-specific content block. cap = capability flag tested.
|
||||
const CAPABILITY_PROBES = {
|
||||
@@ -42,6 +47,20 @@ const CAPABILITY_PROBES = {
|
||||
{ type: "input_audio", input_audio: { data: WAV_B64, format: "wav" } },
|
||||
],
|
||||
},
|
||||
imageUrl: {
|
||||
cap: "vision",
|
||||
content: [
|
||||
{ type: "text", text: "What is in this image? One word." },
|
||||
{ type: "image_url", image_url: { url: IMAGE_REMOTE_URL } },
|
||||
],
|
||||
},
|
||||
file: {
|
||||
cap: "pdf",
|
||||
content: [
|
||||
{ type: "text", text: "Summarize this document. One word." },
|
||||
{ type: "file", file: { filename: "doc.pdf", file_data: PDF_DATA_URI } },
|
||||
],
|
||||
},
|
||||
};
|
||||
// Set PROBES=vision,audio (default vision only to limit live quota).
|
||||
const ACTIVE_PROBES = (process.env.PROBES || "vision").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Unit tests for unified thinking normalization (thinkingUnified.js).
|
||||
// Covers extract, suffix parse, and per-provider apply per MATRIX (.docs/thinking/plan.md).
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
parseSuffix,
|
||||
extractThinking,
|
||||
applyThinking,
|
||||
} from "../../open-sse/translator/concerns/thinkingUnified.js";
|
||||
import { extractReasoningText } from "../../open-sse/translator/concerns/reasoning.js";
|
||||
|
||||
const apply = (targetFormat, model, body, provider) => {
|
||||
const b = JSON.parse(JSON.stringify(body));
|
||||
applyThinking(targetFormat, model, b, provider);
|
||||
return b;
|
||||
};
|
||||
|
||||
describe("parseSuffix", () => {
|
||||
it("parses level suffix", () => {
|
||||
expect(parseSuffix("gpt-5(high)")).toEqual({ cleanModel: "gpt-5", override: { mode: "level", level: "high" } });
|
||||
});
|
||||
it("parses numeric budget suffix", () => {
|
||||
expect(parseSuffix("model(8192)")).toEqual({ cleanModel: "model", override: { mode: "budget", budget: 8192 } });
|
||||
});
|
||||
it("parses auto / none", () => {
|
||||
expect(parseSuffix("m(auto)").override).toEqual({ mode: "auto" });
|
||||
expect(parseSuffix("m(none)").override).toEqual({ mode: "none" });
|
||||
});
|
||||
it("no suffix → passthrough", () => {
|
||||
expect(parseSuffix("claude-opus-4.7")).toEqual({ cleanModel: "claude-opus-4.7", override: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractThinking", () => {
|
||||
it("claude enabled+budget", () => {
|
||||
expect(extractThinking({ thinking: { type: "enabled", budget_tokens: 4096 } })).toEqual({ mode: "budget", budget: 4096 });
|
||||
});
|
||||
it("claude disabled", () => {
|
||||
expect(extractThinking({ thinking: { type: "disabled" } })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("openai reasoning_effort", () => {
|
||||
expect(extractThinking({ reasoning_effort: "high" })).toEqual({ mode: "level", level: "high" });
|
||||
});
|
||||
it("responses reasoning.effort none", () => {
|
||||
expect(extractThinking({ reasoning: { effort: "none" } })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("gemini thinkingBudget 0 → none", () => {
|
||||
expect(extractThinking({ thinkingConfig: { thinkingBudget: 0 } })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("qwen enable_thinking false", () => {
|
||||
expect(extractThinking({ enable_thinking: false })).toEqual({ mode: "none" });
|
||||
});
|
||||
it("no intent → null", () => {
|
||||
expect(extractThinking({ messages: [] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyThinking per provider format", () => {
|
||||
it("claude 4.6+ → adaptive output_config (no budget_tokens)", () => {
|
||||
const out = apply("claude", "claude-opus-4.7", { reasoning_effort: "high" }, "claude");
|
||||
expect(out.output_config).toEqual({ effort: "high" });
|
||||
expect(out.thinking).toBeUndefined();
|
||||
});
|
||||
it("claude haiku → enabled+budget", () => {
|
||||
const out = apply("claude", "claude-haiku-4.5", { reasoning_effort: "high" }, "claude");
|
||||
expect(out.thinking).toEqual({ type: "enabled", budget_tokens: 24576 });
|
||||
});
|
||||
it("gemini-3 → thinkingLevel", () => {
|
||||
const out = apply("gemini", "gemini-3-pro", { reasoning_effort: "medium" }, "gemini");
|
||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBe("medium");
|
||||
});
|
||||
it("gemini-2.5 → thinkingBudget", () => {
|
||||
const out = apply("gemini", "gemini-2.5-flash", { reasoning_effort: "high" }, "gemini");
|
||||
expect(out.generationConfig.thinkingConfig.thinkingBudget).toBe(24576);
|
||||
expect(out.generationConfig.thinkingConfig.thinkingLevel).toBeUndefined();
|
||||
});
|
||||
it("GLM off → enable_thinking:false (not thinking.disabled)", () => {
|
||||
const out = apply("openai", "glm-4.6", { reasoning_effort: "none" }, "glm");
|
||||
expect(out.enable_thinking).toBe(false);
|
||||
expect(out.thinking).toBeUndefined();
|
||||
});
|
||||
it("Qwen on → enable_thinking + thinking_budget", () => {
|
||||
const out = apply("openai", "qwen3-max", { reasoning_effort: "medium" }, "qwen");
|
||||
expect(out.enable_thinking).toBe(true);
|
||||
expect(out.thinking_budget).toBe(8192);
|
||||
});
|
||||
it("QwQ cannot disable → clamp minimal", () => {
|
||||
const out = apply("openai", "qwq-32b", { reasoning_effort: "none" }, "qwen");
|
||||
expect(out.enable_thinking).toBe(true);
|
||||
});
|
||||
it("DeepSeek → enabled + reasoning_effort high (low→high)", () => {
|
||||
const out = apply("openai", "deepseek-v4-pro", { reasoning_effort: "low" }, "deepseek");
|
||||
expect(out.thinking).toEqual({ type: "enabled" });
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
});
|
||||
it("Kimi on → reasoning_effort", () => {
|
||||
const out = apply("openai", "kimi-k2.6", { reasoning_effort: "high" }, "kimi");
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
});
|
||||
it("MiniMax M3 → adaptive", () => {
|
||||
const out = apply("claude", "MiniMax-M3", { reasoning_effort: "high" }, "minimax");
|
||||
expect(out.thinking).toEqual({ type: "adaptive" });
|
||||
});
|
||||
it("non-reasoning model → strips thinking", () => {
|
||||
const out = apply("openai", "gpt-4o", { reasoning_effort: "high" }, "openai");
|
||||
expect(out.reasoning_effort).toBeUndefined();
|
||||
});
|
||||
it("aggregator (siliconflow) GLM model → forced openai reasoning_effort", () => {
|
||||
const out = apply("openai", "zai-org/GLM-5", { reasoning_effort: "high" }, "siliconflow");
|
||||
expect(out.reasoning_effort).toBe("high");
|
||||
expect(out.enable_thinking).toBeUndefined();
|
||||
});
|
||||
it("suffix overrides body", () => {
|
||||
const out = apply("openai", "gpt-5(low)", { reasoning_effort: "high" }, "openai");
|
||||
expect(out.reasoning_effort).toBe("low");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractReasoningText (response shapes)", () => {
|
||||
it("reasoning_content (GLM/Qwen/DeepSeek)", () => {
|
||||
expect(extractReasoningText({ reasoning_content: "abc" })).toBe("abc");
|
||||
});
|
||||
it("reasoning fallback", () => {
|
||||
expect(extractReasoningText({ reasoning: "xyz" })).toBe("xyz");
|
||||
});
|
||||
it("reasoning_details[] (MiniMax split)", () => {
|
||||
expect(extractReasoningText({ reasoning_details: [{ text: "a" }, { content: "b" }, "c"] })).toBe("abc");
|
||||
});
|
||||
it("no reasoning → empty", () => {
|
||||
expect(extractReasoningText({ content: "hello" })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -9,24 +9,40 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
// Mock DNS so the SSRF guard treats example.com as public.
|
||||
vi.mock("node:dns/promises", () => ({ lookup: async () => ({ address: "93.184.216.34" }) }));
|
||||
|
||||
import { CodexExecutor } from "../../open-sse/executors/codex.js";
|
||||
import * as proxyFetchModule from "../../open-sse/utils/proxyFetch.js";
|
||||
|
||||
const IMAGE_1MB_BYTES = 1024 * 1024;
|
||||
const REMOTE_URL = "https://example.com/big.jpg";
|
||||
const DATA_URI = "data:image/png;base64,iVBORw0KGgo=";
|
||||
// JPEG magic bytes (FF D8 FF) so magic-byte verification passes.
|
||||
const JPEG_MAGIC = [0xff, 0xd8, 0xff];
|
||||
|
||||
function makeImageBuffer(sizeBytes) {
|
||||
const buf = new Uint8Array(sizeBytes);
|
||||
for (let i = 0; i < sizeBytes; i++) buf[i] = i & 0xff;
|
||||
return buf.buffer;
|
||||
for (let i = 0; i < JPEG_MAGIC.length; i++) buf[i] = JPEG_MAGIC[i];
|
||||
for (let i = JPEG_MAGIC.length; i < sizeBytes; i++) buf[i] = i & 0xff;
|
||||
return buf;
|
||||
}
|
||||
|
||||
function mockImageFetch(sizeBytes, mimeType = "image/jpeg") {
|
||||
// Mock a streaming Response body (getReader) as the hardened fetcher expects.
|
||||
function mockImageFetch(sizeBytes) {
|
||||
const bytes = makeImageBuffer(sizeBytes);
|
||||
return {
|
||||
ok: true,
|
||||
headers: { get: (k) => (k === "Content-Type" ? mimeType : null) },
|
||||
arrayBuffer: async () => makeImageBuffer(sizeBytes),
|
||||
body: {
|
||||
getReader() {
|
||||
let sent = false;
|
||||
return {
|
||||
read: async () => sent ? { done: true } : (sent = true, { done: false, value: bytes }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { detectRequiredCapabilities, reorderByCapabilities } from "../../open-sse/services/combo.js";
|
||||
|
||||
describe("detectRequiredCapabilities", () => {
|
||||
it("text-only -> empty", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: "hi" }] });
|
||||
expect(r.size).toBe(0);
|
||||
});
|
||||
|
||||
it("openai image_url -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: [
|
||||
{ type: "image_url", image_url: { url: "x" } },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("openai file -> pdf", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: [
|
||||
{ type: "file", file: { file_data: "data:application/pdf;base64,x" } },
|
||||
] }] });
|
||||
expect(r.has("pdf")).toBe(true);
|
||||
});
|
||||
|
||||
it("gemini inlineData image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("antigravity request.contents image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ request: { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/jpeg", data: "x" } },
|
||||
] }] } });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
|
||||
it("web_search tool -> search", () => {
|
||||
const r = detectRequiredCapabilities({ messages: [{ role: "user", content: "q" }], tools: [
|
||||
{ type: "web_search" },
|
||||
] });
|
||||
expect(r.has("search")).toBe(true);
|
||||
});
|
||||
|
||||
it("responses input_image -> vision", () => {
|
||||
const r = detectRequiredCapabilities({ input: [{ role: "user", content: [
|
||||
{ type: "input_image", image_url: "x" },
|
||||
] }] });
|
||||
expect(r.has("vision")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderByCapabilities", () => {
|
||||
it("no required -> unchanged", () => {
|
||||
const models = ["a/x", "b/y"];
|
||||
expect(reorderByCapabilities(models, new Set())).toBe(models);
|
||||
});
|
||||
|
||||
it("floats vision-capable model to front, keeps fallback", () => {
|
||||
// deepseek-chat = no vision; claude-sonnet = vision
|
||||
const models = ["deepseek/deepseek-chat", "anthropic/claude-sonnet-4.6"];
|
||||
const out = reorderByCapabilities(models, new Set(["vision"]));
|
||||
expect(out[0]).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(out).toContain("deepseek/deepseek-chat"); // not dropped
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps order when no model matches", () => {
|
||||
const models = ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"];
|
||||
const out = reorderByCapabilities(models, new Set(["vision"]));
|
||||
expect(out).toBe(models);
|
||||
});
|
||||
|
||||
it("single model -> unchanged", () => {
|
||||
const models = ["a/x"];
|
||||
expect(reorderByCapabilities(models, new Set(["vision"]))).toBe(models);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,26 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote Host-spoof when real peer IP is non-loopback", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
host: "localhost",
|
||||
"x-9r-real-ip": "10.204.111.34",
|
||||
}));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows loopback peer IP regardless of Host", async () => {
|
||||
const response = await proxy(request("/v1/chat/completions", {
|
||||
host: "localhost:20128",
|
||||
"x-9r-real-ip": "127.0.0.1",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects remote rewritten public LLM API without API key", async () => {
|
||||
const response = await proxy(request("/api/v1/chat/completions", { host: "router.example.com" }));
|
||||
|
||||
@@ -89,6 +109,25 @@ describe("dashboard guard public LLM API access", () => {
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("rejects remote codex rewrite without API key", async () => {
|
||||
const response = await proxy(request("/codex/x", { host: "router.example.com" }));
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(response.body.error).toBe("API key required for remote API access");
|
||||
});
|
||||
|
||||
it("allows remote codex rewrite with valid API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
const response = await proxy(request("/codex/x", {
|
||||
host: "router.example.com",
|
||||
authorization: "Bearer sk-valid",
|
||||
}));
|
||||
|
||||
expect(response).toBe(mocks.nextResponse);
|
||||
expect(mocks.validateApiKey).toHaveBeenCalledWith("sk-valid");
|
||||
});
|
||||
|
||||
it("allows remote public LLM API with valid bearer API key", async () => {
|
||||
mocks.validateApiKey.mockResolvedValue(true);
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { convertOpenAIContentToParts } from "../../open-sse/translator/formats/gemini.js";
|
||||
import { openaiToClaudeRequest } from "../../open-sse/translator/request/openai-to-claude.js";
|
||||
import { VALID_OPENAI_CONTENT_TYPES, OPENAI_BLOCK, CLAUDE_BLOCK } from "../../open-sse/translator/schema/index.js";
|
||||
|
||||
const PDF_DATA = "data:application/pdf;base64,JVBERi0xLjE=";
|
||||
const PNG_DATA = "data:image/png;base64,iVBORw0KGgo=";
|
||||
|
||||
describe("file/document block support", () => {
|
||||
it("schema: file is a valid openai content type", () => {
|
||||
expect(VALID_OPENAI_CONTENT_TYPES).toContain(OPENAI_BLOCK.FILE);
|
||||
expect(OPENAI_BLOCK.FILE).toBe("file");
|
||||
expect(CLAUDE_BLOCK.DOCUMENT).toBe("document");
|
||||
});
|
||||
|
||||
it("gemini: openai file block -> inlineData", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "text", text: "read this" },
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: PDF_DATA } },
|
||||
]);
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
expect(inline).toBeTruthy();
|
||||
expect(inline.inlineData.mime_type).toBe("application/pdf");
|
||||
expect(inline.inlineData.data).toBe("JVBERi0xLjE=");
|
||||
});
|
||||
|
||||
it("gemini: ignores non-data-uri file", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: "https://x/d.pdf" } },
|
||||
]);
|
||||
expect(parts.some((p) => p.inlineData)).toBe(false);
|
||||
});
|
||||
|
||||
it("claude: openai file (pdf) -> document block", () => {
|
||||
const out = openaiToClaudeRequest("claude-x", {
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "read" },
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: PDF_DATA } },
|
||||
] }],
|
||||
}, false);
|
||||
const blocks = out.messages[0].content;
|
||||
const doc = blocks.find((b) => b.type === "document");
|
||||
expect(doc).toBeTruthy();
|
||||
expect(doc.source.media_type).toBe("application/pdf");
|
||||
});
|
||||
|
||||
it("claude: non-pdf file is dropped (not a document)", () => {
|
||||
const out = openaiToClaudeRequest("claude-x", {
|
||||
messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "read" },
|
||||
{ type: "file", file: { filename: "i.png", file_data: PNG_DATA } },
|
||||
] }],
|
||||
}, false);
|
||||
const blocks = out.messages[0].content;
|
||||
expect(blocks.some((b) => b.type === "document")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Mock DNS lookup so we control which host resolves to what IP.
|
||||
const lookupMock = vi.fn();
|
||||
vi.mock("node:dns/promises", () => ({ lookup: (...a) => lookupMock(...a) }));
|
||||
|
||||
import { fetchImageAsBase64 } from "../../open-sse/translator/concerns/image.js";
|
||||
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
|
||||
function mockFetchOnce(bytes, ok = true) {
|
||||
const body = {
|
||||
getReader() {
|
||||
let sent = false;
|
||||
return {
|
||||
read: async () => sent ? { done: true } : (sent = true, { done: false, value: new Uint8Array(bytes) }),
|
||||
cancel: async () => {},
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.fetch = vi.fn(async () => ({ ok, body }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
lookupMock.mockReset();
|
||||
lookupMock.mockResolvedValue({ address: "93.184.216.34" }); // public by default
|
||||
});
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
describe("fetchImageAsBase64 hardening", () => {
|
||||
it("rejects non-http url", async () => {
|
||||
expect(await fetchImageAsBase64("ftp://x/y.png")).toBeNull();
|
||||
expect(await fetchImageAsBase64("data:image/png;base64,xx")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects private IP (10.x)", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "10.0.0.5" });
|
||||
expect(await fetchImageAsBase64("http://internal.example/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects cloud metadata 169.254.169.254", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "169.254.169.254" });
|
||||
expect(await fetchImageAsBase64("http://metadata/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects blocked hostname localhost", async () => {
|
||||
expect(await fetchImageAsBase64("http://localhost/x.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("SSRF: rejects IPv6 loopback", async () => {
|
||||
lookupMock.mockResolvedValue({ address: "::1" });
|
||||
expect(await fetchImageAsBase64("http://x/y.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts valid PNG from public host", async () => {
|
||||
mockFetchOnce(PNG);
|
||||
const r = await fetchImageAsBase64("https://example.com/a.png");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r.mimeType).toBe("image/png");
|
||||
expect(r.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects disguised non-image payload (magic byte mismatch)", async () => {
|
||||
mockFetchOnce(Buffer.from("<?php system($_GET[c]); ?>"));
|
||||
expect(await fetchImageAsBase64("https://example.com/evil.png")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects payload over size cap", async () => {
|
||||
mockFetchOnce(Buffer.alloc(1024));
|
||||
expect(await fetchImageAsBase64("https://example.com/big.png", { maxBytes: 100 })).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetch not ok", async () => {
|
||||
mockFetchOnce(PNG, false);
|
||||
expect(await fetchImageAsBase64("https://example.com/404.png")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { stripUnsupportedModalities } from "../../open-sse/translator/concerns/modality.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const NO_VISION = { vision: false, audioInput: true, pdf: true };
|
||||
const NO_AUDIO = { vision: true, audioInput: false, pdf: true };
|
||||
const NO_PDF = { vision: true, audioInput: true, pdf: false };
|
||||
const ALL = { vision: true, audioInput: true, pdf: true };
|
||||
|
||||
describe("stripUnsupportedModalities", () => {
|
||||
it("fast-exits when model supports all modalities", () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }] };
|
||||
expect(stripUnsupportedModalities(body, FORMATS.OPENAI, ALL)).toBe(false);
|
||||
expect(body.messages[0].content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("openai: strips image when vision:false, leaves placeholder", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,xx" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_VISION);
|
||||
const types = body.messages[0].content.map((b) => b.type);
|
||||
expect(types).toContain("text");
|
||||
expect(types).not.toContain("image_url");
|
||||
expect(body.messages[0].content.some((b) => b.type === "text" && /image omitted/.test(b.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("openai: strips input_audio when audioInput:false", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "input_audio", input_audio: { data: "x", format: "wav" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_AUDIO);
|
||||
expect(body.messages[0].content.some((b) => b.type === "input_audio")).toBe(false);
|
||||
expect(body.messages[0].content.some((b) => /audio omitted/.test(b.text || ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("openai: strips file when pdf:false", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "file", file: { filename: "d.pdf", file_data: "data:application/pdf;base64,x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_PDF);
|
||||
expect(body.messages[0].content.some((b) => b.type === "file")).toBe(false);
|
||||
});
|
||||
|
||||
it("openai: keeps image when vision:true", () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "x" } }] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI, NO_AUDIO);
|
||||
expect(body.messages[0].content.some((b) => b.type === "image_url")).toBe(true);
|
||||
});
|
||||
|
||||
it("claude: strips image + document by capability", () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "hi" },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "x" } },
|
||||
{ type: "document", source: { type: "base64", media_type: "application/pdf", data: "x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.CLAUDE, { vision: false, audioInput: true, pdf: false });
|
||||
const types = body.messages[0].content.map((b) => b.type);
|
||||
expect(types).not.toContain("image");
|
||||
expect(types).not.toContain("document");
|
||||
expect(types).toContain("text");
|
||||
});
|
||||
|
||||
it("gemini: strips inlineData image by mime when vision:false", () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ text: "hi" },
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.GEMINI, NO_VISION);
|
||||
expect(body.contents[0].parts.some((p) => p.inlineData)).toBe(false);
|
||||
expect(body.contents[0].parts.some((p) => /image omitted/.test(p.text || ""))).toBe(true);
|
||||
});
|
||||
|
||||
it("gemini: keeps inlineData pdf when pdf:true, strips image when vision:false", () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
{ inlineData: { mimeType: "application/pdf", data: "y" } },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.GEMINI, NO_VISION);
|
||||
const mimes = body.contents[0].parts.filter((p) => p.inlineData).map((p) => p.inlineData.mimeType);
|
||||
expect(mimes).toEqual(["application/pdf"]);
|
||||
});
|
||||
|
||||
it("antigravity: strips inside request.contents", () => {
|
||||
const body = { request: { contents: [{ role: "user", parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "x" } },
|
||||
] }] } };
|
||||
stripUnsupportedModalities(body, FORMATS.ANTIGRAVITY, NO_VISION);
|
||||
expect(body.request.contents[0].parts.some((p) => p.inlineData)).toBe(false);
|
||||
});
|
||||
|
||||
it("responses: strips input_image when vision:false", () => {
|
||||
const body = { input: [{ role: "user", content: [
|
||||
{ type: "input_text", text: "hi" },
|
||||
{ type: "input_image", image_url: "data:image/png;base64,x" },
|
||||
] }] };
|
||||
stripUnsupportedModalities(body, FORMATS.OPENAI_RESPONSES, NO_VISION);
|
||||
expect(body.input[0].content.some((b) => b.type === "input_image")).toBe(false);
|
||||
expect(body.input[0].content.some((b) => b.type === "input_text" && /image omitted/.test(b.text))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles missing/empty body safely", () => {
|
||||
expect(stripUnsupportedModalities(null, FORMATS.OPENAI, NO_VISION)).toBe(false);
|
||||
expect(stripUnsupportedModalities({}, FORMATS.OPENAI, null)).toBe(false);
|
||||
expect(stripUnsupportedModalities({ messages: [] }, FORMATS.OPENAI, NO_VISION)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
vi.mock("../../open-sse/translator/concerns/image.js", async (orig) => {
|
||||
const actual = await orig();
|
||||
return {
|
||||
...actual,
|
||||
fetchImageAsBase64: vi.fn(async () => ({ url: "data:image/png;base64,QUJD", mimeType: "image/png" })),
|
||||
};
|
||||
});
|
||||
|
||||
import { prefetchRemoteImages } from "../../open-sse/translator/concerns/prefetch.js";
|
||||
import { fetchImageAsBase64 } from "../../open-sse/translator/concerns/image.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
beforeEach(() => { fetchImageAsBase64.mockClear(); });
|
||||
afterEach(() => { vi.restoreAllMocks(); });
|
||||
|
||||
describe("prefetchRemoteImages", () => {
|
||||
it("no-op for targets that accept remote URLs (openai)", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OPENAI);
|
||||
expect(n).toBe(0);
|
||||
expect(body.messages[0].content[0].image_url.url).toBe("https://x/a.png");
|
||||
});
|
||||
|
||||
it("openai source -> ollama target: converts remote URL to base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OLLAMA);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].image_url.url.startsWith("data:image/png;base64,")).toBe(true);
|
||||
});
|
||||
|
||||
it("skips data URI (already inline)", async () => {
|
||||
const body = { messages: [{ role: "user", content: [{ type: "image_url", image_url: { url: "data:image/png;base64,xx" } }] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.OPENAI, FORMATS.OLLAMA);
|
||||
expect(n).toBe(0);
|
||||
expect(fetchImageAsBase64).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gemini source -> gemini target: fileData URL -> inlineData base64", async () => {
|
||||
const body = { contents: [{ role: "user", parts: [
|
||||
{ fileData: { mimeType: "image/png", fileUri: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.GEMINI, FORMATS.GEMINI);
|
||||
expect(n).toBe(1);
|
||||
expect(body.contents[0].parts[0].inlineData).toBeTruthy();
|
||||
expect(body.contents[0].parts[0].fileData).toBeUndefined();
|
||||
});
|
||||
|
||||
it("claude source -> kiro target: source.url -> base64", async () => {
|
||||
const body = { messages: [{ role: "user", content: [
|
||||
{ type: "image", source: { type: "url", url: "https://x/a.png" } },
|
||||
] }] };
|
||||
const n = await prefetchRemoteImages(body, FORMATS.CLAUDE, FORMATS.KIRO);
|
||||
expect(n).toBe(1);
|
||||
expect(body.messages[0].content[0].source.type).toBe("base64");
|
||||
});
|
||||
});
|
||||
@@ -95,9 +95,9 @@ describe("RTK filters", () => {
|
||||
const input = makeFindOutput();
|
||||
const out = find(input);
|
||||
expect(out).toContain("55 files in 3 dirs:");
|
||||
expect(out).toContain("./src/a/ (30):");
|
||||
expect(out).toContain("./src/b/ (20):");
|
||||
expect(out).toContain("./ (5):");
|
||||
expect(out).toContain("./src/a/ (30)");
|
||||
expect(out).toContain("./src/b/ (20)");
|
||||
expect(out).toContain("./ (5)");
|
||||
expect(out.length).toBeLessThan(input.length);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user