From b282f0554972ea35281520738759d76abcd0b0b3 Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 15 Jun 2026 18:18:04 +0700 Subject: [PATCH] Refactor --- cli/cli.js | 24 +- open-sse/config/mediaConfig.js | 27 ++ open-sse/handlers/chatCore.js | 16 ++ open-sse/providers/capabilities.js | 124 +++++---- open-sse/providers/registry/blackbox.js | 1 + open-sse/providers/registry/cloudflare-ai.js | 1 + open-sse/providers/registry/codex.js | 1 + open-sse/providers/registry/gemini.js | 2 - open-sse/providers/registry/iflow.js | 1 + open-sse/providers/registry/openrouter.js | 1 + open-sse/providers/registry/siliconflow.js | 1 + .../providers/registry/vercel-ai-gateway.js | 1 + open-sse/rtk/constants.js | 8 +- open-sse/rtk/filters/find.js | 5 +- open-sse/services/combo.js | 91 ++++++- open-sse/services/usage.js | 4 +- open-sse/services/usage/codex.js | 46 ++++ open-sse/translator/concerns/image.js | 88 +++++- open-sse/translator/concerns/modality.js | 155 +++++++++++ open-sse/translator/concerns/prefetch.js | 96 +++++++ open-sse/translator/concerns/reasoning.js | 16 ++ open-sse/translator/concerns/thinking.js | 44 ++- .../translator/concerns/thinkingUnified.js | 257 ++++++++++++++++++ open-sse/translator/formats/gemini.js | 8 + open-sse/translator/index.js | 8 + .../translator/request/openai-to-claude.js | 31 +-- .../translator/request/openai-to-gemini.js | 20 +- .../translator/response/openai-responses.js | 9 +- .../translator/response/openai-to-claude.js | 5 +- open-sse/translator/schema/blocks.js | 4 +- open-sse/utils/claudeCloaking.js | 5 +- open-sse/utils/streamHandler.js | 5 +- package.json | 2 +- scripts/test-combo-autoswitch.mjs | 84 ++++++ .../cli-tools/components/ClaudeToolCard.js | 6 +- src/app/(dashboard)/dashboard/combos/page.js | 23 +- .../dashboard/providers/[id]/ModelRow.js | 9 +- .../dashboard/providers/[id]/page.js | 4 + .../usage/components/ProviderLimits/index.js | 180 +++++++++--- src/app/api/models/route.js | 3 + .../codex-reset-credits/route.js | 104 +++++++ src/app/api/usage/[connectionId]/route.js | 2 +- src/app/globals.css | 14 + src/dashboardGuard.js | 11 +- src/shared/components/CapacityBadges.js | 28 ++ src/shared/components/ModelSelectModal.js | 9 +- src/shared/components/Tooltip.js | 4 +- src/shared/components/index.js | 1 + src/shared/constants/models.js | 7 + src/shared/constants/providers.js | 5 +- src/shared/hooks/index.js | 1 + src/shared/hooks/useModelCaps.js | 39 +++ .../__snapshots__/golden-request.test.js.snap | 9 +- tests/translator/golden-request.test.js | 2 +- .../real/file-base64-survey.real.test.js | 168 ++++++++++++ tests/translator/real/thinking.real.test.js | 122 +++++++++ .../vision-capability-survey.real.test.js | 19 ++ tests/translator/thinking-unified.test.js | 131 +++++++++ tests/unit/codex-image-fetch.test.js | 26 +- tests/unit/combo-autoswitch.test.js | 78 ++++++ tests/unit/dashboard-guard.test.js | 39 +++ tests/unit/file-block-routing.test.js | 57 ++++ tests/unit/image-fetch-hardening.test.js | 77 ++++++ tests/unit/modality-strip.test.js | 108 ++++++++ tests/unit/prefetch-images.test.js | 58 ++++ tests/unit/rtk.test.js | 6 +- 66 files changed, 2328 insertions(+), 213 deletions(-) create mode 100644 open-sse/config/mediaConfig.js create mode 100644 open-sse/translator/concerns/modality.js create mode 100644 open-sse/translator/concerns/prefetch.js create mode 100644 open-sse/translator/concerns/thinkingUnified.js create mode 100644 scripts/test-combo-autoswitch.mjs create mode 100644 src/app/api/usage/[connectionId]/codex-reset-credits/route.js create mode 100644 src/shared/components/CapacityBadges.js create mode 100644 src/shared/hooks/useModelCaps.js create mode 100644 tests/translator/real/file-base64-survey.real.test.js create mode 100644 tests/translator/real/thinking.real.test.js create mode 100644 tests/translator/thinking-unified.test.js create mode 100644 tests/unit/combo-autoswitch.test.js create mode 100644 tests/unit/file-block-routing.test.js create mode 100644 tests/unit/image-fetch-hardening.test.js create mode 100644 tests/unit/modality-strip.test.js create mode 100644 tests/unit/prefetch-images.test.js diff --git a/cli/cli.js b/cli/cli.js index 01300ca1..09057b2e 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -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(); diff --git a/open-sse/config/mediaConfig.js b/open-sse/config/mediaConfig.js new file mode 100644 index 00000000..5b8fbb2a --- /dev/null +++ b/open-sse/config/mediaConfig.js @@ -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", +]); diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index 465be25c..c1a02603 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -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) { diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index e1e390c8..fe9fcbb5 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -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 } }, ]; diff --git a/open-sse/providers/registry/blackbox.js b/open-sse/providers/registry/blackbox.js index 0661f3b7..bb5a70b7 100644 --- a/open-sse/providers/registry/blackbox.js +++ b/open-sse/providers/registry/blackbox.js @@ -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" }, diff --git a/open-sse/providers/registry/cloudflare-ai.js b/open-sse/providers/registry/cloudflare-ai.js index 2b9a5bd0..4d440b74 100644 --- a/open-sse/providers/registry/cloudflare-ai.js +++ b/open-sse/providers/registry/cloudflare-ai.js @@ -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" }, diff --git a/open-sse/providers/registry/codex.js b/open-sse/providers/registry/codex.js index 803bc834..4620c4d3 100644 --- a/open-sse/providers/registry/codex.js +++ b/open-sse/providers/registry/codex.js @@ -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: [ diff --git a/open-sse/providers/registry/gemini.js b/open-sse/providers/registry/gemini.js index 65ebb316..8101fe5f 100644 --- a/open-sse/providers/registry/gemini.js +++ b/open-sse/providers/registry/gemini.js @@ -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" }, diff --git a/open-sse/providers/registry/iflow.js b/open-sse/providers/registry/iflow.js index a48a103c..00f550e8 100644 --- a/open-sse/providers/registry/iflow.js +++ b/open-sse/providers/registry/iflow.js @@ -15,6 +15,7 @@ export default { category: "oauth", transport: { baseUrl: "https://apis.iflow.cn/v1/chat/completions", + thinkingFormat: "openai", headers: { "User-Agent": "iFlow-Cli", }, diff --git a/open-sse/providers/registry/openrouter.js b/open-sse/providers/registry/openrouter.js index 7ea00d30..4ac03641 100644 --- a/open-sse/providers/registry/openrouter.js +++ b/open-sse/providers/registry/openrouter.js @@ -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", diff --git a/open-sse/providers/registry/siliconflow.js b/open-sse/providers/registry/siliconflow.js index f5f08944..902cd814 100644 --- a/open-sse/providers/registry/siliconflow.js +++ b/open-sse/providers/registry/siliconflow.js @@ -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" }, diff --git a/open-sse/providers/registry/vercel-ai-gateway.js b/open-sse/providers/registry/vercel-ai-gateway.js index d4afa5a6..798d94c0 100644 --- a/open-sse/providers/registry/vercel-ai-gateway.js +++ b/open-sse/providers/registry/vercel-ai-gateway.js @@ -20,6 +20,7 @@ export default { category: "apikey", transport: { baseUrl: "https://ai-gateway.vercel.sh/v1/chat/completions", + thinkingFormat: "openai", retry: { "429": 2, }, diff --git a/open-sse/rtk/constants.js b/open-sse/rtk/constants.js index b62740c1..752c2fee 100644 --- a/open-sse/rtk/constants.js +++ b/open-sse/rtk/constants.js @@ -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) diff --git a/open-sse/rtk/filters/find.js b/open-sse/rtk/filters/find.js index 4f6ee239..5770a99b 100644 --- a/open-sse/rtk/filters/find.js +++ b/open-sse/rtk/filters/find.js @@ -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; diff --git a/open-sse/services/combo.js b/open-sse/services/combo.js index 6a4b17df..deaa0dc1 100644 --- a/open-sse/services/combo.js +++ b/open-sse/services/combo.js @@ -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} */ -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; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 156add2b..d8816709 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -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 { diff --git a/open-sse/services/usage/codex.js b/open-sse/services/usage/codex.js index 0aa74ee1..cfcd5931 100644 --- a/open-sse/services/usage/codex.js +++ b/open-sse/services/usage/codex.js @@ -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, + }; +} diff --git a/open-sse/translator/concerns/image.js b/open-sse/translator/concerns/image.js index 8f4b055d..32f2d63f 100644 --- a/open-sse/translator/concerns/image.js +++ b/open-sse/translator/concerns/image.js @@ -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 { diff --git a/open-sse/translator/concerns/modality.js b/open-sse/translator/concerns/modality.js new file mode 100644 index 00000000..b3bba2de --- /dev/null +++ b/open-sse/translator/concerns/modality.js @@ -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; +} diff --git a/open-sse/translator/concerns/prefetch.js b/open-sse/translator/concerns/prefetch.js new file mode 100644 index 00000000..5055be3c --- /dev/null +++ b/open-sse/translator/concerns/prefetch.js @@ -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} 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; +} diff --git a/open-sse/translator/concerns/reasoning.js b/open-sse/translator/concerns/reasoning.js index f3964575..f4855eb7 100644 --- a/open-sse/translator/concerns/reasoning.js +++ b/open-sse/translator/concerns/reasoning.js @@ -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 ""; +} diff --git a/open-sse/translator/concerns/thinking.js b/open-sse/translator/concerns/thinking.js index bb13976c..3db892c4 100644 --- a/open-sse/translator/concerns/thinking.js +++ b/open-sse/translator/concerns/thinking.js @@ -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"; diff --git a/open-sse/translator/concerns/thinkingUnified.js b/open-sse/translator/concerns/thinkingUnified.js new file mode 100644 index 00000000..1f110a2f --- /dev/null +++ b/open-sse/translator/concerns/thinkingUnified.js @@ -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; +} diff --git a/open-sse/translator/formats/gemini.js b/open-sse/translator/formats/gemini.js index d5dea849..b4432402 100644 --- a/open-sse/translator/formats/gemini.js +++ b/open-sse/translator/formats/gemini.js @@ -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 } }); + } } } } diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index 0053c5a4..fa42b139 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -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) { diff --git a/open-sse/translator/request/openai-to-claude.js b/open-sse/translator/request/openai-to-claude.js index bbc6f26d..bc73149b 100644 --- a/open-sse/translator/request/openai-to-claude.js +++ b/open-sse/translator/request/openai-to-claude.js @@ -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 } + }); + } } } } diff --git a/open-sse/translator/request/openai-to-gemini.js b/open-sse/translator/request/openai-to-gemini.js index 34c8621e..90db3afe 100644 --- a/open-sse/translator/request/openai-to-gemini.js +++ b/open-sse/translator/request/openai-to-gemini.js @@ -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) { diff --git a/open-sse/translator/response/openai-responses.js b/open-sse/translator/response/openai-responses.js index 6094fd90..b9336785 100644 --- a/open-sse/translator/response/openai-responses.js +++ b/open-sse/translator/response/openai-responses.js @@ -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 diff --git a/open-sse/translator/response/openai-to-claude.js b/open-sse/translator/response/openai-to-claude.js index b255044d..e771c154 100644 --- a/open-sse/translator/response/openai-to-claude.js +++ b/open-sse/translator/response/openai-to-claude.js @@ -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); diff --git a/open-sse/translator/schema/blocks.js b/open-sse/translator/schema/blocks.js index 42885774..61c25645 100644 --- a/open-sse/translator/schema/blocks.js +++ b/open-sse/translator/schema/blocks.js @@ -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, diff --git a/open-sse/utils/claudeCloaking.js b/open-sse/utils/claudeCloaking.js index 688573ab..204a7cfa 100644 --- a/open-sse/utils/claudeCloaking.js +++ b/open-sse/utils/claudeCloaking.js @@ -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); diff --git a/open-sse/utils/streamHandler.js b/open-sse/utils/streamHandler.js index 6c8b031a..b8a06e2f 100644 --- a/open-sse/utils/streamHandler.js +++ b/open-sse/utils/streamHandler.js @@ -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(() => {}); diff --git a/package.json b/package.json index 27f9dc42..9211d5ba 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-combo-autoswitch.mjs b/scripts/test-combo-autoswitch.mjs new file mode 100644 index 00000000..358ebcf9 --- /dev/null +++ b/scripts/test-combo-autoswitch.mjs @@ -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.`); +})(); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index c276fcb9..589d847a 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -347,10 +347,10 @@ export default function ClaudeToolCard({ - - info - diff --git a/src/app/(dashboard)/dashboard/combos/page.js b/src/app/(dashboard)/dashboard/combos/page.js index 8f795e30..8b694d31 100644 --- a/src/app/(dashboard)/dashboard/combos/page.js +++ b/src/app/(dashboard)/dashboard/combos/page.js @@ -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() {

