mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-23 04:09:49 +00:00
feat: add STT support, Gemini TTS, and expand usage tracking
- Speech-to-Text: full pipeline with sttCore handler, /v1/audio/transcriptions endpoint, sttConfig for OpenAI, Gemini, Groq, Deepgram, AssemblyAI, HuggingFace, NVIDIA Parakeet; new 9router-stt skill - Gemini TTS: add gemini provider with 30 prebuilt voices and TTS_PROVIDER_CONFIG - Usage: implement GLM (intl/cn) and MiniMax (intl/cn) quota fetchers; refactor Gemini CLI usage to use retrieveUserQuota with per-model buckets - Disabled models: lowdb-backed disabledModelsDb + /api/models/disabled route - Header search: reusable Zustand store (headerSearchStore) wired into Header - CLI tools: add Claude Cowork tool card and cowork-settings API - Providers: introduce mediaPriority sorting in getProvidersByKind, add Kimi K2.6, reorder hermes, drop qwen STT kind - UI: expand media-providers/[kind]/[id] page (+314), enhance OAuthModal, ModelSelectModal, ProviderTopology, ProxyPools, ProviderLimits - Assets: refresh provider PNGs (alicode, byteplus, cloudflare-ai, nvidia, ollama, vertex, volcengine-ark) and add aws-polly, fal-ai, jina-ai, recraft, runwayml, stability-ai, topaz, black-forest-labs
This commit is contained in:
+328
-17
@@ -11,6 +11,24 @@ const GITHUB_CONFIG = {
|
||||
userAgent: "GitHubCopilotChat/0.26.7",
|
||||
};
|
||||
|
||||
// GLM quota endpoints (region-aware)
|
||||
const GLM_QUOTA_URLS = {
|
||||
international: "https://api.z.ai/api/monitor/usage/quota/limit",
|
||||
china: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
|
||||
};
|
||||
|
||||
// MiniMax usage endpoints (try in order, fallback on transient errors)
|
||||
const MINIMAX_USAGE_URLS = {
|
||||
minimax: [
|
||||
"https://www.minimax.io/v1/token_plan/remains",
|
||||
"https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
|
||||
],
|
||||
"minimax-cn": [
|
||||
"https://www.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
||||
"https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
||||
],
|
||||
};
|
||||
|
||||
// Antigravity API config (from Quotio)
|
||||
const ANTIGRAVITY_CONFIG = {
|
||||
quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels",
|
||||
@@ -40,13 +58,13 @@ const CLAUDE_CONFIG = {
|
||||
* @returns {Object} Usage data with quotas
|
||||
*/
|
||||
export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||
const { provider, accessToken, providerSpecificData } = connection;
|
||||
const { provider, accessToken, apiKey, providerSpecificData } = connection;
|
||||
|
||||
switch (provider) {
|
||||
case "github":
|
||||
return await getGitHubUsage(accessToken, providerSpecificData, proxyOptions);
|
||||
case "gemini-cli":
|
||||
return await getGeminiUsage(accessToken, proxyOptions);
|
||||
return await getGeminiUsage(accessToken, providerSpecificData, proxyOptions);
|
||||
case "antigravity":
|
||||
return await getAntigravityUsage(accessToken, providerSpecificData, proxyOptions);
|
||||
case "claude":
|
||||
@@ -61,6 +79,12 @@ export async function getUsageForProvider(connection, proxyOptions = null) {
|
||||
return await getIflowUsage(accessToken);
|
||||
case "ollama":
|
||||
return await getOllamaUsage(accessToken);
|
||||
case "glm":
|
||||
case "glm-cn":
|
||||
return await getGlmUsage(apiKey, provider, proxyOptions);
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return await getMiniMaxUsage(apiKey, provider, proxyOptions);
|
||||
default:
|
||||
return { message: `Usage API not implemented for ${provider}` };
|
||||
}
|
||||
@@ -188,31 +212,115 @@ function formatGitHubQuotaSnapshot(quota) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini CLI Usage (Google Cloud)
|
||||
* Gemini CLI Usage — fetch per-model quota via Cloud Code Assist API.
|
||||
* Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning
|
||||
* per-model buckets with remainingFraction + resetTime.
|
||||
*/
|
||||
async function getGeminiUsage(accessToken, proxyOptions = null) {
|
||||
async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
||||
if (!accessToken) {
|
||||
return { plan: "Free", message: "Gemini CLI access token not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve project id: prefer connection-stored id, else loadCodeAssist lookup
|
||||
let projectId = providerSpecificData?.projectId || null;
|
||||
let plan = "Free";
|
||||
|
||||
if (!projectId) {
|
||||
const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions);
|
||||
projectId = subInfo?.cloudaicompanionProject || null;
|
||||
plan = subInfo?.currentTier?.name || plan;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return { plan, message: "Gemini CLI project ID not available." };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
let response;
|
||||
try {
|
||||
response = await proxyAwareFetch(
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ project: projectId }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
proxyOptions
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { plan, message: `Gemini CLI quota error (${response.status}).` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quotas = {};
|
||||
|
||||
if (Array.isArray(data.buckets)) {
|
||||
for (const bucket of data.buckets) {
|
||||
if (!bucket.modelId || bucket.remainingFraction == null) continue;
|
||||
|
||||
const remainingFraction = Number(bucket.remainingFraction) || 0;
|
||||
const total = 1000; // Normalized base, matches antigravity convention
|
||||
const remaining = Math.round(total * remainingFraction);
|
||||
const used = Math.max(0, total - remaining);
|
||||
|
||||
quotas[bucket.modelId] = {
|
||||
used,
|
||||
total,
|
||||
resetAt: parseResetTime(bucket.resetTime),
|
||||
remainingPercentage: remainingFraction * 100,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `Gemini CLI error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gemini CLI subscription info via loadCodeAssist
|
||||
*/
|
||||
async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
// Gemini CLI uses Google Cloud quotas
|
||||
// Try to get quota info from Cloud Resource Manager
|
||||
const response = await proxyAwareFetch(
|
||||
"https://cloudresourcemanager.googleapis.com/v1/projects?filter=lifecycleState:ACTIVE",
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
metadata: {
|
||||
ideType: "IDE_UNSPECIFIED",
|
||||
platform: "PLATFORM_UNSPECIFIED",
|
||||
pluginType: "GEMINI",
|
||||
},
|
||||
}),
|
||||
signal: controller.signal,
|
||||
},
|
||||
proxyOptions
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Quota API may not be accessible, return generic message
|
||||
return { message: "Gemini CLI uses Google Cloud quotas. Check Google Cloud Console for details." };
|
||||
}
|
||||
|
||||
return { message: "Gemini CLI connected. Usage tracked via Google Cloud Console." };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Gemini usage. Check Google Cloud Console." };
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,3 +906,206 @@ async function getOllamaUsage(accessToken, providerSpecificData) {
|
||||
return { message: "Unable to fetch Ollama Cloud usage." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GLM Coding Plan usage (international + China regions)
|
||||
*/
|
||||
async function getGlmUsage(apiKey, provider, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "GLM API key not available." };
|
||||
}
|
||||
|
||||
const region = provider === "glm-cn" ? "china" : "international";
|
||||
const quotaUrl = GLM_QUOTA_URLS[region];
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(quotaUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return { message: "GLM API key invalid or expired." };
|
||||
}
|
||||
return { message: `GLM quota API error (${response.status}).` };
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const data = json?.data && typeof json.data === "object" ? json.data : {};
|
||||
const limits = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
if (!limit || limit.type !== "TOKENS_LIMIT") continue;
|
||||
const usedPercent = Number(limit.percentage) || 0;
|
||||
const resetMs = Number(limit.nextResetTime) || 0;
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
quotas["session"] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
const levelRaw = typeof data.level === "string" ? data.level : "";
|
||||
const plan = levelRaw
|
||||
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
|
||||
: "Unknown";
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `GLM error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
// ── MiniMax helpers ──────────────────────────────────────────────────────
|
||||
function isMiniMaxTextQuotaModel(modelName) {
|
||||
const normalized = (modelName || "").trim().toLowerCase();
|
||||
return normalized.startsWith("minimax-m") || normalized.startsWith("coding-plan");
|
||||
}
|
||||
|
||||
function getMiniMaxField(model, snakeKey, camelKey) {
|
||||
if (!model || typeof model !== "object") return null;
|
||||
return model[snakeKey] ?? model[camelKey] ?? null;
|
||||
}
|
||||
|
||||
function getMiniMaxSessionTotal(model) {
|
||||
return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0);
|
||||
}
|
||||
|
||||
function getMiniMaxWeeklyTotal(model) {
|
||||
return Math.max(0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0);
|
||||
}
|
||||
|
||||
function pickMiniMaxRepresentativeModel(models, getTotal) {
|
||||
const withQuota = models.filter((m) => getTotal(m) > 0);
|
||||
const pool = withQuota.length > 0 ? withQuota : models;
|
||||
if (pool.length === 0) return null;
|
||||
return pool.reduce((best, current) => (getTotal(current) > getTotal(best) ? current : best));
|
||||
}
|
||||
|
||||
function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) {
|
||||
const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0;
|
||||
if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString();
|
||||
return parseResetTime(getMiniMaxField(model, endSnake, endCamel));
|
||||
}
|
||||
|
||||
function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining) {
|
||||
const safeTotal = Math.max(0, total);
|
||||
const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal);
|
||||
const remaining = Math.max(safeTotal - used, 0);
|
||||
return {
|
||||
used,
|
||||
total: safeTotal,
|
||||
remaining,
|
||||
remainingPercentage: safeTotal > 0 ? Math.max(0, Math.min(100, (remaining / safeTotal) * 100)) : 0,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniMax Token Plan / Coding Plan usage
|
||||
*/
|
||||
async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "MiniMax API key not available." };
|
||||
}
|
||||
|
||||
const usageUrls = MINIMAX_USAGE_URLS[provider] || [];
|
||||
let lastErrorMessage = "";
|
||||
|
||||
for (let index = 0; index < usageUrls.length; index += 1) {
|
||||
const usageUrl = usageUrls[index];
|
||||
const canFallback = index < usageUrls.length - 1;
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
const rawText = await response.text();
|
||||
let payload = {};
|
||||
if (rawText) {
|
||||
try { payload = JSON.parse(rawText); } catch { payload = {}; }
|
||||
}
|
||||
|
||||
const baseResp = (payload?.base_resp ?? payload?.baseResp) || {};
|
||||
const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0;
|
||||
const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim();
|
||||
const combined = `${apiStatusMessage} ${rawText}`.trim();
|
||||
const authLike = /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i;
|
||||
|
||||
if (response.status === 401 || response.status === 403 || apiStatusCode === 1004 || authLike.test(combined)) {
|
||||
return { message: "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
lastErrorMessage = `MiniMax usage endpoint error (${response.status})`;
|
||||
if ((response.status === 404 || response.status === 405 || response.status >= 500) && canFallback) continue;
|
||||
return { message: `MiniMax connected. ${lastErrorMessage}` };
|
||||
}
|
||||
|
||||
if (apiStatusCode !== 0) {
|
||||
return { message: `MiniMax connected. ${apiStatusMessage || "Upstream quota API error"}` };
|
||||
}
|
||||
|
||||
const modelRemains = payload?.model_remains ?? payload?.modelRemains;
|
||||
const allModels = Array.isArray(modelRemains) ? modelRemains : [];
|
||||
const textModels = allModels.filter((m) => isMiniMaxTextQuotaModel(String(getMiniMaxField(m, "model_name", "modelName"))));
|
||||
|
||||
if (textModels.length === 0) {
|
||||
return { message: "MiniMax connected. No text quota data was returned." };
|
||||
}
|
||||
|
||||
const capturedAtMs = Date.now();
|
||||
const countMeansRemaining = usageUrl.includes("/coding_plan/remains");
|
||||
const quotas = {};
|
||||
|
||||
const sessionModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxSessionTotal);
|
||||
if (sessionModel) {
|
||||
const total = getMiniMaxSessionTotal(sessionModel);
|
||||
const count = Math.max(0, Number(getMiniMaxField(sessionModel, "current_interval_usage_count", "currentIntervalUsageCount")) || 0);
|
||||
quotas["session (5h)"] = buildMiniMaxQuota(
|
||||
total, count,
|
||||
getMiniMaxResetAt(sessionModel, capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"),
|
||||
countMeansRemaining
|
||||
);
|
||||
}
|
||||
|
||||
const weeklyModel = pickMiniMaxRepresentativeModel(textModels, getMiniMaxWeeklyTotal);
|
||||
if (weeklyModel && getMiniMaxWeeklyTotal(weeklyModel) > 0) {
|
||||
const total = getMiniMaxWeeklyTotal(weeklyModel);
|
||||
const count = Math.max(0, Number(getMiniMaxField(weeklyModel, "current_weekly_usage_count", "currentWeeklyUsageCount")) || 0);
|
||||
quotas["weekly (7d)"] = buildMiniMaxQuota(
|
||||
total, count,
|
||||
getMiniMaxResetAt(weeklyModel, capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"),
|
||||
countMeansRemaining
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return { message: "MiniMax connected. Unable to extract quota usage." };
|
||||
}
|
||||
|
||||
return { quotas };
|
||||
} catch (error) {
|
||||
lastErrorMessage = error.message;
|
||||
if (!canFallback) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user