- {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");