diff --git a/open-sse/handlers/embeddingProviders/index.js b/open-sse/handlers/embeddingProviders/index.js index 53e63f8dc6b..08b6da423c3 100644 --- a/open-sse/handlers/embeddingProviders/index.js +++ b/open-sse/handlers/embeddingProviders/index.js @@ -7,8 +7,8 @@ import selfhostedEmbedding from "./selfhostedEmbedding.js"; const OPENAI_COMPAT_PROVIDERS = [ "openai", "openrouter", "mistral", "voyage-ai", "fireworks", - "together", "nebius", "nvidia", "jina-ai", - "vercel-ai-gateway", + "together", "nebius", "github", "nvidia", "jina-ai", + "vercel-ai-gateway", "nan", ]; const ADAPTERS = { diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 6b95c55d2ff..7bc81014c21 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -26,6 +26,7 @@ const ADAPTERS = { recraft: createOpenAIAdapter("recraft"), "vercel-ai-gateway": createOpenAIAdapter("vercel-ai-gateway"), xai: createOpenAIAdapter("xai"), + nan: createOpenAIAdapter("nan"), sensenova: createOpenAIAdapter("sensenova"), gemini, codex, diff --git a/open-sse/handlers/ttsCore.js b/open-sse/handlers/ttsCore.js index e074eb96650..2f320cb611d 100644 --- a/open-sse/handlers/ttsCore.js +++ b/open-sse/handlers/ttsCore.js @@ -64,7 +64,7 @@ export async function handleTtsCore({ provider, model, input, credentials, respo } // Generic config-driven (hyperbolic, deepgram, nvidia, huggingface, inworld, cartesia, playht, coqui, tortoise, qwen, ...) - const result = await synthesizeViaConfig(provider, input.trim(), model, credentials); + const result = await synthesizeViaConfig(provider, input.trim(), model, credentials, responseFormat); if (result) return createTtsResponse(result.base64, result.format, responseFormat); return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support TTS via this route.`); diff --git a/open-sse/handlers/ttsProviders/genericFormats.js b/open-sse/handlers/ttsProviders/genericFormats.js index a5c0cda36f9..2b5dc649b97 100644 --- a/open-sse/handlers/ttsProviders/genericFormats.js +++ b/open-sse/handlers/ttsProviders/genericFormats.js @@ -154,7 +154,7 @@ async function tortoise({ baseUrl, text, voiceId }) { } // OpenAI-compatible upstream (qwen3-tts, etc.) -async function openaiCompat({ baseUrl, apiKey, text, modelId, voiceId }) { +async function openaiCompat({ baseUrl, apiKey, text, modelId, voiceId, responseFormat = "mp3" }) { const headers = { "Content-Type": "application/json" }; if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`; const res = await fetch(baseUrl, { @@ -164,12 +164,12 @@ async function openaiCompat({ baseUrl, apiKey, text, modelId, voiceId }) { model: modelId, input: text, voice: voiceId || "alloy", - response_format: "mp3", + response_format: responseFormat, speed: 1.0, }), }); if (!res.ok) await throwUpstreamError(res); - return responseToBase64(res, "mp3"); + return responseToBase64(res, responseFormat); } // format → handler dispatcher diff --git a/open-sse/handlers/ttsProviders/index.js b/open-sse/handlers/ttsProviders/index.js index d2be03fad08..f3717e193a0 100644 --- a/open-sse/handlers/ttsProviders/index.js +++ b/open-sse/handlers/ttsProviders/index.js @@ -29,7 +29,7 @@ export function getTtsAdapter(provider) { } // Generic config-driven dispatcher (uses ttsConfig.format) -export async function synthesizeViaConfig(provider, text, model, credentials) { +export async function synthesizeViaConfig(provider, text, model, credentials, responseFormat = "mp3") { const { AI_PROVIDERS } = await import("@/shared/constants/providers"); const cfg = AI_PROVIDERS[provider]?.ttsConfig; if (!cfg) return null; @@ -40,8 +40,8 @@ export async function synthesizeViaConfig(provider, text, model, credentials) { const { PROVIDER_MODELS } = await import("open-sse/config/providerModels.js"); const ttsModels = (PROVIDER_MODELS[provider] || []).filter(m => (m.kind || m.type) === "tts"); const defaultModel = ttsModels[0]?.id || ""; - const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", ttsModels); - return handler({ baseUrl: cfg.baseUrl, apiKey, text, modelId, voiceId }); + const { modelId, voiceId } = parseModelVoice(model, defaultModel, cfg.defaultVoice || "", ttsModels); + return handler({ baseUrl: cfg.baseUrl, apiKey, text, modelId, voiceId, responseFormat }); } // Voice fetchers (used by /api/media-providers/tts/voices route) diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index cd78ec703c3..d605f8795ae 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -75,6 +75,7 @@ const SERVICE_KIND_CAPABILITIES = { stt: { audioInput: true }, tts: { audioOutput: true }, embedding: { tools: false }, + rerank: { tools: false }, }; export function capabilitiesFromServiceKind(kind) { @@ -233,6 +234,16 @@ const DEVIN_CLI_GPT_CAPS = { vision: true, reasoning: true, search: true, thinki * Provider-specific capability overrides. Keyed by provider alias/id. */ export const PROVIDER_CAPABILITIES = { + nan: { + "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1000000 }, + "mimo-v2.5": { vision: true, audioInput: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1000000 }, + "mimo-v2.6-flash": { vision: true, audioInput: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1000000 }, + "qwen3.8-flash": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 262144 }, + "glm5.3-flash": { vision: true, reasoning: true, thinkingFormat: "openai", contextWindow: 1000000 }, + "qwen3.6": { vision: true, reasoning: true, thinkingFormat: "openai" }, + "gemma4": { vision: true, reasoning: true, thinkingFormat: "openai" }, + "glm5.3": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000 }, + }, // NVIDIA NIM is OpenAI-compatible → rejects MiniMax/GLM native `thinking` field. // Force openai reasoning_effort format for its reasoning models. #issue "nvidia": { diff --git a/open-sse/providers/index.js b/open-sse/providers/index.js index 641d2a20fea..7372a855032 100644 --- a/open-sse/providers/index.js +++ b/open-sse/providers/index.js @@ -22,7 +22,7 @@ function buildTransport(transport, oauth) { const MEDIA_KEYS = new Set([ "serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig", - "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", + "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", "rerankConfig", "searchViaChat", "searchConfig", "fetchConfig", "systemoneConfig", "modelsFetcher", "mediaPriority", "hiddenKinds", ]); diff --git a/open-sse/providers/registry/index.js b/open-sse/providers/registry/index.js index 409fd2cf50a..0bb712fb8cb 100644 --- a/open-sse/providers/registry/index.js +++ b/open-sse/providers/registry/index.js @@ -145,6 +145,7 @@ import p144 from "./muse-web.js"; import p146 from "./chatgpt-web.js"; import p147 from "./veoaifree-web.js"; import p151 from "./uc-web.js"; +import p152 from "./nan.js"; export default [ p0, p1, @@ -290,4 +291,5 @@ export default [ p146, p147, p151, + p152, ]; diff --git a/open-sse/providers/registry/nan.js b/open-sse/providers/registry/nan.js new file mode 100644 index 00000000000..b688ceaa689 --- /dev/null +++ b/open-sse/providers/registry/nan.js @@ -0,0 +1,44 @@ +export default { + id: "nan", + alias: "nan", + category: "apikey", + display: { + name: "NaN", + icon: "hub", + color: "#6D5EF3", + textIcon: "NaN", + website: "https://nan.builders", + notice: { apiKeyUrl: "https://cloud.nan.builders" }, + }, + transport: { + baseUrl: "https://api.nan.builders/v1/chat/completions", + usage: { + // Dashboard quota counters; /v1/usage can return an empty history for an active key. + url: "https://cloud-api.nan.builders/api/usage/quota", + }, + }, + modelsFetcher: { url: "https://api.nan.builders/v1/models", type: "openai" }, + models: [ + { id: "deepseek-v4-flash" }, + { id: "mimo-v2.5" }, + { id: "mimo-v2.6-flash" }, + { id: "qwen3.8-flash" }, + { id: "glm5.3-flash" }, + { id: "qwen3.6" }, + { id: "gemma4" }, + { id: "glm5.3" }, + { id: "qwen3-embedding", kind: "embedding", dimensions: 4096 }, + { id: "whisper", kind: "stt" }, + { id: "kokoro", kind: "tts" }, + { id: "flux-2-klein", kind: "image", capabilities: ["text2img", "edit"] }, + { id: "qwen-image-2.1", kind: "image", capabilities: ["text2img"] }, + { id: "rerank", kind: "rerank" }, + ], + serviceKinds: ["llm", "imageToText", "embedding", "stt", "tts", "image", "rerank"], + embeddingConfig: { baseUrl: "https://api.nan.builders/v1/embeddings", authType: "apikey", authHeader: "bearer" }, + sttConfig: { baseUrl: "https://api.nan.builders/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai" }, + ttsConfig: { baseUrl: "https://api.nan.builders/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "openai", defaultVoice: "af_heart" }, + imageConfig: { baseUrl: "https://api.nan.builders/v1/images/generations", editUrl: "https://api.nan.builders/v1/images/edits" }, + rerankConfig: { baseUrl: "https://api.nan.builders/v1/rerank" }, + features: { usage: true, usageApikey: true }, +}; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index d974024ff30..13c6a86030e 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -6,6 +6,7 @@ import { getGitHubUsage } from "./usage/github.js"; import { getGeminiUsage, getAntigravityUsage } from "./usage/google.js"; import { getClaudeUsage, consumeClaudeResetGrant } from "./usage/claude.js"; import { getCodexUsage, consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits } from "./usage/codex.js"; +import { getNanUsage } from "./usage/nan.js"; export { consumeCodexRateLimitResetCredit, getCodexRateLimitResetCredits, consumeClaudeResetGrant }; import { getKiroUsage } from "./usage/kiro.js"; @@ -43,6 +44,7 @@ const USAGE_HANDLERS = { antigravity: (c) => getAntigravityUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), claude: (c) => getClaudeUsage(c.accessToken, c.proxyOptions, { force: c.force }), codex: (c) => getCodexUsage(c.accessToken, c.proxyOptions), + nan: (c) => getNanUsage(c.apiKey, c.proxyOptions), kiro: (c) => getKiroUsage(c.accessToken, c.providerSpecificData, c.proxyOptions), qoder: (c) => getQoderUsageFor(c), "qoder-cn": (c) => getQoderUsageFor(c), diff --git a/open-sse/services/usage/nan.js b/open-sse/services/usage/nan.js new file mode 100644 index 00000000000..28bb03f4333 --- /dev/null +++ b/open-sse/services/usage/nan.js @@ -0,0 +1,27 @@ +import { proxyAwareFetch } from "../../utils/proxyFetch.js"; +import { U } from "./shared.js"; + +export function formatNanUsage(report) { + const unavailable = { plan: "NaN", message: "NaN quota unavailable: missing or invalid model counters" }; + if (!Array.isArray(report?.models) || !report.models.length) return unavailable; + const quotas = Object.create(null); + for (const row of report.models) { + if (typeof row?.model !== "string" || !row.model.trim() || Object.hasOwn(quotas, row.model) + || !Number.isSafeInteger(row.tokensUsed) || row.tokensUsed < 0 + || !Number.isSafeInteger(row.cap) || row.cap <= 0) return unavailable; + const resetAt = row.periodEnd ?? null; + if (resetAt !== null && (typeof resetAt !== "string" || !Number.isFinite(Date.parse(resetAt)))) return unavailable; + quotas[row.model] = { used: row.tokensUsed, total: row.cap, resetAt, unit: "tokens" }; + } + return { plan: "NaN", quotas }; +} + +export async function getNanUsage(apiKey, proxyOptions) { + if (!apiKey) return { plan: "NaN", message: "NaN API key missing" }; + const response = await proxyAwareFetch(U("nan").url, { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal: AbortSignal.timeout(10000), + }, proxyOptions); + if (!response.ok) return { plan: "NaN", message: `NaN usage: HTTP ${response.status}` }; + return formatNanUsage(await response.json()); +} diff --git a/open-sse/utils/error.js b/open-sse/utils/error.js index 6992d4a4147..4940826803d 100644 --- a/open-sse/utils/error.js +++ b/open-sse/utils/error.js @@ -57,6 +57,10 @@ export async function writeStreamError(writer, statusCode, message) { * @returns {Promise<{statusCode: number, message: string, resetsAtMs?: number}>} */ export async function parseUpstreamError(response, executor = null) { + const retryAfter = response.status === 429 ? response.headers.get("retry-after") : null; + const retrySeconds = Number(retryAfter); + const retryAt = retryAfter && (Number.isFinite(retrySeconds) ? Date.now() + retrySeconds * 1000 : Date.parse(retryAfter)); + const resetsAtMs = Number.isFinite(retryAt) && retryAt > Date.now() ? retryAt : undefined; let bodyText = ""; try { bodyText = await response.text(); @@ -70,7 +74,7 @@ export async function parseUpstreamError(response, executor = null) { const parsed = executor.parseError(response, bodyText); if (parsed && typeof parsed === "object") { const msg = parsed.message || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; - return { statusCode: parsed.status || response.status, message: msg, resetsAtMs: parsed.resetsAtMs }; + return { statusCode: parsed.status || response.status, message: msg, resetsAtMs: parsed.resetsAtMs || resetsAtMs }; } } catch { /* fall through to default parsing */ } } @@ -86,7 +90,7 @@ export async function parseUpstreamError(response, executor = null) { const messageStr = typeof message === "string" ? message : JSON.stringify(message); const finalMessage = messageStr || DEFAULT_ERROR_MESSAGES[response.status] || `Upstream error: ${response.status}`; - return { statusCode: response.status, message: finalMessage }; + return { statusCode: response.status, message: finalMessage, resetsAtMs }; } /** diff --git a/public/providers/nan.png b/public/providers/nan.png new file mode 100644 index 00000000000..6a16aeb6fb7 Binary files /dev/null and b/public/providers/nan.png differ diff --git a/src/app/(dashboard)/dashboard/providers/page.js b/src/app/(dashboard)/dashboard/providers/page.js index f03d1b4a684..685fe17de71 100644 --- a/src/app/(dashboard)/dashboard/providers/page.js +++ b/src/app/(dashboard)/dashboard/providers/page.js @@ -27,7 +27,7 @@ import ModelAvailabilityBadge from "./components/ModelAvailabilityBadge"; import AddCompatibleModal from "./components/AddCompatibleModal"; import { STATUS_FILTER_OPTIONS, matchesStatusFilter } from "./utils"; -function getStatusDisplay(connected, error, errorCode) { +function getStatusDisplay(connected, error, errorCode, total) { const parts = []; if (connected > 0) { parts.push( @@ -46,6 +46,14 @@ function getStatusDisplay(connected, error, errorCode) { , ); } + const untested = total - connected - error; + if (untested > 0) { + parts.push( + + {untested} Untested + , + ); + } if (parts.length === 0) { return No connections; } @@ -250,6 +258,8 @@ export default function ProvidersPage() { }); const data = await res.json(); setTestResults(data); + const refreshed = await fetch("/api/providers").catch(() => null); + if (refreshed?.ok) setConnections((await refreshed.json()).connections || []); if (data.summary) { const { passed, failed, total } = data.summary; if (failed === 0) notify.success(`All ${total} tests passed`); @@ -701,7 +711,7 @@ export default function ProvidersPage() { } function ProviderCard({ providerId, provider, stats, authType, onToggle }) { - const { connected, error, errorCode, errorTime, allDisabled } = stats; + const { connected, error, total, errorCode, errorTime, allDisabled } = stats; const isNoAuth = !!provider.noAuth; const dotColors = { @@ -758,7 +768,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) { Ready ) : ( <> - {getStatusDisplay(connected, error, errorCode)} + {getStatusDisplay(connected, error, errorCode, total)} {errorTime && ( {errorTime} )} @@ -817,7 +827,7 @@ function ApiKeyProviderCard({ authType, onToggle, }) { - const { connected, error, errorCode, errorTime, allDisabled } = stats; + const { connected, error, total, errorCode, errorTime, allDisabled } = stats; const isCompatible = providerId.startsWith(OPENAI_COMPATIBLE_PREFIX); const isAnthropicCompatible = providerId.startsWith( ANTHROPIC_COMPATIBLE_PREFIX, @@ -884,7 +894,7 @@ function ApiKeyProviderCard({ ) : ( <> - {getStatusDisplay(connected, error, errorCode)} + {getStatusDisplay(connected, error, errorCode, total)} {isCompatible && ( {provider.apiType === "responses" diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js index 8b6e01ff68f..a715309c7a8 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaTable.js @@ -90,6 +90,7 @@ export default function QuotaTable({ sortMode = "default", showSortLabel = false, onHideQuota = null, + rowLabel = "quota", }) { const [page, setPage] = useState(1); @@ -97,7 +98,7 @@ export default function QuotaTable({ () => quotas.map((quota, index) => ({ ...quota, index, - remaining: getRemainingPercentage(quota), + remaining: quota.limitUnknown ? null : getRemainingPercentage(quota), })), [quotas], ); @@ -139,7 +140,7 @@ export default function QuotaTable({
- {sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""} + {sortedQuotas.length} {rowLabel}{sortedQuotas.length === 1 ? "" : "s"}
{showSortLabel && (
@@ -152,7 +153,10 @@ export default function QuotaTable({ {currentPageRows.map((quota) => { const isUnlimited = quota.unlimited === true; const isCreditBalance = quota.isCreditBalance === true; - const colors = isCreditBalance + const isUnknown = quota.limitUnknown === true; + const colors = isUnknown + ? { text: "text-text-muted", bg: "", bgLight: "", emoji: "•" } + : isCreditBalance ? { text: "text-blue-600 dark:text-blue-400", bg: "bg-blue-500", bgLight: "bg-blue-500/10", emoji: "💰" } : getColorClasses(quota.colorPercentage ?? quota.remaining); // Qoder flags open-ended plans with `neverExpires` (its API reports a @@ -211,7 +215,7 @@ export default function QuotaTable({ {/* Progress + used/total */}
- {!isUnlimited && !isCreditBalance && ( + {!isUnlimited && !isCreditBalance && !isUnknown && (
@@ -226,14 +230,18 @@ export default function QuotaTable({ 0 ? quota.total.toLocaleString() : "∞"}` + : `${quota.used.toLocaleString()} / ${quota.total > 0 ? quota.total.toLocaleString() : "∞"}${quota.unit ? ` ${quota.unit}` : ""}` } > - {isUnlimited + {isUnknown + ? `${quota.used.toLocaleString()} ${quota.unit || "units"} used · ${(quota.apiRequests || 0).toLocaleString()} requests` + : isUnlimited ? `${quota.used.toLocaleString()} used · Unlimited` : isCreditBalance ? `Credit: ${quota.total.toFixed(2)} ${quota.currency || ""}` @@ -242,7 +250,7 @@ export default function QuotaTable({ }`} - {isUnlimited ? "Unlimited" : isCreditBalance ? "" : `${quota.remaining}%`} + {isUnknown ? "Limit unpublished" : isUnlimited ? "Unlimited" : isCreditBalance ? "" : `${quota.remaining}%`}
diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js index ae346438abb..89cbcd1e661 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.js @@ -1378,15 +1378,16 @@ export default function ProviderLimits() { quotas={visibleQuotas} compact sortMode="default" + rowLabel={conn.provider === "nan" ? "model" : "quota"} showSortLabel={ conn.provider === "codex" && quotaSortMode !== "default" } onHideQuota={(quotaRow) => handleHideQuota(conn.provider, quotaRow)} /> )} - {quota?.message && !error && !isLoading && ( + {quota?.raw?.summary && !error && !isLoading && (

- {quota.message} + {quota.raw.summary}

)} {hiddenQuotaRows.length > 0 && ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index c070f27f9ed..77cf94c9ed3 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -740,6 +740,22 @@ export function parseQuotaData(provider, data) { } break; + case "nan": + if (data.quotas) { + Object.entries(data.quotas).forEach(([model, quota]) => { + normalizedQuotas.push({ + name: model, + used: quota.used, + total: quota.total, + resetAt: quota.resetAt || null, + limitUnknown: quota.limitUnknown === true, + unit: quota.unit, + apiRequests: quota.apiRequests, + }); + }); + } + break; + case "ollama": // Session (5h) / Weekly (7d) / Monthly usage % from ollama.com/api/balance (or /api/usage). // remainingPercentage only — no absolute remaining (UI treats remaining as %). diff --git a/src/app/api/models/route.js b/src/app/api/models/route.js index dddb2afc763..6f6e42cf36a 100644 --- a/src/app/api/models/route.js +++ b/src/app/api/models/route.js @@ -29,6 +29,7 @@ export async function GET() { alias: modelAliases[fullModel] || m.model, caps: { vision: c.vision, + audioInput: c.audioInput, search: c.search, reasoning: c.reasoning, contextWindow: c.contextWindow, @@ -66,6 +67,7 @@ export async function GET() { alias: modelAliases[fullModel] || m.id, caps: { vision: c.vision, + audioInput: c.audioInput, search: c.search, reasoning: c.reasoning, contextWindow: c.contextWindow, diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index 609402dc83c..378d7d2cbcf 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -179,6 +179,7 @@ function buildQoderModelsResolver(providerId) { // Provider models endpoints configuration const PROVIDER_MODELS_CONFIG = { + nan: createOpenAIModelsConfig("https://api.nan.builders/v1/models"), "muse": { url: "https://api.meta.ai/v1/models", method: "GET", diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 6019bac7197..bfd3096ba4e 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -641,6 +641,17 @@ async function testApiKeyConnection(connection, effectiveProxy = null) { const res = await fetchWithConnectionProxy("https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; } + case "nan": { + const url = PROVIDERS.nan.baseUrl.replace(/\/chat\/completions$/, "/models"); + const res = await fetchWithConnectionProxy(url, { + headers: { Authorization: `Bearer ${connection.apiKey}`, "User-Agent": "9Router" }, + }, effectiveProxy); + if (res.status === 429) return { valid: true, error: "NaN model list rate limited (429); retry later" }; + if (!res.ok) return { valid: false, error: `NaN model list: HTTP ${res.status}` }; + const data = await res.json(); + const valid = Array.isArray(data?.data) && data.data.length > 0; + return { valid, error: valid ? null : "NaN returned no models" }; + } case "vercel-ai-gateway": { const res = await fetchWithConnectionProxy("https://ai-gateway.vercel.sh/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } }, effectiveProxy); return { valid: res.ok, error: res.ok ? null : "Invalid API key" }; diff --git a/src/app/api/v1/images/edits/route.js b/src/app/api/v1/images/edits/route.js new file mode 100644 index 00000000000..3525e0a6071 --- /dev/null +++ b/src/app/api/v1/images/edits/route.js @@ -0,0 +1,6 @@ +import { editImage } from "@/sse/handlers/nanExtra.js"; + +export async function POST(request) { return editImage(request); } +export async function OPTIONS() { + return new Response(null, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*" } }); +} diff --git a/src/app/api/v1/models/[...model]/route.js b/src/app/api/v1/models/[...model]/route.js index 77df20f3cad..93ae158b559 100644 --- a/src/app/api/v1/models/[...model]/route.js +++ b/src/app/api/v1/models/[...model]/route.js @@ -7,6 +7,7 @@ const KIND_SLUG_MAP = { "tts": ["tts"], "stt": ["stt"], "embedding": ["embedding"], + "rerank": ["rerank"], "image-to-text": ["imageToText"], "web": ["webSearch", "webFetch"], }; diff --git a/src/app/api/v1/models/info/route.js b/src/app/api/v1/models/info/route.js index 551c12221d4..3c270e4db18 100644 --- a/src/app/api/v1/models/info/route.js +++ b/src/app/api/v1/models/info/route.js @@ -8,6 +8,7 @@ const KIND_ENDPOINT = { tts: "/v1/audio/speech", stt: "/v1/audio/transcriptions", embedding: "/v1/embeddings", + rerank: "/v1/rerank", imageToText: "/v1/chat/completions", webSearch: "/v1/search", webFetch: "/v1/fetch", diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js index 83ee968e5d4..69475cf54b9 100644 --- a/src/app/api/v1/models/route.js +++ b/src/app/api/v1/models/route.js @@ -181,6 +181,7 @@ const MODEL_TYPE_TO_KIND = { stt: "stt", imageToText: "imageToText", video: "video", + rerank: "rerank", }; function modelKind(model) { @@ -416,7 +417,7 @@ export async function buildModelsList(kindFilter, options = {}) { const providerId = ALIAS_TO_PROVIDER_ID[alias] || alias; if (!providerMatchesKinds(providerId, kindFilter)) continue; for (const model of providerModels) { - if (!kindFilter.includes(modelKind(model))) continue; + if (!kindFilter.includes(modelKind(model)) && !(kindFilter.includes("imageToText") && modelKind(model) === LLM_KIND && getCapabilitiesForModel(providerId, model.id).vision)) continue; if (isDisabled(alias, model.id) || isDisabled(providerId, model.id)) continue; models.push({ id: `${alias}/${model.id}`, @@ -571,7 +572,8 @@ export async function buildModelsList(kindFilter, options = {}) { const kind = customKind || liveKind || staticModelKindById.get(modelId) || inferKindFromUnknownModelId(modelId); // imageToText custom models stay in the LLM list (vision-capable chat models) const allowAsLlm = kind === "imageToText" && kindFilter.includes(LLM_KIND); - if (!kindFilter.includes(kind) && !allowAsLlm) continue; + const allowAsVision = kind === LLM_KIND && kindFilter.includes("imageToText") && getCapabilitiesForModel(providerId, modelId).vision; + if (!kindFilter.includes(kind) && !allowAsLlm && !allowAsVision) continue; if (isDisabled(outputAlias, modelId) || isDisabled(staticAlias, modelId) || isDisabled(providerId, modelId)) continue; const model = { diff --git a/src/app/api/v1/rerank/route.js b/src/app/api/v1/rerank/route.js new file mode 100644 index 00000000000..99f12a963e6 --- /dev/null +++ b/src/app/api/v1/rerank/route.js @@ -0,0 +1,6 @@ +import { rerank } from "@/sse/handlers/nanExtra.js"; + +export async function POST(request) { return rerank(request); } +export async function OPTIONS() { + return new Response(null, { headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "*" } }); +} diff --git a/src/shared/constants/models.js b/src/shared/constants/models.js index 96667048f69..bf11751ec3b 100644 --- a/src/shared/constants/models.js +++ b/src/shared/constants/models.js @@ -42,6 +42,7 @@ export const getModelKind = (m, fallback = null) => m?.kind || m?.type || fallba // 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" }, + audioInput: { icon: "mic", label: "Audio input", desc: "Supports audio input", color: "text-purple-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 cd25585e41b..2d77a9e2d12 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -4,7 +4,7 @@ import { RISK_NOTICE } from "@/shared/constants/providersDisplay.js"; const MEDIA_ENTRY_KEYS = [ "serviceKinds", "ttsConfig", "sttConfig", "embeddingConfig", - "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", + "imageConfig", "imageToTextConfig", "videoConfig", "musicConfig", "rerankConfig", "searchViaChat", "searchConfig", "fetchConfig", "credentialFallback", "systemoneConfig", "modelsFetcher", "mediaPriority", "hiddenKinds", ]; diff --git a/src/shared/hooks/useModelCaps.js b/src/shared/hooks/useModelCaps.js index 317e95804aa..71e59f0fc6c 100644 --- a/src/shared/hooks/useModelCaps.js +++ b/src/shared/hooks/useModelCaps.js @@ -53,6 +53,7 @@ export function resolveCaps(byFull, byId, key) { const c = getCapabilitiesForModel(provider, bare); return { vision: c.vision, + audioInput: c.audioInput, search: c.search, reasoning: c.reasoning, contextWindow: c.contextWindow, diff --git a/src/sse/handlers/nanExtra.js b/src/sse/handlers/nanExtra.js new file mode 100644 index 00000000000..74aa46116f5 --- /dev/null +++ b/src/sse/handlers/nanExtra.js @@ -0,0 +1,108 @@ +import { getSettings } from "@/lib/localDb"; +import { PROVIDER_MEDIA } from "open-sse/providers/index.js"; +import { proxyAwareFetch } from "open-sse/utils/proxyFetch.js"; +import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; +import { getProviderCredentials, markAccountUnavailable, clearAccountError, extractApiKey, isValidApiKey } from "../services/auth.js"; +import { resolveConnectionProxyConfig } from "@/lib/network/connectionProxy"; +import { getKeyAccessContext, enforceKeyAccessResolved } from "../services/keyAccess.js"; + +const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); +const IMAGE_FIELDS = new Set(["prompt", "n", "size", "response_format", "seed", "guidance"]); + +async function authenticated(request) { + const settings = await getSettings(); + if (!settings.requireApiKey) return null; + const key = extractApiKey(request); + return key && await isValidApiKey(key) ? null : errorResponse(401, "Invalid or missing API key"); +} + +async function sendToNan(request, model, url, body, headers) { + const denied = await enforceKeyAccessResolved(await getKeyAccessContext(request), `nan/${model}`, "nan", model); + if (denied) return denied; + const excluded = new Set(); + let lastStatus = 503; + let lastError = "No NaN account available"; + while (true) { + const credentials = await getProviderCredentials("nan", excluded, model); + if (!credentials) return errorResponse(lastStatus, lastError); + if (credentials.allRateLimited) { + return unavailableResponse(lastStatus, lastError, credentials?.retryAfter, credentials?.retryAfterHuman); + } + if (!credentials.apiKey) return errorResponse(401, "NaN API key missing"); + let response; + try { + response = await proxyAwareFetch(url, { + method: "POST", + headers: { Authorization: `Bearer ${credentials.apiKey}`, ...headers }, + body, + signal: AbortSignal.timeout(120000), + }, await resolveConnectionProxyConfig(credentials.providerSpecificData || {})); + } catch (error) { + const failed = await markAccountUnavailable(credentials.connectionId, 502, error.message, "nan", model); + if (failed.shouldFallback) { + excluded.add(credentials.connectionId); + lastStatus = 502; + lastError = "NaN request failed"; + continue; + } + return errorResponse(502, "NaN request failed"); + } + const bytes = await response.arrayBuffer(); + const responseHeaders = { + "Content-Type": response.headers.get("content-type") || "application/json", + "Access-Control-Allow-Origin": "*", + }; + const retryAfter = response.headers.get("retry-after"); + if (retryAfter) responseHeaders["Retry-After"] = retryAfter; + if (response.ok) { + await clearAccountError(credentials.connectionId, credentials, model); + return new Response(bytes, { status: response.status, headers: responseHeaders }); + } + const message = new TextDecoder().decode(bytes).slice(0, 2000); + const retrySeconds = Number(retryAfter); + const resetAt = response.status === 429 && Number.isFinite(retrySeconds) && retrySeconds > 0 + ? Date.now() + retrySeconds * 1000 : null; + const failed = await markAccountUnavailable(credentials.connectionId, response.status, message, "nan", model, resetAt); + if (!failed.shouldFallback) return new Response(bytes, { status: response.status, headers: responseHeaders }); + excluded.add(credentials.connectionId); + lastStatus = response.status; + lastError = message.slice(0, 200); + } +} + +export async function rerank(request) { + const denied = await authenticated(request); + if (denied) return denied; + let input; + try { input = await request.json(); } catch { return errorResponse(400, "Invalid JSON body"); } + const { model, query, documents, top_n } = input || {}; + if (model !== "nan/rerank" || typeof query !== "string" || !query.trim() + || !Array.isArray(documents) || !documents.length || !documents.every((item) => typeof item === "string" && item.trim())) { + return errorResponse(400, "Expected nan/rerank, non-empty query, and non-empty string documents"); + } + if (top_n !== undefined && (!Number.isInteger(top_n) || top_n < 1 || top_n > documents.length)) { + return errorResponse(400, "top_n must be between 1 and document count"); + } + const body = JSON.stringify({ model: "rerank", query, documents, ...(top_n === undefined ? {} : { top_n }) }); + return sendToNan(request, "rerank", PROVIDER_MEDIA.nan.rerankConfig.baseUrl, body, { "Content-Type": "application/json" }); +} + +export async function editImage(request) { + const denied = await authenticated(request); + if (denied) return denied; + let input; + try { input = await request.formData(); } catch { return errorResponse(400, "Invalid multipart body"); } + if (input.get("model") !== "nan/flux-2-klein" || typeof input.get("prompt") !== "string" || !input.get("prompt").trim() + || input.has("mask")) return errorResponse(400, "Expected nan/flux-2-klein, prompt, and no mask"); + const images = [...input.entries()].filter(([name]) => name === "image" || name === "image[]").map(([, value]) => value); + if (!images.length || images.length > 4 || images.some((file) => !(file instanceof File) || !IMAGE_TYPES.has(file.type) || file.size > 25 * 1024 * 1024)) { + return errorResponse(400, "Expected 1-4 PNG, JPEG, or WebP images up to 25 MiB each"); + } + const body = new FormData(); + body.set("model", "flux-2-klein"); + for (const [name, value] of input) { + if (IMAGE_FIELDS.has(name)) body.set(name, value); + } + for (const image of images) body.append("image[]", image, image.name); + return sendToNan(request, "flux-2-klein", PROVIDER_MEDIA.nan.imageConfig.editUrl, body, {}); +} diff --git a/tests/__baseline__/alias-baseline.json b/tests/__baseline__/alias-baseline.json index 13ff65d330f..bb09135d2a3 100644 --- a/tests/__baseline__/alias-baseline.json +++ b/tests/__baseline__/alias-baseline.json @@ -220,6 +220,7 @@ "morph": "morph", "muse": "muse", "muse-web": "muse-web", + "nan": "nan", "nanobanana": "nanobanana", "nebius": "nebius", "nous": "nous", @@ -349,6 +350,7 @@ "morph", "muse", "muse-web", + "nan", "nanobanana", "nebius", "nous", @@ -356,6 +358,7 @@ "oc", "ocz", "ollama", + "ollama-local", "openai", "openai-tts-models", "openai-tts-voices", diff --git a/tests/__baseline__/providers-baseline.json b/tests/__baseline__/providers-baseline.json index 586ee7affde..e9be77e276e 100644 --- a/tests/__baseline__/providers-baseline.json +++ b/tests/__baseline__/providers-baseline.json @@ -1272,5 +1272,17 @@ "baseUrl": "https://internal.chatuncensored.ai", "format": "openai", "authType": "cookie" + }, + "sensenova": { + "baseUrl": "https://token.sensenova.cn/v1/chat/completions", + "validateUrl": "https://token.sensenova.cn/v1/models", + "format": "openai" + }, + "nan": { + "baseUrl": "https://api.nan.builders/v1/chat/completions", + "usage": { + "url": "https://cloud-api.nan.builders/api/usage/quota" + }, + "format": "openai" } } \ No newline at end of file diff --git a/tests/unit/key-access-handlers.test.js b/tests/unit/key-access-handlers.test.js index cfc60b16723..cbf61ee8e4e 100644 --- a/tests/unit/key-access-handlers.test.js +++ b/tests/unit/key-access-handlers.test.js @@ -53,6 +53,7 @@ const { handleVideoCreate } = await import("../../src/sse/handlers/videoGenerati const { handleSearch } = await import("../../src/sse/handlers/search.js"); const { handleFetch } = await import("../../src/sse/handlers/fetch.js"); const { handleSystemone } = await import("../../src/sse/handlers/systemone.js"); +const { rerank, editImage } = await import("../../src/sse/handlers/nanExtra.js"); const geminiRoute = await import("../../src/app/api/v1beta/models/[...path]/route.js"); const modelsRoute = await import("../../src/app/api/v1/models/route.js"); const modelsKindRoute = await import("../../src/app/api/v1/models/[...model]/route.js"); @@ -199,6 +200,29 @@ describe.each(handlers)("%s handler is wired", (_name, call, allowed, denied) => }); }); +describe.each([ + ["nan/rerank", (k) => rerank(post("/v1/rerank", { model: "nan/rerank", query: "x", documents: ["x"] }, k))], + ["nan/flux-2-klein", (k) => { + const body = new FormData(); + body.set("model", "nan/flux-2-klein"); + body.set("prompt", "x"); + body.set("image", new Blob([new Uint8Array(4)], { type: "image/png" }), "a.png"); + return editImage(new Request("http://localhost/v1/images/edits", { method: "POST", headers: auth(k), body })); + }], +])("NaN key access: %s", (model, call) => { + it("denies unlisted models before credential lookup", async () => { + expect((await call("sk-empty")).status).toBe(403); + expect(mocks.getProviderCredentials).not.toHaveBeenCalled(); + }); + it("allows listed models and unrestricted keys", async () => { + fx.keys["sk-media"].access.allow.push(model); + for (const key of ["sk-media", "sk-open", null]) { + expect((await call(key)).status).toBe(503); + } + expect(mocks.getProviderCredentials).toHaveBeenCalledTimes(3); + }); +}); + describe("video: a body without a readable model is denied for restricted keys", () => { it("multipart / no model -> 403 for restricted, untouched for unrestricted", async () => { const noModel = (k) => handleVideoCreate(post("/v1/videos/generations", { prompt: "x" }, k), "generations"); diff --git a/tests/unit/nan-provider.mjs b/tests/unit/nan-provider.mjs new file mode 100644 index 00000000000..17dcd940d70 --- /dev/null +++ b/tests/unit/nan-provider.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import REGISTRY from "../../open-sse/providers/registry/index.js"; +import { getCapabilitiesForModel } from "../../open-sse/providers/capabilities.js"; +import { parseUpstreamError } from "../../open-sse/utils/error.js"; +import { formatNanUsage } from "../../open-sse/services/usage/nan.js"; + +assert.equal(new Set(REGISTRY.map((item) => item.id)).size, REGISTRY.length, "provider IDs must be unique"); +for (const id of ["nan", "tinyfish", "v1m", "muse", "minimax-code", "minimax-code-global", "bedrock", "bedrock-xai", "sensenova", "deepseek-web", "gemini-web", "muse-web", "chatgpt-web", "veoaifree-web", "uc-web"]) { + assert.equal(REGISTRY.filter((item) => item.id === id).length, 1, `${id} must be registered once`); +} +for (const id of ["trae", "devin-cli", "windsurf"]) { + assert.equal(REGISTRY.some((item) => item.id === id), false, `${id} must remain disabled`); +} +const nan = REGISTRY.find((item) => item.id === "nan"); +assert(nan); +assert.equal(nan.models.find((item) => item.id === "rerank").kind, "rerank"); +assert.equal(nan.models.find((item) => item.id === "flux-2-klein").kind, "image"); +assert.equal(nan.imageConfig.editUrl, "https://api.nan.builders/v1/images/edits"); +assert.equal(getCapabilitiesForModel("nan", "mimo-v2.5").audioInput, true); +assert.equal(getCapabilitiesForModel("nan", "glm5.3-flash").vision, true); +assert.equal(getCapabilitiesForModel("nan", "glm5.3").vision, false); +const retry = await parseUpstreamError(new Response('{"error":{"message":"slow down"}}', { + status: 429, headers: { "retry-after": "60" }, +})); +assert(retry.resetsAtMs > Date.now() + 59000 && retry.resetsAtMs < Date.now() + 61000); +const report = { + periodStart: "2026-10-01", + models: [ + { model: "deepseek-v4-flash", tokensUsed: 1500000000, cap: 3000000000, periodEnd: "2026-11-01T00:00:00Z" }, + { model: "glm5.3-flash", tokensUsed: 650000000, cap: 2000000000, periodEnd: "2026-11-01T00:00:00Z" }, + { model: "glm5.3", tokensUsed: 500000000, cap: 1500000000, fullCap: 3000000000, periodEnd: "2026-11-09T15:06:22Z" }, + ], +}; +const usage = formatNanUsage(report); +assert.equal(usage.plan, "NaN"); +assert.deepEqual(Object.keys(usage.quotas), report.models.map((row) => row.model)); +for (const row of report.models) { + assert.deepEqual(usage.quotas[row.model], { + used: row.tokensUsed, total: row.cap, resetAt: row.periodEnd, unit: "tokens", + }); +} +assert.equal(usage.summary, undefined); +assert.equal(nan.transport.usage.url, "https://cloud-api.nan.builders/api/usage/quota"); +assert.equal(nan.transport.usage.monthlyTokenCaps, undefined); + +for (const used of [0, 100, 101]) { + const result = formatNanUsage({ models: [{ model: "new-model", tokensUsed: used, cap: 100 }] }); + assert.deepEqual(result.quotas["new-model"], { used, total: 100, resetAt: null, unit: "tokens" }); +} +for (const invalid of [ + null, {}, { models: [] }, { models: {} }, { models: [null] }, + { totals: { total_tokens: 0, api_requests: 0, by_model: [] }, all_time: { total_tokens: 0 } }, + ...[ + { model: "" }, { model: " " }, { model: 1 }, + { tokensUsed: undefined }, { tokensUsed: null }, { tokensUsed: -1 }, { tokensUsed: "0" }, + { tokensUsed: 0.5 }, { tokensUsed: Infinity }, { tokensUsed: Number.MAX_SAFE_INTEGER + 1 }, + { cap: undefined }, { cap: null }, { cap: 0 }, { cap: -1 }, { cap: "100" }, + { cap: 0.5 }, { cap: Infinity }, { periodEnd: "invalid-date" }, { periodEnd: 123 }, + ].map((overrides) => ({ models: [report.models[0], { ...report.models[1], ...overrides }] })), + { models: [report.models[0], report.models[0]] }, +]) { + const result = formatNanUsage(invalid); + assert.equal(result.quotas, undefined); + assert.match(result.message, /unavailable/i); +} +console.log("NaN provider and usage OK");