From 4e9d4f4eb7fe79a4b338b3e102626070f3a41c5c Mon Sep 17 00:00:00 2001 From: Jan Leon Date: Mon, 22 Jun 2026 23:40:10 +0200 Subject: [PATCH] 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) --- .env.example | 16 + docs/reference/ENVIRONMENT.md | 9 + open-sse/services/opencodeOllamaUsage.ts | 558 ++++++++++++++++++ open-sse/services/usage.ts | 209 +------ scripts/check/check-public-creds.mjs | 6 +- .../api-manager/apiManagerPageUtils.ts | 1 + .../[id]/components/modals/AddApiKeyModal.tsx | 62 +- .../components/modals/EditConnectionModal.tsx | 73 ++- .../components/modals/QuotaScrapingFields.tsx | 113 ++++ .../modals/connectionProviderSpecificData.ts | 125 ++++ .../components/ProviderLimits/QuotaCard.tsx | 1 + .../components/ProviderLimits/formatters.ts | 6 + .../usage/components/ProviderLimits/index.tsx | 38 +- .../parts/QuotaCardExpanded.tsx | 6 + .../components/ProviderLimits/quotaParsing.ts | 189 ++++++ .../ProviderLimits/useVisibleQuotaData.ts | 66 +++ .../usage/components/ProviderLimits/utils.tsx | 494 +++++----------- src/lib/providers/requestDefaults.ts | 7 + src/lib/usage/providerLimits.ts | 2 +- src/shared/constants/providers.ts | 3 +- src/shared/validation/providerSpecificData.ts | 44 ++ src/shared/validation/schemas/provider.ts | 60 +- tests/unit/api-manager-key-visibility.test.ts | 4 + tests/unit/chatcore-translation-paths.test.ts | 9 +- tests/unit/ollama-cloud-usage.test.ts | 169 ++++++ tests/unit/opencode-go-usage.test.ts | 134 ++++- tests/unit/provider-limits-ui.test.ts | 35 ++ .../provider-specific-data-schema.test.ts | 40 ++ .../request-defaults-store-session.test.ts | 17 + .../ui/add-api-key-modal-free-models.test.tsx | 65 +- ...edit-connection-modal-free-models.test.tsx | 82 ++- 31 files changed, 1955 insertions(+), 688 deletions(-) create mode 100644 open-sse/services/opencodeOllamaUsage.ts create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts create mode 100644 src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts create mode 100644 tests/unit/ollama-cloud-usage.test.ts diff --git a/.env.example b/.env.example index bec17f344c..919db23428 100644 --- a/.env.example +++ b/.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) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c8d791b781..ce1f812143 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -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] diff --git a/open-sse/services/opencodeOllamaUsage.ts b/open-sse/services/opencodeOllamaUsage.ts new file mode 100644 index 0000000000..45c05437cd --- /dev/null +++ b/open-sse/services/opencodeOllamaUsage.ts @@ -0,0 +1,558 @@ +import { sanitizeErrorMessage } from "../utils/error.ts"; + +type JsonRecord = Record; +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>; +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 +): "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): Record { + const ordered: Record = {}; + 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">([^<]+)[^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 +) { + 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 = {}; + 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 = {}; + 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[^"]*"[^>]*>([^<]*) +) { + 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 = {}; + 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)}` }; + } +} diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 6db2f04ea3..edd14b8a09 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -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 -): "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): Record { - const ordered: Record = {}; - - 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).code, 200); - if (code === 401 || code === 403 || (json as Record).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).data); - const limits: unknown[] = Array.isArray(data.limits) ? data.limits : []; - const quotas: Record = {}; - - 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: {}, diff --git a/scripts/check/check-public-creds.mjs b/scripts/check/check-public-creds.mjs index b6cc521db6..f73c4a296c 100644 --- a/scripts/check/check-public-creds.mjs +++ b/scripts/check/check-public-creds.mjs @@ -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) ]); /** diff --git a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts index 7530b48d11..39474592f0 100644 --- a/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts +++ b/src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.ts @@ -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; } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 1e44881b18..7f0447d4db 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -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 = {}; - 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({ )} {freeModelsToggle} + setFormData({ ...formData, ...patch })} + t={t} + /> {isCompatible && !isCcCompatible && (

{isAnthropic diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx index 775373c084..0874a9bfcc 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx @@ -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")} /> + setFormData({ ...formData, ...patch })} + t={t} + editMode + /> {supportsGoogleProjectId && (

{isAntigravity && ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx new file mode 100644 index 0000000000..4ba800708f --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/QuotaScrapingFields.tsx @@ -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 +) { + 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) => void; + t: ProviderMessageTranslator; + editMode?: boolean; +}; + +export default function QuotaScrapingFields({ + provider, + values, + onChange, + t, + editMode = false, +}: QuotaScrapingFieldsProps) { + if (provider === "opencode-go") { + return ( +
+ 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} + /> + 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" + /> +
+ ); + } + + if (provider === "ollama-cloud") { + return ( +
+ 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" + /> +
+ ); + } + + return null; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts new file mode 100644 index 0000000000..69abfc2f4b --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/connectionProviderSpecificData.ts @@ -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; + +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 + ); + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx index c09bb79c18..9c42bb0647 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/QuotaCard.tsx @@ -91,6 +91,7 @@ export default function QuotaCard({ quotas={quotas} loading={loading} error={error} + message={quota?.message ?? null} refreshedAt={displayRefreshedAt} hasStaleData={hasStaleData} onRefresh={onRefresh} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts new file mode 100644 index 0000000000..d5eaf86caa --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/formatters.ts @@ -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")}`; +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx index fcd9f47316..7176942e8a 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx @@ -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 = { antigravity: "Antigravity", "gemini-cli": "Gemini CLI", @@ -46,6 +47,7 @@ const PROVIDER_LABEL: Record = { 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 = { 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 = { antigravity: 1, "gemini-cli": 2, @@ -66,10 +67,11 @@ const PROVIDER_ORDER: Record = { 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 = {}; @@ -613,10 +609,10 @@ export default function ProviderLimits({ const statusByConnection = useMemo(() => { const out: Record = {}; 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 = { @@ -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({ 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); diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx index 2b6c2511cf..47641ec896 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaCardExpanded.tsx @@ -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({ error {error}
+ ) : quotas.length === 0 && message ? ( +
+ {message} +
) : quotas.length === 0 ? (
{t("noQuotaData")}
) : ( diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts new file mode 100644 index 0000000000..dfed75ba20 --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/quotaParsing.ts @@ -0,0 +1,189 @@ +import { getModelsByProviderId } from "@omniroute/open-sse/config/providerModels.ts"; +import { safePercentage } from "@/shared/utils/formatting"; + +const GLM_QUOTA_ORDER: Record = { 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 []; + } +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts new file mode 100644 index 0000000000..ecf6b9618d --- /dev/null +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/useVisibleQuotaData.ts @@ -0,0 +1,66 @@ +import { useEffect, useMemo, useState } from "react"; + +import { collectHiddenQuotaModelIds, filterHiddenModelQuotas } from "./utils"; + +function getProviderKey(connections: any[]): string { + const providers = new Set(); + 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 +): Record { + const [hiddenModelsByProvider, setHiddenModelsByProvider] = useState>( + {} + ); + 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 = {}; + 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]); +} diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx index b8dec9cddb..e19f8b973b 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx @@ -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 = { time_limit: "Time Limit", }; -const GLM_QUOTA_ORDER: Record = { - session: 0, - weekly: 1, - mcp_monthly: 2, -}; - function toRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -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} 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, 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(); + 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(); + addQuotaModelIdVariants(candidates, provider, modelId); + return !Array.from(candidates).some((candidate) => hidden.has(candidate)); + }); +} + // --- Provider dropdown filter (PR #769 port) ----------------------------- // Pure helpers extracted from so the filter+dropdown logic // can be exercised by unit tests without rendering React. Keep them free of diff --git a/src/lib/providers/requestDefaults.ts b/src/lib/providers/requestDefaults.ts index 479d6f532f..a517484964 100644 --- a/src/lib/providers/requestDefaults.ts +++ b/src/lib/providers/requestDefaults.ts @@ -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; } diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index 80db7e6846..40898f7727 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -35,7 +35,6 @@ import { isUserCallableAgyModelId } from "@omniroute/open-sse/config/agyModels.t import { onUsageRecorded } from "./usageEvents"; type JsonRecord = Record; - 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", diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 2816ad6439..48d1af83bc 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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", diff --git a/src/shared/validation/providerSpecificData.ts b/src/shared/validation/providerSpecificData.ts index 3258d389e7..8a81208892 100644 --- a/src/shared/validation/providerSpecificData.ts +++ b/src/shared/validation/providerSpecificData.ts @@ -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 && diff --git a/src/shared/validation/schemas/provider.ts b/src/shared/validation/schemas/provider.ts index c716040611..c3817d93fb 100644 --- a/src/shared/validation/schemas/provider.ts +++ b/src/shared/validation/schemas/provider.ts @@ -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 | 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"], }); } - }); \ No newline at end of file + }); diff --git a/tests/unit/api-manager-key-visibility.test.ts b/tests/unit/api-manager-key-visibility.test.ts index 261426c3e0..ed8e5fea22 100644 --- a/tests/unit/api-manager-key-visibility.test.ts +++ b/tests/unit/api-manager-key-visibility.test.ts @@ -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); diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 4a9fa93b7c..263debfd60 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -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(); diff --git a/tests/unit/ollama-cloud-usage.test.ts b/tests/unit/ollama-cloud-usage.test.ts new file mode 100644 index 0000000000..9c54789911 --- /dev/null +++ b/tests/unit/ollama-cloud-usage.test.ts @@ -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( + [ + 'pro', + '
', + '', + '
', + '', + ].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; + }; + + 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( + [ + '', + '
', + '', + "
", + '
', + '', + '', + "
", + ].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; + }; + + 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; + } +}); diff --git a/tests/unit/opencode-go-usage.test.ts b/tests/unit/opencode-go-usage.test.ts index efabd38099..2488ada889 100644 --- a/tests/unit/opencode-go-usage.test.ts +++ b/tests/unit/opencode-go-usage.test.ts @@ -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 }; + + 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( + [ + '
', + 'Rolling Usage', + '25%', + 'Resets in 1 hour 30 minutes', + "
", + '
', + 'Weekly Usage', + '50%', + 'Resets in 2 days', + "
", + '
', + 'Monthly Usage', + '10%', + 'Resets in 10 days', + "
", + ].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; + }; + + 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; diff --git a/tests/unit/provider-limits-ui.test.ts b/tests/unit/provider-limits-ui.test.ts index fb9741fe72..240dc6918c 100644 --- a/tests/unit/provider-limits-ui.test.ts +++ b/tests/unit/provider-limits-ui.test.ts @@ -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")); diff --git a/tests/unit/provider-specific-data-schema.test.ts b/tests/unit/provider-specific-data-schema.test.ts index 6df8c155d8..f71f581d45 100644 --- a/tests/unit/provider-specific-data-schema.test.ts +++ b/tests/unit/provider-specific-data-schema.test.ts @@ -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); +}); diff --git a/tests/unit/request-defaults-store-session.test.ts b/tests/unit/request-defaults-store-session.test.ts index 19f9b1925d..23090810be 100644 --- a/tests/unit/request-defaults-store-session.test.ts +++ b/tests/unit/request-defaults-store-session.test.ts @@ -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", + }); +}); diff --git a/tests/unit/ui/add-api-key-modal-free-models.test.tsx b/tests/unit/ui/add-api-key-modal-free-models.test.tsx index e6082e7812..c366485a9e 100644 --- a/tests/unit/ui/add-api-key-modal-free-models.test.tsx +++ b/tests/unit/ui/add-api-key-modal-free-models.test.tsx @@ -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) { } 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('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const workspaceInput = el.querySelector( + 'input[name="opencodeGoWorkspaceId"]' + )!; + const cookieInput = el.querySelector('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('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const cookieInput = el.querySelector('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" + ); + }); +}); diff --git a/tests/unit/ui/edit-connection-modal-free-models.test.tsx b/tests/unit/ui/edit-connection-modal-free-models.test.tsx index 617a57d66e..757b0cff84 100644 --- a/tests/unit/ui/edit-connection-modal-free-models.test.tsx +++ b/tests/unit/ui/edit-connection-modal-free-models.test.tsx @@ -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) { 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( + 'input[name="opencodeGoWorkspaceId"]' + )!; + const cookieInput = el.querySelector('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('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); + }); +});