diff --git a/open-sse/config/models.js b/open-sse/config/models.js new file mode 100644 index 00000000..a1917cdd --- /dev/null +++ b/open-sse/config/models.js @@ -0,0 +1,13 @@ +// Model metadata registry +// Only define models that differ from DEFAULT_MODEL_INFO +// Custom entries are merged over default +const DEFAULT_MODEL_INFO = { + type: ["chat"], + contextWindow: 200000, +}; + +export const MODEL_INFO = {}; + +export function getModelInfo(modelId) { + return { ...DEFAULT_MODEL_INFO, ...MODEL_INFO[modelId] }; +} diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index 64b4302a..43d5eec0 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -1,191 +1,5 @@ import { PROVIDERS } from "./providers.js"; -// Global model capabilities registry -// Only define models that have non-default capabilities -// Default: { thinking: false, multimodal: { image: false, audio: false, video: false, pdf: false } } -export const MODEL_CAPS = { - // Claude models — full capabilities - "claude-opus-4-6": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-sonnet-4-6": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-opus-4-6-thinking": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-opus-4-5-20251101": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-sonnet-4-5-20250929": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-haiku-4-5-20251001": { multimodal: { image: true } }, - "claude-sonnet-4-20250514": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-opus-4-20250514": { thinking: true, multimodal: { image: true, pdf: true } }, - "claude-3-5-sonnet-20241022": { multimodal: { image: true } }, - // Cursor Claude aliases - "claude-4.5-opus-high-thinking": { thinking: true, multimodal: { image: true } }, - "claude-4.5-opus-high": { multimodal: { image: true } }, - "claude-4.5-sonnet-thinking": { thinking: true, multimodal: { image: true } }, - "claude-4.5-sonnet": { multimodal: { image: true } }, - "claude-4.5-haiku": { multimodal: { image: true } }, - "claude-4.5-opus": { multimodal: { image: true } }, - "claude-4.6-opus-max": { thinking: true, multimodal: { image: true } }, - "claude-4.6-sonnet-medium-thinking": { thinking: true, multimodal: { image: true } }, - // GitHub Copilot Claude aliases - "claude-haiku-4.5": { multimodal: { image: true } }, - "claude-opus-4.1": { thinking: true, multimodal: { image: true } }, - "claude-opus-4.5": { thinking: true, multimodal: { image: true } }, - "claude-sonnet-4": { multimodal: { image: true } }, - "claude-sonnet-4.5": { thinking: true, multimodal: { image: true } }, - "claude-sonnet-4.6": { thinking: true, multimodal: { image: true } }, - "claude-opus-4.6": { thinking: true, multimodal: { image: true } }, - // Kiro aliases - "claude-sonnet-4.5": { thinking: true, multimodal: { image: true } }, - - // Gemini models — full multimodal - "gemini-3.1-pro-preview": { thinking: true, multimodal: { image: true, audio: true, video: true, pdf: true } }, - "gemini-3.1-flash-lite-preview": { multimodal: { image: true, audio: true, video: true } }, - "gemini-3.1-flash-image-preview": { multimodal: { image: true, audio: true, video: true } }, - "gemini-3-flash-preview": { thinking: true, multimodal: { image: true, audio: true, video: true } }, - "gemini-3-pro-preview": { thinking: true, multimodal: { image: true, audio: true, video: true, pdf: true } }, - "gemini-3-flash": { thinking: true, multimodal: { image: true, audio: true, video: true } }, - "gemini-3.1-pro-high": { thinking: true, multimodal: { image: true, audio: true, video: true, pdf: true } }, - "gemini-3.1-pro-low": { thinking: true, multimodal: { image: true, audio: true, video: true, pdf: true } }, - "gemini-2.5-pro": { thinking: true, multimodal: { image: true, audio: true, video: true, pdf: true } }, - "gemini-2.5-flash": { thinking: true, multimodal: { image: true, audio: true, video: true } }, - "gemini-2.5-flash-lite": { multimodal: { image: true } }, - "gemini-2.0-flash": { multimodal: { image: true, audio: true, video: true } }, - "gemini-2.0-flash-lite": { multimodal: { image: true } }, - - // GPT models - "gpt-5.4": { multimodal: { image: true } }, - "gpt-5.4-mini": { multimodal: { image: true } }, - "gpt-5.3-codex": { thinking: true, multimodal: { image: true } }, - "gpt-5.3-codex-xhigh": { thinking: true }, - "gpt-5.3-codex-high": { thinking: true }, - "gpt-5.3-codex-low": { thinking: true }, - "gpt-5.3-codex-none": {}, - "gpt-5.3-codex-spark": {}, - "gpt-5.2-codex": { thinking: true }, - "gpt-5.2": { multimodal: { image: true } }, - "gpt-5.1-codex": { thinking: true }, - "gpt-5.1-codex-mini": { thinking: true }, - "gpt-5.1-codex-high": { thinking: true }, - "gpt-5.1-codex-max": { thinking: true }, - "gpt-5.1": { multimodal: { image: true } }, - "gpt-5-codex": { thinking: true }, - "gpt-5-codex-mini": {}, - "gpt-5": { multimodal: { image: true } }, - "gpt-5-mini": { multimodal: { image: true } }, - "gpt-4o": { multimodal: { image: true, audio: true } }, - "gpt-4o-mini": { multimodal: { image: true } }, - "gpt-4-turbo": { multimodal: { image: true } }, - "gpt-4.1": { multimodal: { image: true } }, - "gpt-4.1-mini": { multimodal: { image: true } }, - "gpt-4.1-nano": {}, - "o3": { thinking: true, multimodal: { image: true } }, - "o3-mini": { thinking: true }, - "o3-pro": { thinking: true, multimodal: { image: true } }, - "o4-mini": { thinking: true, multimodal: { image: true } }, - "o1": { thinking: true, multimodal: { image: true } }, - "o1-mini": { thinking: true }, - - // DeepSeek models - "deepseek-chat": {}, - "deepseek-reasoner": { thinking: true }, - "deepseek-r1": { thinking: true }, - "deepseek-v3": {}, - "deepseek-v3.1": {}, - "deepseek-v3.2": {}, - "deepseek-3.1": {}, - "deepseek-3.2": {}, - "deepseek-ai/DeepSeek-R1": { thinking: true }, - "deepseek-ai/DeepSeek-V3": {}, - "deepseek-ai/DeepSeek-V3.2": {}, - "deepseek-ai/DeepSeek-V3.1": {}, - "deepseek-ai/deepseek-v3.2-maas": {}, - - // Qwen models - "qwen3-vl-plus": { multimodal: { image: true } }, - "vision-model": { multimodal: { image: true } }, - "qwen3-coder-plus": {}, - "qwen3-coder-flash": {}, - "qwen3-max": { thinking: true }, - "qwen3-max-preview": { thinking: true }, - "qwen3-235b": { thinking: true }, - "qwen3-235b-a22b-instruct": {}, - "qwen3-235b-a22b-thinking-2507": { thinking: true }, - "qwen3-32b": { thinking: true }, - "qwen3-coder-next": {}, - "qwen3.5-plus": {}, - "qwen/qwen3-32b": { thinking: true }, - "qwen/qwen3-next-80b-a3b-thinking-maas": { thinking: true }, - "qwen/qwen3-next-80b-a3b-instruct-maas": {}, - "Qwen/Qwen3-235B-A22B": { thinking: true }, - "Qwen/Qwen3-235B-A22B-Instruct-2507": {}, - "Qwen/Qwen3-Coder-480B-A35B-Instruct": {}, - "Qwen/Qwen3-32B": { thinking: true }, - "qwen-3-235b-a22b-instruct-2507": {}, - "qwen-3-32b": { thinking: true }, - - // Kimi models - "kimi-k2": {}, - "kimi-k2.5": {}, - "kimi-k2.5-thinking": { thinking: true }, - "kimi-latest": {}, - "moonshotai/Kimi-K2.5": {}, - "moonshotai/kimi-k2.5": {}, - - // GLM models - "glm-5.1": {}, - "glm-5": {}, - "glm-4.7": {}, - "glm-4.6v": { multimodal: { image: true } }, - "glm-4.6": {}, - "glm-4.5-air": {}, - "glm-4.7-flash": {}, - "z-ai/glm4.7": {}, - "zai-org/GLM-4.7": {}, - "zai-glm-4.7": {}, - "zai-org/glm-5-maas": {}, - - // Grok models - "grok-4": { thinking: true, multimodal: { image: true } }, - "grok-4-fast-reasoning": { thinking: true }, - "grok-code-fast-1": {}, - "grok-3": { multimodal: { image: true } }, - - // GPT-OSS (no toolUse) - "gpt-oss-120b": {}, - "gpt-oss-120b-medium": {}, - "openai/gpt-oss-120b": {}, - "gpt-oss:120b": {}, - - // Llama models - "meta-llama/Llama-3.3-70B-Instruct-Turbo": {}, - "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { multimodal: { image: true } }, - "meta-llama/llama-4-maverick-17b-128e-instruct": { multimodal: { image: true } }, - "meta-llama/Llama-3.3-70B-Instruct": {}, - "meta-llama/Llama-3.2-3B-Instruct": {}, - "llama-3.3-70b-versatile": {}, - "llama-3.3-70b": {}, - "llama-4-scout-17b-16e-instruct": { multimodal: { image: true } }, -}; - -// Default capabilities for unknown models -const DEFAULT_CAPS = { thinking: false, multimodal: { image: false, audio: false, video: false, pdf: false } }; - -// Merge caps: global as base, provider entry overrides -function mergeCaps(global, override) { - if (!override) return global; - return { - thinking: override.thinking ?? global.thinking, - multimodal: { ...global.multimodal, ...override.multimodal } - }; -} - -// Resolve model capabilities: provider override → global → default -export function getModelCaps(alias, modelId) { - const entry = PROVIDER_MODELS[alias]?.find(m => m.id === modelId); - const global = MODEL_CAPS[modelId] ?? DEFAULT_CAPS; - // Extract caps fields from entry (exclude id, name, type, targetFormat) - const { id, name, type, targetFormat, ...overrideCaps } = entry || {}; - const hasOverride = Object.keys(overrideCaps).length > 0; - return mergeCaps({ ...DEFAULT_CAPS, ...global }, hasOverride ? overrideCaps : null); -} - // Provider models - Single source of truth // Key = alias (cc, cx, gc, qw, if, ag, gh for OAuth; id for API Key) // Field "provider" for special cases (e.g. AntiGravity models that call different backends) @@ -293,9 +107,9 @@ export const PROVIDER_MODELS = { // { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, - { id: "deepseek-3.2", name: "DeepSeek 3.2" }, - { id: "deepseek-3.1", name: "DeepSeek 3.1" }, - { id: "qwen3-coder-next", name: "Qwen3 Coder Next" }, + { id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image", "audio"] }, + { id: "deepseek-3.1", name: "DeepSeek 3.1", strip: ["image", "audio"] }, + { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image", "audio"] }, ], cu: [ // Cursor IDE { id: "default", name: "Auto (Server Picks)" }, @@ -611,5 +425,9 @@ export function getModelsByProviderId(providerId) { return PROVIDER_MODELS[alias] || []; } -// Re-export getModelCaps here for convenience (defined above PROVIDER_MODELS) -// getModelCaps is already exported above +// Get strip list for a model entry (explicit opt-in only) +// Returns array of content types to strip, e.g. ["image", "audio"] +export function getModelStrip(alias, modelId) { + const entry = PROVIDER_MODELS[alias]?.find(m => m.id === modelId); + return entry?.strip || []; +} diff --git a/open-sse/handlers/chatCore.js b/open-sse/handlers/chatCore.js index f921ac3c..fc564d3e 100644 --- a/open-sse/handlers/chatCore.js +++ b/open-sse/handlers/chatCore.js @@ -5,7 +5,7 @@ import { COLORS } from "../utils/stream.js"; import { createStreamController } from "../utils/streamHandler.js"; import { refreshWithRetry } from "../services/tokenRefresh.js"; import { createRequestLogger } from "../utils/requestLogger.js"; -import { getModelTargetFormat, getModelCaps, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; +import { getModelTargetFormat, getModelStrip, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js"; import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js"; import { HTTP_STATUS } from "../config/runtimeConfig.js"; import { handleBypassRequest } from "../utils/bypassHandler.js"; @@ -37,7 +37,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred const alias = PROVIDER_ID_TO_ALIAS[provider] || provider; const modelTargetFormat = getModelTargetFormat(alias, model); const targetFormat = modelTargetFormat || getTargetFormat(provider); - const modelCaps = getModelCaps(alias, model); + const stripList = getModelStrip(alias, model); const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI; const providerRequiresStreaming = provider === "openai" || provider === "codex"; @@ -68,7 +68,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log?.debug?.("PASSTHROUGH", `${clientTool} → ${provider} | native lossless`); translatedBody = { ...body, model }; } else { - translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger, modelCaps, connectionId); + translatedBody = translateRequest(sourceFormat, targetFormat, model, body, stream, credentials, provider, reqLogger, stripList, connectionId); if (!translatedBody) { trackPendingRequest(model, provider, connectionId, false, true); return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Failed to translate request for ${sourceFormat} → ${targetFormat}`); diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index c2238e72..60b315b8 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -52,37 +52,30 @@ function ensureInitialized() { require("./response/ollama-to-openai.js"); } -// Strip multimodal content blocks (image/audio/video) from messages if model doesn't support them -function stripUnsupportedMultimodal(body, multimodal = {}) { - if (!body.messages || !Array.isArray(body.messages)) return; +// Strip specific content types from messages (explicit opt-in via strip[] in PROVIDER_MODELS) +function stripContentTypes(body, stripList = []) { + if (!stripList.length || !body.messages || !Array.isArray(body.messages)) return; + const imageTypes = new Set(["image_url", "image"]); + const audioTypes = new Set(["audio_url", "input_audio"]); + const shouldStrip = (type) => { + if (imageTypes.has(type)) return stripList.includes("image"); + if (audioTypes.has(type)) return stripList.includes("audio"); + return false; + }; for (const msg of body.messages) { if (!Array.isArray(msg.content)) continue; - msg.content = msg.content.filter(part => { - if (part.type === "image_url" || part.type === "image") return multimodal.image === true; - if (part.type === "audio_url" || part.type === "input_audio") return multimodal.audio === true; - return true; // keep text, tool_use, tool_result, etc. - }); - // If content array becomes empty after filtering, replace with empty string to avoid API errors + msg.content = msg.content.filter(part => !shouldStrip(part.type)); if (msg.content.length === 0) msg.content = ""; } } // Translate request: source -> openai -> target -export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, caps = null, connectionId = null) { +export function translateRequest(sourceFormat, targetFormat, model, body, stream = true, credentials = null, provider = null, reqLogger = null, stripList = [], connectionId = null) { ensureInitialized(); let result = body; - // Apply model capability guards before translation - if (caps) { - // Strip multimodal content if model doesn't support it - stripUnsupportedMultimodal(result, caps.multimodal || {}); - - // Strip thinking config if model doesn't support thinking - if (!caps.thinking) { - delete result.thinking; - delete result.reasoning_effort; - } - } + // Strip explicit content types (opt-in via strip[] in PROVIDER_MODELS entry) + stripContentTypes(result, stripList); // Normalize thinking config: remove if lastMessage is not user normalizeThinkingConfig(result); diff --git a/package.json b/package.json index e05e262d..3de30989 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.79", + "version": "0.3.80", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index eb65258d..8ea33e5a 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from "react"; import { useSearchParams, useRouter } from "next/navigation"; +import { FREE_PROVIDERS } from "@/shared/constants/providers"; import Badge from "./Badge"; import Card from "./Card"; import OverviewCards from "@/app/(dashboard)/dashboard/usage/components/OverviewCards"; @@ -196,18 +197,21 @@ export default function UsageStats() { const [period, setPeriod] = useState("7d"); // Fetch connected providers once, deduplicate by provider type + // Always include noAuth free providers (e.g. opencode) regardless of connections useEffect(() => { fetch("/api/providers") .then((r) => r.ok ? r.json() : null) .then((d) => { - if (!d?.connections) return; const seen = new Set(); - const unique = d.connections.filter((c) => { + const unique = (d?.connections || []).filter((c) => { if (seen.has(c.provider)) return false; seen.add(c.provider); return true; }); - setProviders(unique); + const noAuthProviders = Object.values(FREE_PROVIDERS) + .filter((p) => p.noAuth && !seen.has(p.id)) + .map((p) => ({ provider: p.id, name: p.name })); + setProviders([...unique, ...noAuthProviders]); }) .catch(() => {}); }, []); diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js index c266a454..1fb37c5c 100644 --- a/src/shared/constants/models.js +++ b/src/shared/constants/models.js @@ -1,13 +1,12 @@ // Import directly from file to avoid pulling in server-side dependencies via index.js export { PROVIDER_MODELS, - MODEL_CAPS, getProviderModels, getDefaultModel, isValidModel as isValidModelCore, findModelName, getModelTargetFormat, - getModelCaps, + getModelStrip, PROVIDER_ID_TO_ALIAS, getModelsByProviderId } from "open-sse/config/providerModels.js"; diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index a91d9d0c..0aa44b42 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -43,7 +43,7 @@ export const APIKEY_PROVIDERS = { "alicode-intl": { id: "alicode-intl", alias: "alicode-intl", name: "Alibaba Intl", icon: "cloud", color: "#FF6A00", textIcon: "ALi" }, openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", serviceKinds: ["llm", "embedding", "tts"] }, anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", serviceKinds: ["llm"] }, - gemini: { id: "gemini", alias: "gemini", name: "Gemini", icon: "diamond", color: "#4285F4", textIcon: "GE", website: "https://ai.google.dev", serviceKinds: ["llm", "embedding"] }, + deepseek: { id: "deepseek", alias: "ds", name: "DeepSeek", icon: "bolt", color: "#4D6BFE", textIcon: "DS", website: "https://deepseek.com" }, groq: { id: "groq", alias: "groq", name: "Groq", icon: "speed", color: "#F55036", textIcon: "GQ", website: "https://groq.com" }, xai: { id: "xai", alias: "xai", name: "xAI (Grok)", icon: "auto_awesome", color: "#1DA1F2", textIcon: "XA", website: "https://x.ai" },