Combos

- 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.

+ + )} + + - - + + + + + edit + + + + + +
+ { + 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)} + /> + { 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 }, }; }); diff --git a/src/app/api/usage/[connectionId]/codex-reset-credits/route.js b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js new file mode 100644 index 00000000..5fa2f6da --- /dev/null +++ b/src/app/api/usage/[connectionId]/codex-reset-credits/route.js @@ -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 }); + } +} diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 99953dea..4b775b18 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -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 diff --git a/src/app/globals.css b/src/app/globals.css index d2951537..d96cac7e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 ============================================================ */ diff --git a/src/dashboardGuard.js b/src/dashboardGuard.js index 76275db4..47ced963 100644 --- a/src/dashboardGuard.js +++ b/src/dashboardGuard.js @@ -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 { diff --git a/src/shared/components/CapacityBadges.js b/src/shared/components/CapacityBadges.js new file mode 100644 index 00000000..81c34188 --- /dev/null +++ b/src/shared/components/CapacityBadges.js @@ -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 ( + + {active.map((k) => ( + + + {CAPACITY_META[k].icon} + + + ))} + + ); +} diff --git a/src/shared/components/ModelSelectModal.js b/src/shared/components/ModelSelectModal.js index dc7ff19d..d5866012 100644 --- a/src/shared/components/ModelSelectModal.js +++ b/src/shared/components/ModelSelectModal.js @@ -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} custom + ) : ( - model.name + <> + {model.name} + + )} diff --git a/src/shared/components/Tooltip.js b/src/shared/components/Tooltip.js index 9e1c7e1c..8473c0c0 100644 --- a/src/shared/components/Tooltip.js +++ b/src/shared/components/Tooltip.js @@ -12,10 +12,10 @@ export default function Tooltip({ text, children, position = "top", color }) { const bgClass = color ? "" : "bg-gray-900"; return ( -
+
{children}
{text} diff --git a/src/shared/components/index.js b/src/shared/components/index.js index af8ab320..c04453a9 100644 --- a/src/shared/components/index.js +++ b/src/shared/components/index.js @@ -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"; diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js index cd5f6bcf..297daae7 100644 --- a/src/shared/constants/models.js +++ b/src/shared/constants/models.js @@ -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" }, +}; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index dc729454..34356cb7 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -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 } : {}), diff --git a/src/shared/hooks/index.js b/src/shared/hooks/index.js index 68c81c2d..0e5f64bb 100644 --- a/src/shared/hooks/index.js +++ b/src/shared/hooks/index.js @@ -1,2 +1,3 @@ // Shared Hooks - Export all export { useTheme } from "./useTheme"; +export { useModelCaps } from "./useModelCaps"; diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js new file mode 100644 index 00000000..db16bdda --- /dev/null +++ b/src/shared/hooks/useModelCaps.js @@ -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 }; +} diff --git a/tests/translator/__snapshots__/golden-request.test.js.snap b/tests/translator/__snapshots__/golden-request.test.js.snap index 433e3a34..17fa66dd 100644 --- a/tests/translator/__snapshots__/golden-request.test.js.snap +++ b/tests/translator/__snapshots__/golden-request.test.js.snap @@ -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", - }, } `; diff --git a/tests/translator/golden-request.test.js b/tests/translator/golden-request.test.js index 482794be..a6491543 100644 --- a/tests/translator/golden-request.test.js +++ b/tests/translator/golden-request.test.js @@ -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(); diff --git a/tests/translator/real/file-base64-survey.real.test.js b/tests/translator/real/file-base64-survey.real.test.js new file mode 100644 index 00000000..d7d16f2f --- /dev/null +++ b/tests/translator/real/file-base64-survey.real.test.js @@ -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"); + }); +}); diff --git a/tests/translator/real/thinking.real.test.js b/tests/translator/real/thinking.real.test.js new file mode 100644 index 00000000..32b02940 --- /dev/null +++ b/tests/translator/real/thinking.real.test.js @@ -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| r.provider).sort(); + if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p)); + return list; + } catch { + return []; + } +} diff --git a/tests/translator/real/vision-capability-survey.real.test.js b/tests/translator/real/vision-capability-survey.real.test.js index 55ec3534..c1bf0f89 100644 --- a/tests/translator/real/vision-capability-survey.real.test.js +++ b/tests/translator/real/vision-capability-survey.real.test.js @@ -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); diff --git a/tests/translator/thinking-unified.test.js b/tests/translator/thinking-unified.test.js new file mode 100644 index 00000000..45453f82 --- /dev/null +++ b/tests/translator/thinking-unified.test.js @@ -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(""); + }); +}); diff --git a/tests/unit/codex-image-fetch.test.js b/tests/unit/codex-image-fetch.test.js index b2c6906a..d50614a2 100644 --- a/tests/unit/codex-image-fetch.test.js +++ b/tests/unit/codex-image-fetch.test.js @@ -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 () => {}, + }; + }, + }, }; } diff --git a/tests/unit/combo-autoswitch.test.js b/tests/unit/combo-autoswitch.test.js new file mode 100644 index 00000000..904d9e51 --- /dev/null +++ b/tests/unit/combo-autoswitch.test.js @@ -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); + }); +}); diff --git a/tests/unit/dashboard-guard.test.js b/tests/unit/dashboard-guard.test.js index a2eb318f..99cdd918 100644 --- a/tests/unit/dashboard-guard.test.js +++ b/tests/unit/dashboard-guard.test.js @@ -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); diff --git a/tests/unit/file-block-routing.test.js b/tests/unit/file-block-routing.test.js new file mode 100644 index 00000000..71b8f9c7 --- /dev/null +++ b/tests/unit/file-block-routing.test.js @@ -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); + }); +}); diff --git a/tests/unit/image-fetch-hardening.test.js b/tests/unit/image-fetch-hardening.test.js new file mode 100644 index 00000000..860982da --- /dev/null +++ b/tests/unit/image-fetch-hardening.test.js @@ -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("")); + 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(); + }); +}); diff --git a/tests/unit/modality-strip.test.js b/tests/unit/modality-strip.test.js new file mode 100644 index 00000000..0abb8966 --- /dev/null +++ b/tests/unit/modality-strip.test.js @@ -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); + }); +}); diff --git a/tests/unit/prefetch-images.test.js b/tests/unit/prefetch-images.test.js new file mode 100644 index 00000000..2289697b --- /dev/null +++ b/tests/unit/prefetch-images.test.js @@ -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"); + }); +}); diff --git a/tests/unit/rtk.test.js b/tests/unit/rtk.test.js index 271d9470..17d0f1c6 100644 --- a/tests/unit/rtk.test.js +++ b/tests/unit/rtk.test.js @@ -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); });