From 785d1bddf27da2354659ee9f9fa648e70b750a57 Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:23:35 +0800 Subject: [PATCH 1/5] fix: preserve Luna Reserve quota routing --- .../app/accounts/accounts-page-helpers.tsx | 3 + apps/src/app/accounts/accounts-page-view.tsx | 24 ++ apps/src/app/accounts/page.tsx | 12 + apps/src/hooks/useAccounts.ts | 17 +- apps/src/lib/api/normalize.ts | 29 ++- .../lib/i18n/messages/sections/en-accounts.ts | 7 + .../lib/i18n/messages/sections/ko-accounts.ts | 7 + .../lib/i18n/messages/sections/ru-accounts.ts | 7 + apps/src/lib/utils/usage.ts | 237 +++++++++++++++-- apps/tests/luna-reserve-usage.test.mjs | 102 ++++++++ crates/core/src/storage/accounts.rs | 65 ++++- crates/core/src/storage/accounts_tests.rs | 74 +++++- crates/core/src/usage/mod.rs | 244 ++++++++++++++++-- crates/core/tests/usage.rs | 126 ++++++++- .../src/account/account_availability.rs | 7 + crates/service/src/account/account_status.rs | 19 +- .../src/account/account_status_tests.rs | 35 +++ crates/service/src/account/account_update.rs | 15 ++ .../src/account/account_update_tests.rs | 68 +++++ .../tests/account_availability_tests.rs | 14 + crates/service/src/codex_profile.rs | 3 +- crates/service/src/codex_profile_tests.rs | 13 +- .../service/src/gateway/routing/selection.rs | 8 +- .../gateway/upstream/support/candidates.rs | 90 ++++++- .../upstream/support/candidates_tests.rs | 87 +++++++ crates/service/src/quota/read.rs | 5 +- crates/service/src/usage/refresh/batch.rs | 15 +- .../service/src/usage/refresh/batch_tests.rs | 2 + crates/service/src/usage/refresh/mod.rs | 62 ++++- crates/service/src/usage/usage_read.rs | 41 ++- .../service/src/usage/usage_snapshot_store.rs | 119 +++++++-- .../tests/usage/usage_refresh_status_tests.rs | 71 ++++- docs/en/CHANGELOG.md | 9 + ...20\275\320\265\320\275\320\270\320\271.md" | 9 + docs/zh-CN/CHANGELOG.md | 9 + 35 files changed, 1547 insertions(+), 108 deletions(-) create mode 100644 apps/tests/luna-reserve-usage.test.mjs diff --git a/apps/src/app/accounts/accounts-page-helpers.tsx b/apps/src/app/accounts/accounts-page-helpers.tsx index 5170075c2..40c0d42ce 100644 --- a/apps/src/app/accounts/accounts-page-helpers.tsx +++ b/apps/src/app/accounts/accounts-page-helpers.tsx @@ -137,6 +137,7 @@ export interface AccountEditorState { currentTags: string; currentNote: string; currentSort: number; + currentForceEnabled: boolean; currentQuotaPrimaryWindowTokens: number | null; currentQuotaSecondaryWindowTokens: number | null; } @@ -438,6 +439,8 @@ export function formatAccountStatusReasonLabel( return t("工作区已停用"); case "usage_limit_exhausted": return t("额度已耗尽"); + case "manual_force_enable": + return t("手动强制开启"); default: return reasonCode; } diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index 15d1c4287..f4f00d5ad 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -175,6 +175,7 @@ export interface AccountsPageViewProps { tagsDraft: string; noteDraft: string; sortDraft: string; + forceEnabledDraft: boolean; quotaPrimaryDraft: string; quotaSecondaryDraft: string; isRefreshingAllAccounts: boolean; @@ -213,6 +214,7 @@ export interface AccountsPageViewProps { setTagsDraft: Dispatch>; setNoteDraft: Dispatch>; setSortDraft: Dispatch>; + setForceEnabledDraft: Dispatch>; setQuotaPrimaryDraft: Dispatch>; setQuotaSecondaryDraft: Dispatch>; setPage: Dispatch>; @@ -307,6 +309,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { tagsDraft, noteDraft, sortDraft, + forceEnabledDraft, quotaPrimaryDraft, quotaSecondaryDraft, isRefreshingAllAccounts, @@ -343,6 +346,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { setTagsDraft, setNoteDraft, setSortDraft, + setForceEnabledDraft, setQuotaPrimaryDraft, setQuotaSecondaryDraft, setPage, @@ -387,6 +391,9 @@ export function AccountsPageView(props: AccountsPageViewProps) { toggleAccountStatus, } = props; + const forceToggleBlocked = ["disabled", "inactive", "unavailable", "banned"].includes( + String(currentEditingAccount?.status || "").trim().toLowerCase(), + ); const accountProxyBusy = isProxySettingsLoading || isSavingAccountProxy || isClearingAccountProxy; const selectedProxyProfile = @@ -1724,6 +1731,23 @@ export function AccountsPageView(props: AccountsPageViewProps) { /> +
+
+ +

+ {t("开启后忽略 5h/7d 耗尽状态,继续把该账号加入网关候选;默认关闭。")} +

