Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions open-sse/handlers/embeddingProviders/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
1 change: 1 addition & 0 deletions open-sse/handlers/imageProviders/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion open-sse/handlers/ttsCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`);
Expand Down
6 changes: 3 additions & 3 deletions open-sse/handlers/ttsProviders/genericFormats.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions open-sse/handlers/ttsProviders/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions open-sse/providers/capabilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const SERVICE_KIND_CAPABILITIES = {
stt: { audioInput: true },
tts: { audioOutput: true },
embedding: { tools: false },
rerank: { tools: false },
};

export function capabilitiesFromServiceKind(kind) {
Expand Down Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion open-sse/providers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]);
Expand Down
2 changes: 2 additions & 0 deletions open-sse/providers/registry/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -290,4 +291,5 @@ export default [
p146,
p147,
p151,
p152,
];
44 changes: 44 additions & 0 deletions open-sse/providers/registry/nan.js
Original file line number Diff line number Diff line change
@@ -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 },
};
2 changes: 2 additions & 0 deletions open-sse/services/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
Expand Down
27 changes: 27 additions & 0 deletions open-sse/services/usage/nan.js
Original file line number Diff line number Diff line change
@@ -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());
}
8 changes: 6 additions & 2 deletions open-sse/utils/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 */ }
}
Expand All @@ -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 };
}

/**
Expand Down
Binary file added public/providers/nan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 15 additions & 5 deletions src/app/(dashboard)/dashboard/providers/page.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -46,6 +46,14 @@ function getStatusDisplay(connected, error, errorCode) {
</Badge>,
);
}
const untested = total - connected - error;
if (untested > 0) {
parts.push(
<Badge key="untested" variant="default" size="sm" dot>
{untested} Untested
</Badge>,
);
}
if (parts.length === 0) {
return <span className="text-text-muted">No connections</span>;
}
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -758,7 +768,7 @@ function ProviderCard({ providerId, provider, stats, authType, onToggle }) {
<Badge variant="success" size="sm" dot>Ready</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, total)}
{errorTime && (
<span className="text-text-muted">{errorTime}</span>
)}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -884,7 +894,7 @@ function ApiKeyProviderCard({
</Badge>
) : (
<>
{getStatusDisplay(connected, error, errorCode)}
{getStatusDisplay(connected, error, errorCode, total)}
{isCompatible && (
<Badge variant="default" size="sm">
{provider.apiType === "responses"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,14 +90,15 @@ export default function QuotaTable({
sortMode = "default",
showSortLabel = false,
onHideQuota = null,
rowLabel = "quota",
}) {
const [page, setPage] = useState(1);

const normalizedQuotas = useMemo(
() => quotas.map((quota, index) => ({
...quota,
index,
remaining: getRemainingPercentage(quota),
remaining: quota.limitUnknown ? null : getRemainingPercentage(quota),
})),
[quotas],
);
Expand Down Expand Up @@ -139,7 +140,7 @@ export default function QuotaTable({
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="text-[10px] text-text-muted">
{sortedQuotas.length} quota{sortedQuotas.length > 1 ? "s" : ""}
{sortedQuotas.length} {rowLabel}{sortedQuotas.length === 1 ? "" : "s"}
</div>
{showSortLabel && (
<div className="rounded-md border border-black/10 bg-black/[0.02] px-2 py-1 text-[10px] text-text-muted dark:border-white/10 dark:bg-white/[0.03]">
Expand All @@ -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
Expand Down Expand Up @@ -211,7 +215,7 @@ export default function QuotaTable({

{/* Progress + used/total */}
<div className={`min-w-0 flex-1 ${compact ? "space-y-1" : "space-y-1.5"}`}>
{!isUnlimited && !isCreditBalance && (
{!isUnlimited && !isCreditBalance && !isUnknown && (
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
quota.remaining === 0 ? "border-black/10 dark:border-white/10" : "border-transparent"
}`}>
Expand All @@ -226,14 +230,18 @@ export default function QuotaTable({
<span
className="text-text-muted truncate"
title={
isUnlimited
isUnknown
? `${quota.used.toLocaleString()} ${quota.unit || "units"} used · ${(quota.apiRequests || 0).toLocaleString()} recorded requests · limit unpublished`
: isUnlimited
? `${quota.used.toLocaleString()} used · Unlimited`
: isCreditBalance
? `Credit balance: ${quota.total.toFixed(2)} ${quota.currency || ""}`
: `${quota.used.toLocaleString()} / ${quota.total > 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 || ""}`
Expand All @@ -242,7 +250,7 @@ export default function QuotaTable({
}`}
</span>
<span className={`font-medium ${isUnlimited ? "text-green-600 dark:text-green-400" : isCreditBalance ? "text-blue-600 dark:text-blue-400" : colors.text} shrink-0`}>
{isUnlimited ? "Unlimited" : isCreditBalance ? "" : `${quota.remaining}%`}
{isUnknown ? "Limit unpublished" : isUnlimited ? "Unlimited" : isCreditBalance ? "" : `${quota.remaining}%`}
</span>
</div>
</div>
Expand Down
Loading