mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
Support quota scraping for OpenCode Go and Ollama Cloud (#4642)
Integrated into release/v3.8.34 (Ollama Cloud + OpenCode Go dashboard quota scraping; rebuilt onto tip, gates green: typecheck/public-creds/file-size/lint/docs-sync + 31 tests)
This commit is contained in:
16
.env.example
16
.env.example
@@ -384,6 +384,22 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
|
||||
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
|
||||
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
|
||||
|
||||
# OpenCode Go dashboard quota scraping. Prefer configuring these per connection
|
||||
# in Dashboard → Providers → OpenCode Go. Env vars are useful for headless
|
||||
# deployments or shared server defaults. The cookie is sensitive.
|
||||
#OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OMNIROUTE_OPENCODE_GO_WORKSPACE_ID=wrk_...
|
||||
#OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=...
|
||||
|
||||
# Ollama Cloud quota scraping. Prefer configuring this per connection in
|
||||
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
|
||||
#OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
#OLLAMA_CLOUD_USAGE_COOKIE=__Secure-session=...
|
||||
#OMNIROUTE_OLLAMA_USAGE_COOKIE=__Secure-session=...
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 8. OUTBOUND PROXY (Upstream Provider Calls)
|
||||
|
||||
@@ -265,6 +265,15 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
|
||||
| `OMNIROUTE_GEMINI_CLI_USAGE_URL` | `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | `open-sse/services/usage.ts` | Gemini CLI quota lookup endpoint. Override for relays / test fixtures. |
|
||||
| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
|
||||
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
|
||||
| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. |
|
||||
| `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go `auth` cookie used for dashboard quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OMNIROUTE_OPENCODE_GO_AUTH_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go `auth` cookie env var used before the shorter alias. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OMNIROUTE_OLLAMA_CLOUD_USAGE_URL` | `https://ollama.com/settings` | `open-sse/services/usage.ts` | Ollama Cloud settings URL used for quota scraping. Override for relays / test fixtures. |
|
||||
| `OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Ollama Cloud `__Secure-session` cookie used for settings-page quota scraping. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OLLAMA_CLOUD_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OMNIROUTE_OLLAMA_USAGE_COOKIE` | _(unset)_ | `open-sse/services/usage.ts` | Alternate Ollama Cloud `__Secure-session` cookie env var used before the shorter aliases. Sensitive; prefer the per-connection Dashboard field when multiple accounts are configured. |
|
||||
| `OMNIROUTE_CODEWHISPERER_BASE_URL` | `https://codewhisperer.us-east-1.amazonaws.com` | `open-sse/services/usage.ts` | CodeWhisperer (AWS Kiro) usage limits endpoint. Override for relays / test fixtures. |
|
||||
|
||||
> [!IMPORTANT]
|
||||
|
||||
558
open-sse/services/opencodeOllamaUsage.ts
Normal file
558
open-sse/services/opencodeOllamaUsage.ts
Normal file
@@ -0,0 +1,558 @@
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type UsageQuota = {
|
||||
used: number;
|
||||
total: number;
|
||||
remaining?: number;
|
||||
remainingPercentage?: number;
|
||||
resetAt: string | null;
|
||||
unlimited: boolean;
|
||||
displayName?: string;
|
||||
details?: Array<{ name: string; used: number }>;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
const OPENCODE_GO_QUOTA_URL =
|
||||
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit";
|
||||
const OPENCODE_GO_DASHBOARD_BASE_URL =
|
||||
process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace";
|
||||
const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const;
|
||||
const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const;
|
||||
const OPENCODE_GO_SCRAPED_NUMBER = String.raw`(-?\d+(?:\.\d+)?)`;
|
||||
const OLLAMA_CLOUD_USAGE_URL =
|
||||
process.env.OMNIROUTE_OLLAMA_CLOUD_USAGE_URL ?? "https://ollama.com/settings";
|
||||
const OLLAMA_CLOUD_SESSION_COOKIE = "__Secure-session";
|
||||
|
||||
type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number];
|
||||
type DashboardWindow = { usagePercent: number; resetAt: string | null };
|
||||
type OpenCodeGoDashboardUsage = Partial<Record<OpenCodeGoQuotaName, DashboardWindow>>;
|
||||
type OpenCodeGoDashboardConfig =
|
||||
| { state: "configured"; workspaceId: string; authCookie: string }
|
||||
| { state: "incomplete"; missing: string }
|
||||
| { state: "none" };
|
||||
type OllamaCloudUsage = {
|
||||
session?: DashboardWindow;
|
||||
weekly?: DashboardWindow;
|
||||
planTier?: string | null;
|
||||
};
|
||||
type OllamaCloudConfig =
|
||||
| { state: "configured"; cookie: string }
|
||||
| { state: "invalid"; error: string }
|
||||
| { state: "none" };
|
||||
|
||||
function toRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
const parsed =
|
||||
typeof value === "number"
|
||||
? value
|
||||
: typeof value === "string" && value.trim().length > 0
|
||||
? Number(value)
|
||||
: Number.NaN;
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
function toPercentage(value: unknown): number {
|
||||
return Math.max(0, Math.min(100, toNumber(value, 0)));
|
||||
}
|
||||
|
||||
function toTitleCase(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.split(/[\s_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function roundCurrency(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
function safeToIsoString(time: number): string | null {
|
||||
if (!Number.isFinite(time) || time < 0 || time > 8.64e15) return null;
|
||||
try {
|
||||
return new Date(time).toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseResetTime(resetValue: unknown): string | null {
|
||||
const numeric = toNumber(resetValue, Number.NaN);
|
||||
if (Number.isFinite(numeric) && numeric > 0) return safeToIsoString(numeric);
|
||||
if (typeof resetValue !== "string" || !resetValue.trim()) return null;
|
||||
const parsed = Date.parse(resetValue);
|
||||
return Number.isFinite(parsed) ? safeToIsoString(parsed) : null;
|
||||
}
|
||||
|
||||
function getProviderSpecificString(data: JsonRecord | undefined, keys: string[]): string {
|
||||
const obj = toRecord(data);
|
||||
for (const key of keys) {
|
||||
const value = obj[key];
|
||||
if (typeof value === "string" && value.trim()) return value.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function resolveOpenCodeGoDashboardConfig(
|
||||
providerSpecificData?: JsonRecord
|
||||
): OpenCodeGoDashboardConfig {
|
||||
const workspaceId =
|
||||
process.env.OMNIROUTE_OPENCODE_GO_WORKSPACE_ID?.trim() ||
|
||||
process.env.OPENCODE_GO_WORKSPACE_ID?.trim() ||
|
||||
getProviderSpecificString(providerSpecificData, [
|
||||
"openCodeGoWorkspaceId",
|
||||
"opencodeGoWorkspaceId",
|
||||
"workspaceId",
|
||||
]);
|
||||
const authCookie =
|
||||
process.env.OMNIROUTE_OPENCODE_GO_AUTH_COOKIE?.trim() ||
|
||||
process.env.OPENCODE_GO_AUTH_COOKIE?.trim() ||
|
||||
getProviderSpecificString(providerSpecificData, [
|
||||
"openCodeGoAuthCookie",
|
||||
"opencodeGoAuthCookie",
|
||||
"authCookie",
|
||||
]);
|
||||
|
||||
if (!workspaceId && !authCookie) return { state: "none" };
|
||||
if (workspaceId && authCookie) return { state: "configured", workspaceId, authCookie };
|
||||
return {
|
||||
state: "incomplete",
|
||||
missing: workspaceId ? "OPENCODE_GO_AUTH_COOKIE" : "OPENCODE_GO_WORKSPACE_ID",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOpenCodeGoAuthCookie(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^auth=/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function buildBearerAuthorization(value: string): string {
|
||||
const token = value
|
||||
.trim()
|
||||
.replace(/^Bearer\s+/i, "")
|
||||
.trim();
|
||||
return token ? `Bearer ${token}` : "";
|
||||
}
|
||||
|
||||
function getOpenCodeGoTokenQuotaName(
|
||||
limit: JsonRecord,
|
||||
existingQuotas: Record<string, UsageQuota>
|
||||
): "session" | "weekly" {
|
||||
const unit = toNumber(limit.unit, 0);
|
||||
const number = toNumber(limit.number, 0);
|
||||
if (unit === 3 && number === 5) return "session";
|
||||
if (unit === 6 && number === 1) return "weekly";
|
||||
if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly";
|
||||
return existingQuotas.session ? "weekly" : "session";
|
||||
}
|
||||
|
||||
function buildOpenCodeGoDollarQuota(
|
||||
quotaName: OpenCodeGoQuotaName,
|
||||
percentage: unknown,
|
||||
resetAt: string | null,
|
||||
usedOverride?: unknown,
|
||||
details?: UsageQuota["details"]
|
||||
): UsageQuota {
|
||||
const total = OPENCODE_GO_QUOTA_TOTALS[quotaName];
|
||||
const percentUsed = toPercentage(percentage);
|
||||
const rawUsed = toNumber(usedOverride, Number.NaN);
|
||||
const used = roundCurrency(
|
||||
Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100
|
||||
);
|
||||
const remaining = roundCurrency(Math.max(0, total - used));
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage:
|
||||
total > 0 ? Math.max(0, Math.min(100, Math.round((remaining / total) * 100))) : 100,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName:
|
||||
quotaName === "session" ? "5-hour rolling" : quotaName === "weekly" ? "Weekly" : "Monthly",
|
||||
currency: "USD",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function orderOpenCodeGoQuotas(quotas: Record<string, UsageQuota>): Record<string, UsageQuota> {
|
||||
const ordered: Record<string, UsageQuota> = {};
|
||||
for (const key of OPENCODE_GO_QUOTA_ORDER) if (quotas[key]) ordered[key] = quotas[key];
|
||||
for (const [key, quota] of Object.entries(quotas)) if (!ordered[key]) ordered[key] = quota;
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function parseOpenCodeGoSsrWindow(html: string, field: string): DashboardWindow | null {
|
||||
for (const candidate of [
|
||||
{
|
||||
usageIndex: 1,
|
||||
resetIndex: 2,
|
||||
pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`,
|
||||
},
|
||||
{
|
||||
usageIndex: 2,
|
||||
resetIndex: 1,
|
||||
pattern: String.raw`${field}:\$R\[\d+\]=\{[^}]*resetInSec:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*usagePercent:${OPENCODE_GO_SCRAPED_NUMBER}[^}]*\}`,
|
||||
},
|
||||
]) {
|
||||
const match = new RegExp(candidate.pattern).exec(html);
|
||||
if (!match) continue;
|
||||
const usagePercent = toNumber(match[candidate.usageIndex], Number.NaN);
|
||||
const resetInSec = toNumber(match[candidate.resetIndex], Number.NaN);
|
||||
if (Number.isFinite(usagePercent) && Number.isFinite(resetInSec)) {
|
||||
return {
|
||||
usagePercent,
|
||||
resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseOpenCodeGoHumanReset(value: string): number | null {
|
||||
const text = value.toLowerCase().replace(/\s+/g, " ").trim();
|
||||
if (["reset-now", "reset now", "now", "resets now"].includes(text)) return 0;
|
||||
const days = text.match(/(\d+(?:\.\d+)?)\s*days?/);
|
||||
const hours = text.match(/(\d+(?:\.\d+)?)\s*hours?/);
|
||||
const minutes = text.match(/(\d+(?:\.\d+)?)\s*minutes?/);
|
||||
const seconds = text.match(/(\d+(?:\.\d+)?)\s*seconds?/);
|
||||
if (!days && !hours && !minutes && !seconds) return null;
|
||||
return (
|
||||
toNumber(days?.[1], 0) * 86_400 +
|
||||
toNumber(hours?.[1], 0) * 3_600 +
|
||||
toNumber(minutes?.[1], 0) * 60 +
|
||||
toNumber(seconds?.[1], 0)
|
||||
);
|
||||
}
|
||||
|
||||
function parseOpenCodeGoDashboardHtml(html: string): OpenCodeGoDashboardUsage | null {
|
||||
const usage: OpenCodeGoDashboardUsage = {
|
||||
session: parseOpenCodeGoSsrWindow(html, "rollingUsage") ?? undefined,
|
||||
weekly: parseOpenCodeGoSsrWindow(html, "weeklyUsage") ?? undefined,
|
||||
mcp_monthly: parseOpenCodeGoSsrWindow(html, "monthlyUsage") ?? undefined,
|
||||
};
|
||||
if (usage.session || usage.weekly || usage.mcp_monthly) return usage;
|
||||
|
||||
for (const content of html.split(/data-slot="usage-item"/).slice(1)) {
|
||||
const label = content
|
||||
.match(/data-slot="usage-label">([^<]+)</)?.[1]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
const usagePercent = toNumber(
|
||||
content.match(/data-slot="usage-value">[^0-9]*(\d+(?:\.\d+)?)/)?.[1],
|
||||
Number.NaN
|
||||
);
|
||||
const resetMatch = content.match(/data-slot="(reset-time|reset-now)">([\s\S]*?)<\/span>/);
|
||||
if (!label || !Number.isFinite(usagePercent) || !resetMatch) continue;
|
||||
const resetInSec =
|
||||
resetMatch[1] === "reset-now"
|
||||
? 0
|
||||
: parseOpenCodeGoHumanReset(
|
||||
resetMatch[2].replace(/<!--\$-->|<!--\/-->/g, "").replace(/Resets?\s*in\s*/i, "")
|
||||
);
|
||||
if (resetInSec === null || !Number.isFinite(resetInSec)) continue;
|
||||
const window = {
|
||||
usagePercent,
|
||||
resetAt: safeToIsoString(Date.now() + Math.max(0, resetInSec) * 1000),
|
||||
};
|
||||
if (label.includes("rolling")) usage.session = window;
|
||||
else if (label.includes("weekly")) usage.weekly = window;
|
||||
else if (label.includes("monthly")) usage.mcp_monthly = window;
|
||||
}
|
||||
return usage.session || usage.weekly || usage.mcp_monthly ? usage : null;
|
||||
}
|
||||
|
||||
async function fetchOpenCodeGoDashboardUsage(
|
||||
config: Extract<OpenCodeGoDashboardConfig, { state: "configured" }>
|
||||
) {
|
||||
const url = `${OPENCODE_GO_DASHBOARD_BASE_URL.replace(/\/+$/, "")}/${encodeURIComponent(
|
||||
config.workspaceId
|
||||
)}/go`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "text/html",
|
||||
Cookie: `auth=${normalizeOpenCodeGoAuthCookie(config.authCookie)}`,
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok)
|
||||
return { usage: null, message: `OpenCode Go dashboard error (${response.status}).` };
|
||||
const usage = parseOpenCodeGoDashboardHtml(await response.text());
|
||||
return {
|
||||
usage,
|
||||
message: usage ? undefined : "OpenCode Go dashboard response did not contain quota windows.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?: JsonRecord) {
|
||||
const dashboardConfig = resolveOpenCodeGoDashboardConfig(providerSpecificData);
|
||||
if (dashboardConfig.state === "incomplete") {
|
||||
return {
|
||||
message: `OpenCode Go dashboard quota config is incomplete. Missing ${dashboardConfig.missing}.`,
|
||||
};
|
||||
}
|
||||
if (dashboardConfig.state === "configured") {
|
||||
try {
|
||||
const dashboard = await fetchOpenCodeGoDashboardUsage(dashboardConfig);
|
||||
if (!dashboard.usage) {
|
||||
return { message: dashboard.message || "OpenCode Go dashboard quota data unavailable." };
|
||||
}
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
for (const quotaName of OPENCODE_GO_QUOTA_ORDER) {
|
||||
const usage = dashboard.usage[quotaName];
|
||||
if (usage) {
|
||||
quotas[quotaName] = buildOpenCodeGoDollarQuota(
|
||||
quotaName,
|
||||
usage.usagePercent,
|
||||
usage.resetAt
|
||||
);
|
||||
}
|
||||
}
|
||||
return { plan: "OpenCode Go", quotas: orderOpenCodeGoQuotas(quotas) };
|
||||
} catch (error) {
|
||||
return { message: `OpenCode Go dashboard quota error: ${sanitizeErrorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
const token = apiKey.trim().replace(/^Bearer\s+/i, "");
|
||||
if (!token) {
|
||||
return {
|
||||
message:
|
||||
"OpenCode Go quota requires OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE. " +
|
||||
"The API key can be used for chat/models, but OpenCode Go does not expose quota via API key.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(OPENCODE_GO_QUOTA_URL, {
|
||||
headers: {
|
||||
Authorization: buildBearerAuthorization(token),
|
||||
"Accept-Language": "en-US,en",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return {
|
||||
message:
|
||||
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
|
||||
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
message:
|
||||
`OpenCode Go quota API error (${res.status}). ` +
|
||||
"Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " +
|
||||
"https://github.com/anomalyco/opencode/issues/16017 for upstream status.",
|
||||
};
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return { message: "OpenCode Go quota response parsing failed." };
|
||||
}
|
||||
const root = toRecord(json);
|
||||
if (
|
||||
toNumber(root.code, 200) === 401 ||
|
||||
toNumber(root.code, 200) === 403 ||
|
||||
root.success === false
|
||||
) {
|
||||
return {
|
||||
message:
|
||||
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
|
||||
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.",
|
||||
};
|
||||
}
|
||||
|
||||
const data = toRecord(root.data);
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
for (const limit of Array.isArray(data.limits) ? data.limits : []) {
|
||||
const src = toRecord(limit);
|
||||
const type = String(src.type || "").toUpperCase();
|
||||
const resetAt = parseResetTime(src.nextResetTime);
|
||||
if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") {
|
||||
const quotaName = getOpenCodeGoTokenQuotaName(src, quotas);
|
||||
quotas[quotaName] = buildOpenCodeGoDollarQuota(
|
||||
quotaName,
|
||||
src.percentage,
|
||||
resetAt,
|
||||
undefined,
|
||||
Array.isArray(src.models)
|
||||
? src.models.map((model) => {
|
||||
const modelInfo = toRecord(model);
|
||||
return {
|
||||
name: String(modelInfo.model || modelInfo.modelCode || "usage"),
|
||||
used: toNumber(modelInfo.percentage, 0),
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
);
|
||||
} else if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") {
|
||||
quotas.mcp_monthly = buildOpenCodeGoDollarQuota(
|
||||
"mcp_monthly",
|
||||
src.percentage,
|
||||
resetAt,
|
||||
src.currentValue,
|
||||
Array.isArray(src.usageDetails)
|
||||
? src.usageDetails.map((item) => {
|
||||
const detail = toRecord(item);
|
||||
return {
|
||||
name: String(detail.modelCode || detail.name || "usage"),
|
||||
used: toNumber(detail.usage, 0),
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const levelRaw =
|
||||
typeof data.planName === "string"
|
||||
? data.planName
|
||||
: typeof data.level === "string"
|
||||
? data.level
|
||||
: "";
|
||||
const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, ""));
|
||||
return {
|
||||
plan: planLabel
|
||||
? /^opencode\s+go\b/i.test(planLabel)
|
||||
? planLabel
|
||||
: `OpenCode Go ${planLabel}`
|
||||
: null,
|
||||
quotas: orderOpenCodeGoQuotas(quotas),
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `OpenCode Go quota API error: ${sanitizeErrorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOllamaCloudConfig(providerSpecificData?: JsonRecord): OllamaCloudConfig {
|
||||
const cookie =
|
||||
process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE?.trim() ||
|
||||
process.env.OLLAMA_USAGE_COOKIE?.trim() ||
|
||||
process.env.OLLAMA_CLOUD_USAGE_COOKIE?.trim() ||
|
||||
getProviderSpecificString(providerSpecificData, [
|
||||
"ollamaUsageCookie",
|
||||
"ollamaCloudUsageCookie",
|
||||
"ollamaCloudCookie",
|
||||
"usageCookie",
|
||||
"cookie",
|
||||
]);
|
||||
if (!cookie) return { state: "none" };
|
||||
if (cookie.includes("\r") || cookie.includes("\n")) {
|
||||
return { state: "invalid", error: "Ollama Cloud cookie contains invalid CRLF characters." };
|
||||
}
|
||||
return { state: "configured", cookie };
|
||||
}
|
||||
|
||||
function normalizeOllamaCloudCookie(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.toLowerCase().startsWith(`${OLLAMA_CLOUD_SESSION_COOKIE.toLowerCase()}=`)
|
||||
? trimmed.slice(OLLAMA_CLOUD_SESSION_COOKIE.length + 1).trim()
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
function extractOllamaUsagePercent(trackHtml: string): number | null {
|
||||
const tagHeader = trackHtml.match(/^[^>]*/)?.[0] ?? "";
|
||||
const ariaMatch = tagHeader.match(/(\d+(?:\.\d+)?)%\s*used/);
|
||||
if (ariaMatch) {
|
||||
const pct = toNumber(ariaMatch[1], Number.NaN);
|
||||
if (Number.isFinite(pct) && pct >= 0 && pct <= 100) return pct;
|
||||
}
|
||||
const style = tagHeader.match(/style="([^"]*)"/)?.[1] ?? "";
|
||||
const pct = toNumber(style.match(/(?:^|;)\s*width\s*:\s*([0-9.]+)%/)?.[1], Number.NaN);
|
||||
return Number.isFinite(pct) && pct >= 0 && pct <= 100 ? pct : null;
|
||||
}
|
||||
|
||||
function parseOllamaCloudSettingsHtml(html: string): OllamaCloudUsage | null {
|
||||
const parts = html.split(/\bdata-usage-track\b/);
|
||||
if (parts.length < 2) return null;
|
||||
const extractTime = (text: string): string | null => {
|
||||
const match = text.match(/class="[^"]*local-time[^"]*"[^>]*data-time="([^"]*)"/);
|
||||
return match?.[1] || null;
|
||||
};
|
||||
const sessionPercent = extractOllamaUsagePercent(parts[1]);
|
||||
const weeklyPercent = parts[2] ? extractOllamaUsagePercent(parts[2]) : null;
|
||||
if (sessionPercent === null && weeklyPercent === null) return null;
|
||||
return {
|
||||
...(sessionPercent !== null
|
||||
? { session: { usagePercent: sessionPercent, resetAt: extractTime(parts[1]) } }
|
||||
: {}),
|
||||
...(weeklyPercent !== null
|
||||
? { weekly: { usagePercent: weeklyPercent, resetAt: extractTime(parts[2]) } }
|
||||
: {}),
|
||||
planTier: html.match(/class="[^"]*capitalize[^"]*"[^>]*>([^<]*)</)?.[1]?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchOllamaCloudUsageFromSettings(
|
||||
config: Extract<OllamaCloudConfig, { state: "configured" }>
|
||||
) {
|
||||
const response = await fetch(OLLAMA_CLOUD_USAGE_URL, {
|
||||
redirect: "manual",
|
||||
headers: {
|
||||
Accept: "text/html",
|
||||
Cookie: `${OLLAMA_CLOUD_SESSION_COOKIE}=${normalizeOllamaCloudCookie(config.cookie)}`,
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Gecko/20100101 Firefox/148.0",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
return { usage: null, message: "Ollama Cloud authentication expired. Refresh the cookie." };
|
||||
}
|
||||
if (!response.ok)
|
||||
return { usage: null, message: `Ollama Cloud settings error (${response.status}).` };
|
||||
const usage = parseOllamaCloudSettingsHtml(await response.text());
|
||||
return {
|
||||
usage,
|
||||
message: usage ? undefined : "Ollama Cloud settings page did not contain usage quota tracks.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function getOllamaCloudUsage(providerSpecificData?: JsonRecord) {
|
||||
const config = resolveOllamaCloudConfig(providerSpecificData);
|
||||
if (config.state === "none") {
|
||||
return {
|
||||
message:
|
||||
"Ollama Cloud quota requires OLLAMA_USAGE_COOKIE. Copy the __Secure-session cookie from ollama.com/settings.",
|
||||
};
|
||||
}
|
||||
if (config.state === "invalid") return { message: config.error };
|
||||
|
||||
try {
|
||||
const result = await fetchOllamaCloudUsageFromSettings(config);
|
||||
if (!result.usage) return { message: result.message || "Ollama Cloud quota data unavailable." };
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
for (const key of ["session", "weekly"] as const) {
|
||||
const quota = result.usage[key];
|
||||
if (!quota) continue;
|
||||
const pct = toPercentage(quota.usagePercent);
|
||||
quotas[key] = {
|
||||
used: pct,
|
||||
total: 100,
|
||||
remaining: Math.max(0, 100 - pct),
|
||||
remainingPercentage: Math.max(0, 100 - pct),
|
||||
resetAt: quota.resetAt,
|
||||
unlimited: false,
|
||||
displayName: key === "session" ? "Session" : "Weekly",
|
||||
};
|
||||
}
|
||||
return {
|
||||
plan: result.usage.planTier ? `Ollama Cloud ${result.usage.planTier}` : "Ollama Cloud",
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Ollama Cloud quota error: ${sanitizeErrorMessage(error)}` };
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { getDbInstance } from "@/lib/db/core";
|
||||
import { fetchBailianQuota, type BailianTripleWindowQuota } from "./bailianQuotaFetcher.ts";
|
||||
import { fetchDeepseekQuota, type DeepseekQuota } from "./deepseekQuotaFetcher.ts";
|
||||
import { fetchOpencodeQuota, type OpencodeTripleWindowQuota } from "./opencodeQuotaFetcher.ts";
|
||||
import { getOllamaCloudUsage, getOpenCodeGoUsage } from "./opencodeOllamaUsage.ts";
|
||||
import {
|
||||
applyAntigravityClientProfileHeaders,
|
||||
getAntigravityBootstrapHeaders,
|
||||
@@ -91,19 +92,6 @@ const NANOGPT_CONFIG = {
|
||||
usageUrl: "https://nano-gpt.com/api/subscription/v1/usage",
|
||||
};
|
||||
|
||||
const OPENCODE_GO_QUOTA_URL =
|
||||
// Note: api.z.ai rejects opencode-go keys with {"code":401}. This default is a
|
||||
// known broken placeholder (see issues #10448, #16017). The env-var override lets
|
||||
// operators point at a working endpoint once OpenCode ships one.
|
||||
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit";
|
||||
const OPENCODE_GO_QUOTA_TOTALS = {
|
||||
session: 12,
|
||||
weekly: 30,
|
||||
mcp_monthly: 60,
|
||||
} as const;
|
||||
const OPENCODE_GO_QUOTA_ORDER = ["session", "weekly", "mcp_monthly"] as const;
|
||||
type OpenCodeGoQuotaName = (typeof OPENCODE_GO_QUOTA_ORDER)[number];
|
||||
|
||||
// Cursor dashboard usage API config
|
||||
// The endpoint that powers https://cursor.com/dashboard/spending. Validates the WorkOS
|
||||
// session via the WorkosCursorSessionToken cookie (format: `${userId}::${jwt}`) and
|
||||
@@ -214,77 +202,6 @@ function getGlmQuotaDisplayName(quotaName: string): string {
|
||||
if (quotaName === "weekly") return "Weekly Quota";
|
||||
return quotaName;
|
||||
}
|
||||
|
||||
function getOpenCodeGoTokenQuotaName(
|
||||
limit: JsonRecord,
|
||||
existingQuotas: Record<string, UsageQuota>
|
||||
): "session" | "weekly" {
|
||||
const unit = toNumber(limit.unit, 0);
|
||||
const number = toNumber(limit.number, 0);
|
||||
|
||||
if (unit === 3 && number === 5) return "session";
|
||||
if (unit === 6 && number === 1) return "weekly";
|
||||
if ((unit === 4 && number === 7) || (unit === 3 && number >= 24 * 7)) return "weekly";
|
||||
|
||||
return existingQuotas.session ? "weekly" : "session";
|
||||
}
|
||||
|
||||
function getOpenCodeGoQuotaDisplayName(quotaName: OpenCodeGoQuotaName): string {
|
||||
if (quotaName === "session") return "5-hour rolling";
|
||||
if (quotaName === "weekly") return "Weekly";
|
||||
return "Monthly";
|
||||
}
|
||||
|
||||
function normalizeOpenCodeGoQuotaToken(apiKey: string): string {
|
||||
return apiKey.trim().replace(/^Bearer\s+/i, "");
|
||||
}
|
||||
|
||||
function buildOpenCodeGoDollarQuota(
|
||||
quotaName: OpenCodeGoQuotaName,
|
||||
percentage: unknown,
|
||||
resetAt: string | null,
|
||||
usedOverride?: unknown,
|
||||
details?: UsageQuota["details"]
|
||||
): UsageQuota {
|
||||
const total = OPENCODE_GO_QUOTA_TOTALS[quotaName];
|
||||
const percentUsed = toPercentage(percentage);
|
||||
const rawUsed = toNumber(usedOverride, Number.NaN);
|
||||
const used = roundCurrency(
|
||||
Number.isFinite(rawUsed) ? Math.max(0, Math.min(total, rawUsed)) : (total * percentUsed) / 100
|
||||
);
|
||||
const remaining = roundCurrency(Math.max(0, total - used));
|
||||
const remainingPercentage =
|
||||
total > 0
|
||||
? clampPercentage(Math.round((remaining / total) * 100))
|
||||
: clampPercentage(100 - percentUsed);
|
||||
|
||||
return {
|
||||
used,
|
||||
total,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
displayName: getOpenCodeGoQuotaDisplayName(quotaName),
|
||||
currency: "USD",
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
function orderOpenCodeGoQuotas(quotas: Record<string, UsageQuota>): Record<string, UsageQuota> {
|
||||
const ordered: Record<string, UsageQuota> = {};
|
||||
|
||||
for (const key of OPENCODE_GO_QUOTA_ORDER) {
|
||||
if (quotas[key]) ordered[key] = quotas[key];
|
||||
}
|
||||
|
||||
for (const [key, quota] of Object.entries(quotas)) {
|
||||
if (!ordered[key]) ordered[key] = quota;
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function getFieldValue(source: unknown, snakeKey: string, camelKey: string): unknown {
|
||||
const obj = toRecord(source);
|
||||
return obj[snakeKey] ?? obj[camelKey] ?? null;
|
||||
@@ -966,121 +883,6 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record<string,
|
||||
return { plan, quotas: orderGlmQuotas(quotas) };
|
||||
}
|
||||
|
||||
async function getOpenCodeGoUsage(apiKey: string) {
|
||||
const token = normalizeOpenCodeGoQuotaToken(apiKey);
|
||||
|
||||
if (!token) {
|
||||
return { message: "API key not available. Add an OpenCode Go API key to view usage." };
|
||||
}
|
||||
|
||||
const res = await fetch(OPENCODE_GO_QUOTA_URL, {
|
||||
headers: {
|
||||
Authorization: token,
|
||||
"Accept-Language": "en-US,en",
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
return {
|
||||
message:
|
||||
"OpenCode Go does not expose a public quota API. Chat requests still work. " +
|
||||
"Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " +
|
||||
"https://github.com/anomalyco/opencode/issues/16017 for upstream status.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
message:
|
||||
`OpenCode Go quota API error (${res.status}). ` +
|
||||
"Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " +
|
||||
"https://github.com/anomalyco/opencode/issues/16017 for upstream status.",
|
||||
};
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return { message: "OpenCode Go quota response parsing failed." };
|
||||
}
|
||||
|
||||
const code = toNumber((json as Record<string, unknown>).code, 200);
|
||||
if (code === 401 || code === 403 || (json as Record<string, unknown>).success === false) {
|
||||
return {
|
||||
message:
|
||||
"OpenCode Go does not expose a public quota API. Chat requests still work. " +
|
||||
"Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " +
|
||||
"https://github.com/anomalyco/opencode/issues/16017 for upstream status.",
|
||||
};
|
||||
}
|
||||
|
||||
const data = toRecord((json as Record<string, unknown>).data);
|
||||
const limits: unknown[] = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas: Record<string, UsageQuota> = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
const src = toRecord(limit);
|
||||
const type = String(src.type || "").toUpperCase();
|
||||
const resetAt = parseResetTime(src.nextResetTime);
|
||||
|
||||
if (type === "TOKENS_LIMIT" || type === "TOKEN_LIMIT") {
|
||||
const quotaName = getOpenCodeGoTokenQuotaName(src, quotas);
|
||||
|
||||
quotas[quotaName] = buildOpenCodeGoDollarQuota(
|
||||
quotaName,
|
||||
src.percentage,
|
||||
resetAt,
|
||||
undefined,
|
||||
Array.isArray(src.models)
|
||||
? (src.models as unknown[]).map((model) => {
|
||||
const modelInfo = toRecord(model);
|
||||
return {
|
||||
name: String(modelInfo.model || modelInfo.modelCode || "usage"),
|
||||
used: toNumber(modelInfo.percentage, 0),
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type === "TIME_LIMIT" || type === "TIME_USAGE_LIMIT") {
|
||||
quotas.mcp_monthly = buildOpenCodeGoDollarQuota(
|
||||
"mcp_monthly",
|
||||
src.percentage,
|
||||
resetAt,
|
||||
src.currentValue,
|
||||
Array.isArray(src.usageDetails)
|
||||
? src.usageDetails.map((item) => {
|
||||
const detail = toRecord(item);
|
||||
return {
|
||||
name: String(detail.modelCode || detail.name || "usage"),
|
||||
used: toNumber(detail.usage, 0),
|
||||
};
|
||||
})
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const levelRaw =
|
||||
typeof data.planName === "string"
|
||||
? data.planName
|
||||
: typeof data.level === "string"
|
||||
? data.level
|
||||
: "";
|
||||
const planLabel = toTitleCase(levelRaw.replace(/\s*plan$/i, ""));
|
||||
const plan = planLabel
|
||||
? /^opencode\s+go\b/i.test(planLabel)
|
||||
? planLabel
|
||||
: `OpenCode Go ${planLabel}`
|
||||
: null;
|
||||
|
||||
return { plan, quotas: orderOpenCodeGoQuotas(quotas) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bailian (Alibaba Coding Plan) Usage
|
||||
* Fetches triple-window quota (5h, weekly, monthly) and returns worst-case.
|
||||
@@ -1589,7 +1391,9 @@ export async function getUsageForProvider(
|
||||
...(provider === "glm-cn" ? { apiRegion: "china" } : {}),
|
||||
});
|
||||
case "opencode-go":
|
||||
return await getOpenCodeGoUsage(apiKey || "");
|
||||
return await getOpenCodeGoUsage(apiKey || "", providerSpecificData);
|
||||
case "ollama-cloud":
|
||||
return await getOllamaCloudUsage(providerSpecificData);
|
||||
case "minimax":
|
||||
case "minimax-cn":
|
||||
return await getMiniMaxUsage(apiKey || "", provider);
|
||||
@@ -3118,7 +2922,10 @@ async function getKiroUsage(accessToken?: string, providerSpecificData?: JsonRec
|
||||
// "auth expired, chat may still work" message instead of a generic upstream-error blob
|
||||
// so the quota card matches what users with legacy social-auth accounts already see.
|
||||
// Inspired by https://github.com/decolua/9router/pull/620.
|
||||
if ((response.status === 401 || response.status === 403) && isSocialAuthKiroAccount(providerSpecificData)) {
|
||||
if (
|
||||
(response.status === 401 || response.status === 403) &&
|
||||
isSocialAuthKiroAccount(providerSpecificData)
|
||||
) {
|
||||
return {
|
||||
message: "Kiro quota API authentication expired. Chat may still work.",
|
||||
quotas: {},
|
||||
|
||||
@@ -81,15 +81,15 @@ const ENV_KEY_RE = /(clientId|clientSecret|apiKey)Env\s*:/;
|
||||
//
|
||||
// 6A.8: Expanded scope to open-sse/** + src/lib/oauth/**. Newly discovered FPs:
|
||||
//
|
||||
// open-sse/services/usage.ts L582: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
|
||||
// open-sse/services/usage.ts L499: `getMiniMaxUsage(apiKey: string, provider: "minimax" | "minimax-cn")`
|
||||
// The CRED_KEY_RE matches `apiKey:` in the TypeScript function-parameter type annotation.
|
||||
// "minimax" and "minimax-cn" are provider-name strings in the type annotation, NOT credentials.
|
||||
// This is a false positive (the gate was designed for object-literal assignments, not fn params).
|
||||
// TODO(6A.8): Consider tightening CRED_KEY_RE to exclude function-signature contexts — but
|
||||
// that adds complexity; the FP rate is low (1 file). Frozen by file:line:value key.
|
||||
export const KNOWN_LITERAL_CREDS = new Set([
|
||||
"open-sse/services/usage.ts:582:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838/#4293, then 547→582 by the v3.8.33 usage.ts growth)
|
||||
"open-sse/services/usage.ts:582:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 543→547 by #3838/#4293, then 547→582 by the v3.8.33 usage.ts growth)
|
||||
"open-sse/services/usage.ts:499:minimax", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction)
|
||||
"open-sse/services/usage.ts:499:minimax-cn", // TODO(6A.8): pre-existing FP — TS fn-param type, not a credential (moved 582→499 by the OpenCode/Ollama usage extraction)
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -111,6 +111,7 @@ export function formatUsdCost(value: number, locale: string): string {
|
||||
*/
|
||||
export function maskKey(fullKey: string | null | undefined): string {
|
||||
if (!fullKey) return "";
|
||||
if (fullKey.includes("****")) return fullKey;
|
||||
return fullKey.length > 8 ? `${fullKey.slice(0, 8)}...` : fullKey;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
getProviderBaseUrlHint,
|
||||
getProviderBaseUrlPlaceholder,
|
||||
isGlmProvider,
|
||||
parseRoutingTagsInput,
|
||||
parseExcludedModelsInput,
|
||||
getWebSessionCredentialLabel,
|
||||
getWebSessionCredentialHint,
|
||||
getWebSessionCredentialCheckLabel,
|
||||
@@ -28,7 +26,8 @@ import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"
|
||||
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
|
||||
import { assignCcCompatibleRequestDefaults } from "./ccCompatibleRequestDefaults";
|
||||
import { buildAddProviderSpecificData } from "./connectionProviderSpecificData";
|
||||
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
|
||||
|
||||
export interface AddApiKeyModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -112,6 +111,7 @@ export default function AddApiKeyModal({
|
||||
customUserAgent: "",
|
||||
accountId: "",
|
||||
consoleApiKey: "",
|
||||
...EMPTY_QUOTA_SCRAPING_FIELDS,
|
||||
ccCompatibleContext1m: false,
|
||||
ccCompatibleRedactThinking: false,
|
||||
passthroughModels: false,
|
||||
@@ -283,47 +283,27 @@ export default function AddApiKeyModal({
|
||||
}
|
||||
}
|
||||
|
||||
const providerSpecificData: Record<string, unknown> = {};
|
||||
if (formData.customUserAgent.trim()) {
|
||||
providerSpecificData.customUserAgent = formData.customUserAgent.trim();
|
||||
}
|
||||
openRouterPreset.applyTo(providerSpecificData);
|
||||
if (formData.routingTags.trim()) {
|
||||
providerSpecificData.tags = parseRoutingTagsInput(formData.routingTags);
|
||||
}
|
||||
if (formData.excludedModels.trim()) {
|
||||
providerSpecificData.excludedModels = parseExcludedModelsInput(formData.excludedModels);
|
||||
}
|
||||
if (formData.passthroughModels) {
|
||||
providerSpecificData.passthroughModels = true;
|
||||
}
|
||||
if (showFreeModelsToggle && formData.importFreeModelsOnly) {
|
||||
providerSpecificData.importFreeModelsOnly = true;
|
||||
}
|
||||
if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) {
|
||||
providerSpecificData.consoleApiKey = formData.consoleApiKey.trim();
|
||||
}
|
||||
if (isGooglePse && formData.cx.trim()) {
|
||||
providerSpecificData.cx = formData.cx.trim();
|
||||
}
|
||||
if (usesBaseUrl) {
|
||||
providerSpecificData.baseUrl = validatedBaseUrl;
|
||||
} else if (showsRegion) {
|
||||
providerSpecificData.region = formData.region.trim() || defaultRegion;
|
||||
} else if (isGlm) {
|
||||
providerSpecificData.apiRegion = formData.apiRegion;
|
||||
} else if (isCloudflare && formData.accountId.trim()) {
|
||||
providerSpecificData.accountId = formData.accountId.trim();
|
||||
}
|
||||
if (isCcCompatible) assignCcCompatibleRequestDefaults(providerSpecificData, formData);
|
||||
const providerSpecificData = buildAddProviderSpecificData({
|
||||
provider,
|
||||
formData,
|
||||
openRouterPreset,
|
||||
showFreeModelsToggle,
|
||||
isGooglePse,
|
||||
usesBaseUrl,
|
||||
validatedBaseUrl,
|
||||
showsRegion,
|
||||
defaultRegion,
|
||||
isGlm,
|
||||
isCloudflare,
|
||||
isCcCompatible,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
name: formData.name,
|
||||
apiKey: credentialInput.trim() || undefined,
|
||||
priority: formData.priority,
|
||||
testStatus: "active",
|
||||
providerSpecificData:
|
||||
Object.keys(providerSpecificData).length > 0 ? providerSpecificData : undefined,
|
||||
providerSpecificData,
|
||||
};
|
||||
|
||||
const error = await onSave(payload);
|
||||
@@ -710,6 +690,12 @@ export default function AddApiKeyModal({
|
||||
</div>
|
||||
)}
|
||||
{freeModelsToggle}
|
||||
<QuotaScrapingFields
|
||||
provider={provider}
|
||||
values={formData}
|
||||
onChange={(patch) => setFormData({ ...formData, ...patch })}
|
||||
t={t}
|
||||
/>
|
||||
{isCompatible && !isCcCompatible && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{isAnthropic
|
||||
|
||||
@@ -49,7 +49,8 @@ import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"
|
||||
import { useOpenRouterPresetControl } from "../OpenRouterPresetInput";
|
||||
import WebSessionCredentialGuide from "../WebSessionCredentialGuide";
|
||||
import CcCompatibleRequestDefaultsFields from "./CcCompatibleRequestDefaultsFields";
|
||||
import { mergeCcCompatibleRequestDefaults } from "./ccCompatibleRequestDefaults";
|
||||
import { assignEditApiKeyProviderSpecificData } from "./connectionProviderSpecificData";
|
||||
import QuotaScrapingFields, { EMPTY_QUOTA_SCRAPING_FIELDS } from "./QuotaScrapingFields";
|
||||
|
||||
export interface EditConnectionModalConnection {
|
||||
id?: string;
|
||||
@@ -115,6 +116,7 @@ export default function EditConnectionModal({
|
||||
codexServiceTier: "default" as CodexServiceTier,
|
||||
codexOpenaiStoreEnabled: false,
|
||||
consoleApiKey: "",
|
||||
...EMPTY_QUOTA_SCRAPING_FIELDS,
|
||||
ccCompatibleContext1m: false,
|
||||
ccCompatibleRedactThinking: false,
|
||||
cloudCodeProjectId: "",
|
||||
@@ -218,6 +220,10 @@ export default function EditConnectionModal({
|
||||
const existingOpenRouterPreset = stringField(connection.providerSpecificData?.preset);
|
||||
const existingCx = stringField(connection.providerSpecificData?.cx);
|
||||
const existingAccountId = stringField(connection.providerSpecificData?.accountId);
|
||||
const existingOpenCodeGoWorkspaceId =
|
||||
stringField(connection.providerSpecificData?.opencodeGoWorkspaceId) ||
|
||||
stringField(connection.providerSpecificData?.openCodeGoWorkspaceId) ||
|
||||
stringField(connection.providerSpecificData?.workspaceId);
|
||||
const codexRequestDefaults = getCodexRequestDefaults(connection.providerSpecificData);
|
||||
const ccRequestDefaults = getClaudeCodeCompatibleRequestDefaults(
|
||||
connection.providerSpecificData
|
||||
@@ -269,6 +275,9 @@ export default function EditConnectionModal({
|
||||
codexServiceTier: codexRequestDefaults.serviceTier ?? "default",
|
||||
codexOpenaiStoreEnabled: connection.providerSpecificData?.openaiStoreEnabled === true,
|
||||
consoleApiKey: existingConsoleApiKey,
|
||||
opencodeGoWorkspaceId: existingOpenCodeGoWorkspaceId,
|
||||
opencodeGoAuthCookie: "",
|
||||
ollamaCloudUsageCookie: "",
|
||||
ccCompatibleContext1m: ccRequestDefaults.context1m,
|
||||
ccCompatibleRedactThinking: ccRequestDefaults.redactThinking,
|
||||
cloudCodeProjectId:
|
||||
@@ -482,45 +491,24 @@ export default function EditConnectionModal({
|
||||
if (!isOAuth) {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
extraApiKeys: extraApiKeys.filter((k) => k.trim().length > 0),
|
||||
tag: formData.tag.trim() || undefined,
|
||||
tags: parseRoutingTagsInput(formData.routingTags),
|
||||
excludedModels: parseExcludedModelsInput(formData.excludedModels),
|
||||
customUserAgent: formData.customUserAgent.trim(),
|
||||
...openRouterPreset.getPatch(),
|
||||
...(formData.passthroughModels ? { passthroughModels: true } : {}),
|
||||
};
|
||||
if (provider === "bailian-coding-plan") {
|
||||
if (formData.consoleApiKey.trim()) {
|
||||
updates.providerSpecificData.consoleApiKey = formData.consoleApiKey.trim();
|
||||
} else {
|
||||
updates.providerSpecificData.consoleApiKey = undefined;
|
||||
}
|
||||
}
|
||||
if (formData.validationModelId) {
|
||||
updates.providerSpecificData.validationModelId = formData.validationModelId;
|
||||
}
|
||||
if (isGooglePse) {
|
||||
updates.providerSpecificData.cx = formData.cx.trim() || undefined;
|
||||
}
|
||||
if (usesBaseUrl) {
|
||||
updates.providerSpecificData.baseUrl = validatedBaseUrl;
|
||||
} else if (showsRegion) {
|
||||
updates.providerSpecificData.region = formData.region.trim() || defaultRegion;
|
||||
} else if (isGlm) {
|
||||
updates.providerSpecificData.apiRegion = formData.apiRegion;
|
||||
} else if (isCloudflare && formData.accountId.trim()) {
|
||||
updates.providerSpecificData.accountId = formData.accountId.trim();
|
||||
}
|
||||
if (supportsGoogleProjectId) {
|
||||
updates.providerSpecificData.projectId = trimmedCloudCodeProjectId || null;
|
||||
}
|
||||
if (isCcCompatible) {
|
||||
updates.providerSpecificData.requestDefaults = mergeCcCompatibleRequestDefaults(
|
||||
updates.providerSpecificData.requestDefaults,
|
||||
formData
|
||||
);
|
||||
}
|
||||
assignEditApiKeyProviderSpecificData({
|
||||
provider,
|
||||
formData,
|
||||
target: updates.providerSpecificData,
|
||||
extraApiKeys,
|
||||
openRouterPreset,
|
||||
usesBaseUrl,
|
||||
validatedBaseUrl,
|
||||
showsRegion,
|
||||
defaultRegion,
|
||||
isGlm,
|
||||
isCloudflare,
|
||||
supportsGoogleProjectId,
|
||||
trimmedCloudCodeProjectId,
|
||||
isGooglePse,
|
||||
isCcCompatible,
|
||||
});
|
||||
} else {
|
||||
updates.providerSpecificData = {
|
||||
...(connection.providerSpecificData || {}),
|
||||
@@ -695,6 +683,13 @@ export default function EditConnectionModal({
|
||||
description={t("disableCoolingDescription")}
|
||||
/>
|
||||
</div>
|
||||
<QuotaScrapingFields
|
||||
provider={provider}
|
||||
values={formData}
|
||||
onChange={(patch) => setFormData({ ...formData, ...patch })}
|
||||
t={t}
|
||||
editMode
|
||||
/>
|
||||
{supportsGoogleProjectId && (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
{isAntigravity && (
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/shared/components";
|
||||
import { providerText, type ProviderMessageTranslator } from "../../providerPageHelpers";
|
||||
|
||||
export type QuotaScrapingFieldValues = {
|
||||
opencodeGoWorkspaceId: string;
|
||||
opencodeGoAuthCookie: string;
|
||||
ollamaCloudUsageCookie: string;
|
||||
};
|
||||
|
||||
export const EMPTY_QUOTA_SCRAPING_FIELDS: QuotaScrapingFieldValues = {
|
||||
opencodeGoWorkspaceId: "",
|
||||
opencodeGoAuthCookie: "",
|
||||
ollamaCloudUsageCookie: "",
|
||||
};
|
||||
|
||||
export function assignQuotaScrapingProviderData(
|
||||
provider: string | undefined,
|
||||
values: QuotaScrapingFieldValues,
|
||||
target: Record<string, unknown>
|
||||
) {
|
||||
if (provider === "opencode-go") {
|
||||
target.opencodeGoWorkspaceId = values.opencodeGoWorkspaceId.trim() || undefined;
|
||||
if (values.opencodeGoAuthCookie.trim()) {
|
||||
target.opencodeGoAuthCookie = values.opencodeGoAuthCookie.trim();
|
||||
}
|
||||
} else if (provider === "ollama-cloud" && values.ollamaCloudUsageCookie.trim()) {
|
||||
target.ollamaCloudUsageCookie = values.ollamaCloudUsageCookie.trim();
|
||||
}
|
||||
}
|
||||
|
||||
type QuotaScrapingFieldsProps = {
|
||||
provider?: string;
|
||||
values: QuotaScrapingFieldValues;
|
||||
onChange: (patch: Partial<QuotaScrapingFieldValues>) => void;
|
||||
t: ProviderMessageTranslator;
|
||||
editMode?: boolean;
|
||||
};
|
||||
|
||||
export default function QuotaScrapingFields({
|
||||
provider,
|
||||
values,
|
||||
onChange,
|
||||
t,
|
||||
editMode = false,
|
||||
}: QuotaScrapingFieldsProps) {
|
||||
if (provider === "opencode-go") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Input
|
||||
label={providerText(t, "opencodeGoWorkspaceIdLabel", "OpenCode Go workspace ID")}
|
||||
name="opencodeGoWorkspaceId"
|
||||
value={values.opencodeGoWorkspaceId}
|
||||
onChange={(e) => onChange({ opencodeGoWorkspaceId: e.target.value })}
|
||||
placeholder="workspace_..."
|
||||
hint={providerText(
|
||||
t,
|
||||
"opencodeGoWorkspaceIdHint",
|
||||
"Required for quota scraping. Copy it from the OpenCode Go workspace URL."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Input
|
||||
label={providerText(t, "opencodeGoAuthCookieLabel", "OpenCode Go auth cookie")}
|
||||
name="opencodeGoAuthCookie"
|
||||
type="password"
|
||||
value={values.opencodeGoAuthCookie}
|
||||
onChange={(e) => onChange({ opencodeGoAuthCookie: e.target.value })}
|
||||
placeholder="auth=..."
|
||||
hint={providerText(
|
||||
t,
|
||||
"opencodeGoAuthCookieHint",
|
||||
editMode
|
||||
? "Leave blank to keep the stored cookie. Paste auth=... or only the cookie value to replace it."
|
||||
: "Paste the auth cookie value from opencode.ai. The auth= prefix is accepted."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (provider === "ollama-cloud") {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-border/50 bg-surface/20 p-4">
|
||||
<Input
|
||||
label={providerText(t, "ollamaCloudUsageCookieLabel", "Ollama Cloud usage cookie")}
|
||||
name="ollamaCloudUsageCookie"
|
||||
type="password"
|
||||
value={values.ollamaCloudUsageCookie}
|
||||
onChange={(e) => onChange({ ollamaCloudUsageCookie: e.target.value })}
|
||||
placeholder="__Secure-session=..."
|
||||
hint={providerText(
|
||||
t,
|
||||
"ollamaCloudUsageCookieHint",
|
||||
editMode
|
||||
? "Leave blank to keep the stored cookie. Paste the __Secure-session cookie value from ollama.com/settings to replace it."
|
||||
: "Required for quota scraping. Paste the __Secure-session cookie value from ollama.com/settings."
|
||||
)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { parseExcludedModelsInput, parseRoutingTagsInput } from "../../providerPageHelpers";
|
||||
import {
|
||||
assignCcCompatibleRequestDefaults,
|
||||
mergeCcCompatibleRequestDefaults,
|
||||
} from "./ccCompatibleRequestDefaults";
|
||||
import {
|
||||
assignQuotaScrapingProviderData,
|
||||
type QuotaScrapingFieldValues,
|
||||
} from "./QuotaScrapingFields";
|
||||
|
||||
type FormData = QuotaScrapingFieldValues & {
|
||||
accountId: string;
|
||||
apiRegion: string;
|
||||
ccCompatibleContext1m: boolean;
|
||||
ccCompatibleRedactThinking: boolean;
|
||||
consoleApiKey: string;
|
||||
customUserAgent: string;
|
||||
cx: string;
|
||||
excludedModels: string;
|
||||
importFreeModelsOnly: boolean;
|
||||
passthroughModels: boolean;
|
||||
region: string;
|
||||
routingTags: string;
|
||||
tag?: string;
|
||||
validationModelId?: string;
|
||||
};
|
||||
type ProviderSpecificData = Record<string, unknown>;
|
||||
|
||||
export function buildAddProviderSpecificData(options: {
|
||||
provider?: string;
|
||||
formData: FormData;
|
||||
openRouterPreset: { applyTo: (target: ProviderSpecificData) => void };
|
||||
showFreeModelsToggle: boolean;
|
||||
isGooglePse: boolean;
|
||||
usesBaseUrl: boolean;
|
||||
validatedBaseUrl: string | null;
|
||||
showsRegion: boolean;
|
||||
defaultRegion: string;
|
||||
isGlm: boolean;
|
||||
isCloudflare: boolean;
|
||||
isCcCompatible?: boolean;
|
||||
}) {
|
||||
const {
|
||||
provider,
|
||||
formData,
|
||||
openRouterPreset,
|
||||
showFreeModelsToggle,
|
||||
isGooglePse,
|
||||
usesBaseUrl,
|
||||
validatedBaseUrl,
|
||||
showsRegion,
|
||||
defaultRegion,
|
||||
isGlm,
|
||||
isCloudflare,
|
||||
isCcCompatible,
|
||||
} = options;
|
||||
const data: ProviderSpecificData = {};
|
||||
if (formData.customUserAgent.trim()) data.customUserAgent = formData.customUserAgent.trim();
|
||||
openRouterPreset.applyTo(data);
|
||||
if (formData.routingTags.trim()) data.tags = parseRoutingTagsInput(formData.routingTags);
|
||||
if (formData.excludedModels.trim()) {
|
||||
data.excludedModels = parseExcludedModelsInput(formData.excludedModels);
|
||||
}
|
||||
if (formData.passthroughModels) data.passthroughModels = true;
|
||||
if (showFreeModelsToggle && formData.importFreeModelsOnly) data.importFreeModelsOnly = true;
|
||||
if (provider === "bailian-coding-plan" && formData.consoleApiKey.trim()) {
|
||||
data.consoleApiKey = formData.consoleApiKey.trim();
|
||||
}
|
||||
assignQuotaScrapingProviderData(provider, formData, data);
|
||||
if (isGooglePse && formData.cx.trim()) data.cx = formData.cx.trim();
|
||||
if (usesBaseUrl) data.baseUrl = validatedBaseUrl;
|
||||
else if (showsRegion) data.region = formData.region.trim() || defaultRegion;
|
||||
else if (isGlm) data.apiRegion = formData.apiRegion;
|
||||
else if (isCloudflare && formData.accountId.trim()) data.accountId = formData.accountId.trim();
|
||||
if (isCcCompatible) assignCcCompatibleRequestDefaults(data, formData);
|
||||
return Object.keys(data).length > 0 ? data : undefined;
|
||||
}
|
||||
|
||||
export function assignEditApiKeyProviderSpecificData(options: {
|
||||
provider: string;
|
||||
formData: FormData;
|
||||
target: ProviderSpecificData;
|
||||
extraApiKeys: string[];
|
||||
openRouterPreset: { getPatch: () => ProviderSpecificData };
|
||||
usesBaseUrl: boolean;
|
||||
validatedBaseUrl: string | null;
|
||||
showsRegion: boolean;
|
||||
defaultRegion: string;
|
||||
isGlm: boolean;
|
||||
isCloudflare: boolean;
|
||||
supportsGoogleProjectId: boolean;
|
||||
trimmedCloudCodeProjectId: string;
|
||||
isGooglePse: boolean;
|
||||
isCcCompatible: boolean;
|
||||
}) {
|
||||
const o = options;
|
||||
Object.assign(o.target, {
|
||||
extraApiKeys: o.extraApiKeys.filter((key) => key.trim().length > 0),
|
||||
tag: o.formData.tag.trim() || undefined,
|
||||
tags: parseRoutingTagsInput(o.formData.routingTags),
|
||||
excludedModels: parseExcludedModelsInput(o.formData.excludedModels),
|
||||
customUserAgent: o.formData.customUserAgent.trim(),
|
||||
...o.openRouterPreset.getPatch(),
|
||||
...(o.formData.passthroughModels ? { passthroughModels: true } : {}),
|
||||
});
|
||||
if (o.provider === "bailian-coding-plan") {
|
||||
o.target.consoleApiKey = o.formData.consoleApiKey.trim() || undefined;
|
||||
}
|
||||
assignQuotaScrapingProviderData(o.provider, o.formData, o.target);
|
||||
if (o.formData.validationModelId) o.target.validationModelId = o.formData.validationModelId;
|
||||
if (o.isGooglePse) o.target.cx = o.formData.cx.trim() || undefined;
|
||||
if (o.usesBaseUrl) o.target.baseUrl = o.validatedBaseUrl;
|
||||
else if (o.showsRegion) o.target.region = o.formData.region.trim() || o.defaultRegion;
|
||||
else if (o.isGlm) o.target.apiRegion = o.formData.apiRegion;
|
||||
else if (o.isCloudflare && o.formData.accountId.trim()) {
|
||||
o.target.accountId = o.formData.accountId.trim();
|
||||
}
|
||||
if (o.supportsGoogleProjectId) o.target.projectId = o.trimmedCloudCodeProjectId || null;
|
||||
if (o.isCcCompatible) {
|
||||
o.target.requestDefaults = mergeCcCompatibleRequestDefaults(
|
||||
o.target.requestDefaults,
|
||||
o.formData
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export default function QuotaCard({
|
||||
quotas={quotas}
|
||||
loading={loading}
|
||||
error={error}
|
||||
message={quota?.message ?? null}
|
||||
refreshedAt={displayRefreshedAt}
|
||||
hasStaleData={hasStaleData}
|
||||
onRefresh={onRefresh}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function formatAutoRefreshCountdown(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import useEmailPrivacyStore from "@/store/emailPrivacyStore";
|
||||
import { useNotificationStore } from "@/store/notificationStore";
|
||||
import QuotaCutoffModal from "./QuotaCutoffModal";
|
||||
import QuotaCardGrid from "./QuotaCardGrid";
|
||||
import { useVisibleQuotaData } from "./useVisibleQuotaData";
|
||||
import { formatAutoRefreshCountdown } from "./formatters";
|
||||
import { translateUsageOrFallback, type UsageTranslationValues } from "./i18nFallback";
|
||||
import { compareTr } from "@/shared/utils/turkishText";
|
||||
|
||||
@@ -33,7 +35,6 @@ const MIN_FETCH_INTERVAL_MS = 30000;
|
||||
const QUOTA_BAR_GREEN_THRESHOLD = 50;
|
||||
const QUOTA_BAR_YELLOW_THRESHOLD = 20;
|
||||
|
||||
// Display label per known provider; the icon is resolved by ProviderIcon.
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
antigravity: "Antigravity",
|
||||
"gemini-cli": "Gemini CLI",
|
||||
@@ -46,6 +47,7 @@ const PROVIDER_LABEL: Record<string, string> = {
|
||||
zai: "Z.AI",
|
||||
glmt: "GLM Thinking",
|
||||
"opencode-go": "OpenCode Go",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
"kimi-coding": "Kimi Coding",
|
||||
minimax: "MiniMax",
|
||||
"minimax-cn": "MiniMax CN",
|
||||
@@ -53,8 +55,7 @@ const PROVIDER_LABEL: Record<string, string> = {
|
||||
deepseek: "DeepSeek",
|
||||
};
|
||||
|
||||
// Group ordering — single source of truth for "where does Codex sit
|
||||
// relative to Antigravity on the page".
|
||||
// Group ordering — single source of truth for provider placement.
|
||||
const PROVIDER_ORDER: Record<string, number> = {
|
||||
antigravity: 1,
|
||||
"gemini-cli": 2,
|
||||
@@ -66,10 +67,11 @@ const PROVIDER_ORDER: Record<string, number> = {
|
||||
zai: 8,
|
||||
glmt: 9,
|
||||
"opencode-go": 10,
|
||||
"kimi-coding": 11,
|
||||
minimax: 12,
|
||||
"minimax-cn": 13,
|
||||
nanogpt: 14,
|
||||
"ollama-cloud": 11,
|
||||
"kimi-coding": 12,
|
||||
minimax: 13,
|
||||
"minimax-cn": 14,
|
||||
nanogpt: 15,
|
||||
};
|
||||
|
||||
const TIER_FILTERS = [
|
||||
@@ -213,13 +215,6 @@ function aggregateWorst(statuses: StatusKey[]): "critical" | "alert" | "ok" | "e
|
||||
return worst;
|
||||
}
|
||||
|
||||
function formatAutoRefreshCountdown(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
interface ProviderLimitsProps {
|
||||
showFilters?: boolean;
|
||||
autoRefreshInterval?: number;
|
||||
@@ -564,6 +559,7 @@ export default function ProviderLimits({
|
||||
(a, b) => (PROVIDER_ORDER[a.provider] || 99) - (PROVIDER_ORDER[b.provider] || 99)
|
||||
);
|
||||
}, [filteredConnections]);
|
||||
const visibleQuotaData = useVisibleQuotaData(sortedConnections, quotaData);
|
||||
|
||||
const resolvedPlanByConnection = useMemo(() => {
|
||||
const out: Record<string, string | null> = {};
|
||||
@@ -613,10 +609,10 @@ export default function ProviderLimits({
|
||||
const statusByConnection = useMemo(() => {
|
||||
const out: Record<string, StatusKey> = {};
|
||||
for (const conn of sortedConnections) {
|
||||
out[conn.id] = getWorstStatus(quotaData[conn.id]?.quotas);
|
||||
out[conn.id] = getWorstStatus(visibleQuotaData[conn.id]?.quotas);
|
||||
}
|
||||
return out;
|
||||
}, [sortedConnections, quotaData]);
|
||||
}, [sortedConnections, visibleQuotaData]);
|
||||
|
||||
const purchaseTypeCounts = useMemo(() => {
|
||||
const counts: Record<PurchaseTypeKey, number> = {
|
||||
@@ -697,8 +693,8 @@ export default function ProviderLimits({
|
||||
const sa = statusRank[statusByConnection[a.id] || "empty"];
|
||||
const sb = statusRank[statusByConnection[b.id] || "empty"];
|
||||
if (sa !== sb) return sa - sb;
|
||||
const ra = getSoonestResetMs(quotaData[a.id]?.quotas);
|
||||
const rb = getSoonestResetMs(quotaData[b.id]?.quotas);
|
||||
const ra = getSoonestResetMs(visibleQuotaData[a.id]?.quotas);
|
||||
const rb = getSoonestResetMs(visibleQuotaData[b.id]?.quotas);
|
||||
return ra - rb;
|
||||
});
|
||||
}, [
|
||||
@@ -711,7 +707,7 @@ export default function ProviderLimits({
|
||||
statusByConnection,
|
||||
envFilter,
|
||||
providerFilter,
|
||||
quotaData,
|
||||
visibleQuotaData,
|
||||
]);
|
||||
|
||||
// Distinct provider keys present in the current connection set (after the
|
||||
@@ -1055,7 +1051,7 @@ export default function ProviderLimits({
|
||||
|
||||
<QuotaCardGrid
|
||||
connections={visibleConnections}
|
||||
quotaData={quotaData}
|
||||
quotaData={visibleQuotaData}
|
||||
loading={loading}
|
||||
errors={errors}
|
||||
lastRefreshedAt={lastRefreshedAt}
|
||||
@@ -1064,7 +1060,7 @@ export default function ProviderLimits({
|
||||
renderInlineQuotaSummary={(quota) => renderInlineQuotaSummary(quota.quotas)}
|
||||
onRefresh={refreshProvider}
|
||||
onOpenCutoff={(conn) => {
|
||||
const windows = (quotaData[conn.id]?.quotas || []).filter(
|
||||
const windows = (visibleQuotaData[conn.id]?.quotas || []).filter(
|
||||
(q: any) => q && typeof q.name === "string" && !q.isCredits
|
||||
);
|
||||
setCutoffModalWindows(windows);
|
||||
|
||||
@@ -25,6 +25,7 @@ interface Props {
|
||||
quotas: any[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
message?: string | null;
|
||||
refreshedAt?: string;
|
||||
hasStaleData: boolean;
|
||||
onRefresh: () => void;
|
||||
@@ -109,6 +110,7 @@ export default function QuotaCardExpanded({
|
||||
quotas,
|
||||
loading,
|
||||
error,
|
||||
message,
|
||||
refreshedAt,
|
||||
hasStaleData,
|
||||
onRefresh,
|
||||
@@ -143,6 +145,10 @@ export default function QuotaCardExpanded({
|
||||
<span className="material-symbols-outlined text-[13px]">error</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : quotas.length === 0 && message ? (
|
||||
<div className="text-[11px] text-text-muted italic" title={message}>
|
||||
{message}
|
||||
</div>
|
||||
) : quotas.length === 0 ? (
|
||||
<div className="text-[11px] text-text-muted italic">{t("noQuotaData")}</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
|
||||
const GLM_QUOTA_ORDER: Record<string, number> = { session: 0, weekly: 1, mcp_monthly: 2 };
|
||||
|
||||
function quotaEntries(data: any): Array<[string, any]> {
|
||||
return data?.quotas && typeof data.quotas === "object" ? Object.entries(data.quotas) : [];
|
||||
}
|
||||
|
||||
function isUnlimitedEmpty(quota: any): boolean {
|
||||
return Boolean(quota?.unlimited && (!quota?.total || quota.total <= 0));
|
||||
}
|
||||
|
||||
function isPastResetWindow(resetAt: any): boolean {
|
||||
if (!resetAt) return false;
|
||||
const resetTime =
|
||||
typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN;
|
||||
return Number.isFinite(resetTime) && Date.now() >= resetTime;
|
||||
}
|
||||
|
||||
function getResetAdjustedQuota(quota: any) {
|
||||
const usedRaw = Number(quota?.used || 0);
|
||||
const totalRaw = Number(quota?.total || 0);
|
||||
const total = Number.isFinite(totalRaw) ? totalRaw : 0;
|
||||
const remainingRaw = safePercentage(quota?.remainingPercentage);
|
||||
const hasPendingUsage = usedRaw > 0 || (remainingRaw !== undefined && remainingRaw < 100);
|
||||
const staleAfterReset = isPastResetWindow(quota?.resetAt || null) && hasPendingUsage;
|
||||
|
||||
return {
|
||||
staleAfterReset,
|
||||
total,
|
||||
used: staleAfterReset ? 0 : usedRaw,
|
||||
remainingPercentage: staleAfterReset && total > 0 ? 100 : remainingRaw,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) {
|
||||
const adjusted = getResetAdjustedQuota(quota);
|
||||
return {
|
||||
name,
|
||||
used: Number.isFinite(adjusted.used) ? adjusted.used : 0,
|
||||
total: adjusted.total,
|
||||
resetAt: quota?.resetAt || null,
|
||||
staleAfterReset: adjusted.staleAfterReset,
|
||||
...(adjusted.remainingPercentage !== undefined
|
||||
? { remainingPercentage: adjusted.remainingPercentage }
|
||||
: {}),
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGeneric(data: any) {
|
||||
return quotaEntries(data).map(([name, quota]) => normalizeQuotaEntry(name, quota));
|
||||
}
|
||||
|
||||
function parseGithub(data: any) {
|
||||
return quotaEntries(data)
|
||||
.filter(([, quota]) => !isUnlimitedEmpty(quota))
|
||||
.map(([name, quota]) => normalizeQuotaEntry(name, quota));
|
||||
}
|
||||
|
||||
function parseGlmFamily(data: any) {
|
||||
return quotaEntries(data).map(([name, quota]) =>
|
||||
normalizeQuotaEntry(name, quota, {
|
||||
displayName: quota?.displayName,
|
||||
details: Array.isArray(quota?.details) ? quota.details : undefined,
|
||||
isPercentageOnly:
|
||||
Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function buildCreditsQuota(
|
||||
name: string,
|
||||
remaining: number,
|
||||
remainingPercentage: number,
|
||||
extra = {}
|
||||
) {
|
||||
return {
|
||||
name,
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
isCredits: true,
|
||||
remainingPercentage,
|
||||
creditCount: remaining,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAntigravityQuota(modelKey: string, quota: any) {
|
||||
if (modelKey === "credits") {
|
||||
const remaining = Number(quota?.remaining ?? 0);
|
||||
return buildCreditsQuota("credits", remaining, remaining > 50 ? 100 : remaining > 10 ? 60 : 20);
|
||||
}
|
||||
if (modelKey === "models" || isUnlimitedEmpty(quota)) return null;
|
||||
return normalizeQuotaEntry(modelKey, quota, {
|
||||
modelKey,
|
||||
isPercentageOnly: quota?.fractionReported === true,
|
||||
...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}),
|
||||
...(quota?.fractionReported !== undefined ? { fractionReported: quota.fractionReported } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function parseAntigravity(data: any) {
|
||||
return quotaEntries(data)
|
||||
.map(([modelKey, quota]) => parseAntigravityQuota(modelKey, quota))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parseCodex(data: any) {
|
||||
return quotaEntries(data).map(([quotaType, quota]) =>
|
||||
normalizeQuotaEntry(quotaType, quota, {
|
||||
displayName: quota?.displayName,
|
||||
isPercentageOnly: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function parseClaude(data: any) {
|
||||
if (data?.message)
|
||||
return [{ name: "error", used: 0, total: 0, resetAt: null, message: data.message }];
|
||||
return quotaEntries(data).map(([name, quota]) =>
|
||||
normalizeQuotaEntry(name, quota, { isPercentageOnly: true })
|
||||
);
|
||||
}
|
||||
|
||||
function parseGeminiCli(data: any) {
|
||||
return quotaEntries(data).map(([modelKey, quota]) =>
|
||||
normalizeQuotaEntry(modelKey, quota, { modelKey })
|
||||
);
|
||||
}
|
||||
|
||||
function parseDeepseekQuota(quotaKey: string, quota: any) {
|
||||
const match = quotaKey.match(/^credits(?:_([a-z]{3}))?$/);
|
||||
if (!match) return normalizeQuotaEntry(quotaKey, quota);
|
||||
const remaining = Number(quota?.remaining ?? 0);
|
||||
const currency = quota?.currency ?? (match[1] ? match[1].toUpperCase() : "USD");
|
||||
return buildCreditsQuota(currency, remaining, remaining > 20 ? 100 : remaining > 5 ? 60 : 20, {
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
function parseDeepseek(data: any) {
|
||||
return quotaEntries(data).map(([quotaKey, quota]) => parseDeepseekQuota(quotaKey, quota));
|
||||
}
|
||||
|
||||
function parseProviderQuotas(providerId: string, data: any) {
|
||||
if (providerId === "github") return parseGithub(data);
|
||||
if (["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return parseGlmFamily(data);
|
||||
if (providerId === "antigravity" || providerId === "agy") return parseAntigravity(data);
|
||||
if (providerId === "codex") return parseCodex(data);
|
||||
if (providerId === "claude") return parseClaude(data);
|
||||
if (providerId === "gemini-cli") return parseGeminiCli(data);
|
||||
if (providerId === "deepseek") return parseDeepseek(data);
|
||||
return parseGeneric(data);
|
||||
}
|
||||
|
||||
function sortProviderModelOrder(provider: string, quotas: any[]) {
|
||||
const modelOrder = getModelsByProviderId(provider);
|
||||
if (modelOrder.length === 0) return;
|
||||
const orderMap = new Map(modelOrder.map((m, i) => [m.id, i]));
|
||||
quotas.sort(
|
||||
(a, b) =>
|
||||
(orderMap.get(a.modelKey || a.name) ?? 999) - (orderMap.get(b.modelKey || b.name) ?? 999)
|
||||
);
|
||||
}
|
||||
|
||||
function sortGlmOrder(providerId: string, quotas: any[]) {
|
||||
if (!["glm", "glm-cn", "glmt", "opencode-go"].includes(providerId)) return;
|
||||
quotas.sort((a, b) => (GLM_QUOTA_ORDER[a.name] ?? 99) - (GLM_QUOTA_ORDER[b.name] ?? 99));
|
||||
}
|
||||
|
||||
export function parseQuotaData(provider: string | undefined, data: any) {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const providerId = String(provider || "").toLowerCase();
|
||||
|
||||
try {
|
||||
const normalizedQuotas = parseProviderQuotas(providerId, data);
|
||||
sortProviderModelOrder(provider, normalizedQuotas);
|
||||
sortGlmOrder(providerId, normalizedQuotas);
|
||||
return normalizedQuotas;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing quota data for ${provider}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { collectHiddenQuotaModelIds, filterHiddenModelQuotas } from "./utils";
|
||||
|
||||
function getProviderKey(connections: any[]): string {
|
||||
const providers = new Set<string>();
|
||||
for (const conn of connections) {
|
||||
if (typeof conn?.provider === "string" && conn.provider) providers.add(conn.provider);
|
||||
}
|
||||
return Array.from(providers).sort().join("|");
|
||||
}
|
||||
|
||||
export function useVisibleQuotaData(
|
||||
connections: any[],
|
||||
quotaData: Record<string, any>
|
||||
): Record<string, any> {
|
||||
const [hiddenModelsByProvider, setHiddenModelsByProvider] = useState<Record<string, string[]>>(
|
||||
{}
|
||||
);
|
||||
const providerKey = useMemo(() => getProviderKey(connections), [connections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!providerKey) return;
|
||||
|
||||
let alive = true;
|
||||
const providers = providerKey.split("|").filter(Boolean);
|
||||
|
||||
Promise.all(
|
||||
providers.map(async (provider) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/provider-models?provider=${encodeURIComponent(provider)}`
|
||||
);
|
||||
if (!response.ok) return [provider, []] as const;
|
||||
const data = await response.json();
|
||||
return [provider, collectHiddenQuotaModelIds(provider, data)] as const;
|
||||
} catch {
|
||||
return [provider, []] as const;
|
||||
}
|
||||
})
|
||||
).then((entries) => {
|
||||
if (alive) setHiddenModelsByProvider(Object.fromEntries(entries));
|
||||
});
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [providerKey]);
|
||||
|
||||
return useMemo(() => {
|
||||
const next: Record<string, any> = {};
|
||||
for (const conn of connections) {
|
||||
const data = quotaData[conn.id];
|
||||
if (!data) continue;
|
||||
next[conn.id] = {
|
||||
...data,
|
||||
quotas: filterHiddenModelQuotas(
|
||||
conn.provider,
|
||||
data.quotas,
|
||||
hiddenModelsByProvider[conn.provider]
|
||||
),
|
||||
};
|
||||
}
|
||||
return next;
|
||||
}, [connections, hiddenModelsByProvider, quotaData]);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts";
|
||||
import { safePercentage } from "@/shared/utils/formatting";
|
||||
export { parseQuotaData } from "./quotaParsing";
|
||||
|
||||
const PROVIDER_PLAN_FALLBACKS = new Set([
|
||||
"claude code",
|
||||
@@ -36,12 +35,6 @@ const QUOTA_LABEL_MAP: Record<string, string> = {
|
||||
time_limit: "Time Limit",
|
||||
};
|
||||
|
||||
const GLM_QUOTA_ORDER: Record<string, number> = {
|
||||
session: 0,
|
||||
weekly: 1,
|
||||
mcp_monthly: 2,
|
||||
};
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
@@ -179,267 +172,6 @@ export function calculatePercentage(used, total) {
|
||||
return Math.round(((total - used) / total) * 100);
|
||||
}
|
||||
|
||||
function isPastResetWindow(resetAt) {
|
||||
if (!resetAt) return false;
|
||||
const resetTime =
|
||||
typeof resetAt === "number" ? resetAt : typeof resetAt === "string" ? Date.parse(resetAt) : NaN;
|
||||
if (!Number.isFinite(resetTime)) return false;
|
||||
return Date.now() >= resetTime;
|
||||
}
|
||||
|
||||
function normalizeQuotaEntry(name: string, quota: any = {}, extras: any = {}) {
|
||||
const usedRaw = Number(quota?.used || 0);
|
||||
const totalRaw = Number(quota?.total || 0);
|
||||
const resetAt = quota?.resetAt || null;
|
||||
|
||||
// T13: Only consider it stale if the reset time passed AND there's still usage shown.
|
||||
// If usage is already 0 (or remaining is 100%), it's naturally reset and doesn't need to be marked as stale.
|
||||
const passedReset = isPastResetWindow(resetAt);
|
||||
const remainingPercentageRaw = safePercentage(quota?.remainingPercentage);
|
||||
const hasPendingUsage =
|
||||
usedRaw > 0 || (remainingPercentageRaw !== undefined && remainingPercentageRaw < 100);
|
||||
const staleAfterReset = passedReset && hasPendingUsage;
|
||||
|
||||
const used = staleAfterReset ? 0 : usedRaw;
|
||||
const total = Number.isFinite(totalRaw) ? totalRaw : 0;
|
||||
|
||||
const remainingPercentage =
|
||||
staleAfterReset && total > 0
|
||||
? 100
|
||||
: remainingPercentageRaw !== undefined
|
||||
? remainingPercentageRaw
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
name,
|
||||
used: Number.isFinite(used) ? used : 0,
|
||||
total,
|
||||
resetAt,
|
||||
staleAfterReset,
|
||||
...(remainingPercentage !== undefined ? { remainingPercentage } : {}),
|
||||
...extras,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse provider-specific quota structures into normalized array
|
||||
* @param {string} provider - Provider name (github, antigravity, codex, kiro, claude)
|
||||
* @param {Object} data - Raw quota data from provider
|
||||
* @returns {Array<Object>} Normalized quota objects with { name, used, total, resetAt }
|
||||
*/
|
||||
export function parseQuotaData(provider, data) {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
|
||||
const normalizedQuotas = [];
|
||||
const providerId = String(provider || "").toLowerCase();
|
||||
|
||||
try {
|
||||
switch (providerId) {
|
||||
case "github":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
|
||||
return;
|
||||
}
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "glm":
|
||||
case "glm-cn":
|
||||
case "glmt":
|
||||
case "opencode-go":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(name, quota, {
|
||||
displayName: quota?.displayName,
|
||||
details: Array.isArray(quota?.details) ? quota.details : undefined,
|
||||
isPercentageOnly:
|
||||
Number(quota?.total || 0) === 100 && quota?.remainingPercentage !== undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "antigravity":
|
||||
case "agy":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
|
||||
if (modelKey === "credits") {
|
||||
// Credit balance: render as "N credits remaining" counter, not a progress bar
|
||||
const remaining = Number(quota?.remaining ?? 0);
|
||||
normalizedQuotas.push({
|
||||
name: "credits",
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
isCredits: true,
|
||||
// Show green if >50, yellow if >10, red if ≤10
|
||||
remainingPercentage: remaining > 50 ? 100 : remaining > 10 ? 60 : 20,
|
||||
creditCount: remaining,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (modelKey === "models") {
|
||||
// Summary row: skip — individual models are shown via modelQuotas if needed
|
||||
return;
|
||||
}
|
||||
if (quota?.unlimited && (!quota?.total || quota.total <= 0)) {
|
||||
return;
|
||||
}
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(modelKey, quota, {
|
||||
modelKey: modelKey,
|
||||
isPercentageOnly: quota?.fractionReported === true,
|
||||
...(quota?.quotaSource ? { quotaSource: quota.quotaSource } : {}),
|
||||
...(quota?.fractionReported !== undefined
|
||||
? { fractionReported: quota.fractionReported }
|
||||
: {}),
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "codex":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(quotaType, quota, {
|
||||
displayName: quota?.displayName,
|
||||
isPercentageOnly: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "kiro":
|
||||
case "amazon-q":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([quotaType, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(quotaType, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "claude":
|
||||
if (data.message) {
|
||||
// Handle error message case
|
||||
normalizedQuotas.push({
|
||||
name: "error",
|
||||
used: 0,
|
||||
total: 0,
|
||||
resetAt: null,
|
||||
message: data.message,
|
||||
});
|
||||
} else if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(
|
||||
normalizeQuotaEntry(name, quota, {
|
||||
isPercentageOnly: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "gemini-cli":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([modelKey, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(modelKey, quota, { modelKey }));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "nanogpt":
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "deepseek":
|
||||
// DeepSeek balance: credits-style display with currency
|
||||
// Match any "credits" key with optional 3-letter currency suffix
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([quotaKey, quota]: [string, any]) => {
|
||||
// Match credits, credits_usd, credits_cny, credits_eur, etc.
|
||||
const match = quotaKey.match(/^credits(?:_([a-z]{3}))?$/);
|
||||
if (match) {
|
||||
const remaining = Number(quota?.remaining ?? 0);
|
||||
// Extract currency from key suffix or use quota.currency, fallback to USD
|
||||
const currency = quota?.currency ?? (match[1] ? match[1].toUpperCase() : "USD");
|
||||
normalizedQuotas.push({
|
||||
name: currency,
|
||||
used: 0,
|
||||
total: 0,
|
||||
remaining,
|
||||
resetAt: null,
|
||||
unlimited: false,
|
||||
isCredits: true,
|
||||
currency,
|
||||
creditCount: remaining,
|
||||
// Color coding based on balance amount: green >20, yellow 5-20, red <5
|
||||
remainingPercentage: remaining > 20 ? 100 : remaining > 5 ? 60 : 20,
|
||||
});
|
||||
} else {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(quotaKey, quota));
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Generic fallback for unknown providers
|
||||
if (data.quotas) {
|
||||
Object.entries(data.quotas).forEach(([name, quota]: [string, any]) => {
|
||||
normalizedQuotas.push(normalizeQuotaEntry(name, quota));
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error parsing quota data for ${provider}:`, error);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Sort quotas according to PROVIDER_MODELS order
|
||||
const modelOrder = getModelsByProviderId(provider);
|
||||
if (modelOrder.length > 0) {
|
||||
const orderMap = new Map(modelOrder.map((m, i) => [m.id, i]));
|
||||
|
||||
normalizedQuotas.sort((a, b) => {
|
||||
// Use modelKey for antigravity, otherwise use name
|
||||
const keyA = a.modelKey || a.name;
|
||||
const keyB = b.modelKey || b.name;
|
||||
const orderA = orderMap.get(keyA) ?? 999;
|
||||
const orderB = orderMap.get(keyB) ?? 999;
|
||||
return (orderA as number) - (orderB as number);
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
providerId === "glm" ||
|
||||
providerId === "glm-cn" ||
|
||||
providerId === "glmt" ||
|
||||
providerId === "opencode-go"
|
||||
) {
|
||||
normalizedQuotas.sort((a, b) => {
|
||||
const orderA = GLM_QUOTA_ORDER[a.name] ?? 99;
|
||||
const orderB = GLM_QUOTA_ORDER[b.name] ?? 99;
|
||||
return orderA - orderB;
|
||||
});
|
||||
}
|
||||
|
||||
return normalizedQuotas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the best available plan label using live usage first, then persisted
|
||||
* provider-specific connection metadata.
|
||||
@@ -472,107 +204,93 @@ export function resolvePlanValue(plan, providerSpecificData) {
|
||||
return livePlan || null;
|
||||
}
|
||||
|
||||
function unknownPlanTier(raw: string | null = null) {
|
||||
return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw };
|
||||
}
|
||||
|
||||
function formatUnknownPlanLabel(raw: string) {
|
||||
return raw
|
||||
.toLowerCase()
|
||||
.split(/[\s_-]+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function matchClaudePlanTier(raw: string, upper: string) {
|
||||
const match = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/);
|
||||
if (!match) return null;
|
||||
|
||||
const multiplier = match[2] ? ` ${match[2].toLowerCase()}` : "";
|
||||
const tiers = {
|
||||
MAX: { key: "ultra", label: `Max${multiplier}`, variant: "success", rank: 4, raw },
|
||||
PRO: { key: "pro", label: "Pro", variant: "success", rank: 3, raw },
|
||||
TEAM: { key: "team", label: "Team", variant: "info", rank: 6, raw },
|
||||
ENTERPRISE: { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw },
|
||||
FREE: { key: "free", label: "Free", variant: "default", rank: 1, raw },
|
||||
};
|
||||
return tiers[match[1]];
|
||||
}
|
||||
|
||||
function matchKeywordPlanTier(raw: string, upper: string) {
|
||||
if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS"))
|
||||
return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw };
|
||||
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG"))
|
||||
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
|
||||
if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM"))
|
||||
return { key: "team", label: "Team", variant: "info", rank: 6, raw };
|
||||
if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ"))
|
||||
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
|
||||
if (upper.includes("STUDENT"))
|
||||
return { key: "pro", label: "Student", variant: "success", rank: 3, raw };
|
||||
if (upper.includes("ULTRA"))
|
||||
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchTokenPlanTier(raw: string, upper: string) {
|
||||
if (hasTierToken(upper, "MAX"))
|
||||
return { key: "ultra", label: "Max", variant: "success", rank: 4, raw };
|
||||
if (hasTierToken(upper, "PRO") || hasTierToken(upper, "PREMIUM"))
|
||||
return { key: "pro", label: "Pro", variant: "success", rank: 3, raw };
|
||||
if (hasTierToken(upper, "STARTER"))
|
||||
return { key: "lite", label: "Starter", variant: "primary", rank: 2, raw };
|
||||
if (hasTierToken(upper, "LITE") || hasTierToken(upper, "LIGHT"))
|
||||
return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw };
|
||||
if (hasTierToken(upper, "PLUS") || hasTierToken(upper, "PAID"))
|
||||
return { key: "plus", label: "Plus", variant: "success", rank: 2, raw };
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchFreePlanTier(raw: string, upper: string) {
|
||||
return upper.includes("FREE") ||
|
||||
upper.includes("BASIC") ||
|
||||
upper.includes("TRIAL") ||
|
||||
upper.includes("LEGACY")
|
||||
? { key: "free", label: "Free", variant: "default", rank: 1, raw }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize provider-specific plan labels into a shared tier taxonomy.
|
||||
* Supported tiers: enterprise, business, team, ultra, pro, plus, lite, free, unknown.
|
||||
*/
|
||||
export function normalizePlanTier(plan) {
|
||||
const raw = typeof plan === "string" ? plan.trim() : "";
|
||||
if (!raw) {
|
||||
return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw: null };
|
||||
}
|
||||
if (!raw) return unknownPlanTier(null);
|
||||
|
||||
const upper = raw.toUpperCase();
|
||||
|
||||
// Provider names that are not real plan tiers — treat as unknown
|
||||
if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) {
|
||||
return { key: "unknown", label: "Unknown", variant: "default", rank: 0, raw };
|
||||
}
|
||||
if (PROVIDER_PLAN_FALLBACKS.has(raw.toLowerCase())) return unknownPlanTier(raw);
|
||||
|
||||
// Match Anthropic bootstrap strings (claude_max, default_claude_max_20x, etc.)
|
||||
// before the generic PRO/TEAM checks so underscored values don't fall through.
|
||||
const claudeMatch = upper.match(/(?:DEFAULT_)?CLAUDE_(MAX|PRO|TEAM|ENTERPRISE|FREE)(?:_(\d+X))?/);
|
||||
if (claudeMatch) {
|
||||
const family = claudeMatch[1];
|
||||
const multiplier = claudeMatch[2] ? ` ${claudeMatch[2].toLowerCase()}` : "";
|
||||
if (family === "MAX") {
|
||||
return { key: "ultra", label: `Max${multiplier}`, variant: "success", rank: 4, raw };
|
||||
}
|
||||
if (family === "PRO") {
|
||||
return { key: "pro", label: "Pro", variant: "success", rank: 3, raw };
|
||||
}
|
||||
if (family === "TEAM") {
|
||||
return { key: "team", label: "Team", variant: "info", rank: 6, raw };
|
||||
}
|
||||
if (family === "ENTERPRISE") {
|
||||
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
|
||||
}
|
||||
if (family === "FREE") {
|
||||
return { key: "free", label: "Free", variant: "default", rank: 1, raw };
|
||||
}
|
||||
}
|
||||
|
||||
if (upper.includes("PRO+") || upper.includes("PRO PLUS") || upper.includes("PROPLUS")) {
|
||||
return { key: "plus", label: "Pro+", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("ENTERPRISE") || upper.includes("CORP") || upper.includes("ORG")) {
|
||||
return { key: "enterprise", label: "Enterprise", variant: "info", rank: 7, raw };
|
||||
}
|
||||
|
||||
// Team plan (e.g., ChatGPT Team, GitHub Team)
|
||||
if (upper.includes("TEAM") || upper.includes("CHATGPTTEAM")) {
|
||||
return { key: "team", label: "Team", variant: "info", rank: 6, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("BUSINESS") || upper.includes("STANDARD") || upper.includes("BIZ")) {
|
||||
return { key: "business", label: "Business", variant: "warning", rank: 5, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("STUDENT")) {
|
||||
return { key: "pro", label: "Student", variant: "success", rank: 3, raw };
|
||||
}
|
||||
|
||||
if (upper.includes("ULTRA")) {
|
||||
return { key: "ultra", label: "Ultra", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (hasTierToken(upper, "MAX")) {
|
||||
return { key: "ultra", label: "Max", variant: "success", rank: 4, raw };
|
||||
}
|
||||
|
||||
if (hasTierToken(upper, "PRO") || hasTierToken(upper, "PREMIUM")) {
|
||||
return { key: "pro", label: "Pro", variant: "success", rank: 3, raw };
|
||||
}
|
||||
|
||||
if (hasTierToken(upper, "STARTER")) {
|
||||
return { key: "lite", label: "Starter", variant: "primary", rank: 2, raw };
|
||||
}
|
||||
|
||||
if (hasTierToken(upper, "LITE") || hasTierToken(upper, "LIGHT")) {
|
||||
return { key: "lite", label: "Lite", variant: "primary", rank: 2, raw };
|
||||
}
|
||||
|
||||
if (hasTierToken(upper, "PLUS") || hasTierToken(upper, "PAID")) {
|
||||
return { key: "plus", label: "Plus", variant: "success", rank: 2, raw };
|
||||
}
|
||||
|
||||
if (
|
||||
upper.includes("FREE") ||
|
||||
upper.includes("BASIC") ||
|
||||
upper.includes("TRIAL") ||
|
||||
upper.includes("LEGACY")
|
||||
) {
|
||||
return { key: "free", label: "Free", variant: "default", rank: 1, raw };
|
||||
}
|
||||
|
||||
const titleCased = raw
|
||||
.toLowerCase()
|
||||
.split(/[\s_-]+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
|
||||
return { key: "unknown", label: titleCased || "Unknown", variant: "default", rank: 0, raw };
|
||||
const matched =
|
||||
matchClaudePlanTier(raw, upper) ||
|
||||
matchKeywordPlanTier(raw, upper) ||
|
||||
matchTokenPlanTier(raw, upper) ||
|
||||
matchFreePlanTier(raw, upper);
|
||||
return matched || { ...unknownPlanTier(raw), label: formatUnknownPlanLabel(raw) || "Unknown" };
|
||||
}
|
||||
|
||||
// === Card Grid Helpers (T7) =================================================
|
||||
@@ -691,6 +409,68 @@ export function getNextResetSummary(quotas: any[] | undefined): string | null {
|
||||
return soonestIso ? formatCountdown(soonestIso) : null;
|
||||
}
|
||||
|
||||
function addQuotaModelIdVariants(out: Set<string>, provider: string, modelId: string) {
|
||||
const raw = modelId.trim().toLowerCase();
|
||||
const providerId = provider.trim().toLowerCase();
|
||||
if (!raw) return;
|
||||
out.add(raw);
|
||||
if (!providerId) return;
|
||||
|
||||
const prefix = `${providerId}/`;
|
||||
if (raw.startsWith(prefix)) {
|
||||
const stripped = raw.slice(prefix.length);
|
||||
if (stripped) out.add(stripped);
|
||||
} else {
|
||||
out.add(`${providerId}/${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function collectHiddenQuotaModelIds(provider: string, payload: unknown): string[] {
|
||||
const hidden = new Set<string>();
|
||||
const data = toRecord(payload);
|
||||
const collect = (entries: unknown) => {
|
||||
if (!Array.isArray(entries)) return;
|
||||
for (const entry of entries) {
|
||||
const record = toRecord(entry);
|
||||
if (record.isHidden !== true && record.isDeleted !== true) continue;
|
||||
if (typeof record.id === "string") addQuotaModelIdVariants(hidden, provider, record.id);
|
||||
}
|
||||
};
|
||||
|
||||
collect(data.models);
|
||||
collect(data.modelCompatOverrides);
|
||||
return Array.from(hidden);
|
||||
}
|
||||
|
||||
export function filterHiddenModelQuotas(
|
||||
provider: string,
|
||||
quotas: any[] | undefined,
|
||||
hiddenModelIds: string[] | undefined
|
||||
): any[] {
|
||||
if (!Array.isArray(quotas)) return [];
|
||||
if (!hiddenModelIds || hiddenModelIds.length === 0) return quotas;
|
||||
|
||||
const hidden = new Set(
|
||||
hiddenModelIds.map((id) => id.trim().toLowerCase()).filter((id) => id.length > 0)
|
||||
);
|
||||
if (hidden.size === 0) return quotas;
|
||||
|
||||
return quotas.filter((quota) => {
|
||||
if (!quota || quota.isCredits) return true;
|
||||
const modelId =
|
||||
typeof quota.modelKey === "string"
|
||||
? quota.modelKey
|
||||
: typeof quota.modelId === "string"
|
||||
? quota.modelId
|
||||
: "";
|
||||
if (!modelId) return true;
|
||||
|
||||
const candidates = new Set<string>();
|
||||
addQuotaModelIdVariants(candidates, provider, modelId);
|
||||
return !Array.from(candidates).some((candidate) => hidden.has(candidate));
|
||||
});
|
||||
}
|
||||
|
||||
// --- Provider dropdown filter (PR #769 port) -----------------------------
|
||||
// Pure helpers extracted from <ProviderLimits/> so the filter+dropdown logic
|
||||
// can be exercised by unit tests without rendering React. Keep them free of
|
||||
|
||||
@@ -209,6 +209,13 @@ export function sanitizeProviderSpecificDataForResponse(value: unknown): JsonRec
|
||||
delete sanitized.awsSecretAccessKey;
|
||||
delete sanitized.sessionToken;
|
||||
delete sanitized.awsSessionToken;
|
||||
delete sanitized.openCodeGoAuthCookie;
|
||||
delete sanitized.opencodeGoAuthCookie;
|
||||
delete sanitized.authCookie;
|
||||
delete sanitized.ollamaUsageCookie;
|
||||
delete sanitized.ollamaCloudUsageCookie;
|
||||
delete sanitized.ollamaCloudCookie;
|
||||
delete sanitized.usageCookie;
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,6 @@ import { isUserCallableAgyModelId } from "@omniroute/open-sse/config/agyModels.t
|
||||
import { onUsageRecorded } from "./usageEvents";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
type SyncSource = "manual" | "scheduled";
|
||||
|
||||
interface ProviderConnectionLike {
|
||||
@@ -64,6 +63,7 @@ const PROVIDER_LIMITS_APIKEY_PROVIDERS = new Set([
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"ollama-cloud",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// Provider definitions
|
||||
|
||||
/**
|
||||
* Service kind — declarative tag for what a provider can do beyond basic LLM chat.
|
||||
* Affects UI filtering and playground routing; does not influence request routing.
|
||||
@@ -3229,6 +3227,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [
|
||||
"zai",
|
||||
"glmt",
|
||||
"opencode-go",
|
||||
"ollama-cloud",
|
||||
"minimax",
|
||||
"minimax-cn",
|
||||
"crof",
|
||||
|
||||
@@ -179,6 +179,50 @@ export function validateProviderSpecificData(
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ["openCodeGoWorkspaceId", "opencodeGoWorkspaceId", "workspaceId"] as const) {
|
||||
const value = data[key];
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be a string`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
if (typeof value === "string" && value.length > 1000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be at most 1000 characters`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
"openCodeGoAuthCookie",
|
||||
"opencodeGoAuthCookie",
|
||||
"authCookie",
|
||||
"ollamaUsageCookie",
|
||||
"ollamaCloudUsageCookie",
|
||||
"ollamaCloudCookie",
|
||||
"usageCookie",
|
||||
] as const) {
|
||||
const value = data[key];
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be a string`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
if (typeof value === "string" && value.length > 10000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be at most 10000 characters`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const groupTag = data.tag;
|
||||
if (
|
||||
groupTag !== undefined &&
|
||||
|
||||
@@ -18,7 +18,14 @@ import {
|
||||
} from "@/shared/constants/upstreamHeaders";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@/shared/utils/runtimeTimeouts";
|
||||
|
||||
import { isHttpUrl, CODEX_REASONING_EFFORT_VALUES, REQUEST_DEFAULT_SERVICE_TIER_VALUES, upstreamHeadersRecordSchema, modelCompatPerProtocolSchema, customHeadersSchema } from "./misc.ts";
|
||||
import {
|
||||
isHttpUrl,
|
||||
CODEX_REASONING_EFFORT_VALUES,
|
||||
REQUEST_DEFAULT_SERVICE_TIER_VALUES,
|
||||
upstreamHeadersRecordSchema,
|
||||
modelCompatPerProtocolSchema,
|
||||
customHeadersSchema,
|
||||
} from "./misc.ts";
|
||||
|
||||
export function validateProviderSpecificData(
|
||||
data: Record<string, unknown> | undefined,
|
||||
@@ -184,6 +191,50 @@ export function validateProviderSpecificData(
|
||||
});
|
||||
}
|
||||
|
||||
for (const key of ["openCodeGoWorkspaceId", "opencodeGoWorkspaceId", "workspaceId"] as const) {
|
||||
const value = data[key];
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be a string`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
if (typeof value === "string" && value.length > 1000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be at most 1000 characters`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of [
|
||||
"openCodeGoAuthCookie",
|
||||
"opencodeGoAuthCookie",
|
||||
"authCookie",
|
||||
"ollamaUsageCookie",
|
||||
"ollamaCloudUsageCookie",
|
||||
"ollamaCloudCookie",
|
||||
"usageCookie",
|
||||
] as const) {
|
||||
const value = data[key];
|
||||
if (value !== undefined && value !== null && typeof value !== "string") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be a string`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
if (typeof value === "string" && value.length > 10000) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `providerSpecificData.${key} must be at most 10000 characters`,
|
||||
path: [key],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const groupTag = data.tag;
|
||||
if (
|
||||
groupTag !== undefined &&
|
||||
@@ -361,7 +412,10 @@ export const bulkWebSessionImportSchema = z.object({
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
credential: z.string().min(1).max(64 * 1024, "Credential must be under 64 KB"),
|
||||
credential: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64 * 1024, "Credential must be under 64 KB"),
|
||||
})
|
||||
)
|
||||
.min(1, "entries must contain at least 1 item")
|
||||
@@ -632,4 +686,4 @@ export const validateProviderApiKeySchema = z
|
||||
path: ["cx"],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ describe("maskKey", () => {
|
||||
assert.equal(maskKey("sk-12345"), "sk-12345");
|
||||
});
|
||||
|
||||
it("does not double-mask API keys that are already masked by the server", () => {
|
||||
assert.equal(maskKey("sk-live-****1002"), "sk-live-****1002");
|
||||
});
|
||||
|
||||
it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => {
|
||||
const full = "sk-or-1234567890abcdef";
|
||||
const masked = maskKey(full);
|
||||
|
||||
@@ -274,11 +274,10 @@ async function resetStorage() {
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 10s ceiling: on 2-core CI runners under shard contention the 1500ms budget
|
||||
// expired mid-flight (observed: 1580ms fail on the upstream-timeout test) —
|
||||
// green runs return as soon as the condition holds, so the ceiling only
|
||||
// bounds the failure case.
|
||||
async function waitFor(fn, timeoutMs = 10000) {
|
||||
// 30s ceiling: c8 instrumentation plus --test-concurrency=8 can stall CI workers
|
||||
// well past the upstream timeout budget. Green runs return as soon as the condition
|
||||
// holds, so the ceiling only bounds the failure case.
|
||||
async function waitFor(fn, timeoutMs = 30000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await fn();
|
||||
|
||||
169
tests/unit/ollama-cloud-usage.test.ts
Normal file
169
tests/unit/ollama-cloud-usage.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const usage = await import("../../open-sse/services/usage.ts");
|
||||
const { USAGE_SUPPORTED_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
|
||||
test("USAGE_SUPPORTED_PROVIDERS includes ollama-cloud", () => {
|
||||
assert.ok(
|
||||
(USAGE_SUPPORTED_PROVIDERS as string[]).includes("ollama-cloud"),
|
||||
"ollama-cloud must be in the usage-supported providers allowlist"
|
||||
);
|
||||
});
|
||||
|
||||
test("getUsageForProvider returns helpful message when Ollama Cloud has no usage cookie", async () => {
|
||||
const originalCookie = process.env.OLLAMA_USAGE_COOKIE;
|
||||
const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
|
||||
let called = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
called = true;
|
||||
return new Response("unexpected", { status: 500 });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "ollama-cloud-no-cookie",
|
||||
provider: "ollama-cloud",
|
||||
apiKey: "ollama-chat-key",
|
||||
})) as { message?: string };
|
||||
|
||||
assert.equal(called, false, "settings scrape must not run without a cookie");
|
||||
assert.match(result.message ?? "", /Ollama Cloud/);
|
||||
assert.match(result.message ?? "", /OLLAMA_USAGE_COOKIE/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
|
||||
if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider scrapes Ollama Cloud settings quota", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCookie = process.env.OLLAMA_USAGE_COOKIE;
|
||||
const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "__Secure-session=test-cookie";
|
||||
|
||||
let requestUrl = "";
|
||||
let requestHeaders: Headers | null = null;
|
||||
let redirectMode: RequestRedirect | undefined;
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requestUrl = String(input);
|
||||
requestHeaders = new Headers(init?.headers as HeadersInit | undefined);
|
||||
redirectMode = init?.redirect;
|
||||
return new Response(
|
||||
[
|
||||
'<span class="capitalize">pro</span>',
|
||||
'<div data-usage-track aria-label="34% used" style="width: 34%"></div>',
|
||||
'<span class="local-time" data-time="2026-06-22T15:00:00.000Z"></span>',
|
||||
'<div data-usage-track style="width: 67%"></div>',
|
||||
'<span class="local-time" data-time="2026-06-29T15:00:00.000Z"></span>',
|
||||
].join(""),
|
||||
{ status: 200, headers: { "content-type": "text/html" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "ollama-cloud-settings",
|
||||
provider: "ollama-cloud",
|
||||
apiKey: "ollama-chat-key",
|
||||
})) as {
|
||||
plan?: string | null;
|
||||
quotas?: Record<string, { used: number; total: number; remainingPercentage: number }>;
|
||||
};
|
||||
|
||||
assert.equal(requestUrl, "https://ollama.com/settings");
|
||||
assert.equal(requestHeaders?.get("Cookie"), "__Secure-session=test-cookie");
|
||||
assert.equal(redirectMode, "manual");
|
||||
assert.equal(result.plan, "Ollama Cloud pro");
|
||||
assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly"]);
|
||||
assert.equal(result.quotas!.session.used, 34);
|
||||
assert.equal(result.quotas!.session.remainingPercentage, 66);
|
||||
assert.equal(result.quotas!.weekly.used, 67);
|
||||
assert.equal(result.quotas!.weekly.remainingPercentage, 33);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
|
||||
if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider keeps Ollama Cloud reset times aligned to usage tracks", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCookie = process.env.OLLAMA_USAGE_COOKIE;
|
||||
const originalOmniCookie = process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = "test-cookie";
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
[
|
||||
'<span class="local-time" data-time="2026-01-01T00:00:00.000Z"></span>',
|
||||
'<div data-usage-track aria-label="34% used" style="width: 1%">',
|
||||
'<span class="local-time" data-time="2026-06-22T15:00:00.000Z"></span>',
|
||||
"</div>",
|
||||
'<div data-usage-track style="width: 67%">',
|
||||
'<span style="width: 1%"></span>',
|
||||
'<span class="local-time" data-time="2026-06-29T15:00:00.000Z"></span>',
|
||||
"</div>",
|
||||
].join(""),
|
||||
{ status: 200, headers: { "content-type": "text/html" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "ollama-cloud-aligned-times",
|
||||
provider: "ollama-cloud",
|
||||
apiKey: "ollama-chat-key",
|
||||
})) as {
|
||||
quotas?: Record<string, { used: number; resetAt: string | null }>;
|
||||
};
|
||||
|
||||
assert.equal(result.quotas!.session.used, 34);
|
||||
assert.equal(result.quotas!.session.resetAt, "2026-06-22T15:00:00.000Z");
|
||||
assert.equal(result.quotas!.weekly.used, 67);
|
||||
assert.equal(result.quotas!.weekly.resetAt, "2026-06-29T15:00:00.000Z");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
|
||||
if (originalOmniCookie === undefined) delete process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OMNIROUTE_OLLAMA_USAGE_COOKIE = originalOmniCookie;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider reports expired Ollama Cloud cookies on redirect", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalCookie = process.env.OLLAMA_USAGE_COOKIE;
|
||||
process.env.OLLAMA_USAGE_COOKIE = "expired-cookie";
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("", {
|
||||
status: 302,
|
||||
headers: { location: "/signin" },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "ollama-cloud-redirect",
|
||||
provider: "ollama-cloud",
|
||||
apiKey: "ollama-chat-key",
|
||||
})) as { message?: string };
|
||||
|
||||
assert.match(result.message ?? "", /authentication expired/i);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalCookie === undefined) delete process.env.OLLAMA_USAGE_COOKIE;
|
||||
else process.env.OLLAMA_USAGE_COOKIE = originalCookie;
|
||||
}
|
||||
});
|
||||
@@ -28,6 +28,7 @@ test("getUsageForProvider returns helpful message when opencode-go has no apiKey
|
||||
|
||||
assert.equal(called, false, "quota fetch must not run without an API key");
|
||||
assert.match(result.message ?? "", /OpenCode Go/);
|
||||
assert.match(result.message ?? "", /OPENCODE_GO_WORKSPACE_ID/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -104,7 +105,7 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a
|
||||
};
|
||||
|
||||
assert.equal(requestUrl, "https://api.z.ai/api/monitor/usage/quota/limit");
|
||||
assert.equal(requestHeaders?.get("Authorization"), "opencode-go-key");
|
||||
assert.equal(requestHeaders?.get("Authorization"), "Bearer opencode-go-key");
|
||||
assert.equal(requestHeaders?.get("Content-Type"), "application/json");
|
||||
assert.equal(result.plan, "OpenCode Go Pro");
|
||||
assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]);
|
||||
@@ -136,6 +137,107 @@ test("getUsageForProvider exposes OpenCode Go 5h, weekly, and monthly quotas", a
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider ignores out-of-range OpenCode Go reset timestamps", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 200,
|
||||
success: true,
|
||||
data: {
|
||||
level: "pro",
|
||||
limits: [
|
||||
{
|
||||
type: "TOKENS_LIMIT",
|
||||
unit: 3,
|
||||
number: 5,
|
||||
percentage: 25,
|
||||
nextResetTime: Number.MAX_VALUE,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "opencode-go-huge-reset",
|
||||
provider: "opencode-go",
|
||||
apiKey: "opencode-go-key",
|
||||
})) as { quotas?: Record<string, { resetAt: string | null }> };
|
||||
|
||||
assert.equal(result.quotas!.session.resetAt, null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider scrapes OpenCode Go dashboard quota when workspace cookie is configured", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWorkspace = process.env.OPENCODE_GO_WORKSPACE_ID;
|
||||
const originalCookie = process.env.OPENCODE_GO_AUTH_COOKIE;
|
||||
let requestUrl = "";
|
||||
let requestHeaders: Headers | null = null;
|
||||
|
||||
process.env.OPENCODE_GO_WORKSPACE_ID = "workspace-123";
|
||||
process.env.OPENCODE_GO_AUTH_COOKIE = "auth-cookie-value";
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requestUrl = String(input);
|
||||
requestHeaders = new Headers(init?.headers as HeadersInit | undefined);
|
||||
return new Response(
|
||||
[
|
||||
'<div data-slot="usage-item">',
|
||||
'<span data-slot="usage-label">Rolling Usage</span>',
|
||||
'<span data-slot="usage-value">25%</span>',
|
||||
'<span data-slot="reset-time">Resets in 1 hour 30 minutes</span>',
|
||||
"</div>",
|
||||
'<div data-slot="usage-item">',
|
||||
'<span data-slot="usage-label">Weekly Usage</span>',
|
||||
'<span data-slot="usage-value">50%</span>',
|
||||
'<span data-slot="reset-time">Resets in 2 days</span>',
|
||||
"</div>",
|
||||
'<div data-slot="usage-item">',
|
||||
'<span data-slot="usage-label">Monthly Usage</span>',
|
||||
'<span data-slot="usage-value">10%</span>',
|
||||
'<span data-slot="reset-time">Resets in 10 days</span>',
|
||||
"</div>",
|
||||
].join(""),
|
||||
{ status: 200, headers: { "content-type": "text/html" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "opencode-go-dashboard",
|
||||
provider: "opencode-go",
|
||||
apiKey: "opencode-go-key",
|
||||
})) as {
|
||||
plan?: string | null;
|
||||
quotas?: Record<string, { used: number; total: number; remainingPercentage: number }>;
|
||||
};
|
||||
|
||||
assert.equal(requestUrl, "https://opencode.ai/workspace/workspace-123/go");
|
||||
assert.equal(requestHeaders?.get("Cookie"), "auth=auth-cookie-value");
|
||||
assert.equal(result.plan, "OpenCode Go");
|
||||
assert.deepEqual(Object.keys(result.quotas ?? {}), ["session", "weekly", "mcp_monthly"]);
|
||||
assert.equal(result.quotas!.session.used, 3);
|
||||
assert.equal(result.quotas!.session.total, 12);
|
||||
assert.equal(result.quotas!.session.remainingPercentage, 75);
|
||||
assert.equal(result.quotas!.weekly.used, 15);
|
||||
assert.equal(result.quotas!.weekly.remainingPercentage, 50);
|
||||
assert.equal(result.quotas!.mcp_monthly.used, 6);
|
||||
assert.equal(result.quotas!.mcp_monthly.remainingPercentage, 90);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (originalWorkspace === undefined) delete process.env.OPENCODE_GO_WORKSPACE_ID;
|
||||
else process.env.OPENCODE_GO_WORKSPACE_ID = originalWorkspace;
|
||||
if (originalCookie === undefined) delete process.env.OPENCODE_GO_AUTH_COOKIE;
|
||||
else process.env.OPENCODE_GO_AUTH_COOKIE = originalCookie;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider returns message for invalid OpenCode Go API keys", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("nope", { status: 401 });
|
||||
@@ -148,15 +250,34 @@ test("getUsageForProvider returns message for invalid OpenCode Go API keys", asy
|
||||
})) as { message: string };
|
||||
assert.equal(
|
||||
result.message,
|
||||
"OpenCode Go does not expose a public quota API. Chat requests still work. " +
|
||||
"Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " +
|
||||
"https://github.com/anomalyco/opencode/issues/16017 for upstream status."
|
||||
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
|
||||
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping."
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider returns message when OpenCode Go quota fetch fails", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("network offline");
|
||||
};
|
||||
|
||||
try {
|
||||
const result = (await usage.getUsageForProvider({
|
||||
id: "opencode-go-network-error",
|
||||
provider: "opencode-go",
|
||||
apiKey: "opencode-go-key",
|
||||
})) as { message: string };
|
||||
|
||||
assert.match(result.message, /OpenCode Go quota API error:/);
|
||||
assert.match(result.message, /network offline/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("getUsageForProvider returns message when OpenCode Go quota API returns 200 with auth error in body", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
@@ -173,9 +294,8 @@ test("getUsageForProvider returns message when OpenCode Go quota API returns 200
|
||||
})) as { message: string };
|
||||
assert.equal(
|
||||
result.message,
|
||||
"OpenCode Go does not expose a public quota API. Chat requests still work. " +
|
||||
"Set OMNIROUTE_OPENCODE_GO_QUOTA_URL to a working endpoint, or follow " +
|
||||
"https://github.com/anomalyco/opencode/issues/16017 for upstream status."
|
||||
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
|
||||
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping."
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
|
||||
@@ -205,6 +205,41 @@ test("GLM quota rows are ordered by session, weekly, then monthly", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden provider models are filtered from per-model quota rows", () => {
|
||||
const quotas = providerLimitUtils.parseQuotaData("antigravity", {
|
||||
quotas: {
|
||||
"gpt-oss-120b-medium": { used: 2, total: 100, remainingPercentage: 98 },
|
||||
"gemini-3.5-pro": { used: 10, total: 100, remainingPercentage: 90 },
|
||||
credits: { remaining: 42 },
|
||||
},
|
||||
});
|
||||
const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", {
|
||||
models: [{ id: "antigravity/gpt-oss-120b-medium", isHidden: true }],
|
||||
modelCompatOverrides: [{ id: "gemini-3.5-flash", isDeleted: true }],
|
||||
});
|
||||
const visible = providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden);
|
||||
|
||||
assert.deepEqual(
|
||||
visible.map((quota) => quota.modelKey || quota.name),
|
||||
["gemini-3.5-pro", "credits"]
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden quota filtering keeps non-model provider quota rows", () => {
|
||||
const quotas = [
|
||||
{ name: "weekly", used: 2, total: 100 },
|
||||
{ name: "credits", isCredits: true, remaining: 10 },
|
||||
];
|
||||
const hidden = providerLimitUtils.collectHiddenQuotaModelIds("antigravity", {
|
||||
modelCompatOverrides: [{ id: "weekly", isHidden: true }],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
providerLimitUtils.filterHiddenModelQuotas("antigravity", quotas, hidden),
|
||||
quotas
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard i18n keys used by OrFallback helpers exist in en.json", () => {
|
||||
const enPath = path.resolve("src/i18n/messages/en.json");
|
||||
const messages = JSON.parse(readFileSync(enPath, "utf8"));
|
||||
|
||||
@@ -175,3 +175,43 @@ test("provider schemas reject oversized OpenRouter preset values", () => {
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept quota scraping provider-specific strings", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "opencode-go",
|
||||
apiKey: "token",
|
||||
name: "OpenCode Go",
|
||||
providerSpecificData: {
|
||||
opencodeGoWorkspaceId: "workspace-123",
|
||||
opencodeGoAuthCookie: "auth=cookie-value",
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
ollamaCloudUsageCookie: "__Secure-session=cookie-value",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, true);
|
||||
assert.equal(updated.success, true);
|
||||
});
|
||||
|
||||
test("provider schemas reject malformed quota scraping provider-specific values", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "opencode-go",
|
||||
apiKey: "token",
|
||||
name: "OpenCode Go",
|
||||
providerSpecificData: {
|
||||
opencodeGoWorkspaceId: 123,
|
||||
opencodeGoAuthCookie: "x".repeat(10001),
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
ollamaCloudUsageCookie: 123,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
ensureOpenAIStoreSessionFallback,
|
||||
getClaudeCodeCompatibleRequestDefaults,
|
||||
normalizeProviderSpecificData,
|
||||
sanitizeProviderSpecificDataForResponse,
|
||||
} = await import("../../src/lib/providers/requestDefaults.ts");
|
||||
|
||||
test("buildOpenAIStoreSessionId normalizes external and generated session ids", () => {
|
||||
@@ -108,3 +109,19 @@ test("normalizeProviderSpecificData trims OpenRouter preset and clears empty val
|
||||
assert.equal(ignored?.preset, undefined);
|
||||
assert.equal(ignored?.tag, "primary");
|
||||
});
|
||||
|
||||
test("sanitizeProviderSpecificDataForResponse removes quota scraping cookies", () => {
|
||||
const sanitized = sanitizeProviderSpecificDataForResponse({
|
||||
opencodeGoWorkspaceId: "workspace-123",
|
||||
opencodeGoAuthCookie: "auth-cookie",
|
||||
ollamaCloudUsageCookie: "ollama-cookie",
|
||||
usageCookie: "fallback-cookie",
|
||||
consoleApiKey: "console-key",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.deepEqual(sanitized, {
|
||||
opencodeGoWorkspaceId: "workspace-123",
|
||||
tag: "primary",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,9 +8,8 @@ vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: AddApiKeyModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal"
|
||||
);
|
||||
const { default: AddApiKeyModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal");
|
||||
|
||||
const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]';
|
||||
|
||||
@@ -35,10 +34,7 @@ function render(props: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value"
|
||||
)!.set!;
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
@@ -108,3 +104,58 @@ describe("AddApiKeyModal — import only free models", () => {
|
||||
expect(payload.providerSpecificData?.importFreeModelsOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AddApiKeyModal — quota scraping fields", () => {
|
||||
it("saves OpenCode Go workspace and auth cookie in providerSpecificData", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({ provider: "opencode-go", providerName: "OpenCode Go", onSave });
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
const workspaceInput = el.querySelector<HTMLInputElement>(
|
||||
'input[name="opencodeGoWorkspaceId"]'
|
||||
)!;
|
||||
const cookieInput = el.querySelector<HTMLInputElement>('input[name="opencodeGoAuthCookie"]')!;
|
||||
setInputValue(nameInput, "OpenCode Go");
|
||||
setInputValue(apiKeyInput, "sk-opencode-go-test");
|
||||
setInputValue(workspaceInput, "workspace-123");
|
||||
setInputValue(cookieInput, "auth=opencode-cookie");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-123");
|
||||
expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie");
|
||||
});
|
||||
|
||||
it("saves Ollama Cloud usage cookie in providerSpecificData", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({ provider: "ollama-cloud", providerName: "Ollama Cloud", onSave });
|
||||
|
||||
const nameInput = el.querySelector<HTMLInputElement>('input[placeholder="productionKey"]')!;
|
||||
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
|
||||
const cookieInput = el.querySelector<HTMLInputElement>('input[name="ollamaCloudUsageCookie"]')!;
|
||||
setInputValue(nameInput, "Ollama Cloud");
|
||||
setInputValue(apiKeyInput, "ollama-key");
|
||||
setInputValue(cookieInput, "__Secure-session=ollama-cookie");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.ollamaCloudUsageCookie).toBe(
|
||||
"__Secure-session=ollama-cookie"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,9 +11,8 @@ vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: EditConnectionModal } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal"
|
||||
);
|
||||
const { default: EditConnectionModal } =
|
||||
await import("../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal");
|
||||
|
||||
const FREE_TOGGLE = 'button[role="switch"][aria-label="importFreeModelsOnlyLabel"]';
|
||||
|
||||
@@ -38,6 +37,14 @@ function render(props: Record<string, unknown>) {
|
||||
return el;
|
||||
}
|
||||
|
||||
function setInputValue(input: HTMLInputElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
|
||||
act(() => {
|
||||
setter.call(input, value);
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(fn: () => boolean, timeoutMs = 2000) {
|
||||
const start = Date.now();
|
||||
while (!fn()) {
|
||||
@@ -51,7 +58,9 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(() => Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response))
|
||||
vi.fn(() =>
|
||||
Promise.resolve({ ok: true, json: async () => ({}), text: async () => "" } as Response)
|
||||
)
|
||||
);
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: () => null,
|
||||
@@ -159,3 +168,68 @@ describe("EditConnectionModal — import only free models", () => {
|
||||
expect(onResyncModels).toHaveBeenCalledWith("conn-4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EditConnectionModal — quota scraping fields", () => {
|
||||
it("saves OpenCode Go workspace and replacement auth cookie", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "opencode-go",
|
||||
connection: {
|
||||
id: "conn-opencode-go",
|
||||
provider: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
authType: "apikey",
|
||||
providerSpecificData: { workspaceId: "workspace-existing" },
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
|
||||
const workspaceInput = el.querySelector<HTMLInputElement>(
|
||||
'input[name="opencodeGoWorkspaceId"]'
|
||||
)!;
|
||||
const cookieInput = el.querySelector<HTMLInputElement>('input[name="opencodeGoAuthCookie"]')!;
|
||||
expect(workspaceInput.value).toBe("workspace-existing");
|
||||
setInputValue(workspaceInput, "workspace-updated");
|
||||
setInputValue(cookieInput, "auth=opencode-cookie");
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect(payload.providerSpecificData?.opencodeGoWorkspaceId).toBe("workspace-updated");
|
||||
expect(payload.providerSpecificData?.opencodeGoAuthCookie).toBe("auth=opencode-cookie");
|
||||
});
|
||||
|
||||
it("omits Ollama Cloud usage cookie when the edit field is left blank", async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
const el = render({
|
||||
providerId: "ollama-cloud",
|
||||
connection: {
|
||||
id: "conn-ollama-cloud",
|
||||
provider: "ollama-cloud",
|
||||
name: "Ollama Cloud",
|
||||
authType: "apikey",
|
||||
providerSpecificData: {},
|
||||
},
|
||||
onSave,
|
||||
});
|
||||
|
||||
expect(el.querySelector<HTMLInputElement>('input[name="ollamaCloudUsageCookie"]')).toBeTruthy();
|
||||
|
||||
const saveBtn = Array.from(el.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "save"
|
||||
)!;
|
||||
act(() => {
|
||||
saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
await waitFor(() => onSave.mock.calls.length > 0);
|
||||
const payload = onSave.mock.calls[0][0];
|
||||
expect("ollamaCloudUsageCookie" in payload.providerSpecificData).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user