Merge remote-tracking branch 'origin/master' into gitea/feature/end
Resolved conflicts taking origin/master (v0.5.55) as canonical, with local features re-applied: - runtime log level (LOG_LEVEL env + dashboard Settings → Logging, applied immediately and persisted across restarts) - free/noAuth provider enable/disable toggle via providerStrategies.enabled - parallel model testing (Test All Models / Test Selected Keys)
This commit is contained in:
@@ -19,7 +19,43 @@ const CLAUDE_CONFIG = {
|
||||
const OAUTH_429_COOLDOWN_MS = 180000;
|
||||
const oauthCooldown = new Map();
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
||||
// Dedup + short TTL cache per access token. Many tabs / many accounts / auto-refresh
|
||||
// all funnel through here; without this each call hits Anthropic and triggers 429.
|
||||
const USAGE_CACHE_TTL_MS = 300000;
|
||||
const usageCache = new Map(); // token -> { promise } | { result, expiresAt }
|
||||
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null, options = {}) {
|
||||
const force = options?.force === true;
|
||||
|
||||
// Serve in-flight or fresh cached result (skip on manual force)
|
||||
if (!force && accessToken) {
|
||||
const hit = usageCache.get(accessToken);
|
||||
if (hit?.promise) return hit.promise;
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.result;
|
||||
}
|
||||
|
||||
const stale = (!force && accessToken && usageCache.get(accessToken)?.result) || null;
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchClaudeUsageRaw(accessToken, proxyOptions);
|
||||
// Only cache real quota data, not soft-failure {message: ...} payloads
|
||||
if (accessToken && result?.quotas) {
|
||||
usageCache.set(accessToken, {
|
||||
result,
|
||||
expiresAt: Date.now() + USAGE_CACHE_TTL_MS,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
// Soft failure (429/error): prefer the last good read over a transient error
|
||||
if (stale) return stale;
|
||||
return result;
|
||||
})();
|
||||
|
||||
if (accessToken) usageCache.set(accessToken, { promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
// Skip OAuth usage call while this token is cooling down from a recent 429
|
||||
const cooldownUntil = oauthCooldown.get(accessToken);
|
||||
|
||||
@@ -43,17 +43,17 @@ function refillCadence(acc) {
|
||||
return "Monthly";
|
||||
}
|
||||
|
||||
export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
async function getCodeBuddyUsage(providerId, accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
const token = accessToken || apiKey;
|
||||
if (!token) {
|
||||
return { message: "CodeBuddy CN credential not available." };
|
||||
return { message: `CodeBuddy (${providerId}) credential not available.` };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(U(PROVIDER_ID).url, {
|
||||
const response = await proxyAwareFetch(U(providerId).url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...(PROVIDERS[PROVIDER_ID]?.headers || {}),
|
||||
...(PROVIDERS[providerId]?.headers || {}),
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
@@ -129,10 +129,18 @@ export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificD
|
||||
});
|
||||
|
||||
const basePkg = refills[0] || accounts[0] || {};
|
||||
const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy CN";
|
||||
const plan = basePkg.PackageName || basePkg.SubProductName || "CodeBuddy";
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `CodeBuddy CN error: ${error.message}` };
|
||||
return { message: `CodeBuddy (${providerId}) error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getCodeBuddyCnUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
return getCodeBuddyUsage(PROVIDER_ID, accessToken, apiKey, providerSpecificData, proxyOptions);
|
||||
}
|
||||
|
||||
export async function getCodeBuddyIntlUsage(accessToken, apiKey, providerSpecificData, proxyOptions = null) {
|
||||
return getCodeBuddyUsage("codebuddy-intl", accessToken, apiKey, providerSpecificData, proxyOptions);
|
||||
}
|
||||
|
||||
112
open-sse/services/usage/deepseek.js
Normal file
112
open-sse/services/usage/deepseek.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* DeepSeek usage — GET https://api.deepseek.com/user/balance
|
||||
* Auth: Bearer <apiKey>
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { toFiniteNumber } from "./shared.js";
|
||||
|
||||
const BALANCE_URL = "https://api.deepseek.com/user/balance";
|
||||
|
||||
function parseBalanceInfos(data) {
|
||||
const list = Array.isArray(data?.balance_infos) ? data.balance_infos : [];
|
||||
const results = [];
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const currency =
|
||||
typeof item.currency === "string" ? item.currency.toUpperCase() : "";
|
||||
if (!currency) continue;
|
||||
const totalBalance = toFiniteNumber(
|
||||
item.total_balance ?? item.totalBalance,
|
||||
0,
|
||||
);
|
||||
results.push({
|
||||
currency,
|
||||
totalBalance,
|
||||
grantedBalance: toFiniteNumber(
|
||||
item.granted_balance ?? item.grantedBalance,
|
||||
0,
|
||||
),
|
||||
toppedUpBalance: toFiniteNumber(
|
||||
item.topped_up_balance ?? item.toppedUpBalance,
|
||||
0,
|
||||
),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} apiKey
|
||||
* @param {object|null} proxyOptions
|
||||
*/
|
||||
export async function getDeepseekUsage(apiKey = null, proxyOptions = null) {
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
return { message: "DeepSeek API key not available. Add a key to view usage." };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(
|
||||
BALANCE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey.trim()}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
plan: "DeepSeek",
|
||||
message: "DeepSeek authentication failed. Check the API key.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => "");
|
||||
return {
|
||||
plan: "DeepSeek",
|
||||
message: `DeepSeek balance API error (${response.status})${errText ? `: ${errText.slice(0, 120)}` : ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || typeof data !== "object") {
|
||||
return { message: "DeepSeek balance response was not JSON." };
|
||||
}
|
||||
|
||||
const balances = parseBalanceInfos(data);
|
||||
if (balances.length === 0) {
|
||||
return {
|
||||
plan: "DeepSeek",
|
||||
message: "DeepSeek connected. No balance data returned.",
|
||||
};
|
||||
}
|
||||
|
||||
const isAvailable = data.is_available === true || data.isAvailable === true;
|
||||
const quotas = {};
|
||||
for (const b of balances) {
|
||||
const total = Math.max(0, b.totalBalance);
|
||||
// Credit pot: show full remaining against current balance; never set absolute
|
||||
// `remaining` — QuotaTable treats it as a 0–100 percentage.
|
||||
quotas[`Balance (${b.currency})`] = {
|
||||
used: 0,
|
||||
total,
|
||||
remainingPercentage: total > 0 ? 100 : 0,
|
||||
resetAt: null,
|
||||
unlimited: total > 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
plan: isAvailable ? "DeepSeek" : "DeepSeek (Insufficient Balance)",
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `DeepSeek error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@
|
||||
* Google usage handlers (Gemini CLI + Antigravity)
|
||||
*/
|
||||
|
||||
import { CLIENT_METADATA, getPlatformUserAgent } from "../../config/appConstants.js";
|
||||
import { ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js";
|
||||
import { CLIENT_METADATA } from "../../config/appConstants.js";
|
||||
import { ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_IDE_VERSION, ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js";
|
||||
import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js";
|
||||
|
||||
// Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here
|
||||
const ANTIGRAVITY_CONFIG = {
|
||||
...U("antigravity"),
|
||||
...ANTIGRAVITY_OAUTH_CLIENT,
|
||||
userAgent: getPlatformUserAgent(),
|
||||
userAgent: ANTIGRAVITY_IDE_USER_AGENT,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -129,8 +129,7 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Name": "antigravity",
|
||||
"X-Client-Version": "1.107.0",
|
||||
"x-request-source": "local", // MITM bypass
|
||||
"X-Client-Version": ANTIGRAVITY_IDE_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(projectId ? { project: projectId } : {})
|
||||
@@ -162,7 +161,12 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
if (data.models) {
|
||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||
const importantModels = [
|
||||
'gemini-3-flash-agent',
|
||||
'gemini-3.7-flash-high',
|
||||
'gemini-3.7-flash-medium',
|
||||
'gemini-3.7-flash-low',
|
||||
'gemini-3.6-flash-high',
|
||||
'gemini-3.6-flash-medium',
|
||||
'gemini-3.6-flash-low',
|
||||
'gemini-3.5-flash-low',
|
||||
'gemini-3.5-flash-extra-low',
|
||||
'gemini-pro-agent',
|
||||
@@ -170,10 +174,8 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6-thinking',
|
||||
'gpt-oss-120b-medium',
|
||||
'gemini-3-flash',
|
||||
// Image generation models
|
||||
'gemini-3.1-flash-image',
|
||||
'gemini-3-pro-image',
|
||||
];
|
||||
|
||||
for (const [modelKey, info] of Object.entries(data.models)) {
|
||||
@@ -229,7 +231,6 @@ async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null)
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"x-request-source": "local", // MITM bypass
|
||||
},
|
||||
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
|
||||
}, 10000, proxyOptions);
|
||||
|
||||
424
open-sse/services/usage/grok-cli.js
Normal file
424
open-sse/services/usage/grok-cli.js
Normal file
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Grok CLI / Grok Build usage handler
|
||||
*
|
||||
* Source of truth: official grok-shell/grok-pager traffic to cli-chat-proxy.grok.com
|
||||
* GET /v1/billing?format=credits
|
||||
* GET /v1/user?include=subscription
|
||||
*
|
||||
* Observed billing shape (protobuf-json style `{ val: number }`):
|
||||
* {
|
||||
* config: {
|
||||
* currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end },
|
||||
* onDemandCap: { val },
|
||||
* onDemandUsed: { val },
|
||||
* prepaidBalance: { val },
|
||||
* isUnifiedBillingUser: true,
|
||||
* billingPeriodStart, billingPeriodEnd
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* Exhausted free/promo accounts return cap=0/used=0/prepaid=0 and chat 402s with
|
||||
* personal-team-blocked:spending-limit. Paid/sub accounts surface non-zero cap
|
||||
* or prepaidBalance; richer credit fields are parsed opportunistically if present.
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
|
||||
import {
|
||||
GROK_CLI_CLIENT_IDENTIFIER,
|
||||
GROK_CLI_USER_AGENT,
|
||||
GROK_CLI_VERSION,
|
||||
} from "../../config/grokCli.js";
|
||||
import { decodeGrokCreditsFrame } from "./grokCliQuotaFrame.js";
|
||||
|
||||
const USAGE = U("grok-cli");
|
||||
const BILLING_URL = USAGE.url || "https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
const USER_URL = USAGE.userUrl || "https://cli-chat-proxy.grok.com/v1/user?include=subscription";
|
||||
|
||||
// SuperGrok weekly pool.
|
||||
const GRPC_CREDITS_URL =
|
||||
"https://grok.com/grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig";
|
||||
// Empty gRPC-web request frame (flag 0 + length 0). Without it upstream returns
|
||||
// grpc-status 13 "Missing request message." with a 0-byte body.
|
||||
const GRPC_WEB_EMPTY_REQUEST_FRAME = Buffer.from([0, 0, 0, 0, 0]);
|
||||
|
||||
/** Unwrap protobuf-json `{ val: n }` or plain numbers/strings. */
|
||||
function unwrapVal(value, fallback = 0) {
|
||||
if (value == null) return fallback;
|
||||
if (typeof value === "object" && !Array.isArray(value) && "val" in value) {
|
||||
return toFiniteNumber(value.val, fallback);
|
||||
}
|
||||
return toFiniteNumber(value, fallback);
|
||||
}
|
||||
|
||||
function buildGrokCliHeaders(accessToken, providerSpecificData = {}) {
|
||||
const psd = providerSpecificData || {};
|
||||
const headers = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": GROK_CLI_USER_AGENT,
|
||||
"x-xai-token-auth": "xai-grok-cli",
|
||||
"x-grok-client-identifier": GROK_CLI_CLIENT_IDENTIFIER,
|
||||
"x-grok-client-version": GROK_CLI_VERSION,
|
||||
"x-grok-client-mode": "headless",
|
||||
};
|
||||
const email = psd.email;
|
||||
const userId = psd.userId || psd.principalId;
|
||||
if (email) headers["x-email"] = email;
|
||||
if (userId) headers["x-userid"] = userId;
|
||||
return headers;
|
||||
}
|
||||
|
||||
function subscriptionTier(user, config) {
|
||||
const rawTier =
|
||||
user?.subscriptionTier ??
|
||||
user?.subscription_tier ??
|
||||
user?.subscription?.tier ??
|
||||
config?.subscriptionTier ??
|
||||
config?.subscription_tier;
|
||||
return typeof rawTier === "string" ? rawTier.trim() : "";
|
||||
}
|
||||
|
||||
function resolvePlan(user, config) {
|
||||
const tier = subscriptionTier(user, config);
|
||||
if (tier) {
|
||||
return tier
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
if (user?.hasGrokCodeAccess === true) return "Grok Code";
|
||||
if (config?.isUnifiedBillingUser === true) return "Grok Build";
|
||||
return "Grok Build";
|
||||
}
|
||||
|
||||
// Display only; upstream remains authoritative for access and quota enforcement.
|
||||
function planFromAccessToken(accessToken) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(accessToken.split(".")[1], "base64url"));
|
||||
return {
|
||||
0: "Free",
|
||||
1: "SuperGrok",
|
||||
2: "X Basic",
|
||||
3: "X Premium",
|
||||
4: "X Premium Plus",
|
||||
5: "SuperGrok Heavy",
|
||||
6: "SuperGrok Lite",
|
||||
}[payload.tier] || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function makeQuota({ used, total, resetAt, unlimited = false }) {
|
||||
const safeTotal = Math.max(0, toFiniteNumber(total, 0));
|
||||
const safeUsed = Math.max(0, toFiniteNumber(used, 0));
|
||||
// Do NOT set absolute `remaining` — QuotaTable's getRemainingPercentage treats
|
||||
// `remaining` as a 0–100 percentage (same trap as Qoder credits).
|
||||
if (unlimited || safeTotal === 0) {
|
||||
return {
|
||||
used: safeUsed,
|
||||
total: 0,
|
||||
remainingPercentage: unlimited ? 100 : 0,
|
||||
resetAt: resetAt || null,
|
||||
unlimited: true,
|
||||
};
|
||||
}
|
||||
const remaining = Math.max(0, safeTotal - safeUsed);
|
||||
const remainingPercentage = (remaining / safeTotal) * 100;
|
||||
return {
|
||||
used: safeUsed,
|
||||
total: safeTotal,
|
||||
remainingPercentage,
|
||||
resetAt: resetAt || null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map billing JSON → normalized quotas object for the dashboard.
|
||||
* Returns { quotas, periodEnd, exhaustedHint } or empty quotas when nothing usable.
|
||||
*/
|
||||
export function parseGrokCliBilling(billing, user = null) {
|
||||
const root = billing && typeof billing === "object" ? billing : {};
|
||||
const config =
|
||||
root.config && typeof root.config === "object" && !Array.isArray(root.config)
|
||||
? root.config
|
||||
: root;
|
||||
|
||||
const periodEnd =
|
||||
parseResetTime(config.billingPeriodEnd) ||
|
||||
parseResetTime(config.billing_period_end) ||
|
||||
parseResetTime(config.currentPeriod?.end) ||
|
||||
parseResetTime(config.resetAt || config.resetsAt || config.periodEnd) ||
|
||||
parseResetTime(root.billingPeriodEnd) ||
|
||||
parseResetTime(root.billing_period_end) ||
|
||||
parseResetTime(root.resetAt || root.resetsAt || root.periodEnd) ||
|
||||
null;
|
||||
|
||||
const quotas = {};
|
||||
const tier = subscriptionTier(user, config);
|
||||
const subscriptionAccess = Boolean(tier) && !/^(free|none|null)$/i.test(tier);
|
||||
|
||||
// Current Grok Build responses expose included monthly usage at top level.
|
||||
const monthlyLimit = unwrapVal(
|
||||
config.monthlyLimit ?? config.monthly_limit ?? root.monthlyLimit ?? root.monthly_limit,
|
||||
NaN,
|
||||
);
|
||||
const includedUsed = unwrapVal(
|
||||
config.includedUsed ?? config.included_used ?? root.includedUsed ?? root.included_used,
|
||||
NaN,
|
||||
);
|
||||
const totalUsed = unwrapVal(
|
||||
config.totalUsed ?? config.total_used ?? root.totalUsed ?? root.total_used,
|
||||
NaN,
|
||||
);
|
||||
if (Number.isFinite(monthlyLimit) && monthlyLimit > 0) {
|
||||
quotas["Monthly included"] = makeQuota({
|
||||
used: Number.isFinite(includedUsed)
|
||||
? includedUsed
|
||||
: Number.isFinite(totalUsed)
|
||||
? totalUsed
|
||||
: 0,
|
||||
total: monthlyLimit,
|
||||
resetAt: periodEnd,
|
||||
});
|
||||
}
|
||||
|
||||
// Primary: on-demand spending window (subscription / promo credits)
|
||||
const onDemandCap = unwrapVal(config.onDemandCap ?? root.onDemandCap, NaN);
|
||||
const onDemandUsed = unwrapVal(config.onDemandUsed ?? root.onDemandUsed, NaN);
|
||||
if (Number.isFinite(onDemandCap) && onDemandCap > 0) {
|
||||
const used = Number.isFinite(onDemandUsed) ? Math.max(0, onDemandUsed) : 0;
|
||||
quotas["On-demand"] = makeQuota({
|
||||
used,
|
||||
total: onDemandCap,
|
||||
resetAt: periodEnd,
|
||||
});
|
||||
} else if (
|
||||
!subscriptionAccess &&
|
||||
Number.isFinite(onDemandCap) &&
|
||||
onDemandCap === 0 &&
|
||||
Number.isFinite(onDemandUsed)
|
||||
) {
|
||||
// Cap 0 is the exhausted free/promo state (chat returns 402 spending-limit).
|
||||
// UI treats total===0 as unlimited, so use a synthetic 1/1 depleted row.
|
||||
quotas["On-demand"] = {
|
||||
used: 1,
|
||||
total: 1,
|
||||
remainingPercentage: 0,
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Prepaid top-up balance (remaining credits; no fixed allotment known)
|
||||
const prepaid = unwrapVal(config.prepaidBalance ?? root.prepaidBalance, NaN);
|
||||
if (Number.isFinite(prepaid) && prepaid > 0) {
|
||||
// Show full bar against the current balance (0 spent of this remaining pot).
|
||||
quotas["Prepaid"] = {
|
||||
used: 0,
|
||||
total: prepaid,
|
||||
remainingPercentage: 100,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
// SuperGrok weekly shared-pool usage (subscription tier). creditUsagePercent is
|
||||
// the single total used %; productUsage is a breakdown legend, NOT independent
|
||||
// quotas — never split it into separate bars.
|
||||
const usedPct = unwrapVal(
|
||||
config.creditUsagePercent ?? config.credit_usage_percent ?? root.creditUsagePercent,
|
||||
NaN,
|
||||
);
|
||||
if (Number.isFinite(usedPct) && usedPct >= 0) {
|
||||
quotas["Weekly SuperGrok"] = makeQuota({
|
||||
used: Math.max(0, Math.min(100, usedPct)),
|
||||
total: 100,
|
||||
resetAt: periodEnd,
|
||||
});
|
||||
}
|
||||
|
||||
// Opportunistic richer credit envelopes (future / other account types)
|
||||
const creditBags = [
|
||||
root.credits,
|
||||
root.creditBalance,
|
||||
root.usage,
|
||||
config.credits,
|
||||
config.includedCredits,
|
||||
config.subscriptionCredits,
|
||||
].filter((bag) => bag && typeof bag === "object" && !Array.isArray(bag));
|
||||
|
||||
for (const bag of creditBags) {
|
||||
const total = unwrapVal(
|
||||
bag.total ?? bag.limit ?? bag.cap ?? bag.allocation ?? bag.amount,
|
||||
NaN,
|
||||
);
|
||||
const used = unwrapVal(bag.used ?? bag.spent ?? bag.consumed, NaN);
|
||||
const remaining = unwrapVal(bag.remaining ?? bag.balance ?? bag.left, NaN);
|
||||
if (Number.isFinite(total) && total > 0) {
|
||||
const resolvedUsed = Number.isFinite(used)
|
||||
? used
|
||||
: Number.isFinite(remaining)
|
||||
? Math.max(0, total - remaining)
|
||||
: 0;
|
||||
if (!quotas.Credits) {
|
||||
quotas.Credits = makeQuota({
|
||||
used: resolvedUsed,
|
||||
total,
|
||||
resetAt: parseResetTime(bag.resetAt || bag.resetsAt || bag.end) || periodEnd,
|
||||
});
|
||||
}
|
||||
} else if (Number.isFinite(remaining) && remaining >= 0 && !quotas.Credits) {
|
||||
quotas.Credits = {
|
||||
used: 0,
|
||||
total: remaining > 0 ? remaining : 1,
|
||||
remainingPercentage: remaining > 0 ? 100 : 0,
|
||||
resetAt: periodEnd,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted when every finite quota bar is at 0% remaining
|
||||
const exhausted =
|
||||
Object.keys(quotas).length > 0 &&
|
||||
Object.values(quotas).every(
|
||||
(q) => q.unlimited !== true && (q.remainingPercentage ?? 100) <= 0,
|
||||
);
|
||||
|
||||
return {
|
||||
plan: resolvePlan(user, config),
|
||||
quotas,
|
||||
periodEnd,
|
||||
exhausted,
|
||||
subscriptionAccess,
|
||||
rawConfig: config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live SuperGrok weekly pool via gRPC-web GetGrokCreditsConfig.
|
||||
* Fail-open: any network/auth/parse failure returns null.
|
||||
* @returns {{ percentUsed: number, resetAt: string|null } | null}
|
||||
*/
|
||||
export async function fetchGrokCliCreditsConfig(accessToken, proxyOptions = null) {
|
||||
if (!accessToken) return null;
|
||||
try {
|
||||
const res = await proxyAwareFetch(
|
||||
GRPC_CREDITS_URL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/grpc-web+proto",
|
||||
"X-Grpc-Web": "1",
|
||||
Accept: "application/grpc-web+proto",
|
||||
},
|
||||
body: GRPC_WEB_EMPTY_REQUEST_FRAME,
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res?.ok) return null;
|
||||
const arrayBuffer = await res.arrayBuffer().catch(() => null);
|
||||
if (!arrayBuffer) return null;
|
||||
return decodeGrokCreditsFrame(Buffer.from(arrayBuffer));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function quotasFromGrpcCredits(decoded) {
|
||||
if (!decoded || !Number.isFinite(decoded.percentUsed)) return null;
|
||||
// Round for bar display (fixed32 ratio * 100 can be 34.999… for 0.35)
|
||||
const used = Math.round(Math.max(0, Math.min(100, decoded.percentUsed)));
|
||||
return {
|
||||
"Weekly SuperGrok": makeQuota({
|
||||
used,
|
||||
total: 100,
|
||||
resetAt: decoded.resetAt || null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} accessToken
|
||||
* @param {object|null} providerSpecificData
|
||||
* @param {object|null} proxyOptions
|
||||
*/
|
||||
export async function getGrokCliUsage(accessToken, providerSpecificData = null, proxyOptions = null) {
|
||||
if (!accessToken) {
|
||||
return { message: "Grok CLI access token not available." };
|
||||
}
|
||||
|
||||
const headers = buildGrokCliHeaders(accessToken, providerSpecificData);
|
||||
|
||||
try {
|
||||
// Fetch billing + user profile in parallel (same pattern as official CLI startup)
|
||||
const [billingRes, userRes] = await Promise.all([
|
||||
proxyAwareFetch(
|
||||
BILLING_URL,
|
||||
{ method: "GET", headers },
|
||||
proxyOptions,
|
||||
),
|
||||
proxyAwareFetch(
|
||||
USER_URL,
|
||||
{ method: "GET", headers },
|
||||
proxyOptions,
|
||||
).catch(() => null),
|
||||
]);
|
||||
|
||||
if (billingRes.status === 401 || billingRes.status === 403) {
|
||||
return { message: "Grok CLI authentication expired. Please re-authorize." };
|
||||
}
|
||||
|
||||
if (!billingRes.ok) {
|
||||
const errText = await billingRes.text().catch(() => "");
|
||||
const trimmed = errText ? `: ${errText.slice(0, 200)}` : "";
|
||||
return { message: `Grok CLI billing API error (${billingRes.status})${trimmed}` };
|
||||
}
|
||||
|
||||
const billing = await billingRes.json().catch(() => null);
|
||||
if (!billing || typeof billing !== "object") {
|
||||
return { message: "Grok CLI billing response was not JSON." };
|
||||
}
|
||||
|
||||
let user = null;
|
||||
if (userRes?.ok) {
|
||||
user = await userRes.json().catch(() => null);
|
||||
}
|
||||
|
||||
const parsed = parseGrokCliBilling(billing, user);
|
||||
parsed.plan = planFromAccessToken(accessToken) || parsed.plan;
|
||||
|
||||
if (!parsed.quotas || Object.keys(parsed.quotas).length === 0) {
|
||||
// Paid SuperGrok often returns cap=0 over REST but exposes the shared
|
||||
// weekly pool on GetGrokCreditsConfig — try that before giving up.
|
||||
const grpc = await fetchGrokCliCreditsConfig(accessToken, proxyOptions);
|
||||
const grpcQuotas = quotasFromGrpcCredits(grpc);
|
||||
if (grpcQuotas) {
|
||||
return {
|
||||
plan: parsed.plan,
|
||||
quotas: grpcQuotas,
|
||||
};
|
||||
}
|
||||
return {
|
||||
plan: parsed.plan,
|
||||
message: parsed.subscriptionAccess
|
||||
? "Subscription access is active; Grok does not expose a numeric included quota."
|
||||
: "Grok Build connected, but no credit allotment was returned. Free promo may be exhausted.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Dashboard hides QuotaTable whenever `message` is set, so only attach a
|
||||
// message when there are no quota rows to render. Depleted accounts keep
|
||||
// the 0% On-demand bar without a blocking message.
|
||||
return {
|
||||
plan: parsed.plan,
|
||||
quotas: parsed.quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Grok CLI usage error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
191
open-sse/services/usage/grokCliQuotaFrame.js
Normal file
191
open-sse/services/usage/grokCliQuotaFrame.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* gRPC-web frame decoder for xAI GetGrokCreditsConfig
|
||||
* (grok_api_v2.GrokBuildBilling/GetGrokCreditsConfig).
|
||||
*
|
||||
* Real response shape (live capture 2026-07-20):
|
||||
* top-level field 1 (length-delimited) — nested credits info
|
||||
* subfield 1 (fixed32 float) — usage ratio 0..1
|
||||
* subfield 5 (Timestamp{seconds,nanos}) — credit-pool reset time
|
||||
*
|
||||
* Fail-open: any malformed buffer returns null, never throws.
|
||||
*/
|
||||
|
||||
const FIELD_CREDITS_INFO = 1;
|
||||
const CREDITS_FIELD_USAGE_RATIO = 1;
|
||||
const CREDITS_FIELD_RESET_TIMESTAMP = 5;
|
||||
const TIMESTAMP_FIELD_SECONDS = 1;
|
||||
const TIMESTAMP_FIELD_NANOS = 2;
|
||||
|
||||
const WIRE_TYPE_VARINT = 0;
|
||||
const WIRE_TYPE_FIXED64 = 1;
|
||||
const WIRE_TYPE_LENGTH_DELIMITED = 2;
|
||||
const WIRE_TYPE_FIXED32 = 5;
|
||||
|
||||
const GRPC_WEB_TRAILER_FLAG_BIT = 0x80;
|
||||
const MAX_VARINT_SHIFT_BITS = 70n;
|
||||
|
||||
/**
|
||||
* Validate a gRPC-web frame header at `offset`.
|
||||
* @returns {{ flag: number, payloadStart: number, payloadLength: number } | null}
|
||||
*/
|
||||
export function probeFrameHeader(buffer, offset = 0) {
|
||||
if (!Buffer.isBuffer(buffer) || offset < 0 || buffer.length - offset < 5) return null;
|
||||
const flag = buffer[offset];
|
||||
if (flag !== 0x00 && flag !== 0x01 && flag !== 0x80 && flag !== 0x81) return null;
|
||||
const payloadStart = offset + 5;
|
||||
const payloadLength = buffer.readUInt32BE(offset + 1);
|
||||
if (payloadLength > buffer.length - payloadStart) return null;
|
||||
return { flag, payloadStart, payloadLength };
|
||||
}
|
||||
|
||||
function readVarint(buffer, offset) {
|
||||
let result = 0n;
|
||||
let shift = 0n;
|
||||
let pos = offset;
|
||||
for (;;) {
|
||||
if (pos >= buffer.length) return null;
|
||||
const byte = buffer[pos];
|
||||
result |= BigInt(byte & 0x7f) << shift;
|
||||
pos += 1;
|
||||
if ((byte & 0x80) === 0) break;
|
||||
shift += 7n;
|
||||
if (shift > MAX_VARINT_SHIFT_BITS) return null;
|
||||
}
|
||||
return { value: Number(result), next: pos };
|
||||
}
|
||||
|
||||
function readLengthDelimitedField(buffer, offset) {
|
||||
const lengthResult = readVarint(buffer, offset);
|
||||
if (!lengthResult) return null;
|
||||
const { value: length, next: bodyStart } = lengthResult;
|
||||
if (length < 0 || bodyStart + length > buffer.length) return null;
|
||||
return {
|
||||
field: { wireType: WIRE_TYPE_LENGTH_DELIMITED, bytes: buffer.subarray(bodyStart, bodyStart + length) },
|
||||
next: bodyStart + length,
|
||||
};
|
||||
}
|
||||
|
||||
function readFixedWidthField(buffer, offset, width, wireType) {
|
||||
if (offset + width > buffer.length) return null;
|
||||
return {
|
||||
field: { wireType, bytes: buffer.subarray(offset, offset + width) },
|
||||
next: offset + width,
|
||||
};
|
||||
}
|
||||
|
||||
function readField(buffer, offset) {
|
||||
const tagResult = readVarint(buffer, offset);
|
||||
if (!tagResult) return null;
|
||||
const fieldNumber = tagResult.value >>> 3;
|
||||
const wireType = tagResult.value & 0x7;
|
||||
if (fieldNumber === 0) return null;
|
||||
|
||||
if (wireType === WIRE_TYPE_VARINT) {
|
||||
const valueResult = readVarint(buffer, tagResult.next);
|
||||
if (!valueResult) return null;
|
||||
return {
|
||||
fieldNumber,
|
||||
field: { wireType: WIRE_TYPE_VARINT, value: valueResult.value },
|
||||
next: valueResult.next,
|
||||
};
|
||||
}
|
||||
if (wireType === WIRE_TYPE_LENGTH_DELIMITED) {
|
||||
const result = readLengthDelimitedField(buffer, tagResult.next);
|
||||
return result ? { fieldNumber, field: result.field, next: result.next } : null;
|
||||
}
|
||||
if (wireType === WIRE_TYPE_FIXED64) {
|
||||
const result = readFixedWidthField(buffer, tagResult.next, 8, WIRE_TYPE_FIXED64);
|
||||
return result ? { fieldNumber, field: result.field, next: result.next } : null;
|
||||
}
|
||||
if (wireType === WIRE_TYPE_FIXED32) {
|
||||
const result = readFixedWidthField(buffer, tagResult.next, 4, WIRE_TYPE_FIXED32);
|
||||
return result ? { fieldNumber, field: result.field, next: result.next } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function decodeFields(buffer) {
|
||||
const fields = new Map();
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const result = readField(buffer, offset);
|
||||
if (!result) return null;
|
||||
fields.set(result.fieldNumber, result.field);
|
||||
offset = result.next;
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function findDataFramePayload(buffer) {
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const frame = probeFrameHeader(buffer, offset);
|
||||
if (!frame) return null;
|
||||
const frameEnd = frame.payloadStart + frame.payloadLength;
|
||||
const isTrailer = (frame.flag & GRPC_WEB_TRAILER_FLAG_BIT) !== 0;
|
||||
if (!isTrailer) {
|
||||
return buffer.subarray(frame.payloadStart, frameEnd);
|
||||
}
|
||||
offset = frameEnd;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractNestedMessage(field) {
|
||||
if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null;
|
||||
return decodeFields(field.bytes);
|
||||
}
|
||||
|
||||
function extractUsageRatio(field) {
|
||||
if (!field) return 0; // proto3 omission = 0% used
|
||||
if (field.wireType === WIRE_TYPE_FIXED32) return field.bytes.readFloatLE(0);
|
||||
if (field.wireType === WIRE_TYPE_FIXED64) return field.bytes.readDoubleLE(0);
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractResetAt(field) {
|
||||
if (!field || field.wireType !== WIRE_TYPE_LENGTH_DELIMITED) return null;
|
||||
|
||||
const timestampFields = decodeFields(field.bytes);
|
||||
if (!timestampFields) return null;
|
||||
|
||||
const secondsField = timestampFields.get(TIMESTAMP_FIELD_SECONDS);
|
||||
const nanosField = timestampFields.get(TIMESTAMP_FIELD_NANOS);
|
||||
const seconds = secondsField?.wireType === WIRE_TYPE_VARINT ? secondsField.value : 0;
|
||||
const nanos = nanosField?.wireType === WIRE_TYPE_VARINT ? nanosField.value : 0;
|
||||
|
||||
const millis = seconds * 1000 + Math.round(nanos / 1_000_000);
|
||||
const parsed = new Date(millis);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode GetGrokCreditsConfig response → `{ percentUsed: 0-100, resetAt }` or null.
|
||||
* @param {Buffer} buffer
|
||||
* @returns {{ percentUsed: number, resetAt: string|null } | null}
|
||||
*/
|
||||
export function decodeGrokCreditsFrame(buffer) {
|
||||
if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) return null;
|
||||
|
||||
try {
|
||||
const framed = probeFrameHeader(buffer, 0) !== null;
|
||||
const payload = framed ? findDataFramePayload(buffer) : buffer;
|
||||
if (!payload) return null;
|
||||
|
||||
const topLevelFields = decodeFields(payload);
|
||||
if (!topLevelFields) return null;
|
||||
|
||||
const creditsInfo = extractNestedMessage(topLevelFields.get(FIELD_CREDITS_INFO));
|
||||
if (!creditsInfo) return null;
|
||||
|
||||
const usageRatio = extractUsageRatio(creditsInfo.get(CREDITS_FIELD_USAGE_RATIO));
|
||||
if (usageRatio === null || !Number.isFinite(usageRatio) || usageRatio < 0) return null;
|
||||
|
||||
return {
|
||||
percentUsed: Math.min(100, usageRatio * 100),
|
||||
resetAt: extractResetAt(creditsInfo.get(CREDITS_FIELD_RESET_TIMESTAMP)),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
211
open-sse/services/usage/kimi.js
Normal file
211
open-sse/services/usage/kimi.js
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Kimi Coding usage — GET /v1/usages
|
||||
*
|
||||
* Dual auth (single provider id `kimi`):
|
||||
* - apiKey present → x-api-key only (platform / coding API key)
|
||||
* - else accessToken → Bearer + X-Msh-* (device-code OAuth)
|
||||
*
|
||||
* Note: chat messages use combined x-api-key; /usages OAuth is Bearer.
|
||||
* 403 permission_denied is NOT auth-expired — account lacks usage feature / sub.
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { parseResetTime, toFiniteNumber } from "./shared.js";
|
||||
import { buildKimiHeaders } from "../../config/appConstants.js";
|
||||
|
||||
const USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
||||
|
||||
const PLAN_LEVELS = {
|
||||
LEVEL_BASIC: "Moderato",
|
||||
LEVEL_INTERMEDIATE: "Allegretto",
|
||||
LEVEL_ADVANCED: "Allegro",
|
||||
LEVEL_STANDARD: "Vivace",
|
||||
};
|
||||
|
||||
function getKimiPlanName(level) {
|
||||
if (!level) return "";
|
||||
const key = String(level);
|
||||
if (PLAN_LEVELS[key]) return PLAN_LEVELS[key];
|
||||
return key.replace(/^LEVEL_/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** Best-effort extract human message from Kimi error JSON (403 body is Connect-RPC-ish). */
|
||||
export function formatKimiUsageError(status, responseText) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = JSON.parse(responseText || "");
|
||||
} catch {
|
||||
/* plain text */
|
||||
}
|
||||
|
||||
const detail0 = Array.isArray(parsed?.details) ? parsed.details[0] : null;
|
||||
const debug = detail0?.debug || parsed?.debug || null;
|
||||
const reason = debug?.reason || parsed?.reason || "";
|
||||
const localized =
|
||||
debug?.localizedMessage?.message ||
|
||||
detail0?.localizedMessage?.message ||
|
||||
parsed?.message ||
|
||||
"";
|
||||
|
||||
if (status === 401) {
|
||||
return "Kimi authentication expired. Please re-authorize.";
|
||||
}
|
||||
|
||||
// Live OAuth token without Kimi Code usage entitlement returns 403
|
||||
// REASON_FEATURE_NO_PERMISSION — not an expired session.
|
||||
if (
|
||||
status === 403 &&
|
||||
(reason === "REASON_FEATURE_NO_PERMISSION" ||
|
||||
/permission_denied|do not have permission|subscribe/i.test(
|
||||
`${parsed?.code || ""} ${localized} ${responseText || ""}`,
|
||||
))
|
||||
) {
|
||||
return (
|
||||
localized ||
|
||||
"Kimi connected, but this account has no permission to view usage. Subscribe to Kimi Code to access quota."
|
||||
);
|
||||
}
|
||||
|
||||
const snippet = (localized || responseText || "").slice(0, 100);
|
||||
return snippet
|
||||
? `Kimi Coding connected. API Error ${status}: ${snippet}`
|
||||
: `Kimi Coding connected. API Error ${status}`;
|
||||
}
|
||||
|
||||
function makeQuota({ used, total, remaining, resetAt }) {
|
||||
const safeTotal = Math.max(0, toFiniteNumber(total, 0));
|
||||
const safeUsed = Math.max(0, toFiniteNumber(used, 0));
|
||||
// Prefer provider remaining when present; never set absolute `remaining`
|
||||
// on the quota object — QuotaTable treats it as a 0–100 percentage.
|
||||
let remainingPct;
|
||||
if (safeTotal > 0 && remaining != null && Number.isFinite(Number(remaining))) {
|
||||
remainingPct = (Math.max(0, Number(remaining)) / safeTotal) * 100;
|
||||
} else if (safeTotal > 0) {
|
||||
remainingPct = (Math.max(0, safeTotal - safeUsed) / safeTotal) * 100;
|
||||
} else {
|
||||
remainingPct = 0;
|
||||
}
|
||||
return {
|
||||
used: safeUsed,
|
||||
total: safeTotal,
|
||||
remainingPercentage: remainingPct,
|
||||
resetAt: resetAt || null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} accessToken
|
||||
* @param {string|null|undefined} apiKey
|
||||
* @param {object|null} proxyOptions
|
||||
* @param {object|null} providerSpecificData
|
||||
*/
|
||||
export async function getKimiUsage(
|
||||
accessToken = null,
|
||||
apiKey = null,
|
||||
proxyOptions = null,
|
||||
providerSpecificData = null,
|
||||
) {
|
||||
const useApiKey = typeof apiKey === "string" && apiKey.length > 0;
|
||||
const useOAuth = !useApiKey && typeof accessToken === "string" && accessToken.length > 0;
|
||||
|
||||
if (!useApiKey && !useOAuth) {
|
||||
return { message: "Kimi access token or API key not available." };
|
||||
}
|
||||
|
||||
const authHeaders = useApiKey
|
||||
? { "x-api-key": apiKey }
|
||||
: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...buildKimiHeaders(providerSpecificData?.deviceId),
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(
|
||||
USAGE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
...authHeaders,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
|
||||
const responseText = await response.text().catch(() => "");
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
plan: "Kimi Coding",
|
||||
message: formatKimiUsageError(response.status, responseText),
|
||||
};
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(responseText || "{}");
|
||||
} catch {
|
||||
return {
|
||||
plan: "Kimi Coding",
|
||||
message: "Kimi Coding connected. Invalid JSON response from API.",
|
||||
};
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
const usageObj = data?.usage && typeof data.usage === "object" ? data.usage : {};
|
||||
const usageLimit = toFiniteNumber(usageObj.limit ?? usageObj.Limit, 0);
|
||||
const usageUsed = toFiniteNumber(usageObj.used ?? usageObj.Used, 0);
|
||||
const usageRemainingRaw = usageObj.remaining ?? usageObj.Remaining;
|
||||
const usageRemaining =
|
||||
usageRemainingRaw != null && usageRemainingRaw !== ""
|
||||
? toFiniteNumber(usageRemainingRaw, NaN)
|
||||
: NaN;
|
||||
const usageResetTime =
|
||||
usageObj.resetTime || usageObj.ResetTime || usageObj.reset_at || usageObj.resetAt;
|
||||
|
||||
if (usageLimit > 0) {
|
||||
quotas.Weekly = makeQuota({
|
||||
used: usageUsed,
|
||||
total: usageLimit,
|
||||
remaining: Number.isFinite(usageRemaining) ? usageRemaining : null,
|
||||
resetAt: parseResetTime(usageResetTime),
|
||||
});
|
||||
}
|
||||
|
||||
const limitsArray = Array.isArray(data?.limits) ? data.limits : [];
|
||||
for (const item of limitsArray) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const detail = item.detail && typeof item.detail === "object" ? item.detail : {};
|
||||
const limit = toFiniteNumber(detail.limit ?? detail.Limit, 0);
|
||||
const remaining = toFiniteNumber(detail.remaining ?? detail.Remaining, NaN);
|
||||
const resetTime = detail.resetTime || detail.reset_at || detail.resetAt;
|
||||
if (limit > 0) {
|
||||
const rem = Number.isFinite(remaining) ? remaining : Math.max(0, limit);
|
||||
quotas.Ratelimit = makeQuota({
|
||||
used: Math.max(0, limit - rem),
|
||||
total: limit,
|
||||
remaining: rem,
|
||||
resetAt: parseResetTime(resetTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const membershipLevel = data?.user?.membership?.level;
|
||||
const planName = getKimiPlanName(membershipLevel) || "Kimi Coding";
|
||||
|
||||
if (Object.keys(quotas).length > 0) {
|
||||
return { plan: planName, quotas };
|
||||
}
|
||||
|
||||
return {
|
||||
plan: planName,
|
||||
message: "Kimi Coding connected. Usage tracked per request.",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
message: `Kimi Coding connected. Unable to fetch usage: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
||||
* Misc usage handlers (iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
@@ -15,23 +15,6 @@ const GLM_QUOTA_URLS = {
|
||||
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
|
||||
const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url;
|
||||
|
||||
/**
|
||||
* Qwen Usage
|
||||
*/
|
||||
export async function getQwenUsage(accessToken, providerSpecificData) {
|
||||
try {
|
||||
const resourceUrl = providerSpecificData?.resourceUrl;
|
||||
if (!resourceUrl) {
|
||||
return { message: "Qwen connected. No resource URL available." };
|
||||
}
|
||||
|
||||
// Qwen may have usage endpoint at resource URL
|
||||
return { message: "Qwen connected. Usage tracked per request." };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Qwen usage." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iFlow Usage
|
||||
*/
|
||||
@@ -46,23 +29,86 @@ export async function getIflowUsage(accessToken) {
|
||||
|
||||
/**
|
||||
* Ollama Cloud Usage
|
||||
* Ollama Cloud uses an API key from ollama.com/settings/keys
|
||||
* and has no public usage API — free tier has light usage limits (resets every 5h & 7d).
|
||||
* This returns an informational message with the plan details.
|
||||
* GET https://ollama.com/api/usage — session (5h) + weekly (7d) `usage` is a 0..1
|
||||
* ratio (1.0 = limit reached, e.g. weekly 100% used). No reset timestamp exposed.
|
||||
* POST https://ollama.com/api/me — plan label (fail-open).
|
||||
* Auth: Authorization: Bearer <apiKey>
|
||||
*/
|
||||
export async function getOllamaUsage(accessToken, providerSpecificData) {
|
||||
export async function getOllamaUsage(apiKey, providerSpecificData, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "Ollama Cloud API key not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Ollama Cloud does not expose a public quota/usage API.
|
||||
// The provider is configured as noAuth with a notice explaining limits.
|
||||
// We return a graceful message so the UI shows a friendly state instead of an error.
|
||||
const plan = providerSpecificData?.plan || "Free";
|
||||
return {
|
||||
plan,
|
||||
message: "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.",
|
||||
quotas: [],
|
||||
};
|
||||
const response = await proxyAwareFetch("https://ollama.com/api/usage", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { message: "Ollama Cloud API key invalid or expired." };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return { message: `Ollama Cloud usage API error (${response.status}).` };
|
||||
}
|
||||
|
||||
let data;
|
||||
try {
|
||||
data = await response.json();
|
||||
} catch {
|
||||
return { message: "Ollama Cloud usage response was not JSON." };
|
||||
}
|
||||
|
||||
// Best-effort plan label from /api/me
|
||||
const me = await proxyAwareFetch("https://ollama.com/api/me", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Length": "0",
|
||||
},
|
||||
}, proxyOptions).then((r) => (r.ok ? r.json() : null)).catch(() => null);
|
||||
|
||||
const planRaw = typeof me?.Plan === "string" ? me.Plan : "";
|
||||
const plan = planRaw
|
||||
? planRaw.charAt(0).toUpperCase() + planRaw.slice(1).toLowerCase()
|
||||
: "Ollama Cloud";
|
||||
|
||||
const limits = data?.limits && typeof data.limits === "object" ? data.limits : {};
|
||||
|
||||
// Ollama `usage` is a 0..1 ratio (1.0 = limit reached). Convert to a 0..100
|
||||
// bar. Do NOT set absolute `remaining` — QuotaTable reads remainingPercentage.
|
||||
function ratioQuota(usageRatio, resetAt = null) {
|
||||
const ratio = Math.max(0, Math.min(1, Number(usageRatio) || 0));
|
||||
const usedPct = Math.round(ratio * 100);
|
||||
return { used: usedPct, total: 100, remainingPercentage: 100 - usedPct, resetAt, unlimited: false };
|
||||
}
|
||||
|
||||
const sessionRaw = limits.session?.usage;
|
||||
const weeklyRaw = limits.weekly?.usage;
|
||||
const sessionNum = Number(sessionRaw);
|
||||
const weeklyNum = Number(weeklyRaw);
|
||||
const hasSession = sessionRaw !== undefined && sessionRaw !== null && !Number.isNaN(sessionNum);
|
||||
const hasWeekly = weeklyRaw !== undefined && weeklyRaw !== null && !Number.isNaN(weeklyNum);
|
||||
|
||||
if (!hasSession && !hasWeekly) {
|
||||
return {
|
||||
plan,
|
||||
message: "Ollama Cloud connected. No usage limits reported.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
const quotas = {};
|
||||
if (hasSession) quotas["Session (5h)"] = ratioQuota(sessionNum);
|
||||
if (hasWeekly) quotas["Weekly (7d)"] = ratioQuota(weeklyNum);
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Ollama Cloud usage." };
|
||||
return { message: `Ollama Cloud error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user