Merge origin/master (v0.5.75) into gitea/new_feature
Resolve conflicts: - package.json / cli/package.json: take 0.5.75 - .gitignore: union both sides (upstream 9router-*/temp files + local state dirs) - CHANGELOG.md: keep both blocks, v0.5.75 above v0.5.70 - nonStreamingHandler.js: merge imports (unwrapClineEnvelope + tokensForDetail/shouldPersistRequestDetail); drop dead appendRequestLog - providers/[id]/page.js: union useState blocks (compatible-model states + importingClineModels) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
150
open-sse/services/usage/antigravity-weekly.js
Normal file
150
open-sse/services/usage/antigravity-weekly.js
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Antigravity weekly quota — best-effort retrieval from retrieveUserQuotaSummary.
|
||||
* Failure never breaks existing per-model quota display.
|
||||
*/
|
||||
|
||||
import { U, parseResetTime, fetchWithTimeout } from "./shared.js";
|
||||
import { ANTIGRAVITY_IDE_USER_AGENT, ANTIGRAVITY_IDE_VERSION } from "../../providers/shared.js";
|
||||
|
||||
// — Weekly quota summary config ——————————————————————————————
|
||||
const WEEKLY_CONFIG = {
|
||||
...U("antigravity"),
|
||||
userAgent: ANTIGRAVITY_IDE_USER_AGENT,
|
||||
};
|
||||
|
||||
// — Cache: TTL + in-flight dedup per project ———————————————
|
||||
const WEEKLY_CACHE_TTL_MS = 180_000; // 3 minutes
|
||||
const weeklyCache = new Map(); // cacheKey -> { result, expiresAt } | { promise }
|
||||
|
||||
function cacheKey(accessToken, projectId) {
|
||||
return `${accessToken}::${projectId || ""}`;
|
||||
}
|
||||
|
||||
// Exported for tests only
|
||||
export function _clearWeeklyCache() {
|
||||
weeklyCache.clear();
|
||||
}
|
||||
|
||||
// — Group-name to stable key mapping ——————————————————————
|
||||
const GROUP_MATCHERS = [
|
||||
{ pattern: /gemini/i, key: "gemini_weekly", displayName: "Gemini (Weekly)" },
|
||||
{ pattern: /claude|gpt/i, key: "claude_gpt_weekly", displayName: "Claude & GPT (Weekly)" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse a retrieveUserQuotaSummary response into normalized weekly quotas.
|
||||
* Pure function — safe to unit-test without network.
|
||||
*
|
||||
* @param {Object|null} data Raw JSON response
|
||||
* @returns {Object} e.g. { gemini_weekly: { used, total, ... }, claude_gpt_weekly: { ... } }
|
||||
*/
|
||||
export function parseWeeklyQuotaSummary(data) {
|
||||
if (!data || typeof data !== "object") return {};
|
||||
|
||||
// Groups may live at data.groups or data.quotaSummary.groups
|
||||
const groups = Array.isArray(data.groups)
|
||||
? data.groups
|
||||
: Array.isArray(data.quotaSummary?.groups)
|
||||
? data.quotaSummary.groups
|
||||
: null;
|
||||
|
||||
if (!groups) return {};
|
||||
|
||||
const result = {};
|
||||
|
||||
for (const group of groups) {
|
||||
if (!group || typeof group !== "object") continue;
|
||||
const displayName = group.displayName || "";
|
||||
|
||||
const buckets = Array.isArray(group.buckets) ? group.buckets : [];
|
||||
for (const bucket of buckets) {
|
||||
if (!bucket || typeof bucket !== "object") continue;
|
||||
|
||||
// Identify weekly buckets by checking bucketId + displayName for "weekly"
|
||||
const bucketText = `${bucket.bucketId || ""} ${bucket.displayName || ""}`.toLowerCase();
|
||||
if (!bucketText.includes("weekly")) continue;
|
||||
|
||||
// Skip disabled buckets
|
||||
if (bucket.disabled === true) continue;
|
||||
|
||||
const remainingFraction = Number(bucket.remainingFraction);
|
||||
if (!Number.isFinite(remainingFraction)) continue;
|
||||
|
||||
// Match group to a known family
|
||||
for (const matcher of GROUP_MATCHERS) {
|
||||
if (matcher.pattern.test(displayName)) {
|
||||
const total = 1000;
|
||||
const remaining = Math.round(total * remainingFraction);
|
||||
const used = Math.max(0, total - remaining);
|
||||
|
||||
result[matcher.key] = {
|
||||
used,
|
||||
total,
|
||||
resetAt: parseResetTime(bucket.resetTime),
|
||||
remainingPercentage: remainingFraction * 100,
|
||||
unlimited: false,
|
||||
displayName: matcher.displayName,
|
||||
};
|
||||
break; // first matching bucket per family wins
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch weekly quota summary — cached, deduped, never throws.
|
||||
*/
|
||||
export async function fetchAntigravityWeeklyQuota(accessToken, projectId, proxyOptions = null) {
|
||||
const key = cacheKey(accessToken, projectId);
|
||||
|
||||
// Serve in-flight or cached
|
||||
const hit = weeklyCache.get(key);
|
||||
if (hit?.promise) return hit.promise;
|
||||
if (hit && hit.expiresAt > Date.now()) return hit.result;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const url = WEEKLY_CONFIG.quotaSummaryApiUrl;
|
||||
if (!url) return {};
|
||||
|
||||
const response = await fetchWithTimeout(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": WEEKLY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Name": "antigravity",
|
||||
"X-Client-Version": ANTIGRAVITY_IDE_VERSION,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(projectId ? { project: projectId } : {}),
|
||||
}),
|
||||
}, 10000, proxyOptions);
|
||||
|
||||
if (!response.ok) return {};
|
||||
|
||||
const data = await response.json();
|
||||
return parseWeeklyQuotaSummary(data);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
|
||||
weeklyCache.set(key, { promise });
|
||||
|
||||
try {
|
||||
const result = await promise;
|
||||
if (result && Object.keys(result).length > 0) {
|
||||
weeklyCache.set(key, { result, expiresAt: Date.now() + WEEKLY_CACHE_TTL_MS });
|
||||
} else {
|
||||
weeklyCache.delete(key);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
weeklyCache.delete(key);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -102,32 +102,28 @@ async function fetchClaudeUsageRaw(accessToken, proxyOptions = null) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(data.seven_day);
|
||||
}
|
||||
|
||||
// Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus, seven_day_fable)
|
||||
const MODEL_DISPLAY_NAMES = {
|
||||
fable_5_1: "fable",
|
||||
fable_5: "fable",
|
||||
};
|
||||
|
||||
// Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus)
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) {
|
||||
const rawName = key.replace("seven_day_", "");
|
||||
const modelName = MODEL_DISPLAY_NAMES[rawName] || rawName;
|
||||
const modelName = key.replace("seven_day_", "");
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value);
|
||||
} else if ((key === "fable" || key === "fable_5" || key === "fable_5_1") && hasUtilization(value)) {
|
||||
quotas["weekly fable (7d)"] = createQuotaObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: surface Fable quota row if weekly window exists but Fable was not returned yet
|
||||
if (!quotas["weekly fable (7d)"] && hasUtilization(data.seven_day)) {
|
||||
quotas["weekly fable (7d)"] = {
|
||||
used: 0,
|
||||
total: 100,
|
||||
remaining: 100,
|
||||
remainingPercentage: 100,
|
||||
resetAt: parseResetTime(data.seven_day.resets_at),
|
||||
unlimited: false,
|
||||
};
|
||||
// Model-scoped weekly limits (e.g. Fable) arrive in limits[], not as
|
||||
// seven_day_* keys: { kind: "weekly_scoped", percent, resets_at,
|
||||
// scope: { model: { display_name: "Fable" } } }. No limits entry means
|
||||
// the account has no such window — omit the row, never fabricate one.
|
||||
if (Array.isArray(data.limits)) {
|
||||
for (const limit of data.limits) {
|
||||
if (limit?.kind !== "weekly_scoped") continue;
|
||||
const modelName = String(limit?.scope?.model?.display_name || "").trim().toLowerCase();
|
||||
if (!modelName || typeof limit.percent !== "number") continue;
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject({
|
||||
utilization: Math.max(0, Math.min(100, limit.percent)),
|
||||
resets_at: limit.resets_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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";
|
||||
import { fetchAntigravityWeeklyQuota } from "./antigravity-weekly.js";
|
||||
|
||||
// Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here
|
||||
const ANTIGRAVITY_CONFIG = {
|
||||
@@ -157,8 +158,15 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
const data = await response.json();
|
||||
const quotas = {};
|
||||
|
||||
// Parse model quotas (inspired by vscode-antigravity-cockpit)
|
||||
if (data.models) {
|
||||
// Detect tier: free-tier accounts only have weekly quotas (no separate 5h window).
|
||||
// On free-tier, fetchAvailableModels returns misleading per-model quota info
|
||||
// (missing remainingFraction defaults to 0, or reflects the weekly limit not a 5h window).
|
||||
const paidTierId = subscriptionInfo?.paidTier?.id;
|
||||
const isFreeTier = !paidTierId || paidTierId === "free-tier";
|
||||
|
||||
// Parse model quotas only for paid-tier accounts.
|
||||
// Free-tier accounts skip this — their only meaningful quota is the weekly limit.
|
||||
if (!isFreeTier && data.models) {
|
||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||
const importantModels = [
|
||||
'gemini-3.8-flash-high',
|
||||
@@ -212,6 +220,56 @@ export async function getAntigravityUsage(accessToken, providerSpecificData, pro
|
||||
}
|
||||
}
|
||||
|
||||
// Best-effort weekly quota overlay — never blocks or breaks per-model results
|
||||
try {
|
||||
const weeklyQuotas = await fetchAntigravityWeeklyQuota(
|
||||
accessToken,
|
||||
projectId,
|
||||
proxyOptions
|
||||
);
|
||||
|
||||
// Reconcile weekly quota against model family status:
|
||||
// If every model in a family is locked/exhausted (remainingPercentage === 0)
|
||||
// until a future reset time, the weekly limit cannot be 100% available.
|
||||
// On Google's Free Starter tier, retrieveUserQuotaSummary buggily reports
|
||||
// remainingFraction: 1 even after the starter quota is depleted and all models 429.
|
||||
const entries = Object.entries(quotas);
|
||||
const geminiModels = entries.filter(([k]) => k.startsWith("gemini-") && !k.includes("image"));
|
||||
const claudeModels = entries.filter(([k]) => k.startsWith("claude-"));
|
||||
|
||||
if (weeklyQuotas.gemini_weekly && geminiModels.length > 0) {
|
||||
const allGeminiExhausted = geminiModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0);
|
||||
if (allGeminiExhausted && weeklyQuotas.gemini_weekly.remainingPercentage > 0) {
|
||||
const maxResetAt = geminiModels.reduce((max, [, q]) =>
|
||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
||||
);
|
||||
weeklyQuotas.gemini_weekly.used = weeklyQuotas.gemini_weekly.total;
|
||||
weeklyQuotas.gemini_weekly.remainingPercentage = 0;
|
||||
if (maxResetAt) {
|
||||
weeklyQuotas.gemini_weekly.resetAt = maxResetAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (weeklyQuotas.claude_gpt_weekly && claudeModels.length > 0) {
|
||||
const allClaudeExhausted = claudeModels.every(([, q]) => (q.remainingPercentage ?? 0) === 0);
|
||||
if (allClaudeExhausted && weeklyQuotas.claude_gpt_weekly.remainingPercentage > 0) {
|
||||
const maxResetAt = claudeModels.reduce((max, [, q]) =>
|
||||
!max || (q.resetAt && new Date(q.resetAt) > new Date(max)) ? q.resetAt : max, null
|
||||
);
|
||||
weeklyQuotas.claude_gpt_weekly.used = weeklyQuotas.claude_gpt_weekly.total;
|
||||
weeklyQuotas.claude_gpt_weekly.remainingPercentage = 0;
|
||||
if (maxResetAt) {
|
||||
weeklyQuotas.claude_gpt_weekly.resetAt = maxResetAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(quotas, weeklyQuotas);
|
||||
} catch {
|
||||
// Silently ignore — weekly is best-effort
|
||||
}
|
||||
|
||||
return {
|
||||
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
||||
quotas,
|
||||
|
||||
125
open-sse/services/usage/xiaomi-mimo.js
Normal file
125
open-sse/services/usage/xiaomi-mimo.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Xiaomi MiMo usage — weekly quota from the Xiaomi account session.
|
||||
*
|
||||
* Primary path: GET {mimo-server}/api/user/usage authorized by the account-session
|
||||
* cookie (see shared/mimoAccount.js). Response: { code: 0, data: { percent (remaining
|
||||
* %), resetDate, resetAt } }.
|
||||
*
|
||||
* Fallback: the sk- API key cannot read the quota, so when no account session is
|
||||
* available we surface a graceful message instead of failing.
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { getMimoAccountUsage } from "../../shared/mimoAccount.js";
|
||||
|
||||
const USAGE_URL = "https://aistudio.xiaomimimo.com/open-apis/v1/user/usage";
|
||||
|
||||
/**
|
||||
* @param {string|null|undefined} accessToken - sk- API key
|
||||
* @param {object|null} providerSpecificData - may contain mimoPassToken, uid, etc.
|
||||
* @param {object|null} proxyOptions
|
||||
*/
|
||||
export async function getXiaomiMimoUsage(accessToken = null, providerSpecificData = null, proxyOptions = null) {
|
||||
// Preferred path: the weekly quota comes from the account service session
|
||||
// (mimo-server /api/user/usage), which the sk- key cannot reach. The session is
|
||||
// derived from MiMo Desktop's persisted passToken via the SSO/sts handshake.
|
||||
const account = await getMimoAccountUsage(providerSpecificData, proxyOptions);
|
||||
if (typeof account.percent === "number" && Number.isFinite(account.percent)) {
|
||||
const remaining = Math.max(0, Math.min(100, Math.round(account.percent)));
|
||||
const used = 100 - remaining;
|
||||
let resetAt = null;
|
||||
if (typeof account.resetAt === "number" && account.resetAt > 0) {
|
||||
resetAt = new Date(account.resetAt * 1000).toISOString();
|
||||
} else if (typeof account.resetDate === "string") {
|
||||
const parsed = new Date(`${account.resetDate}T00:00:00Z`);
|
||||
if (!Number.isNaN(parsed.getTime())) resetAt = parsed.toISOString();
|
||||
}
|
||||
return {
|
||||
plan: "Xiaomi MiMo Desktop",
|
||||
quotas: {
|
||||
Weekly: { used, total: 100, remainingPercentage: remaining, resetAt, unlimited: false },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: no account session available (Desktop never logged in, or its cookie
|
||||
// store is locked). The sk- key cannot read the quota, so surface a clear message.
|
||||
const key = accessToken || providerSpecificData?.apiKey;
|
||||
if (!key || typeof key !== "string" || !key.trim()) {
|
||||
return { message: "Xiaomi MiMo Desktop not connected. Add credentials to view usage." };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(
|
||||
USAGE_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${key.trim()}`,
|
||||
"X-Mimo-Source": "mimocode-cli",
|
||||
Accept: "application/json",
|
||||
},
|
||||
signal: AbortSignal.timeout(10000),
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
|
||||
if (response.status === 401) {
|
||||
return {
|
||||
plan: "Xiaomi MiMo Desktop",
|
||||
message: "Weekly quota requires Xiaomi account session. API key alone is insufficient.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
plan: "Xiaomi MiMo Desktop",
|
||||
message: `Usage API error (${response.status})`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data || data.code !== 0 || !data.data) {
|
||||
return {
|
||||
plan: "Xiaomi MiMo Desktop",
|
||||
message: "Usage endpoint returned unexpected response.",
|
||||
};
|
||||
}
|
||||
|
||||
const { percent, resetDate } = data.data;
|
||||
if (typeof percent !== "number" || !Number.isFinite(percent)) {
|
||||
return {
|
||||
plan: "Xiaomi MiMo Desktop",
|
||||
message: "Usage data missing percent field.",
|
||||
};
|
||||
}
|
||||
|
||||
// percent = remaining percentage (e.g. 94 means 94% remaining)
|
||||
const remaining = Math.max(0, Math.min(100, Math.round(percent)));
|
||||
const used = 100 - remaining;
|
||||
|
||||
// Parse resetDate — expected format "2026-09-16"
|
||||
let resetAt = null;
|
||||
if (resetDate && typeof resetDate === "string") {
|
||||
const parsed = new Date(`${resetDate}T00:00:00Z`);
|
||||
if (!Number.isNaN(parsed.getTime())) {
|
||||
resetAt = parsed.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: "Xiaomi MiMo Desktop",
|
||||
quotas: {
|
||||
Weekly: {
|
||||
used,
|
||||
total: 100,
|
||||
remainingPercentage: remaining,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Xiaomi MiMo Desktop usage error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user