+
+ +
{t("账号 ID")}
diff --git a/apps/src/app/accounts/page.tsx b/apps/src/app/accounts/page.tsx index 6d11cd3a7..19ea78951 100644 --- a/apps/src/app/accounts/page.tsx +++ b/apps/src/app/accounts/page.tsx @@ -140,6 +140,7 @@ export default function AccountsPage() { const [tagsDraft, setTagsDraft] = useState(""); const [noteDraft, setNoteDraft] = useState(""); const [sortDraft, setSortDraft] = useState(""); + const [forceEnabledDraft, setForceEnabledDraft] = useState(false); const [quotaPrimaryDraft, setQuotaPrimaryDraft] = useState(""); const [quotaSecondaryDraft, setQuotaSecondaryDraft] = useState(""); const [proxyDialogAccount, setProxyDialogAccount] = useState(null); @@ -684,6 +685,7 @@ const toggleCleanupStatus = (rawStatus: string) => { currentTags: account.tags.join(", "), currentNote: account.note || "", currentSort: account.priority, + currentForceEnabled: account.status.trim().toLowerCase() === "force_enabled", currentQuotaPrimaryWindowTokens: account.quotaCapacityPrimaryWindowTokens, currentQuotaSecondaryWindowTokens: account.quotaCapacitySecondaryWindowTokens, }); @@ -692,6 +694,7 @@ const toggleCleanupStatus = (rawStatus: string) => { setTagsDraft(account.tags.join(", ")); setNoteDraft(account.note || ""); setSortDraft(String(account.priority)); + setForceEnabledDraft(account.status.trim().toLowerCase() === "force_enabled"); setQuotaPrimaryDraft( account.quotaCapacityPrimaryWindowTokens == null ? "" @@ -848,6 +851,7 @@ const toggleCleanupStatus = (rawStatus: string) => { nextTagsText === accountEditorState.currentTags && nextNote === accountEditorState.currentNote && nextSort === accountEditorState.currentSort && + forceEnabledDraft === accountEditorState.currentForceEnabled && nextPrimaryCapacity === accountEditorState.currentQuotaPrimaryWindowTokens && nextSecondaryCapacity === accountEditorState.currentQuotaSecondaryWindowTokens ) { @@ -862,6 +866,12 @@ const toggleCleanupStatus = (rawStatus: string) => { note: nextNote || null, tags: nextTags, sort: nextSort, + status: + forceEnabledDraft === accountEditorState.currentForceEnabled + ? undefined + : forceEnabledDraft + ? "force_enabled" + : "active", quotaCapacityPrimaryWindowTokens: nextPrimaryCapacity ?? 0, quotaCapacitySecondaryWindowTokens: nextSecondaryCapacity ?? 0, }); @@ -930,6 +940,7 @@ const toggleCleanupStatus = (rawStatus: string) => { tagsDraft={tagsDraft} noteDraft={noteDraft} sortDraft={sortDraft} + forceEnabledDraft={forceEnabledDraft} quotaPrimaryDraft={quotaPrimaryDraft} quotaSecondaryDraft={quotaSecondaryDraft} isRefreshingAllAccounts={isRefreshingAllAccounts} @@ -968,6 +979,7 @@ const toggleCleanupStatus = (rawStatus: string) => { setTagsDraft={setTagsDraft} setNoteDraft={setNoteDraft} setSortDraft={setSortDraft} + setForceEnabledDraft={setForceEnabledDraft} setQuotaPrimaryDraft={setQuotaPrimaryDraft} setQuotaSecondaryDraft={setQuotaSecondaryDraft} setPage={setPage} diff --git a/apps/src/hooks/useAccounts.ts b/apps/src/hooks/useAccounts.ts index a65c95410..4e9a895c3 100644 --- a/apps/src/hooks/useAccounts.ts +++ b/apps/src/hooks/useAccounts.ts @@ -5,7 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { accountClient, type AccountUsageRefreshResult } from "@/lib/api/account-client"; import { CODEX_PROFILE_CANDIDATES_QUERY_KEY } from "@/lib/api/codex-profile-client"; -import { attachUsagesToAccounts } from "@/lib/api/normalize"; +import { attachUsagesToAccounts, buildUsageMap } from "@/lib/api/normalize"; import { serviceClient } from "@/lib/api/service-client"; import { buildStartupSnapshotQueryKey, @@ -228,6 +228,7 @@ export function useAccounts() { backgroundTasks.usagePollIntervalSecs, ); const usageListFingerprintRef = useRef(null); + const lastKnownUsagesRef = useRef>(new Map()); const importedUsageRefreshIdsRef = useRef>(new Set()); const importedUsageRefreshInFlightRef = useRef>(new Set()); const [importedUsageRefreshVersion, setImportedUsageRefreshVersion] = useState(0); @@ -497,9 +498,17 @@ export function useAccounts() { const visibleAccountList = accountsQuery.data; const accounts = useMemo(() => { + const incomingUsages = usagesQuery.data || []; + if (incomingUsages.length > 0) { + const mergedUsages = buildUsageMap([ + ...lastKnownUsagesRef.current.values(), + ...incomingUsages, + ]); + lastKnownUsagesRef.current = mergedUsages; + } return attachUsagesToAccounts( visibleAccountList?.items || [], - usagesQuery.data || [] + Array.from(lastKnownUsagesRef.current.values()), ); }, [visibleAccountList?.items, usagesQuery.data]); @@ -776,6 +785,7 @@ export function useAccounts() { note, tags, sort, + status, quotaCapacityPrimaryWindowTokens, quotaCapacitySecondaryWindowTokens, }: { @@ -785,6 +795,7 @@ export function useAccounts() { note?: string | null; tags?: string[] | string | null; sort?: number | null; + status?: string | null; quotaCapacityPrimaryWindowTokens?: number | null; quotaCapacitySecondaryWindowTokens?: number | null; }) => @@ -794,6 +805,7 @@ export function useAccounts() { note, tags, sort, + status, quotaCapacityPrimaryWindowTokens, quotaCapacitySecondaryWindowTokens, }), @@ -1248,6 +1260,7 @@ export function useAccounts() { note?: string | null; tags?: string[] | string | null; sort?: number | null; + status?: string | null; quotaCapacityPrimaryWindowTokens?: number | null; quotaCapacitySecondaryWindowTokens?: number | null; } diff --git a/apps/src/lib/api/normalize.ts b/apps/src/lib/api/normalize.ts index 31f94acb8..68a61137b 100644 --- a/apps/src/lib/api/normalize.ts +++ b/apps/src/lib/api/normalize.ts @@ -144,6 +144,19 @@ function asString(value: unknown, fallback = ""): string { return typeof value === "string" ? value.trim() : fallback; } +function asJsonString(value: unknown): string | null { + if (typeof value === "string") { + const text = value.trim(); + return text || null; + } + if (!value || typeof value !== "object") return null; + try { + return JSON.stringify(value); + } catch { + return null; + } +} + /** * 函数 `asBoolean` * @@ -282,7 +295,7 @@ export function normalizeUsageSnapshot(payload: unknown): AccountUsage | null { secondaryResetsAt: toNullableNumber( source.secondaryResetsAt ?? source.secondary_resets_at ), - creditsJson: asString(source.creditsJson ?? source.credits_json) || null, + creditsJson: asJsonString(source.creditsJson ?? source.credits_json), capturedAt: toNullableNumber(source.capturedAt ?? source.captured_at), }; } @@ -322,7 +335,19 @@ export function normalizeUsageList(payload: unknown): AccountUsage[] { * 返回函数执行结果 */ export function buildUsageMap(usages: AccountUsage[]): Map { - return new Map(usages.map((item) => [item.accountId, item])); + const result = new Map(); + for (const item of usages) { + const previous = result.get(item.accountId); + if ( + previous && + previous.capturedAt != null && + (item.capturedAt == null || item.capturedAt < previous.capturedAt) + ) { + continue; + } + result.set(item.accountId, item); + } + return result; } /** diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index cfda3028d..3caade709 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -234,6 +234,13 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "额度容量必须是大于 0 的数字,留空表示未覆盖": "Quota capacity must be a number greater than 0. Leave blank for no override.", "额度已耗尽": "Quota exhausted", + "强制开启": "Force enabled", + "仅 Luna Reserve": "Luna Reserve only", + "Luna Reserve 额度": "Luna Reserve quota", + "额度耗尽后仍使用账号": "Keep using account after quota exhaustion", + "开启后忽略 5h/7d 耗尽状态,继续把该账号加入网关候选;默认关闭。": + "When enabled, ignore exhausted 5h/7d windows and keep this account in the gateway pool; off by default.", + "手动强制开启": "Manually force enabled", "额度已重置,但最新用量同步失败,请稍后手动刷新": "Quota was reset, but the latest usage could not be synced. Refresh it manually later.", "重置 5h + 7d": "Reset 5h + 7d", diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index 500470046..b070c7927 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -221,6 +221,13 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "额度容量必须是大于 0 的数字,留空表示未覆盖": "한도 용량은 0보다 큰 숫자여야 합니다. 비워 두면 오버라이드하지 않습니다.", "额度已耗尽": "한도가 소진되었습니다", + "强制开启": "강제 활성화됨", + "仅 Luna Reserve": "Luna Reserve만", + "Luna Reserve 额度": "Luna Reserve 한도", + "额度耗尽后仍使用账号": "한도 소진 후에도 계정 사용", + "开启后忽略 5h/7d 耗尽状态,继续把该账号加入网关候选;默认关闭。": + "활성화하면 5시간/7일 한도 소진 상태를 무시하고 이 계정을 게이트웨이 후보에 계속 포함합니다. 기본값은 꺼짐입니다.", + "手动强制开启": "수동 강제 활성화", "额度已重置,但最新用量同步失败,请稍后手动刷新": "한도는 재설정되었지만 최신 사용량을 동기화하지 못했습니다. 잠시 후 수동으로 새로고침하세요.", "重置 5h + 7d": "5h + 7d 재설정", diff --git a/apps/src/lib/i18n/messages/sections/ru-accounts.ts b/apps/src/lib/i18n/messages/sections/ru-accounts.ts index ed092e485..d1e4bfcb9 100644 --- a/apps/src/lib/i18n/messages/sections/ru-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ru-accounts.ts @@ -234,6 +234,13 @@ export const RU_ACCOUNTS_MESSAGES: MessageCatalog = { "额度容量必须是大于 0 的数字,留空表示未覆盖": "Емкость квоты должна быть числом больше 0. Оставьте пустым, чтобы не переопределять.", "额度已耗尽": "Квота исчерпана", + "强制开启": "Принудительно включен", + "仅 Luna Reserve": "Только Luna Reserve", + "Luna Reserve 额度": "Квота Luna Reserve", + "额度耗尽后仍使用账号": "Продолжать использовать аккаунт после исчерпания квоты", + "开启后忽略 5h/7d 耗尽状态,继续把该账号加入网关候选;默认关闭。": + "После включения игнорировать исчерпание квот 5 ч/7 д и оставлять аккаунт в пуле шлюза; по умолчанию выключено.", + "手动强制开启": "Включено вручную принудительно", "额度已重置,但最新用量同步失败,请稍后手动刷新": "Квота сброшена, но актуальное использование не синхронизировано. Обновите его вручную позже.", "重置 5h + 7d": "Сбросить 5 ч + 7 д", diff --git a/apps/src/lib/utils/usage.ts b/apps/src/lib/utils/usage.ts index fb51df5ea..9e96e513f 100644 --- a/apps/src/lib/utils/usage.ts +++ b/apps/src/lib/utils/usage.ts @@ -296,6 +296,10 @@ export function isLimitedAccount(account?: { status?: string } | null): boolean return normalizedAccountStatus(account) === "limited"; } +export function isForceEnabledAccount(account?: { status?: string } | null): boolean { + return normalizedAccountStatus(account) === "force_enabled"; +} + /** * 函数 `isBannedAccount` * @@ -411,9 +415,148 @@ function asObjectRecord(value: unknown): Record | null { : null; } +function firstObjectValue( + source: Record, + keys: string[], +): unknown { + for (const key of keys) { + if (source[key] !== undefined) return source[key]; + } + return undefined; +} + +function objectTextValue( + source: Record, + keys: string[], +): string { + const value = firstObjectValue(source, keys); + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeRateLimitKey(key: string): string { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/[-\s]+/g, "_") + .toLowerCase(); +} + +function isExtraRateLimitKey(key: string): boolean { + const normalized = normalizeRateLimitKey(key); + return ( + normalized.endsWith("_rate_limit") || + normalized.endsWith("ratelimit") || + (normalized.includes("luna") && normalized.includes("reserve")) || + (normalized.includes("gpt") && normalized.includes("reserve")) + ); +} + +function normalizeExtraRateLimitItems(raw: unknown): Record[] { + const payload = asObjectRecord(raw); + if (!payload) return []; + + const items: Record[] = []; + const append = (value: unknown, sourceKey?: string) => { + if (Array.isArray(value)) { + value.forEach((item) => append(item, sourceKey)); + return; + } + const source = asObjectRecord(value); + if (!source) return; + const sourceValue = objectTextValue(source, ["source_key", "sourceKey"]); + items.push( + sourceValue || !sourceKey + ? source + : { ...source, source_key: sourceKey }, + ); + }; + + append(payload[EXTRA_RATE_LIMITS_JSON_KEY]); + for (const key of ["additional_rate_limits", "additionalRateLimits"]) { + const value = payload[key]; + if (Array.isArray(value)) { + value.forEach((item) => append(item)); + } else if (asObjectRecord(value)) { + for (const [sourceKey, item] of Object.entries(value as Record)) { + append(item, sourceKey); + } + } + } + for (const [key, value] of Object.entries(payload)) { + if ( + key === EXTRA_RATE_LIMITS_JSON_KEY || + key === "additional_rate_limits" || + key === "additionalRateLimits" || + key === "rate_limit" || + key === "rateLimit" || + !isExtraRateLimitKey(key) + ) { + continue; + } + append(value, key); + } + + return items; +} + +function rateLimitEntryIdentifiers(source: Record): string[] { + const nested = asObjectRecord(firstObjectValue(source, ["rate_limit", "rateLimit"])); + return [ + objectTextValue(source, ["source_key", "sourceKey"]), + objectTextValue(source, ["limit_id", "limitId"]), + objectTextValue(source, ["limit_name", "limitName"]), + objectTextValue(source, ["metered_feature", "meteredFeature"]), + nested ? objectTextValue(nested, ["limit_id", "limitId"]) : "", + nested ? objectTextValue(nested, ["limit_name", "limitName"]) : "", + nested ? objectTextValue(nested, ["metered_feature", "meteredFeature"]) : "", + ].filter(Boolean); +} + +function isLunaReserveIdentifier(value: string): boolean { + const normalized = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]/g, ""); + return ( + normalized.includes("gptreserve") || + normalized.includes("lunareserve") || + normalized.includes("basemodelinference") || + (normalized.includes("luna") && normalized.includes("reserve")) + ); +} + +function isRateLimitWindowUsable(window: Record | null): boolean { + if (!window) return false; + const explicitRemaining = toNullableNumber( + firstObjectValue(window, ["remaining_percent", "remainingPercent"]), + ); + if (explicitRemaining != null) return explicitRemaining > 0; + const used = toNullableNumber( + firstObjectValue(window, ["used_percent", "usedPercent"]), + ); + return used != null && used < 100; +} + +function isUsableRateLimitEntry(source: Record): boolean { + const allowed = firstObjectValue(source, ["allowed"]); + if (allowed === false) return false; + const reached = firstObjectValue(source, ["limit_reached", "limitReached"]); + if (reached === true) return false; + const nested = asObjectRecord(firstObjectValue(source, ["rate_limit", "rateLimit"])); + const container = nested ?? source; + return ( + isRateLimitWindowUsable( + asObjectRecord(firstObjectValue(container, ["primary_window", "primaryWindow"])), + ) || + isRateLimitWindowUsable( + asObjectRecord(firstObjectValue(container, ["secondary_window", "secondaryWindow"])), + ) + ); +} + function humanizeExtraRateLimitLabel(raw: string): string { const normalized = raw.trim().toLowerCase(); if (!normalized) return "额外额度"; + if (isLunaReserveIdentifier(normalized)) return "Luna Reserve 额度"; if (normalized.includes("spark") || normalized === "codex_other") return "Spark 额度"; if (normalized.includes("code_review") || normalized.includes("code review")) { return "Code Review 额度"; @@ -447,41 +590,64 @@ function formatWindowLabel( function extractExtraRateLimitWindows(raw: string | null | undefined): ExtraUsageDisplayRow[] { const credits = parseCreditsJson(raw); - const payload = asObjectRecord(credits); - const items = Array.isArray(payload?.[EXTRA_RATE_LIMITS_JSON_KEY]) - ? (payload?.[EXTRA_RATE_LIMITS_JSON_KEY] as unknown[]) - : []; + const items = normalizeExtraRateLimitItems(credits); + const seenIds = new Map(); return items.reduce((rows, item, index) => { - const source = asObjectRecord(item); - if (!source) return rows; - + const source = item; + const identifiers = rateLimitEntryIdentifiers(source); const labelSeed = - (typeof source.limit_name === "string" && source.limit_name.trim()) || - (typeof source.limit_id === "string" && source.limit_id.trim()) || - (typeof source.source_key === "string" && source.source_key.trim()) || + objectTextValue(source, ["limit_name", "limitName"]) || + objectTextValue(source, ["limit_id", "limitId"]) || + objectTextValue(source, ["source_key", "sourceKey"]) || + objectTextValue(source, ["metered_feature", "meteredFeature"]) || `extra-${index + 1}`; - const baseLabel = humanizeExtraRateLimitLabel(labelSeed); + const baseLabel = identifiers.some(isLunaReserveIdentifier) + ? "Luna Reserve 额度" + : humanizeExtraRateLimitLabel(labelSeed); + const windowContainer = + asObjectRecord(firstObjectValue(source, ["rate_limit", "rateLimit"])) ?? source; const windows = [ - { key: "primary_window" }, - { key: "secondary_window", suffix: " · 长周期" }, + { key: "primary_window", aliases: ["primary_window", "primaryWindow"] }, + { + key: "secondary_window", + aliases: ["secondary_window", "secondaryWindow"], + suffix: " · 长周期", + }, ]; - for (const { key, suffix } of windows) { - const window = asObjectRecord(source[key]); + for (const { key, aliases, suffix } of windows) { + const window = asObjectRecord(firstObjectValue(windowContainer, aliases)); if (!window) continue; - const remainPercent = remainingPercent(toNullableNumber(window.used_percent)); - const resetsAt = toNullableNumber(window.reset_at); - const windowSeconds = toNullableNumber(window.limit_window_seconds); + const explicitRemaining = toNullableNumber( + firstObjectValue(window, ["remaining_percent", "remainingPercent"]), + ); + const remainPercent = + explicitRemaining == null + ? remainingPercent( + toNullableNumber(firstObjectValue(window, ["used_percent", "usedPercent"])), + ) + : Math.max(0, Math.min(100, Math.round(explicitRemaining))); + const resetsAt = toNullableNumber(firstObjectValue(window, ["reset_at", "resetAt"])); + const windowSeconds = toNullableNumber( + firstObjectValue(window, ["limit_window_seconds", "limitWindowSeconds"]), + ); const minutes = windowSeconds == null ? null : Math.max(1, Math.ceil(windowSeconds / 60)); if (remainPercent == null && resetsAt == null && minutes == null) { continue; } const windowLabel = formatWindowLabel(minutes); + const idSeed = identifiers.join("-") || `extra-${index + 1}`; + const normalizedId = `${idSeed}-${key}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); + const duplicateCount = seenIds.get(normalizedId) ?? 0; + seenIds.set(normalizedId, duplicateCount + 1); rows.push({ - id: `${labelSeed}-${key}-${index}`, + id: duplicateCount > 0 ? `${normalizedId}-${duplicateCount}` : normalizedId, label: baseLabel, labelSuffix: suffix, remainPercent, @@ -495,6 +661,17 @@ function extractExtraRateLimitWindows(raw: string | null | undefined): ExtraUsag }, []); } +export function hasUsableLunaReserve( + usage?: Partial | null, +): boolean { + const credits = parseCreditsJson(usage?.creditsJson); + return normalizeExtraRateLimitItems(credits).some( + (source) => + rateLimitEntryIdentifiers(source).some(isLunaReserveIdentifier) && + isUsableRateLimitEntry(source), + ); +} + /** * 函数 `extractPlanTypeRecursive` * @@ -665,6 +842,10 @@ export function calcAvailability( ): { text: string; level: AvailabilityLevel } { const primaryExhausted = (usage?.usedPercent ?? 0) >= 100; const secondaryExhausted = (usage?.secondaryUsedPercent ?? 0) >= 100; + const reserveAvailable = hasUsableLunaReserve(usage); + const normalizedStatus = String(usage?.availabilityStatus || "") + .trim() + .toLowerCase(); if (isDisabledAccount(account)) { return { text: "已禁用", level: "bad" }; @@ -675,22 +856,28 @@ export function calcAvailability( if (isBannedAccount(account)) { return { text: "封禁", level: "bad" }; } - if (isLimitedAccount(account)) { - return { text: "限流", level: "bad" }; - } if (isUnavailableAccount(account)) { return { text: "不可用", level: "bad" }; } if (account?.hasToken === false) { return { text: "缺少授权 Token", level: "bad" }; } + if (isForceEnabledAccount(account)) { + return { text: "强制开启", level: "ok" }; + } + if (normalizedStatus === "available_luna_reserve") { + return { text: "仅 Luna Reserve", level: "ok" }; + } + if (reserveAvailable && (primaryExhausted || secondaryExhausted)) { + return { text: "仅 Luna Reserve", level: "ok" }; + } + if (isLimitedAccount(account)) { + return { text: "限流", level: "bad" }; + } if (!usage) { return { text: "未知", level: "unknown" }; } - const normalizedStatus = String(usage.availabilityStatus || "") - .trim() - .toLowerCase(); const displayMode = getUsageWindowDisplayMode(usage); if (normalizedStatus === "available") { return { text: "可用", level: "ok" }; diff --git a/apps/tests/luna-reserve-usage.test.mjs b/apps/tests/luna-reserve-usage.test.mjs new file mode 100644 index 000000000..4e3311893 --- /dev/null +++ b/apps/tests/luna-reserve-usage.test.mjs @@ -0,0 +1,102 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { pathToFileURL } from "node:url"; +import ts from "../node_modules/typescript/lib/typescript.js"; + +const appsRoot = path.resolve(import.meta.dirname, ".."); +const sourcePath = path.join(appsRoot, "src", "lib", "utils", "usage.ts"); + +async function loadUsageModule() { + const source = await fs.readFile(sourcePath, "utf8"); + const compiled = ts.transpileModule( + source + .replace( + 'import { formatLocalDateTimeFromSeconds } from "@/lib/utils/time";', + 'const formatLocalDateTimeFromSeconds = (timestamp, emptyLabel) => emptyLabel || String(timestamp || "");', + ) + .replace('import { Account, AccountUsage, AvailabilityLevel, RequestLog } from "@/types";', ""), + { + compilerOptions: { + module: ts.ModuleKind.ES2022, + target: ts.ScriptTarget.ES2022, + }, + fileName: sourcePath, + }, + ); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "codexmanager-luna-reserve-")); + const tempFile = path.join(tempDir, "usage.mjs"); + await fs.writeFile(tempFile, compiled.outputText, "utf8"); + return import(pathToFileURL(tempFile).href); +} + +const usage = await loadUsageModule(); + +const reserveCreditsJson = JSON.stringify({ + additionalRateLimits: [ + { + limitName: "Luna Reserve", + meteredFeature: "base_model_inference", + allowed: true, + limitReached: false, + rateLimit: { + primaryWindow: { + remainingPercent: 80, + limitWindowSeconds: 604800, + }, + }, + }, + ], +}); + +test("Luna Reserve 的 camelCase 用量会显示且保持可用", () => { + const snapshot = { + usedPercent: 100, + secondaryUsedPercent: 100, + creditsJson: reserveCreditsJson, + }; + + assert.equal(usage.hasUsableLunaReserve(snapshot), true); + assert.deepEqual(usage.calcAvailability(snapshot, { status: "active" }), { + text: "仅 Luna Reserve", + level: "ok", + }); + const rows = usage.getExtraUsageDisplayRows(snapshot); + assert.equal(rows.length, 1); + assert.equal(rows[0].label, "Luna Reserve 额度"); + assert.equal(rows[0].remainPercent, 80); +}); + +test("强制开启状态绕过额度状态并默认关闭", () => { + assert.deepEqual(usage.calcAvailability(undefined, { status: "force_enabled" }), { + text: "强制开启", + level: "ok", + }); + assert.deepEqual(usage.calcAvailability(undefined, { status: "active" }), { + text: "未知", + level: "unknown", + }); +}); + +test("明确耗尽的 Luna Reserve 不会被当作可用额度", () => { + const exhausted = { + usedPercent: 100, + creditsJson: JSON.stringify({ + additionalRateLimits: [ + { + limitName: "Luna Reserve", + limitReached: true, + rateLimit: { primaryWindow: { remainingPercent: 100 } }, + }, + ], + }), + }; + assert.equal(usage.hasUsableLunaReserve(exhausted), false); + assert.deepEqual(usage.calcAvailability(exhausted, { status: "limited" }), { + text: "限流", + level: "bad", + }); +}); diff --git a/crates/core/src/storage/accounts.rs b/crates/core/src/storage/accounts.rs index 357c073aa..33a811863 100644 --- a/crates/core/src/storage/accounts.rs +++ b/crates/core/src/storage/accounts.rs @@ -961,7 +961,13 @@ impl Storage { /// # 返回 /// 返回函数执行结果 pub fn list_gateway_candidates(&self) -> Result> { - list_gateway_candidates_filtered(self, None) + list_gateway_candidates_filtered(self, None, true) + } + + /// Lists token-bearing accounts while leaving usage-window eligibility to the caller. + /// Hard account states remain excluded so this is safe for model-specific reserve routing. + pub fn list_gateway_candidates_unfiltered(&self) -> Result> { + list_gateway_candidates_filtered(self, None, false) } pub fn list_gateway_candidates_for_accounts( @@ -975,7 +981,29 @@ impl Storage { let mut out = Vec::new(); for chunk in account_ids.chunks(SQLITE_IN_CLAUSE_BATCH_SIZE) { - out.extend(list_gateway_candidates_filtered(self, Some(chunk))?); + out.extend(list_gateway_candidates_filtered(self, Some(chunk), true)?); + } + out.sort_by(|(left, _), (right, _)| { + left.sort + .cmp(&right.sort) + .then_with(|| right.updated_at.cmp(&left.updated_at)) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(out) + } + + pub fn list_gateway_candidates_unfiltered_for_accounts( + &self, + account_ids: &[String], + ) -> Result> { + let account_ids = normalize_text_ids(account_ids); + if account_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut out = Vec::new(); + for chunk in account_ids.chunks(SQLITE_IN_CLAUSE_BATCH_SIZE) { + out.extend(list_gateway_candidates_filtered(self, Some(chunk), false)?); } out.sort_by(|(left, _), (right, _)| { left.sort @@ -1806,7 +1834,7 @@ fn active_account_codex_profile_candidates_for_ids_chunk_sql(condition: &str) -> "SELECT id, label, issuer, chatgpt_account_id, workspace_id, group_name, status, sort, updated_at FROM accounts WHERE {condition} - AND LOWER(TRIM(COALESCE(status, ''))) = 'active'" + AND LOWER(TRIM(COALESCE(status, ''))) IN ('active', 'force_enabled')" ) } @@ -2170,8 +2198,13 @@ fn list_account_dashboard_source_metadata_for_ids_chunk( fn list_gateway_candidates_filtered( storage: &Storage, account_ids: Option<&[String]>, + require_available_usage: bool, ) -> Result> { - let availability_clause = gateway_account_usage_filter_clause("a", "lu"); + let availability_clause = if require_available_usage { + gateway_account_usage_filter_clause("a", "lu") + } else { + gateway_account_status_filter_clause("a") + }; let mut usage_cte_params = Vec::new(); let latest_usage_cte = if let Some(account_ids) = account_ids { let Some((usage_condition, usage_params)) = text_id_in_clause("account_id", account_ids) @@ -2276,7 +2309,9 @@ fn latest_usage_cte_sql_for_condition(where_condition: &str) -> String { } fn available_account_status_clause(account_alias: &str) -> String { - format!("LOWER(TRIM(COALESCE({account_alias}.status, ''))) IN ('active', 'available')") + format!( + "LOWER(TRIM(COALESCE({account_alias}.status, ''))) IN ('active', 'available', 'force_enabled')" + ) } fn remaining_percent_sql(percent_expr: &str) -> String { @@ -2328,9 +2363,23 @@ fn available_usage_clause(usage_alias: &str) -> String { /// 返回函数执行结果 fn gateway_account_usage_filter_clause(account_alias: &str, usage_alias: &str) -> String { format!( - "LOWER(TRIM(COALESCE({account_alias}.status, ''))) NOT IN ('inactive', 'disabled', 'unavailable', 'limited', 'banned') - AND ({usage_alias}.account_id IS NULL OR ({}))", - available_usage_clause(usage_alias) + "{status_clause} + AND (LOWER(TRIM(COALESCE({account_alias}.status, ''))) = 'force_enabled' + OR {usage_alias}.account_id IS NULL OR ({available_clause}))", + status_clause = gateway_account_usage_status_filter_clause(account_alias), + available_clause = available_usage_clause(usage_alias) + ) +} + +fn gateway_account_usage_status_filter_clause(account_alias: &str) -> String { + format!( + "LOWER(TRIM(COALESCE({account_alias}.status, ''))) NOT IN ('inactive', 'disabled', 'unavailable', 'limited', 'banned')" + ) +} + +fn gateway_account_status_filter_clause(account_alias: &str) -> String { + format!( + "LOWER(TRIM(COALESCE({account_alias}.status, ''))) NOT IN ('inactive', 'disabled', 'unavailable', 'banned')" ) } diff --git a/crates/core/src/storage/accounts_tests.rs b/crates/core/src/storage/accounts_tests.rs index 14e557ec3..a7f1e5b24 100644 --- a/crates/core/src/storage/accounts_tests.rs +++ b/crates/core/src/storage/accounts_tests.rs @@ -841,11 +841,14 @@ fn list_available_account_quota_pool_sources_filters_and_reads_only_id_label() { second.label = "Second Pool".to_string(); second.sort = 0; second.workspace_id = Some("ignored-workspace".to_string()); + let mut force_enabled = sample_account("acc-force-enabled-pool-source", "force_enabled", now); + force_enabled.label = "Force Pool".to_string(); + force_enabled.sort = 2; let mut disabled = sample_account("acc-disabled-pool-source", "disabled", now); disabled.label = "Disabled Pool".to_string(); disabled.sort = -1; - for account in [&first, &second, &disabled] { + for account in [&first, &second, &force_enabled, &disabled] { storage.insert_account(account).expect("insert account"); } @@ -853,11 +856,13 @@ fn list_available_account_quota_pool_sources_filters_and_reads_only_id_label() { .list_available_account_quota_pool_sources() .expect("list account quota pool sources"); - assert_eq!(sources.len(), 2); + assert_eq!(sources.len(), 3); assert_eq!(sources[0].id, "acc-second-pool-source"); assert_eq!(sources[0].label, "Second Pool"); assert_eq!(sources[1].id, "acc-first-pool-source"); assert_eq!(sources[1].label, "First Pool"); + assert_eq!(sources[2].id, "acc-force-enabled-pool-source"); + assert_eq!(sources[2].label, "Force Pool"); } #[test] @@ -1925,7 +1930,14 @@ fn list_active_account_codex_profile_candidates_for_ids_filters_active_and_reads let mut disabled = sample_account("acc-disabled-codex-profile", "disabled", now); disabled.label = "Disabled Codex".to_string(); disabled.sort = -1; - for account in [&first, &second, &disabled] { + let mut force_enabled = sample_account("acc-force-codex-profile", "force_enabled", now); + force_enabled.label = "Force Codex".to_string(); + force_enabled.issuer = "issuer-force".to_string(); + force_enabled.chatgpt_account_id = Some("cgpt-force".to_string()); + force_enabled.workspace_id = Some("ws-force".to_string()); + force_enabled.group_name = Some("group-force".to_string()); + force_enabled.sort = 2; + for account in [&first, &second, &disabled, &force_enabled] { storage.insert_account(account).expect("insert account"); } @@ -1934,10 +1946,11 @@ fn list_active_account_codex_profile_candidates_for_ids_filters_active_and_reads "acc-disabled-codex-profile".to_string(), "acc-first-codex-profile".to_string(), "acc-second-codex-profile".to_string(), + "acc-force-codex-profile".to_string(), ]) .expect("list codex profile account candidates"); - assert_eq!(targets.len(), 2); + assert_eq!(targets.len(), 3); assert_eq!(targets[0].id, "acc-second-codex-profile"); assert_eq!(targets[0].label, "Second Codex"); assert_eq!(targets[0].issuer, "issuer-second"); @@ -1951,6 +1964,9 @@ fn list_active_account_codex_profile_candidates_for_ids_filters_active_and_reads assert_eq!(targets[1].id, "acc-first-codex-profile"); assert_eq!(targets[1].label, "First Codex"); assert_eq!(targets[1].issuer, "issuer-first"); + assert_eq!(targets[2].id, "acc-force-codex-profile"); + assert_eq!(targets[2].label, "Force Codex"); + assert_eq!(targets[2].status, "force_enabled"); } #[test] @@ -2343,6 +2359,56 @@ fn list_gateway_candidates_for_accounts_filters_requested_available_accounts() { ); } +#[test] +fn list_gateway_candidates_force_enabled_bypasses_usage_window_filter() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let now = now_ts(); + let force_enabled = sample_account("acc-force-enabled", "force_enabled", now); + let limited = sample_account("acc-limited", "limited", now); + for account in [&force_enabled, &limited] { + storage.insert_account(account).expect("insert account"); + storage + .insert_token(&sample_token(account.id.as_str(), now)) + .expect("insert token"); + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: account.id.clone(), + used_percent: Some(100.0), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: Some(100.0), + secondary_window_minutes: Some(10080), + secondary_resets_at: None, + credits_json: None, + captured_at: now, + }) + .expect("insert saturated usage"); + } + + let normal = storage + .list_gateway_candidates() + .expect("list normal gateway candidates"); + assert_eq!( + normal + .iter() + .map(|(account, _)| account.id.as_str()) + .collect::>(), + vec!["acc-force-enabled"] + ); + + let unfiltered = storage + .list_gateway_candidates_unfiltered() + .expect("list unfiltered gateway candidates"); + assert_eq!( + unfiltered + .iter() + .map(|(account, _)| account.id.as_str()) + .collect::>(), + vec!["acc-force-enabled", "acc-limited"] + ); +} + #[test] fn gateway_candidates_for_accounts_scope_latest_usage_cte_to_requested_ids() { let storage = Storage::open_in_memory().expect("open"); diff --git a/crates/core/src/usage/mod.rs b/crates/core/src/usage/mod.rs index b64bc555c..9a6d4e22c 100644 --- a/crates/core/src/usage/mod.rs +++ b/crates/core/src/usage/mod.rs @@ -3,6 +3,7 @@ use serde_json::Value; const EXTRA_RATE_LIMITS_JSON_KEY: &str = "_codexmanager_extra_rate_limits"; pub const RESET_CREDITS_JSON_KEY: &str = "rate_limit_reset_credits"; +const ADDITIONAL_RATE_LIMITS_KEYS: [&str; 2] = ["additional_rate_limits", "additionalRateLimits"]; #[derive(Debug, Clone)] pub struct UsageSnapshot { @@ -44,14 +45,49 @@ pub struct ResetCreditConsumeResult { pub warning: Option, } +fn object_value<'a>(obj: &'a serde_json::Map, keys: &[&str]) -> Option<&'a Value> { + keys.iter().find_map(|key| obj.get(*key)) +} + +fn object_string<'a>(obj: &'a serde_json::Map, keys: &[&str]) -> Option<&'a str> { + object_value(obj, keys).and_then(Value::as_str) +} + +fn normalized_identifier(value: &str) -> String { + value + .trim() + .to_ascii_lowercase() + .chars() + .filter(|ch| ch.is_ascii_alphanumeric()) + .collect() +} + +fn is_luna_reserve_identifier(value: &str) -> bool { + let normalized = normalized_identifier(value); + normalized.contains("gptreserve") + || normalized.contains("lunareserve") + || normalized.contains("basemodelinference") + || (normalized.contains("luna") && normalized.contains("reserve")) +} + +fn is_extra_rate_limit_key(key: &str) -> bool { + let normalized_key = key.to_ascii_lowercase().replace('-', "_"); + normalized_key.ends_with("_rate_limit") + || normalized_key.ends_with("ratelimit") + || (normalized_key.contains("luna") && normalized_key.contains("reserve")) + || (normalized_key.contains("gpt") && normalized_key.contains("reserve")) +} + fn normalize_rate_limit_entry(source_key: Option<&str>, value: &Value) -> Option { let obj = value.as_object()?; let rate_limit = obj .get("rate_limit") + .or_else(|| obj.get("rateLimit")) .and_then(Value::as_object) .unwrap_or(obj); - let has_primary = rate_limit.get("primary_window").is_some(); - let has_secondary = rate_limit.get("secondary_window").is_some(); + let has_primary = object_value(rate_limit, &["primary_window", "primaryWindow"]).is_some(); + let has_secondary = + object_value(rate_limit, &["secondary_window", "secondaryWindow"]).is_some(); if !has_primary && !has_secondary { return None; } @@ -63,30 +99,45 @@ fn normalize_rate_limit_entry(source_key: Option<&str>, value: &Value) -> Option Value::String(source_key.to_string()), ); } - for key in ["limit_name", "metered_feature"] { - if let Some(field) = obj.get(key) { + for (key, aliases) in [ + ("limit_name", &["limit_name", "limitName"][..]), + ( + "metered_feature", + &["metered_feature", "meteredFeature"][..], + ), + ] { + if let Some(field) = + object_value(obj, aliases).or_else(|| object_value(rate_limit, aliases)) + { normalized.insert(key.to_string(), field.clone()); } } - if let Some(field) = obj.get("limit_id").or_else(|| obj.get("metered_feature")) { + if let Some(field) = object_value(obj, &["limit_id", "limitId"]) + .or_else(|| object_value(obj, &["metered_feature", "meteredFeature"])) + .or_else(|| object_value(rate_limit, &["limit_id", "limitId"])) + .or_else(|| object_value(rate_limit, &["metered_feature", "meteredFeature"])) + { normalized.insert("limit_id".to_string(), field.clone()); } - for key in ["allowed", "limit_reached"] { - if let Some(field) = obj.get(key).or_else(|| rate_limit.get(key)) { + for (key, aliases) in [ + ("allowed", &["allowed"][..]), + ("limit_reached", &["limit_reached", "limitReached"][..]), + ] { + if let Some(field) = + object_value(obj, aliases).or_else(|| object_value(rate_limit, aliases)) + { normalized.insert(key.to_string(), field.clone()); } } normalized.insert( "primary_window".to_string(), - rate_limit - .get("primary_window") + object_value(rate_limit, &["primary_window", "primaryWindow"]) .cloned() .unwrap_or(Value::Null), ); normalized.insert( "secondary_window".to_string(), - rate_limit - .get("secondary_window") + object_value(rate_limit, &["secondary_window", "secondaryWindow"]) .cloned() .unwrap_or(Value::Null), ); @@ -100,7 +151,12 @@ fn collect_extra_rate_limits(value: &Value) -> Vec { }; for (key, nested) in root { - if key == "rate_limit" || !key.ends_with("_rate_limit") { + if key == "rate_limit" + || key == "rateLimit" + || key == EXTRA_RATE_LIMITS_JSON_KEY + || ADDITIONAL_RATE_LIMITS_KEYS.contains(&key.as_str()) + || !is_extra_rate_limit_key(key) + { continue; } if let Some(item) = normalize_rate_limit_entry(Some(key.as_str()), nested) { @@ -108,14 +164,19 @@ fn collect_extra_rate_limits(value: &Value) -> Vec { } } - match root.get("additional_rate_limits") { + let additional = ADDITIONAL_RATE_LIMITS_KEYS + .iter() + .find_map(|key| root.get(*key)); + match additional { Some(Value::Array(items)) => { for (index, item) in items.iter().enumerate() { let source_key = item - .get("limit_id") - .and_then(Value::as_str) - .or_else(|| item.get("metered_feature").and_then(Value::as_str)) - .or_else(|| item.get("limit_name").and_then(Value::as_str)) + .as_object() + .and_then(|item| { + object_string(item, &["limit_id", "limitId"]) + .or_else(|| object_string(item, &["metered_feature", "meteredFeature"])) + .or_else(|| object_string(item, &["limit_name", "limitName"])) + }) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) @@ -137,9 +198,158 @@ fn collect_extra_rate_limits(value: &Value) -> Vec { _ => {} } + if let Some(Value::Array(items)) = root.get(EXTRA_RATE_LIMITS_JSON_KEY) { + for (index, item) in items.iter().enumerate() { + let source_key = item + .as_object() + .and_then(|item| object_string(item, &["source_key", "sourceKey"])) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| format!("{EXTRA_RATE_LIMITS_JSON_KEY}[{index}]")); + if let Some(normalized) = normalize_rate_limit_entry(Some(source_key.as_str()), item) { + out.push(normalized); + } + } + } + + if let Some(credits) = root.get("credits") { + out.extend(collect_extra_rate_limits(credits)); + } + out } +/// Returns whether a usage response explicitly contains additional rate-limit data. +pub fn usage_payload_declares_extra_rate_limits(value: &Value) -> bool { + let Some(root) = value.as_object() else { + return false; + }; + if root.contains_key(EXTRA_RATE_LIMITS_JSON_KEY) + || ADDITIONAL_RATE_LIMITS_KEYS + .iter() + .any(|key| root.contains_key(*key)) + { + return true; + } + if root + .iter() + .any(|(key, _)| key != "rate_limit" && key != "rateLimit" && is_extra_rate_limit_key(key)) + { + return true; + } + root.get("credits") + .is_some_and(usage_payload_declares_extra_rate_limits) +} + +fn rate_limit_entry_identifier(entry: &Value) -> impl Iterator { + ["source_key", "limit_id", "limit_name", "metered_feature"] + .into_iter() + .filter_map(|key| entry.get(key).and_then(Value::as_str)) +} + +fn rate_limit_window_is_usable(window: Option<&Value>) -> bool { + let Some(window) = window.and_then(Value::as_object) else { + return false; + }; + if let Some(remaining) = + object_value(window, &["remaining_percent", "remainingPercent"]).and_then(Value::as_f64) + { + return remaining > 0.0; + } + object_value(window, &["used_percent", "usedPercent"]) + .and_then(Value::as_f64) + .is_some_and(|used| used < 100.0) +} + +fn rate_limit_entry_is_usable(entry: &Value) -> bool { + let Some(obj) = entry.as_object() else { + return false; + }; + if object_value(obj, &["allowed"]) + .and_then(Value::as_bool) + .is_some_and(|allowed| !allowed) + { + return false; + } + if object_value(obj, &["limit_reached", "limitReached"]) + .and_then(Value::as_bool) + .is_some_and(|reached| reached) + { + return false; + } + rate_limit_window_is_usable(obj.get("primary_window")) + || rate_limit_window_is_usable(obj.get("secondary_window")) +} + +/// Returns whether a stored usage payload contains a usable Luna Reserve window. +pub fn has_usable_luna_reserve(credits_json: Option<&str>) -> bool { + let Some(raw) = credits_json.map(str::trim).filter(|raw| !raw.is_empty()) else { + return false; + }; + let Ok(value) = serde_json::from_str::(raw) else { + return false; + }; + collect_extra_rate_limits(&value).iter().any(|entry| { + rate_limit_entry_identifier(entry).any(is_luna_reserve_identifier) + && rate_limit_entry_is_usable(entry) + }) +} + +/// Merges the previous extra rate-limit buckets when a newer usage response omits them. +/// An explicitly supplied current bucket list remains authoritative. +pub fn merge_missing_extra_rate_limits( + current_credits_json: Option<&str>, + previous_credits_json: Option<&str>, +) -> Option { + let current_raw = current_credits_json + .map(str::trim) + .filter(|raw| !raw.is_empty()); + let previous = previous_credits_json + .map(str::trim) + .filter(|raw| !raw.is_empty()) + .and_then(|raw| serde_json::from_str::(raw).ok()); + let previous_extra = previous + .as_ref() + .and_then(Value::as_object) + .and_then(|obj| obj.get(EXTRA_RATE_LIMITS_JSON_KEY)) + .filter(|value| !value.is_null()) + .cloned(); + + let Some(previous_extra) = previous_extra else { + return current_raw.map(ToString::to_string); + }; + + let mut current = match current_raw { + Some(raw) => match serde_json::from_str::(raw).ok() { + Some(value) => match value { + Value::Object(obj) => obj, + value => { + let mut wrapped = serde_json::Map::new(); + wrapped.insert("credits".to_string(), value); + wrapped + } + }, + None => return Some(raw.to_string()), + }, + None => serde_json::Map::new(), + }; + if current.contains_key(EXTRA_RATE_LIMITS_JSON_KEY) { + return Some(Value::Object(current).to_string()); + } + current.insert(EXTRA_RATE_LIMITS_JSON_KEY.to_string(), previous_extra); + Some(Value::Object(current).to_string()) +} + +/// Returns whether a request model is eligible for the Luna Reserve candidate pool. +pub fn is_luna_reserve_model(model: Option<&str>) -> bool { + let Some(model) = model.map(str::trim).filter(|model| !model.is_empty()) else { + return false; + }; + let normalized = normalized_identifier(model); + normalized.contains("luna") || normalized.contains("gptreserve") +} + fn serialize_credits_payload( credits: Option<&Value>, extra_rate_limits: &[Value], diff --git a/crates/core/tests/usage.rs b/crates/core/tests/usage.rs index 2be447b06..77a8f3e98 100644 --- a/crates/core/tests/usage.rs +++ b/crates/core/tests/usage.rs @@ -1,6 +1,8 @@ use codexmanager_core::usage::{ - accounts_check_endpoint, parse_reset_credits_snapshot, parse_usage_snapshot, + accounts_check_endpoint, has_usable_luna_reserve, is_luna_reserve_model, + merge_missing_extra_rate_limits, parse_reset_credits_snapshot, parse_usage_snapshot, reset_credits_consume_endpoint, reset_credits_endpoint, usage_endpoint, + usage_payload_declares_extra_rate_limits, }; use serde_json::json; @@ -121,3 +123,125 @@ fn reset_credit_snapshot_parses_compatible_fields() { assert_eq!(snapshot.next_expires_at, Some(future)); assert_eq!(snapshot.credits[1].status.as_deref(), Some("expired")); } + +#[test] +fn luna_reserve_survives_camel_case_usage_payload_and_exhausted_standard_window() { + let payload = json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 100.0, + "limit_window_seconds": 604800 + } + }, + "additionalRateLimits": [ + { + "limitName": "Luna Reserve", + "meteredFeature": "base_model_inference", + "allowed": true, + "limitReached": false, + "rateLimit": { + "primaryWindow": { + "usedPercent": 0.0, + "remainingPercent": 100.0, + "limitWindowSeconds": 604800 + } + } + } + ] + }); + + let snapshot = parse_usage_snapshot(&payload); + assert!(has_usable_luna_reserve(snapshot.credits_json.as_deref())); + assert!(is_luna_reserve_model(Some("gpt-5.6-luna"))); + assert!(is_luna_reserve_model(Some("gpt-reserve"))); + assert!(!is_luna_reserve_model(Some("gpt-5.6"))); + + let credits: serde_json::Value = + serde_json::from_str(snapshot.credits_json.as_deref().expect("credits json")) + .expect("parse credits json"); + let reserve = &credits["_codexmanager_extra_rate_limits"][0]; + assert_eq!(reserve["limit_name"], "Luna Reserve"); + assert_eq!(reserve["metered_feature"], "base_model_inference"); + assert_eq!(reserve["primary_window"]["remainingPercent"], 100.0); +} + +#[test] +fn luna_reserve_is_unusable_when_explicitly_reached_or_empty() { + for reserve in [ + json!({ + "limitName": "Luna Reserve", + "limitReached": true, + "rateLimit": { "primaryWindow": { "remainingPercent": 100.0 } } + }), + json!({ + "limitName": "Luna Reserve", + "rateLimit": { "primaryWindow": { "remainingPercent": 0.0 } } + }), + ] { + let payload = json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "additionalRateLimits": [reserve] + }); + let snapshot = parse_usage_snapshot(&payload); + assert!(!has_usable_luna_reserve(snapshot.credits_json.as_deref())); + } +} + +#[test] +fn nested_extra_rate_limits_are_preserved_and_explicit_empty_is_authoritative() { + let first = json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "credits": { + "additionalRateLimits": [{ + "limitName": "Luna Reserve", + "rateLimit": { "primaryWindow": { "remainingPercent": 70.0 } } + }] + } + }); + let first_snapshot = parse_usage_snapshot(&first); + assert!(usage_payload_declares_extra_rate_limits(&first)); + assert!(has_usable_luna_reserve( + first_snapshot.credits_json.as_deref() + )); + + let second = json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "credits": { "balance": 1.0 } + }); + assert!(!usage_payload_declares_extra_rate_limits(&second)); + let merged = merge_missing_extra_rate_limits( + parse_usage_snapshot(&second).credits_json.as_deref(), + first_snapshot.credits_json.as_deref(), + ); + assert!(has_usable_luna_reserve(merged.as_deref())); + + let explicit_empty = json!({ + "rate_limit": { "primary_window": { "used_percent": 100.0 } }, + "additionalRateLimits": [] + }); + assert!(usage_payload_declares_extra_rate_limits(&explicit_empty)); + assert!(!has_usable_luna_reserve( + parse_usage_snapshot(&explicit_empty) + .credits_json + .as_deref() + )); +} diff --git a/crates/service/src/account/account_availability.rs b/crates/service/src/account/account_availability.rs index e95b263b0..a5a6e64cd 100644 --- a/crates/service/src/account/account_availability.rs +++ b/crates/service/src/account/account_availability.rs @@ -1,4 +1,5 @@ use codexmanager_core::storage::UsageSnapshotRecord; +use codexmanager_core::usage::has_usable_luna_reserve; pub(crate) enum Availability { Available, @@ -26,11 +27,17 @@ pub(crate) fn evaluate_snapshot(snap: &UsageSnapshotRecord) -> Availability { // 这样可以避免快照字段短暂不完整时误伤仍有额度的账号。 if let Some(value) = snap.used_percent { if value >= 100.0 { + if has_usable_luna_reserve(snap.credits_json.as_deref()) { + return Availability::Available; + } return Availability::Unavailable("usage_exhausted_primary"); } } if let Some(value) = snap.secondary_used_percent { if value >= 100.0 { + if has_usable_luna_reserve(snap.credits_json.as_deref()) { + return Availability::Available; + } return Availability::Unavailable("usage_exhausted_secondary"); } } diff --git a/crates/service/src/account/account_status.rs b/crates/service/src/account/account_status.rs index b0c2bd0ca..789d28cc1 100644 --- a/crates/service/src/account/account_status.rs +++ b/crates/service/src/account/account_status.rs @@ -138,6 +138,20 @@ fn should_preserve_manual_account_status(storage: &Storage, account_id: &str) -> .unwrap_or(false) } +fn should_preserve_usage_limit_status(storage: &Storage, account_id: &str) -> bool { + storage + .find_account_status_by_id(account_id) + .ok() + .flatten() + .map(|status| { + let normalized = status.trim(); + normalized.eq_ignore_ascii_case("disabled") + || normalized.eq_ignore_ascii_case("inactive") + || normalized.eq_ignore_ascii_case("force_enabled") + }) + .unwrap_or(false) +} + /// 函数 `classify_account_availability_signal` /// /// 作者: gaohongshun @@ -323,7 +337,7 @@ fn set_account_unavailable_with_reason(storage: &Storage, account_id: &str, reas } fn set_account_limited_with_reason(storage: &Storage, account_id: &str, reason: &str) -> bool { - if should_preserve_manual_account_status(storage, account_id) { + if should_preserve_usage_limit_status(storage, account_id) { return false; } set_account_status(storage, account_id, "limited", reason); @@ -544,6 +558,9 @@ fn set_account_status_after_test_if_context_matches( if matches!(normalized.as_str(), "disabled" | "inactive" | "banned") { return false; } + if normalized == "force_enabled" && status == "limited" { + return false; + } if load_account_status_context(storage, account_id) != *context { return false; } diff --git a/crates/service/src/account/account_status_tests.rs b/crates/service/src/account/account_status_tests.rs index 25be62815..09ebd11cf 100644 --- a/crates/service/src/account/account_status_tests.rs +++ b/crates/service/src/account/account_status_tests.rs @@ -263,6 +263,41 @@ fn stale_account_test_outcomes_do_not_overwrite_newer_banned_status() { ); } +#[test] +fn account_test_rate_limit_does_not_clear_force_enabled_status() { + let _guard = crate::test_env_guard(); + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + let now = now_ts(); + let account_id = "acc-force-enabled-test-rate-limit"; + storage + .insert_account(&Account { + id: account_id.to_string(), + label: "force-enabled-test-rate-limit".to_string(), + issuer: "issuer".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "force_enabled".to_string(), + created_at: now, + updated_at: now, + }) + .expect("insert account"); + let context = load_account_status_context(&storage, account_id); + + assert!(!mark_account_limited_for_test_rate_limit( + &storage, account_id, &context + )); + assert_eq!( + storage + .find_account_status_by_id(account_id) + .expect("read account status") + .as_deref(), + Some("force_enabled") + ); +} + #[test] fn account_test_success_never_restores_an_existing_banned_status() { let _guard = crate::test_env_guard(); diff --git a/crates/service/src/account/account_update.rs b/crates/service/src/account/account_update.rs index d4c0ecf10..049b74e96 100644 --- a/crates/service/src/account/account_update.rs +++ b/crates/service/src/account/account_update.rs @@ -69,6 +69,18 @@ pub(crate) fn update_account( } let mut storage = open_storage().ok_or_else(|| "storage unavailable".to_string())?; + if normalized_status == Some("force_enabled") { + let current_status = storage + .find_account_status_by_id(normalized_account_id) + .map_err(|err| err.to_string())? + .unwrap_or_default(); + if ["disabled", "inactive", "unavailable", "banned"] + .iter() + .any(|status| current_status.trim().eq_ignore_ascii_case(status)) + { + return Err("account status must be active before force enabling".to_string()); + } + } let now = now_ts(); if let Some(preferred) = preferred { if preferred { @@ -109,6 +121,8 @@ pub(crate) fn update_account( if let Some(status) = normalized_status { let reason = if status == "disabled" { "manual_disable" + } else if status == "force_enabled" { + "manual_force_enable" } else { "manual_enable" }; @@ -237,6 +251,7 @@ fn normalize_account_status(status: &str) -> Result<&'static str, String> { let normalized = status.trim().to_ascii_lowercase(); match normalized.as_str() { "active" => Ok("active"), + "force_enabled" => Ok("force_enabled"), "disabled" | "inactive" => Ok("disabled"), _ => Err(format!("unsupported account status: {status}")), } diff --git a/crates/service/src/account/account_update_tests.rs b/crates/service/src/account/account_update_tests.rs index e1762cae3..744663397 100644 --- a/crates/service/src/account/account_update_tests.rs +++ b/crates/service/src/account/account_update_tests.rs @@ -171,6 +171,74 @@ fn update_account_group_name_trims_and_clears_explicitly() { ); } +#[test] +fn update_account_toggles_force_enabled_status_and_rejects_hard_states() { + let _lock = crate::test_env_guard(); + let (db_path, _guard) = set_test_db("account-update-force-enabled"); + let storage = Storage::open(&db_path).expect("open db"); + storage + .insert_account(&account("acc-force-enabled", 1)) + .expect("insert account"); + + update_account( + "acc-force-enabled", + None, + None, + Some("force_enabled"), + None, + None, + false, + None, + None, + None, + None, + ) + .expect("enable force status"); + assert_eq!( + Storage::open(&db_path) + .expect("reopen db") + .find_account_by_id("acc-force-enabled") + .expect("find") + .expect("exists") + .status, + "force_enabled" + ); + + update_account( + "acc-force-enabled", + None, + None, + Some("active"), + None, + None, + false, + None, + None, + None, + None, + ) + .expect("disable force status"); + let storage = Storage::open(&db_path).expect("reopen active db"); + storage + .update_account_status("acc-force-enabled", "unavailable") + .expect("mark unavailable"); + let err = update_account( + "acc-force-enabled", + None, + None, + Some("force_enabled"), + None, + None, + false, + None, + None, + None, + None, + ) + .expect_err("hard state must be cleared before force enable"); + assert_eq!(err, "account status must be active before force enabling"); +} + #[test] fn update_account_sorts_updates_all_rows_and_records_events() { let _lock = crate::test_env_guard(); diff --git a/crates/service/src/account/tests/account_availability_tests.rs b/crates/service/src/account/tests/account_availability_tests.rs index f77465e87..e634f0669 100644 --- a/crates/service/src/account/tests/account_availability_tests.rs +++ b/crates/service/src/account/tests/account_availability_tests.rs @@ -114,6 +114,20 @@ fn availability_marks_exhausted_secondary_unavailable() { )); } +#[test] +fn availability_keeps_exhausted_standard_windows_available_with_luna_reserve() { + let mut record = snap(Some(100.0), Some(300), Some(100.0), Some(10080)); + record.credits_json = Some( + r#"{"_codexmanager_extra_rate_limits":[{"limit_name":"Luna Reserve","primary_window":{"used_percent":25.0}}]}"# + .to_string(), + ); + + assert!(matches!( + evaluate_snapshot(&record), + Availability::Available + )); +} + /// 函数 `availability_marks_ok_available` /// /// 作者: gaohongshun diff --git a/crates/service/src/codex_profile.rs b/crates/service/src/codex_profile.rs index 6d5d80f21..d8b05f919 100644 --- a/crates/service/src/codex_profile.rs +++ b/crates/service/src/codex_profile.rs @@ -317,7 +317,8 @@ pub(crate) fn apply_direct_account( .find_account_direct_auth_profile_by_id(account_id) .map_err(|err| format!("read account failed: {err}"))? .ok_or_else(|| "account not found".to_string())?; - if account.status.trim() != "active" { + let normalized_status = account.status.trim().to_ascii_lowercase(); + if !matches!(normalized_status.as_str(), "active" | "force_enabled") { return Err("account is not active".to_string()); } let mut token = storage diff --git a/crates/service/src/codex_profile_tests.rs b/crates/service/src/codex_profile_tests.rs index 3d648c19e..f9093a6c7 100644 --- a/crates/service/src/codex_profile_tests.rs +++ b/crates/service/src/codex_profile_tests.rs @@ -453,18 +453,26 @@ fn list_candidates_uses_active_account_projection_and_usable_tokens() { active.group_name = Some("candidate-group".to_string()); let mut disabled = test_account("acc-disabled-candidate", "disabled"); disabled.label = "Disabled Candidate".to_string(); + let mut force_enabled = test_account("acc-force-candidate", "force_enabled"); + force_enabled.label = "Force Candidate".to_string(); storage .insert_account(&active) .expect("insert active account"); storage .insert_account(&disabled) .expect("insert disabled account"); + storage + .insert_account(&force_enabled) + .expect("insert force-enabled account"); storage .insert_token(&test_token("acc-active-candidate", "access", "refresh")) .expect("insert active token"); storage .insert_token(&test_token("acc-disabled-candidate", "access", "refresh")) .expect("insert disabled token"); + storage + .insert_token(&test_token("acc-force-candidate", "access", "refresh")) + .expect("insert force-enabled token"); storage .insert_account(&test_account("acc-missing-refresh", "active")) .expect("insert missing refresh account"); @@ -475,7 +483,7 @@ fn list_candidates_uses_active_account_projection_and_usable_tokens() { let result = list_candidates().expect("list candidates"); - assert_eq!(result.accounts.len(), 1); + assert_eq!(result.accounts.len(), 2); let account = &result.accounts[0]; assert_eq!(account.id, "acc-active-candidate"); assert_eq!(account.label, "Active Candidate"); @@ -491,6 +499,9 @@ fn list_candidates_uses_active_account_projection_and_usable_tokens() { ); assert_eq!(account.issuer, "issuer-acc-active-candidate"); assert_eq!(account.last_refresh, 123); + let force_account = &result.accounts[1]; + assert_eq!(force_account.id, "acc-force-candidate"); + assert_eq!(force_account.status, "force_enabled"); cleanup_profile(&dir); } diff --git a/crates/service/src/gateway/routing/selection.rs b/crates/service/src/gateway/routing/selection.rs index aa9a643f6..ccc79ab14 100644 --- a/crates/service/src/gateway/routing/selection.rs +++ b/crates/service/src/gateway/routing/selection.rs @@ -197,7 +197,13 @@ fn apply_quota_guard( let mut normal = Vec::with_capacity(candidates.len()); let mut low_quota = Vec::new(); for candidate in candidates.drain(..) { - if low_quota_ids.contains(candidate.0.id.as_str()) { + if low_quota_ids.contains(candidate.0.id.as_str()) + && !candidate + .0 + .status + .trim() + .eq_ignore_ascii_case("force_enabled") + { low_quota.push(candidate); } else { normal.push(candidate); diff --git a/crates/service/src/gateway/upstream/support/candidates.rs b/crates/service/src/gateway/upstream/support/candidates.rs index 44e3a3cdb..e7f4d63b9 100644 --- a/crates/service/src/gateway/upstream/support/candidates.rs +++ b/crates/service/src/gateway/upstream/support/candidates.rs @@ -1,5 +1,8 @@ -use codexmanager_core::storage::{Account, Storage, Token, UsageSnapshotRecord}; -use std::collections::HashMap; +use codexmanager_core::storage::{now_ts, Account, Storage, Token, UsageSnapshotRecord}; +use codexmanager_core::usage::{has_usable_luna_reserve, is_luna_reserve_model}; +use std::collections::{HashMap, HashSet}; + +use crate::usage_account_meta::{derive_account_meta, patch_account_meta_in_place}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(in super::super) enum CandidateSkipReason { @@ -32,12 +35,14 @@ pub(crate) fn prepare_gateway_candidates( .map(str::trim) .filter(|value| !value.is_empty() && !value.eq_ignore_ascii_case("all")); let exclude_free_accounts = request_exceeds_free_account_model_ceiling(storage, request_model)?; + let reserve_model = is_luna_reserve_model(request_model); // 中文注释:未受限的 Key 继续复用全局缓存;受限 Key 必须先形成 group + plan // 的授权交集,再在交集内执行额度保护,避免组外账号影响组内低额度兜底。 if normalized_group_filter.is_none() && normalized_plan_filter.is_none() && !exclude_free_accounts + && !reserve_model { return super::super::super::collect_gateway_candidates_with_low_quota_mode( storage, @@ -45,21 +50,25 @@ pub(crate) fn prepare_gateway_candidates( ); } - let mut authorized_candidates = storage - .list_gateway_candidates() - .map_err(|err| format!("list gateway candidates failed: {err}"))?; + let mut authorized_candidates = if reserve_model { + storage.list_gateway_candidates_unfiltered() + } else { + storage.list_gateway_candidates() + } + .map_err(|err| format!("list gateway candidates failed: {err}"))?; if let Some(group_filter) = normalized_group_filter { authorized_candidates.retain(|(account, _)| { crate::account_group::account_matches_group_filter(account, Some(group_filter)) }); } - if normalized_plan_filter.is_some() || exclude_free_accounts { + let mut snapshots = HashMap::new(); + if reserve_model || normalized_plan_filter.is_some() || exclude_free_accounts { let account_ids = authorized_candidates .iter() .map(|(account, _)| account.id.clone()) .collect::>(); - let snapshots = storage + snapshots = storage .latest_usage_snapshots_for_accounts(&account_ids) .map_err(|err| format!("list account usage snapshots failed: {err}"))? .into_iter() @@ -99,11 +108,68 @@ pub(crate) fn prepare_gateway_candidates( .map(|(account, _)| account.id) .collect::>(); // 中文注释:保持账号原始顺序(按账户排序字段)作为候选顺序,失败时再依次切下一个。 - super::super::super::collect_gateway_candidates_for_account_ids_with_low_quota_mode( - storage, - &authorized_account_ids, - low_quota_mode, - ) + let mut candidates = + super::super::super::collect_gateway_candidates_for_account_ids_with_low_quota_mode( + storage, + &authorized_account_ids, + low_quota_mode, + )?; + if !reserve_model { + return Ok(candidates); + } + + let reserve_candidates = + collect_luna_reserve_candidates(storage, &authorized_account_ids, &snapshots)?; + let mut seen = candidates + .iter() + .map(|(account, _)| account.id.clone()) + .collect::>(); + for candidate in reserve_candidates { + if seen.insert(candidate.0.id.clone()) { + candidates.push(candidate); + } + } + Ok(candidates) +} + +fn collect_luna_reserve_candidates( + storage: &Storage, + account_ids: &[String], + snapshots: &HashMap, +) -> Result, String> { + if account_ids.is_empty() { + return Ok(Vec::new()); + } + + let candidates = storage + .list_gateway_candidates_unfiltered_for_accounts(account_ids) + .map_err(|err| format!("list Luna Reserve candidates failed: {err}"))?; + let mut out = Vec::with_capacity(candidates.len()); + for (account, token) in candidates { + let force_enabled = account.status.trim().eq_ignore_ascii_case("force_enabled"); + let reserve_available = snapshots + .get(account.id.as_str()) + .and_then(|snapshot| snapshot.credits_json.as_deref()) + .map(|credits| has_usable_luna_reserve(Some(credits))) + .unwrap_or(false); + if !force_enabled && !reserve_available { + continue; + } + + let mut candidate_account = account; + let (chatgpt_account_id, workspace_id) = derive_account_meta(&token); + if patch_account_meta_in_place(&mut candidate_account, chatgpt_account_id, workspace_id) { + candidate_account.updated_at = now_ts(); + let _ = storage.update_account_workspace_identity( + &candidate_account.id, + candidate_account.chatgpt_account_id.as_deref(), + candidate_account.workspace_id.as_deref(), + candidate_account.updated_at, + ); + } + out.push((candidate_account, token)); + } + Ok(out) } fn request_exceeds_free_account_model_ceiling( diff --git a/crates/service/src/gateway/upstream/support/candidates_tests.rs b/crates/service/src/gateway/upstream/support/candidates_tests.rs index addce6c58..263f29851 100644 --- a/crates/service/src/gateway/upstream/support/candidates_tests.rs +++ b/crates/service/src/gateway/upstream/support/candidates_tests.rs @@ -64,6 +64,27 @@ fn insert_usage_snapshot( crate::gateway::invalidate_candidate_cache(); } +fn insert_saturated_usage_snapshot( + storage: &Storage, + account_id: &str, + credits_json: Option<&str>, +) { + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: account_id.to_string(), + used_percent: Some(100.0), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: Some(100.0), + secondary_window_minutes: Some(10080), + secondary_resets_at: None, + credits_json: credits_json.map(str::to_string), + captured_at: now_ts(), + }) + .expect("insert saturated usage snapshot"); + crate::gateway::invalidate_candidate_cache(); +} + struct QuotaGuardReset(crate::gateway::QuotaGuardConfig); impl Drop for QuotaGuardReset { @@ -327,6 +348,72 @@ fn free_account_model_ceiling_treats_unknown_request_models_as_above_ceiling() { assert_eq!(candidates[0].0.id, "acc-plus-unknown"); } +#[test] +fn luna_reserve_and_force_enabled_accounts_survive_exhausted_standard_windows() { + let _guard = crate::test_env_guard(); + let _free_model_reset = + FreeAccountMaxModelReset(crate::gateway::current_free_account_max_model()); + crate::gateway::set_free_account_max_model("auto").expect("disable free model ceiling"); + let previous_quota_guard = crate::gateway::current_quota_guard_config(); + let _quota_guard_reset = QuotaGuardReset(previous_quota_guard); + crate::gateway::set_quota_guard_config(crate::gateway::QuotaGuardConfig { + enabled: false, + primary_min_remaining_percent: 0.0, + secondary_min_remaining_percent: 0.0, + allow_all_low_quota_fallback: false, + }); + + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + insert_active_account_with_token(&storage, "acc-luna-reserve", 0); + insert_active_account_with_token(&storage, "acc-force-enabled", 1); + insert_active_account_with_token(&storage, "acc-exhausted", 2); + storage + .update_account_status("acc-force-enabled", "force_enabled") + .expect("mark force enabled"); + insert_saturated_usage_snapshot( + &storage, + "acc-luna-reserve", + Some( + r#"{"_codexmanager_extra_rate_limits":[{"metered_feature":"base_model_inference","primary_window":{"used_percent":10.0}}]}"#, + ), + ); + insert_saturated_usage_snapshot(&storage, "acc-force-enabled", None); + insert_saturated_usage_snapshot(&storage, "acc-exhausted", None); + + let luna_candidates = super::prepare_gateway_candidates( + &storage, + Some("gpt-5.6-luna"), + None, + None, + crate::gateway::LowQuotaCandidateMode::NormalOnly, + ) + .expect("prepare Luna Reserve candidates"); + assert_eq!( + luna_candidates + .iter() + .map(|(account, _)| account.id.as_str()) + .collect::>(), + vec!["acc-force-enabled", "acc-luna-reserve"] + ); + + let standard_candidates = super::prepare_gateway_candidates( + &storage, + Some("gpt-5.4"), + None, + None, + crate::gateway::LowQuotaCandidateMode::NormalOnly, + ) + .expect("prepare standard candidates"); + assert_eq!( + standard_candidates + .iter() + .map(|(account, _)| account.id.as_str()) + .collect::>(), + vec!["acc-force-enabled"] + ); +} + fn upsert_account_source_model(storage: &Storage, account_id: &str, upstream_model: &str) { let now = now_ts(); storage diff --git a/crates/service/src/quota/read.rs b/crates/service/src/quota/read.rs index 2fcd6e5f0..6ff62955f 100644 --- a/crates/service/src/quota/read.rs +++ b/crates/service/src/quota/read.rs @@ -128,7 +128,10 @@ fn remaining_percent(used_percent: Option) -> Option { } fn account_source_is_available(account: &AccountQuotaSourceSummary) -> bool { - matches!(account.status.as_str(), "active" | "available") + matches!( + account.status.as_str(), + "active" | "available" | "force_enabled" + ) } fn aggregate_source_display_name(api: &AggregateApiQuotaSourceSummary) -> String { diff --git a/crates/service/src/usage/refresh/batch.rs b/crates/service/src/usage/refresh/batch.rs index 69a474880..f1800f767 100644 --- a/crates/service/src/usage/refresh/batch.rs +++ b/crates/service/src/usage/refresh/batch.rs @@ -205,10 +205,17 @@ fn load_refreshable_usage_refresh_tasks( } fn refreshable_account_statuses() -> Vec { - ["active", "inactive", "limited", "unavailable", "unknown"] - .into_iter() - .map(String::from) - .collect() + [ + "active", + "inactive", + "limited", + "unavailable", + "unknown", + "force_enabled", + ] + .into_iter() + .map(String::from) + .collect() } #[derive(Clone)] diff --git a/crates/service/src/usage/refresh/batch_tests.rs b/crates/service/src/usage/refresh/batch_tests.rs index dd4005e20..260776b3e 100644 --- a/crates/service/src/usage/refresh/batch_tests.rs +++ b/crates/service/src/usage/refresh/batch_tests.rs @@ -175,6 +175,7 @@ fn load_refreshable_accounts_skips_disabled_and_banned_rows_in_sql() { account("acc-limited", "limited", None), account("acc-unavailable", "unavailable", None), account("acc-unknown", "unknown", None), + account("acc-force-enabled", "force_enabled", None), account("acc-disabled", "disabled", None), account("acc-banned", "banned", None), ] { @@ -191,6 +192,7 @@ fn load_refreshable_accounts_skips_disabled_and_banned_rows_in_sql() { account_ids, vec![ "acc-active".to_string(), + "acc-force-enabled".to_string(), "acc-inactive".to_string(), "acc-limited".to_string(), "acc-unavailable".to_string(), diff --git a/crates/service/src/usage/refresh/mod.rs b/crates/service/src/usage/refresh/mod.rs index ed5299983..921dfd4da 100644 --- a/crates/service/src/usage/refresh/mod.rs +++ b/crates/service/src/usage/refresh/mod.rs @@ -1,5 +1,9 @@ use codexmanager_core::auth::{extract_token_exp, DEFAULT_CLIENT_ID, DEFAULT_ISSUER}; -use codexmanager_core::storage::{now_ts, Account, AccountTokenRefreshIssuer, Storage, Token}; +use codexmanager_core::storage::{ + now_ts, Account, AccountTokenRefreshIssuer, Storage, Token, UsageSnapshotRecord, +}; +use codexmanager_core::usage::has_usable_luna_reserve; +#[cfg(test)] use codexmanager_core::usage::parse_usage_snapshot; use crossbeam_channel::{bounded, unbounded, Receiver, Sender, TrySendError}; use serde::Serialize; @@ -111,6 +115,7 @@ const BACKGROUND_TASK_RESTART_REQUIRED_KEYS: [&str; 5] = [ #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UsageAvailabilityStatus { Available, + AvailableLunaReserve, PrimaryWindowAvailableOnly, Unavailable, Unknown, @@ -131,6 +136,7 @@ impl UsageAvailabilityStatus { fn as_code(self) -> &'static str { match self { Self::Available => "available", + Self::AvailableLunaReserve => "available_luna_reserve", Self::PrimaryWindowAvailableOnly => "primary_window_available_only", Self::Unavailable => "unavailable", Self::Unknown => "unknown", @@ -834,9 +840,8 @@ fn refresh_account_snapshot( return Err(error.clone()); } }; - let status = classify_usage_status_from_snapshot_value(&value); - store_usage_snapshot(storage, account_id, value)?; - Ok(status) + let stored = store_usage_snapshot(storage, account_id, value)?; + Ok(classify_usage_status_from_snapshot_record(&stored)) } #[cfg(test)] @@ -858,20 +863,53 @@ mod tests; /// /// # 返回 /// 返回函数执行结果 +#[cfg(test)] fn classify_usage_status_from_snapshot_value(value: &serde_json::Value) -> UsageAvailabilityStatus { let parsed = parse_usage_snapshot(value); - let primary_present = parsed.used_percent.is_some() && parsed.window_minutes.is_some(); + classify_usage_status( + parsed.used_percent, + parsed.window_minutes, + parsed.secondary_used_percent, + parsed.secondary_window_minutes, + parsed.credits_json.as_deref(), + ) +} + +fn classify_usage_status_from_snapshot_record( + snapshot: &UsageSnapshotRecord, +) -> UsageAvailabilityStatus { + classify_usage_status( + snapshot.used_percent, + snapshot.window_minutes, + snapshot.secondary_used_percent, + snapshot.secondary_window_minutes, + snapshot.credits_json.as_deref(), + ) +} + +fn classify_usage_status( + used_percent: Option, + window_minutes: Option, + secondary_used_percent: Option, + secondary_window_minutes: Option, + credits_json: Option<&str>, +) -> UsageAvailabilityStatus { + let primary_present = used_percent.is_some() && window_minutes.is_some(); if !primary_present { return UsageAvailabilityStatus::Unknown; } - if parsed.used_percent.map(|v| v >= 100.0).unwrap_or(false) { - return UsageAvailabilityStatus::Unavailable; + if used_percent.map(|v| v >= 100.0).unwrap_or(false) { + return if has_usable_luna_reserve(credits_json) { + UsageAvailabilityStatus::AvailableLunaReserve + } else { + UsageAvailabilityStatus::Unavailable + }; } - let secondary_used = parsed.secondary_used_percent; - let secondary_window = parsed.secondary_window_minutes; + let secondary_used = secondary_used_percent; + let secondary_window = secondary_window_minutes; let secondary_present = secondary_used.is_some() || secondary_window.is_some(); let secondary_complete = secondary_used.is_some() && secondary_window.is_some(); @@ -884,7 +922,11 @@ fn classify_usage_status_from_snapshot_value(value: &serde_json::Value) -> Usage return UsageAvailabilityStatus::PrimaryWindowAvailableOnly; } if secondary_used.map(|v| v >= 100.0).unwrap_or(false) { - return UsageAvailabilityStatus::Unavailable; + return if has_usable_luna_reserve(credits_json) { + UsageAvailabilityStatus::AvailableLunaReserve + } else { + UsageAvailabilityStatus::Unavailable + }; } UsageAvailabilityStatus::Available } diff --git a/crates/service/src/usage/usage_read.rs b/crates/service/src/usage/usage_read.rs index 012f2e326..f4c331fe7 100644 --- a/crates/service/src/usage/usage_read.rs +++ b/crates/service/src/usage/usage_read.rs @@ -1,5 +1,6 @@ use codexmanager_core::rpc::types::UsageSnapshotResult; use codexmanager_core::storage::UsageSnapshotRecord; +use codexmanager_core::usage::has_usable_luna_reserve; use crate::storage_helpers::open_storage; @@ -52,7 +53,11 @@ fn classify_availability_status(snap: &UsageSnapshotRecord) -> &'static str { .map(|value| value >= 100.0) .unwrap_or(false) { - return "unavailable"; + return if has_usable_luna_reserve(snap.credits_json.as_deref()) { + "available_luna_reserve" + } else { + "unavailable" + }; } let secondary_present = @@ -73,7 +78,11 @@ fn classify_availability_status(snap: &UsageSnapshotRecord) -> &'static str { .map(|value| value >= 100.0) .unwrap_or(false) { - return "unavailable"; + return if has_usable_luna_reserve(snap.credits_json.as_deref()) { + "available_luna_reserve" + } else { + "unavailable" + }; } "available" } @@ -101,3 +110,31 @@ pub(crate) fn read_usage_snapshot(account_id: Option<&str>) -> Option bool { - context.status.trim().eq_ignore_ascii_case("disabled") + let normalized = context.status.trim(); + normalized.eq_ignore_ascii_case("disabled") || normalized.eq_ignore_ascii_case("force_enabled") } /// 函数 `usage_snapshots_retain_per_account` @@ -88,14 +91,28 @@ pub(crate) fn apply_status_from_snapshot( /// /// # 返回 /// 返回函数执行结果 -pub(crate) fn store_usage_snapshot( - storage: &Storage, - account_id: &str, - value: serde_json::Value, -) -> Result<(), String> { - // 解析并写入用量快照 - let parsed = parse_usage_snapshot(&value); - let record = UsageSnapshotRecord { +pub(crate) fn store_usage_snapshot( + storage: &Storage, + account_id: &str, + value: serde_json::Value, +) -> Result { + // 解析并写入用量快照 + let parsed = parse_usage_snapshot(&value); + let previous_credits_json = storage + .latest_usage_snapshot_for_account(account_id) + .ok() + .flatten() + .and_then(|snapshot| snapshot.credits_json); + let credits_json = if usage_payload_declares_extra_rate_limits(&value) { + parsed.credits_json + } else { + merge_missing_extra_rate_limits( + parsed.credits_json.as_deref(), + previous_credits_json.as_deref(), + ) + .or(parsed.credits_json) + }; + let record = UsageSnapshotRecord { account_id: account_id.to_string(), used_percent: parsed.used_percent, window_minutes: parsed.window_minutes, @@ -103,7 +120,7 @@ pub(crate) fn store_usage_snapshot( secondary_used_percent: parsed.secondary_used_percent, secondary_window_minutes: parsed.secondary_window_minutes, secondary_resets_at: parsed.secondary_resets_at, - credits_json: parsed.credits_json, + credits_json, captured_at: now_ts(), }; storage @@ -112,7 +129,79 @@ pub(crate) fn store_usage_snapshot( let retain = usage_snapshots_retain_per_account(); if retain > 0 { let _ = storage.prune_usage_snapshots_for_account(account_id, retain); - } - let _ = apply_status_from_snapshot(storage, &record); - Ok(()) -} + } + let _ = apply_status_from_snapshot(storage, &record); + Ok(record) +} + +#[cfg(test)] +mod tests { + use super::store_usage_snapshot; + use codexmanager_core::storage::Storage; + use codexmanager_core::usage::has_usable_luna_reserve; + + #[test] + fn followup_usage_without_extra_buckets_keeps_previous_luna_reserve() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + + store_usage_snapshot( + &storage, + "acc-luna-reserve", + serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "additionalRateLimits": [{ + "limitName": "Luna Reserve", + "rateLimit": { "primaryWindow": { "remainingPercent": 75.0 } } + }] + }), + ) + .expect("store initial usage"); + + store_usage_snapshot( + &storage, + "acc-luna-reserve", + serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "credits": { "balance": 2.0 } + }), + ) + .expect("store followup usage"); + + let latest = storage + .latest_usage_snapshot_for_account("acc-luna-reserve") + .expect("read latest usage") + .expect("latest usage exists"); + assert!(has_usable_luna_reserve(latest.credits_json.as_deref())); + + store_usage_snapshot( + &storage, + "acc-luna-reserve", + serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "additionalRateLimits": [] + }), + ) + .expect("store explicit empty usage"); + let latest = storage + .latest_usage_snapshot_for_account("acc-luna-reserve") + .expect("read latest explicit usage") + .expect("latest explicit usage exists"); + assert!(!has_usable_luna_reserve(latest.credits_json.as_deref())); + } +} diff --git a/crates/service/tests/usage/usage_refresh_status_tests.rs b/crates/service/tests/usage/usage_refresh_status_tests.rs index faefd8084..fd681963e 100644 --- a/crates/service/tests/usage/usage_refresh_status_tests.rs +++ b/crates/service/tests/usage/usage_refresh_status_tests.rs @@ -1,5 +1,6 @@ use super::{ - mark_usage_unreachable_if_needed, record_usage_refresh_failure, should_retry_with_refresh, + classify_usage_status_from_snapshot_value, mark_usage_unreachable_if_needed, + record_usage_refresh_failure, should_retry_with_refresh, UsageAvailabilityStatus, }; use crate::account_availability::Availability; use crate::account_status::{ @@ -12,6 +13,30 @@ use crate::usage_snapshot_store::apply_status_from_snapshot; use codexmanager_core::storage::{now_ts, Account, Storage, UsageSnapshotRecord}; use std::time::{SystemTime, UNIX_EPOCH}; +#[test] +fn usage_refresh_classifies_luna_reserve_as_available_after_standard_exhaustion() { + let status = classify_usage_status_from_snapshot_value(&serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "additionalRateLimits": [ + { + "limitName": "Luna Reserve", + "meteredFeature": "base_model_inference", + "rateLimit": { + "primaryWindow": { "remainingPercent": 80.0 } + } + } + ] + })); + + assert_eq!(status, UsageAvailabilityStatus::AvailableLunaReserve); + assert_eq!(status.as_code(), "available_luna_reserve"); +} + /// 函数 `unique_id` /// /// 作者: gaohongshun @@ -220,6 +245,50 @@ fn apply_status_exhausted_snapshot_marks_account_limited() { ); } +#[test] +fn apply_status_preserves_force_enabled_account_after_exhaustion() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let account = Account { + id: "acc-force-enabled".to_string(), + label: "force-enabled".to_string(), + issuer: "issuer".to_string(), + chatgpt_account_id: None, + workspace_id: None, + group_name: None, + sort: 0, + status: "force_enabled".to_string(), + created_at: now_ts(), + updated_at: now_ts(), + }; + storage.insert_account(&account).expect("insert"); + + let availability = apply_status_from_snapshot( + &storage, + &UsageSnapshotRecord { + account_id: account.id.clone(), + used_percent: Some(100.0), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: Some(100.0), + secondary_window_minutes: Some(10080), + secondary_resets_at: None, + credits_json: None, + captured_at: now_ts(), + }, + ); + + assert!(matches!(availability, Availability::Unavailable(_))); + assert_eq!( + storage + .find_account_by_id(account.id.as_str()) + .expect("find") + .expect("exists") + .status, + "force_enabled" + ); +} + /// 函数 `apply_status_available_snapshot_recovers_limited_account_to_active` /// /// 作者: gaohongshun diff --git a/docs/en/CHANGELOG.md b/docs/en/CHANGELOG.md index 79fe4513c..bff46d339 100644 --- a/docs/en/CHANGELOG.md +++ b/docs/en/CHANGELOG.md @@ -5,6 +5,15 @@ It follows Keep a Changelog with a lightweight adaptation for this repository. ## [Unreleased] +### Added + +- Added a **Keep using account after quota exhaustion** switch to the account editor. It persists as `force_enabled`, is off by default, and keeps the account in the gateway candidate pool for manual quota handling. + +### Fixed + +- Fixed Luna Reserve usage disappearing from the account page after later refreshes or transient empty-list responses, and normalized both snake_case and camelCase additional-quota payloads. +- Fixed accounts with an exhausted standard 5-hour/7-day window but usable Luna Reserve being marked limited and omitted from the `gpt-5.6-luna` candidate pool; hard authorization or deactivation states retain their existing handling. + ## [0.5.6] - 2026-09-02 ### Added diff --git "a/docs/ru/\320\226\321\203\321\200\320\275\320\260\320\273-\320\270\320\267\320\274\320\265\320\275\320\265\320\275\320\270\320\271.md" "b/docs/ru/\320\226\321\203\321\200\320\275\320\260\320\273-\320\270\320\267\320\274\320\265\320\275\320\265\320\275\320\270\320\271.md" index 857eef0dd..36711a1dc 100644 --- "a/docs/ru/\320\226\321\203\321\200\320\275\320\260\320\273-\320\270\320\267\320\274\320\265\320\275\320\265\320\275\320\270\320\271.md" +++ "b/docs/ru/\320\226\321\203\321\200\320\275\320\260\320\273-\320\270\320\267\320\274\320\265\320\275\320\265\320\275\320\270\320\271.md" @@ -5,6 +5,15 @@ ## [Unreleased] +### Added + +- В редактор аккаунта добавлен переключатель **Продолжать использовать аккаунт после исчерпания квоты**. Он сохраняется как `force_enabled`, по умолчанию выключен и оставляет аккаунт в пуле кандидатов шлюза для ручного управления квотой. + +### Fixed + +- Исправлено исчезновение usage Luna Reserve со страницы аккаунтов после последующих обновлений или временных пустых ответов; добавлена единая обработка дополнительных квот в форматах snake_case и camelCase. +- Исправлено исключение из пула `gpt-5.6-luna` аккаунтов с исчерпанным стандартным окном 5 ч/7 д, но доступной Luna Reserve; жесткие состояния авторизации и деактивации продолжают обрабатываться по прежним правилам. + ## [0.5.6] - 2026-09-02 ### Added diff --git a/docs/zh-CN/CHANGELOG.md b/docs/zh-CN/CHANGELOG.md index 97d7f8b9b..6b6028dcf 100644 --- a/docs/zh-CN/CHANGELOG.md +++ b/docs/zh-CN/CHANGELOG.md @@ -5,6 +5,15 @@ ## [Unreleased] +### Added + +- 账号编辑器新增“额度耗尽后仍使用账号”开关,持久化为 `force_enabled`,默认关闭;开启后该账号仍可参与网关候选,适合需要人工接管额度判定的场景。 + +### Fixed + +- 修复 Luna Reserve 用量在后续刷新或暂态空列表后从账号页消失的问题,并统一兼容附加额度的 snake_case/camelCase 返回结构。 +- 修复标准 5h/7d 窗口耗尽后仍有 Luna Reserve 的账号被自动限流、无法进入 `gpt-5.6-luna` 候选池的问题;硬性授权或停用状态仍按原规则处理。 + ## [0.5.6] - 2026-09-02 ### Added From 1c27fa2a38030cafcb7b11aedeb3b699b444de45 Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:20:59 +0800 Subject: [PATCH 2/5] fix: preserve Luna Reserve usage and quota layout --- .../app/accounts/accounts-page-helpers.tsx | 78 ++++++++--------- apps/src/app/accounts/accounts-page-view.tsx | 42 ++++++++-- apps/src/app/accounts/page.tsx | 15 ++++ apps/src/app/globals.css | 33 ++++++-- .../lib/i18n/messages/sections/en-accounts.ts | 1 + .../lib/i18n/messages/sections/ko-accounts.ts | 1 + .../lib/i18n/messages/sections/ru-accounts.ts | 1 + apps/tests/ui-responsive-regressions.test.mjs | 7 +- crates/core/src/usage/mod.rs | 84 +++++++++++++++---- crates/core/tests/usage.rs | 45 +++++++++- .../src/usage/tests/usage_http_tests.rs | 26 ++++-- crates/service/src/usage/usage_http.rs | 8 ++ .../service/src/usage/usage_snapshot_store.rs | 22 +++++ 13 files changed, 290 insertions(+), 73 deletions(-) diff --git a/apps/src/app/accounts/accounts-page-helpers.tsx b/apps/src/app/accounts/accounts-page-helpers.tsx index 40c0d42ce..e5ef67389 100644 --- a/apps/src/app/accounts/accounts-page-helpers.tsx +++ b/apps/src/app/accounts/accounts-page-helpers.tsx @@ -127,6 +127,7 @@ export interface QuotaProgressProps { export interface QuotaSummaryItem extends QuotaProgressProps { id: string; + resetDurationMode?: "hours" | "days"; } export interface AccountEditorState { @@ -217,27 +218,29 @@ function QuotaProgress({ export function QuotaOverviewCell({ items }: { items: QuotaSummaryItem[] }) { const { t } = useI18n(); - const summaryItems = items.slice(0, 2); return ( } className="block min-w-0 cursor-help">
-
- {summaryItems.map((item) => ( -
-
+
+ {items.map((item) => ( +
+
{item.label} - + {item.remainPercent == null ? (item.emptyText ?? "--") : `${item.remainPercent}%`} @@ -260,38 +263,32 @@ export function QuotaOverviewCell({ items }: { items: QuotaSummaryItem[] }) { : "bg-green-500" } /> -
- ))} -
-
- {summaryItems.map((item) => ( -
- + + {formatTsFromSeconds( + item.resetsAt, + item.emptyResetText ?? t("未知"), + )} + + + {formatRemainingDurationFromSeconds( item.resetsAt, + item.resetDurationMode ?? + (item.id.endsWith("-primary") ? "hours" : "days"), item.emptyResetText ?? t("未知"), - ), - "block min-w-0 break-words leading-tight [overflow-wrap:anywhere]", - "text-[11px]", - )} - > - {formatTsFromSeconds( - item.resetsAt, - item.emptyResetText ?? t("未知"), - )} - - - {formatRemainingDurationFromSeconds( - item.resetsAt, - item.id.endsWith("-primary") ? "hours" : "days", - item.emptyResetText ?? t("未知"), - )} - {t("后刷新")} - + )} + {t("后刷新")} + +
))}
@@ -468,7 +465,7 @@ export function AccountStatusCell({ account }: { account: Account }) { /> ({ id: item.id, @@ -739,6 +738,9 @@ export function buildQuotaSummaryItems( caption: t(item.windowLabel, item.windowLabelValues), emptyText: "--", emptyResetText: t("未知"), + resetDurationMode: item.windowLabel.includes("天") + ? ("days" as const) + : ("hours" as const), })), ]; } diff --git a/apps/src/app/accounts/accounts-page-view.tsx b/apps/src/app/accounts/accounts-page-view.tsx index f4f00d5ad..a9a89a721 100644 --- a/apps/src/app/accounts/accounts-page-view.tsx +++ b/apps/src/app/accounts/accounts-page-view.tsx @@ -258,6 +258,7 @@ export interface AccountsPageViewProps { refreshAccount: (accountId: string) => void; clearPreferredAccount: (accountId: string) => void; setPreferredAccount: (accountId: string) => void; + toggleForceEnabled: (account: Account) => Promise; toggleAccountStatus: ( accountId: string, enabled: boolean, @@ -388,6 +389,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { onAccountTestFinished, clearPreferredAccount, setPreferredAccount, + toggleForceEnabled, toggleAccountStatus, } = props; @@ -452,6 +454,18 @@ export function AccountsPageView(props: AccountsPageViewProps) { const isAtListTop = accounts[0]?.id === account.id; const isAtListBottom = accounts[accounts.length - 1]?.id === account.id; + const normalizedAccountStatus = account.status.trim().toLowerCase(); + const isForceEnabled = normalizedAccountStatus === "force_enabled"; + const forceToggleBlocked = [ + "disabled", + "inactive", + "unavailable", + "banned", + ].includes(normalizedAccountStatus); + const isForceToggleBusy = + isUpdatingManyStatuses || + isUpdatingProfileAccountId === account.id || + isUpdatingStatusAccountId === account.id; return (
@@ -572,6 +586,21 @@ export function AccountsPageView(props: AccountsPageViewProps) { {t("测试账号")} ) : null} + void toggleForceEnabled(account)} + > + {isForceEnabled ? ( + + ) : ( + + )} + {isForceEnabled ? t("取消强制开启") : t("强制开启")} + + - + @@ -1117,13 +1146,13 @@ export function AccountsPageView(props: AccountsPageViewProps) { onCheckedChange={toggleSelectAllVisible} /> - + {t("账号信息")} {t("额度详情")} - {t("顺序")} + {t("顺序")} {t("账号代理")} {t("状态")} @@ -1188,7 +1217,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { onCheckedChange={() => toggleSelect(account.id)} /> - +
- -
+ +
{account.priority} @@ -1277,6 +1306,7 @@ export function AccountsPageView(props: AccountsPageViewProps) { isUpdatingProfileAccountId === account.id } onClick={() => openAccountEditor(account)} + aria-label={t("编辑账号信息")} title={t("编辑账号信息")} > diff --git a/apps/src/app/accounts/page.tsx b/apps/src/app/accounts/page.tsx index 19ea78951..d324a655e 100644 --- a/apps/src/app/accounts/page.tsx +++ b/apps/src/app/accounts/page.tsx @@ -707,6 +707,20 @@ const toggleCleanupStatus = (rawStatus: string) => { ); }; + const handleToggleForceEnabled = async (account: Account) => { + const normalizedStatus = account.status.trim().toLowerCase(); + if (["disabled", "inactive", "unavailable", "banned"].includes(normalizedStatus)) { + return; + } + try { + await updateAccountProfile(account.id, { + status: normalizedStatus === "force_enabled" ? "active" : "force_enabled", + }); + } catch { + // mutation 已统一处理 toast,这里保持菜单状态不变 + } + }; + // 顶部/底部按全量列表定位,上移/下移仍按当前筛选结果取相邻账号。 const resolveAccountMovePlacement = ( account: Account, @@ -1023,6 +1037,7 @@ const toggleCleanupStatus = (rawStatus: string) => { refreshAccount={refreshAccount} clearPreferredAccount={clearPreferredAccount} setPreferredAccount={setPreferredAccount} + toggleForceEnabled={handleToggleForceEnabled} toggleAccountStatus={toggleAccountStatus} /> ); diff --git a/apps/src/app/globals.css b/apps/src/app/globals.css index 8f10eead7..a5bbee3d4 100644 --- a/apps/src/app/globals.css +++ b/apps/src/app/globals.css @@ -1085,13 +1085,16 @@ tr { .account-pool-main-pane { min-width: 0; - overflow: hidden; + overflow-x: auto; + overflow-y: hidden; + scrollbar-color: var(--border) transparent; + scrollbar-width: thin; } .account-pool-main-table { table-layout: fixed; width: 100%; - min-width: 1280px; + min-width: 1216px; } .account-pool-col-select { @@ -1099,18 +1102,38 @@ tr { } .account-pool-col-info { - width: 440px; + width: 360px; +} + +.account-pool-col-quota { + width: 300px; } .account-pool-col-order { - width: 132px; + width: 168px; } -.account-pool-col-proxy, .account-pool-col-status { width: 180px; } +.account-pool-col-proxy { + width: 160px; +} + +.account-pool-quota-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + min-width: 0; +} + +.account-pool-quota-item { + min-width: 0; + max-width: 100%; + overflow: hidden; +} + .account-pool-status-head, .account-pool-status-cell { overflow: hidden; diff --git a/apps/src/lib/i18n/messages/sections/en-accounts.ts b/apps/src/lib/i18n/messages/sections/en-accounts.ts index 3caade709..3ab68c910 100644 --- a/apps/src/lib/i18n/messages/sections/en-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/en-accounts.ts @@ -235,6 +235,7 @@ export const EN_ACCOUNTS_MESSAGES: MessageCatalog = { "Quota capacity must be a number greater than 0. Leave blank for no override.", "额度已耗尽": "Quota exhausted", "强制开启": "Force enabled", + "取消强制开启": "Disable force enabled", "仅 Luna Reserve": "Luna Reserve only", "Luna Reserve 额度": "Luna Reserve quota", "额度耗尽后仍使用账号": "Keep using account after quota exhaustion", diff --git a/apps/src/lib/i18n/messages/sections/ko-accounts.ts b/apps/src/lib/i18n/messages/sections/ko-accounts.ts index b070c7927..92d6f5eb6 100644 --- a/apps/src/lib/i18n/messages/sections/ko-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ko-accounts.ts @@ -222,6 +222,7 @@ export const KO_ACCOUNTS_MESSAGES: MessageCatalog = { "한도 용량은 0보다 큰 숫자여야 합니다. 비워 두면 오버라이드하지 않습니다.", "额度已耗尽": "한도가 소진되었습니다", "强制开启": "강제 활성화됨", + "取消强制开启": "강제 활성화 해제", "仅 Luna Reserve": "Luna Reserve만", "Luna Reserve 额度": "Luna Reserve 한도", "额度耗尽后仍使用账号": "한도 소진 후에도 계정 사용", diff --git a/apps/src/lib/i18n/messages/sections/ru-accounts.ts b/apps/src/lib/i18n/messages/sections/ru-accounts.ts index d1e4bfcb9..be7e31b52 100644 --- a/apps/src/lib/i18n/messages/sections/ru-accounts.ts +++ b/apps/src/lib/i18n/messages/sections/ru-accounts.ts @@ -235,6 +235,7 @@ export const RU_ACCOUNTS_MESSAGES: MessageCatalog = { "Емкость квоты должна быть числом больше 0. Оставьте пустым, чтобы не переопределять.", "额度已耗尽": "Квота исчерпана", "强制开启": "Принудительно включен", + "取消强制开启": "Отключить принудительное включение", "仅 Luna Reserve": "Только Luna Reserve", "Luna Reserve 额度": "Квота Luna Reserve", "额度耗尽后仍使用账号": "Продолжать использовать аккаунт после исчерпания квоты", diff --git a/apps/tests/ui-responsive-regressions.test.mjs b/apps/tests/ui-responsive-regressions.test.mjs index ad1bea256..77398ac20 100644 --- a/apps/tests/ui-responsive-regressions.test.mjs +++ b/apps/tests/ui-responsive-regressions.test.mjs @@ -103,10 +103,15 @@ test("wide tables retain reachable actions and visible empty states", async () = stylesSource, /\.account-pool-layout[\s\S]*grid-template-columns: minmax\(0, 1fr\) var\(--account-pool-action-width\);/, ); + assert.match(stylesSource, /\.account-pool-main-pane[\s\S]*overflow-x: auto;/); assert.match( stylesSource, - /\.account-pool-main-table[\s\S]*table-layout: fixed;[\s\S]*width: 100%;[\s\S]*min-width: 1280px;/, + /\.account-pool-main-table[\s\S]*table-layout: fixed;[\s\S]*width: 100%;[\s\S]*min-width: 1216px;/, ); + assert.match(stylesSource, /\.account-pool-col-quota[\s\S]*width: 300px;/); + assert.match(stylesSource, /\.account-pool-quota-grid[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/); + assert.match(stylesSource, /\.account-pool-col-order[\s\S]*width: 168px;/); + assert.match(accountsSource, /w-\[168px\].*顺序/); assert.match( stylesSource, /\.account-pool-action-rail[\s\S]*position: relative;[\s\S]*z-index: 5;[\s\S]*width: var\(--account-pool-action-width\);/, diff --git a/crates/core/src/usage/mod.rs b/crates/core/src/usage/mod.rs index 9a6d4e22c..928a79ca5 100644 --- a/crates/core/src/usage/mod.rs +++ b/crates/core/src/usage/mod.rs @@ -78,6 +78,10 @@ fn is_extra_rate_limit_key(key: &str) -> bool { || (normalized_key.contains("gpt") && normalized_key.contains("reserve")) } +fn is_stable_non_reserve_rate_limit_key(key: &str) -> bool { + normalized_identifier(key) == "codereviewratelimit" +} + fn normalize_rate_limit_entry(source_key: Option<&str>, value: &Value) -> Option { let obj = value.as_object()?; let rate_limit = obj @@ -225,17 +229,26 @@ pub fn usage_payload_declares_extra_rate_limits(value: &Value) -> bool { let Some(root) = value.as_object() else { return false; }; - if root.contains_key(EXTRA_RATE_LIMITS_JSON_KEY) + // `null` is returned by the upstream usage endpoint when the optional + // reserve section is not present in that response. Keep the previous + // cached bucket in that case; an array/object (including an empty one) + // remains an authoritative refresh. + if root + .get(EXTRA_RATE_LIMITS_JSON_KEY) + .is_some_and(|value| !value.is_null()) || ADDITIONAL_RATE_LIMITS_KEYS .iter() - .any(|key| root.contains_key(*key)) + .any(|key| root.get(*key).is_some_and(|value| !value.is_null())) { return true; } - if root - .iter() - .any(|(key, _)| key != "rate_limit" && key != "rateLimit" && is_extra_rate_limit_key(key)) - { + if root.iter().any(|(key, value)| { + key != "rate_limit" + && key != "rateLimit" + && !value.is_null() + && is_extra_rate_limit_key(key) + && !is_stable_non_reserve_rate_limit_key(key) + }) { return true; } root.get("credits") @@ -296,6 +309,28 @@ pub fn has_usable_luna_reserve(credits_json: Option<&str>) -> bool { }) } +fn extra_rate_limit_identifiers(entry: &Value) -> Vec { + entry + .as_object() + .into_iter() + .flat_map(|object| { + ["source_key", "limit_id", "limit_name", "metered_feature"] + .into_iter() + .filter_map(|key| object.get(key).and_then(Value::as_str)) + .map(normalized_identifier) + .filter(|value| !value.is_empty()) + }) + .collect() +} + +fn extra_rate_limit_entries_overlap(left: &Value, right: &Value) -> bool { + let right_identifiers = extra_rate_limit_identifiers(right); + !right_identifiers.is_empty() + && extra_rate_limit_identifiers(left) + .into_iter() + .any(|identifier| right_identifiers.contains(&identifier)) +} + /// Merges the previous extra rate-limit buckets when a newer usage response omits them. /// An explicitly supplied current bucket list remains authoritative. pub fn merge_missing_extra_rate_limits( @@ -309,12 +344,18 @@ pub fn merge_missing_extra_rate_limits( .map(str::trim) .filter(|raw| !raw.is_empty()) .and_then(|raw| serde_json::from_str::(raw).ok()); - let previous_extra = previous - .as_ref() - .and_then(Value::as_object) - .and_then(|obj| obj.get(EXTRA_RATE_LIMITS_JSON_KEY)) - .filter(|value| !value.is_null()) - .cloned(); + let previous_extra = previous.as_ref().and_then(|value| { + let mut entries = Vec::new(); + for entry in collect_extra_rate_limits(value) { + if !entries + .iter() + .any(|existing| extra_rate_limit_entries_overlap(existing, &entry)) + { + entries.push(entry); + } + } + (!entries.is_empty()).then_some(Value::Array(entries)) + }); let Some(previous_extra) = previous_extra else { return current_raw.map(ToString::to_string); @@ -334,10 +375,23 @@ pub fn merge_missing_extra_rate_limits( }, None => serde_json::Map::new(), }; - if current.contains_key(EXTRA_RATE_LIMITS_JSON_KEY) { - return Some(Value::Object(current).to_string()); + match current.get_mut(EXTRA_RATE_LIMITS_JSON_KEY) { + Some(Value::Array(current_entries)) => { + if let Value::Array(previous_entries) = previous_extra { + for previous_entry in previous_entries { + if !current_entries.iter().any(|current_entry| { + extra_rate_limit_entries_overlap(current_entry, &previous_entry) + }) { + current_entries.push(previous_entry); + } + } + } + } + Some(_) => return Some(Value::Object(current).to_string()), + None => { + current.insert(EXTRA_RATE_LIMITS_JSON_KEY.to_string(), previous_extra); + } } - current.insert(EXTRA_RATE_LIMITS_JSON_KEY.to_string(), previous_extra); Some(Value::Object(current).to_string()) } diff --git a/crates/core/tests/usage.rs b/crates/core/tests/usage.rs index 77a8f3e98..2a67a33cf 100644 --- a/crates/core/tests/usage.rs +++ b/crates/core/tests/usage.rs @@ -4,7 +4,7 @@ use codexmanager_core::usage::{ reset_credits_consume_endpoint, reset_credits_endpoint, usage_endpoint, usage_payload_declares_extra_rate_limits, }; -use serde_json::json; +use serde_json::{json, Value}; /// 函数 `usage_snapshot_parsed` /// @@ -244,4 +244,47 @@ fn nested_extra_rate_limits_are_preserved_and_explicit_empty_is_authoritative() .credits_json .as_deref() )); + + let mixed_followup = json!({ + "rate_limit": { "primary_window": { "used_percent": 100.0 } }, + "code_review_rate_limit": { + "primary_window": { + "used_percent": 0.0, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": null + }); + assert!(!usage_payload_declares_extra_rate_limits(&mixed_followup)); + let merged_mixed = merge_missing_extra_rate_limits( + parse_usage_snapshot(&mixed_followup) + .credits_json + .as_deref(), + first_snapshot.credits_json.as_deref(), + ) + .expect("mixed usage payload should be serialized"); + let merged_mixed_value: Value = serde_json::from_str(&merged_mixed).expect("valid merged JSON"); + let merged_entries = merged_mixed_value + .get("_codexmanager_extra_rate_limits") + .and_then(Value::as_array) + .expect("merged extra rate limits"); + assert_eq!(merged_entries.len(), 2); + assert!(has_usable_luna_reserve(Some(&merged_mixed))); + + let legacy_previous = json!({ + "_codexmanager_extra_rate_limits": [], + "additionalRateLimits": [{ + "limitName": "Luna Reserve", + "meteredFeature": "base_model_inference", + "rateLimit": { "primaryWindow": { "remainingPercent": 65.0 } } + }] + }); + let merged_legacy = merge_missing_extra_rate_limits( + parse_usage_snapshot(&mixed_followup) + .credits_json + .as_deref(), + Some(&legacy_previous.to_string()), + ) + .expect("legacy usage payload should be serialized"); + assert!(has_usable_luna_reserve(Some(&merged_legacy))); } diff --git a/crates/service/src/usage/tests/usage_http_tests.rs b/crates/service/src/usage/tests/usage_http_tests.rs index fb14627cd..232e31eeb 100644 --- a/crates/service/src/usage/tests/usage_http_tests.rs +++ b/crates/service/src/usage/tests/usage_http_tests.rs @@ -804,7 +804,13 @@ fn usage_request_headers_use_official_chatgpt_account_header_name() { .and_then(|value| value.to_str().ok()), Some("workspace_123") ); - assert_eq!(headers.len(), 1); + assert_eq!( + headers + .get("x-openai-codex-luna-reserve") + .and_then(|value| value.to_str().ok()), + Some("1") + ); + assert_eq!(headers.len(), 2); } #[test] @@ -817,6 +823,7 @@ fn usage_request_headers_include_fedramp_context_when_enabled() { .and_then(|value| value.to_str().ok()), Some("true") ); + assert!(headers.get("x-openai-codex-luna-reserve").is_none()); assert_eq!(headers.len(), 2); } @@ -1130,6 +1137,7 @@ fn fetch_usage_snapshot_with_explicit_proxy_uses_explicit_proxy_before_global_pr assert!(request.starts_with("get http://chatgpt.test/")); assert!(request.contains("authorization: bearer token_123")); assert!(request.contains("chatgpt-account-id: workspace_123")); + assert!(request.contains("x-openai-codex-luna-reserve: 1")); assert_eq!(snapshot["gpt4"]["usedPercent"], 12.5); } @@ -1435,7 +1443,11 @@ fn legacy_usage_request_ignores_proxy_pool_when_account_proxy_is_disabled() { .recv_timeout(Duration::from_secs(5)) .expect("usage server timeout") .expect("receive legacy usage request"); - tx.send(request.url().to_string()) + let has_luna_reserve_header = request + .headers() + .iter() + .any(|header| header.field.equiv("x-openai-codex-luna-reserve")); + tx.send((request.url().to_string(), has_luna_reserve_header)) .expect("send legacy usage path"); let response = Response::from_string(r#"{"gpt4":{"usedPercent":99.0,"windowMinutes":180}}"#) @@ -1451,11 +1463,11 @@ fn legacy_usage_request_ignores_proxy_pool_when_account_proxy_is_disabled() { .expect("fetch legacy usage"); assert_eq!(snapshot["gpt4"]["usedPercent"], 99.0); - assert_eq!( - rx.recv_timeout(Duration::from_secs(5)) - .expect("receive legacy usage path"), - "/api/codex/usage" - ); + let (path, has_luna_reserve_header) = rx + .recv_timeout(Duration::from_secs(5)) + .expect("receive legacy usage path"); + assert_eq!(path, "/api/codex/usage"); + assert!(has_luna_reserve_header); assert!(proxy_rx.recv_timeout(Duration::from_millis(300)).is_err()); handle.join().expect("join legacy usage server"); proxy_handle.join().expect("join unused proxy"); diff --git a/crates/service/src/usage/usage_http.rs b/crates/service/src/usage/usage_http.rs index 891212b44..01a09c3db 100644 --- a/crates/service/src/usage/usage_http.rs +++ b/crates/service/src/usage/usage_http.rs @@ -41,6 +41,7 @@ const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; const CF_RAY_HEADER: &str = "cf-ray"; const AUTH_ERROR_HEADER: &str = "x-openai-authorization-error"; const X_OPENAI_FEDRAMP_HEADER_NAME: &str = "x-openai-fedramp"; +const X_OPENAI_CODEX_LUNA_RESERVE_HEADER_NAME: &str = "x-openai-codex-luna-reserve"; #[derive(Debug, Clone)] pub(crate) struct UsageActionHttpError { @@ -661,6 +662,13 @@ fn build_usage_request_headers(workspace_id: Option<&str>, is_fedramp: bool) -> HeaderName::from_static(X_OPENAI_FEDRAMP_HEADER_NAME), HeaderValue::from_static("true"), ); + } else { + // Match the official Codex usage request so eligible ChatGPT plans + // receive the optional Luna Reserve bucket. + headers.insert( + HeaderName::from_static(X_OPENAI_CODEX_LUNA_RESERVE_HEADER_NAME), + HeaderValue::from_static("1"), + ); } headers } diff --git a/crates/service/src/usage/usage_snapshot_store.rs b/crates/service/src/usage/usage_snapshot_store.rs index 2d8bbef4f..6e10dbffb 100644 --- a/crates/service/src/usage/usage_snapshot_store.rs +++ b/crates/service/src/usage/usage_snapshot_store.rs @@ -178,6 +178,28 @@ mod tests { ) .expect("store followup usage"); + store_usage_snapshot( + &storage, + "acc-luna-reserve", + serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "code_review_rate_limit": { + "primary_window": { + "used_percent": 0.0, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": null, + "credits": { "balance": 2.0 } + }), + ) + .expect("store null extra usage"); + let latest = storage .latest_usage_snapshot_for_account("acc-luna-reserve") .expect("read latest usage") From 45ec961336858b647c7a6a4cb400cf585ff2f915 Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:26:26 +0800 Subject: [PATCH 3/5] fix: refine Luna Reserve quota card layout --- apps/src/app/globals.css | 9 ++++----- apps/src/lib/utils/usage.ts | 4 ++-- apps/tests/luna-reserve-usage.test.mjs | 2 +- apps/tests/ui-responsive-regressions.test.mjs | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/apps/src/app/globals.css b/apps/src/app/globals.css index a5bbee3d4..c34a7a6fa 100644 --- a/apps/src/app/globals.css +++ b/apps/src/app/globals.css @@ -1094,7 +1094,7 @@ tr { .account-pool-main-table { table-layout: fixed; width: 100%; - min-width: 1216px; + min-width: 1206px; } .account-pool-col-select { @@ -1106,18 +1106,17 @@ tr { } .account-pool-col-quota { - width: 300px; + width: 330px; } .account-pool-col-order { width: 168px; } -.account-pool-col-status { - width: 180px; +.account-pool-col-proxy { } -.account-pool-col-proxy { +.account-pool-col-status { width: 160px; } diff --git a/apps/src/lib/utils/usage.ts b/apps/src/lib/utils/usage.ts index 9e96e513f..51bb755e8 100644 --- a/apps/src/lib/utils/usage.ts +++ b/apps/src/lib/utils/usage.ts @@ -556,7 +556,7 @@ function isUsableRateLimitEntry(source: Record): boolean { function humanizeExtraRateLimitLabel(raw: string): string { const normalized = raw.trim().toLowerCase(); if (!normalized) return "额外额度"; - if (isLunaReserveIdentifier(normalized)) return "Luna Reserve 额度"; + if (isLunaReserveIdentifier(normalized)) return "Luna Reserve"; if (normalized.includes("spark") || normalized === "codex_other") return "Spark 额度"; if (normalized.includes("code_review") || normalized.includes("code review")) { return "Code Review 额度"; @@ -603,7 +603,7 @@ function extractExtraRateLimitWindows(raw: string | null | undefined): ExtraUsag objectTextValue(source, ["metered_feature", "meteredFeature"]) || `extra-${index + 1}`; const baseLabel = identifiers.some(isLunaReserveIdentifier) - ? "Luna Reserve 额度" + ? "Luna Reserve" : humanizeExtraRateLimitLabel(labelSeed); const windowContainer = asObjectRecord(firstObjectValue(source, ["rate_limit", "rateLimit"])) ?? source; diff --git a/apps/tests/luna-reserve-usage.test.mjs b/apps/tests/luna-reserve-usage.test.mjs index 4e3311893..a5f2ff316 100644 --- a/apps/tests/luna-reserve-usage.test.mjs +++ b/apps/tests/luna-reserve-usage.test.mjs @@ -66,7 +66,7 @@ test("Luna Reserve 的 camelCase 用量会显示且保持可用", () => { }); const rows = usage.getExtraUsageDisplayRows(snapshot); assert.equal(rows.length, 1); - assert.equal(rows[0].label, "Luna Reserve 额度"); + assert.equal(rows[0].label, "Luna Reserve"); assert.equal(rows[0].remainPercent, 80); }); diff --git a/apps/tests/ui-responsive-regressions.test.mjs b/apps/tests/ui-responsive-regressions.test.mjs index 77398ac20..9768ff36b 100644 --- a/apps/tests/ui-responsive-regressions.test.mjs +++ b/apps/tests/ui-responsive-regressions.test.mjs @@ -106,9 +106,9 @@ test("wide tables retain reachable actions and visible empty states", async () = assert.match(stylesSource, /\.account-pool-main-pane[\s\S]*overflow-x: auto;/); assert.match( stylesSource, - /\.account-pool-main-table[\s\S]*table-layout: fixed;[\s\S]*width: 100%;[\s\S]*min-width: 1216px;/, + /\.account-pool-main-table[\s\S]*table-layout: fixed;[\s\S]*width: 100%;[\s\S]*min-width: 1206px;/, ); - assert.match(stylesSource, /\.account-pool-col-quota[\s\S]*width: 300px;/); + assert.match(stylesSource, /\.account-pool-col-quota[\s\S]*width: 330px;/); assert.match(stylesSource, /\.account-pool-quota-grid[\s\S]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/); assert.match(stylesSource, /\.account-pool-col-order[\s\S]*width: 168px;/); assert.match(accountsSource, /w-\[168px\].*顺序/); From b1eaf93f76de7b72b9bde0c33c66c397ab7dcb5c Mon Sep 17 00:00:00 2001 From: Mi Tom <6468993+MDX-Tom@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:24:45 +0800 Subject: [PATCH 4/5] fix: route Luna Reserve usage by account identity --- crates/service/src/usage/refresh/mod.rs | 27 +++++- .../src/usage/tests/usage_http_tests.rs | 26 +++++- crates/service/src/usage/usage_http.rs | 62 +++++++------ .../service/src/usage/usage_reset_credits.rs | 87 ++++++++++++++----- .../tests/usage/usage_refresh_status_tests.rs | 15 +++- 5 files changed, 159 insertions(+), 58 deletions(-) diff --git a/crates/service/src/usage/refresh/mod.rs b/crates/service/src/usage/refresh/mod.rs index 921dfd4da..2f339a252 100644 --- a/crates/service/src/usage/refresh/mod.rs +++ b/crates/service/src/usage/refresh/mod.rs @@ -816,25 +816,30 @@ fn refresh_account_snapshot( .map_err(|err| format!("store account subscription failed: {err}"))?; } + // The usage endpoint expects the ChatGPT account UUID in + // `ChatGPT-Account-ID`. `workspace_id` is still used for the + // subscription lookup above, but it must not replace the account UUID + // when a token-derived account identity is available. + let usage_account_id = resolve_usage_account_id(subscription_account_id, workspace_id); log_account_data_route("usage", account_id, &proxy_mode, "usage", true); let value = match &proxy_mode { crate::account_proxy::AccountProxyMode::Disabled if is_fedramp => { - fetch_usage_snapshot_with_auth_context(base_url, bearer, workspace_id, is_fedramp)? + fetch_usage_snapshot_with_auth_context(base_url, bearer, usage_account_id, is_fedramp)? } crate::account_proxy::AccountProxyMode::Disabled => { - fetch_usage_snapshot(base_url, bearer, workspace_id)? + fetch_usage_snapshot(base_url, bearer, usage_account_id)? } crate::account_proxy::AccountProxyMode::Explicit { proxy_url, .. } if is_fedramp => { fetch_usage_snapshot_with_auth_context_and_explicit_proxy( base_url, bearer, - workspace_id, + usage_account_id, is_fedramp, proxy_url, )? } crate::account_proxy::AccountProxyMode::Explicit { proxy_url, .. } => { - fetch_usage_snapshot_with_explicit_proxy(base_url, bearer, workspace_id, proxy_url)? + fetch_usage_snapshot_with_explicit_proxy(base_url, bearer, usage_account_id, proxy_url)? } crate::account_proxy::AccountProxyMode::Invalid { error, .. } => { return Err(error.clone()); @@ -844,6 +849,20 @@ fn refresh_account_snapshot( Ok(classify_usage_status_from_snapshot_record(&stored)) } +fn resolve_usage_account_id<'a>( + chatgpt_account_id: Option<&'a str>, + workspace_id: Option<&'a str>, +) -> Option<&'a str> { + chatgpt_account_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + workspace_id + .map(str::trim) + .filter(|value| !value.is_empty()) + }) +} + #[cfg(test)] #[path = "../../../tests/usage/usage_refresh_status_tests.rs"] mod status_tests; diff --git a/crates/service/src/usage/tests/usage_http_tests.rs b/crates/service/src/usage/tests/usage_http_tests.rs index 232e31eeb..2e0e60e69 100644 --- a/crates/service/src/usage/tests/usage_http_tests.rs +++ b/crates/service/src/usage/tests/usage_http_tests.rs @@ -796,13 +796,13 @@ fn usage_http_default_headers_follow_gateway_runtime_profile() { /// 无 #[test] fn usage_request_headers_use_official_chatgpt_account_header_name() { - let headers = build_usage_request_headers(Some("workspace_123"), false); + let headers = build_usage_request_headers(Some("account_123"), false); assert_eq!( headers .get(CHATGPT_ACCOUNT_ID_HEADER_NAME) .and_then(|value| value.to_str().ok()), - Some("workspace_123") + Some("account_123") ); assert_eq!( headers @@ -810,7 +810,17 @@ fn usage_request_headers_use_official_chatgpt_account_header_name() { .and_then(|value| value.to_str().ok()), Some("1") ); - assert_eq!(headers.len(), 2); + assert_eq!( + headers + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-cache") + ); + assert_eq!( + headers.get("pragma").and_then(|value| value.to_str().ok()), + Some("no-cache") + ); + assert_eq!(headers.len(), 4); } #[test] @@ -824,7 +834,13 @@ fn usage_request_headers_include_fedramp_context_when_enabled() { Some("true") ); assert!(headers.get("x-openai-codex-luna-reserve").is_none()); - assert_eq!(headers.len(), 2); + assert_eq!( + headers + .get("cache-control") + .and_then(|value| value.to_str().ok()), + Some("no-cache") + ); + assert_eq!(headers.len(), 4); } #[test] @@ -1138,6 +1154,8 @@ fn fetch_usage_snapshot_with_explicit_proxy_uses_explicit_proxy_before_global_pr assert!(request.contains("authorization: bearer token_123")); assert!(request.contains("chatgpt-account-id: workspace_123")); assert!(request.contains("x-openai-codex-luna-reserve: 1")); + assert!(request.contains("cache-control: no-cache")); + assert!(request.contains("pragma: no-cache")); assert_eq!(snapshot["gpt4"]["usedPercent"], 12.5); } diff --git a/crates/service/src/usage/usage_http.rs b/crates/service/src/usage/usage_http.rs index 01a09c3db..7a9029f60 100644 --- a/crates/service/src/usage/usage_http.rs +++ b/crates/service/src/usage/usage_http.rs @@ -4,6 +4,7 @@ use codexmanager_core::usage::{ reset_credits_endpoint, usage_endpoint, ResetCreditsSnapshot, }; use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE}; +use reqwest::header::{CACHE_CONTROL, PRAGMA}; use reqwest::{Client, Proxy, Url}; use std::collections::HashMap; use std::future::Future; @@ -641,17 +642,17 @@ fn build_usage_http_default_headers() -> HeaderMap { /// 时间: 2026-04-02 /// /// # 参数 -/// - workspace_id: 参数 workspace_id +/// - chatgpt_account_id: 用于上游 `ChatGPT-Account-ID` 请求头的账号身份 /// /// # 返回 /// 返回函数执行结果 -fn build_usage_request_headers(workspace_id: Option<&str>, is_fedramp: bool) -> HeaderMap { +fn build_usage_request_headers(chatgpt_account_id: Option<&str>, is_fedramp: bool) -> HeaderMap { let mut headers = HeaderMap::new(); - if let Some(workspace_id) = workspace_id + if let Some(chatgpt_account_id) = chatgpt_account_id .map(str::trim) .filter(|value| !value.is_empty()) { - if let Ok(value) = HeaderValue::from_str(workspace_id) { + if let Ok(value) = HeaderValue::from_str(chatgpt_account_id) { if let Ok(name) = HeaderName::from_bytes(CHATGPT_ACCOUNT_ID_HEADER_NAME.as_bytes()) { headers.insert(name, value); } @@ -670,6 +671,11 @@ fn build_usage_request_headers(workspace_id: Option<&str>, is_fedramp: bool) -> HeaderValue::from_static("1"), ); } + // The usage endpoint is a GET and may be served through an intermediary; + // an explicit refresh must observe the current reserve bucket rather than + // a previously cached response. + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + headers.insert(PRAGMA, HeaderValue::from_static("no-cache")); headers } @@ -1056,21 +1062,21 @@ fn current_upstream_proxy_url() -> Option { pub(crate) fn fetch_usage_snapshot( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, ) -> Result { - fetch_usage_snapshot_with_auth_context(base_url, bearer, workspace_id, false) + fetch_usage_snapshot_with_auth_context(base_url, bearer, chatgpt_account_id, false) } pub(crate) fn fetch_usage_snapshot_with_auth_context( base_url: &str, auth_token: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, is_fedramp: bool, ) -> Result { run_usage_future(fetch_usage_snapshot_async( base_url, auth_token, - workspace_id, + chatgpt_account_id, is_fedramp, None, )) @@ -1079,13 +1085,13 @@ pub(crate) fn fetch_usage_snapshot_with_auth_context( pub(crate) fn fetch_usage_snapshot_with_explicit_proxy( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, proxy_url: &str, ) -> Result { fetch_usage_snapshot_with_auth_context_and_explicit_proxy( base_url, bearer, - workspace_id, + chatgpt_account_id, false, proxy_url, ) @@ -1094,7 +1100,7 @@ pub(crate) fn fetch_usage_snapshot_with_explicit_proxy( pub(crate) fn fetch_usage_snapshot_with_auth_context_and_explicit_proxy( base_url: &str, auth_token: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, is_fedramp: bool, proxy_url: &str, ) -> Result { @@ -1102,7 +1108,7 @@ pub(crate) fn fetch_usage_snapshot_with_auth_context_and_explicit_proxy( run_usage_future(fetch_usage_snapshot_async( base_url, auth_token, - workspace_id, + chatgpt_account_id, is_fedramp, Some(proxy_url.as_str()), )) @@ -1111,12 +1117,12 @@ pub(crate) fn fetch_usage_snapshot_with_auth_context_and_explicit_proxy( pub(crate) fn fetch_reset_credits_snapshot( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, ) -> Result { run_usage_future(fetch_reset_credits_snapshot_async( base_url, bearer, - workspace_id, + chatgpt_account_id, None, )) } @@ -1124,7 +1130,7 @@ pub(crate) fn fetch_reset_credits_snapshot( pub(crate) fn fetch_reset_credits_snapshot_with_explicit_proxy( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, proxy_url: &str, ) -> Result { let proxy_url = @@ -1135,7 +1141,7 @@ pub(crate) fn fetch_reset_credits_snapshot_with_explicit_proxy( run_usage_future(fetch_reset_credits_snapshot_async( base_url, bearer, - workspace_id, + chatgpt_account_id, Some(proxy_url.as_str()), )) } @@ -1143,13 +1149,13 @@ pub(crate) fn fetch_reset_credits_snapshot_with_explicit_proxy( pub(crate) fn consume_reset_credit_request( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, redeem_request_id: &str, ) -> Result<(), UsageActionHttpError> { run_usage_future(consume_reset_credit_request_async( base_url, bearer, - workspace_id, + chatgpt_account_id, redeem_request_id, None, )) @@ -1158,7 +1164,7 @@ pub(crate) fn consume_reset_credit_request( pub(crate) fn consume_reset_credit_request_with_explicit_proxy( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, redeem_request_id: &str, proxy_url: &str, ) -> Result<(), UsageActionHttpError> { @@ -1170,7 +1176,7 @@ pub(crate) fn consume_reset_credit_request_with_explicit_proxy( run_usage_future(consume_reset_credit_request_async( base_url, bearer, - workspace_id, + chatgpt_account_id, redeem_request_id, Some(proxy_url.as_str()), )) @@ -1178,7 +1184,7 @@ pub(crate) fn consume_reset_credit_request_with_explicit_proxy( fn reset_credit_request_headers( base_url: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, ) -> Result { let endpoint = reset_credits_endpoint(base_url); let url = Url::parse(&endpoint).map_err(|error| UsageActionHttpError { @@ -1193,7 +1199,7 @@ fn reset_credit_request_headers( } let origin = url.origin().ascii_serialization(); let referer = format!("{origin}/"); - let mut headers = build_usage_request_headers(workspace_id, false); + let mut headers = build_usage_request_headers(chatgpt_account_id, false); headers.insert( reqwest::header::ACCEPT, HeaderValue::from_static("application/json"), @@ -1222,11 +1228,11 @@ fn reset_credit_request_headers( async fn fetch_reset_credits_snapshot_async( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, explicit_proxy_url: Option<&str>, ) -> Result { let url = reset_credits_endpoint(base_url); - let request_headers = reset_credit_request_headers(base_url, workspace_id)?; + let request_headers = reset_credit_request_headers(base_url, chatgpt_account_id)?; let build_request = |client: Client| { client .get(&url) @@ -1284,12 +1290,12 @@ async fn fetch_reset_credits_snapshot_async( async fn consume_reset_credit_request_async( base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, redeem_request_id: &str, explicit_proxy_url: Option<&str>, ) -> Result<(), UsageActionHttpError> { let url = reset_credits_consume_endpoint(base_url); - let request_headers = reset_credit_request_headers(base_url, workspace_id)?; + let request_headers = reset_credit_request_headers(base_url, chatgpt_account_id)?; let build_request = |client: Client| { client .post(&url) @@ -1408,13 +1414,13 @@ pub(crate) fn fetch_account_subscription_with_explicit_proxy( async fn fetch_usage_snapshot_async( base_url: &str, auth_token: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, is_fedramp: bool, explicit_proxy_url: Option<&str>, ) -> Result { // 调用上游用量接口 let url = usage_endpoint(base_url); - let request_headers = build_usage_request_headers(workspace_id, is_fedramp); + let request_headers = build_usage_request_headers(chatgpt_account_id, is_fedramp); let authorization = crate::agent_identity::format_upstream_authorization(auth_token); let build_request = |client: Client| { let mut req = client.get(&url).header("Authorization", &authorization); diff --git a/crates/service/src/usage/usage_reset_credits.rs b/crates/service/src/usage/usage_reset_credits.rs index 967920a48..9c198885a 100644 --- a/crates/service/src/usage/usage_reset_credits.rs +++ b/crates/service/src/usage/usage_reset_credits.rs @@ -6,7 +6,9 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use crate::storage_helpers::open_storage; -use crate::usage_account_meta::{derive_account_meta, resolve_workspace_id_for_account}; +use crate::usage_account_meta::{ + clean_header_value, derive_account_meta, resolve_workspace_id_for_account, +}; use crate::usage_http::{ consume_reset_credit_request, consume_reset_credit_request_with_explicit_proxy, fetch_reset_credits_snapshot, fetch_reset_credits_snapshot_with_explicit_proxy, @@ -44,11 +46,18 @@ fn load_token(storage: &Storage, account_id: &str) -> Result { Ok(token) } -fn resolve_workspace_header(storage: &Storage, token: &Token) -> Option { - resolve_workspace_id_for_account(storage, &token.account_id).or_else(|| { - let (chatgpt_account_id, workspace_id) = derive_account_meta(token); - workspace_id.or(chatgpt_account_id) - }) +fn resolve_account_header(storage: &Storage, token: &Token) -> Option { + let (token_chatgpt_account_id, token_workspace_id) = derive_account_meta(token); + clean_header_value(token_chatgpt_account_id) + .or_else(|| { + storage + .find_account_workspace_identity_by_id(&token.account_id) + .ok() + .flatten() + .and_then(|identity| clean_header_value(identity.chatgpt_account_id)) + }) + .or(token_workspace_id) + .or_else(|| resolve_workspace_id_for_account(storage, &token.account_id)) } fn refresh_token_for_reset(storage: &Storage, token: &mut Token) -> Result<(), String> { @@ -79,7 +88,7 @@ fn fetch_snapshot_for_account( account_id: &str, base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, ) -> Result { let proxy_mode = crate::account_proxy::resolve_account_proxy_mode(account_id); log_account_data_route( @@ -91,13 +100,13 @@ fn fetch_snapshot_for_account( ); match &proxy_mode { crate::account_proxy::AccountProxyMode::Disabled => { - fetch_reset_credits_snapshot(base_url, bearer, workspace_id) + fetch_reset_credits_snapshot(base_url, bearer, chatgpt_account_id) } crate::account_proxy::AccountProxyMode::Explicit { proxy_url, .. } => { fetch_reset_credits_snapshot_with_explicit_proxy( base_url, bearer, - workspace_id, + chatgpt_account_id, proxy_url, ) } @@ -111,7 +120,7 @@ fn consume_for_account( account_id: &str, base_url: &str, bearer: &str, - workspace_id: Option<&str>, + chatgpt_account_id: Option<&str>, redeem_request_id: &str, ) -> Result<(), UsageActionHttpError> { let proxy_mode = crate::account_proxy::resolve_account_proxy_mode(account_id); @@ -124,13 +133,13 @@ fn consume_for_account( ); match &proxy_mode { crate::account_proxy::AccountProxyMode::Disabled => { - consume_reset_credit_request(base_url, bearer, workspace_id, redeem_request_id) + consume_reset_credit_request(base_url, bearer, chatgpt_account_id, redeem_request_id) } crate::account_proxy::AccountProxyMode::Explicit { proxy_url, .. } => { consume_reset_credit_request_with_explicit_proxy( base_url, bearer, - workspace_id, + chatgpt_account_id, redeem_request_id, proxy_url, ) @@ -146,22 +155,22 @@ fn fetch_snapshot_with_retry( token: &mut Token, ) -> Result { let base_url = usage_base_url(); - let mut workspace_id = resolve_workspace_header(storage, token); + let mut chatgpt_account_id = resolve_account_header(storage, token); match fetch_snapshot_for_account( token.account_id.as_str(), &base_url, &token.access_token, - workspace_id.as_deref(), + chatgpt_account_id.as_deref(), ) { Ok(snapshot) => Ok(snapshot), Err(error) if error.is_unauthorized() => { refresh_token_for_reset(storage, token)?; - workspace_id = resolve_workspace_header(storage, token); + chatgpt_account_id = resolve_account_header(storage, token); fetch_snapshot_for_account( token.account_id.as_str(), &base_url, &token.access_token, - workspace_id.as_deref(), + chatgpt_account_id.as_deref(), ) .map_err(|retry_error| retry_error.message) } @@ -175,23 +184,23 @@ fn consume_with_retry( redeem_request_id: &str, ) -> Result<(), String> { let base_url = usage_base_url(); - let mut workspace_id = resolve_workspace_header(storage, token); + let mut chatgpt_account_id = resolve_account_header(storage, token); match consume_for_account( token.account_id.as_str(), &base_url, &token.access_token, - workspace_id.as_deref(), + chatgpt_account_id.as_deref(), redeem_request_id, ) { Ok(()) => Ok(()), Err(error) if error.is_unauthorized() => { refresh_token_for_reset(storage, token)?; - workspace_id = resolve_workspace_header(storage, token); + chatgpt_account_id = resolve_account_header(storage, token); consume_for_account( token.account_id.as_str(), &base_url, &token.access_token, - workspace_id.as_deref(), + chatgpt_account_id.as_deref(), redeem_request_id, ) .map_err(|retry_error| retry_error.message) @@ -264,7 +273,8 @@ pub(crate) fn consume_reset_credit(account_id: &str) -> Result Date: Thu, 3 Sep 2026 18:34:05 +0800 Subject: [PATCH 5/5] fix: recover Luna Reserve after stale usage refresh --- crates/core/src/storage/usage.rs | 35 +++++++ crates/core/src/storage/usage_tests.rs | 44 +++++++++ .../service/src/usage/usage_snapshot_store.rs | 93 ++++++++++++++++++- 3 files changed, 169 insertions(+), 3 deletions(-) diff --git a/crates/core/src/storage/usage.rs b/crates/core/src/storage/usage.rs index be146dc53..8af3409f4 100644 --- a/crates/core/src/storage/usage.rs +++ b/crates/core/src/storage/usage.rs @@ -38,6 +38,19 @@ fn latest_usage_snapshot_for_account_sql() -> &'static str { LIMIT 1" } +fn latest_usage_snapshot_with_extra_rate_limits_for_account_sql() -> &'static str { + "SELECT account_id, used_percent, window_minutes, resets_at, secondary_used_percent, secondary_window_minutes, secondary_resets_at, credits_json, captured_at + FROM usage_snapshots + WHERE account_id = ?1 + AND CASE + WHEN json_valid(credits_json) + THEN COALESCE(json_array_length(credits_json, '$._codexmanager_extra_rate_limits'), 0) + ELSE 0 + END > 0 + ORDER BY captured_at DESC, id DESC + LIMIT 1" +} + fn latest_usage_snapshot_summary_rows_sql() -> String { format!( "{cte} @@ -232,6 +245,28 @@ impl Storage { } } + /// Returns the newest snapshot for an account that still contains at least + /// one normalized optional rate-limit bucket. + /// + /// A previous application version could persist an empty optional bucket + /// list when the upstream response temporarily returned `null`. Keeping + /// this lookup account-scoped lets the service recover a still-valid + /// reserve bucket without mixing data between accounts. + pub fn latest_usage_snapshot_with_extra_rate_limits_for_account( + &self, + account_id: &str, + ) -> Result> { + let mut stmt = self + .conn + .prepare(latest_usage_snapshot_with_extra_rate_limits_for_account_sql())?; + let mut rows = stmt.query([account_id])?; + if let Some(row) = rows.next()? { + Ok(Some(map_usage_snapshot_row(row)?)) + } else { + Ok(None) + } + } + /// 函数 `latest_usage_snapshots_by_account` /// /// 作者: gaohongshun diff --git a/crates/core/src/storage/usage_tests.rs b/crates/core/src/storage/usage_tests.rs index d5fadbd00..503b65bdd 100644 --- a/crates/core/src/storage/usage_tests.rs +++ b/crates/core/src/storage/usage_tests.rs @@ -155,6 +155,50 @@ fn latest_usage_snapshots_by_account_limited_zero_returns_empty() { assert!(items.is_empty()); } +#[test] +fn latest_usage_snapshot_with_extra_rate_limits_skips_empty_latest_bucket() { + let storage = Storage::open_in_memory().expect("open"); + storage.init().expect("init"); + let now = now_ts(); + storage + .insert_account(&sample_account("acc-extra-history", now)) + .expect("insert account"); + + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: "acc-extra-history".to_string(), + used_percent: Some(100.0), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: Some(100.0), + secondary_window_minutes: Some(10080), + secondary_resets_at: None, + credits_json: Some( + r#"{"_codexmanager_extra_rate_limits":[{"limit_name":"gpt-reserve"}]}"#.to_string(), + ), + captured_at: now, + }) + .expect("insert extra snapshot"); + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: "acc-extra-history".to_string(), + credits_json: Some(r#"{"_codexmanager_extra_rate_limits":[]}"#.to_string()), + captured_at: now + 1, + ..sample_snapshot("acc-extra-history", now + 1, 100.0) + }) + .expect("insert empty snapshot"); + + let recovered = storage + .latest_usage_snapshot_with_extra_rate_limits_for_account("acc-extra-history") + .expect("read extra snapshot") + .expect("extra snapshot exists"); + assert_eq!(recovered.captured_at, now); + assert!(recovered + .credits_json + .as_deref() + .is_some_and(|json| json.contains("gpt-reserve"))); +} + #[test] fn latest_usage_quota_source_rows_for_accounts_reads_only_quota_source_fields() { let storage = Storage::open_in_memory().expect("open"); diff --git a/crates/service/src/usage/usage_snapshot_store.rs b/crates/service/src/usage/usage_snapshot_store.rs index 6e10dbffb..e0a8c5bf0 100644 --- a/crates/service/src/usage/usage_snapshot_store.rs +++ b/crates/service/src/usage/usage_snapshot_store.rs @@ -4,7 +4,8 @@ use crate::account_status::{ }; use codexmanager_core::storage::{now_ts, Storage, UsageSnapshotRecord}; use codexmanager_core::usage::{ - merge_missing_extra_rate_limits, parse_usage_snapshot, usage_payload_declares_extra_rate_limits, + has_usable_luna_reserve, merge_missing_extra_rate_limits, parse_usage_snapshot, + usage_payload_declares_extra_rate_limits, }; const DEFAULT_USAGE_SNAPSHOTS_RETAIN_PER_ACCOUNT: usize = 1; @@ -103,12 +104,26 @@ pub(crate) fn store_usage_snapshot( .ok() .flatten() .and_then(|snapshot| snapshot.credits_json); + let recovery_credits_json = previous_credits_json + .as_deref() + .filter(|credits_json| has_usable_luna_reserve(Some(credits_json))) + .map(ToString::to_string) + .or_else(|| { + storage + .latest_usage_snapshot_with_extra_rate_limits_for_account(account_id) + .ok() + .flatten() + .and_then(|snapshot| { + let credits_json = snapshot.credits_json?; + has_usable_luna_reserve(Some(&credits_json)).then_some(credits_json) + }) + }); let credits_json = if usage_payload_declares_extra_rate_limits(&value) { parsed.credits_json } else { merge_missing_extra_rate_limits( parsed.credits_json.as_deref(), - previous_credits_json.as_deref(), + recovery_credits_json.as_deref(), ) .or(parsed.credits_json) }; @@ -137,7 +152,7 @@ pub(crate) fn store_usage_snapshot( #[cfg(test)] mod tests { use super::store_usage_snapshot; - use codexmanager_core::storage::Storage; + use codexmanager_core::storage::{now_ts, Storage, UsageSnapshotRecord}; use codexmanager_core::usage::has_usable_luna_reserve; #[test] @@ -226,4 +241,76 @@ mod tests { .expect("latest explicit usage exists"); assert!(!has_usable_luna_reserve(latest.credits_json.as_deref())); } + + #[test] + fn null_extra_payload_recovers_reserve_after_legacy_empty_snapshot() { + let storage = Storage::open_in_memory().expect("open storage"); + storage.init().expect("init storage"); + + store_usage_snapshot( + &storage, + "acc-luna-recovery", + serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + } + }, + "additional_rate_limits": [{ + "limit_name": "gpt-reserve", + "metered_feature": "base_model_inference", + "rate_limit": { + "primary_window": { + "used_percent": 0.0, + "limit_window_seconds": 604800 + } + } + }] + }), + ) + .expect("store reserve usage"); + + storage + .insert_usage_snapshot(&UsageSnapshotRecord { + account_id: "acc-luna-recovery".to_string(), + used_percent: Some(100.0), + window_minutes: Some(300), + resets_at: None, + secondary_used_percent: Some(100.0), + secondary_window_minutes: Some(10080), + secondary_resets_at: None, + credits_json: Some( + r#"{"_codexmanager_extra_rate_limits":[],"has_credits":false}"#.to_string(), + ), + captured_at: now_ts(), + }) + .expect("store legacy empty snapshot"); + + store_usage_snapshot( + &storage, + "acc-luna-recovery", + serde_json::json!({ + "rate_limit": { + "primary_window": { + "used_percent": 100.0, + "limit_window_seconds": 18000 + }, + "secondary_window": { + "used_percent": 100.0, + "limit_window_seconds": 604800 + } + }, + "additional_rate_limits": null, + "credits": {"has_credits": false} + }), + ) + .expect("store null extra usage"); + + let latest = storage + .latest_usage_snapshot_for_account("acc-luna-recovery") + .expect("read latest usage") + .expect("latest usage exists"); + assert!(has_usable_luna_reserve(latest.credits_json.as_deref())); + } }