diff --git a/packages/ai/src/usage/alibaba-token-plan.ts b/packages/ai/src/usage/alibaba-token-plan.ts index 02ce4584b..746c48da4 100644 --- a/packages/ai/src/usage/alibaba-token-plan.ts +++ b/packages/ai/src/usage/alibaba-token-plan.ts @@ -1,3 +1,4 @@ +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan"; import type { CredentialRankingStrategy, @@ -8,7 +9,7 @@ import type { UsageReport, } from "../usage"; import { isRecord } from "../utils"; -import { toNumber } from "./shared"; +import { HOUR_MS, parsePositiveTimestamp, WEEK_MS } from "./shared"; const PROVIDER = "alibaba-token-plan"; const CONSOLE_ORIGIN = "https://home.qwencloud.com"; @@ -19,8 +20,6 @@ const USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage"; const USAGE_URL = `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=${GATEWAY_ACTION}&api=${encodeURIComponent(USAGE_API)}`; const BROWSER_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"; -const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const CONSOLE_CORNERSTONE_PARAM = { domain: "home.qwencloud.com", consoleSite: "QWENCLOUD", @@ -55,12 +54,6 @@ function unwrapGatewayData(value: Record): Record limit !== undefined); @@ -224,7 +217,7 @@ export const alibabaTokenPlanRankingStrategy: CredentialRankingStrategy = { secondary: report.limits.find(limit => limit.id === "credits:7d"), }), windowDefaults: { - primaryMs: FIVE_HOURS_MS, - secondaryMs: SEVEN_DAYS_MS, + primaryMs: 5 * HOUR_MS, + secondaryMs: WEEK_MS, }, }; diff --git a/packages/ai/src/usage/claude.ts b/packages/ai/src/usage/claude.ts index 5dfe09b6f..4957f3095 100644 --- a/packages/ai/src/usage/claude.ts +++ b/packages/ai/src/usage/claude.ts @@ -17,10 +17,9 @@ import { type UsageWindow, } from "../usage"; import { isRecord } from "../utils"; +import { HOUR_MS, parseIsoTimestamp, WEEK_MS } from "./shared"; const DEFAULT_ENDPOINT = "https://api.anthropic.com/api/oauth"; -const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; const MAX_ATTEMPTS = 3; const BASE_RETRY_DELAY_MS = 500; @@ -123,16 +122,10 @@ interface ParsedApiLimitEntry { displayName?: string; } -function parseIsoTime(value: string | undefined): number | undefined { - if (!value) return undefined; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - function parseBucket(bucket: unknown): ParsedUsageBucket | undefined { if (!isRecord(bucket)) return undefined; const utilization = toNumber(bucket.utilization); - const resetsAt = parseIsoTime(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined); + const resetsAt = parseIsoTimestamp(typeof bucket.resets_at === "string" ? bucket.resets_at : undefined); if (utilization === undefined && resetsAt === undefined) { return undefined; } @@ -168,7 +161,7 @@ function parseApiLimitEntries(raw: unknown): ParsedApiLimitEntry[] { const entry = rawEntry as ClaudeApiLimitEntry; if (typeof entry.kind !== "string") continue; const utilization = toNumber(entry.percent); - const resetsAt = parseIsoTime(typeof entry.resets_at === "string" ? entry.resets_at : undefined); + const resetsAt = parseIsoTimestamp(typeof entry.resets_at === "string" ? entry.resets_at : undefined); if (utilization === undefined && resetsAt === undefined) continue; const displayName = getApiLimitDisplayName(entry.scope); entries.push({ @@ -554,7 +547,7 @@ function buildScopedWeeklyUsageLimits(entries: readonly ParsedApiLimitEntry[]): label: `Claude 7 Day (${entry.displayName})`, windowId: "7d", windowLabel: "7 Day", - durationMs: SEVEN_DAYS_MS, + durationMs: WEEK_MS, bucket: entry.bucket, provider: "anthropic", tier: slug, @@ -574,7 +567,7 @@ export function parseClaudeRateLimitHeaders(headers: Record, now label: "Claude 5 Hour", windowId: "5h", windowLabel: "5 Hour", - durationMs: FIVE_HOURS_MS, + durationMs: 5 * HOUR_MS, bucket: fiveHour, provider: "anthropic", shared: true, @@ -584,7 +577,7 @@ export function parseClaudeRateLimitHeaders(headers: Record, now label: "Claude 7 Day", windowId: "7d", windowLabel: "7 Day", - durationMs: SEVEN_DAYS_MS, + durationMs: WEEK_MS, bucket: sevenDay, provider: "anthropic", shared: true, @@ -594,7 +587,7 @@ export function parseClaudeRateLimitHeaders(headers: Record, now label: "Claude 7 Day (Fable)", windowId: "7d", windowLabel: "7 Day", - durationMs: SEVEN_DAYS_MS, + durationMs: WEEK_MS, bucket: modelScopedSevenDay, provider: "anthropic", tier: "fable", @@ -638,7 +631,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext label: "Claude 5 Hour", windowId: "5h", windowLabel: "5 Hour", - durationMs: FIVE_HOURS_MS, + durationMs: 5 * HOUR_MS, bucket: fiveHour, provider: "anthropic", shared: true, @@ -648,7 +641,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext label: "Claude 7 Day", windowId: "7d", windowLabel: "7 Day", - durationMs: SEVEN_DAYS_MS, + durationMs: WEEK_MS, bucket: sevenDay, provider: "anthropic", shared: true, @@ -658,7 +651,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext label: "Claude 7 Day (Opus)", windowId: "7d", windowLabel: "7 Day", - durationMs: SEVEN_DAYS_MS, + durationMs: WEEK_MS, bucket: sevenDayOpus, provider: "anthropic", tier: "opus", @@ -668,7 +661,7 @@ async function fetchClaudeUsage(params: UsageFetchParams, ctx: UsageFetchContext label: "Claude 7 Day (Sonnet)", windowId: "7d", windowLabel: "7 Day", - durationMs: SEVEN_DAYS_MS, + durationMs: WEEK_MS, bucket: sevenDaySonnet, provider: "anthropic", tier: "sonnet", @@ -774,7 +767,7 @@ function rankingUsedFraction(limit: UsageLimit): number { function rankingDrainRate(limit: UsageLimit, nowMs: number): number { const usedFraction = rankingUsedFraction(limit); - const durationMs = limit.window?.durationMs ?? SEVEN_DAYS_MS; + const durationMs = limit.window?.durationMs ?? WEEK_MS; if (!Number.isFinite(durationMs) || durationMs <= 0) return usedFraction; const resetAt = limit.window?.resetsAt; if (typeof resetAt !== "number" || !Number.isFinite(resetAt)) return usedFraction; diff --git a/packages/ai/src/usage/cursor.ts b/packages/ai/src/usage/cursor.ts index eb24db28c..ba46bc76f 100644 --- a/packages/ai/src/usage/cursor.ts +++ b/packages/ai/src/usage/cursor.ts @@ -1,3 +1,4 @@ +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import { extractCursorAccessTokenUserId } from "../registry/oauth/cursor"; import type { UsageAmount, @@ -6,21 +7,15 @@ import type { UsageLimit, UsageProvider, UsageReport, - UsageStatus, UsageWindow, } from "../usage"; -import { toNumber } from "./shared"; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} +import { isRecord } from "../utils"; +import { parseIsoTimestamp, usageStatus } from "./shared"; function parseTimestamp(value: unknown): number | undefined { const numeric = toNumber(value); if (numeric !== undefined) return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; - if (typeof value !== "string" || !value.trim()) return undefined; - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; + return parseIsoTimestamp(value); } const DEFAULT_CURSOR_BASE_URL = "https://api2.cursor.sh"; @@ -78,13 +73,6 @@ function deriveResetsAt(payload: Record): number | undefined { return undefined; } -function resolveCursorStatus(usedFraction: number | undefined): UsageStatus { - if (usedFraction === undefined) return "unknown"; - if (usedFraction >= 1) return "exhausted"; - if (usedFraction >= 0.9) return "warning"; - return "ok"; -} - export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.now()): UsageReport | null { if (!isRecord(payload) || !isRecord(payload.individualUsage) || !isRecord(payload.individualUsage.overall)) { return null; @@ -140,7 +128,7 @@ export function parseCursorIndividualUsage(payload: unknown, fetchedAt = Date.no }, window, amount, - ...(amount.usedFraction !== undefined ? { status: resolveCursorStatus(amount.usedFraction) } : {}), + ...(amount.usedFraction !== undefined ? { status: usageStatus(amount.usedFraction) } : {}), }; return { provider: "cursor", @@ -197,7 +185,7 @@ export function parseCursorUsage(payload: unknown, fetchedAt = Date.now()): Usag unit, }; - const status = resolveCursorStatus(amount.usedFraction); + const status = usageStatus(amount.usedFraction); limits.push({ id: limitId, diff --git a/packages/ai/src/usage/gemini.ts b/packages/ai/src/usage/gemini.ts index b74265e6f..610c67c6c 100644 --- a/packages/ai/src/usage/gemini.ts +++ b/packages/ai/src/usage/gemini.ts @@ -8,6 +8,7 @@ import type { UsageReport, UsageWindow, } from "../usage"; +import { parseIsoTimestamp } from "./shared"; // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.) @@ -79,8 +80,8 @@ function parseWindow(resetTime: string | undefined): UsageWindow { label: "Quota window", }; } - const resetsAt = Date.parse(resetTime); - if (Number.isNaN(resetsAt)) { + const resetsAt = parseIsoTimestamp(resetTime); + if (resetsAt === undefined) { return { id: "quota", label: "Quota window", diff --git a/packages/ai/src/usage/github-copilot.ts b/packages/ai/src/usage/github-copilot.ts index fbd03ec88..0ee38cbbe 100644 --- a/packages/ai/src/usage/github-copilot.ts +++ b/packages/ai/src/usage/github-copilot.ts @@ -18,6 +18,7 @@ import type { UsageWindow, } from "../usage"; import { isRecord } from "../utils"; +import { parseIsoTimestamp } from "./shared"; type CopilotQuotaDetail = { entitlement: number; @@ -74,8 +75,8 @@ function resolveGitHubApiBaseUrl(params: UsageFetchParams): string { function buildWindow(resetDate: string | undefined): UsageWindow | undefined { if (!resetDate) return undefined; - const resetAt = Date.parse(resetDate); - if (!Number.isFinite(resetAt)) return undefined; + const resetAt = parseIsoTimestamp(resetDate); + if (resetAt === undefined) return undefined; return { id: "monthly", label: "Monthly", diff --git a/packages/ai/src/usage/google-antigravity.ts b/packages/ai/src/usage/google-antigravity.ts index a948542de..5080144bb 100644 --- a/packages/ai/src/usage/google-antigravity.ts +++ b/packages/ai/src/usage/google-antigravity.ts @@ -12,6 +12,7 @@ import type { UsageStatus, UsageWindow, } from "../usage"; +import { DAY_MS, parseIsoTimestamp, WEEK_MS } from "./shared"; // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.) @@ -47,9 +48,6 @@ interface AntigravityUsageResponse { const DEFAULT_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com"; const FETCH_AVAILABLE_MODELS_PATH = "/v1internal:fetchAvailableModels"; -const ONE_DAY_MS = 24 * 60 * 60 * 1000; -const ONE_WEEK_MS = 7 * ONE_DAY_MS; - interface AntigravityWindowDescriptor { id: string; label: string; @@ -59,25 +57,20 @@ interface AntigravityWindowDescriptor { function classifyWindow(id: string | undefined, label: string | undefined): AntigravityWindowDescriptor | undefined { const source = `${id ?? ""} ${label ?? ""}`.toLowerCase(); if (source.includes("week") || source.includes("7d") || /7[\s_-]*day/.test(source)) { - return { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS }; + return { id: "weekly", label: "Weekly", durationMs: WEEK_MS }; } if (source.includes("day") || source.includes("daily") || source.includes("24h")) { - return { id: "daily", label: "Daily", durationMs: ONE_DAY_MS }; + return { id: "daily", label: "Daily", durationMs: DAY_MS }; } if (id || label) return { id: id ?? label ?? "default", label: label ?? id ?? "Default" }; return undefined; } -function parseResetTime(info: AntigravityQuotaInfo): number | undefined { - const resetAt = info.resetTime ? Date.parse(info.resetTime) : undefined; - return resetAt !== undefined && Number.isFinite(resetAt) ? resetAt : undefined; -} - function inferWindowFromReset(resetAt: number | undefined, nowMs: number): AntigravityWindowDescriptor { - if (resetAt !== undefined && resetAt - nowMs > ONE_DAY_MS) { - return { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS }; + if (resetAt !== undefined && resetAt - nowMs > DAY_MS) { + return { id: "weekly", label: "Weekly", durationMs: WEEK_MS }; } - return { id: "daily", label: "Daily", durationMs: ONE_DAY_MS }; + return { id: "daily", label: "Daily", durationMs: DAY_MS }; } function quotaInferenceKey(info: AntigravityQuotaInfo): string { @@ -98,7 +91,7 @@ function inferWindowDescriptors( continue; } const group = groups.get(quotaInferenceKey(info)) ?? []; - group.push({ info, resetAt: parseResetTime(info) }); + group.push({ info, resetAt: parseIsoTimestamp(info.resetTime) }); groups.set(quotaInferenceKey(info), group); } @@ -110,7 +103,7 @@ function inferWindowDescriptors( for (const entry of group) { const descriptor = latestReset !== undefined && entry.resetAt === latestReset - ? { id: "weekly", label: "Weekly", durationMs: ONE_WEEK_MS } + ? { id: "weekly", label: "Weekly", durationMs: WEEK_MS } : inferWindowFromReset(entry.resetAt, nowMs); descriptors.set(entry.info, descriptor); } @@ -149,7 +142,7 @@ function parseWindow( info: AntigravityQuotaInfo, descriptor: AntigravityWindowDescriptor | undefined, ): UsageWindow | undefined { - const resetAt = parseResetTime(info); + const resetAt = parseIsoTimestamp(info.resetTime); const hasResetAt = resetAt !== undefined; if (!descriptor && !hasResetAt) return undefined; return { @@ -493,5 +486,5 @@ export const antigravityRankingStrategy: CredentialRankingStrategy = { // Antigravity windows carry `durationMs` when the response identifies them // as daily/weekly. Fall back to daily for legacy unlabelled quotaInfo // entries from `daily-cloudcode-pa.googleapis.com`. - windowDefaults: { primaryMs: ONE_DAY_MS, secondaryMs: ONE_DAY_MS }, + windowDefaults: { primaryMs: DAY_MS, secondaryMs: DAY_MS }, }; diff --git a/packages/ai/src/usage/kimi.ts b/packages/ai/src/usage/kimi.ts index 10c2db5d5..3cb021920 100644 --- a/packages/ai/src/usage/kimi.ts +++ b/packages/ai/src/usage/kimi.ts @@ -1,3 +1,4 @@ +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import { $env } from "@oh-my-pi/pi-utils"; import { getKimiCommonHeaders } from "../registry/oauth/kimi"; import type { @@ -7,12 +8,12 @@ import type { UsageLimit, UsageProvider, UsageReport, - UsageStatus, UsageWindow, } from "../usage"; import { isRecord } from "../utils"; +import { parseIsoTimestamp, usageStatus } from "./shared"; + // (Refresh is the sole responsibility of AuthStorage; no provider-direct refresh here.) -import { toNumber } from "./shared"; const DEFAULT_BASE_URL = "https://api.kimi.com/coding/v1"; const USAGE_PATH = "usages"; @@ -47,8 +48,8 @@ function parseResetTime(data: Record, nowMs: number): number | for (const key of timeKeys) { const value = data[key]; if (typeof value === "string" && value.trim()) { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) return parsed; + const parsed = parseIsoTimestamp(value); + if (parsed !== undefined) return parsed; } if (typeof value === "number" && Number.isFinite(value)) { return value > 1_000_000_000_000 ? value : value * 1000; @@ -136,13 +137,6 @@ function buildUsageAmount(row: KimiUsageRow): UsageAmount { return amount; } -function buildUsageStatus(amount: UsageAmount): UsageStatus { - if (amount.usedFraction === undefined) return "unknown"; - if (amount.usedFraction >= 1) return "exhausted"; - if (amount.usedFraction >= 0.9) return "warning"; - return "ok"; -} - function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accountId?: string): UsageLimit { // Kimi puts `resetTime` on the limit `detail`, not on `window`, so a // window built from `duration`/`timeUnit` alone carries no resetsAt. @@ -172,7 +166,7 @@ function toUsageLimit(row: KimiUsageRow, provider: string, index: number, accoun }, window, amount, - status: buildUsageStatus(amount), + status: usageStatus(amount.usedFraction), }; } diff --git a/packages/ai/src/usage/minimax-code.ts b/packages/ai/src/usage/minimax-code.ts index 9f913332d..ed8ce9a11 100644 --- a/packages/ai/src/usage/minimax-code.ts +++ b/packages/ai/src/usage/minimax-code.ts @@ -1,18 +1,11 @@ -import type { - UsageFetchContext, - UsageFetchParams, - UsageLimit, - UsageProvider, - UsageReport, - UsageStatus, -} from "../usage"; +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; +import type { UsageFetchContext, UsageFetchParams, UsageLimit, UsageProvider, UsageReport } from "../usage"; import { isRecord } from "../utils"; -import { toNumber } from "./shared"; +import { HOUR_MS, parsePositiveTimestamp, usageStatus } from "./shared"; const INTL_PROVIDER = "minimax-code"; const INTL_BASE_URL = "https://api.minimax.io"; const REMAINS_PATH = "/v1/token_plan/remains"; -const HOUR_MS = 60 * 60 * 1000; /** `current_*_status` enum reported per window: 1 normal, 2 exhausted, 3 unlimited. */ const STATUS_EXHAUSTED = 2; const STATUS_UNLIMITED = 3; @@ -42,13 +35,6 @@ interface TokenPlanBucket { weeklyStatus?: number; } -/** MiniMax reports epoch milliseconds; tolerate seconds in case a deployment differs. */ -function parseTimestamp(value: unknown): number | undefined { - const parsed = toNumber(value); - if (parsed === undefined || parsed <= 0) return undefined; - return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed; -} - /** `current_*_remaining_percent` is 0..100 remaining; usage fractions are 0..1 used. */ function usedFractionFromRemainingPercent(value: unknown): number | undefined { const parsed = toNumber(value); @@ -57,26 +43,20 @@ function usedFractionFromRemainingPercent(value: unknown): number | undefined { return Math.min(1, Math.max(0, (100 - parsed) / 100)); } -function usageStatus(usedFraction: number): UsageStatus { - if (usedFraction >= 1) return "exhausted"; - if (usedFraction >= 0.9) return "warning"; - return "ok"; -} - function parseBucket(value: unknown): TokenPlanBucket | null { if (!isRecord(value)) return null; const modelName = typeof value.model_name === "string" ? value.model_name.trim() : ""; if (!modelName) return null; return { modelName, - intervalStart: parseTimestamp(value.start_time), - intervalEnd: parseTimestamp(value.end_time), + intervalStart: parsePositiveTimestamp(value.start_time), + intervalEnd: parsePositiveTimestamp(value.end_time), intervalRemainingPercent: toNumber(value.current_interval_remaining_percent), intervalTotalCount: toNumber(value.current_interval_total_count), intervalUsageCount: toNumber(value.current_interval_usage_count), intervalStatus: toNumber(value.current_interval_status), - weeklyStart: parseTimestamp(value.weekly_start_time), - weeklyEnd: parseTimestamp(value.weekly_end_time), + weeklyStart: parsePositiveTimestamp(value.weekly_start_time), + weeklyEnd: parsePositiveTimestamp(value.weekly_end_time), weeklyRemainingPercent: toNumber(value.current_weekly_remaining_percent), weeklyTotalCount: toNumber(value.current_weekly_total_count), weeklyUsageCount: toNumber(value.current_weekly_usage_count), diff --git a/packages/ai/src/usage/openai-codex-reset.ts b/packages/ai/src/usage/openai-codex-reset.ts index 3c769a0ba..944fe4205 100644 --- a/packages/ai/src/usage/openai-codex-reset.ts +++ b/packages/ai/src/usage/openai-codex-reset.ts @@ -18,10 +18,10 @@ * (the `/usage reset` command + auto-redeem) and any out-of-band tooling can * share one wire contract. */ +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import type { FetchImpl } from "../types"; import { isRecord } from "../utils"; import { normalizeCodexBaseUrl } from "./openai-codex-base-url"; -import { toNumber } from "./shared"; const RESET_CREDITS_PATH = "wham/rate-limit-reset-credits"; const RESET_CREDITS_CONSUME_PATH = "wham/rate-limit-reset-credits/consume"; diff --git a/packages/ai/src/usage/openai-codex.ts b/packages/ai/src/usage/openai-codex.ts index 48f2248ca..03ee5a213 100644 --- a/packages/ai/src/usage/openai-codex.ts +++ b/packages/ai/src/usage/openai-codex.ts @@ -1,4 +1,5 @@ import { Buffer } from "node:buffer"; +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import type { CredentialRankingContext, CredentialRankingStrategy, @@ -14,7 +15,7 @@ import type { import { isRecord } from "../utils"; import { normalizeCodexBaseUrl } from "./openai-codex-base-url"; import { listCodexResetCredits } from "./openai-codex-reset"; -import { toNumber } from "./shared"; +import { HOUR_MS } from "./shared"; const CODEX_USAGE_PATH = "wham/usage"; const JWT_AUTH_CLAIM = "https://api.openai.com/auth"; @@ -558,8 +559,6 @@ export const openaiCodexUsageProvider: UsageProvider = { }, }; -const FIVE_HOUR_MS = 5 * 60 * 60 * 1000; - // A Codex request gates only on the chat windows it actually consumes. A // "-spark" model spends the separate Spark meter; every other Codex model spends // the 5h/weekly chat windows. Scoping the gating set this way keeps an exhausted @@ -619,7 +618,7 @@ export const codexRankingStrategy: CredentialRankingStrategy = { windowId === "5h" || (typeof durationMs === "number" && Number.isFinite(durationMs) && - Math.abs(durationMs - FIVE_HOUR_MS) <= 60_000); + Math.abs(durationMs - 5 * HOUR_MS) <= 60_000); if (!isFiveHourWindow) return false; const usedFraction = primary.amount.usedFraction; return typeof usedFraction === "number" && Number.isFinite(usedFraction) && usedFraction === 0; diff --git a/packages/ai/src/usage/opencode-go.ts b/packages/ai/src/usage/opencode-go.ts index 772ff35ec..8e4a117ba 100644 --- a/packages/ai/src/usage/opencode-go.ts +++ b/packages/ai/src/usage/opencode-go.ts @@ -1,8 +1,7 @@ import type { UsageCostHistoryEntry, UsageLimit, UsageProvider, UsageWindow } from "../usage"; +import { DAY_MS, HOUR_MS } from "./shared"; const OPENCODE_GO_PROVIDER = "opencode-go"; -const HOUR_MS = 60 * 60 * 1000; -const DAY_MS = 24 * HOUR_MS; const OPENCODE_GO_LIMITS = [ { id: "rolling-5h", label: "5 Hour", durationMs: 5 * HOUR_MS, limitUsd: 12 }, { id: "weekly", label: "Weekly", durationMs: 7 * DAY_MS, limitUsd: 30 }, diff --git a/packages/ai/src/usage/shared.ts b/packages/ai/src/usage/shared.ts index f4a23e7c8..f930536d9 100644 --- a/packages/ai/src/usage/shared.ts +++ b/packages/ai/src/usage/shared.ts @@ -1,10 +1,33 @@ -export const toNumber = (value: unknown): number | undefined => { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed) return undefined; - const parsed = Number(trimmed); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -}; +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; +import type { UsageStatus } from "../usage"; + +/** Milliseconds in one hour. */ +export const HOUR_MS = 60 * 60 * 1000; + +/** Milliseconds in one day. */ +export const DAY_MS = 24 * HOUR_MS; + +/** Milliseconds in one seven-day week. */ +export const WEEK_MS = 7 * DAY_MS; + +/** Parses a finite positive epoch timestamp, tolerating seconds or milliseconds. */ +export function parsePositiveTimestamp(value: unknown): number | undefined { + const parsed = toNumber(value); + if (parsed === undefined || parsed <= 0) return undefined; + return parsed < 1_000_000_000_000 ? parsed * 1000 : parsed; +} + +/** Parses an ISO timestamp into epoch milliseconds. */ +export function parseIsoTimestamp(value: unknown): number | undefined { + if (typeof value !== "string" || !value) return undefined; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +/** Maps a used fraction to the standard quota status thresholds. */ +export function usageStatus(usedFraction: number | undefined): UsageStatus { + if (usedFraction === undefined) return "unknown"; + if (usedFraction >= 1) return "exhausted"; + if (usedFraction >= 0.9) return "warning"; + return "ok"; +} diff --git a/packages/ai/src/usage/synthetic.ts b/packages/ai/src/usage/synthetic.ts index 0b932b553..490a6dc9d 100644 --- a/packages/ai/src/usage/synthetic.ts +++ b/packages/ai/src/usage/synthetic.ts @@ -9,10 +9,9 @@ import type { UsageStatus, UsageWindow, } from "../usage"; +import { HOUR_MS, parseIsoTimestamp, WEEK_MS } from "./shared"; const QUOTAS_URL = "https://api.synthetic.new/v2/quotas"; -const FIVE_HOUR_MS = 5 * 60 * 60 * 1000; -const WEEK_MS = 7 * 24 * 60 * 60 * 1000; function parseDollarAmount(value: unknown): number | undefined { if (typeof value !== "string") return undefined; @@ -21,12 +20,6 @@ function parseDollarAmount(value: unknown): number | undefined { return Number.isFinite(parsed) ? parsed : undefined; } -function parseIsoMs(value: unknown): number | undefined { - if (typeof value !== "string" || !value) return undefined; - const ms = Date.parse(value); - return Number.isFinite(ms) ? ms : undefined; -} - function buildUsageAmount(args: { used: number | undefined; limit: number | undefined; @@ -61,7 +54,7 @@ function parseRollingFiveHourLimit(raw: unknown, provider: UsageFetchParams["pro const remaining = typeof raw.remaining === "number" ? raw.remaining : undefined; const max = typeof raw.max === "number" ? raw.max : undefined; const limited = raw.limited === true; - const nextTickAt = parseIsoMs(raw.nextTickAt); + const nextTickAt = parseIsoTimestamp(raw.nextTickAt); const tickPercent = typeof raw.tickPercent === "number" ? raw.tickPercent : undefined; if (remaining === undefined && max === undefined) return null; @@ -71,7 +64,7 @@ function parseRollingFiveHourLimit(raw: unknown, provider: UsageFetchParams["pro const window: UsageWindow = { id: "5h", label: regenPercent !== undefined ? `5h ยท regen ${regenPercent}%/tick` : "5h", - durationMs: FIVE_HOUR_MS, + durationMs: 5 * HOUR_MS, ...(nextTickAt !== undefined ? { resetsAt: nextTickAt, resetLabel: "tick" } : {}), }; const amount = buildUsageAmount({ @@ -97,7 +90,7 @@ function parseWeeklyTokenLimit(raw: unknown, provider: UsageFetchParams["provide const remainingCredits = parseDollarAmount(raw.remainingCredits); const maxCredits = parseDollarAmount(raw.maxCredits); const percentRemaining = typeof raw.percentRemaining === "number" ? raw.percentRemaining : undefined; - const nextRegenAt = parseIsoMs(raw.nextRegenAt); + const nextRegenAt = parseIsoTimestamp(raw.nextRegenAt); if (remainingCredits === undefined && maxCredits === undefined) return null; diff --git a/packages/ai/src/usage/umans.ts b/packages/ai/src/usage/umans.ts index c32675d61..a171731c8 100644 --- a/packages/ai/src/usage/umans.ts +++ b/packages/ai/src/usage/umans.ts @@ -10,11 +10,11 @@ import type { UsageWindow, } from "../usage"; import { isRecord } from "../utils"; +import { HOUR_MS } from "./shared"; const UMANS_PROVIDER = "umans"; const DEFAULT_ENDPOINT = "https://api.code.umans.ai"; const USAGE_PATH = "/v1/usage"; -const FIVE_HOURS_MS = 5 * 60 * 60 * 1000; /** Umans `GET /v1/usage` response (subset; extras ignored). */ interface UmansUsagePayload { @@ -89,7 +89,7 @@ function buildRequestsLimit(payload: UmansUsagePayload, provider: string): Usage const window: UsageWindow = { id: "5h", label: "rolling 5h", - durationMs: windowSeconds ? windowSeconds * 1000 : FIVE_HOURS_MS, + durationMs: windowSeconds ? windowSeconds * 1000 : 5 * HOUR_MS, }; return { id: "umans:requests", diff --git a/packages/ai/src/usage/xai-oauth.ts b/packages/ai/src/usage/xai-oauth.ts index 909f8d9e0..91eb688f7 100644 --- a/packages/ai/src/usage/xai-oauth.ts +++ b/packages/ai/src/usage/xai-oauth.ts @@ -9,6 +9,7 @@ * product and must never be sent here. */ +import { toNumber } from "@oh-my-pi/pi-catalog/utils"; import { buildXAICliBillingUrl, extractXAIAccessTokenSubject, @@ -22,15 +23,12 @@ import type { UsageLimit, UsageProvider, UsageReport, - UsageStatus, UsageWindow, } from "../usage"; import { isRecord } from "../utils"; -import { toNumber } from "./shared"; +import { DAY_MS, parseIsoTimestamp, usageStatus, WEEK_MS } from "./shared"; const PROVIDER_ID = "xai-oauth"; -const WEEK_MS = 7 * 24 * 60 * 60 * 1000; -const DAY_MS = 24 * 60 * 60 * 1000; const BILLING_SOURCE = "cli-chat-proxy.grok.com/v1/billing"; interface XaiBillingPeriod { @@ -71,11 +69,6 @@ interface XaiMonthlyBillingConfig { type XaiBillingConfig = XaiWeeklyBillingConfig | XaiMonthlyBillingConfig; -function parseIsoMs(value: string): number | undefined { - const parsed = Date.parse(value); - return Number.isFinite(parsed) ? parsed : undefined; -} - function parsePercent(value: unknown): number | undefined { const percent = toNumber(value); return percent !== undefined && percent >= 0 && percent <= 100 ? percent : undefined; @@ -99,12 +92,6 @@ function buildPercentAmount(usagePercent: number): UsageAmount { }; } -function buildUsageStatus(usedFraction: number): UsageStatus { - if (usedFraction >= 1) return "exhausted"; - if (usedFraction >= 0.9) return "warning"; - return "ok"; -} - function slugifyProduct(product: string): string { return product .trim() @@ -118,13 +105,13 @@ function buildPeriodWindow(period: XaiBillingPeriod): UsageWindow { id: "1w", label: "Weekly", durationMs: WEEK_MS, - resetsAt: parseIsoMs(period.end), + resetsAt: parseIsoTimestamp(period.end), }; } function buildMonthlyWindow(periodStart: string, periodEnd: string): UsageWindow | undefined { - const startMs = parseIsoMs(periodStart); - const endMs = parseIsoMs(periodEnd); + const startMs = parseIsoTimestamp(periodStart); + const endMs = parseIsoTimestamp(periodEnd); if (startMs === undefined || endMs === undefined || endMs <= startMs) return undefined; // Real calendar months vary; use the observed period length from the API. const durationMs = endMs - startMs; @@ -140,8 +127,8 @@ function buildMonthlyWindow(periodStart: string, periodEnd: string): UsageWindow function parseWeeklyBillingConfig(raw: Record): XaiWeeklyBillingConfig | null { if (!isRecord(raw.currentPeriod)) return null; - const start = typeof raw.currentPeriod.start === "string" ? parseIsoMs(raw.currentPeriod.start) : undefined; - const end = typeof raw.currentPeriod.end === "string" ? parseIsoMs(raw.currentPeriod.end) : undefined; + const start = typeof raw.currentPeriod.start === "string" ? parseIsoTimestamp(raw.currentPeriod.start) : undefined; + const end = typeof raw.currentPeriod.end === "string" ? parseIsoTimestamp(raw.currentPeriod.end) : undefined; const type = typeof raw.currentPeriod.type === "string" ? raw.currentPeriod.type : ""; // Keep recently-ended weekly windows so /usage still renders across period // rollover while the billing API is mid-refresh. Reject only inverted ranges @@ -182,8 +169,8 @@ function parseWeeklyBillingConfig(raw: Record): XaiWeeklyBillin function parseMonthlyBillingConfig(raw: Record): XaiMonthlyBillingConfig | null { const periodStart = typeof raw.billingPeriodStart === "string" ? raw.billingPeriodStart : ""; const periodEnd = typeof raw.billingPeriodEnd === "string" ? raw.billingPeriodEnd : ""; - const startMs = parseIsoMs(periodStart); - const endMs = parseIsoMs(periodEnd); + const startMs = parseIsoTimestamp(periodStart); + const endMs = parseIsoTimestamp(periodEnd); if (!periodStart || !periodEnd || startMs === undefined || endMs === undefined || endMs <= startMs) { return null; } @@ -227,7 +214,7 @@ function buildOnDemandLimit( remainingFraction: 1 - usedFraction, unit: "unknown", }, - status: buildUsageStatus(usedFraction), + status: usageStatus(usedFraction), }; } @@ -248,7 +235,7 @@ function buildLimits(config: XaiBillingConfig, accountId: string | undefined): U scope, window, amount: overall, - status: buildUsageStatus(overall.usedFraction ?? 0), + status: usageStatus(overall.usedFraction ?? 0), }, ]; @@ -262,7 +249,7 @@ function buildLimits(config: XaiBillingConfig, accountId: string | undefined): U scope, window, amount, - status: buildUsageStatus(amount.usedFraction ?? 0), + status: usageStatus(amount.usedFraction ?? 0), }); } const onDemand = buildOnDemandLimit(config.onDemandCap, config.onDemandUsed, accountId); @@ -293,7 +280,7 @@ function buildLimits(config: XaiBillingConfig, accountId: string | undefined): U // xAI does not label the unit; amounts match the dashboard quota points. unit: "unknown", }, - status: buildUsageStatus(usedFraction), + status: usageStatus(usedFraction), }, ]; const onDemand = buildOnDemandLimit(config.onDemandCap, config.onDemandUsed, accountId); diff --git a/packages/ai/src/usage/zai.ts b/packages/ai/src/usage/zai.ts index bdfce2ff6..582cddb85 100644 --- a/packages/ai/src/usage/zai.ts +++ b/packages/ai/src/usage/zai.ts @@ -11,14 +11,11 @@ import type { UsageWindow, } from "../usage"; import { isRecord } from "../utils"; +import { DAY_MS, HOUR_MS, WEEK_MS } from "./shared"; const DEFAULT_ENDPOINT = "https://api.z.ai"; const QUOTA_PATH = "/api/monitor/usage/quota/limit"; const MODEL_USAGE_PATH = "/api/monitor/usage/model-usage"; -const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; -const HOUR_MS = 60 * 60 * 1000; -const DAY_MS = 24 * HOUR_MS; -const WEEK_MS = 7 * DAY_MS; const MONTH_MS = 30 * DAY_MS; interface ZaiUsageDetail { @@ -188,7 +185,7 @@ function requestQuotaLabel(parsed: ZaiUsageLimitItem): string { } function buildModelUsageUrl(baseUrl: string, now: Date): string { - const start = new Date(now.getTime() - SEVEN_DAYS_MS); + const start = new Date(now.getTime() - WEEK_MS); const startTime = formatDate(start); const endTime = formatDate(now); return `${baseUrl}${MODEL_USAGE_PATH}?startTime=${encodeURIComponent(startTime)}&endTime=${encodeURIComponent(endTime)}`;