From cc7c68e82dcf788a28b6514ffef2ad0af7952f44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:03:46 +0000 Subject: [PATCH 01/25] feat: add product name and logo branding configuration Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- backend/internal/app/app.go | 15 ++- backend/internal/app/auth_settings.go | 40 ++++++ .../202608060001_product_branding.sql | 5 + backend/migrations/migrations.go | 2 +- frontend/src/app/layout/AppShell.vue | 13 +- .../auth/views/ChangeCredentialsView.vue | 5 +- .../src/features/auth/views/LoginView.vue | 15 ++- .../features/settings/views/SettingsView.vue | 125 ++++++++++++++++++ frontend/src/main.ts | 2 + frontend/src/shared/state/productInfo.ts | 32 +++++ frontend/src/shared/types/api.ts | 9 ++ 11 files changed, 245 insertions(+), 18 deletions(-) create mode 100644 backend/migrations/202608060001_product_branding.sql create mode 100644 frontend/src/shared/state/productInfo.ts diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 78fa2268..0371472a 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -257,6 +257,7 @@ func (a *App) Routes() http.Handler { })) mux.HandleFunc("/api/auth/", a.wrap(a.handleAuth)) + mux.HandleFunc("/api/product-info", a.wrap(a.handleProductInfo)) mux.HandleFunc("/api/settings", a.wrap(a.handleSettings)) mux.HandleFunc("/api/collector/status", a.wrap(a.handleCollectorStatus)) mux.HandleFunc("/api/usage/", a.wrap(a.handleUsage)) @@ -495,6 +496,8 @@ type AppConfig struct { LiteLLMProxy LiteLLMProxyConfig `json:"litellm_proxy"` ModelRequestURL string `json:"model_request_url"` SessionSecret string `json:"session_secret"` + ProductName string `json:"product_name"` + ProductLogo string `json:"product_logo"` } func defaultConfig() (AppConfig, error) { @@ -550,14 +553,15 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) { SELECT collector_enabled, cliaproxy_url, management_key, queue_name, batch_size, poll_interval_seconds, retry_interval_seconds, codex_keeper_settings, codex_keeper_priority_rules, litellm_proxy_enabled, litellm_proxy_url, - model_request_url, session_secret + model_request_url, session_secret, product_name, product_logo FROM app_settings WHERE id = 1 `) var collectorEnabled, litellmProxyEnabled bool var cliaproxyURL, managementKey, queueName, keeperJSON, rulesJSON, litellmProxyURL, modelRequestURL, sessionSecret string + var productName, productLogo string var batchSize int var pollInterval, retryInterval float64 - if err := row.Scan(&collectorEnabled, &cliaproxyURL, &managementKey, &queueName, &batchSize, &pollInterval, &retryInterval, &keeperJSON, &rulesJSON, &litellmProxyEnabled, &litellmProxyURL, &modelRequestURL, &sessionSecret); err != nil { + if err := row.Scan(&collectorEnabled, &cliaproxyURL, &managementKey, &queueName, &batchSize, &pollInterval, &retryInterval, &keeperJSON, &rulesJSON, &litellmProxyEnabled, &litellmProxyURL, &modelRequestURL, &sessionSecret, &productName, &productLogo); err != nil { if errors.Is(err, sql.ErrNoRows) { return AppConfig{}, fmt.Errorf("%w: app_settings id=1 is missing; run `cpa-helper migrate`", ErrAppSettingsMissing) } @@ -594,6 +598,8 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) { ProxyURL: strings.TrimSpace(litellmProxyURL), } cfg.ModelRequestURL = nonBlank(strings.TrimRight(strings.TrimSpace(modelRequestURL), "/"), cfg.Collector.CLIProxyURL) + cfg.ProductName = strings.TrimSpace(productName) + cfg.ProductLogo = strings.TrimSpace(productLogo) return cfg, nil } @@ -648,9 +654,10 @@ func (a *App) saveConfig(ctx context.Context, cfg AppConfig) error { batch_size = ?, poll_interval_seconds = ?, retry_interval_seconds = ?, codex_keeper_settings = ?, codex_keeper_priority_rules = ?, litellm_proxy_enabled = ?, litellm_proxy_url = ?, - model_request_url = ?, session_secret = ?, updated_at = ? + model_request_url = ?, session_secret = ?, + product_name = ?, product_logo = ?, updated_at = ? WHERE id = 1 - `, cfg.Collector.Enabled, strings.TrimRight(strings.TrimSpace(cfg.Collector.CLIProxyURL), "/"), strings.TrimSpace(cfg.Collector.ManagementKey), strings.TrimSpace(cfg.Collector.QueueName), cfg.Collector.BatchSize, cfg.Collector.PollIntervalSeconds, cfg.Collector.RetryIntervalSeconds, string(keeperBytes), string(rulesBytes), cfg.LiteLLMProxy.Enabled, strings.TrimSpace(cfg.LiteLLMProxy.ProxyURL), strings.TrimRight(strings.TrimSpace(cfg.ModelRequestURL), "/"), cfg.SessionSecret, dbTime(time.Now())) + `, cfg.Collector.Enabled, strings.TrimRight(strings.TrimSpace(cfg.Collector.CLIProxyURL), "/"), strings.TrimSpace(cfg.Collector.ManagementKey), strings.TrimSpace(cfg.Collector.QueueName), cfg.Collector.BatchSize, cfg.Collector.PollIntervalSeconds, cfg.Collector.RetryIntervalSeconds, string(keeperBytes), string(rulesBytes), cfg.LiteLLMProxy.Enabled, strings.TrimSpace(cfg.LiteLLMProxy.ProxyURL), strings.TrimRight(strings.TrimSpace(cfg.ModelRequestURL), "/"), cfg.SessionSecret, cfg.ProductName, cfg.ProductLogo, dbTime(time.Now())) return err } diff --git a/backend/internal/app/auth_settings.go b/backend/internal/app/auth_settings.go index a83f7e1c..5a04980d 100644 --- a/backend/internal/app/auth_settings.go +++ b/backend/internal/app/auth_settings.go @@ -265,6 +265,8 @@ type settingsUpdateRequest struct { BatchSize *int `json:"batch_size"` PollIntervalSeconds *float64 `json:"poll_interval_seconds"` RetryIntervalSeconds *float64 `json:"retry_interval_seconds"` + ProductName *string `json:"product_name"` + ProductLogo *string `json:"product_logo"` } type modelRequestTestPayload struct { @@ -349,6 +351,20 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) error { } cfg.Collector.RetryIntervalSeconds = *payload.RetryIntervalSeconds } + if payload.ProductName != nil { + name := strings.TrimSpace(*payload.ProductName) + if len([]rune(name)) > 64 { + return validationError("product_name 超出最大长度 64") + } + cfg.ProductName = name + } + if payload.ProductLogo != nil { + logo := strings.TrimSpace(*payload.ProductLogo) + if logo != "" && !isAllowedLogoDataURL(logo) { + return validationError("product_logo 必须是 PNG 或 JPEG 的 base64 data URL") + } + cfg.ProductLogo = logo + } if err := a.saveConfig(r.Context(), cfg); err != nil { return err } @@ -371,7 +387,31 @@ func settingsResponse(cfg AppConfig) map[string]any { "batch_size": collector.BatchSize, "poll_interval_seconds": collector.PollIntervalSeconds, "retry_interval_seconds": collector.RetryIntervalSeconds, + "product_name": cfg.ProductName, + "product_logo": cfg.ProductLogo, + } +} + +func isAllowedLogoDataURL(s string) bool { + return strings.HasPrefix(s, "data:image/png;base64,") || + strings.HasPrefix(s, "data:image/jpeg;base64,") || + strings.HasPrefix(s, "data:image/webp;base64,") || + strings.HasPrefix(s, "data:image/gif;base64,") +} + +func (a *App) handleProductInfo(w http.ResponseWriter, r *http.Request) error { + if err := requireMethod(r, http.MethodGet); err != nil { + return err } + cfg, err := a.loadConfig(r.Context()) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]any{ + "product_name": cfg.ProductName, + "product_logo": cfg.ProductLogo, + }) + return nil } func (a *App) handleCurrentModelRequestGuide(w http.ResponseWriter, r *http.Request) error { diff --git a/backend/migrations/202608060001_product_branding.sql b/backend/migrations/202608060001_product_branding.sql new file mode 100644 index 00000000..cc4c10f6 --- /dev/null +++ b/backend/migrations/202608060001_product_branding.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE app_settings ADD COLUMN product_name VARCHAR(64) NOT NULL DEFAULT ''; +ALTER TABLE app_settings ADD COLUMN product_logo TEXT NOT NULL DEFAULT ''; + +-- +goose Down diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index 17562db2..8e54d43f 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -3,7 +3,7 @@ package migrations import "embed" // LatestVersion is the newest embedded migration version this binary expects. -const LatestVersion int64 = 202607290001 +const LatestVersion int64 = 202608060001 // FS contains SQL migrations embedded into the application binary. // diff --git a/frontend/src/app/layout/AppShell.vue b/frontend/src/app/layout/AppShell.vue index 91b22374..abf58225 100644 --- a/frontend/src/app/layout/AppShell.vue +++ b/frontend/src/app/layout/AppShell.vue @@ -42,7 +42,7 @@ import { getMe, isAuthUser, logout } from '@/features/auth/api/authApi' import { useCurrentUser } from '@/features/auth/state/currentUser' import { useThemePreference } from '@/shared/composables/useThemePreference' import { useI18n } from '@/shared/i18n' -import { logoUrl } from '@/shared/utils/assets' +import { useProductInfo } from '@/shared/state/productInfo' const route = useRoute() const router = useRouter() @@ -57,6 +57,7 @@ const { currentUser, setCurrentUser } = useCurrentUser() const hasLoadedUser = ref(currentUser.value !== null) const { isDark, preference, setThemePreference, toggleTheme } = useThemePreference() const { language, t, toggleLanguage } = useI18n() +const { productName, productLogo } = useProductInfo() let navigationFeedbackTimer: number | undefined let routeTransitionReleaseTimer: number | undefined @@ -309,10 +310,10 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out')) >
- +
- CPA-Helper + {{ productName }} {{ accountText }} · {{ roleText }}
@@ -379,10 +380,10 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out'))
- +
- CPA-Helper + {{ productName }} {{ appVersion }}
{{ accountText }} · {{ roleText }} @@ -430,7 +431,7 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out')) - +
diff --git a/frontend/src/features/auth/views/ChangeCredentialsView.vue b/frontend/src/features/auth/views/ChangeCredentialsView.vue index 78bcbff7..0446b92a 100644 --- a/frontend/src/features/auth/views/ChangeCredentialsView.vue +++ b/frontend/src/features/auth/views/ChangeCredentialsView.vue @@ -6,11 +6,12 @@ import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'na import { changeCredentials, getMe } from '@/features/auth/api/authApi' import { setCurrentUser } from '@/features/auth/state/currentUser' import { useI18n } from '@/shared/i18n' -import { logoUrl } from '@/shared/utils/assets' +import { useProductInfo } from '@/shared/state/productInfo' const router = useRouter() const message = useMessage() const { errorText, t } = useI18n() +const { productLogo } = useProductInfo() const isLoading = ref(false) const errorMessage = ref(null) const form = reactive({ @@ -58,7 +59,7 @@ async function handleSubmit() {
- +
diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 784cf1b4..4fd69436 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -6,12 +6,13 @@ import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'na import { getSetupState, login, setupFirstAdmin } from '@/features/auth/api/authApi' import { setCurrentUser } from '@/features/auth/state/currentUser' import { useI18n } from '@/shared/i18n' -import { logoUrl } from '@/shared/utils/assets' +import { loadProductInfo, useProductInfo } from '@/shared/state/productInfo' const route = useRoute() const router = useRouter() const message = useMessage() const { errorText, t } = useI18n() +const { productName, productLogo } = useProductInfo() const isLoading = ref(false) const isSetupLoading = ref(true) const setupRequired = ref(false) @@ -21,7 +22,7 @@ const form = reactive({ password: '', nickname: '', }) -const headingTitle = computed(() => (setupRequired.value ? t('创建首个管理员账号', 'Create first admin account') : 'CPA-Helper')) +const headingTitle = computed(() => (setupRequired.value ? t('创建首个管理员账号', 'Create first admin account') : productName.value)) const headingSubtitle = computed(() => setupRequired.value ? t('首次使用前需要先录入管理员账号', 'Create an admin account before first use') : t('本地 AI 用量管理控制台', 'Local AI usage management console'), ) @@ -29,8 +30,12 @@ const submitText = computed(() => (setupRequired.value ? t('创建并登录', 'C onMounted(async () => { try { - const state = await getSetupState() - setupRequired.value = state.setup_required + await Promise.all([ + loadProductInfo(), + getSetupState().then((state) => { + setupRequired.value = state.setup_required + }), + ]) } catch (error) { errorMessage.value = errorText(error, '初始化状态加载失败', 'Failed to load setup state') } finally { @@ -83,7 +88,7 @@ async function handleSubmit() {
- +
diff --git a/frontend/src/features/settings/views/SettingsView.vue b/frontend/src/features/settings/views/SettingsView.vue index 5c76bce7..df50f62d 100644 --- a/frontend/src/features/settings/views/SettingsView.vue +++ b/frontend/src/features/settings/views/SettingsView.vue @@ -22,6 +22,7 @@ import { updateSettings, } from '@/features/settings/api/settingsApi' import { useI18n } from '@/shared/i18n' +import { setProductInfo } from '@/shared/state/productInfo' import type { CollectorStatus, SettingsUpdatePayload } from '@/shared/types/api' import { formatDateTime, formatInteger } from '@/shared/utils/format' @@ -39,8 +40,35 @@ const settingsForm = reactive({ batch_size: 100, poll_interval_seconds: 2, retry_interval_seconds: 10, + product_name: '', + product_logo: '', }) +const logoPreview = computed(() => settingsForm.product_logo || null) + +function handleLogoFile(event: Event) { + const input = event.target as HTMLInputElement + const file = input.files?.[0] + if (!file) return + if (!file.type.startsWith('image/')) { + message.error(t('请选择图片文件', 'Please select an image file')) + return + } + if (file.size > 512 * 1024) { + message.error(t('图片文件不能超过 512 KB', 'Image file must not exceed 512 KB')) + return + } + const reader = new FileReader() + reader.onload = () => { + settingsForm.product_logo = reader.result as string + } + reader.readAsDataURL(file) +} + +function clearLogo() { + settingsForm.product_logo = '' +} + const remoteStatusType = computed(() => { if (collectorStatus.value?.remote_enabled === true) { return 'success' @@ -78,6 +106,8 @@ async function refresh() { settingsForm.batch_size = settings.batch_size settingsForm.poll_interval_seconds = settings.poll_interval_seconds settingsForm.retry_interval_seconds = settings.retry_interval_seconds + settingsForm.product_name = settings.product_name ?? '' + settingsForm.product_logo = settings.product_logo ?? '' collectorStatus.value = status } catch (error) { message.error(errorText(error, '加载设置失败', 'Failed to load settings')) @@ -97,9 +127,12 @@ async function saveSettings() { batch_size: settingsForm.batch_size, poll_interval_seconds: settingsForm.poll_interval_seconds, retry_interval_seconds: settingsForm.retry_interval_seconds, + product_name: settingsForm.product_name, + product_logo: settingsForm.product_logo, } const saved = await updateSettings(payload) settingsForm.management_key = saved.management_key + setProductInfo(saved.product_name ?? '', saved.product_logo ?? '') message.success(t('设置已保存', 'Settings saved')) await refresh() } catch (error) { @@ -244,6 +277,43 @@ onMounted(refresh)
+ +
+
+

{{ t('产品信息', 'Product Branding') }}

+ +
+
+
{{ t('产品名称', 'Product name') }}
+ +
{{ t('显示在侧边栏、登录页等位置。', 'Shown in the sidebar, login page, and other locations.') }}
+
+
+
{{ t('产品头像', 'Product logo') }}
+
+
+ + ? +
+
+ + {{ t('移除', 'Remove') }} +
+
+
{{ t('支持 PNG / JPEG / WebP / GIF,不超过 512 KB。留空使用默认图标。', 'PNG / JPEG / WebP / GIF, max 512 KB. Leave blank to use the default icon.') }}
+
+
+
+
+
@@ -290,6 +360,61 @@ onMounted(refresh) margin-top: 10px; } +.branding-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.logo-upload-row { + display: flex; + align-items: center; + gap: 12px; +} + +.logo-preview { + flex-shrink: 0; + width: 52px; + height: 52px; + border-radius: 12px; + overflow: hidden; + background: var(--cpa-surface-solid); + border: 1px solid var(--cpa-border); + display: grid; + place-items: center; +} + +.logo-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.logo-placeholder { + color: var(--cpa-text-muted); + font-size: 22px; + font-weight: 700; + line-height: 1; +} + +.logo-upload-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.logo-file-label { + cursor: pointer; +} + +.logo-file-input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + @media (max-width: 900px) { .settings-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/frontend/src/main.ts b/frontend/src/main.ts index a082a36d..908a14ef 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -2,7 +2,9 @@ import { createApp } from 'vue' import App from './app/App.vue' import { router } from './app/router' +import { loadProductInfo } from './shared/state/productInfo' import './styles/tokens.css' +loadProductInfo() createApp(App).use(router).mount('#app') diff --git a/frontend/src/shared/state/productInfo.ts b/frontend/src/shared/state/productInfo.ts new file mode 100644 index 00000000..65564b0e --- /dev/null +++ b/frontend/src/shared/state/productInfo.ts @@ -0,0 +1,32 @@ +import { readonly, ref } from 'vue' + +import { apiClient } from '@/shared/api/apiClient' +import type { ProductInfoResponse } from '@/shared/types/api' +import { logoUrl as defaultLogoUrl } from '@/shared/utils/assets' + +const DEFAULT_PRODUCT_NAME = 'CPA-Helper' + +const productName = ref(DEFAULT_PRODUCT_NAME) +const productLogo = ref(defaultLogoUrl) + +export async function loadProductInfo(): Promise { + try { + const info = await apiClient.get('/product-info') + productName.value = info.product_name.trim() || DEFAULT_PRODUCT_NAME + productLogo.value = info.product_logo.trim() || defaultLogoUrl + } catch { + // fall back to defaults on error + } +} + +export function setProductInfo(name: string, logo: string): void { + productName.value = name.trim() || DEFAULT_PRODUCT_NAME + productLogo.value = logo.trim() || defaultLogoUrl +} + +export function useProductInfo() { + return { + productName: readonly(productName), + productLogo: readonly(productLogo), + } +} diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index a03c1dac..cdfe2fe1 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -37,6 +37,8 @@ export interface SettingsResponse { batch_size: number poll_interval_seconds: number retry_interval_seconds: number + product_name: string + product_logo: string } export interface SettingsUpdatePayload { @@ -48,6 +50,13 @@ export interface SettingsUpdatePayload { batch_size?: number poll_interval_seconds?: number retry_interval_seconds?: number + product_name?: string + product_logo?: string +} + +export interface ProductInfoResponse { + product_name: string + product_logo: string } export interface ModelRequestGuide { From 08cf6e84ae858be3980227d76f6241e54ced7711 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:06:20 +0000 Subject: [PATCH 02/25] fix: address review comments on branding feature Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- backend/internal/app/auth_settings.go | 2 +- backend/migrations/202608060001_product_branding.sql | 2 ++ frontend/src/app/layout/AppShell.vue | 2 +- frontend/src/features/settings/views/SettingsView.vue | 1 + 4 files changed, 5 insertions(+), 2 deletions(-) diff --git a/backend/internal/app/auth_settings.go b/backend/internal/app/auth_settings.go index 5a04980d..23c4ebaa 100644 --- a/backend/internal/app/auth_settings.go +++ b/backend/internal/app/auth_settings.go @@ -361,7 +361,7 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) error { if payload.ProductLogo != nil { logo := strings.TrimSpace(*payload.ProductLogo) if logo != "" && !isAllowedLogoDataURL(logo) { - return validationError("product_logo 必须是 PNG 或 JPEG 的 base64 data URL") + return validationError("product_logo 必须是 PNG、JPEG、WebP 或 GIF 的 base64 data URL") } cfg.ProductLogo = logo } diff --git a/backend/migrations/202608060001_product_branding.sql b/backend/migrations/202608060001_product_branding.sql index cc4c10f6..02b88783 100644 --- a/backend/migrations/202608060001_product_branding.sql +++ b/backend/migrations/202608060001_product_branding.sql @@ -3,3 +3,5 @@ ALTER TABLE app_settings ADD COLUMN product_name VARCHAR(64) NOT NULL DEFAULT '' ALTER TABLE app_settings ADD COLUMN product_logo TEXT NOT NULL DEFAULT ''; -- +goose Down +ALTER TABLE app_settings DROP COLUMN product_logo; +ALTER TABLE app_settings DROP COLUMN product_name; diff --git a/frontend/src/app/layout/AppShell.vue b/frontend/src/app/layout/AppShell.vue index abf58225..9de237c7 100644 --- a/frontend/src/app/layout/AppShell.vue +++ b/frontend/src/app/layout/AppShell.vue @@ -379,7 +379,7 @@ const logoutAriaLabel = computed(() => t('退出登录', 'Sign out')) -
+
diff --git a/frontend/src/features/settings/views/SettingsView.vue b/frontend/src/features/settings/views/SettingsView.vue index df50f62d..e73a3af3 100644 --- a/frontend/src/features/settings/views/SettingsView.vue +++ b/frontend/src/features/settings/views/SettingsView.vue @@ -412,6 +412,7 @@ onMounted(refresh) height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); + clip-path: inset(50%); white-space: nowrap; } From 8d6bc6ccbf805e9d2c68a2763a2fd5eefb8b0f01 Mon Sep 17 00:00:00 2001 From: bytemain Date: Mon, 10 Aug 2026 13:09:56 +0000 Subject: [PATCH 03/25] fix: sync document.title with product name branding --- frontend/src/shared/state/productInfo.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/shared/state/productInfo.ts b/frontend/src/shared/state/productInfo.ts index 65564b0e..fccef7ef 100644 --- a/frontend/src/shared/state/productInfo.ts +++ b/frontend/src/shared/state/productInfo.ts @@ -9,11 +9,16 @@ const DEFAULT_PRODUCT_NAME = 'CPA-Helper' const productName = ref(DEFAULT_PRODUCT_NAME) const productLogo = ref(defaultLogoUrl) +function syncDocumentTitle(name: string): void { + document.title = name +} + export async function loadProductInfo(): Promise { try { const info = await apiClient.get('/product-info') productName.value = info.product_name.trim() || DEFAULT_PRODUCT_NAME productLogo.value = info.product_logo.trim() || defaultLogoUrl + syncDocumentTitle(productName.value) } catch { // fall back to defaults on error } @@ -22,6 +27,7 @@ export async function loadProductInfo(): Promise { export function setProductInfo(name: string, logo: string): void { productName.value = name.trim() || DEFAULT_PRODUCT_NAME productLogo.value = logo.trim() || defaultLogoUrl + syncDocumentTitle(productName.value) } export function useProductInfo() { From 90de775d425b11da2e42bed21a4fcaf3c4bf064c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:56:03 +0000 Subject: [PATCH 04/25] fix: inject product branding into index.html server-side Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- backend/internal/app/app.go | 67 ++++++++++++++++++++++-- backend/internal/app/frontend_test.go | 64 ++++++++++++++++++++++ frontend/src/shared/state/productInfo.ts | 1 - 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 0371472a..c5a0962a 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -402,8 +402,14 @@ func (a *App) serveExternalSPA(w http.ResponseWriter, r *http.Request) (bool, er } indexPath := filepath.Join(a.frontendDist, "index.html") if _, err := os.Stat(indexPath); err == nil { - http.ServeFile(w, r, indexPath) - return true, nil + data, err := os.ReadFile(indexPath) + if err != nil { + return false, err + } + data = a.injectBranding(r.Context(), data) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err = w.Write(data) + return true, err } return false, nil } @@ -416,11 +422,66 @@ func (a *App) serveEmbeddedSPA(w http.ResponseWriter, r *http.Request) (bool, er } } if _, err := fs.Stat(a.frontendFS, "index.html"); err == nil { - return true, serveFSFile(w, r, a.frontendFS, "index.html") + data, err := fs.ReadFile(a.frontendFS, "index.html") + if err != nil { + return false, err + } + data = a.injectBranding(r.Context(), data) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, err = w.Write(data) + return true, err } return false, nil } +// injectBranding replaces the tag and __CPA_HELPER_LOGO_URL__ placeholder +// in the given HTML with the product name and logo from the app configuration. +// On any error it returns the original HTML unchanged. +func (a *App) injectBranding(ctx context.Context, html []byte) []byte { + if a.db == nil { + return html + } + cfg, err := a.loadConfig(ctx) + if err != nil { + return html + } + + productName := cfg.ProductName + if productName == "" { + productName = "CPA-Helper" + } + logoURL := cfg.ProductLogo + if logoURL == "" { + logoURL = "/logo.png" + } + + html = bytes.ReplaceAll(html, []byte("__CPA_HELPER_LOGO_URL__"), []byte(logoURL)) + html = replaceTitleTag(html, productName) + return html +} + +// replaceTitleTag replaces the content of the first <title>... in html. +func replaceTitleTag(html []byte, title string) []byte { + const openTag = "" + const closeTag = "" + start := bytes.Index(html, []byte(openTag)) + if start == -1 { + return html + } + end := bytes.Index(html[start:], []byte(closeTag)) + if end == -1 { + return html + } + end += start + var buf bytes.Buffer + buf.Write(html[:start]) + buf.WriteString(openTag) + buf.WriteString(title) + buf.WriteString(closeTag) + buf.Write(html[end+len(closeTag):]) + return buf.Bytes() +} + func cleanSPAPath(requestPath string) string { cleaned := slashpath.Clean("/" + strings.TrimPrefix(requestPath, "/")) if cleaned == "/" { diff --git a/backend/internal/app/frontend_test.go b/backend/internal/app/frontend_test.go index 1daffdfc..e3eaced9 100644 --- a/backend/internal/app/frontend_test.go +++ b/backend/internal/app/frontend_test.go @@ -1,6 +1,7 @@ package app import ( + "context" "net/http/httptest" "os" "path/filepath" @@ -96,3 +97,66 @@ func TestHandleSPAFrontendDistOverrideUsesExternalFiles(t *testing.T) { t.Fatalf("body = %q", body) } } + +func TestReplaceTitleTag(t *testing.T) { + tests := []struct { + name string + input string + title string + want string + }{ + { + name: "replaces existing title", + input: `CPA-Helper`, + title: "My Product", + want: `My Product`, + }, + { + name: "no title tag returns unchanged", + input: ``, + title: "My Product", + want: ``, + }, + { + name: "no closing tag returns unchanged", + input: `CPA-Helper</head></html>`, + title: "My Product", + want: `<html><head><title>CPA-Helper</head></html>`, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := string(replaceTitleTag([]byte(tc.input), tc.title)) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestInjectBrandingNilDB(t *testing.T) { + // When a.db is nil (no database), injectBranding returns the HTML unchanged. + app := &App{} + input := `<html><head><title>CPA-Helper` + got := string(app.injectBranding(context.Background(), []byte(input))) + if got != input { + t.Fatalf("expected unchanged HTML when db is nil, got %q", got) + } +} + +func TestHandleSPAInjectsTitle(t *testing.T) { + // When db is nil the HTML is served as-is (no injection panic). + app := &App{frontendFS: fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(`CPA-Helper`)}, + }} + + req := httptest.NewRequest("GET", "http://example.com/", nil) + recorder := httptest.NewRecorder() + if err := app.handleSPA(recorder, req); err != nil { + t.Fatal(err) + } + body := recorder.Body.String() + if !strings.Contains(body, "CPA-Helper") { + t.Fatalf("body = %q", body) + } +} diff --git a/frontend/src/shared/state/productInfo.ts b/frontend/src/shared/state/productInfo.ts index fccef7ef..a5e42e1e 100644 --- a/frontend/src/shared/state/productInfo.ts +++ b/frontend/src/shared/state/productInfo.ts @@ -18,7 +18,6 @@ export async function loadProductInfo(): Promise { const info = await apiClient.get('/product-info') productName.value = info.product_name.trim() || DEFAULT_PRODUCT_NAME productLogo.value = info.product_logo.trim() || defaultLogoUrl - syncDocumentTitle(productName.value) } catch { // fall back to defaults on error } From 441bf6afe646de83d1b52610e877d69b4776f3a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:03:35 +0000 Subject: [PATCH 05/25] fix: embed product info in HTML to eliminate /api/product-info request Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- backend/internal/app/app.go | 30 +++++++++++++++- backend/internal/app/frontend_test.go | 34 ++++++++++++++++++- .../src/features/auth/views/LoginView.vue | 11 +++--- frontend/src/shared/state/productInfo.ts | 16 ++++----- 4 files changed, 73 insertions(+), 18 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index c5a0962a..eb3a5000 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -435,7 +435,9 @@ func (a *App) serveEmbeddedSPA(w http.ResponseWriter, r *http.Request) (bool, er } // injectBranding replaces the tag and __CPA_HELPER_LOGO_URL__ placeholder -// in the given HTML with the product name and logo from the app configuration. +// in the given HTML with the product name and logo from the app configuration, +// and injects a <script> block that exposes window.__PRODUCT_INFO__ so the +// frontend can bootstrap without an extra HTTP round-trip. // On any error it returns the original HTML unchanged. func (a *App) injectBranding(ctx context.Context, html []byte) []byte { if a.db == nil { @@ -457,9 +459,35 @@ func (a *App) injectBranding(ctx context.Context, html []byte) []byte { html = bytes.ReplaceAll(html, []byte("__CPA_HELPER_LOGO_URL__"), []byte(logoURL)) html = replaceTitleTag(html, productName) + html = injectProductInfoScript(html, productName, logoURL) return html } +// injectProductInfoScript injects a <script> block before </head> that sets +// window.__PRODUCT_INFO__ = {product_name: "...", product_logo: "..."}. +// Values are embedded as JSON so they are properly escaped. +func injectProductInfoScript(html []byte, productName, productLogo string) []byte { + type productInfo struct { + ProductName string `json:"product_name"` + ProductLogo string `json:"product_logo"` + } + jsonBytes, err := json.Marshal(productInfo{ProductName: productName, ProductLogo: productLogo}) + if err != nil { + return html + } + script := []byte("<script>window.__PRODUCT_INFO__=" + string(jsonBytes) + "</script>") + const headClose = "</head>" + idx := bytes.Index(html, []byte(headClose)) + if idx == -1 { + return html + } + var buf bytes.Buffer + buf.Write(html[:idx]) + buf.Write(script) + buf.Write(html[idx:]) + return buf.Bytes() +} + // replaceTitleTag replaces the content of the first <title>... in html. func replaceTitleTag(html []byte, title string) []byte { const openTag = "" diff --git a/backend/internal/app/frontend_test.go b/backend/internal/app/frontend_test.go index e3eaced9..321d5097 100644 --- a/backend/internal/app/frontend_test.go +++ b/backend/internal/app/frontend_test.go @@ -144,8 +144,40 @@ func TestInjectBrandingNilDB(t *testing.T) { } } +func TestInjectProductInfoScript(t *testing.T) { + tests := []struct { + name string + input string + productName string + productLogo string + wantContain string + }{ + { + name: "injects script before </head>", + input: `<html><head><title>CPA-Helper`, + productName: "My Product", + productLogo: "/logo.png", + wantContain: ``, + }, + { + name: "no tag returns unchanged", + input: ``, + productName: "My Product", + productLogo: "/logo.png", + wantContain: ``, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := string(injectProductInfoScript([]byte(tc.input), tc.productName, tc.productLogo)) + if !strings.Contains(got, tc.wantContain) { + t.Fatalf("got %q, want it to contain %q", got, tc.wantContain) + } + }) + } +} + func TestHandleSPAInjectsTitle(t *testing.T) { - // When db is nil the HTML is served as-is (no injection panic). app := &App{frontendFS: fstest.MapFS{ "index.html": &fstest.MapFile{Data: []byte(`CPA-Helper`)}, }} diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 4fd69436..03bf3622 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -6,7 +6,7 @@ import { NAlert, NButton, NCard, NForm, NFormItem, NInput, useMessage } from 'na import { getSetupState, login, setupFirstAdmin } from '@/features/auth/api/authApi' import { setCurrentUser } from '@/features/auth/state/currentUser' import { useI18n } from '@/shared/i18n' -import { loadProductInfo, useProductInfo } from '@/shared/state/productInfo' +import { useProductInfo } from '@/shared/state/productInfo' const route = useRoute() const router = useRouter() @@ -30,12 +30,9 @@ const submitText = computed(() => (setupRequired.value ? t('创建并登录', 'C onMounted(async () => { try { - await Promise.all([ - loadProductInfo(), - getSetupState().then((state) => { - setupRequired.value = state.setup_required - }), - ]) + await getSetupState().then((state) => { + setupRequired.value = state.setup_required + }) } catch (error) { errorMessage.value = errorText(error, '初始化状态加载失败', 'Failed to load setup state') } finally { diff --git a/frontend/src/shared/state/productInfo.ts b/frontend/src/shared/state/productInfo.ts index a5e42e1e..f3561415 100644 --- a/frontend/src/shared/state/productInfo.ts +++ b/frontend/src/shared/state/productInfo.ts @@ -1,7 +1,5 @@ import { readonly, ref } from 'vue' -import { apiClient } from '@/shared/api/apiClient' -import type { ProductInfoResponse } from '@/shared/types/api' import { logoUrl as defaultLogoUrl } from '@/shared/utils/assets' const DEFAULT_PRODUCT_NAME = 'CPA-Helper' @@ -13,13 +11,13 @@ function syncDocumentTitle(name: string): void { document.title = name } -export async function loadProductInfo(): Promise { - try { - const info = await apiClient.get('/product-info') - productName.value = info.product_name.trim() || DEFAULT_PRODUCT_NAME - productLogo.value = info.product_logo.trim() || defaultLogoUrl - } catch { - // fall back to defaults on error +export function loadProductInfo(): void { + const info = (window as Record).__PRODUCT_INFO__ as + | { product_name?: string; product_logo?: string } + | undefined + if (info) { + productName.value = (typeof info.product_name === 'string' ? info.product_name.trim() : '') || DEFAULT_PRODUCT_NAME + productLogo.value = (typeof info.product_logo === 'string' ? info.product_logo.trim() : '') || defaultLogoUrl } } From 67ac061764fcf5d4c27775a37062e6a4f2fe393d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 20 Aug 2026 06:14:19 +0000 Subject: [PATCH 06/25] refactor: simplify productInfo.ts branding/title sync Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> --- frontend/package-lock.json | 10 ---------- frontend/src/shared/state/productInfo.ts | 19 ++++++++++++------- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ddf1e353..87fe4b9d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1103,7 +1103,6 @@ "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1153,7 +1152,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -1615,7 +1613,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1785,7 +1782,6 @@ "resolved": "https://registry.npmjs.org/css-render/-/css-render-0.15.14.tgz", "integrity": "sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==", "license": "MIT", - "peer": true, "dependencies": { "@emotion/hash": "~0.8.0", "csstype": "~3.0.5" @@ -1821,7 +1817,6 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/kossnocorp" @@ -1948,7 +1943,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2685,7 +2679,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2961,7 +2954,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3036,7 +3028,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3115,7 +3106,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz", "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.33", "@vue/compiler-sfc": "3.5.33", diff --git a/frontend/src/shared/state/productInfo.ts b/frontend/src/shared/state/productInfo.ts index f3561415..0862410d 100644 --- a/frontend/src/shared/state/productInfo.ts +++ b/frontend/src/shared/state/productInfo.ts @@ -7,8 +7,12 @@ const DEFAULT_PRODUCT_NAME = 'CPA-Helper' const productName = ref(DEFAULT_PRODUCT_NAME) const productLogo = ref(defaultLogoUrl) -function syncDocumentTitle(name: string): void { - document.title = name +function normalizeProductName(raw: string): string { + return raw.trim() || DEFAULT_PRODUCT_NAME +} + +function normalizeProductLogo(raw: string): string { + return raw.trim() || defaultLogoUrl } export function loadProductInfo(): void { @@ -16,15 +20,16 @@ export function loadProductInfo(): void { | { product_name?: string; product_logo?: string } | undefined if (info) { - productName.value = (typeof info.product_name === 'string' ? info.product_name.trim() : '') || DEFAULT_PRODUCT_NAME - productLogo.value = (typeof info.product_logo === 'string' ? info.product_logo.trim() : '') || defaultLogoUrl + productName.value = normalizeProductName(typeof info.product_name === 'string' ? info.product_name : '') + productLogo.value = normalizeProductLogo(typeof info.product_logo === 'string' ? info.product_logo : '') } + document.title = productName.value } export function setProductInfo(name: string, logo: string): void { - productName.value = name.trim() || DEFAULT_PRODUCT_NAME - productLogo.value = logo.trim() || defaultLogoUrl - syncDocumentTitle(productName.value) + productName.value = normalizeProductName(name) + productLogo.value = normalizeProductLogo(logo) + document.title = productName.value } export function useProductInfo() { From d6932d585a371497731cefe01e3a35d62bfc3235 Mon Sep 17 00:00:00 2001 From: artin Date: Sat, 22 Aug 2026 01:32:33 +0800 Subject: [PATCH 07/25] refactor: drop dead /api/product-info endpoint and placeholder replace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend bootstraps branding from window.__PRODUCT_INFO__ injected into index.html, so the standalone /api/product-info endpoint is no longer called by anything: remove the route, handler, and the unused ProductInfoResponse type. Also remove the __CPA_HELPER_LOGO_URL__ ReplaceAll in injectBranding: the vite build substitutes that placeholder at build time, so it can never match in served HTML. The custom favicon is now applied by the frontend from __PRODUCT_INFO__ instead. Revert the pointless await-promise-then rewrite in LoginView and fix the window cast that broke vue-tsc -b. Co-authored-by: bytemain <13938334+bytemain@users.noreply.github.com> Signed-off-by: 怪味胡豆 Signed-off-by: artin --- backend/internal/app/app.go | 12 ++++++------ backend/internal/app/auth_settings.go | 15 --------------- frontend/src/features/auth/views/LoginView.vue | 5 ++--- frontend/src/shared/state/productInfo.ts | 12 +++++++++--- frontend/src/shared/types/api.ts | 5 ----- 5 files changed, 17 insertions(+), 32 deletions(-) diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index eb3a5000..effb2d57 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -257,7 +257,6 @@ func (a *App) Routes() http.Handler { })) mux.HandleFunc("/api/auth/", a.wrap(a.handleAuth)) - mux.HandleFunc("/api/product-info", a.wrap(a.handleProductInfo)) mux.HandleFunc("/api/settings", a.wrap(a.handleSettings)) mux.HandleFunc("/api/collector/status", a.wrap(a.handleCollectorStatus)) mux.HandleFunc("/api/usage/", a.wrap(a.handleUsage)) @@ -434,10 +433,12 @@ func (a *App) serveEmbeddedSPA(w http.ResponseWriter, r *http.Request) (bool, er return false, nil } -// injectBranding replaces the tag and __CPA_HELPER_LOGO_URL__ placeholder -// in the given HTML with the product name and logo from the app configuration, -// and injects a <script> block that exposes window.__PRODUCT_INFO__ so the -// frontend can bootstrap without an extra HTTP round-trip. +// injectBranding replaces the <title> tag in the given HTML with the product +// name from the app configuration, and injects a <script> block that exposes +// window.__PRODUCT_INFO__ so the frontend can bootstrap without an extra HTTP +// round-trip. The favicon is applied by the frontend from __PRODUCT_INFO__, +// because the vite build already substitutes the __CPA_HELPER_LOGO_URL__ +// placeholder in index.html at build time. // On any error it returns the original HTML unchanged. func (a *App) injectBranding(ctx context.Context, html []byte) []byte { if a.db == nil { @@ -457,7 +458,6 @@ func (a *App) injectBranding(ctx context.Context, html []byte) []byte { logoURL = "/logo.png" } - html = bytes.ReplaceAll(html, []byte("__CPA_HELPER_LOGO_URL__"), []byte(logoURL)) html = replaceTitleTag(html, productName) html = injectProductInfoScript(html, productName, logoURL) return html diff --git a/backend/internal/app/auth_settings.go b/backend/internal/app/auth_settings.go index 23c4ebaa..c8048391 100644 --- a/backend/internal/app/auth_settings.go +++ b/backend/internal/app/auth_settings.go @@ -399,21 +399,6 @@ func isAllowedLogoDataURL(s string) bool { strings.HasPrefix(s, "data:image/gif;base64,") } -func (a *App) handleProductInfo(w http.ResponseWriter, r *http.Request) error { - if err := requireMethod(r, http.MethodGet); err != nil { - return err - } - cfg, err := a.loadConfig(r.Context()) - if err != nil { - return err - } - writeJSON(w, http.StatusOK, map[string]any{ - "product_name": cfg.ProductName, - "product_logo": cfg.ProductLogo, - }) - return nil -} - func (a *App) handleCurrentModelRequestGuide(w http.ResponseWriter, r *http.Request) error { if err := requireMethod(r, http.MethodGet); err != nil { return err diff --git a/frontend/src/features/auth/views/LoginView.vue b/frontend/src/features/auth/views/LoginView.vue index 03bf3622..c6efcc7c 100644 --- a/frontend/src/features/auth/views/LoginView.vue +++ b/frontend/src/features/auth/views/LoginView.vue @@ -30,9 +30,8 @@ const submitText = computed(() => (setupRequired.value ? t('创建并登录', 'C onMounted(async () => { try { - await getSetupState().then((state) => { - setupRequired.value = state.setup_required - }) + const state = await getSetupState() + setupRequired.value = state.setup_required } catch (error) { errorMessage.value = errorText(error, '初始化状态加载失败', 'Failed to load setup state') } finally { diff --git a/frontend/src/shared/state/productInfo.ts b/frontend/src/shared/state/productInfo.ts index 0862410d..fb52e9f4 100644 --- a/frontend/src/shared/state/productInfo.ts +++ b/frontend/src/shared/state/productInfo.ts @@ -15,21 +15,27 @@ function normalizeProductLogo(raw: string): string { return raw.trim() || defaultLogoUrl } +function applyProductInfo(): void { + document.title = productName.value + const favicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]') + if (favicon) favicon.href = productLogo.value +} + export function loadProductInfo(): void { - const info = (window as Record<string, unknown>).__PRODUCT_INFO__ as + const info = (window as unknown as Record<string, unknown>).__PRODUCT_INFO__ as | { product_name?: string; product_logo?: string } | undefined if (info) { productName.value = normalizeProductName(typeof info.product_name === 'string' ? info.product_name : '') productLogo.value = normalizeProductLogo(typeof info.product_logo === 'string' ? info.product_logo : '') } - document.title = productName.value + applyProductInfo() } export function setProductInfo(name: string, logo: string): void { productName.value = normalizeProductName(name) productLogo.value = normalizeProductLogo(logo) - document.title = productName.value + applyProductInfo() } export function useProductInfo() { diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index cdfe2fe1..74b6b98d 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -54,11 +54,6 @@ export interface SettingsUpdatePayload { product_logo?: string } -export interface ProductInfoResponse { - product_name: string - product_logo: string -} - export interface ModelRequestGuide { model_request_url: string openai_base_url: string From 89273f8be997d119793e1979648a16bf577cbcfe Mon Sep 17 00:00:00 2001 From: Friday <friday@botiverse.dev> Date: Fri, 21 Aug 2026 18:38:57 +0000 Subject: [PATCH 08/25] =?UTF-8?q?feat:=20projected=20per-account=20quota-w?= =?UTF-8?q?indow=20cost=20(=E6=9C=AC=E7=AA=97=E5=8F=A3=E9=A2=84=E8=AE=A1?= =?UTF-8?q?=E6=B6=88=E8=B4=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: keeperQuotaWindowUsage gains window progress fields (elapsed seconds/percent) and ProjectedCostUSD — observed cost linearly extrapolated to the full window; stale windows project to their own total. Exposed via /api/codex-keeper/accounts as window_elapsed_seconds, window_elapsed_percent, projected_cost_usd. Frontend: per-window tags/text show the projection, and the accounts dashboard gains a 本窗口预计消费 metric card summing each enabled account's long-window projection at list price. --- backend/internal/app/codex_keeper.go | 44 +++++++++ .../app/codex_keeper_internal_test.go | 91 +++++++++++++++++++ .../views/CodexKeeperStatusView.vue | 53 ++++++++++- frontend/src/shared/types/api.ts | 3 + 4 files changed, 189 insertions(+), 2 deletions(-) diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index f39ae78a..2d0dea8f 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -167,6 +167,10 @@ type keeperQuotaWindowUsageResponse struct { ReasoningTokens int `json:"reasoning_tokens"` TotalTokens int `json:"total_tokens"` EstimatedCostUSD float64 `json:"estimated_cost_usd"` + + ElapsedSeconds int `json:"window_elapsed_seconds"` + WindowElapsedPercent float64 `json:"window_elapsed_percent"` + ProjectedCostUSD float64 `json:"projected_cost_usd"` UnpricedRecords int `json:"unpriced_records"` Stale bool `json:"stale"` WindowSource string `json:"window_source"` @@ -189,6 +193,10 @@ type keeperQuotaWindowUsage struct { UnpricedRecords int Stale bool WindowSource string + + ElapsedSeconds int + WindowElapsedPercent float64 + ProjectedCostUSD float64 } type keeperQuotaWindowUsagePair struct { @@ -1080,6 +1088,10 @@ func keeperQuotaWindowUsageResponseFrom(usage *keeperQuotaWindowUsage) *keeperQu UnpricedRecords: usage.UnpricedRecords, Stale: usage.Stale, WindowSource: usage.WindowSource, + + ElapsedSeconds: usage.ElapsedSeconds, + WindowElapsedPercent: usage.WindowElapsedPercent, + ProjectedCostUSD: usage.ProjectedCostUSD, } } @@ -1222,9 +1234,41 @@ func (a *App) computeKeeperQuotaWindowUsages(ctx context.Context, accounts []kee addRecordToKeeperQuotaWindowUsage(pair.Secondary, record, prices) } } + for _, pair := range usages { + finalizeKeeperQuotaWindowUsage(pair.Primary, now) + finalizeKeeperQuotaWindowUsage(pair.Secondary, now) + } return usages, nil } +// finalizeKeeperQuotaWindowUsage fills the window-progress and projection +// fields once all records are aggregated. ProjectedCostUSD linearly +// extrapolates the observed cost to the full window ("本窗口预计消费"): +// a completed/stale window projects to its own total; a window with no +// elapsed time projects to zero. +func finalizeKeeperQuotaWindowUsage(usage *keeperQuotaWindowUsage, now time.Time) { + if usage == nil || usage.WindowSeconds <= 0 { + return + } + elapsed := int(now.Sub(usage.WindowStart).Seconds()) + if elapsed < 0 { + elapsed = 0 + } + if elapsed > usage.WindowSeconds { + elapsed = usage.WindowSeconds + } + usage.ElapsedSeconds = elapsed + usage.WindowElapsedPercent = mathRound(float64(elapsed)/float64(usage.WindowSeconds)*100, 2) + switch { + case usage.Stale || elapsed >= usage.WindowSeconds: + usage.ProjectedCostUSD = usage.EstimatedCostUSD + case elapsed > 0: + usage.ProjectedCostUSD = mathRound(usage.EstimatedCostUSD*float64(usage.WindowSeconds)/float64(elapsed), 8) + default: + usage.ProjectedCostUSD = 0 + } +} + func keeperQuotaWindowPairForAccount(account keeperAccount, now time.Time) keeperQuotaWindowUsagePair { return keeperQuotaWindowUsagePair{ Primary: keeperQuotaWindowForAccount(account, true, now), diff --git a/backend/internal/app/codex_keeper_internal_test.go b/backend/internal/app/codex_keeper_internal_test.go index 660ad8d7..b14c993a 100644 --- a/backend/internal/app/codex_keeper_internal_test.go +++ b/backend/internal/app/codex_keeper_internal_test.go @@ -2214,3 +2214,94 @@ func keeperWebsocketUsageSuccessPayload(usedPercent int) map[string]any { }, } } + +func TestFinalizeKeeperQuotaWindowUsageProjection(t *testing.T) { + windowSeconds := 3600 + start := time.Date(2026, 8, 21, 10, 0, 0, 0, time.UTC) + cases := []struct { + name string + now time.Time + stale bool + cost float64 + wantElapsed int + wantPercent float64 + wantProjection float64 + }{ + {"halfway", start.Add(30 * time.Minute), false, 10, 1800, 50, 20}, + {"just started", start.Add(2 * time.Minute), false, 3, 120, 3.33, 90}, + {"no elapsed", start, false, 5, 0, 0, 0}, + {"stale projects to own total", start.Add(2 * time.Duration(windowSeconds) * time.Second), true, 42, 3600, 100, 42}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + usage := &keeperQuotaWindowUsage{ + WindowStart: start, + WindowEnd: start.Add(time.Duration(windowSeconds) * time.Second), + WindowSeconds: windowSeconds, + EstimatedCostUSD: tc.cost, + Stale: tc.stale, + } + finalizeKeeperQuotaWindowUsage(usage, tc.now) + if usage.ElapsedSeconds != tc.wantElapsed { + t.Fatalf("elapsed = %d, want %d", usage.ElapsedSeconds, tc.wantElapsed) + } + if usage.WindowElapsedPercent != tc.wantPercent { + t.Fatalf("elapsed percent = %v, want %v", usage.WindowElapsedPercent, tc.wantPercent) + } + if usage.ProjectedCostUSD != tc.wantProjection { + t.Fatalf("projected cost = %v, want %v", usage.ProjectedCostUSD, tc.wantProjection) + } + }) + } +} + +func TestComputeKeeperQuotaWindowUsagesFillsProjection(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + + now := time.Date(2026, 8, 21, 12, 30, 0, 0, appTimeLocation) + resetAt := now.Add(30 * time.Minute) + accounts := []keeperAccount{ + { + Name: "proj.json", + Email: stringPtr("proj@example.com"), + AccountType: stringPtr("plus"), + PrimaryResetAt: timePtrValue(resetAt), + PrimaryWindowSeconds: intPtrValue(3600), + }, + } + insertKeeperWindowUsageRecord(t, app, keeperWindowUsageSeed{ + Dedupe: "proj-record", + Timestamp: now.Add(-10 * time.Minute), + Source: "proj@example.com", + InputTokens: 100, + OutputTokens: 20, + RawJSON: `{"source":"proj@example.com"}`, + }) + + usages, err := app.computeKeeperQuotaWindowUsages(context.Background(), accounts, now) + if err != nil { + t.Fatalf("compute window usages: %v", err) + } + usage := usages["proj.json"].Primary + if usage == nil { + t.Fatal("primary usage missing") + } + if usage.ElapsedSeconds != 1800 { + t.Fatalf("elapsed = %d, want 1800", usage.ElapsedSeconds) + } + if usage.WindowElapsedPercent != 50 { + t.Fatalf("elapsed percent = %v, want 50", usage.WindowElapsedPercent) + } + if want := mathRound(usage.EstimatedCostUSD*2, 8); usage.ProjectedCostUSD != want { + t.Fatalf("projected = %v, want %v (2x observed)", usage.ProjectedCostUSD, want) + } + resp := keeperQuotaWindowUsageResponseFrom(usage) + if resp.WindowElapsedPercent != usage.WindowElapsedPercent || resp.ProjectedCostUSD != usage.ProjectedCostUSD { + t.Fatal("response mapping drops projection fields") + } +} diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index bf429329..9794d841 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -221,6 +221,32 @@ const filteredNormalAccounts = computed(() => ) const tableLoading = computed(() => isLoading.value) const enabledAccountCount = computed(() => accounts.value.filter((account) => !account.disabled).length) + +const longWindowProjectedCostUsd = computed(() => { + let total = 0 + let covered = 0 + for (const account of accounts.value) { + if (account.disabled) { + continue + } + const usage = isFreeQuotaWindowAccount(account.account_type) + ? account.primary_window_usage + : account.secondary_window_usage ?? account.primary_window_usage + if (!usage || usage.stale === true) { + continue + } + total += usage.projected_cost_usd ?? usage.estimated_cost_usd ?? 0 + covered += 1 + } + return { total, covered } +}) + +const projectedWindowCostText = computed(() => { + if (longWindowProjectedCostUsd.value.covered === 0) { + return t('-', '-') + } + return formatUsd(longWindowProjectedCostUsd.value.total) +}) const disabledAccountCount = computed(() => accounts.value.filter((account) => account.disabled).length) const hasDisabledAccounts = computed(() => disabledAccountCount.value > 0) const showDisabledSection = computed( @@ -985,10 +1011,14 @@ function quotaWindowUsageText(item: QuotaWindowItem): string { if (!item.usage) { return t('本窗口暂无用量', 'No usage in this window') } - return t( + const base = t( `${formatInteger(item.usage.records)} 次 / ${formatCompact(item.usage.total_tokens)} Tokens / ${formatUsd(item.usage.estimated_cost_usd)}`, `${formatInteger(item.usage.records)} requests / ${formatCompact(item.usage.total_tokens)} Tokens / ${formatUsd(item.usage.estimated_cost_usd)}`, ) + if (typeof item.usage.projected_cost_usd === 'number' && item.usage.projected_cost_usd > 0) { + return `${base} → ${t('预计', 'projected')} ${formatUsd(item.usage.projected_cost_usd)}` + } + return base } function quotaWindowUsageTags(item: QuotaWindowItem): QuotaUsageTag[] { @@ -996,11 +1026,15 @@ function quotaWindowUsageTags(item: QuotaWindowItem): QuotaUsageTag[] { return [{ label: t('状态', 'Status'), value: t('需刷新', 'Needs refresh'), tone: 'stale' }] } const usage = item.usage - return [ + const tags: QuotaUsageTag[] = [ { label: t('请求', 'Requests'), value: formatInteger(usage?.records ?? 0) }, { label: 'Tokens', value: formatCompact(usage?.total_tokens ?? 0) }, { label: t('费用', 'Cost'), value: formatUsd(usage?.estimated_cost_usd ?? 0) }, ] + if (typeof usage?.projected_cost_usd === 'number' && usage.projected_cost_usd > 0) { + tags.push({ label: t('预计本窗口', 'Projected'), value: formatUsd(usage.projected_cost_usd) }) + } + return tags } function quotaWindowResetText(item: QuotaWindowItem): string { @@ -1927,6 +1961,21 @@ onBeforeUnmount(() => { <div class="metric-value">{{ formatInteger(unauthorizedErrorAccountCount) }}</div> <div class="metric-footnote">HTTP 401</div> </button> + <div class="metric-card"> + <div class="metric-icon" aria-hidden="true"> + <Activity :size="20" :stroke-width="2.2" /> + </div> + <div class="metric-label">{{ t('本窗口预计消费', 'Projected Window Cost') }}</div> + <div class="metric-value">{{ projectedWindowCostText }}</div> + <div class="metric-footnote"> + {{ + t( + `按当前速率外推 ${longWindowProjectedCostUsd.covered} 个账号的长窗口(牌价折算)`, + `Linear extrapolation over ${longWindowProjectedCostUsd.covered} accounts' long windows (list price)`, + ) + }} + </div> + </div> <button type="button" class="metric-card metric-action is-purple" diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 74b6b98d..674a3ffe 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -229,6 +229,9 @@ export interface CodexKeeperQuotaWindowUsage { unpriced_records: number stale: boolean window_source: string + window_elapsed_seconds?: number + window_elapsed_percent?: number + projected_cost_usd?: number } export interface CodexKeeperAccount { From 11284f3e844238f03d3fb5e4168ddcb76ea957f1 Mon Sep 17 00:00:00 2001 From: Friday <friday@botiverse.dev> Date: Sat, 22 Aug 2026 14:57:33 +0000 Subject: [PATCH 09/25] =?UTF-8?q?Revert=20"feat:=20projected=20per-account?= =?UTF-8?q?=20quota-window=20cost=20(=E6=9C=AC=E7=AA=97=E5=8F=A3=E9=A2=84?= =?UTF-8?q?=E8=AE=A1=E6=B6=88=E8=B4=B9)"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 89273f8be997d119793e1979648a16bf577cbcfe. --- backend/internal/app/codex_keeper.go | 44 --------- .../app/codex_keeper_internal_test.go | 91 ------------------- .../views/CodexKeeperStatusView.vue | 53 +---------- frontend/src/shared/types/api.ts | 3 - 4 files changed, 2 insertions(+), 189 deletions(-) diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index 2d0dea8f..f39ae78a 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -167,10 +167,6 @@ type keeperQuotaWindowUsageResponse struct { ReasoningTokens int `json:"reasoning_tokens"` TotalTokens int `json:"total_tokens"` EstimatedCostUSD float64 `json:"estimated_cost_usd"` - - ElapsedSeconds int `json:"window_elapsed_seconds"` - WindowElapsedPercent float64 `json:"window_elapsed_percent"` - ProjectedCostUSD float64 `json:"projected_cost_usd"` UnpricedRecords int `json:"unpriced_records"` Stale bool `json:"stale"` WindowSource string `json:"window_source"` @@ -193,10 +189,6 @@ type keeperQuotaWindowUsage struct { UnpricedRecords int Stale bool WindowSource string - - ElapsedSeconds int - WindowElapsedPercent float64 - ProjectedCostUSD float64 } type keeperQuotaWindowUsagePair struct { @@ -1088,10 +1080,6 @@ func keeperQuotaWindowUsageResponseFrom(usage *keeperQuotaWindowUsage) *keeperQu UnpricedRecords: usage.UnpricedRecords, Stale: usage.Stale, WindowSource: usage.WindowSource, - - ElapsedSeconds: usage.ElapsedSeconds, - WindowElapsedPercent: usage.WindowElapsedPercent, - ProjectedCostUSD: usage.ProjectedCostUSD, } } @@ -1234,41 +1222,9 @@ func (a *App) computeKeeperQuotaWindowUsages(ctx context.Context, accounts []kee addRecordToKeeperQuotaWindowUsage(pair.Secondary, record, prices) } } - for _, pair := range usages { - finalizeKeeperQuotaWindowUsage(pair.Primary, now) - finalizeKeeperQuotaWindowUsage(pair.Secondary, now) - } return usages, nil } -// finalizeKeeperQuotaWindowUsage fills the window-progress and projection -// fields once all records are aggregated. ProjectedCostUSD linearly -// extrapolates the observed cost to the full window ("本窗口预计消费"): -// a completed/stale window projects to its own total; a window with no -// elapsed time projects to zero. -func finalizeKeeperQuotaWindowUsage(usage *keeperQuotaWindowUsage, now time.Time) { - if usage == nil || usage.WindowSeconds <= 0 { - return - } - elapsed := int(now.Sub(usage.WindowStart).Seconds()) - if elapsed < 0 { - elapsed = 0 - } - if elapsed > usage.WindowSeconds { - elapsed = usage.WindowSeconds - } - usage.ElapsedSeconds = elapsed - usage.WindowElapsedPercent = mathRound(float64(elapsed)/float64(usage.WindowSeconds)*100, 2) - switch { - case usage.Stale || elapsed >= usage.WindowSeconds: - usage.ProjectedCostUSD = usage.EstimatedCostUSD - case elapsed > 0: - usage.ProjectedCostUSD = mathRound(usage.EstimatedCostUSD*float64(usage.WindowSeconds)/float64(elapsed), 8) - default: - usage.ProjectedCostUSD = 0 - } -} - func keeperQuotaWindowPairForAccount(account keeperAccount, now time.Time) keeperQuotaWindowUsagePair { return keeperQuotaWindowUsagePair{ Primary: keeperQuotaWindowForAccount(account, true, now), diff --git a/backend/internal/app/codex_keeper_internal_test.go b/backend/internal/app/codex_keeper_internal_test.go index b14c993a..660ad8d7 100644 --- a/backend/internal/app/codex_keeper_internal_test.go +++ b/backend/internal/app/codex_keeper_internal_test.go @@ -2214,94 +2214,3 @@ func keeperWebsocketUsageSuccessPayload(usedPercent int) map[string]any { }, } } - -func TestFinalizeKeeperQuotaWindowUsageProjection(t *testing.T) { - windowSeconds := 3600 - start := time.Date(2026, 8, 21, 10, 0, 0, 0, time.UTC) - cases := []struct { - name string - now time.Time - stale bool - cost float64 - wantElapsed int - wantPercent float64 - wantProjection float64 - }{ - {"halfway", start.Add(30 * time.Minute), false, 10, 1800, 50, 20}, - {"just started", start.Add(2 * time.Minute), false, 3, 120, 3.33, 90}, - {"no elapsed", start, false, 5, 0, 0, 0}, - {"stale projects to own total", start.Add(2 * time.Duration(windowSeconds) * time.Second), true, 42, 3600, 100, 42}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - usage := &keeperQuotaWindowUsage{ - WindowStart: start, - WindowEnd: start.Add(time.Duration(windowSeconds) * time.Second), - WindowSeconds: windowSeconds, - EstimatedCostUSD: tc.cost, - Stale: tc.stale, - } - finalizeKeeperQuotaWindowUsage(usage, tc.now) - if usage.ElapsedSeconds != tc.wantElapsed { - t.Fatalf("elapsed = %d, want %d", usage.ElapsedSeconds, tc.wantElapsed) - } - if usage.WindowElapsedPercent != tc.wantPercent { - t.Fatalf("elapsed percent = %v, want %v", usage.WindowElapsedPercent, tc.wantPercent) - } - if usage.ProjectedCostUSD != tc.wantProjection { - t.Fatalf("projected cost = %v, want %v", usage.ProjectedCostUSD, tc.wantProjection) - } - }) - } -} - -func TestComputeKeeperQuotaWindowUsagesFillsProjection(t *testing.T) { - t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) - app, err := New() - if err != nil { - t.Fatalf("New() failed: %v", err) - } - defer app.Close() - - now := time.Date(2026, 8, 21, 12, 30, 0, 0, appTimeLocation) - resetAt := now.Add(30 * time.Minute) - accounts := []keeperAccount{ - { - Name: "proj.json", - Email: stringPtr("proj@example.com"), - AccountType: stringPtr("plus"), - PrimaryResetAt: timePtrValue(resetAt), - PrimaryWindowSeconds: intPtrValue(3600), - }, - } - insertKeeperWindowUsageRecord(t, app, keeperWindowUsageSeed{ - Dedupe: "proj-record", - Timestamp: now.Add(-10 * time.Minute), - Source: "proj@example.com", - InputTokens: 100, - OutputTokens: 20, - RawJSON: `{"source":"proj@example.com"}`, - }) - - usages, err := app.computeKeeperQuotaWindowUsages(context.Background(), accounts, now) - if err != nil { - t.Fatalf("compute window usages: %v", err) - } - usage := usages["proj.json"].Primary - if usage == nil { - t.Fatal("primary usage missing") - } - if usage.ElapsedSeconds != 1800 { - t.Fatalf("elapsed = %d, want 1800", usage.ElapsedSeconds) - } - if usage.WindowElapsedPercent != 50 { - t.Fatalf("elapsed percent = %v, want 50", usage.WindowElapsedPercent) - } - if want := mathRound(usage.EstimatedCostUSD*2, 8); usage.ProjectedCostUSD != want { - t.Fatalf("projected = %v, want %v (2x observed)", usage.ProjectedCostUSD, want) - } - resp := keeperQuotaWindowUsageResponseFrom(usage) - if resp.WindowElapsedPercent != usage.WindowElapsedPercent || resp.ProjectedCostUSD != usage.ProjectedCostUSD { - t.Fatal("response mapping drops projection fields") - } -} diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index 9794d841..bf429329 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -221,32 +221,6 @@ const filteredNormalAccounts = computed(() => ) const tableLoading = computed(() => isLoading.value) const enabledAccountCount = computed(() => accounts.value.filter((account) => !account.disabled).length) - -const longWindowProjectedCostUsd = computed(() => { - let total = 0 - let covered = 0 - for (const account of accounts.value) { - if (account.disabled) { - continue - } - const usage = isFreeQuotaWindowAccount(account.account_type) - ? account.primary_window_usage - : account.secondary_window_usage ?? account.primary_window_usage - if (!usage || usage.stale === true) { - continue - } - total += usage.projected_cost_usd ?? usage.estimated_cost_usd ?? 0 - covered += 1 - } - return { total, covered } -}) - -const projectedWindowCostText = computed(() => { - if (longWindowProjectedCostUsd.value.covered === 0) { - return t('-', '-') - } - return formatUsd(longWindowProjectedCostUsd.value.total) -}) const disabledAccountCount = computed(() => accounts.value.filter((account) => account.disabled).length) const hasDisabledAccounts = computed(() => disabledAccountCount.value > 0) const showDisabledSection = computed( @@ -1011,14 +985,10 @@ function quotaWindowUsageText(item: QuotaWindowItem): string { if (!item.usage) { return t('本窗口暂无用量', 'No usage in this window') } - const base = t( + return t( `${formatInteger(item.usage.records)} 次 / ${formatCompact(item.usage.total_tokens)} Tokens / ${formatUsd(item.usage.estimated_cost_usd)}`, `${formatInteger(item.usage.records)} requests / ${formatCompact(item.usage.total_tokens)} Tokens / ${formatUsd(item.usage.estimated_cost_usd)}`, ) - if (typeof item.usage.projected_cost_usd === 'number' && item.usage.projected_cost_usd > 0) { - return `${base} → ${t('预计', 'projected')} ${formatUsd(item.usage.projected_cost_usd)}` - } - return base } function quotaWindowUsageTags(item: QuotaWindowItem): QuotaUsageTag[] { @@ -1026,15 +996,11 @@ function quotaWindowUsageTags(item: QuotaWindowItem): QuotaUsageTag[] { return [{ label: t('状态', 'Status'), value: t('需刷新', 'Needs refresh'), tone: 'stale' }] } const usage = item.usage - const tags: QuotaUsageTag[] = [ + return [ { label: t('请求', 'Requests'), value: formatInteger(usage?.records ?? 0) }, { label: 'Tokens', value: formatCompact(usage?.total_tokens ?? 0) }, { label: t('费用', 'Cost'), value: formatUsd(usage?.estimated_cost_usd ?? 0) }, ] - if (typeof usage?.projected_cost_usd === 'number' && usage.projected_cost_usd > 0) { - tags.push({ label: t('预计本窗口', 'Projected'), value: formatUsd(usage.projected_cost_usd) }) - } - return tags } function quotaWindowResetText(item: QuotaWindowItem): string { @@ -1961,21 +1927,6 @@ onBeforeUnmount(() => { <div class="metric-value">{{ formatInteger(unauthorizedErrorAccountCount) }}</div> <div class="metric-footnote">HTTP 401</div> </button> - <div class="metric-card"> - <div class="metric-icon" aria-hidden="true"> - <Activity :size="20" :stroke-width="2.2" /> - </div> - <div class="metric-label">{{ t('本窗口预计消费', 'Projected Window Cost') }}</div> - <div class="metric-value">{{ projectedWindowCostText }}</div> - <div class="metric-footnote"> - {{ - t( - `按当前速率外推 ${longWindowProjectedCostUsd.covered} 个账号的长窗口(牌价折算)`, - `Linear extrapolation over ${longWindowProjectedCostUsd.covered} accounts' long windows (list price)`, - ) - }} - </div> - </div> <button type="button" class="metric-card metric-action is-purple" diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 674a3ffe..74b6b98d 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -229,9 +229,6 @@ export interface CodexKeeperQuotaWindowUsage { unpriced_records: number stale: boolean window_source: string - window_elapsed_seconds?: number - window_elapsed_percent?: number - projected_cost_usd?: number } export interface CodexKeeperAccount { From b419e617fbbad22463fd165c288d439d997f63ed Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Mon, 24 Aug 2026 22:02:23 +0800 Subject: [PATCH 10/25] fix(codex-keeper): label quota windows by actual window seconds (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The account status page hardcoded the 5-hour/weekly label pair for every paid plan. Upstream usage payloads decide the real window length (primary_window_seconds), and pro accounts can report a weekly primary window, so the page showed weekly data under a 5-hour label. Pick the label from the actual window seconds (5h/weekly/monthly) and keep the conventional pair only as a fallback when the seconds are unknown. Signed-off-by: 怪味胡豆 <raft-mobile-guaiweihudou@mail.build> Signed-off-by: artin <artin@cat.ms> --- .../views/CodexKeeperStatusView.vue | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index bf429329..18aee745 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -866,11 +866,33 @@ function quotaWindowLabels(account: CodexKeeperAccount): { primary: string; seco return { primary: t('月限额', 'Monthly Limit'), secondary: t('次限额', 'Secondary Limit') } } if (isPaidQuotaWindow(account)) { - return { primary: t('5小时限额', '5-Hour Limit'), secondary: t('周限额', 'Weekly Limit') } + // The upstream usage payload decides the real window length; the plan + // type alone does not. Pro accounts, for example, can report a weekly + // primary window, so label by actual seconds and only fall back to the + // conventional 5-hour/weekly pair when the seconds are unknown. + const primaryLabel = quotaWindowLabelForSeconds(quotaWindowSecondsFor(account, 'primary')) + const secondaryLabel = quotaWindowLabelForSeconds(quotaWindowSecondsFor(account, 'secondary')) + return { + primary: primaryLabel ?? t('5小时限额', '5-Hour Limit'), + secondary: secondaryLabel ?? t('周限额', 'Weekly Limit'), + } } return { primary: t('主', 'Primary'), secondary: t('次', 'Secondary') } } +function quotaWindowLabelForSeconds(seconds: number | null): string | null { + if (seconds === CODEX_FIVE_HOUR_WINDOW_SECONDS) { + return t('5小时限额', '5-Hour Limit') + } + if (seconds === CODEX_WEEK_WINDOW_SECONDS) { + return t('周限额', 'Weekly Limit') + } + if (seconds === CODEX_MONTH_WINDOW_SECONDS) { + return t('月限额', 'Monthly Limit') + } + return null +} + function shouldShowQuotaWindow(account: CodexKeeperAccount): boolean { return !account.disabled } From 42ec86980dd03604a8b9075d8b36011f8065c19c Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Wed, 26 Aug 2026 03:17:54 +0800 Subject: [PATCH 11/25] fix(usage): show success rates with two decimals Merge the UI-only success-rate precision fix. Production deployment remains a separate authorized step. --- frontend/src/features/usage/views/UsageHistoryView.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/features/usage/views/UsageHistoryView.vue b/frontend/src/features/usage/views/UsageHistoryView.vue index 71df03e3..23767d64 100644 --- a/frontend/src/features/usage/views/UsageHistoryView.vue +++ b/frontend/src/features/usage/views/UsageHistoryView.vue @@ -680,7 +680,8 @@ function distributionLegendItems(items: DistributionItem[]): DistributionLegendI function formatPercent(value: number): string { return new Intl.NumberFormat(currentLanguage.value === 'zh' ? 'zh-CN' : 'en-US', { style: 'percent', - maximumFractionDigits: value > 0 && value < 0.1 ? 1 : 0, + minimumFractionDigits: 2, + maximumFractionDigits: 2, }).format(value) } From f38f6c3ed66a4218d9da17fbafea6a987afe0124 Mon Sep 17 00:00:00 2001 From: "feiniu (Raft agent)" <admin@oranix.io> Date: Wed, 26 Aug 2026 07:24:13 +0000 Subject: [PATCH 12/25] =?UTF-8?q?feat(usage):=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E7=8E=87=20metric=20card=20on=20usage=20dash?= =?UTF-8?q?boards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the task #30 contract pinned in-thread: both 历史用量 and 我的用量 (shared dashboard component) gain a 缓存命中率 card showing only the two-decimal hit rate, footnote = the same time-range label as the requests card, no absolute amounts. Rate = provider-aware cache-hit tokens / aggregated input tokens, capped at 100%. The summary emits a new cache_hit_tokens field: Claude-style records count cache_read_tokens (upstream keeps cache reads outside input_tokens), everything else counts cached_tokens (a subset of input). All existing fields unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- backend/internal/app/pricing.go | 10 ++++++++ backend/internal/app/usage.go | 3 +++ backend/internal/app/usage_internal_test.go | 25 +++++++++++++++++++ .../features/usage/views/UsageHistoryView.vue | 24 ++++++++++++++++++ frontend/src/shared/types/api.ts | 1 + 5 files changed, 63 insertions(+) diff --git a/backend/internal/app/pricing.go b/backend/internal/app/pricing.go index 2cd23778..979bfe9c 100644 --- a/backend/internal/app/pricing.go +++ b/backend/internal/app/pricing.go @@ -878,6 +878,16 @@ func usageAggregateInputTokens(record UsageRecord) int { return inputTokens } +// usageCacheHitTokens returns the tokens served from provider prompt cache. +// Claude-style records report cache reads in cache_read_tokens (kept outside +// input_tokens); everything else reports cached_tokens as a subset of input. +func usageCacheHitTokens(record UsageRecord) int { + if isClaudeProvider(record.Provider) { + return nonNegativeTokens(record.CacheReadTokens) + } + return nonNegativeTokens(record.CachedTokens) +} + func usageAggregateTotalTokens(record UsageRecord) int { if isClaudeProvider(record.Provider) { return usageAggregateInputTokens(record) + nonNegativeTokens(record.OutputTokens) + nonNegativeTokens(record.ReasoningTokens) diff --git a/backend/internal/app/usage.go b/backend/internal/app/usage.go index 920acb1e..a641d9fc 100644 --- a/backend/internal/app/usage.go +++ b/backend/internal/app/usage.go @@ -856,6 +856,7 @@ func listItemFromRecord(record UsageRecord, users map[string]userInfo, prices ma func usageSummaryFromRecords(filters UsageFilters, records []UsageRecord, prices map[[2]string]ModelPrice) map[string]any { failed := 0 input, output, cached, reasoning, total := 0, 0, 0, 0, 0 + cacheHit := 0 estimated := 0.0 unpriced := 0 ttftTotal := 0.0 @@ -867,6 +868,7 @@ func usageSummaryFromRecords(filters UsageFilters, records []UsageRecord, prices input += usageAggregateInputTokens(record) output += record.OutputTokens cached += record.CachedTokens + cacheHit += usageCacheHitTokens(record) reasoning += record.ReasoningTokens total += usageAggregateTotalTokens(record) amount, isUnpriced := recordCost(record, prices) @@ -893,6 +895,7 @@ func usageSummaryFromRecords(filters UsageFilters, records []UsageRecord, prices "input_tokens": input, "output_tokens": output, "cached_tokens": cached, + "cache_hit_tokens": cacheHit, "reasoning_tokens": reasoning, "total_tokens": total, "estimated_cost_usd": estimated, diff --git a/backend/internal/app/usage_internal_test.go b/backend/internal/app/usage_internal_test.go index 108b7d3a..98c7a4ef 100644 --- a/backend/internal/app/usage_internal_test.go +++ b/backend/internal/app/usage_internal_test.go @@ -122,3 +122,28 @@ func TestSaveUsageMessageIgnoresZeroTTFT(t *testing.T) { t.Fatalf("stored ttft_ms = %v, want NULL", ttftMS.Float64) } } + +func TestUsageSummaryCacheHitTokensAcrossProviders(t *testing.T) { + claude := "claude" + codex := "codex" + records := []UsageRecord{ + // Claude-style: cache reads live outside input_tokens. + {Provider: &claude, InputTokens: 100, OutputTokens: 10, CacheReadTokens: 400, CacheCreationTokens: 50}, + // Codex/OpenAI-style: cached_tokens is a subset of input_tokens. + {Provider: &codex, InputTokens: 1000, OutputTokens: 20, CachedTokens: 700, CacheReadTokens: 700, TotalTokens: 1020}, + // No cache usage at all. + {Provider: &codex, InputTokens: 50, OutputTokens: 5, TotalTokens: 55}, + } + summary := usageSummaryFromRecords(UsageFilters{}, records, nil) + if got := summary["cache_hit_tokens"]; got != 1100 { + t.Fatalf("cache_hit_tokens = %v, want 1100 (claude 400 + codex 700)", got) + } + // claude input aggregates to 100+400+50=550; codex rows contribute 1000+50. + if got := summary["input_tokens"]; got != 1600 { + t.Fatalf("input_tokens = %v, want 1600", got) + } + // cached_tokens keeps its legacy meaning (codex-style only). + if got := summary["cached_tokens"]; got != 700 { + t.Fatalf("cached_tokens = %v, want 700", got) + } +} diff --git a/frontend/src/features/usage/views/UsageHistoryView.vue b/frontend/src/features/usage/views/UsageHistoryView.vue index 23767d64..a5d55bef 100644 --- a/frontend/src/features/usage/views/UsageHistoryView.vue +++ b/frontend/src/features/usage/views/UsageHistoryView.vue @@ -9,6 +9,7 @@ import { Layers3, ShieldCheck, Timer, + Zap, } from 'lucide-vue-next' import { getUsageOverview } from '@/features/usage/api/usageApi' @@ -741,6 +742,18 @@ const rateSummary = computed(() => activeQuickRange.value === 'today' && realtimeSummary.value ? realtimeSummary.value : summary.value, ) +// 缓存命中率 = provider-aware cache-hit tokens / aggregated input tokens, +// capped at 100% (contract pinned in the task #30 thread). +const cacheHitRate = computed(() => { + const currentSummary = summary.value + const inputTokens = currentSummary?.input_tokens ?? 0 + const hitTokens = currentSummary?.cache_hit_tokens ?? 0 + if (inputTokens <= 0 || hitTokens <= 0) { + return 0 + } + return Math.min(1, hitTokens / inputTokens) +}) + const requestsPerMinute = computed(() => { const currentSummary = rateSummary.value return (currentSummary?.total_records ?? 0) / summaryDurationMinutes(currentSummary) @@ -824,6 +837,14 @@ const metricCards = computed<MetricCardConfig[]>(() => { )}`, ), }, + { + key: 'cache_hit_rate', + label: t('缓存命中率', 'Cache hit rate'), + value: formatPercent(cacheHitRate.value), + icon: Zap, + tone: 'purple', + footnote: dashboardRangeLabel.value, + }, { key: 'rpm', label: 'RPM', @@ -1761,6 +1782,9 @@ onBeforeUnmount(() => { } .dashboard-metric-grid { + /* 7 cards since the cache-hit-rate metric: share one row on wide screens + and wrap naturally below instead of the global 6-column grid. */ + grid-template-columns: repeat(auto-fit, minmax(138px, 1fr)); gap: 8px; } diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 74b6b98d..4965c86d 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -299,6 +299,7 @@ export interface UsageSummary { input_tokens: number output_tokens: number cached_tokens: number + cache_hit_tokens: number reasoning_tokens: number total_tokens: number average_ttft_ms: number | null From c6e4a46d7f45d4d84c02923dcf876dc5d92b6960 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Fri, 4 Sep 2026 12:17:33 +0800 Subject: [PATCH 13/25] feat(account-status): per-account quota reset with reset counter (#7) Add a Reset action to the admin account-status page. The backend endpoint POST /api/codex-keeper/reset-quota resolves the account's auth_index and calls CLIProxyAPI POST /v0/management/reset-quota (clearing the account's quota-exceeded/cooldown/model error state); only after CLIProxyAPI succeeds is the per-account reset counter incremented (new codex_keeper_quota_resets table). The accounts listing now carries quota_reset_count / last_quota_reset_at and the page shows a Resets column plus a confirm-guarded per-row Reset button. Contract test drives the real route: successful resets increment the counter (1 then 2, visible in the listing), a CLIProxyAPI failure surfaces the error without incrementing, and empty/unknown auth names are rejected before any CLIProxyAPI call. Co-authored-by: feiniu (Raft agent) <admin@oranix.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- backend/internal/app/codex_keeper.go | 117 +++++++++++- .../internal/app/codex_keeper_reset_test.go | 167 ++++++++++++++++++ .../202609040001_keeper_quota_resets.sql | 9 + backend/migrations/migrations.go | 2 +- .../codex-keeper/api/codexKeeperApi.ts | 4 + .../views/CodexKeeperStatusView.vue | 59 ++++++- frontend/src/shared/types/api.ts | 2 + 7 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 backend/internal/app/codex_keeper_reset_test.go create mode 100644 backend/migrations/202609040001_keeper_quota_resets.sql diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index f39ae78a..5eeedbdd 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -110,6 +110,10 @@ type keeperPriorityUpdateRequest struct { Priority int `json:"priority"` } +type keeperQuotaResetRequest struct { + AuthName string `json:"auth_name"` +} + type keeperAccount struct { Name string `json:"name"` Email *string `json:"email"` @@ -125,6 +129,8 @@ type keeperAccount struct { SecondaryWindowSeconds *int `json:"secondary_window_seconds"` QuotaThreshold *int `json:"quota_threshold"` LastStatusCode *int `json:"last_status_code"` + QuotaResetCount int `json:"quota_reset_count"` + LastQuotaResetAt *time.Time `json:"last_quota_reset_at"` LastError *string `json:"last_error"` LatestAction *string `json:"latest_action"` LastCheckedAt *time.Time `json:"last_checked_at"` @@ -151,6 +157,8 @@ type keeperAccountResponse struct { LatestAction *string `json:"latest_action"` LastCheckedAt *string `json:"last_checked_at"` LastHealthyAt *string `json:"last_healthy_at"` + QuotaResetCount int `json:"quota_reset_count"` + LastQuotaResetAt *string `json:"last_quota_reset_at"` } type keeperQuotaWindowUsageResponse struct { @@ -891,6 +899,23 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { } writeJSON(w, http.StatusOK, a.keeper.Status()) return nil + case len(parts) == 1 && parts[0] == "reset-quota": + if err := requireMethod(r, http.MethodPost); err != nil { + return err + } + var payload keeperQuotaResetRequest + if err := decodeJSON(r, &payload); err != nil { + return err + } + if strings.TrimSpace(payload.AuthName) == "" { + return validationError("auth_name 不能为空") + } + account, err := a.resetKeeperQuota(r.Context(), strings.TrimSpace(payload.AuthName)) + if err != nil { + return err + } + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "account": account}) + return nil case len(parts) == 1 && parts[0] == "accounts": if err := requireMethod(r, http.MethodGet); err != nil { return err @@ -1054,6 +1079,8 @@ func keeperAccountResponses(accounts []keeperAccount, windowUsages map[string]ke LatestAction: account.LatestAction, LastCheckedAt: apiDateTimePtr(account.LastCheckedAt), LastHealthyAt: apiDateTimePtr(account.LastHealthyAt), + QuotaResetCount: account.QuotaResetCount, + LastQuotaResetAt: apiDateTimePtr(account.LastQuotaResetAt), }) } return responses @@ -2840,7 +2867,95 @@ func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { } accounts = append(accounts, state.keeperAccount) } - return accounts, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + if err := a.mergeKeeperQuotaResetCounts(ctx, accounts); err != nil { + return nil, err + } + return accounts, nil +} + +// mergeKeeperQuotaResetCounts fills QuotaResetCount/LastQuotaResetAt from the +// codex_keeper_quota_resets table (rows without an entry keep the zero count). +func (a *App) mergeKeeperQuotaResetCounts(ctx context.Context, accounts []keeperAccount) error { + if len(accounts) == 0 { + return nil + } + rows, err := a.db.QueryContext(ctx, `SELECT auth_name, reset_count, CAST(last_reset_at AS TEXT) FROM codex_keeper_quota_resets`) + if err != nil { + return err + } + defer rows.Close() + type resetInfo struct { + count int + lastAt *time.Time + } + counts := map[string]resetInfo{} + for rows.Next() { + var name string + var count int + var lastAt sql.NullString + if err := rows.Scan(&name, &count, &lastAt); err != nil { + return err + } + counts[name] = resetInfo{count: count, lastAt: timePtr(lastAt)} + } + if err := rows.Err(); err != nil { + return err + } + for i := range accounts { + if info, ok := counts[accounts[i].Name]; ok { + accounts[i].QuotaResetCount = info.count + accounts[i].LastQuotaResetAt = info.lastAt + } + } + return nil +} + +// resetKeeperQuota asks CLIProxyAPI to reset the quota/cooldown state of one +// auth (by its auth_index) and records the reset in the local counter table. +// The CLIProxyAPI call must succeed before the counter is incremented. +func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperAccount, error) { + cfg, err := a.loadConfig(ctx) + if err != nil { + return keeperAccount{}, err + } + state, err := a.getKeeperState(ctx, authName) + if err != nil { + return keeperAccount{}, err + } + if state == nil { + return keeperAccount{}, notFoundError("账号不存在") + } + if state.AuthIndex == nil || strings.TrimSpace(*state.AuthIndex) == "" { + return keeperAccount{}, validationError("该账号缺少 auth_index,请先刷新账号列表") + } + timeout := time.Duration(cfg.CodexKeeper.CPATimeoutSeconds) * time.Second + _, _, err = a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/reset-quota", nil, + map[string]any{"auth_index": strings.TrimSpace(*state.AuthIndex)}, timeout) + if err != nil { + return keeperAccount{}, err + } + now := dbTime(time.Now()) + if _, err := a.db.ExecContext(ctx, ` + INSERT INTO codex_keeper_quota_resets (auth_name, reset_count, last_reset_at) + VALUES (?, 1, ?) + ON CONFLICT(auth_name) DO UPDATE SET + reset_count = codex_keeper_quota_resets.reset_count + 1, + last_reset_at = excluded.last_reset_at + `, authName, now); err != nil { + return keeperAccount{}, err + } + account := state.keeperAccount + var count int + var lastAt sql.NullString + if err := a.db.QueryRowContext(ctx, `SELECT reset_count, CAST(last_reset_at AS TEXT) FROM codex_keeper_quota_resets WHERE auth_name = ?`, authName).Scan(&count, &lastAt); err != nil { + return keeperAccount{}, err + } + account.QuotaResetCount = count + account.LastQuotaResetAt = timePtr(lastAt) + return account, nil } func (a *App) pruneKeeperMissingAuthStates(ctx context.Context, remoteNames map[string]bool) (int, error) { diff --git a/backend/internal/app/codex_keeper_reset_test.go b/backend/internal/app/codex_keeper_reset_test.go new file mode 100644 index 00000000..dd10825c --- /dev/null +++ b/backend/internal/app/codex_keeper_reset_test.go @@ -0,0 +1,167 @@ +package app_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + backendApp "cpa-helper/backend/internal/app" +) + +type keeperResetResponse struct { + Status string `json:"status"` + Account struct { + Name string `json:"name"` + QuotaResetCount int `json:"quota_reset_count"` + LastQuotaResetAt *string `json:"last_quota_reset_at"` + } `json:"account"` +} + +type keeperResetAccountsResponse struct { + Items []struct { + Name string `json:"name"` + QuotaResetCount int `json:"quota_reset_count"` + LastQuotaResetAt *string `json:"last_quota_reset_at"` + } `json:"items"` +} + +// TestKeeperQuotaReset drives the real /api/codex-keeper/reset-quota route: +// a successful CLIProxyAPI reset-quota call increments the per-auth counter +// (visible via /accounts), a CLIProxyAPI failure surfaces the error WITHOUT +// incrementing, and bad requests are rejected before any CLIProxyAPI call. +func TestKeeperQuotaReset(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + authName := "reset-me.json" + authDetail := map[string]any{ + "name": authName, + "type": "codex", + "auth_index": "idx-7", + "email": "reset@example.com", + "account_type": "plus", + "disabled": false, + "priority": 1, + "access_token": "test-token", + } + + var mu sync.Mutex + resetCalls := []string{} + failResets := false + + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(authDetail) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + _ = json.NewEncoder(w).Encode(map[string]any{ + "status_code": 200, + "body": map[string]any{ + "rate_limit": map[string]any{ + "primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}, + }, + }, + }) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/reset-quota": + var payload struct { + AuthIndex string `json:"auth_index"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.AuthIndex == "" { + http.Error(w, "auth_index is required", http.StatusBadRequest) + return + } + mu.Lock() + shouldFail := failResets + if !shouldFail { + resetCalls = append(resetCalls, payload.AuthIndex) + } + mu.Unlock() + if shouldFail { + http.Error(w, "boom", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := backendApp.New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + handler := app.Routes() + + cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{ + "username": "admin", + "password": "test-password", + "nickname": "Admin", + }, nil, nil) + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{ + "cliaproxy_url": cpa.URL, + "management_key": "test-management-key", + "collector_enabled": false, + }, cookies, nil) + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", + "dry_run": false, + "quota_threshold": 100, + "worker_threads": 1, + "cpa_timeout_seconds": 1, + }, cookies, nil) + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) + waitForKeeperAccounts(t, handler, cookies, 1) + + // Happy path: reset succeeds, counter becomes 1 and carries a timestamp. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Name != authName { + t.Fatalf("reset response = %+v, want ok for %s", reset, authName) + } + if reset.Account.QuotaResetCount != 1 || reset.Account.LastQuotaResetAt == nil { + t.Fatalf("first reset count = %d (lastAt=%v), want 1 with timestamp", reset.Account.QuotaResetCount, reset.Account.LastQuotaResetAt) + } + mu.Lock() + if len(resetCalls) != 1 || resetCalls[0] != "idx-7" { + mu.Unlock() + t.Fatalf("CLIProxyAPI reset calls = %v, want exactly one for auth_index %q", resetCalls, "idx-7") + } + mu.Unlock() + + // Second reset increments to 2. + reset = keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Account.QuotaResetCount != 2 { + t.Fatalf("second reset count = %d, want 2", reset.Account.QuotaResetCount) + } + + // The accounts listing carries the counter. + accounts := keeperResetAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) + if len(accounts.Items) != 1 || accounts.Items[0].QuotaResetCount != 2 || accounts.Items[0].LastQuotaResetAt == nil { + t.Fatalf("accounts listing = %+v, want quota_reset_count 2 with timestamp", accounts.Items) + } + + // CLIProxyAPI failure surfaces an error and must NOT increment the counter. + mu.Lock() + failResets = true + mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + accounts = keeperResetAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) + if accounts.Items[0].QuotaResetCount != 2 { + t.Fatalf("count after failed reset = %d, want unchanged 2", accounts.Items[0].QuotaResetCount) + } + + // Bad requests are rejected before any CLIProxyAPI call. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": ""}, cookies, http.StatusUnprocessableEntity) + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": "nope.json"}, cookies, http.StatusNotFound) +} diff --git a/backend/migrations/202609040001_keeper_quota_resets.sql b/backend/migrations/202609040001_keeper_quota_resets.sql new file mode 100644 index 00000000..232e2703 --- /dev/null +++ b/backend/migrations/202609040001_keeper_quota_resets.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE TABLE IF NOT EXISTS codex_keeper_quota_resets ( + auth_name VARCHAR(500) PRIMARY KEY, + reset_count INTEGER NOT NULL DEFAULT 0, + last_reset_at TIMESTAMP NULL +); + +-- +goose Down +DROP TABLE IF EXISTS codex_keeper_quota_resets; diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index 8e54d43f..f1edf9af 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -3,7 +3,7 @@ package migrations import "embed" // LatestVersion is the newest embedded migration version this binary expects. -const LatestVersion int64 = 202608060001 +const LatestVersion int64 = 202609040001 // FS contains SQL migrations embedded into the application binary. // diff --git a/frontend/src/features/codex-keeper/api/codexKeeperApi.ts b/frontend/src/features/codex-keeper/api/codexKeeperApi.ts index ffd9b334..148c8002 100644 --- a/frontend/src/features/codex-keeper/api/codexKeeperApi.ts +++ b/frontend/src/features/codex-keeper/api/codexKeeperApi.ts @@ -76,6 +76,10 @@ export function refreshCodexKeeperAccounts(payload: CodexKeeperRefreshPayload): return apiClient.post<void>('/codex-keeper/accounts/refresh', payload) } +export function resetCodexKeeperQuota(authName: string): Promise<void> { + return apiClient.post<void>('/codex-keeper/reset-quota', { auth_name: authName }) +} + export function updateCodexKeeperPriority(authName: string, priority: number): Promise<void> { return apiClient.patch<void>(`/codex-keeper/accounts/${encodeURIComponent(authName)}/priority`, { priority, diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index 18aee745..ca1b1918 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -45,6 +45,7 @@ import { getCodexKeeperSettings, listCodexKeeperAccounts, refreshCodexKeeperAccounts, + resetCodexKeeperQuota, updateCodexKeeperPriority, } from '@/features/codex-keeper/api/codexKeeperApi' import type { @@ -71,7 +72,7 @@ type AccountListViewMode = 'table' | 'bar' | 'ring' type AccountSortKey = 'quotaDay' | 'quotaWeek' | 'accountType' | 'status' | 'priority' | 'lastCheckedAt' type SortDirection = 'asc' | 'desc' type PriorityMode = 'low' | 'high' | 'default' -type AccountAction = 'toggle' | 'priority' | 'delete' | 'refresh' +type AccountAction = 'toggle' | 'priority' | 'delete' | 'refresh' | 'reset-quota' type AccountConfirmType = 'default' | 'warning' | 'error' | 'primary' type QuotaWindowItem = { label: string @@ -1493,6 +1494,28 @@ function confirmDeleteAccount(account: CodexKeeperAccount) { ) } +function resetQuotaAccount(account: CodexKeeperAccount) { + return runAccountAction( + account, + 'reset-quota', + () => resetCodexKeeperQuota(account.name), + t('配额状态已重置', 'Quota state reset'), + ) +} + +function confirmResetQuota(account: CodexKeeperAccount) { + openAccountConfirm( + t('重置配额状态', 'Reset Quota State'), + t( + `重置 ${account.name} 在 CPA 侧的配额/冷却状态?已重置 ${account.quota_reset_count ?? 0} 次。`, + `Reset the CPA-side quota/cooldown state of ${account.name}? Reset ${account.quota_reset_count ?? 0} times so far.`, + ), + t('确认重置', 'Confirm Reset'), + 'warning', + () => resetQuotaAccount(account), + ) +} + function enableAccount(account: CodexKeeperAccount) { return runAccountAction( account, @@ -1677,6 +1700,12 @@ const baseColumns = computed<DataTableColumns<CodexKeeperAccount>>(() => [ width: 280, render: (row) => renderQuotaUsageCell(row), }, + { + title: t('重置次数', 'Resets'), + key: 'quota_reset_count', + width: 96, + render: (row) => String(row.quota_reset_count ?? 0), + }, { title: t('最近巡检', 'Last Inspection'), key: 'last_checked_at', @@ -1700,7 +1729,7 @@ const disabledBaseColumns = computed<DataTableColumns<CodexKeeperAccount>>( const disabledActionColumn = computed<DataTableColumns<CodexKeeperAccount>[number]>(() => ({ title: '', key: 'actions', - width: 224, + width: 280, fixed: 'right', render: (row: CodexKeeperAccount) => { return h( @@ -1749,6 +1778,18 @@ const disabledActionColumn = computed<DataTableColumns<CodexKeeperAccount>[numbe }, { default: () => t('刷新', 'Refresh') }, ), + h( + NButton, + { + size: 'small', + quaternary: true, + type: 'warning', + disabled: isRowActing(row) || isBulkDeleting.value || isBulkRefreshing.value, + loading: isActionLoading(row, 'reset-quota'), + onClick: () => confirmResetQuota(row), + }, + { default: () => t('重置', 'Reset') }, + ), ], }, ) @@ -1758,7 +1799,7 @@ const disabledActionColumn = computed<DataTableColumns<CodexKeeperAccount>[numbe const normalActionColumn = computed<DataTableColumns<CodexKeeperAccount>[number]>(() => ({ title: '', key: 'actions', - width: 232, + width: 288, fixed: 'right', render: (row: CodexKeeperAccount) => { return h( @@ -1805,6 +1846,18 @@ const normalActionColumn = computed<DataTableColumns<CodexKeeperAccount>[number] }, { default: () => t('刷新', 'Refresh') }, ), + h( + NButton, + { + size: 'small', + quaternary: true, + type: 'warning', + disabled: isRowActing(row) || isBulkDeleting.value || isBulkRefreshing.value, + loading: isActionLoading(row, 'reset-quota'), + onClick: () => confirmResetQuota(row), + }, + { default: () => t('重置', 'Reset') }, + ), ], }, ) diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 4965c86d..4061848e 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -251,6 +251,8 @@ export interface CodexKeeperAccount { latest_action: string | null last_checked_at: string | null last_healthy_at: string | null + quota_reset_count: number + last_quota_reset_at: string | null } export interface CodexKeeperAccountsResponse { From edd0d29791e9aa70c81eeb94c25806b1847441f4 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Fri, 4 Sep 2026 17:18:59 +0800 Subject: [PATCH 14/25] fix(account-status): fail-closed reset verification, row fence, minimal wire DTO (#8) * fix(account-status): fail-closed reset verification, row fence, minimal wire DTO Address the four post-landing review findings: - Verify the CLIProxyAPI reset response instead of trusting any 2xx: require status=ok with the exact requested auth_index, otherwise fail closed and do not increment the counter (covered by empty-body / wrong-index / bad-status contract cases alongside the HTTP-failure case). - Return a deliberately minimal reset DTO (name, quota_reset_count, last_quota_reset_at) instead of serializing the internal keeperAccount (auth_index/email/last_error no longer cross the wire); the contract test now asserts no extra fields leak. - Include reset-quota in the row-level action fence so a resetting row cannot be concurrently disabled/re-prioritized/deleted/refreshed. - Correct the table scroll widths to the actual column sums (disabled 1454, normal 1958) so the fixed action column is not clipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(i18n): map the reset backend messages for English mode Add messages.ts pairs for all four backend strings introduced by the reset feature (fail-closed verification message, missing-auth_index hint, empty-auth_name validation, account-not-found) so English mode no longer shows mixed-language errors, with i18n smoke assertions pinning each pair. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(account-status): extend the row fence to bulk and programmatic entries Bulk select-all, bulk/programmatic refresh, and bulk delete previously ignored rows with an in-flight per-row action, so a resetting (or toggling/deleting) row could be hit concurrently through those paths. All three entries now respect the same isRowActing fence as the per-row buttons: select-all skips acting rows, refresh drops busy targets and proceeds with the idle rest, and bulk delete filters busy rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(account-status): enforce exact auth_index echo on reset confirmation The CPA response auth_index is now compared without trimming, matching the documented exact-match contract (the request-side auth_index is already trimmed, so a well-behaved CPA echo passes; a whitespace-padded echo now fails closed instead of being leniently accepted). Adds a padded-index deceptive-response case asserting no counter increment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: feiniu (Raft agent) <admin@oranix.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- backend/internal/app/codex_keeper.go | 55 +++++++++---- .../internal/app/codex_keeper_reset_test.go | 80 ++++++++++++++----- frontend/scripts/i18n-smoke.mjs | 10 +++ .../views/CodexKeeperStatusView.vue | 33 +++++--- frontend/src/shared/i18n/messages.ts | 4 + 5 files changed, 133 insertions(+), 49 deletions(-) diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index 5eeedbdd..3c30682b 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -910,11 +910,11 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { if strings.TrimSpace(payload.AuthName) == "" { return validationError("auth_name 不能为空") } - account, err := a.resetKeeperQuota(r.Context(), strings.TrimSpace(payload.AuthName)) + result, err := a.resetKeeperQuota(r.Context(), strings.TrimSpace(payload.AuthName)) if err != nil { return err } - writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "account": account}) + writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "account": result}) return nil case len(parts) == 1 && parts[0] == "accounts": if err := requireMethod(r, http.MethodGet); err != nil { @@ -2916,26 +2916,48 @@ func (a *App) mergeKeeperQuotaResetCounts(ctx context.Context, accounts []keeper // resetKeeperQuota asks CLIProxyAPI to reset the quota/cooldown state of one // auth (by its auth_index) and records the reset in the local counter table. // The CLIProxyAPI call must succeed before the counter is incremented. -func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperAccount, error) { +// keeperQuotaResetResult is the deliberately minimal wire shape of a reset: +// it must not leak internal keeperAccount fields (auth_index, email, errors). +type keeperQuotaResetResult struct { + Name string `json:"name"` + QuotaResetCount int `json:"quota_reset_count"` + LastQuotaResetAt *string `json:"last_quota_reset_at"` +} + +func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperQuotaResetResult, error) { cfg, err := a.loadConfig(ctx) if err != nil { - return keeperAccount{}, err + return keeperQuotaResetResult{}, err } state, err := a.getKeeperState(ctx, authName) if err != nil { - return keeperAccount{}, err + return keeperQuotaResetResult{}, err } if state == nil { - return keeperAccount{}, notFoundError("账号不存在") + return keeperQuotaResetResult{}, notFoundError("账号不存在") } if state.AuthIndex == nil || strings.TrimSpace(*state.AuthIndex) == "" { - return keeperAccount{}, validationError("该账号缺少 auth_index,请先刷新账号列表") + return keeperQuotaResetResult{}, validationError("该账号缺少 auth_index,请先刷新账号列表") } + authIndex := strings.TrimSpace(*state.AuthIndex) timeout := time.Duration(cfg.CodexKeeper.CPATimeoutSeconds) * time.Second - _, _, err = a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/reset-quota", nil, - map[string]any{"auth_index": strings.TrimSpace(*state.AuthIndex)}, timeout) + _, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/reset-quota", nil, + map[string]any{"auth_index": authIndex}, timeout) if err != nil { - return keeperAccount{}, err + return keeperQuotaResetResult{}, err + } + // A 2xx alone is not proof of a reset: require the CLIProxyAPI response to + // confirm status=ok for the exact auth_index we asked about, otherwise fail + // closed and do not count the reset. + var cpaResult struct { + Status string `json:"status"` + AuthIndex string `json:"auth_index"` + } + // authIndex was already trimmed before the request; require CPA to echo it + // exactly (no lenient trimming of the response) to honor the exact-match contract. + if err := json.Unmarshal(payload, &cpaResult); err != nil || + cpaResult.Status != "ok" || cpaResult.AuthIndex != authIndex { + return keeperQuotaResetResult{}, validationError("CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)") } now := dbTime(time.Now()) if _, err := a.db.ExecContext(ctx, ` @@ -2945,17 +2967,18 @@ func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperAcco reset_count = codex_keeper_quota_resets.reset_count + 1, last_reset_at = excluded.last_reset_at `, authName, now); err != nil { - return keeperAccount{}, err + return keeperQuotaResetResult{}, err } - account := state.keeperAccount var count int var lastAt sql.NullString if err := a.db.QueryRowContext(ctx, `SELECT reset_count, CAST(last_reset_at AS TEXT) FROM codex_keeper_quota_resets WHERE auth_name = ?`, authName).Scan(&count, &lastAt); err != nil { - return keeperAccount{}, err + return keeperQuotaResetResult{}, err } - account.QuotaResetCount = count - account.LastQuotaResetAt = timePtr(lastAt) - return account, nil + return keeperQuotaResetResult{ + Name: authName, + QuotaResetCount: count, + LastQuotaResetAt: apiDateTimePtr(timePtr(lastAt)), + }, nil } func (a *App) pruneKeeperMissingAuthStates(ctx context.Context, remoteNames map[string]bool) (int, error) { diff --git a/backend/internal/app/codex_keeper_reset_test.go b/backend/internal/app/codex_keeper_reset_test.go index dd10825c..a89d38bd 100644 --- a/backend/internal/app/codex_keeper_reset_test.go +++ b/backend/internal/app/codex_keeper_reset_test.go @@ -31,6 +31,16 @@ type keeperResetAccountsResponse struct { // a successful CLIProxyAPI reset-quota call increments the per-auth counter // (visible via /accounts), a CLIProxyAPI failure surfaces the error WITHOUT // incrementing, and bad requests are rejected before any CLIProxyAPI call. +func accountsResetCount(t *testing.T, handler http.Handler, cookies []*http.Cookie) int { + t.Helper() + accounts := keeperResetAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) + if len(accounts.Items) != 1 { + t.Fatalf("accounts listing has %d items, want 1", len(accounts.Items)) + } + return accounts.Items[0].QuotaResetCount +} + func TestKeeperQuotaReset(t *testing.T) { t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) @@ -48,7 +58,7 @@ func TestKeeperQuotaReset(t *testing.T) { var mu sync.Mutex resetCalls := []string{} - failResets := false + resetMode := "ok" // ok | http-fail | empty-body | wrong-index | bad-status cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") @@ -77,16 +87,25 @@ func TestKeeperQuotaReset(t *testing.T) { return } mu.Lock() - shouldFail := failResets - if !shouldFail { + mode := resetMode + if mode == "ok" { resetCalls = append(resetCalls, payload.AuthIndex) } mu.Unlock() - if shouldFail { + switch mode { + case "http-fail": http.Error(w, "boom", http.StatusBadRequest) - return + case "empty-body": + _, _ = w.Write([]byte(`{}`)) + case "wrong-index": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": "someone-else", "models": []string{}}) + case "padded-index": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": " " + payload.AuthIndex + " ", "models": []string{}}) + case "bad-status": + _ = json.NewEncoder(w).Encode(map[string]any{"status": "error", "auth_index": payload.AuthIndex}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) } - _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) default: http.NotFound(w, r) } @@ -136,30 +155,51 @@ func TestKeeperQuotaReset(t *testing.T) { } mu.Unlock() - // Second reset increments to 2. + // Wire minimalism: the reset response must not leak internal account fields. + raw := map[string]json.RawMessage{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &raw) + var accountFields map[string]any + if err := json.Unmarshal(raw["account"], &accountFields); err != nil { + t.Fatalf("decode reset account payload: %v", err) + } + for key := range accountFields { + switch key { + case "name", "quota_reset_count", "last_quota_reset_at": + default: + t.Fatalf("reset response leaks internal field %q (payload %v)", key, accountFields) + } + } + + // Third reset (after the wire-minimalism reset above) increments to 3. reset = keeperResetResponse{} requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) - if reset.Account.QuotaResetCount != 2 { - t.Fatalf("second reset count = %d, want 2", reset.Account.QuotaResetCount) + if reset.Account.QuotaResetCount != 3 { + t.Fatalf("third reset count = %d, want 3", reset.Account.QuotaResetCount) } - // The accounts listing carries the counter. + // The accounts listing carries the counter (3 successful resets so far). accounts := keeperResetAccountsResponse{} requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) - if len(accounts.Items) != 1 || accounts.Items[0].QuotaResetCount != 2 || accounts.Items[0].LastQuotaResetAt == nil { - t.Fatalf("accounts listing = %+v, want quota_reset_count 2 with timestamp", accounts.Items) + if len(accounts.Items) != 1 || accounts.Items[0].QuotaResetCount != 3 || accounts.Items[0].LastQuotaResetAt == nil { + t.Fatalf("accounts listing = %+v, want quota_reset_count 3 with timestamp", accounts.Items) } - // CLIProxyAPI failure surfaces an error and must NOT increment the counter. + // Any CLIProxyAPI outcome short of a confirmed reset (HTTP failure, or a + // deceptive 2xx whose body lacks status=ok for our exact auth_index) must + // surface an error and must NOT increment the counter. + baseline := accountsResetCount(t, handler, cookies) + for _, mode := range []string{"http-fail", "empty-body", "wrong-index", "bad-status", "padded-index"} { + mu.Lock() + resetMode = mode + mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + if got := accountsResetCount(t, handler, cookies); got != baseline { + t.Fatalf("count after %s reset = %d, want unchanged %d", mode, got, baseline) + } + } mu.Lock() - failResets = true + resetMode = "ok" mu.Unlock() - requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) - accounts = keeperResetAccountsResponse{} - requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) - if accounts.Items[0].QuotaResetCount != 2 { - t.Fatalf("count after failed reset = %d, want unchanged 2", accounts.Items[0].QuotaResetCount) - } // Bad requests are rejected before any CLIProxyAPI call. requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": ""}, cookies, http.StatusUnprocessableEntity) diff --git a/frontend/scripts/i18n-smoke.mjs b/frontend/scripts/i18n-smoke.mjs index 31f7cb07..e4685696 100644 --- a/frontend/scripts/i18n-smoke.mjs +++ b/frontend/scripts/i18n-smoke.mjs @@ -135,6 +135,16 @@ try { localizedServerMessage('请求体不是有效 JSON'), 'Request body is not valid JSON', ) + assert.equal( + localizedServerMessage('CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)'), + 'CLIProxyAPI did not confirm the reset (response missing status=ok or auth_index mismatch)', + ) + assert.equal( + localizedServerMessage('该账号缺少 auth_index,请先刷新账号列表'), + 'This account has no auth_index yet; refresh the account list first', + ) + assert.equal(localizedServerMessage('auth_name 不能为空'), 'auth_name must not be empty') + assert.equal(localizedServerMessage('账号不存在'), 'Account not found') assert.equal(localizedKeeperStatusDetail(null), 'Not running') const { apiClient } = await server.ssrLoadModule(`/src/shared/api/apiClient.ts?case=${moduleCase++}`) diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index ca1b1918..f4dc6d91 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -97,8 +97,8 @@ const ACCOUNT_TABLE_VIRTUAL_THRESHOLD = 200 const CODEX_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60 const CODEX_WEEK_WINDOW_SECONDS = 7 * 24 * 60 * 60 const CODEX_MONTH_WINDOW_SECONDS = 30 * 24 * 60 * 60 -const disabledTableScrollX = 1302 -const normalTableScrollX = 1816 +const disabledTableScrollX = 1454 +const normalTableScrollX = 1958 const KEEPER_STATUS_POLL_INTERVAL_MS = 3000 const REFRESH_STATUS_POLL_INTERVAL_MS = 1500 const message = useMessage() @@ -1299,7 +1299,10 @@ function toggleRefreshAccountSelection(account: CodexKeeperAccount) { } function selectAllFilteredRefreshAccounts() { - selectedRefreshAccountNames.value = filteredAccountNames.value + // Rows with any in-flight action (incl. reset-quota) stay out of bulk selection. + selectedRefreshAccountNames.value = filteredAccounts.value + .filter((account) => !isRowActing(account)) + .map((account) => account.name) } function handleAccountCardClick(account: CodexKeeperAccount) { @@ -1351,7 +1354,11 @@ function openFilteredUnauthorizedDisabledBulkDeleteDialog() { } async function submitBulkDelete() { - const authNames = selectedDisabledAccountNames.value + // Same row-level fence as bulk refresh: never delete a row mid-action. + const authNames = selectedDisabledAccountNames.value.filter((name) => { + const account = accounts.value.find((entry) => entry.name === name) + return account === undefined || !isRowActing(account) + }) if (authNames.length === 0) { return } @@ -1587,17 +1594,17 @@ async function refreshAccounts( rawNames: string[], options: { closeDetail?: boolean; clearSelection?: boolean } = {}, ) { - const authNames = uniqueAccountNames(rawNames) - if (authNames.length === 0) { - return - } - const refreshKeys = authNames + // Resolve targets and drop any row that already has an in-flight action + // (toggle/priority/delete/refresh/reset-quota) so bulk and programmatic + // refreshes respect the same row-level fence as the per-row buttons. + const targets = uniqueAccountNames(rawNames) .map((name) => accounts.value.find((account) => account.name === name)) - .filter((account): account is CodexKeeperAccount => account !== undefined) - .map((account) => accountActionKey(account, 'refresh')) - if (refreshKeys.some((key) => actingActions.value.has(key)) || isBulkRefreshing.value) { + .filter((account): account is CodexKeeperAccount => account !== undefined && !isRowActing(account)) + if (targets.length === 0 || isBulkRefreshing.value) { return } + const authNames = targets.map((account) => account.name) + const refreshKeys = targets.map((account) => accountActionKey(account, 'refresh')) const nextActions = new Set(actingActions.value) refreshKeys.forEach((key) => nextActions.add(key)) actingActions.value = nextActions @@ -1635,7 +1642,7 @@ function isActionLoading(account: CodexKeeperAccount, action: AccountAction): bo } function isRowActing(account: CodexKeeperAccount): boolean { - return (['toggle', 'priority', 'delete', 'refresh'] as const).some((action) => + return (['toggle', 'priority', 'delete', 'refresh', 'reset-quota'] as const).some((action) => isActionLoading(account, action), ) } diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts index f290b8bf..fda8f142 100644 --- a/frontend/src/shared/i18n/messages.ts +++ b/frontend/src/shared/i18n/messages.ts @@ -11,6 +11,10 @@ const exactServerMessages: MessagePair[] = [ ['登录状态缺少角色信息,请重启后端服务后重新登录', 'Your session is missing role information. Restart the backend and sign in again.'], ['CPA 配置未完成:请先到「系统设置」填写 CLIProxyAPI 地址和管理密钥,再返回 API 密钥页操作。', 'CPA settings are incomplete. Fill in the CLIProxyAPI URL and management key in System Settings, then return to API Keys.'], ['服务器内部错误', 'Internal server error'], + ['CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)', 'CLIProxyAPI did not confirm the reset (response missing status=ok or auth_index mismatch)'], + ['该账号缺少 auth_index,请先刷新账号列表', 'This account has no auth_index yet; refresh the account list first'], + ['auth_name 不能为空', 'auth_name must not be empty'], + ['账号不存在', 'Account not found'], ['请求体不是有效 JSON', 'Request body is not valid JSON'], ['请先登录', 'Sign in first'], ['登录会话已失效', 'Your sign-in session has expired'], From 16bdb624ad988eb7febc2e456594278cf09b218b Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Fri, 4 Sep 2026 17:44:40 +0800 Subject: [PATCH 15/25] feat(account-status): replace reset-count column with quota reset-window timeline (#9) Per owner clarification: the manual Reset button and its backend endpoint stay, but the standalone reset-count column is dropped in favor of showing the account's own quota reset schedule. A new Quota Reset Windows column lists each window (primary/secondary) with its reset time and a relative countdown, reusing the existing primary_reset_at/secondary_reset_at and window data (no new upstream calls or backend changes). The reset counter still increments on manual reset and is shown in the reset-confirm dialog. Co-authored-by: feiniu (Raft agent) <admin@oranix.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --- .../views/CodexKeeperStatusView.vue | 84 +++++++++++++++++-- 1 file changed, 78 insertions(+), 6 deletions(-) diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index f4dc6d91..4d03949e 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -97,8 +97,8 @@ const ACCOUNT_TABLE_VIRTUAL_THRESHOLD = 200 const CODEX_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60 const CODEX_WEEK_WINDOW_SECONDS = 7 * 24 * 60 * 60 const CODEX_MONTH_WINDOW_SECONDS = 30 * 24 * 60 * 60 -const disabledTableScrollX = 1454 -const normalTableScrollX = 1958 +const disabledTableScrollX = 1568 +const normalTableScrollX = 2072 const KEEPER_STATUS_POLL_INTERVAL_MS = 3000 const REFRESH_STATUS_POLL_INTERVAL_MS = 1500 const message = useMessage() @@ -979,6 +979,27 @@ function formatQuotaResetTime(value: string | null): string | null { }).format(date) } +// formatQuotaResetCountdown renders a coarse "in N days / N hours" hint relative to now. +function formatQuotaResetCountdown(value: string | null): string | null { + if (!value) { + return null + } + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + return null + } + const diffMs = date.getTime() - Date.now() + if (diffMs <= 0) { + return t('已到期', 'due') + } + const days = Math.floor(diffMs / 86400000) + if (days >= 1) { + return t(`${days}天后`, `in ${days}d`) + } + const hours = Math.max(1, Math.floor(diffMs / 3600000)) + return t(`${hours}小时后`, `in ${hours}h`) +} + function quotaText(account: CodexKeeperAccount): string { const items = quotaWindowItems(account) if (items.length === 0) { @@ -1155,6 +1176,29 @@ function renderQuotaUsageCell(account: CodexKeeperAccount) { ) } +// renderQuotaResetScheduleCell lists each quota window's reset time and countdown +// (the account's own OpenAI quota-reset schedule, populated by inspection). +function renderQuotaResetScheduleCell(account: CodexKeeperAccount) { + const items = quotaWindowItems(account).filter((item) => item.resetAt) + if (items.length === 0) { + return '-' + } + return h( + 'div', + { class: 'quota-reset-schedule-cell' }, + items.map((item, index) => { + const resetTime = formatQuotaResetTime(item.resetAt) + const countdown = formatQuotaResetCountdown(item.resetAt) + return h('div', { class: 'quota-reset-schedule-item' }, [ + h('span', { class: 'quota-reset-schedule-label' }, + t(`第 ${index + 1} 次`, `#${index + 1}`)), + h('span', { class: 'quota-reset-schedule-time' }, resetTime ?? '-'), + countdown ? h('span', { class: 'quota-reset-schedule-countdown' }, countdown) : null, + ]) + }), + ) +} + function renderAccountIdentityCell(account: CodexKeeperAccount) { const primary = account.email ?? account.name const statusCode = disabledStatusCodeText(account) @@ -1708,10 +1752,10 @@ const baseColumns = computed<DataTableColumns<CodexKeeperAccount>>(() => [ render: (row) => renderQuotaUsageCell(row), }, { - title: t('重置次数', 'Resets'), - key: 'quota_reset_count', - width: 96, - render: (row) => String(row.quota_reset_count ?? 0), + title: t('配额重置窗口', 'Quota Reset Windows'), + key: 'quota_reset_schedule', + width: 210, + render: (row) => renderQuotaResetScheduleCell(row), }, { title: t('最近巡检', 'Last Inspection'), @@ -3558,6 +3602,34 @@ onBeforeUnmount(() => { font-variant-numeric: tabular-nums; } +:global(.quota-reset-schedule-cell) { + display: flex; + flex-direction: column; + gap: 4px; +} + +:global(.quota-reset-schedule-item) { + display: flex; + align-items: baseline; + gap: 6px; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +:global(.quota-reset-schedule-label) { + color: var(--cpa-text-muted); + font-size: 11px; +} + +:global(.quota-reset-schedule-time) { + color: var(--cpa-text); +} + +:global(.quota-reset-schedule-countdown) { + color: var(--cpa-text-muted); + font-size: 11px; +} + :global(.quota-window-usage) { overflow: hidden; color: var(--cpa-text-muted); From 88cd197efd4c2dcd57fdce0a0fe321eef30f365a Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Fri, 4 Sep 2026 23:31:48 +0800 Subject: [PATCH 16/25] fix(account-status): reset-credit timeline from rate-limit-reset-credits (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuild the account-status reset timeline on the correct data source (wham/rate-limit-reset-credits) instead of the 5h/weekly usage windows. Strict parsing (inner 2xx status, non-negative safe-integer available_count, id/granted_at/expiry validation, present-vs-null expiry), identity-scoped preserve-on-failed-fetch, migration 202609040002. Independent review GO by 怪味胡豆 on exact head 96fc9ee0 (diff sha256 83eda666). Follow-ups (fetched_at/stale metadata, account_id identity key) tracked in task #71. --- backend/internal/app/codex_keeper.go | 351 +++++++++++++++-- .../app/codex_keeper_internal_test.go | 171 ++++++++ .../app/codex_keeper_reset_credits_test.go | 371 ++++++++++++++++++ backend/internal/app/codex_keeper_test.go | 15 + .../202609040002_keeper_reset_credits.sql | 7 + backend/migrations/migrations.go | 2 +- .../views/CodexKeeperStatusView.vue | 81 ++-- frontend/src/shared/types/api.ts | 11 + 8 files changed, 955 insertions(+), 54 deletions(-) create mode 100644 backend/internal/app/codex_keeper_reset_credits_test.go create mode 100644 backend/migrations/202609040002_keeper_reset_credits.sql diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index 3c30682b..b2e3e70d 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "log/slog" + "math" "net/http" "net/url" "os" @@ -23,6 +24,9 @@ import ( const ( keeperUsageURL = "https://chatgpt.com/backend-api/wham/usage" + keeperResetCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + keeperResetCreditType = "codex_rate_limits" + keeperResetCreditStatus = "available" keeperLogFilePrefix = "codex-keeper-" keeperLogComponent = "codex_keeper" keeperLogRetainedFiles = 3 @@ -115,26 +119,52 @@ type keeperQuotaResetRequest struct { } type keeperAccount struct { - Name string `json:"name"` - Email *string `json:"email"` - AuthIndex *string `json:"auth_index"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *time.Time `json:"primary_reset_at"` - SecondaryResetAt *time.Time `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - QuotaResetCount int `json:"quota_reset_count"` - LastQuotaResetAt *time.Time `json:"last_quota_reset_at"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *time.Time `json:"last_checked_at"` - LastHealthyAt *time.Time `json:"last_healthy_at"` + Name string `json:"name"` + Email *string `json:"email"` + AuthIndex *string `json:"auth_index"` + AccountType *string `json:"account_type"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *time.Time `json:"primary_reset_at"` + SecondaryResetAt *time.Time `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + QuotaResetCount int `json:"quota_reset_count"` + LastQuotaResetAt *time.Time `json:"last_quota_reset_at"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCredit `json:"reset_credits"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *time.Time `json:"last_checked_at"` + LastHealthyAt *time.Time `json:"last_healthy_at"` +} + +// keeperResetCredit is the safe projection of one entry from +// wham/rate-limit-reset-credits: only identity + status + timestamps are kept, +// never the profile URL / description. ExpiresAt is nil for a never-expiring +// credit (upstream expires_at: null), which must still be shown, not dropped. +type keeperResetCredit struct { + ID string `json:"id"` + ResetType string `json:"reset_type"` + Status string `json:"status"` + GrantedAt *time.Time `json:"granted_at"` + ExpiresAt *time.Time `json:"expires_at"` + Title string `json:"title,omitempty"` +} + +// keeperResetCreditResponse is the wire shape of one reset credit. Timestamps are +// formatted for the product timezone; ExpiresAt nil means "never expires". +type keeperResetCreditResponse struct { + ID string `json:"id"` + ResetType string `json:"reset_type"` + Status string `json:"status"` + GrantedAt *string `json:"granted_at"` + ExpiresAt *string `json:"expires_at"` + Title string `json:"title,omitempty"` } type keeperAccountResponse struct { @@ -159,6 +189,8 @@ type keeperAccountResponse struct { LastHealthyAt *string `json:"last_healthy_at"` QuotaResetCount int `json:"quota_reset_count"` LastQuotaResetAt *string `json:"last_quota_reset_at"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCreditResponse `json:"reset_credits"` } type keeperQuotaWindowUsageResponse struct { @@ -256,6 +288,12 @@ type keeperAccountResult struct { LastError *string LatestAction *string CheckedAt time.Time + // ResetCreditCount / ResetCredits are set only when a reset-credit fetch + // succeeds. Left nil (the failure/skip case) they preserve the previous + // snapshot in upsertKeeperState via COALESCE, so a failed fetch never wipes + // good data. A successful empty result carries a non-nil count of 0. + ResetCreditCount *int + ResetCredits *string } func NewKeeperRunner(app *App) *KeeperRunner { @@ -1081,11 +1119,34 @@ func keeperAccountResponses(accounts []keeperAccount, windowUsages map[string]ke LastHealthyAt: apiDateTimePtr(account.LastHealthyAt), QuotaResetCount: account.QuotaResetCount, LastQuotaResetAt: apiDateTimePtr(account.LastQuotaResetAt), + ResetCreditCount: account.ResetCreditCount, + ResetCredits: keeperResetCreditResponses(account.ResetCredits), }) } return responses } +// keeperResetCreditResponses formats persisted reset credits for the API, keeping +// the stored expires_at-ascending order and emitting a nil ExpiresAt (never +// expires) unchanged so the frontend can render "永不过期". +func keeperResetCreditResponses(credits []keeperResetCredit) []keeperResetCreditResponse { + if len(credits) == 0 { + return nil + } + out := make([]keeperResetCreditResponse, 0, len(credits)) + for _, credit := range credits { + out = append(out, keeperResetCreditResponse{ + ID: credit.ID, + ResetType: credit.ResetType, + Status: credit.Status, + GrantedAt: apiDateTimePtr(credit.GrantedAt), + ExpiresAt: apiDateTimePtr(credit.ExpiresAt), + Title: credit.Title, + }) + } + return out +} + func keeperQuotaWindowUsageResponseFrom(usage *keeperQuotaWindowUsage) *keeperQuotaWindowUsageResponse { if usage == nil { return nil @@ -2487,6 +2548,16 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.PrimaryWindowSeconds = usage.PrimaryWindowSeconds result.SecondaryWindowSeconds = usage.SecondaryWindowSeconds result.QuotaThreshold = &cfg.CodexKeeper.QuotaThreshold + // Best-effort reset-credit snapshot. A failed/malformed fetch leaves both + // fields nil, so upsertKeeperState preserves the previous snapshot instead of + // wiping it; a successful empty result carries count 0 and an empty list. + if count, credits, ok := a.fetchKeeperResetCredits(ctx, cfg, merged); ok { + result.ResetCreditCount = &count + if encoded, err := json.Marshal(credits); err == nil { + payload := string(encoded) + result.ResetCredits = &payload + } + } result.Result = "healthy" if recoverableUnauthorizedDisabled { @@ -2846,12 +2917,217 @@ func (a *App) checkKeeperUsage(ctx context.Context, cfg AppConfig, detail map[st } } +// fetchKeeperResetCredits pulls the account's reset-credit list from +// wham/rate-limit-reset-credits through the same per-auth api-call egress used by +// checkKeeperUsage. It is best-effort: any transport error, non-2xx inner status, +// or malformed/incomplete body returns ok=false so the caller preserves the +// previous snapshot rather than overwriting it. On success it returns the +// authoritative available_count (never credits length, which upstream may +// truncate) and the safe-projected, expires_at-ascending credit list. +func (a *App) fetchKeeperResetCredits(ctx context.Context, cfg AppConfig, detail map[string]any) (int, []keeperResetCredit, bool) { + authIndex := keeperAuthIndex(detail) + header := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": "codex_cli_rs/0.76.0", + } + if accountID := keeperString(detail["account_id"]); accountID != "" { + header["Chatgpt-Account-Id"] = accountID + } + body := map[string]any{ + "auth_index": authIndex, + "method": "GET", + "url": keeperResetCreditsURL, + "header": header, + "data": "", + } + response, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/api-call", nil, body, time.Duration(cfg.CodexKeeper.UsageTimeoutSeconds)*time.Second) + if err != nil { + return 0, nil, false + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return 0, nil, false + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + return 0, nil, false + } + // The inner status must be a strict JSON integer in the 2xx range. Reusing the + // lenient keeperIntPtr would let a deceptive status_code of 200.5 (truncated) or + // "200" (string) pass as success and overwrite the snapshot, so parse it strictly. + if !keeperInnerStatusOK(raw) { + return 0, nil, false + } + return parseKeeperResetCredits(keeperBodyJSON(raw["body"])) +} + +// keeperInnerStatusOK reports whether the api-call wrapper's inner status is a +// strict non-negative JSON integer in [200,300). It prefers status_code and only +// falls back to statusCode when status_code is absent; a present-but-malformed +// status_code (fractional, string, negative, out-of-range) fails closed. +func keeperInnerStatusOK(raw map[string]any) bool { + value, present := raw["status_code"] + if !present { + value = raw["statusCode"] + } + status, ok := keeperStrictNonNegInt(value) + return ok && status >= 200 && status < 300 +} + +// parseKeeperResetCredits validates and projects a rate-limit-reset-credits body. +// The top-level available_count must be a JSON integer >= 0; a fractional, +// negative, missing, or non-numeric count fails the whole snapshot (ok=false) so +// the previous snapshot is preserved rather than trusting a malformed response. +// A credits array is likewise required. Entries are kept only when +// reset_type == codex_rate_limits, status == available, is_supported_by_plan is +// not explicitly false, AND they pass strict per-entry validation: a non-empty id, +// a present+parseable granted_at, and an expires_at that is either null/absent +// (never-expiring, kept and sorted last) or a valid RFC3339 timestamp. A malformed +// entry is explicitly dropped — never kept with silently nil'd fields — while the +// authoritative available_count is unaffected. Only id/reset_type/status/ +// granted_at/expires_at/title are projected; profile URL and description dropped. +func parseKeeperResetCredits(body map[string]any) (int, []keeperResetCredit, bool) { + if body == nil { + return 0, nil, false + } + count, ok := keeperStrictNonNegInt(body["available_count"]) + if !ok { + return 0, nil, false + } + rawCredits, ok := body["credits"].([]any) + if !ok { + return 0, nil, false + } + credits := make([]keeperResetCredit, 0, len(rawCredits)) + for _, item := range rawCredits { + entry, ok := item.(map[string]any) + if !ok { + continue + } + if keeperString(entry["reset_type"]) != keeperResetCreditType { + continue + } + if keeperString(entry["status"]) != keeperResetCreditStatus { + continue + } + if v, present := entry["is_supported_by_plan"]; present && !keeperBool(v) { + continue + } + id := keeperString(entry["id"]) + if id == "" { + continue // schema requires an id; drop rather than fabricate identity + } + granted, ok := keeperParseRequiredTime(entry["granted_at"]) + if !ok { + continue // granted_at must be present and parseable + } + expires, ok := keeperParseOptionalTime(entry["expires_at"]) + if !ok { + continue // a non-null expires_at that will not parse is malformed + } + credits = append(credits, keeperResetCredit{ + ID: id, + ResetType: keeperString(entry["reset_type"]), + Status: keeperString(entry["status"]), + GrantedAt: granted, + ExpiresAt: expires, + Title: keeperString(entry["title"]), + }) + } + sort.SliceStable(credits, func(i, j int) bool { + a, b := credits[i].ExpiresAt, credits[j].ExpiresAt + if a == nil && b == nil { + return false + } + if a == nil { // never-expiring sorts last + return false + } + if b == nil { + return true + } + return a.Before(*b) + }) + return count, credits, true +} + +// parseStoredKeeperResetCredits decodes the reset_credits JSON snapshot column +// back into projected credits, returning nil for NULL/empty/invalid JSON. +func parseStoredKeeperResetCredits(value sql.NullString) []keeperResetCredit { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return nil + } + var credits []keeperResetCredit + if err := json.Unmarshal([]byte(value.String), &credits); err != nil { + return nil + } + return credits +} + +// keeperMaxSafeCount is the largest count we accept: 2^53-1, the JSON +// safe-integer limit. A float64 represents integers exactly only up to this +// bound, so a larger value cannot be trusted as an exact count (and int(f) could +// overflow), and available_count is realistically tiny anyway. +const keeperMaxSafeCount = 1<<53 - 1 + +// keeperStrictNonNegInt accepts only a JSON integer value (float64 with no +// fractional part) in [0, 2^53-1]. It rejects fractional numbers (e.g. 2.5), +// negatives, out-of-range/NaN/Inf, strings, and non-numeric values so a malformed +// available_count fails closed instead of being silently truncated by keeperIntPtr. +func keeperStrictNonNegInt(value any) (int, bool) { + f, ok := value.(float64) + if !ok { + return 0, false + } + if math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false + } + if f < 0 || f > keeperMaxSafeCount || f != math.Trunc(f) { + return 0, false + } + return int(f), true +} + +// keeperParseRequiredTime requires a present, non-empty, RFC3339-parseable value: +// (t,true) on success, (nil,false) otherwise. +func keeperParseRequiredTime(value any) (*time.Time, bool) { + s := keeperString(value) + if s == "" { + return nil, false + } + parsed, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return nil, false + } + return &parsed, true +} + +// keeperParseOptionalTime enforces the strict expires_at contract: only JSON null +// or an absent field (both surface as a nil value) mean "never expires" (nil,true). +// Any other present value must be a non-empty RFC3339 string; an empty string, a +// non-string type (e.g. a number), or an unparseable string is malformed +// (nil,false) so the entry is dropped rather than silently treated as never-expiring. +func keeperParseOptionalTime(value any) (*time.Time, bool) { + if value == nil { + return nil, true // JSON null or absent field → never expires + } + s, ok := value.(string) + if !ok || s == "" { + return nil, false // present but not a non-empty string → malformed + } + parsed, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return nil, false + } + return &parsed, true +} + func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { rows, err := a.db.QueryContext(ctx, ` SELECT auth_name, email, auth_index, account_type, disabled, priority, primary_used_percent, secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), - primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT) + primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), + reset_credit_count, CAST(reset_credits AS TEXT) FROM codex_keeper_auth_states ORDER BY COALESCE(email, ''), auth_name `) @@ -3036,7 +3312,8 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState SELECT auth_name, email, auth_index, account_type, disabled, priority, primary_used_percent, secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), - primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT) + primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), + reset_credit_count, CAST(reset_credits AS TEXT) FROM codex_keeper_auth_states WHERE auth_name = ? `, name) if err != nil { @@ -3055,13 +3332,13 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthState, error) { var state keeperAuthState - var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt sql.NullString - var priority, primaryUsed, secondaryUsed, quotaThreshold, lastStatus, primaryWindowSeconds, secondaryWindowSeconds, restorePriority sql.NullInt64 + var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt, resetCredits sql.NullString + var priority, primaryUsed, secondaryUsed, quotaThreshold, lastStatus, primaryWindowSeconds, secondaryWindowSeconds, restorePriority, resetCreditCount sql.NullInt64 err := scanner.Scan( &state.Name, &email, &authIndex, &accountType, &state.Disabled, &priority, &primaryUsed, &secondaryUsed, &primaryReset, &secondaryReset, "aThreshold, &lastStatus, &lastError, &latestAction, &lastChecked, &lastHealthy, &primaryWindowSeconds, &secondaryWindowSeconds, &restorePriority, - &createdAt, &updatedAt, + &createdAt, &updatedAt, &resetCreditCount, &resetCredits, ) if err != nil { return keeperAuthState{}, err @@ -3083,6 +3360,8 @@ func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthSt state.LastCheckedAt = timePtr(lastChecked) state.LastHealthyAt = timePtr(lastHealthy) state.RestorePriority = nullableInt(restorePriority) + state.ResetCreditCount = nullableInt(resetCreditCount) + state.ResetCredits = parseStoredKeeperResetCredits(resetCredits) if parsed, ok := parseDBTime(createdAt.String); ok { state.CreatedAt = parsed } @@ -3104,8 +3383,9 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) auth_name, email, auth_index, account_type, disabled, priority, restore_priority, latest_action, last_error, last_status_code, primary_used_percent, secondary_used_percent, quota_threshold, primary_reset_at, secondary_reset_at, primary_window_seconds, secondary_window_seconds, + reset_credit_count, reset_credits, last_checked_at, last_healthy_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(auth_name) DO UPDATE SET email = excluded.email, auth_index = excluded.auth_index, @@ -3127,10 +3407,27 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) secondary_reset_at = excluded.secondary_reset_at, primary_window_seconds = excluded.primary_window_seconds, secondary_window_seconds = excluded.secondary_window_seconds, + -- Preserve-on-failed-fetch, scoped to auth identity. The snapshot is kept + -- (COALESCE) when the incoming auth_index is NULL (identity unknown — e.g. + -- a transient auth-file read failure on the same account must not drop the + -- schedule) OR still matches the stored one. Only a KNOWN, DIFFERENT + -- incoming auth_index (a genuine reassignment of auth_name to another + -- account) falls to ELSE and writes the incoming value, clearing the old + -- account's stale credits so they never surface on the new identity's row. + reset_credit_count = CASE + WHEN excluded.auth_index IS NULL OR codex_keeper_auth_states.auth_index = excluded.auth_index + THEN COALESCE(excluded.reset_credit_count, codex_keeper_auth_states.reset_credit_count) + ELSE excluded.reset_credit_count + END, + reset_credits = CASE + WHEN excluded.auth_index IS NULL OR codex_keeper_auth_states.auth_index = excluded.auth_index + THEN COALESCE(excluded.reset_credits, codex_keeper_auth_states.reset_credits) + ELSE excluded.reset_credits + END, last_checked_at = excluded.last_checked_at, last_healthy_at = COALESCE(excluded.last_healthy_at, codex_keeper_auth_states.last_healthy_at), updated_at = excluded.updated_at - `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, checkedAt, lastHealthy, now, now, result.ClearRestorePriority) + `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, result.ResetCreditCount, result.ResetCredits, checkedAt, lastHealthy, now, now, result.ClearRestorePriority) return err } diff --git a/backend/internal/app/codex_keeper_internal_test.go b/backend/internal/app/codex_keeper_internal_test.go index 660ad8d7..1606d6b9 100644 --- a/backend/internal/app/codex_keeper_internal_test.go +++ b/backend/internal/app/codex_keeper_internal_test.go @@ -1,9 +1,11 @@ package app import ( + "bytes" "context" "encoding/base64" "encoding/json" + "io" "math" "net/http" "net/http/httptest" @@ -14,6 +16,31 @@ import ( "time" ) +// keeperTestIsResetCreditsCall reports whether an api-call proxies the +// rate-limit-reset-credits endpoint. It peeks the body and restores it so the +// handler can still decode the request afterward. +func keeperTestIsResetCreditsCall(r *http.Request) bool { + if r.Body == nil { + return false + } + raw, err := io.ReadAll(r.Body) + if err != nil { + return false + } + r.Body = io.NopCloser(bytes.NewReader(raw)) + var payload struct { + URL string `json:"url"` + } + _ = json.Unmarshal(raw, &payload) + return strings.Contains(payload.URL, "rate-limit-reset-credits") +} + +// keeperTestEmptyResetCreditsPayload is a valid, empty reset-credits api-call +// response (available_count 0, no credits). +func keeperTestEmptyResetCreditsPayload() map[string]any { + return map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}} +} + func TestKeeperUsageTimeoutDefaultIsThirtyButExistingValueIsPreserved(t *testing.T) { cfg, err := defaultConfig() if err != nil { @@ -833,6 +860,10 @@ func TestAutomaticKeeperRunsRespectCacheButManualRefreshBypasses(t *testing.T) { "access_token": "test-token", }) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if keeperTestIsResetCreditsCall(r) { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } usageCalls++ _ = json.NewEncoder(w).Encode(map[string]any{ "status_code": 200, @@ -1339,6 +1370,10 @@ func TestKeeperAuthDetailRequestFailureCountsAsNetworkError(t *testing.T) { case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": http.Error(w, "temporary management failure", http.StatusBadGateway) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if keeperTestIsResetCreditsCall(r) { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } usageCalls++ _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{}}) default: @@ -1402,6 +1437,10 @@ func TestKeeperRunSkipsInFlightAuthBeforeProcessing(t *testing.T) { "access_token": "test-token", }) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if keeperTestIsResetCreditsCall(r) { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } usageCalls++ _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{}}) default: @@ -2004,11 +2043,16 @@ func newKeeperRecoveryTestCPA(t *testing.T, authDetails map[string]map[string]an case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(keeperTestEmptyResetCreditsPayload()) + return + } cpa.mu.Lock() cpa.usageCalls[payload.AuthIndex]++ status := cpa.usageStatuses[payload.AuthIndex] @@ -2214,3 +2258,130 @@ func keeperWebsocketUsageSuccessPayload(usedPercent int) map[string]any { }, } } + +// resetCreditSnapshotJSON is a single valid projected reset credit for identity tests. +const resetCreditSnapshotJSON = `[{"id":"c1","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}]` + +func healthyResetResult(name, authIndex string, count *int, credits *string) keeperAccountResult { + return keeperAccountResult{ + Name: name, + Result: "healthy", + AuthIndex: stringPtr(authIndex), + CheckedAt: time.Now().In(appTimeLocation), + ResetCreditCount: count, + ResetCredits: credits, + } +} + +// TestUpsertKeeperStateClearsResetCreditsOnIdentityChange pins the identity +// boundary: when an auth_name is reassigned a new auth_index and the new account's +// reset-credit fetch fails (nil count/credits), the previous identity's snapshot +// must NOT be preserved by COALESCE — it must be cleared so the wrong account's +// schedule never surfaces on the new index's row. +func TestUpsertKeeperStateClearsResetCreditsOnIdentityChange(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + ctx := context.Background() + + // idx-1 inspects healthy with a populated reset-credit snapshot. + two := 2 + if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + t.Fatalf("upsert idx-1: %v", err) + } + state, err := app.getKeeperState(ctx, "reused.json") + if err != nil { + t.Fatalf("get after idx-1: %v", err) + } + if state.ResetCreditCount == nil || *state.ResetCreditCount != 2 || len(state.ResetCredits) != 1 { + t.Fatalf("idx-1 snapshot not stored: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) + } + + // Same auth_name reassigned to idx-2; the new identity's fetch failed (nil). + if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-2", nil, nil)); err != nil { + t.Fatalf("upsert idx-2: %v", err) + } + state, err = app.getKeeperState(ctx, "reused.json") + if err != nil { + t.Fatalf("get after idx-2: %v", err) + } + if state.AuthIndex == nil || *state.AuthIndex != "idx-2" { + t.Fatalf("auth_index = %v, want idx-2", state.AuthIndex) + } + if state.ResetCreditCount != nil { + t.Fatalf("reset_credit_count = %d, want nil (stale snapshot must be cleared on identity change)", *state.ResetCreditCount) + } + if len(state.ResetCredits) != 0 { + t.Fatalf("reset_credits = %+v, want empty (must not show old account's schedule)", state.ResetCredits) + } +} + +// TestUpsertKeeperStatePreservesResetCreditsOnSameIdentity is the companion: a +// failed fetch on the SAME auth_index keeps the last good snapshot (the intended +// preserve-on-failure semantics), so the boundary fix does not over-clear. +func TestUpsertKeeperStatePreservesResetCreditsOnSameIdentity(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + ctx := context.Background() + + two := 2 + if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + t.Fatalf("upsert first: %v", err) + } + // Same identity, failed fetch (nil count/credits). + if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", nil, nil)); err != nil { + t.Fatalf("upsert second: %v", err) + } + state, err := app.getKeeperState(ctx, "stable.json") + if err != nil { + t.Fatalf("get: %v", err) + } + if state.ResetCreditCount == nil || *state.ResetCreditCount != 2 || len(state.ResetCredits) != 1 { + t.Fatalf("same-identity failed fetch must preserve snapshot: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) + } +} + +// TestUpsertKeeperStatePreservesResetCreditsOnUnknownIdentity covers the +// transient-failure path: when getKeeperRemoteAuthFile fails (network_error / +// 404) the result carries a nil AuthIndex. That unknown identity must NOT clear +// the previous snapshot — a momentary auth-file read failure on the same account +// should preserve the schedule, not drop it. Only a KNOWN, different auth_index +// (a real reassignment) clears it. +func TestUpsertKeeperStatePreservesResetCreditsOnUnknownIdentity(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + ctx := context.Background() + + two := 2 + if err := app.upsertKeeperState(ctx, healthyResetResult("same.json", "idx-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + t.Fatalf("upsert idx-1: %v", err) + } + // A transport/404 failure on the auth-file read: nil AuthIndex, network_error. + failed := keeperAccountResult{ + Name: "same.json", + Result: "network_error", + AuthIndex: nil, + CheckedAt: time.Now().In(appTimeLocation), + } + if err := app.upsertKeeperState(ctx, failed); err != nil { + t.Fatalf("upsert network_error: %v", err) + } + state, err := app.getKeeperState(ctx, "same.json") + if err != nil { + t.Fatalf("get: %v", err) + } + if state.ResetCreditCount == nil || *state.ResetCreditCount != 2 || len(state.ResetCredits) != 1 { + t.Fatalf("unknown identity (nil auth_index) must preserve snapshot, not clear: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) + } +} diff --git a/backend/internal/app/codex_keeper_reset_credits_test.go b/backend/internal/app/codex_keeper_reset_credits_test.go new file mode 100644 index 00000000..ccaa0168 --- /dev/null +++ b/backend/internal/app/codex_keeper_reset_credits_test.go @@ -0,0 +1,371 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// realResetCreditsBody is Friday's authoritative rate-limit-reset-credits response: +// two available codex_rate_limits credits expiring 2026-09-21 and 2026-10-04. +const realResetCreditsBody = `{ + "credits": [ + {"id":"RateLimitResetCredit_A","reset_type":"codex_rate_limits","is_supported_by_plan":true,"status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z","redeem_started_at":null,"redeemed_at":null,"profile_image_url":"https://example.com/a.png","profile_user_id":"user_a","title":"Full reset","description":"secret description a"}, + {"id":"RateLimitResetCredit_B","reset_type":"codex_rate_limits","is_supported_by_plan":true,"status":"available","granted_at":"2026-09-04T02:24:33.736521Z","expires_at":"2026-10-04T02:24:33.736521Z","redeem_started_at":null,"redeemed_at":null,"profile_image_url":"https://example.com/b.png","profile_user_id":"user_b","title":"Full reset","description":"secret description b"} + ], + "available_count": 2, + "total_earned_count": 2, + "immediate_reset_purchase_eligible": false, + "history_enabled": false +}` + +func mustBody(t *testing.T, raw string) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("unmarshal fixture: %v", err) + } + return m +} + +func TestParseKeeperResetCreditsRealFixture(t *testing.T) { + count, credits, ok := parseKeeperResetCredits(mustBody(t, realResetCreditsBody)) + if !ok { + t.Fatal("expected ok for real fixture") + } + if count != 2 { + t.Fatalf("count=%d, want 2", count) + } + if len(credits) != 2 { + t.Fatalf("credits=%d, want 2", len(credits)) + } + // Ascending by expires_at: A (09/21) before B (10/04). + if credits[0].ID != "RateLimitResetCredit_A" || credits[1].ID != "RateLimitResetCredit_B" { + t.Fatalf("order = %s,%s want A,B", credits[0].ID, credits[1].ID) + } + want, _ := time.Parse(time.RFC3339Nano, "2026-09-21T00:08:46.146320Z") + if credits[0].ExpiresAt == nil || !credits[0].ExpiresAt.Equal(want) { + t.Fatalf("expires_at microsecond parse mismatch: %v want %v", credits[0].ExpiresAt, want) + } + if credits[0].GrantedAt == nil { + t.Fatal("granted_at should parse") + } + if credits[0].Title != "Full reset" { + t.Fatalf("title=%q want Full reset", credits[0].Title) + } + // Safe projection: the struct has no field for profile URL / description, so + // re-marshalling must not carry them. + encoded, _ := json.Marshal(credits[0]) + for _, leaked := range []string{"profile_image_url", "description", "secret description"} { + if strings.Contains(string(encoded), leaked) { + t.Fatalf("projected credit leaked %q: %s", leaked, encoded) + } + } +} + +func TestParseKeeperResetCreditsFilters(t *testing.T) { + body := mustBody(t, `{ + "available_count": 1, + "credits": [ + {"id":"keep","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z"}, + {"id":"wrong-type","reset_type":"gpt4_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z"}, + {"id":"redeemed","reset_type":"codex_rate_limits","status":"redeemed","granted_at":"2026-08-22T00:08:46.146320Z"}, + {"id":"unsupported","reset_type":"codex_rate_limits","status":"available","is_supported_by_plan":false,"granted_at":"2026-08-22T00:08:46.146320Z"} + ] + }`) + count, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + if count != 1 { + t.Fatalf("count=%d want 1 (authoritative available_count)", count) + } + if len(credits) != 1 || credits[0].ID != "keep" { + t.Fatalf("filtered credits = %+v, want only 'keep'", credits) + } +} + +func TestParseKeeperResetCreditsNullExpirySortedLast(t *testing.T) { + body := mustBody(t, `{ + "available_count": 3, + "credits": [ + {"id":"never","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":null}, + {"id":"later","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-10-04T02:24:33.736521Z"}, + {"id":"sooner","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"} + ] + }`) + _, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + if len(credits) != 3 { + t.Fatalf("credits=%d want 3 (null-expiry entry must be kept)", len(credits)) + } + if credits[0].ID != "sooner" || credits[1].ID != "later" || credits[2].ID != "never" { + t.Fatalf("order = %s,%s,%s want sooner,later,never", credits[0].ID, credits[1].ID, credits[2].ID) + } + if credits[2].ExpiresAt != nil { + t.Fatal("never-expiring credit must keep nil ExpiresAt") + } +} + +func TestParseKeeperResetCreditsTruncatedDetail(t *testing.T) { + // Upstream may truncate the detail list while available_count stays authoritative. + body := mustBody(t, `{ + "available_count": 5, + "credits": [ + {"id":"a","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}, + {"id":"b","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-10-04T02:24:33.736521Z"} + ] + }`) + count, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + if count != 5 { + t.Fatalf("count=%d want 5 (must not be overwritten by len)", count) + } + if len(credits) != 2 { + t.Fatalf("credits=%d want 2", len(credits)) + } +} + +func TestParseKeeperResetCreditsEmptyArrayClearsToZero(t *testing.T) { + count, credits, ok := parseKeeperResetCredits(mustBody(t, `{"available_count":0,"credits":[]}`)) + if !ok { + t.Fatal("expected ok for a successful empty result") + } + if count != 0 { + t.Fatalf("count=%d want 0", count) + } + if len(credits) != 0 { + t.Fatalf("credits=%d want 0", len(credits)) + } +} + +func TestParseKeeperResetCreditsMalformed(t *testing.T) { + cases := map[string]string{ + "missing available_count": `{"credits":[]}`, + "non-int available_count": `{"available_count":"lots","credits":[]}`, + "fractional available_count": `{"available_count":2.5,"credits":[]}`, + "negative available_count": `{"available_count":-1,"credits":[]}`, + "oversized available_count": `{"available_count":1e19,"credits":[]}`, + "credits not array": `{"available_count":1,"credits":{}}`, + "missing credits": `{"available_count":1}`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + if _, _, ok := parseKeeperResetCredits(mustBody(t, raw)); ok { + t.Fatalf("expected ok=false for %s", name) + } + }) + } + if _, _, ok := parseKeeperResetCredits(nil); ok { + t.Fatal("expected ok=false for nil body") + } +} + +// TestParseKeeperResetCreditsDropsMalformedEntries pins the strict per-entry +// validation: an available/codex entry is dropped (not kept with silently nil'd +// fields) when it has an empty id, a missing/unparseable granted_at, or a non-null +// but unparseable expires_at. The authoritative available_count is unaffected, and +// a fully valid entry (including a null "never expires") still survives. +func TestParseKeeperResetCreditsDropsMalformedEntries(t *testing.T) { + body := mustBody(t, `{ + "available_count": 5, + "credits": [ + {"id":"","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}, + {"id":"no-granted","reset_type":"codex_rate_limits","status":"available","expires_at":"2026-09-21T00:08:46.146320Z"}, + {"id":"bad-expiry","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"not-a-date"}, + {"id":"good","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":null} + ] + }`) + count, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok (malformed entries are dropped, not fail-closed)") + } + if count != 5 { + t.Fatalf("count=%d want 5 (authoritative, unaffected by dropped entries)", count) + } + if len(credits) != 1 || credits[0].ID != "good" { + t.Fatalf("kept credits = %+v, want only the valid 'good' entry", credits) + } + if credits[0].GrantedAt == nil { + t.Fatal("kept entry must carry a parsed granted_at, never nil") + } + if credits[0].ExpiresAt != nil { + t.Fatal("the valid null-expiry entry must keep nil ExpiresAt (never expires)") + } +} + +// TestParseKeeperResetCreditsExpiryPresentVsNull pins the strict present-vs-null +// distinction for expires_at: only JSON null or an absent field mean "never +// expires" and are kept; a present empty string or a non-string value is malformed +// and the entry is dropped (not silently treated as never-expiring). +func TestParseKeeperResetCreditsExpiryPresentVsNull(t *testing.T) { + const g = `"granted_at":"2026-08-22T00:08:46.146320Z"` + body := mustBody(t, `{ + "available_count": 6, + "credits": [ + {"id":"null-expiry","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":null}, + {"id":"absent-expiry","reset_type":"codex_rate_limits","status":"available",`+g+`}, + {"id":"empty-string","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":""}, + {"id":"numeric","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":123}, + {"id":"valid","reset_type":"codex_rate_limits","status":"available",`+g+`,"expires_at":"2026-09-21T00:08:46.146320Z"} + ] + }`) + _, credits, ok := parseKeeperResetCredits(body) + if !ok { + t.Fatal("expected ok") + } + kept := map[string]*keeperResetCredit{} + for i := range credits { + kept[credits[i].ID] = &credits[i] + } + if len(credits) != 3 { + t.Fatalf("kept %d credits, want 3 (null-expiry, absent-expiry, valid); got %v", len(credits), keysOf(kept)) + } + if c, present := kept["null-expiry"]; !present || c.ExpiresAt != nil { + t.Fatal("null expires_at must be kept as never-expiring") + } + if c, present := kept["absent-expiry"]; !present || c.ExpiresAt != nil { + t.Fatal("absent expires_at must be kept as never-expiring") + } + if _, present := kept["empty-string"]; present { + t.Fatal(`expires_at:"" must be dropped, not treated as never-expiring`) + } + if _, present := kept["numeric"]; present { + t.Fatal("expires_at:123 (non-string) must be dropped, not treated as never-expiring") + } + if c, present := kept["valid"]; !present || c.ExpiresAt == nil { + t.Fatal("valid RFC3339 expires_at must be kept with a parsed time") + } +} + +func keysOf(m map[string]*keeperResetCredit) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} + +// fetchResetCreditsCPA builds a fake CLIProxyAPI whose api-call proxy returns the +// given inner status code and body (an object, or a raw string for malformed cases). +func fetchResetCreditsCPA(t *testing.T, innerStatus int, innerBody any, outerStatus int) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.URL.Path != "/v0/management/api-call" { + http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound) + return + } + if outerStatus != 0 && outerStatus != http.StatusOK { + http.Error(w, "outer failure", outerStatus) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "status_code": innerStatus, + "body": innerBody, + }) + })) +} + +func fetchResetCreditsCfg(url string) AppConfig { + return AppConfig{ + Collector: CollectorConfig{CLIProxyURL: url, ManagementKey: "test-key"}, + CodexKeeper: KeeperConfig{UsageTimeoutSeconds: 5}, + } +} + +func TestFetchKeeperResetCreditsSuccess(t *testing.T) { + var body map[string]any + _ = json.Unmarshal([]byte(realResetCreditsBody), &body) + cpa := fetchResetCreditsCPA(t, http.StatusOK, body, http.StatusOK) + defer cpa.Close() + + count, credits, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}) + if !ok { + t.Fatal("expected ok") + } + if count != 2 || len(credits) != 2 { + t.Fatalf("count=%d credits=%d want 2,2", count, len(credits)) + } +} + +// TestFetchKeeperResetCreditsRejectsDeceptiveInnerStatus pins strict inner-status +// parsing: an outer 2xx api-call whose inner status_code is a fractional number, +// a numeric string, or negative must NOT be accepted as success (which would +// overwrite the snapshot). Only a strict integer in [200,300) succeeds. +func TestFetchKeeperResetCreditsRejectsDeceptiveInnerStatus(t *testing.T) { + var body map[string]any + _ = json.Unmarshal([]byte(realResetCreditsBody), &body) + + rawStatusCPA := func(statusValue any) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Encode status_code as the raw JSON provided so a non-integer survives. + payload := `{"status_code":` + statusValue.(string) + `,"body":` + mustMarshal(body) + `}` + _, _ = w.Write([]byte(payload)) + })) + } + reject := []struct { + name string + statusRaw string + }{ + {"fractional 200.5", "200.5"}, + {"string \"200\"", `"200"`}, + {"negative -1", "-1"}, + {"null", "null"}, + } + for _, tc := range reject { + t.Run(tc.name, func(t *testing.T) { + cpa := rawStatusCPA(tc.statusRaw) + defer cpa.Close() + if _, _, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}); ok { + t.Fatalf("%s: expected ok=false (deceptive inner status must not be accepted)", tc.name) + } + }) + } + // Sanity: a strict integer 200 still succeeds. + cpa := rawStatusCPA("200") + defer cpa.Close() + if _, _, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}); !ok { + t.Fatal("strict integer status_code 200 should succeed") + } +} + +func mustMarshal(v any) string { + b, _ := json.Marshal(v) + return string(b) +} + +func TestFetchKeeperResetCreditsInnerErrorsPreserve(t *testing.T) { + var body map[string]any + _ = json.Unmarshal([]byte(realResetCreditsBody), &body) + cases := []struct { + name string + innerStatus int + innerBody any + outerStatus int + }{ + {"inner 401", http.StatusUnauthorized, map[string]any{"detail": "unauth"}, http.StatusOK}, + {"inner 500", http.StatusInternalServerError, map[string]any{"detail": "boom"}, http.StatusOK}, + {"outer 502", http.StatusOK, body, http.StatusBadGateway}, + {"malformed inner body", http.StatusOK, "not-json-object", http.StatusOK}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cpa := fetchResetCreditsCPA(t, tc.innerStatus, tc.innerBody, tc.outerStatus) + defer cpa.Close() + _, _, ok := (&App{}).fetchKeeperResetCredits(context.Background(), fetchResetCreditsCfg(cpa.URL), map[string]any{"auth_index": "idx-1"}) + if ok { + t.Fatalf("%s: expected ok=false so the previous snapshot is preserved", tc.name) + } + }) + } +} diff --git a/backend/internal/app/codex_keeper_test.go b/backend/internal/app/codex_keeper_test.go index cf5562d3..fd888ccd 100644 --- a/backend/internal/app/codex_keeper_test.go +++ b/backend/internal/app/codex_keeper_test.go @@ -375,8 +375,13 @@ func TestKeeperRunMaintainsSystemPriorityRules(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}}) + return + } usedPercent := usagePercents[payload.AuthIndex] if usedPercent == 0 { usedPercent = 10 @@ -526,8 +531,13 @@ func TestKeeperRefreshAccountsOnlyProcessesSelectedAuths(t *testing.T) { case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}}) + return + } mu.Lock() usageCalls[payload.AuthIndex]++ mu.Unlock() @@ -640,8 +650,13 @@ func TestKeeperRefreshAccountsDisablesBadCredentialAndAppliesPriorityPolicy(t *t case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": var payload struct { AuthIndex string `json:"auth_index"` + URL string `json:"url"` } _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 0, "credits": []any{}}}) + return + } statusCode := 200 usedPercent := 100 if payload.AuthIndex == "bad-token.json" { diff --git a/backend/migrations/202609040002_keeper_reset_credits.sql b/backend/migrations/202609040002_keeper_reset_credits.sql new file mode 100644 index 00000000..bb3a2f6d --- /dev/null +++ b/backend/migrations/202609040002_keeper_reset_credits.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE codex_keeper_auth_states ADD COLUMN reset_credit_count INTEGER; +ALTER TABLE codex_keeper_auth_states ADD COLUMN reset_credits TEXT; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN reset_credits; +ALTER TABLE codex_keeper_auth_states DROP COLUMN reset_credit_count; diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index f1edf9af..3af97a4f 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -3,7 +3,7 @@ package migrations import "embed" // LatestVersion is the newest embedded migration version this binary expects. -const LatestVersion int64 = 202609040001 +const LatestVersion int64 = 202609040002 // FS contains SQL migrations embedded into the application binary. // diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index 4d03949e..01841bf3 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -97,8 +97,8 @@ const ACCOUNT_TABLE_VIRTUAL_THRESHOLD = 200 const CODEX_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60 const CODEX_WEEK_WINDOW_SECONDS = 7 * 24 * 60 * 60 const CODEX_MONTH_WINDOW_SECONDS = 30 * 24 * 60 * 60 -const disabledTableScrollX = 1568 -const normalTableScrollX = 2072 +const disabledTableScrollX = 1588 +const normalTableScrollX = 2092 const KEEPER_STATUS_POLL_INTERVAL_MS = 3000 const REFRESH_STATUS_POLL_INTERVAL_MS = 1500 const message = useMessage() @@ -151,6 +151,11 @@ const priorityDialog = reactive({ }) let refreshPollToken = 0 let keeperStatusTimer: number | undefined +// nowMs is a reactive clock so the reset-credit countdown cells re-render as time +// passes without waiting for a data refresh. +const nowMs = ref(Date.now()) +let nowTickTimer: number | undefined +const RESET_CREDIT_CLOCK_TICK_MS = 60000 const priorityRuleMap = computed(() => Object.fromEntries(priorityRules.value.map((rule) => [rule.account_type, rule.priority])), @@ -979,7 +984,8 @@ function formatQuotaResetTime(value: string | null): string | null { }).format(date) } -// formatQuotaResetCountdown renders a coarse "in N days / N hours" hint relative to now. +// formatQuotaResetCountdown renders a coarse "in N days / N hours" hint relative to +// the reactive clock (nowMs), so the cell ticks down without a data refresh. function formatQuotaResetCountdown(value: string | null): string | null { if (!value) { return null @@ -988,7 +994,7 @@ function formatQuotaResetCountdown(value: string | null): string | null { if (Number.isNaN(date.getTime())) { return null } - const diffMs = date.getTime() - Date.now() + const diffMs = date.getTime() - nowMs.value if (diffMs <= 0) { return t('已到期', 'due') } @@ -1176,27 +1182,44 @@ function renderQuotaUsageCell(account: CodexKeeperAccount) { ) } -// renderQuotaResetScheduleCell lists each quota window's reset time and countdown -// (the account's own OpenAI quota-reset schedule, populated by inspection). -function renderQuotaResetScheduleCell(account: CodexKeeperAccount) { - const items = quotaWindowItems(account).filter((item) => item.resetAt) - if (items.length === 0) { - return '-' +// renderResetCreditScheduleCell lists each active reset credit's expiry time and +// countdown — the account's "主动重置过期时间" from wham/rate-limit-reset-credits. +// A null expires_at means the credit never expires; such entries are still shown. +function renderResetCreditScheduleCell(account: CodexKeeperAccount) { + const credits = account.reset_credits ?? [] + const count = account.reset_credit_count + if (credits.length === 0) { + // Authoritative count with no detail rows still deserves a "0 次" / count line + // rather than a bare dash, so a truncated-but-nonzero snapshot is visible. + if (count === null || count === undefined) { + return '-' + } + return h('div', { class: 'quota-reset-schedule-cell' }, [ + h('span', { class: 'quota-reset-schedule-label' }, t(`主动重置次数:${count}`, `Manual resets: ${count}`)), + ]) } - return h( - 'div', - { class: 'quota-reset-schedule-cell' }, - items.map((item, index) => { - const resetTime = formatQuotaResetTime(item.resetAt) - const countdown = formatQuotaResetCountdown(item.resetAt) + const header = h( + 'span', + { class: 'quota-reset-schedule-label' }, + t(`主动重置次数:${count ?? credits.length}`, `Manual resets: ${count ?? credits.length}`), + ) + const rows = credits.map((credit, index) => { + const label = t(`第 ${index + 1} 次`, `#${index + 1}`) + if (!credit.expires_at) { return h('div', { class: 'quota-reset-schedule-item' }, [ - h('span', { class: 'quota-reset-schedule-label' }, - t(`第 ${index + 1} 次`, `#${index + 1}`)), - h('span', { class: 'quota-reset-schedule-time' }, resetTime ?? '-'), - countdown ? h('span', { class: 'quota-reset-schedule-countdown' }, countdown) : null, + h('span', { class: 'quota-reset-schedule-label' }, label), + h('span', { class: 'quota-reset-schedule-time' }, t('永不过期', 'never expires')), ]) - }), - ) + } + const resetTime = formatQuotaResetTime(credit.expires_at) + const countdown = formatQuotaResetCountdown(credit.expires_at) + return h('div', { class: 'quota-reset-schedule-item' }, [ + h('span', { class: 'quota-reset-schedule-label' }, label), + h('span', { class: 'quota-reset-schedule-time' }, resetTime ?? '-'), + countdown ? h('span', { class: 'quota-reset-schedule-countdown' }, `(${countdown})`) : null, + ]) + }) + return h('div', { class: 'quota-reset-schedule-cell' }, [header, ...rows]) } function renderAccountIdentityCell(account: CodexKeeperAccount) { @@ -1752,10 +1775,10 @@ const baseColumns = computed<DataTableColumns<CodexKeeperAccount>>(() => [ render: (row) => renderQuotaUsageCell(row), }, { - title: t('配额重置窗口', 'Quota Reset Windows'), - key: 'quota_reset_schedule', - width: 210, - render: (row) => renderQuotaResetScheduleCell(row), + title: t('主动重置过期时间', 'Manual Reset Expiry'), + key: 'reset_credit_schedule', + width: 230, + render: (row) => renderResetCreditScheduleCell(row), }, { title: t('最近巡检', 'Last Inspection'), @@ -1961,6 +1984,9 @@ onMounted(() => { keeperStatusTimer = window.setInterval(() => { void loadKeeperStatus() }, KEEPER_STATUS_POLL_INTERVAL_MS) + nowTickTimer = window.setInterval(() => { + nowMs.value = Date.now() + }, RESET_CREDIT_CLOCK_TICK_MS) }) onBeforeUnmount(() => { @@ -1968,6 +1994,9 @@ onBeforeUnmount(() => { if (keeperStatusTimer !== undefined) { window.clearInterval(keeperStatusTimer) } + if (nowTickTimer !== undefined) { + window.clearInterval(nowTickTimer) + } }) </script> diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 4061848e..ee8ee663 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -253,6 +253,17 @@ export interface CodexKeeperAccount { last_healthy_at: string | null quota_reset_count: number last_quota_reset_at: string | null + reset_credit_count: number | null + reset_credits: CodexKeeperResetCredit[] | null +} + +export interface CodexKeeperResetCredit { + id: string + reset_type: string + status: string + granted_at: string | null + expires_at: string | null + title?: string } export interface CodexKeeperAccountsResponse { From a996697f6530fb73a4bc94527aac72d9fc7a1676 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Sat, 5 Sep 2026 16:31:39 +0800 Subject: [PATCH 17/25] feat(account-status): re-inspect on reset-quota + audit logging (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-reset synchronous single-account re-inspection (per-auth locked) + full success/failure/skip/partial audit for reset-quota/enable/disable/delete(bulk)/priority, with safe reason codes and DB-write-failure surfacing. Independent review GO by 怪味胡豆 (task #73) on exact head 9ac5b931, diff sha256 97da0944, triple hash-verified. Closes task #72. --- backend/internal/app/codex_keeper.go | 228 ++++++++++- .../app/codex_keeper_internal_test.go | 381 ++++++++++++++++++ .../internal/app/codex_keeper_reset_test.go | 133 ++++++ 3 files changed, 721 insertions(+), 21 deletions(-) diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index b2e3e70d..1b39ce45 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -63,6 +63,14 @@ type keeperStats struct { PriorityRestored int `json:"priority_restored"` Skipped int `json:"skipped"` NetworkError int `json:"network_error"` + // ResetCreditsUnavailable counts otherwise-healthy accounts whose reset-credit + // fetch failed this run (snapshot preserved, health unchanged). Not persisted to + // the runs table; used to audit a post-reset refresh as partial. + ResetCreditsUnavailable int `json:"reset_credits_unavailable"` + // StateWriteError counts accounts whose state write-back to the DB failed this + // run. The inspection may have looked healthy, but nothing was persisted — so a + // post-reset refresh must be audited as error, not ok. + StateWriteError int `json:"state_write_error"` } type keeperStatusResponse struct { @@ -294,6 +302,12 @@ type keeperAccountResult struct { // good data. A successful empty result carries a non-nil count of 0. ResetCreditCount *int ResetCredits *string + // ResetCreditsUnavailable is set when the account is healthy but its reset-credit + // fetch failed, so the snapshot was not refreshed this inspection. + ResetCreditsUnavailable bool + // StateWriteFailed is set when persisting this result to the DB failed, so the + // inspection did not actually update the stored state. + StateWriteFailed bool } func NewKeeperRunner(app *App) *KeeperRunner { @@ -358,6 +372,26 @@ func (r *KeeperRunner) StartAccounts(authNames []string) error { return nil } +// InspectAccountsLocked inspects the given accounts synchronously using ONLY the +// per-auth locks — deliberately not the global "accounts" mode. The per-auth lock +// is the correctness-critical guard (it never double-inspects an account another +// run already holds, and prevents a stale concurrent write from clobbering a fresh +// snapshot). Skipping the global mutex means a targeted reset refresh of account B +// is not blocked by an unrelated run inspecting account A — that unrelated run +// would never refresh B, so blocking would leave B's post-reset snapshot stale. +// It blocks until done so the caller (reset-quota) can guarantee the write first. +func (r *KeeperRunner) InspectAccountsLocked(authNames []string) (keeperStats, error) { + names, err := normalizeKeeperAuthNames(authNames) + if err != nil { + return keeperStats{}, err + } + options := keeperRunOptionsForMode("accounts", names) + options.TryLockAuthName = r.tryLockAuthName + options.UnlockAuthName = r.unlockAuthName + stats, _, err := r.app.executeKeeperRunWithOptions(context.Background(), options, r.log) + return stats, err +} + func (r *KeeperRunner) StartDaemon() error { cfg, err := r.app.loadConfig(context.Background()) if err != nil { @@ -691,7 +725,7 @@ func (r *KeeperRunner) run(mode string) { r.runAccounts(mode, nil) } -func (r *KeeperRunner) runAccounts(mode string, authNames []string) { +func (r *KeeperRunner) runAccounts(mode string, authNames []string) (keeperStats, error) { options := keeperRunOptionsForMode(mode, authNames) options.TryLockAuthName = r.tryLockAuthName options.UnlockAuthName = r.unlockAuthName @@ -725,6 +759,7 @@ func (r *KeeperRunner) runAccounts(mode string, authNames []string) { if strings.TrimSpace(logMessage) != "" { r.log(logMessage) } + return stats, err } func (r *KeeperRunner) log(message string) { @@ -762,6 +797,88 @@ func formatKeeperLogLine(timestamp time.Time, message string) string { return strings.TrimSuffix(output.String(), "\n") } +// auditKeeperOp records a successful single-account operation (reset-quota, enable, +// disable, delete, …) to both the Keeper run log (UI + rotating log file) and the +// process log (journalctl). CPA-Helper previously logged only on error/panic, so +// HTTP-200 mutations left no per-account audit trail; this closes that gap for +// troubleshooting and reconciliation. kv are alternating key/value pairs. +func (a *App) auditKeeperOp(op, authName string, kv ...any) { + var b strings.Builder + fmt.Fprintf(&b, "[%s] %s", op, authName) + for i := 0; i+1 < len(kv); i += 2 { + fmt.Fprintf(&b, " %v=%v", kv[i], kv[i+1]) + } + if a.keeper != nil { + a.keeper.log(b.String()) + } + args := append([]any{"op", op, "account", authName}, kv...) + slog.Info("codex_keeper op", args...) +} + +// keeperSafeReason maps an error to a stable, non-sensitive reason code for audit +// logs. Known AppErrors expose their machine code (e.g. not_found, conflict, +// validation_error); anything else collapses to "internal_error" so a raw error +// message (which could carry a URL, token, or upstream detail) never reaches the log. +func keeperSafeReason(err error) string { + if err == nil { + return "ok" + } + var appErr *AppError + if errors.As(err, &appErr) && appErr.Code != "" { + return appErr.Code + } + return "internal_error" +} + +// keeperRefreshAuditOutcome classifies the result of the post-reset single-account +// re-inspection for the audit log, so the trail never misreports a refresh: +// - a mode conflict (a background run holds "accounts") is skipped, not an error — +// that run will refresh the account; +// - any other run error (auth-file list transport/parse, normalize) is a real error; +// - a network error during the inspect is an error; +// - a per-auth lock skip is skipped/account_busy; +// - Total==0 means the account was absent from the remote list, so nothing was +// inspected — skipped/not_inspected, NOT ok; +// - otherwise the account was inspected and written: ok. +func keeperRefreshAuditOutcome(stats keeperStats, err error) (result string, reason string) { + switch { + case err != nil: + reason = keeperSafeReason(err) + if reason == "conflict" { + return "skipped", "conflict" + } + return "error", reason + case stats.StateWriteError > 0: + // The inspection may have looked healthy, but the state write-back to the DB + // failed — nothing was persisted, so this is never a successful refresh. + return "error", "state_write_error" + case stats.NetworkError > 0: + return "error", "network_error" + case stats.StatusDisabled > 0: + // The account was disabled during the inspect (invalid/expired credentials) + // before the usage + reset-credit fetch could refresh the snapshot. + return "error", "status_disabled" + case stats.Skipped > 0: + return "skipped", "account_busy" + case stats.Total == 0: + return "skipped", "not_inspected" + } + // Only these post-fetch outcomes mean a snapshot was actually refreshed. Require + // every inspected account to have completed (Total == okCount) so a partially + // completed batch is never reported ok; for the single-account reset refresh + // this is simply "the one account completed". + okCount := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okCount > 0 && stats.Total == okCount { + // The account(s) inspected healthily, but if the reset-credit fetch itself + // failed the snapshot — the whole point of this refresh — was not updated. + if stats.ResetCreditsUnavailable > 0 { + return "partial", "reset_credits_unavailable" + } + return "ok", "" + } + return "skipped", "not_inspected" +} + type keeperLogFile struct { path string date time.Time @@ -945,13 +1062,30 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { if err := decodeJSON(r, &payload); err != nil { return err } - if strings.TrimSpace(payload.AuthName) == "" { + name := strings.TrimSpace(payload.AuthName) + if name == "" { return validationError("auth_name 不能为空") } - result, err := a.resetKeeperQuota(r.Context(), strings.TrimSpace(payload.AuthName)) + result, err := a.resetKeeperQuota(r.Context(), name) if err != nil { + a.auditKeeperOp("reset-quota", name, "result", "error", "reason", keeperSafeReason(err)) return err } + // Immediately re-inspect just this account so its post-reset usage / window / + // reset-credit state is refreshed in the DB (a reset consumes a credit and + // clears the cooldown). The frontend reloads accounts right after a successful + // reset, so it then shows the fresh state instead of the stale pre-reset + // snapshot. InspectAccountsLocked holds the per-auth lock (never concurrent + // with a background inspection of the same account) but not the global + // "accounts" mode, so an unrelated run inspecting a different account does not + // block this one. Best-effort: the reset already succeeded, so the refresh + // outcome is audited (skipped/error/ok) but never fails the response. + stats, ierr := a.keeper.InspectAccountsLocked([]string{name}) + if result, reason := keeperRefreshAuditOutcome(stats, ierr); reason != "" { + a.auditKeeperOp("reset-quota-refresh", name, "result", result, "reason", reason) + } else { + a.auditKeeperOp("reset-quota-refresh", name, "result", result) + } writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "account": result}) return nil case len(parts) == 1 && parts[0] == "accounts": @@ -1028,6 +1162,11 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { } disabled := parts[2] == "disable" if err := a.setKeeperAccountDisabled(r.Context(), authName, disabled); err != nil { + op := "enable" + if disabled { + op = "disable" + } + a.auditKeeperOp(op, authName, "result", "error", "reason", keeperSafeReason(err)) return err } if disabled { @@ -1042,6 +1181,7 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { return validationError("账号名称无效") } if err := a.deleteKeeperAccount(r.Context(), authName); err != nil { + a.auditKeeperOp("delete", authName, "result", "error", "reason", keeperSafeReason(err)) return err } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) @@ -1059,6 +1199,7 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { return err } if err := a.updateKeeperAccountPriority(r.Context(), authName, payload.Priority); err != nil { + a.auditKeeperOp("priority", authName, "result", "error", "reason", keeperSafeReason(err)) return err } writeJSON(w, http.StatusOK, map[string]string{"status": "updated"}) @@ -2419,13 +2560,27 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map name = "unknown" } result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now} + // persistState writes the result to the DB and, if that fails, records the + // failure on the result (and logs it) rather than swallowing the error — so a + // state that never reached the DB is not later reported as a healthy refresh. + persistState := func(r keeperAccountResult) keeperAccountResult { + if err := a.upsertKeeperState(ctx, r); err != nil { + // Keep the raw DB error out of the user-visible Keeper log (and the + // audit) — surface only a stable marker there; the raw detail goes to the + // server process log for diagnostics. + logFn(r.Name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper state write-back failed for %s: %v", r.Name, err) + r.StateWriteFailed = true + } + return r + } detail, err := a.getKeeperRemoteAuthFile(ctx, cfg, name) if err != nil { message := "读取 auth file 详情失败:" + err.Error() result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + message) return result } @@ -2434,7 +2589,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } merged := mergeKeeperObjects(authInfo, detail) @@ -2454,7 +2609,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map if disabled && !manualRefresh && !recoverableUnauthorizedDisabled { result.Result = "disabled" a.preserveKeeperBadCredentialDiagnosis(ctx, &result) - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } if keeperString(merged["access_token"]) == "" { @@ -2466,7 +2621,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map message = "禁用坏凭证失败:" + err.Error() result.LastError = &message result.Result = "network_error" - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } } @@ -2481,7 +2636,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "status_disabled" result.LastError = &message result.LatestAction = &action - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + action) return result } @@ -2492,7 +2647,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + message) return result } @@ -2509,7 +2664,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map message = "禁用坏凭证失败:" + err.Error() result.Result = "network_error" result.LastError = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } } @@ -2524,7 +2679,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "status_disabled" result.LastError = &message result.LatestAction = &action - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + action) return result } @@ -2536,7 +2691,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } usage := parseKeeperUsageInfo(usageResult.JSONData) @@ -2557,6 +2712,13 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map payload := string(encoded) result.ResetCredits = &payload } + } else { + // The usage check succeeded (account is healthy), but the reset-credit fetch + // itself failed (inner 401/500, malformed, transport). The snapshot is + // preserved (best-effort) and account health is unchanged, but the reset + // credits were NOT refreshed — flag it so a post-reset refresh is audited as + // partial rather than falsely reported ok. + result.ResetCreditsUnavailable = true } result.Result = "healthy" @@ -2568,7 +2730,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.Result = "network_error" result.LastError = &message result.LatestAction = &message - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + message) return result } @@ -2581,7 +2743,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.LatestAction = &action result.ClearRestorePriority = true result.LastError = nil - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) logFn(name + ": " + action) return result } @@ -2617,7 +2779,7 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.ClearRestorePriority = true } result.LastError = nil - _ = a.upsertKeeperState(ctx, result) + result = persistState(result) return result } @@ -2733,6 +2895,12 @@ func (a *App) mergeKeeperStats(stats *keeperStats, result keeperAccountResult) { default: stats.Skipped++ } + if result.ResetCreditsUnavailable { + stats.ResetCreditsUnavailable++ + } + if result.StateWriteFailed { + stats.StateWriteError++ + } } func (stats *keeperStats) add(delta keeperStats) { @@ -2744,6 +2912,8 @@ func (stats *keeperStats) add(delta keeperStats) { stats.PriorityRestored += delta.PriorityRestored stats.Skipped += delta.Skipped stats.NetworkError += delta.NetworkError + stats.ResetCreditsUnavailable += delta.ResetCreditsUnavailable + stats.StateWriteError += delta.StateWriteError } func (stats *keeperStats) mergeCachedState(state keeperAuthState) { @@ -3250,6 +3420,7 @@ func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperQuot if err := a.db.QueryRowContext(ctx, `SELECT reset_count, CAST(last_reset_at AS TEXT) FROM codex_keeper_quota_resets WHERE auth_name = ?`, authName).Scan(&count, &lastAt); err != nil { return keeperQuotaResetResult{}, err } + a.auditKeeperOp("reset-quota", authName, "result", "ok", "reset_count", count, "auth_index", authIndex) return keeperQuotaResetResult{ Name: authName, QuotaResetCount: count, @@ -3460,7 +3631,15 @@ func (a *App) setKeeperAccountDisabled(ctx context.Context, authName string, dis last_checked_at = ?, last_healthy_at = COALESCE(?, last_healthy_at), updated_at = ? WHERE auth_name = ? `, disabled, disabled, disabled, disabled, disabled, disabled, checkedAt, lastHealthy, now, state.Name) - return err + if err != nil { + return err + } + op := "enable" + if disabled { + op = "disable" + } + a.auditKeeperOp(op, authName, "result", "ok") + return nil } func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { @@ -3478,8 +3657,11 @@ func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { if err := a.deleteKeeperRemoteAuthFile(ctx, cfg, authName); err != nil { return err } - _, err = a.db.ExecContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`, authName) - return err + if _, err = a.db.ExecContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`, authName); err != nil { + return err + } + a.auditKeeperOp("delete", authName, "result", "ok") + return nil } func (a *App) bulkDeleteKeeperAccounts(w http.ResponseWriter, r *http.Request) error { @@ -3495,6 +3677,7 @@ func (a *App) bulkDeleteKeeperAccounts(w http.ResponseWriter, r *http.Request) e failures := []map[string]string{} for _, name := range names { if err := a.deleteKeeperAccount(r.Context(), name); err != nil { + a.auditKeeperOp("delete", name, "result", "error", "reason", keeperSafeReason(err)) failures = append(failures, map[string]string{"name": name, "message": err.Error()}) continue } @@ -3519,12 +3702,15 @@ func (a *App) updateKeeperAccountPriority(ctx context.Context, authName string, if err := a.setKeeperRemotePriority(ctx, cfg, authName, &priority); err != nil { return err } - _, err = a.db.ExecContext(ctx, ` + if _, err = a.db.ExecContext(ctx, ` UPDATE codex_keeper_auth_states SET priority = ?, restore_priority = NULL, latest_action = NULL, last_error = NULL, updated_at = ? WHERE auth_name = ? - `, priority, dbTime(time.Now()), authName) - return err + `, priority, dbTime(time.Now()), authName); err != nil { + return err + } + a.auditKeeperOp("priority", authName, "result", "ok", "priority", priority) + return nil } func (a *App) createKeeperRun(ctx context.Context, mode string) (int, error) { diff --git a/backend/internal/app/codex_keeper_internal_test.go b/backend/internal/app/codex_keeper_internal_test.go index 1606d6b9..0a89e7c3 100644 --- a/backend/internal/app/codex_keeper_internal_test.go +++ b/backend/internal/app/codex_keeper_internal_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "errors" "io" "math" "net/http" @@ -2385,3 +2386,383 @@ func TestUpsertKeeperStatePreservesResetCreditsOnUnknownIdentity(t *testing.T) { t.Fatalf("unknown identity (nil auth_index) must preserve snapshot, not clear: count=%v credits=%d", state.ResetCreditCount, len(state.ResetCredits)) } } + +// TestKeeperResetInspectHonorsPerAuthLock proves the fix for the concurrency +// blocker: the reset-triggered inspection goes through InspectAccountsLocked, which +// wires the runner's per-auth lock. When a background run already holds the lock +// for the account, the sync inspect must SKIP it (no concurrent usage request), +// instead of the old direct executeKeeperRunForAccounts call that bypassed the +// lock and issued a second in-flight usage request for the same account. +func TestKeeperResetInspectHonorsPerAuthLock(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "locked.json" + var mu sync.Mutex + usageCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-lock", + "email": "lock@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + mu.Lock() + usageCalls++ + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + // A background run holds the per-auth lock for this account. + if !app.keeper.tryLockAuthName("daemon", authName) { + t.Fatal("could not acquire per-auth lock for the simulated background run") + } + + // The reset-triggered sync inspect must skip the locked account — the per-auth + // lock is the guard that prevents a concurrent inspection of the same account. + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked returned %v; expected it to run and skip the locked account", err) + } + mu.Lock() + got := usageCalls + mu.Unlock() + if got != 0 { + t.Fatalf("usage calls = %d, want 0 — the sync inspect bypassed the per-auth lock and inspected a locked account", got) + } + + // Once the background run releases the lock, a fresh sync inspect proceeds. + app.keeper.unlockAuthName(authName) + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked after unlock: %v", err) + } + mu.Lock() + got = usageCalls + mu.Unlock() + if got == 0 { + t.Fatal("usage calls still 0 after unlock — the sync inspect never ran even when the lock was free") + } +} + +// TestKeeperResetInspectNotBlockedByUnrelatedRun proves a targeted reset refresh +// is NOT blocked by an unrelated run occupying the global "accounts" mode: the +// account is still inspected (per-auth lock only, no global mode gate). Under the +// old RunAccountsSync (markRunning "accounts") this returned conflict and left the +// target's post-reset snapshot stale — this test would then show 0 usage calls. +func TestKeeperResetInspectNotBlockedByUnrelatedRun(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const target = "target.json" + var mu sync.Mutex + usageCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": target, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": target, "type": "codex", "auth_index": "idx-target", + "email": "t@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + mu.Lock() + usageCalls++ + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + // An unrelated manual refresh already occupies the global "accounts" mode. + if !app.keeper.markRunning("accounts") { + t.Fatal("could not mark accounts running") + } + // The targeted reset refresh of a DIFFERENT account must still run. + if _, err := app.keeper.InspectAccountsLocked([]string{target}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + mu.Lock() + got := usageCalls + mu.Unlock() + if got == 0 { + t.Fatal("usage calls = 0 — the reset refresh was blocked by an unrelated accounts run") + } +} + +// TestKeeperSafeReason proves audit reasons are stable machine codes and never +// leak a raw error message (which could carry a URL/token/upstream detail). +func TestKeeperSafeReason(t *testing.T) { + if got := keeperSafeReason(nil); got != "ok" { + t.Fatalf("nil -> %q, want ok", got) + } + if got := keeperSafeReason(validationError("auth_index missing")); got != "validation_error" { + t.Fatalf("validationError -> %q, want validation_error", got) + } + if got := keeperSafeReason(notFoundError("gone")); got != "not_found" { + t.Fatalf("notFoundError -> %q, want not_found", got) + } + if got := keeperSafeReason(conflictError("busy")); got != "conflict" { + t.Fatalf("conflictError -> %q, want conflict", got) + } + raw := errors.New("dial https://cpa.internal:8317 failed: token=sk-secret") + got := keeperSafeReason(raw) + if got != "internal_error" { + t.Fatalf("opaque error -> %q, want internal_error", got) + } + if strings.Contains(got, "token") || strings.Contains(got, "cpa.internal") || strings.Contains(got, "sk-secret") { + t.Fatalf("safe reason leaked raw error content: %q", got) + } +} + +// TestKeeperRefreshAuditOutcome pins the post-reset refresh audit classification so +// the reconciliation log never misreports: a mode conflict is skipped (not error), +// a real run error is error, a vanished target (Total==0) is skipped/not_inspected +// (not ok), and only a genuine inspection is ok. +func TestKeeperRefreshAuditOutcome(t *testing.T) { + cases := []struct { + name string + stats keeperStats + err error + wantResult string + wantReason string + }{ + {"conflict is skipped", keeperStats{}, conflictError("busy"), "skipped", "conflict"}, + {"validation is error", keeperStats{}, validationError("bad"), "error", "validation_error"}, + {"opaque run error", keeperStats{}, errors.New("dial cpa.internal token=sk"), "error", "internal_error"}, + {"network error", keeperStats{Total: 1, NetworkError: 1}, nil, "error", "network_error"}, + {"status disabled (bad creds)", keeperStats{Total: 1, StatusDisabled: 1}, nil, "error", "status_disabled"}, + {"healthy but state write failed", keeperStats{Total: 1, Healthy: 1, StateWriteError: 1}, nil, "error", "state_write_error"}, + {"per-auth lock skip", keeperStats{Total: 1, Skipped: 1}, nil, "skipped", "account_busy"}, + {"vanished target not inspected", keeperStats{Total: 0}, nil, "skipped", "not_inspected"}, + {"inspected ok", keeperStats{Total: 1, Healthy: 1}, nil, "ok", ""}, + {"healthy but reset-credits unavailable is partial", keeperStats{Total: 1, Healthy: 1, ResetCreditsUnavailable: 1}, nil, "partial", "reset_credits_unavailable"}, + {"recovered enabled ok", keeperStats{Total: 1, StatusEnabled: 1}, nil, "ok", ""}, + {"priority degraded ok", keeperStats{Total: 1, PriorityDegraded: 1}, nil, "ok", ""}, + {"priority restored ok", keeperStats{Total: 1, PriorityRestored: 1}, nil, "ok", ""}, + {"total>0 no outcome not ok", keeperStats{Total: 1}, nil, "skipped", "not_inspected"}, + {"partial batch not ok", keeperStats{Total: 2, Healthy: 1}, nil, "skipped", "not_inspected"}, + {"disabled prioritized over skipped", keeperStats{Total: 2, StatusDisabled: 1, Skipped: 1}, nil, "error", "status_disabled"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, reason := keeperRefreshAuditOutcome(tc.stats, tc.err) + if result != tc.wantResult || reason != tc.wantReason { + t.Fatalf("got (%q,%q), want (%q,%q)", result, reason, tc.wantResult, tc.wantReason) + } + }) + } +} + +// TestKeeperResetCreditsFetchFailureFlagged proves that when usage succeeds but the +// reset-credit fetch fails (inner 401/malformed/transport), the account stays +// healthy but the run reports ResetCreditsUnavailable — so a post-reset refresh is +// audited partial (reset_credits_unavailable), not a false ok. +func TestKeeperResetCreditsFetchFailureFlagged(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "creds-fail.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-cf", + "email": "cf@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var payload struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + // Reset-credit fetch fails at the inner layer. + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 401, "body": map[string]any{"detail": "unauth"}}) + return + } + // Usage check succeeds -> account healthy. + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + // Usage succeeded, so the account reaches an ok-class outcome (healthy or a + // priority action), never disabled/network — the point is the account is fine. + okCount := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okCount != 1 || stats.NetworkError != 0 || stats.StatusDisabled != 0 { + t.Fatalf("expected one healthy-class outcome, stats=%+v", stats) + } + if stats.ResetCreditsUnavailable != 1 { + t.Fatalf("ResetCreditsUnavailable = %d, want 1 (reset-credit fetch failed)", stats.ResetCreditsUnavailable) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "partial" || reason != "reset_credits_unavailable" { + t.Fatalf("audit outcome = (%q,%q), want (partial, reset_credits_unavailable)", result, reason) + } +} + +// TestKeeperStatsAddSumsEveryField guards keeperStats.add so a newly added counter +// (e.g. ResetCreditsUnavailable) is not silently dropped by the generalized +// aggregation. Every field is given a distinct value and must sum. +func TestKeeperStatsAddSumsEveryField(t *testing.T) { + base := keeperStats{Total: 1, Healthy: 2, StatusDisabled: 3, StatusEnabled: 4, PriorityDegraded: 5, PriorityRestored: 6, Skipped: 7, NetworkError: 8, ResetCreditsUnavailable: 9, StateWriteError: 11} + delta := keeperStats{Total: 10, Healthy: 20, StatusDisabled: 30, StatusEnabled: 40, PriorityDegraded: 50, PriorityRestored: 60, Skipped: 70, NetworkError: 80, ResetCreditsUnavailable: 90, StateWriteError: 110} + base.add(delta) + want := keeperStats{Total: 11, Healthy: 22, StatusDisabled: 33, StatusEnabled: 44, PriorityDegraded: 55, PriorityRestored: 66, Skipped: 77, NetworkError: 88, ResetCreditsUnavailable: 99, StateWriteError: 121} + if base != want { + t.Fatalf("add sum = %+v, want %+v", base, want) + } +} + +// TestKeeperResetInspectStateWriteFailure proves a DB write-back failure is not +// swallowed: with a BEFORE UPDATE trigger aborting the upsert, the account still +// inspects healthy, but the run reports StateWriteError, the stored snapshot is +// unchanged, and the audit outcome is error/state_write_error (never ok). +func TestKeeperResetInspectStateWriteFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "writefail.json" + credit := map[string]any{"id": "c1", "reset_type": "codex_rate_limits", "status": "available", "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": "2026-09-21T00:08:46.146320Z"} + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-wf", + "email": "wf@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var payload struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 1, "credits": []map[string]any{credit}}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + + // First inspection creates the row (INSERT, before the trigger exists). + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("first inspect: %v", err) + } + before, err := app.getKeeperState(context.Background(), authName) + if err != nil { + t.Fatalf("get before: %v", err) + } + + // Make every subsequent UPDATE fail, as the review probe did. + if _, err := app.db.ExecContext(context.Background(), + `CREATE TRIGGER block_keeper_update BEFORE UPDATE ON codex_keeper_auth_states BEGIN SELECT RAISE(ABORT, 'blocked'); END;`); err != nil { + t.Fatalf("create trigger: %v", err) + } + + // Second inspection: usage/reset-credits succeed, but the upsert (now an UPDATE) + // aborts. The failure must surface, not be swallowed. + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("second inspect: %v", err) + } + okCount := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okCount < 1 { + t.Fatalf("expected a healthy-class outcome, stats=%+v", stats) + } + if stats.StateWriteError != 1 { + t.Fatalf("StateWriteError = %d, want 1 (write-back failure must be recorded)", stats.StateWriteError) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "error" || reason != "state_write_error" { + t.Fatalf("audit outcome = (%q,%q), want (error, state_write_error)", result, reason) + } + + // The stored snapshot must be unchanged (the aborted UPDATE wrote nothing). + after, err := app.getKeeperState(context.Background(), authName) + if err != nil { + t.Fatalf("get after: %v", err) + } + if before.LastCheckedAt == nil || after.LastCheckedAt == nil || !before.LastCheckedAt.Equal(*after.LastCheckedAt) { + t.Fatalf("last_checked_at changed despite a failed write: before=%v after=%v", before.LastCheckedAt, after.LastCheckedAt) + } + + // The raw DB error (the trigger's RAISE text) must NOT reach the Keeper UI log; + // only the stable marker is user-visible. + lines, err := app.loadKeeperLogLines(500) + if err != nil { + t.Fatalf("load keeper log lines: %v", err) + } + sawMarker := false + for _, line := range lines { + if strings.Contains(line, "blocked") { + t.Fatalf("raw DB error leaked into Keeper log: %q", line) + } + if strings.Contains(line, "state_write_error") { + sawMarker = true + } + } + if !sawMarker { + t.Fatal("expected a stable state_write_error marker in the Keeper log") + } +} diff --git a/backend/internal/app/codex_keeper_reset_test.go b/backend/internal/app/codex_keeper_reset_test.go index a89d38bd..75eff989 100644 --- a/backend/internal/app/codex_keeper_reset_test.go +++ b/backend/internal/app/codex_keeper_reset_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "sync" "testing" @@ -205,3 +206,135 @@ func TestKeeperQuotaReset(t *testing.T) { requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": ""}, cookies, http.StatusUnprocessableEntity) requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": "nope.json"}, cookies, http.StatusNotFound) } + +// keeperResetInspectAccountsResponse reads the reset_credit fields from the +// accounts endpoint so a test can assert the post-reset inspection refreshed them. +type keeperResetInspectAccountsResponse struct { + Items []struct { + Name string `json:"name"` + QuotaResetCount int `json:"quota_reset_count"` + ResetCreditCount *int `json:"reset_credit_count"` + } `json:"items"` +} + +// TestKeeperResetQuotaTriggersInspection proves that a successful reset-quota +// synchronously re-inspects just that account: the reset-credit snapshot in the DB +// is refreshed from a live fetch (not left at the pre-reset value), so the +// frontend's follow-up accounts reload shows the post-reset state. +func TestKeeperResetQuotaTriggersInspection(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + const authName = "inspect-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-9", + "email": "inspect@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", + } + + credit := func(id, expires string) map[string]any { + return map[string]any{ + "id": id, "reset_type": "codex_rate_limits", "status": "available", + "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": expires, + } + } + var mu sync.Mutex + // Before any reset: 2 available credits. After a reset consumes one: 1. + availableCount := 2 + resetCreditFetches := 0 + + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(authDetail) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var payload struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + if strings.Contains(payload.URL, "rate-limit-reset-credits") { + mu.Lock() + resetCreditFetches++ + n := availableCount + mu.Unlock() + credits := []map[string]any{credit("RateLimitResetCredit_A", "2026-09-21T00:08:46.146320Z")} + if n >= 2 { + credits = append(credits, credit("RateLimitResetCredit_B", "2026-10-04T02:24:33.736521Z")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": n, "credits": credits}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/reset-quota": + var payload struct { + AuthIndex string `json:"auth_index"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + // A reset consumes one credit, so a fresh fetch afterward returns 1. + mu.Lock() + availableCount = 1 + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := backendApp.New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + handler := app.Routes() + + cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{ + "username": "admin", "password": "test-password", "nickname": "Admin", + }, nil, nil) + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{ + "cliaproxy_url": cpa.URL, "management_key": "test-management-key", "collector_enabled": false, + }, cookies, nil) + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": false, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) + waitForKeeperAccounts(t, handler, cookies, 1) + + // After the initial inspection the snapshot shows 2 available credits. + before := keeperResetInspectAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &before) + if len(before.Items) != 1 || before.Items[0].ResetCreditCount == nil || *before.Items[0].ResetCreditCount != 2 { + t.Fatalf("pre-reset reset_credit_count = %+v, want 2", before.Items) + } + mu.Lock() + fetchesBeforeReset := resetCreditFetches + mu.Unlock() + + // Reset succeeds; the handler must synchronously re-inspect this account. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" { + t.Fatalf("reset status = %q, want ok", reset.Status) + } + + mu.Lock() + fetchesAfterReset := resetCreditFetches + mu.Unlock() + if fetchesAfterReset <= fetchesBeforeReset { + t.Fatalf("reset-credit fetches did not increase after reset (%d -> %d): no post-reset inspection ran", fetchesBeforeReset, fetchesAfterReset) + } + + // The accounts readback now reflects the post-reset live fetch (1 credit left). + after := keeperResetInspectAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &after) + if len(after.Items) != 1 || after.Items[0].ResetCreditCount == nil || *after.Items[0].ResetCreditCount != 1 { + t.Fatalf("post-reset reset_credit_count = %+v, want 1 (refreshed by the chained inspection)", after.Items) + } +} From da4484f6a72aae3145a80bd0953c731a5c0c1664 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Mon, 7 Sep 2026 13:34:02 +0800 Subject: [PATCH 18/25] feat(account-status): true reset credits and subscription renewal (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(codex-keeper): real reset-credit consume + subscription renewal + drop manual reset counter 真重置: resetKeeperQuota now redeems a real OpenAI reset credit instead of only clearing the local 429 cooldown. It rebuilds the merged auth detail (list+download, so account_id and auth_index stay same-row), re-reads the authoritative available_count FRESH (a NULL/stale snapshot is never treated as a known 0; an unknown count blocks rather than degrading to a cooldown-only clear), and when a credit is available POSTs wham/rate-limit-reset-credits/consume via the per-auth api-call egress. The redeem_request_id is a client idempotency key generated once per operation, so keeperRequest's internal retries reuse the same key and can never double-consume. A 2xx inner status alone is not proof: the inner `code` decides — only reset/already_redeemed count as consumed; no_credit/nothing_to_reset clear only the cooldown; any transport error / non-2xx / unrecognized code fails closed and never reports a redemption. It then always clears the local cooldown via /reset-quota. 真续期: parse the ChatGPT subscription renewal time from the account id_token claim chatgpt_subscription_active_until (surfaced by CPA ListAuthFiles). Migration 202609060001 adds subscription_active_until. The extractor is tri-state so a missing/malformed claim never corrupts a good snapshot: parsed=known value, confirmed-absent=known nil (clears the stored value), unreadable/malformed=unknown (upsert preserves the previous value). Surfaced in GET /accounts and a new 续期时间 column with countdown. 去噪: remove the meaningless manual reset counter — drop the codex_keeper_quota_resets increment, mergeKeeperQuotaResetCounts, and QuotaResetCount/LastQuotaResetAt from the structs/response/mapper. Migration 202609060002 DROPs the table (Down recreates the empty schema; the historical counts are intentionally not restored). Reset result DTO simplified to {name, consumed}. Frontend reset dialog reworded and the mislabeled "主动重置次数" cell header relabeled to the accurate 可用重置额度. Tests: rewrite the reset route test for the consume/no-credit/fail-closed/unknown-count paths and wire minimalism; add a tri-state unit test for the subscription extractor. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): harden real reset-credit consume — per-auth lock, identity binding, redeem ledger, redaction Addresses the review of the true-reset feature (real, paid, limited credits). Concurrency & identity: - Hold the per-auth lock across the whole fresh-count → consume → cooldown sequence (non-blocking; a contended request returns 409) so concurrent resets of one account can redeem at most one credit. - Resolve the account identity FRESH and bind everything to it: validate the list entry's and download detail's EXPLICIT auth_index/account_id separately (never the merged id_token, whose download side is a raw JWT string), fail closed on a list-vs-detail conflict, never fall back to the auth name for auth_index, require the download's top-level account_id (the consume header) while treating the list's id_token.chatgpt_account_id as an optional cross-check, and require the fresh auth_index to exactly match the DB row. Cross-operation idempotency (redeem ledger, migration 202609060003): - Persist a per-auth redeem_request_id bound to (auth_index, account_id). An identity-matched pending redeem is ALWAYS replayed with its original key — even when the fresh available_count is 0 — because a lost first response may have consumed the last credit, and only replaying the same key recovers already_redeemed. A fresh id is minted only when there is no usable pending redeem and a credit is available; an old account's id is never inherited. The ledger stays pending through the cooldown step and is finalized only after the whole operation succeeds; a consume-then-cooldown failure is audited as an irreversible partial and kept pending for an idempotent retry. Account delete and prune clear the ledger. Redaction: - Never log an external inner body (even truncated). Consume outcomes audit only a stable classification plus whitelisted inner status_code / recognized code. Subscription (strict): - Parse the id_token claim strictly: reject NaN/Inf/fractional/overflow and bound the epoch to a sane 2000–2100 window. Scope preserve-on-unknown to auth identity so a reassigned auth_index never keeps the previous account's renewal date. Frontend: - Reset API returns {name, consumed}; the toast now reports real redemption vs cooldown-only. The reset-credit count shows "未知/陈旧" when unknown instead of substituting a possibly-truncated detail length or 0. The account detail drawer shows the renewal time. New backend errors are mapped in i18n (with smoke assertions) so the English UI shows specific recovery guidance. Rollback: - Document the Down-first-then-old-binary contract (docs/migrations-rollback.md) and test that Down to 202609040002 restores the codex_keeper_quota_resets compat table. Tests: per-auth concurrency (single consume), lost-response → same-key replay (incl. at count=0), redaction canary, auth_index mismatch, missing account_id, list/download account_id cross-check, ledger identity change, strict subscription parser, and the rollback compat-schema assertion. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): round-3 review hardening — resolver guards, outcome DTO, dry-run, rollback CLI Deep-review follow-ups on the real reset-credit consume path (paid, limited credits). Redeem ledger control flow: - Resolve an identity-matched pending redeem FIRST and replay it with its original key even when the fresh reset-credit GET is unavailable — the count gate applies only to a brand-new operation, so a lost-response pending is never stranded while the count endpoint is down. - Make the ledger claim a DB-atomic guarded upsert (overwrite only a non-same-identity pending) + read-winner, so concurrent claimers converge on one request_id. Documented that this hardens the overlap window but the contract is a SINGLE ACTIVE INSTANCE on single-writer SQLite (no client idempotency key spanning HTTP). - Keep the ledger pending through the cooldown step; finalize only after the whole operation succeeds; audit an irreversible partial (consumed + cooldown_failed) and keep it pending for an idempotent retry. - Delete the state row and ledger row in ONE transaction (a ledger-clear failure now fails the delete) so a same-identity re-import can never reuse a residual pending. Identity resolution (irreversibility guards): - Require the list entry to be Codex (and a detail with an explicit non-Codex type fails closed); require the download's account_id and access_token; reject a duplicate remote name; validate a detail name matches the request; validate intra-object alias consistency (auth_index/authIndex/index and account_id vs id_token claim) instead of silent precedence; never fall back to the auth name for auth_index. - Acquire the per-auth lock before reading state (decide under the lock), and fail closed when the keeper runner is absent instead of proceeding unlocked. Dry-run: a manual reset is fail-closed under dry-run (before any remote call or ledger write) so an admin testing the keeper never silently burns a credit. NOTE: the default config is DryRun=true, so a fresh deploy blocks reset until an admin turns it off. Outcome DTO: the reset response now carries a distinct outcome (reset|already_redeemed|no_credit|nothing_to_reset|cooldown_only) instead of a lossy consumed bool; the UI messages each case. Strict subscription parsing now range-gates the RFC3339/date string paths too (not just numeric), and the subscription write is gated on a confirmed identity so a failed detail read preserves the old renewal snapshot. Rollback: implement a real `migrate down-to <version>` subcommand (allowlisted target; refuses a non-downgrade; refuses when pending redeems exist unless --allow-pending) with explicit previous→target output — a bare `migrate` only runs Up. Documented the Down-first-then-old-binary contract, the offline/quiescence requirement, and the subscription data loss. Frontend: derive the table scroll width by summing column widths (drift-proof after adding the renewal column) and localize all new errors with i18n smoke assertions. Tests: pending-replay-when-fetch-unavailable, count-zero replay, outcome-codes contract, dry-run fail-closed, resolver guards (duplicate name, alias conflict, missing token, detail-name mismatch, non-Codex), subscription-preserved-on-unconfirmed-identity, string extreme-date subscriptions, and the migrate down-to CLI (rollback target + pending block). Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(cli): reject unknown migrate subcommands/flags and DB newer than binary A destructive maintenance command must not silently misfire: - `migrate <unknown>` now errors instead of falling back to Up. - `migrate down-to <v> <unknown-flag>` now errors instead of ignoring the flag (only --allow-pending is accepted). - MigrateDownTo refuses when the DB version is newer than the binary's embedded LatestVersion (its Down migrations would not cover the extra versions). Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): put all per-account mutations under the per-auth fence + partial audit Concurrency (real double-consume blocker): delete, prune, enable/disable, and priority now share the SAME per-auth lock as reset/inspection, so none can delete the redeem ledger or drift the remote/DB identity while a reset holds the lock mid-consume (which could drop an in-flight redeem's idempotency key and let a later operation mint a new one → double consume). - deleteKeeperAccount takes the lock (409 on contention); state+ledger delete extracted to deleteKeeperStateAndRedeem (one transaction). - pruneKeeperMissingAuthStates tries the lock per stale name and SKIPS any a reset holds (retried next cycle) — it never blocks, so no lock-order deadlock; bulk delete locks one name at a time (never two at once). - setKeeperAccountDisabled / updateKeeperAccountPriority take the lock too. These are handler-only; processKeeperAuth uses the lock-free setKeeperRemote{Disabled,Priority} helpers, so there is no self-conflict with the inspection run that already holds the lock. - Missing runner fails closed instead of proceeding unlocked. Audit: a consumed-credit-then-cooldown-failure now audits result=partial (an irreversible partial), not a plain error, so the money-affecting half-completion is visible. i18n: map createKeeperRedeem's inconsistent-state error and the new keeper-not-initialized errors, with smoke assertions. Tests: delete-conflicts-with-in-flight-reset (deterministic via the in-lock gate), partial-audit-on-cooldown-failure, and a cross-process claim convergence test — two App instances with independent DB handles on one SQLite file concurrently claim and converge on one winner request_id (proves the DB-atomic claim, not just a comment). Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): protect persisted pending redeems from prune/delete (ledger safety) The prior per-auth fence used only the in-memory lock, which is empty after a restart — so a successful-but-empty/transient remote auth-files list could prune an account that still holds a persisted status='pending' redeem, dropping the sole idempotency key (a later reappearance then mints a new key → double consume). This is a ledger-safety blocker, not a deployment-prerequisite waiver. - Add hasPendingKeeperRedeem: a PERSISTENT (DB) check, independent of the in-memory lock. - pruneKeeperMissingAuthStates retains (skips + audits) any stale account with a pending redeem, and now fails closed when there is no runner (no fence) instead of deleting state+ledger unlocked — consistent with reset/delete/priority. - deleteKeeperAccount refuses when a pending redeem is unresolved (reconcile first). Tests: prune retains an account with a pending redeem while pruning a non-pending stale one; prune deletes nothing without a runner; delete is refused with a pending redeem. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): key the redeem ledger by full identity so no pending key is overwritten The ledger was keyed by auth_name alone, so createKeeperRedeem overwrote a different identity's pending row when an auth_name was rebuilt onto another account — dropping the old identity's unresolved (maybe-consumed) idempotency key. If the original account later returned, a fresh key could be minted and double-consume. - Migration 202609060003 now keys the table by (auth_name, auth_index, account_id), so distinct identities sharing an auth_name each keep their own row; a redeem is never overwritten across identities. - createKeeperRedeem upserts per full identity (ON CONFLICT on the composite key), overwriting only its own terminal row; a concurrent same-identity pending row is still preserved (cross-process convergence unchanged). - lookupPendingKeeperRedeem selects by full identity; hasPendingKeeperRedeem now reports a pending redeem for ANY identity under the auth_name, so delete/prune still fully protect every in-flight key. Test: an unknown-outcome redeem for identity A survives a reset under identity B (same auth_name); when A returns, its ORIGINAL key is replayed (already_redeemed) rather than a new one. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): scope subscription identity to account_id + stop handler masking the partial audit Subscription account-swap (identity scope): CPA's file auth_index is a hash of provider+path, so swapping a filename to a different OpenAI account keeps auth_index unchanged. Scoping the subscription preserve-on-unknown to auth_index alone would let the new account inherit the old account's renewal date. Migration 202609060004 adds an account_id column to codex_keeper_auth_states; the inspection records it; and the upsert's subscription CASE now preserves on an unknown claim only when the account is unchanged and CLEARS on a confirmed account swap (both account_ids known and different). An unconfirmed identity (detail read failed) still preserves. Partial audit no longer masked: the reset handler previously wrote a generic result=error line after resetKeeperQuota had already audited result=partial, so the log tail hid the irreversible partial. resetKeeperQuota now returns a partial-coded AppError (reset_partial, HTTP 409) for a consumed-credit-then-cooldown-failure, and the handler skips its generic re-audit for that code — leaving result=partial as the single audit line. i18n: localize the partial-reset message. Tests: subscription cleared on same-index account swap / preserved on same account; partial-audit test also asserts no generic result=error line masks it; the reset-quota-failure-after-consume cases now assert 409. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * docs(codex-keeper): remove stale Consumed comment on the reset result (DTO is Outcome) Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * docs: cover migration 202609060004 (account_id) in the rollback runbook + test Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): scope the reset-credit snapshot preserve/clear by account_id Same as the subscription fix: the reset_credit_count / reset_credits upsert CASE compared only auth_index, but CPA's file auth_index is a hash of provider+path and stays the same when a filename is swapped to a different OpenAI account. A new account whose reset-credit fetch failed would then inherit the previous account's count/schedule (wrong UI and wrong next-reset decision). The CASE now preserves on an unknown/unconfirmed identity and CLEARS only on a confirmed account swap (both account_ids known and different). The existing identity-change test now models the change by account_id (its premise was auth_index-only); a new same-index/different-account snapshot test pins the clear. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): make account_id the identity — key the redeem ledger by account_id, treat auth_index as routing CPA's auth_index is a hash of the file path (and auth_name is a filename), so BOTH change on a file rename/move/reorder while the OpenAI account_id stays the same. Keying the redeem ledger (or the reset identity gate) on the routing selectors meant a legitimate reindex/ rename stranded an in-flight pending redeem → a new UUID → possible double-consume. Ledger (migration 202609060003, rewritten — it is unpublished; prod is at 202609040002 and every 0600xx migration has only run in disposable test DBs): - Keyed by the STABLE account_id. lookup/create/finish take account_id; the same account converges on one request_id however it is routed, and a lost-response pending is found after a reindex/rename. Distinct accounts keep distinct rows; a resolved (terminal) row lets the next claim mint a fresh id. Reset path: - The identity gate compares the fresh account_id against the DB row's stored account_id (a mismatch — a stale page acting on a swapped-out account — fails closed as account_id_mismatch). auth_index is used as-is for ROUTING and is no longer required to match the DB (a reindex must not block). - Inspection reconciles the account identity across the list id_token.chatgpt_account_id and the download account_id (keeperReconcileInspectionAccountID); on conflict it treats the identity as unknown — binds no account_id, does not trust the list subscription, and skips the reset-credit fetch — so a mixed A/B response never writes one account's data onto another's row. delete/prune: because the ledger is account-keyed (not file-keyed), removing a file can never drop the account's key — delete/prune now remove only the state row (no ledger touch, no pending-refuse/retain), still under the per-auth fence for state consistency. State: codex_keeper_auth_states.account_id (migration 202609060004) is now read back into keeperAuthState so the reset gate can compare it; the reset-credit and subscription upsert CASEs already clear on a confirmed account swap and preserve otherwise. Tests: ledger per-account & route-agnostic claim; reset replays a pending across an auth_index change (rename); account_id mismatch fails closed while an index-only change proceeds; prune/delete keep the account ledger; identity reconciliation; and migration coverage now includes 202609040002 → head → 202609040002 → head (replay). Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): route the cooldown clear with the FRESH auth_index, not the stale DB value resetKeeperQuota consumed with the fresh identity.authIndex but still routed /v0/management/reset-quota (and audited) with the stale DB auth_index. On a reindex (file rename/move/reorder) that meant "consume the credit on the new index, clear the cooldown on the old index" — clearing the wrong account or leaving an irreversible partial. After the identity resolves, ALL remote routing (consume, cooldown clear, the status=ok/auth_index echo check) and audits now use the fresh identity.authIndex; the DB value is kept only for the initial existence check / page snapshot. Test: the auth_index-change case now also asserts /reset-quota was routed with the fresh index ([idx-different]), not the stale one. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): add an account_id-level fence so two files of one account can't double-consume The per-auth lock keys on auth_name (the file), but the paid/limited credit and the redeem ledger are the ACCOUNT's. If the SAME OpenAI account is briefly reachable via two filenames /routes (rename overlap, duplicate import), two resets take DIFFERENT auth_name locks; A could claim → consume → finish (terminal) while B sits between fresh-fetch and claim, then B's account-keyed createKeeperRedeem sees the terminal row and mints a NEW request_id → a second credit. This needs no blue/green or multi-instance, so the single-active-instance contract does not cover it. resetKeeperQuota now also acquires a non-blocking account_id-level fence (KeeperRunner.tryLockAccountID) right after the identity resolves, held across the whole pending-lookup → fetch → claim → consume → cooldown → finish sequence; a contended reset of the same account returns 409. The account fence is released on return (before the handler's chained inspection). Test: two files (a.json idx-A, b.json idx-B) that map to one account_id — while fileA holds the fence (deterministically blocked in its fresh fetch), a reset of fileB is refused (409) and exactly one credit is consumed. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): fail closed on an inspection identity conflict (identity_error) + localize the message An inspection whose list and download identities disagree previously only skipped the reset-credit/subscription writes but still fetched usage, wrote email/priority, and marked result=healthy — so keeperRefreshAuditOutcome misreported a post-reset refresh as ok. Now processKeeperAuth reconciles BOTH the account_id AND the auth_index across the list and detail (keeperReconcileInspectionIdentity); on any conflict (or a self-contradictory source) it bails out early with result="identity_error", preserves the prior snapshot (no usage/reset-credit fetch, no account-data write), records a stable error, and the run carries a new IdentityError stat so keeperRefreshAuditOutcome returns error/identity_error (never ok). The conflict message is stable Chinese, mapped in i18n with smoke assertions (also adds the previously-unmapped account_busy assertion). Tests: keeperReconcileInspectionIdentity (account/index agree, conflict, self-conflict); an end-to-end InspectAccountsLocked conflict (list account A vs detail account B) that asserts no reset-credit fetch, the prior snapshot preserved, IdentityError=1, and audit=error/identity_error; and keeperStats.add covers the new field. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): identity_error must preserve the full prior snapshot, not clobber it The identity_error bail-out called persistState(result) on a near-empty result (only Name/Result/CheckedAt/subscription set), and upsertKeeperState writes email, auth_index, account_type, disabled, priority, usage, quota with `= excluded.*` (not COALESCE/CASE). So a list/detail identity conflict cleared the prior good snapshot's email/auth_index/account_id/ usage/quota/priority to NULL/false — the opposite of "preserve snapshot" — and a cleared auth_index would then block a later reset. Only reset_credits/subscription had CASE-preserve. Route the identity_error write through a dedicated markKeeperIdentityError that UPDATEs only last_error/latest_action/last_checked_at/updated_at and touches NO business column, so every existing value survives intact. It deliberately does not set last_healthy_at (a conflict is never a healthy refresh) and does not INSERT: a first-ever inspection that hits a conflict touches zero rows rather than persisting a partial/ambiguous identity. Test: TestKeeperInspectIdentityConflictPreservesSnapshot now seeds a FULL snapshot (email, auth_index, account_id, account_type, disabled, priority, usage, quota, subscription, reset credits, last_healthy_at) and asserts every column is unchanged after the conflict, that last_healthy_at did not advance, and that only last_error/latest_action/last_checked_at moved. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): treat "neither source has account_id" as identity-unknown — skip account-scoped writes keeperReconcileInspectionIdentity returns ("", true) when NEITHER the list entry nor the download detail carries an account_id (a legacy auth_index-only credential): the identity is consistent but UNKNOWN. processKeeperAuth then still fetched the reset-credit snapshot (the api-call omits the Chatgpt-Account-Id header, so OpenAI resolves the account from $TOKEN$ alone and the snapshot cannot be attributed to a resource) and still bound the list's subscription renewal claim — both account-scoped, both unsafe without a stable account_id, and neither can detect a same-index account swap. Now processKeeperAuth gates account-scoped work on accountIDKnown (acct != ""). When the account_id is unknown it still runs usage/priority (transient current state), but skips the reset-credit fetch (leaving the fields nil so the upsert's COALESCE preserves the prior snapshot), clears the up-front subscription claim (SubscriptionActiveUntil=nil, SubscriptionKnown=false, so its CASE also preserves), and flags ResetCreditsUnavailable so the refresh is audited partial/reset_credits_unavailable rather than a falsely-healthy ok. The previously-known account_id survives via COALESCE(excluded.account_id, existing). Test: TestKeeperInspectNeitherAccountIDSkipsAccountScopedWrites — list+detail both lack account_id over a seeded snapshot (known account_id, reset credits, subscription); asserts 0 reset-credit fetches, reset credits + subscription + prior account_id all preserved, and audit=partial/reset_credits_unavailable. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * test(codex-keeper): make the neither-account-id subscription guard right-cause The neither-account-id test's list fixture had no subscription claim at all, so it did not actually exercise the SubscriptionActiveUntil=nil/SubscriptionKnown=false guard — deleting those two lines still left the test green. Give the list a PARSEABLE renewal claim that differs from the seeded snapshot (via id_token.chatgpt_subscription_active_until, still no chatgpt_account_id), and assert the stored renewal equals the OLD snapshot and is NOT the list claim. Verified by mutation: removing the guard now fails the test (the list renewal binds). Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): decode raw-JWT id_token when resolving account_id identity (fail-closed on conflict) keeperExplicitAccountID only read id_token when it was a map, so it ignored CLIProxyAPI's real download form — a raw JWT string — entirely. A deceptive/misrouted detail with top-level account_id=A but an id_token whose own chatgpt_account_id claim is B passed as consistent: the inspection could mix A's metadata with B's token, and the reset resolver would consume a real, irreversible reset credit on A's header/route using B's token. (Mutation-verified: with the old code the deceptive-JWT reset logs op=reset-consume result=ok code=reset consumed=true.) Now keeperExplicitAccountID decodes the id_token via the existing keeperIDTokenClaims (map, JSON string, or raw JWT payload) and cross-checks chatgpt_account_id from BOTH the flattened top level and the nested OpenAI auth namespace (https://api.openai.com/auth.chatgpt_account_id — where the raw download JWT actually carries it) against the top-level account_id; any disagreement is an identity conflict. An id_token that is present but unparseable leaves the identity indeterminate and also fails closed. This covers both the inspection path (→ identity_error, snapshot preserved) and the reset resolver (→ 422, no consume) since both call keeperExplicitAccountID. Tests: keeperReconcileInspectionIdentity gains raw-JWT agree/flattened-conflict/nested-namespace- conflict/unparseable cases; a reset entry test injects a nested-claim raw JWT and asserts no consume/no reset-quota (422); an inspection entry test asserts no reset-credit fetch, the usage + reset-credit snapshot preserved, and audit=error/identity_error. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * docs(codex-keeper): correct stale lock/DTO comments to the account_id-fence + outcome model Comment-only, no behavior change. - codex_keeper_reset_test.go: several comments still described the old boolean DTO (consumed=true/false); the wire is now `outcome` (reset|already_redeemed|no_credit|nothing_to_reset|cooldown_only). Updated the struct doc and the TestKeeperReset / count-zero-replay comments so a future reader is not misled about the response shape. - lookupPendingKeeperRedeem / createKeeperRedeem: the doc comments still claimed the per-auth (auth_name) lock is the serializing guard for the money-critical window. After the account_id rekey, the guard that serializes the same OpenAI account across different files/routes is the per-account_id fence; the per-auth_name lock is per-file and does not by itself serialize two files of the same account. Corrected both so the mutex boundary is not reused incorrectly later. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): fail closed on a present-but-wrong-type identity alias (not silently ignore it) keeperExplicitAuthIndex / keeperExplicitAccountID / keeperClaimsAccountIDs read identity fields through keeperString / a map type-assertion, both of which map a present-but-wrong-type value to "absent". So a deceptive entry like {auth_index: 123, authIndex: "idx-A"} silently trusted the string sibling, and a JWT namespace https://api.openai.com/auth: "not-object" (or a numeric chatgpt_account_id) was ignored rather than rejected — violating the "any illegal/conflicting alias fails closed" contract for an irreversible consume. New keeperExplicitStringField distinguishes absent (key missing/null → "") from present-invalid (present with a non-string type → errKeeperIdentityConflict). All three helpers now use it, and keeperClaimsAccountIDs additionally rejects a present-but-non-object auth namespace. A present identity field must be a string (the namespace must be an object) or the identity fails closed (inspection → identity_error, reset → validation error). Tests: keeperReconcileInspectionIdentity gains authindex-wrong-type, account-id-wrong-type, jwt-namespace-not-object, and jwt-nested-claim-wrong-type cases. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): route/attribute inspection api-calls by the reconciled identity, not the raw merge processKeeperAuth resolved the account_id but passed the raw right-biased `merged` to checkKeeperUsage/fetchKeeperResetCredits, which read the top-level account_id for the Chatgpt-Account-Id header and call keeperAuthIndex for routing. Two gaps: 1. account_id known only from the list id_token claim (legacy detail with no top-level account_id) → merged["account_id"] empty → the api-call omitted Chatgpt-Account-Id even though the account was known, yet still wrote an account-scoped snapshot (inconsistent attribution). 2. auth_index present only in the list while the detail carried an explicit-null auth_index → the right-biased merge dropped it → keeperAuthIndex fell back to the auth NAME for routing. keeperReconcileInspectionIdentity now returns a keeperInspectionIdentity{accountID, authIndex} (per-source validated). processKeeperAuth normalizes `merged` with the reconciled auth_index (when non-empty) and account_id (when known) before any account-scoped work, so routing and the account header come only from the reconciled identity, never re-derived from the raw merge. Business fields still read from merged. Tests: TestKeeperInspectListOnlyAccountIDSetsHeader (account_id only in the list claim, detail auth_index explicit-null) asserts both api-calls carry Chatgpt-Account-Id=acct-A and route auth_index=idx-1 (not the name), and the snapshot is written; the reconcile unit test switches to the identity struct. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): CAS-persist a legacy NULL account_id on reset before consuming (bind-on-reset) A pre-account_id (NULL account_id) row is allowed to bind on a reset, but the account_id was only used in-memory — never written back — so the NULL row stayed bindable-to-anything on every reset. The account_id swap gate only fires when the stored id is non-NULL, so if the same filename was swapped to a different account between resets (with no successful inspection persisting the new id in the gap the reviewer noted), the next reset would consume the WRONG account's credit undetected. resetKeeperQuota now CAS-binds the resolved account_id onto a NULL row inside the account fence and BEFORE any pending-lookup/fetch/consume/cooldown/ledger write, via bindKeeperAccountID: `UPDATE ... SET account_id=? WHERE auth_name=? AND account_id IS NULL`. affected==1 binds; affected==0 re-reads and continues only if the stored id already equals the resolved id (concurrent/idempotent), else fails closed as an account_id mismatch. So a NULL row is never left unbound after a real consume, and a later same-name swap is caught by the existing gate. 6e278b0d (withdrawn by review as non-blocking): same-account duplicate files are separate auth_name rows, so one row's inspect never overwrites the other's — it is task #71 display staleness, not a write-back/monetary issue. No fence change; documented as duplicate-row eventual consistency in the PR body instead. Test: TestBindKeeperAccountIDCASAndSwap — NULL row binds to A, idempotent re-bind of A, and a different-account bind fails closed without changing the stored id. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * fix(codex-keeper): take the per-account fence on the stored account_id BEFORE the resolve (close the pre-fence double-consume window) The account fence was acquired AFTER the fresh list/download resolve, leaving a pre-fence window: two different files of the SAME account could interleave — file A blocked mid-download (no fence yet) while file B fully resolves, consumes, finishes and releases, then A resumes, sees a terminal ledger and mints a fresh redeem id → a SECOND consume. Reproduced deterministically at A=200, B=200, consume=2. Move mutual exclusion before identity resolution: resetKeeperQuota now requires the state row to already carry a confirmed account_id and takes tryLockAccountID on that STORED account_id BEFORE resolveKeeperResetIdentity, holding it across resolve → lookup → fetch → consume → cooldown → finish. The fresh account_id must then equal the stored (fenced) account_id or it fails closed. This supersedes the previous NULL-bind-on-reset (bindKeeperAccountID removed): a legacy pre-account_id (NULL) row is now refused (fail closed) until it has been inspected once, which persists its account_id — the reviewer's minimal safe option — rather than fencing on an unknown identity. 6e278b0d (duplicate-row snapshot staleness) stays documented-only per review. Tests: TestKeeperResetAccountFenceBeforeResolve gates fileA inside its DOWNLOAD (pre-fetch) while it holds the fence and asserts fileB (same account) is refused 409 with exactly one consume; TestKeeperResetNullAccountRefusedUntilInspected asserts a NULL-account row is refused before any remote call. i18n adds the "identity not confirmed" message with a smoke assertion; the old bind-failure messages/tests are removed. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> * docs(codex-keeper): correct the per-auth_name lock comment to per-file/state guard Comment-only, no behavior change. The resetKeeperQuota lock comment claimed the per-auth lock is THE paid-resource guard that stops two same-account resets from each consuming a credit. That holds only for the same auth_name; the cross-file monetary mutual exclusion is the stored-account_id fence taken afterward. Reworded to describe the per-auth_name lock as a per-file/state guard and point to the account_id fence for the cross-route paid-credit exclusion, so the mutex boundary is not misused later. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> --------- Signed-off-by: feiniu <a-9b3ff9ce@mail.build> Co-authored-by: feiniu (Raft agent) <admin@oranix.io> --- backend/cmd/cpa-helper/main.go | 39 +- backend/cmd/cpa-helper/main_test.go | 102 ++ backend/go.mod | 2 +- backend/internal/app/codex_keeper.go | 1272 ++++++++++++--- .../app/codex_keeper_internal_test.go | 965 +++++++++++- .../internal/app/codex_keeper_reset_test.go | 1398 +++++++++++++++-- backend/internal/app/migrations_test.go | 81 + backend/internal/app/startup.go | 107 ++ ...60001_keeper_subscription_active_until.sql | 5 + .../202609060002_drop_keeper_quota_resets.sql | 14 + .../202609060003_keeper_reset_redeems.sql | 17 + .../202609060004_keeper_account_id.sql | 10 + backend/migrations/migrations.go | 2 +- docs/migrations-rollback.md | 78 + frontend/scripts/i18n-smoke.mjs | 54 + .../codex-keeper/api/codexKeeperApi.ts | 25 +- .../views/CodexKeeperStatusView.vue | 99 +- frontend/src/shared/i18n/messages.ts | 35 + frontend/src/shared/types/api.ts | 3 +- 19 files changed, 3902 insertions(+), 406 deletions(-) create mode 100644 backend/migrations/202609060001_keeper_subscription_active_until.sql create mode 100644 backend/migrations/202609060002_drop_keeper_quota_resets.sql create mode 100644 backend/migrations/202609060003_keeper_reset_redeems.sql create mode 100644 backend/migrations/202609060004_keeper_account_id.sql create mode 100644 docs/migrations-rollback.md diff --git a/backend/cmd/cpa-helper/main.go b/backend/cmd/cpa-helper/main.go index e507f32f..cf2557e2 100644 --- a/backend/cmd/cpa-helper/main.go +++ b/backend/cmd/cpa-helper/main.go @@ -39,6 +39,41 @@ func run(ctx context.Context, args []string, stdout io.Writer) error { case "serve": return serve(ctx) case "migrate": + // `migrate down-to <version>` rolls the schema DOWN to an allowlisted rollback + // target (destructive; see docs/migrations-rollback.md). Bare `migrate` runs Up. + // A destructive subcommand rejects unknown subcommands/flags rather than silently + // falling back to Up or ignoring a typo. + if len(args) >= 2 { + sub := strings.TrimSpace(args[1]) + if sub != "down-to" { + printUsage(stdout) + return fmt.Errorf("unknown migrate subcommand %q", sub) + } + if len(args) < 3 { + printUsage(stdout) + return fmt.Errorf("migrate down-to requires a target version") + } + target, err := strconv.ParseInt(strings.TrimSpace(args[2]), 10, 64) + if err != nil { + return fmt.Errorf("invalid target version %q: %w", args[2], err) + } + allowPending := false + for _, extra := range args[3:] { + switch strings.TrimSpace(extra) { + case "--allow-pending": + allowPending = true + default: + printUsage(stdout) + return fmt.Errorf("unknown flag %q for migrate down-to", extra) + } + } + report, err := backendApp.MigrateDownTo(ctx, target, allowPending) + if err != nil { + return err + } + fmt.Fprintf(stdout, "rollback completed: db=%s previous_version=%d current_version=%d target_version=%d\n", report.DBPath, report.PreviousVersion, report.CurrentVersion, report.TargetVersion) + return nil + } report, err := backendApp.Migrate(ctx) if err != nil { return err @@ -110,7 +145,9 @@ func printUsage(w io.Writer) { fmt.Fprint(w, `Usage: cpa-helper Run migrations, then start the service cpa-helper start Run migrations, then start the service - cpa-helper migrate Run database migrations and exit + cpa-helper migrate Run database migrations (Up) and exit + cpa-helper migrate down-to <version> + Roll the schema DOWN to an allowlisted version (destructive) cpa-helper serve Start only after read-only startup checks pass cpa-helper doctor Run read-only startup checks and exit `) diff --git a/backend/cmd/cpa-helper/main_test.go b/backend/cmd/cpa-helper/main_test.go index f12084a3..6529adba 100644 --- a/backend/cmd/cpa-helper/main_test.go +++ b/backend/cmd/cpa-helper/main_test.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "database/sql" + "path/filepath" "strings" "testing" ) @@ -26,3 +28,103 @@ func TestBackendAddrRejectsBarePort(t *testing.T) { t.Fatal("backendAddr accepted a bare port") } } + +// TestMigrateDownToRollsBackToTarget proves the real `migrate down-to <version>` +// subcommand actually downgrades the schema to an allowlisted target (unlike a bare +// `migrate`, which only runs Up), and refuses unlisted targets / a missing version. +func TestMigrateDownToRollsBackToTarget(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + ctx := context.Background() + + var up bytes.Buffer + if err := run(ctx, []string{"migrate"}, &up); err != nil { + t.Fatalf("migrate up: %v", err) + } + + var down bytes.Buffer + if err := run(ctx, []string{"migrate", "down-to", "202609040002"}, &down); err != nil { + t.Fatalf("migrate down-to: %v", err) + } + out := down.String() + if !strings.Contains(out, "current_version=202609040002") { + t.Fatalf("rollback did not reach 202609040002: %s", out) + } + if !strings.Contains(out, "previous_version=202609060004") { + t.Fatalf("rollback did not start from head 202609060004: %s", out) + } + + // A non-allowlisted target is refused. + if err := run(ctx, []string{"migrate", "down-to", "202609040001"}, &bytes.Buffer{}); err == nil { + t.Fatal("down-to accepted a non-allowlisted target") + } + // A missing version errors rather than silently no-op'ing. + if err := run(ctx, []string{"migrate", "down-to"}, &bytes.Buffer{}); err == nil { + t.Fatal("down-to accepted a missing version") + } + // A destructive subcommand rejects unknown flags/subcommands instead of silently + // ignoring a typo or falling back to Up. + if err := run(ctx, []string{"migrate", "down-to", "202609040002", "--typo"}, &bytes.Buffer{}); err == nil { + t.Fatal("down-to accepted an unknown flag") + } + if err := run(ctx, []string{"migrate", "nonsense"}, &bytes.Buffer{}); err == nil { + t.Fatal("migrate accepted an unknown subcommand (must not fall back to Up)") + } +} + +// TestMigrateDownToRefusesPendingRedeems proves a rollback is refused by default when the +// redeem ledger holds a pending (unresolved) redeem — dropping it would lose the unique +// idempotency key — and proceeds only with the explicit --allow-pending override. +func TestMigrateDownToRefusesPendingRedeems(t *testing.T) { + dataDir := t.TempDir() + t.Setenv("CPA_HELPER_DATA_DIR", dataDir) + ctx := context.Background() + + if err := run(ctx, []string{"migrate"}, &bytes.Buffer{}); err != nil { + t.Fatalf("migrate up: %v", err) + } + + // Inject a pending redeem directly into the ledger. + dbPath := filepath.Join(dataDir, "db", "cpa_helper.sqlite3") + db, err := sql.Open("sqlite", dbPath+"?_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("open db: %v", err) + } + if _, err := db.Exec(`INSERT INTO codex_keeper_reset_redeems (account_id, redeem_request_id, status, updated_at) VALUES ('acct-p','rid','pending','2026-01-01 00:00:00')`); err != nil { + _ = db.Close() + t.Fatalf("insert pending: %v", err) + } + if err := db.Close(); err != nil { + t.Fatalf("close db: %v", err) + } + + // Default rollback is refused while a pending redeem exists. + if err := run(ctx, []string{"migrate", "down-to", "202609040002"}, &bytes.Buffer{}); err == nil { + t.Fatal("rollback should be refused while a pending redeem exists") + } + if v := currentVersionForTest(t, dbPath); v != 202609060004 { + t.Fatalf("refused rollback still changed version to %d", v) + } + + // The explicit override proceeds. + var out bytes.Buffer + if err := run(ctx, []string{"migrate", "down-to", "202609040002", "--allow-pending"}, &out); err != nil { + t.Fatalf("override rollback: %v", err) + } + if !strings.Contains(out.String(), "current_version=202609040002") { + t.Fatalf("override rollback did not reach target: %s", out.String()) + } +} + +func currentVersionForTest(t *testing.T, dbPath string) int64 { + t.Helper() + db, err := sql.Open("sqlite", dbPath+"?mode=ro&_pragma=busy_timeout(5000)") + if err != nil { + t.Fatalf("open db: %v", err) + } + defer db.Close() + var v int64 + if err := db.QueryRow(`SELECT MAX(version_id) FROM goose_db_version`).Scan(&v); err != nil { + t.Fatalf("query version: %v", err) + } + return v +} diff --git a/backend/go.mod b/backend/go.mod index de2f84ab..ff39497e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module cpa-helper/backend go 1.25.7 require ( + github.com/google/uuid v1.6.0 github.com/pressly/goose/v3 v3.27.1 github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.50.0 @@ -11,7 +12,6 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index 1b39ce45..28d86954 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -20,13 +20,28 @@ import ( "strings" "sync" "time" + + "github.com/google/uuid" ) const ( - keeperUsageURL = "https://chatgpt.com/backend-api/wham/usage" - keeperResetCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" - keeperResetCreditType = "codex_rate_limits" - keeperResetCreditStatus = "available" + keeperUsageURL = "https://chatgpt.com/backend-api/wham/usage" + keeperResetCreditsURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + keeperResetCreditsConsumeURL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume" + keeperResetCreditType = "codex_rate_limits" + keeperResetCreditStatus = "available" + // Inner response codes returned by wham/rate-limit-reset-credits/consume + // (OpenAI Codex backend-client rate_limit_resets). A 2xx inner status only + // means the request was processed; the code decides whether a credit was + // actually redeemed this call. + keeperResetCreditCodeReset = "reset" // a real reset happened now + keeperResetCreditCodeAlreadyRedeemed = "already_redeemed" // same redeem_request_id replayed (idempotent) — still our redemption + keeperResetCreditCodeNoCredit = "no_credit" // no credit available (snapshot was stale-high) — NOT consumed + keeperResetCreditCodeNothingToReset = "nothing_to_reset" // nothing to reset — NOT consumed + // keeperRedeemStatusPending marks a redeem ledger row whose outcome is unknown + // (in-flight or a prior attempt that failed transport/protocol). Its + // redeem_request_id must be reused on the next attempt for idempotency. + keeperRedeemStatusPending = "pending" keeperLogFilePrefix = "codex-keeper-" keeperLogComponent = "codex_keeper" keeperLogRetainedFiles = 3 @@ -38,20 +53,21 @@ const ( ) type KeeperRunner struct { - app *App - mu sync.Mutex - daemonStop chan struct{} - daemonDone chan struct{} - running bool - runningModes map[string]struct{} - inFlightAuths map[string]string - state string - detail string - mode *string - lastStartedAt *time.Time - lastFinishedAt *time.Time - stats keeperStats - logs []string + app *App + mu sync.Mutex + daemonStop chan struct{} + daemonDone chan struct{} + running bool + runningModes map[string]struct{} + inFlightAuths map[string]string + inFlightAccounts map[string]bool + state string + detail string + mode *string + lastStartedAt *time.Time + lastFinishedAt *time.Time + stats keeperStats + logs []string } type keeperStats struct { @@ -63,6 +79,10 @@ type keeperStats struct { PriorityRestored int `json:"priority_restored"` Skipped int `json:"skipped"` NetworkError int `json:"network_error"` + // IdentityError counts accounts whose list/detail identity (account_id or auth_index) + // conflicted this run, so the snapshot was preserved and NOT refreshed from an + // ambiguous mixed detail — a post-reset refresh must be audited as error, not ok. + IdentityError int `json:"identity_error"` // ResetCreditsUnavailable counts otherwise-healthy accounts whose reset-credit // fetch failed this run (snapshot preserved, health unchanged). Not persisted to // the runs table; used to audit a post-reset refresh as partial. @@ -127,28 +147,27 @@ type keeperQuotaResetRequest struct { } type keeperAccount struct { - Name string `json:"name"` - Email *string `json:"email"` - AuthIndex *string `json:"auth_index"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *time.Time `json:"primary_reset_at"` - SecondaryResetAt *time.Time `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - QuotaResetCount int `json:"quota_reset_count"` - LastQuotaResetAt *time.Time `json:"last_quota_reset_at"` - ResetCreditCount *int `json:"reset_credit_count"` - ResetCredits []keeperResetCredit `json:"reset_credits"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *time.Time `json:"last_checked_at"` - LastHealthyAt *time.Time `json:"last_healthy_at"` + Name string `json:"name"` + Email *string `json:"email"` + AuthIndex *string `json:"auth_index"` + AccountType *string `json:"account_type"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *time.Time `json:"primary_reset_at"` + SecondaryResetAt *time.Time `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCredit `json:"reset_credits"` + SubscriptionActiveUntil *time.Time `json:"subscription_active_until"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *time.Time `json:"last_checked_at"` + LastHealthyAt *time.Time `json:"last_healthy_at"` } // keeperResetCredit is the safe projection of one entry from @@ -176,29 +195,28 @@ type keeperResetCreditResponse struct { } type keeperAccountResponse struct { - Name string `json:"name"` - Email *string `json:"email"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *string `json:"primary_reset_at"` - SecondaryResetAt *string `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - PrimaryWindowUsage *keeperQuotaWindowUsageResponse `json:"primary_window_usage"` - SecondaryWindowUsage *keeperQuotaWindowUsageResponse `json:"secondary_window_usage"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *string `json:"last_checked_at"` - LastHealthyAt *string `json:"last_healthy_at"` - QuotaResetCount int `json:"quota_reset_count"` - LastQuotaResetAt *string `json:"last_quota_reset_at"` - ResetCreditCount *int `json:"reset_credit_count"` - ResetCredits []keeperResetCreditResponse `json:"reset_credits"` + Name string `json:"name"` + Email *string `json:"email"` + AccountType *string `json:"account_type"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *string `json:"primary_reset_at"` + SecondaryResetAt *string `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + PrimaryWindowUsage *keeperQuotaWindowUsageResponse `json:"primary_window_usage"` + SecondaryWindowUsage *keeperQuotaWindowUsageResponse `json:"secondary_window_usage"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *string `json:"last_checked_at"` + LastHealthyAt *string `json:"last_healthy_at"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCreditResponse `json:"reset_credits"` + SubscriptionActiveUntil *string `json:"subscription_active_until"` } type keeperQuotaWindowUsageResponse struct { @@ -254,8 +272,12 @@ type keeperWindowUsageCache struct { type keeperAuthState struct { keeperAccount RestorePriority *int - CreatedAt time.Time - UpdatedAt time.Time + // AccountID is the stored ChatGPT account identity (nil until an inspection observed + // it). resetKeeperQuota compares it with the fresh identity so a same-filename account + // swap (auth_index unchanged) never resets the wrong account. + AccountID *string + CreatedAt time.Time + UpdatedAt time.Time } type keeperUsageInfo struct { @@ -302,6 +324,19 @@ type keeperAccountResult struct { // good data. A successful empty result carries a non-nil count of 0. ResetCreditCount *int ResetCredits *string + // SubscriptionActiveUntil is the ChatGPT subscription renewal time parsed from + // the account's id_token claims. It is tri-state together with + // SubscriptionKnown: when SubscriptionKnown is true the value is authoritative + // (a nil pointer means "confirmed no subscription" and clears the stored + // value); when SubscriptionKnown is false the claim was unreadable/malformed + // and upsertKeeperState preserves the previous snapshot. + SubscriptionActiveUntil *time.Time + SubscriptionKnown bool + // AccountID is the ChatGPT account identity this inspection observed (nil when the + // detail/claims were unreadable). upsertKeeperState uses it to scope the subscription + // preserve-on-unknown to the ACCOUNT, so an auth_name swapped to a different account + // under the same auth_index never inherits the previous account's renewal date. + AccountID *string // ResetCreditsUnavailable is set when the account is healthy but its reset-credit // fetch failed, so the snapshot was not refreshed this inspection. ResetCreditsUnavailable bool @@ -651,6 +686,39 @@ func (r *KeeperRunner) unlockAuthName(name string) { delete(r.inFlightAuths, name) } +// tryLockAccountID / unlockAccountID guard the RESOURCE (an OpenAI account_id), separately +// from the auth_name (file) lock. Two different filenames/routes for the SAME account +// (rename overlap, duplicate import) take different auth_name locks but the SAME account_id +// lock, so a reset that redeems a credit for an account excludes any other concurrent reset +// of the same account — without this a second route could consume a second credit even in a +// single process. Non-blocking: a contended reset returns a conflict. +func (r *KeeperRunner) tryLockAccountID(accountID string) bool { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return true + } + r.mu.Lock() + defer r.mu.Unlock() + if r.inFlightAccounts == nil { + r.inFlightAccounts = map[string]bool{} + } + if r.inFlightAccounts[accountID] { + return false + } + r.inFlightAccounts[accountID] = true + return true +} + +func (r *KeeperRunner) unlockAccountID(accountID string) { + accountID = strings.TrimSpace(accountID) + if accountID == "" { + return + } + r.mu.Lock() + defer r.mu.Unlock() + delete(r.inFlightAccounts, accountID) +} + func keeperModeOrder(mode string) int { switch mode { case "daemon": @@ -854,6 +922,10 @@ func keeperRefreshAuditOutcome(stats keeperStats, err error) (result string, rea return "error", "state_write_error" case stats.NetworkError > 0: return "error", "network_error" + case stats.IdentityError > 0: + // The account's list/detail identity conflicted, so its snapshot was preserved + // (not refreshed from an ambiguous mixed detail) — never a successful refresh. + return "error", "identity_error" case stats.StatusDisabled > 0: // The account was disabled during the inspect (invalid/expired credentials) // before the usage + reset-credit fetch could refresh the snapshot. @@ -1068,7 +1140,12 @@ func (a *App) handleCodexKeeper(w http.ResponseWriter, r *http.Request) error { } result, err := a.resetKeeperQuota(r.Context(), name) if err != nil { - a.auditKeeperOp("reset-quota", name, "result", "error", "reason", keeperSafeReason(err)) + // An irreversible partial (credit consumed, cooldown-clear failed) is already + // audited as result=partial inside resetKeeperQuota; do NOT overwrite it with a + // generic result=error line that would mask the partial in the log tail. + if keeperSafeReason(err) != keeperResetPartialCode { + a.auditKeeperOp("reset-quota", name, "result", "error", "reason", keeperSafeReason(err)) + } return err } // Immediately re-inspect just this account so its post-reset usage / window / @@ -1239,29 +1316,28 @@ func keeperAccountResponses(accounts []keeperAccount, windowUsages map[string]ke for _, account := range accounts { usage := windowUsages[account.Name] responses = append(responses, keeperAccountResponse{ - Name: account.Name, - Email: account.Email, - AccountType: account.AccountType, - Disabled: account.Disabled, - Priority: keeperDisplayPriority(account.Priority), - PrimaryUsedPercent: account.PrimaryUsedPercent, - SecondaryUsedPercent: account.SecondaryUsedPercent, - PrimaryResetAt: apiDateTimePtr(account.PrimaryResetAt), - SecondaryResetAt: apiDateTimePtr(account.SecondaryResetAt), - PrimaryWindowSeconds: account.PrimaryWindowSeconds, - SecondaryWindowSeconds: account.SecondaryWindowSeconds, - PrimaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Primary), - SecondaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Secondary), - QuotaThreshold: account.QuotaThreshold, - LastStatusCode: account.LastStatusCode, - LastError: account.LastError, - LatestAction: account.LatestAction, - LastCheckedAt: apiDateTimePtr(account.LastCheckedAt), - LastHealthyAt: apiDateTimePtr(account.LastHealthyAt), - QuotaResetCount: account.QuotaResetCount, - LastQuotaResetAt: apiDateTimePtr(account.LastQuotaResetAt), - ResetCreditCount: account.ResetCreditCount, - ResetCredits: keeperResetCreditResponses(account.ResetCredits), + Name: account.Name, + Email: account.Email, + AccountType: account.AccountType, + Disabled: account.Disabled, + Priority: keeperDisplayPriority(account.Priority), + PrimaryUsedPercent: account.PrimaryUsedPercent, + SecondaryUsedPercent: account.SecondaryUsedPercent, + PrimaryResetAt: apiDateTimePtr(account.PrimaryResetAt), + SecondaryResetAt: apiDateTimePtr(account.SecondaryResetAt), + PrimaryWindowSeconds: account.PrimaryWindowSeconds, + SecondaryWindowSeconds: account.SecondaryWindowSeconds, + PrimaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Primary), + SecondaryWindowUsage: keeperQuotaWindowUsageResponseFrom(usage.Secondary), + QuotaThreshold: account.QuotaThreshold, + LastStatusCode: account.LastStatusCode, + LastError: account.LastError, + LatestAction: account.LatestAction, + LastCheckedAt: apiDateTimePtr(account.LastCheckedAt), + LastHealthyAt: apiDateTimePtr(account.LastHealthyAt), + ResetCreditCount: account.ResetCreditCount, + ResetCredits: keeperResetCreditResponses(account.ResetCredits), + SubscriptionActiveUntil: apiDateTimePtr(account.SubscriptionActiveUntil), }) } return responses @@ -2560,6 +2636,10 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map name = "unknown" } result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now} + // Subscription renewal time comes from the auth-file list entry's parsed + // id_token claims (available regardless of the usage-fetch outcome), so set it + // up front to persist on every path. + result.SubscriptionActiveUntil, result.SubscriptionKnown = keeperSubscriptionActiveUntil(authInfo) // persistState writes the result to the DB and, if that fails, records the // failure on the result (and logs it) rather than swallowing the error — so a // state that never reached the DB is not later reported as a healthy refresh. @@ -2593,8 +2673,74 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map return result } merged := mergeKeeperObjects(authInfo, detail) + // Reconcile the account identity across BOTH sources FIRST — the list entry's explicit + // account_id (its id_token.chatgpt_account_id, the same source as the subscription + // claim) and auth_index, and the download detail's account_id and auth_index. If either + // conflicts (or a single source is self-contradictory), the merged detail mixes two + // accounts, so bail out with an identity_error and preserve the prior snapshot rather + // than fetching usage / writing account data from an ambiguous mix. This is audited as + // an error (never a successful refresh) and never binds a wrong account_id. + identity, consistent := keeperReconcileInspectionIdentity(authInfo, detail) + if !consistent { + message := "账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照" + result.Result = "identity_error" + result.LastError = &message + result.LatestAction = &message + result.SubscriptionKnown = false + // The merged detail mixes two accounts, so every business column (email, + // auth_index, account_id, account_type, disabled, priority, usage, quota, + // reset credits, subscription) on `result` is empty/ambiguous here. Persisting + // it through the normal upsert (which is `= excluded.*` for those columns) would + // clobber the prior good snapshot to NULL/false — which also breaks a later reset + // that needs the stored auth_index. Preserve ALL existing state and only stamp the + // error/latest_action and the check time. If no row exists yet (first-ever + // inspection hits a conflict) there is nothing trustworthy to insert, so this + // touches zero rows rather than manufacturing an incomplete identity row. + if err := a.markKeeperIdentityError(ctx, name, &message, result.CheckedAt); err != nil { + logFn(name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper identity-error write-back failed for %s: %v", name, err) + result.StateWriteFailed = true + } + logFn(name + ":" + message) + return result + } result.Email = keeperStringPtr(merged["email"], merged["account_email"], merged["user_email"]) + // Normalize the merged object with the per-source-VALIDATED identity so all account-scoped + // work below consumes only the reconciled {auth_index, account_id}, never re-derived from + // the raw right-biased merge. Routing (checkKeeperUsage / fetchKeeperResetCredits call + // keeperAuthIndex, which otherwise falls back to the auth NAME) uses the reconciled + // auth_index; the Chatgpt-Account-Id header (read from the top-level account_id) uses the + // reconciled account_id. Each is injected only when non-empty. Safe because the identity is + // reconciled (non-conflicting) for this merged detail. + if identity.authIndex != "" { + merged["auth_index"] = identity.authIndex + } result.AuthIndex = keeperRemoteAuthIndex(merged) + // accountIDKnown gates the account-scoped work below. keeperReconcileInspectionIdentity + // yields an empty account_id when NEITHER source carries one (e.g. a legacy auth_index-only + // credential): the identity is consistent (nothing to conflict) but UNKNOWN. Without a stable + // account_id we cannot attribute reset credits to a resource (the api-call would omit the + // Chatgpt-Account-Id header and OpenAI would resolve the account from $TOKEN$ alone) nor detect + // a same-index account swap in the snapshot CASE. So we still run usage/priority (transient + // current state, always overwritten), but skip the reset-credit fetch and the subscription + // write and preserve the prior account-scoped snapshot rather than overwrite it blind. + acct := identity.accountID + accountIDKnown := acct != "" + if accountIDKnown { + result.AccountID = &acct + // Attribute the account-scoped api-calls (Chatgpt-Account-Id, read from the top-level + // account_id) to the confirmed account even when the id came only from the list's + // id_token claim and the download detail carried no top-level account_id — otherwise a + // known account_id could send an account-less request yet write an account-scoped + // snapshot, an inconsistent attribution. + merged["account_id"] = acct + } else { + // Clear the up-front subscription claim so upsertKeeperState's CASE falls through to + // COALESCE(NULL, existing) = preserve, instead of binding a renewal date to an + // account we cannot identify. + result.SubscriptionActiveUntil = nil + result.SubscriptionKnown = false + } result.Priority = keeperIntPtr(merged["priority"]) disabled := keeperBool(merged["disabled"]) result.Disabled = &disabled @@ -2703,10 +2849,17 @@ func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map result.PrimaryWindowSeconds = usage.PrimaryWindowSeconds result.SecondaryWindowSeconds = usage.SecondaryWindowSeconds result.QuotaThreshold = &cfg.CodexKeeper.QuotaThreshold - // Best-effort reset-credit snapshot. A failed/malformed fetch leaves both - // fields nil, so upsertKeeperState preserves the previous snapshot instead of - // wiping it; a successful empty result carries count 0 and an empty list. - if count, credits, ok := a.fetchKeeperResetCredits(ctx, cfg, merged); ok { + // Best-effort reset-credit snapshot. A failed/malformed fetch leaves both fields nil, + // so upsertKeeperState preserves the previous snapshot instead of wiping it; a + // successful empty result carries count 0 and an empty list. (An identity conflict has + // already bailed out above, so this fetch only runs for a reconciled identity.) + if !accountIDKnown { + // No stable account_id → we cannot safely attribute a reset-credit snapshot to a + // resource, so we do NOT fetch it (leaving both fields nil preserves the prior + // account-scoped snapshot via the upsert's COALESCE). Flag it unavailable so a + // post-reset refresh is audited partial, never a falsely-healthy ok. + result.ResetCreditsUnavailable = true + } else if count, credits, ok := a.fetchKeeperResetCredits(ctx, cfg, merged); ok { result.ResetCreditCount = &count if encoded, err := json.Marshal(credits); err == nil { payload := string(encoded) @@ -2892,6 +3045,8 @@ func (a *App) mergeKeeperStats(stats *keeperStats, result keeperAccountResult) { stats.PriorityRestored++ case "network_error": stats.NetworkError++ + case "identity_error": + stats.IdentityError++ default: stats.Skipped++ } @@ -2912,6 +3067,7 @@ func (stats *keeperStats) add(delta keeperStats) { stats.PriorityRestored += delta.PriorityRestored stats.Skipped += delta.Skipped stats.NetworkError += delta.NetworkError + stats.IdentityError += delta.IdentityError stats.ResetCreditsUnavailable += delta.ResetCreditsUnavailable stats.StateWriteError += delta.StateWriteError } @@ -3291,13 +3447,82 @@ func keeperParseOptionalTime(value any) (*time.Time, bool) { return &parsed, true } +// keeperSubscriptionActiveUntil extracts the ChatGPT subscription renewal time +// from an auth-file list entry's parsed id_token claims (CPA's ListAuthFiles +// surfaces `id_token.chatgpt_subscription_active_until`). It is tri-state so a +// missing/malformed claim never corrupts a good stored snapshot: +// - (t, true) — claim present and parsed (Unix seconds or RFC3339 / date). +// - (nil, true) — id_token readable but the claim is confirmed absent/null: +// the account has no active subscription, so the old value may be cleared. +// - (nil, false)— UNKNOWN: id_token unreadable, or the claim is present but +// malformed (wrong type, empty, non-positive, unparseable time). The caller +// must preserve the previous snapshot rather than clearing it. +func keeperSubscriptionActiveUntil(authInfo map[string]any) (*time.Time, bool) { + idToken, ok := authInfo["id_token"].(map[string]any) + if !ok { + return nil, false + } + value, present := idToken["chatgpt_subscription_active_until"] + if !present || value == nil { + return nil, true + } + switch v := value.(type) { + case float64: + // JSON numbers arrive as float64. Require a finite, whole value: reject NaN/Inf + // and a fractional value (a truncated/garbage number). The magnitude is checked + // as float64 BEFORE the int64 conversion so an overflowing value (e.g. + // math.MaxInt64) can never wrap into a spurious in-range integer. + if math.IsNaN(v) || math.IsInf(v, 0) || v != math.Trunc(v) { + return nil, false + } + if v < keeperSubscriptionMinUnix || v > keeperSubscriptionMaxUnix { + return nil, false + } + return keeperValidateSubscriptionTime(time.Unix(int64(v), 0)) + case string: + s := strings.TrimSpace(v) + if s == "" { + return nil, false + } + for _, layout := range []string{time.RFC3339Nano, "2006-01-02T15:04:05Z07:00", "2006-01-02"} { + if t, err := time.Parse(layout, s); err == nil { + return keeperValidateSubscriptionTime(t) + } + } + if secs, err := strconv.ParseInt(s, 10, 64); err == nil { + return keeperValidateSubscriptionTime(time.Unix(secs, 0)) + } + } + return nil, false +} + +// keeperSubscription{Min,Max}Unix bound a plausible subscription renewal time +// (2000-01-01 .. 2100-01-01 UTC) so a corrupt/extreme epoch never becomes a stored +// timestamp. +const ( + keeperSubscriptionMinUnix = 946684800 // 2000-01-01T00:00:00Z + keeperSubscriptionMaxUnix = 4102444800 // 2100-01-01T00:00:00Z +) + +// keeperValidateSubscriptionTime is the single range gate every parsed subscription +// time (numeric Unix, RFC3339, or date string) must pass: it returns the UTC time only +// when it falls inside the plausible [2000, 2100] window, otherwise (nil, false). This +// keeps an extreme string date (e.g. 0001-01-01 / 9999-...) from being stored as known. +func keeperValidateSubscriptionTime(t time.Time) (*time.Time, bool) { + if secs := t.Unix(); secs < keeperSubscriptionMinUnix || secs > keeperSubscriptionMaxUnix { + return nil, false + } + utc := t.UTC() + return &utc, true +} + func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { rows, err := a.db.QueryContext(ctx, ` SELECT auth_name, email, auth_index, account_type, disabled, priority, primary_used_percent, secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), - reset_credit_count, CAST(reset_credits AS TEXT) + reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT) FROM codex_keeper_auth_states ORDER BY COALESCE(email, ''), auth_name `) @@ -3316,65 +3541,71 @@ func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { if err := rows.Err(); err != nil { return nil, err } - if err := a.mergeKeeperQuotaResetCounts(ctx, accounts); err != nil { - return nil, err - } return accounts, nil } -// mergeKeeperQuotaResetCounts fills QuotaResetCount/LastQuotaResetAt from the -// codex_keeper_quota_resets table (rows without an entry keep the zero count). -func (a *App) mergeKeeperQuotaResetCounts(ctx context.Context, accounts []keeperAccount) error { - if len(accounts) == 0 { - return nil - } - rows, err := a.db.QueryContext(ctx, `SELECT auth_name, reset_count, CAST(last_reset_at AS TEXT) FROM codex_keeper_quota_resets`) - if err != nil { - return err - } - defer rows.Close() - type resetInfo struct { - count int - lastAt *time.Time - } - counts := map[string]resetInfo{} - for rows.Next() { - var name string - var count int - var lastAt sql.NullString - if err := rows.Scan(&name, &count, &lastAt); err != nil { - return err - } - counts[name] = resetInfo{count: count, lastAt: timePtr(lastAt)} - } - if err := rows.Err(); err != nil { - return err - } - for i := range accounts { - if info, ok := counts[accounts[i].Name]; ok { - accounts[i].QuotaResetCount = info.count - accounts[i].LastQuotaResetAt = info.lastAt - } - } - return nil -} +// keeperResetOutcomeCooldownOnly is the outcome when no consume ran (no credit was +// available and no pending redeem existed) — only the local cooldown was cleared. The +// other outcomes are exactly the OpenAI terminal codes, so the caller can message each +// distinctly (already_redeemed ≠ reset; nothing_to_reset ≠ no_credit). +const keeperResetOutcomeCooldownOnly = "cooldown_only" -// resetKeeperQuota asks CLIProxyAPI to reset the quota/cooldown state of one -// auth (by its auth_index) and records the reset in the local counter table. -// The CLIProxyAPI call must succeed before the counter is incremented. -// keeperQuotaResetResult is the deliberately minimal wire shape of a reset: -// it must not leak internal keeperAccount fields (auth_index, email, errors). -type keeperQuotaResetResult struct { - Name string `json:"name"` - QuotaResetCount int `json:"quota_reset_count"` - LastQuotaResetAt *string `json:"last_quota_reset_at"` +// keeperResetPartialCode is the stable error code for an irreversible partial reset (a +// credit was consumed but the local cooldown clear then failed). The handler recognizes +// it and does NOT re-audit a generic result=error over the already-recorded partial. +const keeperResetPartialCode = "reset_partial" + +func keeperResetPartialError() *AppError { + return appError(keeperResetPartialCode, http.StatusConflict, "已消耗 1 次主动重置额度,但清理本地冷却失败;请稍后重试(系统会复用同一凭据幂等重试,不会重复消耗)") } +type keeperQuotaResetResult struct { + Name string `json:"name"` + // Outcome is one of: reset, already_redeemed, no_credit, nothing_to_reset, + // cooldown_only. It preserves the real business result without leaking internals. + Outcome string `json:"outcome"` +} + +// resetKeeperQuota performs a real reset for one auth. When the account has an +// available reset credit (reset_credit_count > 0, the authoritative +// available_count — never the possibly-truncated credits list length), it first +// redeems one credit against OpenAI via wham/rate-limit-reset-credits/consume +// (fail-closed: any transport error / non-2xx / non-ok inner status aborts the +// whole operation and never reports a redemption). It then always clears the +// local CLIProxyAPI 429 cooldown via /reset-quota. When no credit is available +// it skips the consume and only clears the cooldown. func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperQuotaResetResult, error) { cfg, err := a.loadConfig(ctx) if err != nil { return keeperQuotaResetResult{}, err } + // Dry-run means "no real side effects". A manual reset really redeems a paid credit + // and clears the cooldown, so it is fail-closed under dry-run — an admin testing the + // keeper must not silently burn a credit. Turn dry-run off to perform a real reset. + if cfg.CodexKeeper.DryRun { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "dry_run") + return keeperQuotaResetResult{}, validationError("当前为 dry-run 模式,已阻止真实核销/重置;请关闭 dry-run 后重试") + } + + // A missing runner is fail-closed rather than "proceed unlocked" — never let a serve/test + // variant bypass the concurrency guards below. + if a.keeper == nil { + return keeperQuotaResetResult{}, validationError("Keeper 未初始化,无法安全重置") + } + // Acquire the per-auth_name lock FIRST, then read state under it, so the decision never uses + // a pre-lock snapshot that a concurrent delete/rebuild could have invalidated. This lock is a + // per-FILE/state guard: it serializes operations on THIS auth_name (reset/inspect/delete/…) + // so its state row and remote credential are not mutated concurrently. It is NOT by itself the + // cross-file monetary guard — two DIFFERENT files that resolve to the same OpenAI account hold + // DIFFERENT auth_name locks; the paid-credit mutual exclusion across those routes is the + // stored-account_id fence acquired below. Non-blocking: a contended request returns a + // conflict. Released on return, BEFORE the handler's chained InspectAccountsLocked (which + // re-acquires it itself). + if !a.keeper.tryLockAuthName("reset", authName) { + return keeperQuotaResetResult{}, conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return keeperQuotaResetResult{}, err @@ -3385,47 +3616,567 @@ func (a *App) resetKeeperQuota(ctx context.Context, authName string) (keeperQuot if state.AuthIndex == nil || strings.TrimSpace(*state.AuthIndex) == "" { return keeperQuotaResetResult{}, validationError("该账号缺少 auth_index,请先刷新账号列表") } - authIndex := strings.TrimSpace(*state.AuthIndex) + // The DB auth_index is only a page snapshot for the initial existence check; it must + // NOT route any remote call (see below). + dbAuthIndex := strings.TrimSpace(*state.AuthIndex) + + // The account fence MUST be acquired before any fresh list/download resolution, so two + // different files that resolve to the SAME account cannot interleave in a pre-fence window + // (file A blocked mid-download while file B fully resolves, consumes, finishes and releases, + // then A resumes, sees a terminal ledger and mints a fresh redeem id → a SECOND consume). + // Fencing before the remote resolve requires a stable key up front, so the fence keys on the + // STORED account_id. That means the row must already carry a confirmed account_id: a legacy + // pre-account_id (NULL) row must be inspected once first (which persists its account_id) + // before it can be reset — fail closed otherwise rather than fencing on an unknown identity. + if state.AccountID == nil || strings.TrimSpace(*state.AccountID) == "" { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "account_id_unknown", "auth_index", dbAuthIndex) + return keeperQuotaResetResult{}, validationError("账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置") + } + storedAccountID := strings.TrimSpace(*state.AccountID) + + // Hold the ACCOUNT-level fence (in addition to the per-auth_name lock) across the WHOLE + // operation — resolve → pending-lookup → fetch → claim → consume → cooldown → finish. The + // paid, limited credit is the ACCOUNT's, so two different filenames/routes for the SAME + // account (rename overlap / duplicate import) must be mutually exclusive even though they + // hold different auth_name locks — otherwise the second route could redeem a second credit. + // Keying on the stored account_id and taking it BEFORE the resolve closes the pre-fence + // window. Non-blocking: a contended reset of the same account returns a conflict. + if !a.keeper.tryLockAccountID(storedAccountID) { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "account_busy", "auth_index", dbAuthIndex) + return keeperQuotaResetResult{}, conflictError("同一 OpenAI 账号的另一路由正在重置,请稍后重试") + } + defer a.keeper.unlockAccountID(storedAccountID) + + // Resolve the account identity (auth_index + account_id) FRESH from CPA and bind + // every downstream call to it. The list entry and download detail are validated + // separately: a conflicting explicit auth_index/account_id between them fails + // closed (never silently merged into a cross-row mix), there is no auth-name + // fallback for the auth_index on the reset path, and a missing account_id fails + // closed (the consume header needs it). + identity, ierr := a.resolveKeeperResetIdentity(ctx, cfg, authName) + if ierr != nil { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", keeperSafeReason(ierr), "auth_index", dbAuthIndex) + return keeperQuotaResetResult{}, ierr + } + // From here ALL remote routing (consume, /reset-quota cooldown clear) and audits use the + // FRESH auth_index — never the stale DB value — so a reindexed account is consumed AND + // cooled down on the SAME (current) index. Routing the cooldown clear with the old index + // would clear the wrong account or leave an irreversible partial. + authIndex := identity.authIndex + + // account_id is the RESOURCE identity; auth_index is only a routing selector (a CPA hash of + // the file path) and legitimately changes on a file rename/move/reorder, so it is NOT + // compared to the DB row — the fresh auth_index is used as-is for routing. What MUST match is + // the account: the FRESH account_id has to equal the STORED account_id we fenced on, else a + // stale page is acting on a swapped-out account (or the file was rebound to another account) + // and would consume the WRONG account's credit. Fail closed and ask for a refresh. + if identity.accountID != storedAccountID { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "account_id_mismatch", "auth_index", authIndex) + return keeperQuotaResetResult{}, validationError("账号身份已变化(account_id 不一致),请刷新账号列表后重试") + } + + // A normalized detail carrying ONLY the validated identity, so the fetch/consume + // never re-derive an index from a name fallback or a conflicting field. + identDetail := map[string]any{"auth_index": identity.authIndex, "account_id": identity.accountID} + + consumed := false + // Set when a real credit was redeemed: the ledger stays pending through the + // cooldown step and is finalized only after the whole operation succeeds. + var pendingRedeemID, redeemCode string + // availableCount is only meaningful (>=0) on the new-operation path; a pending + // replay does not depend on it. -1 marks "not fetched" for the audit trail. + availableCount := -1 + + // Resolve an identity-matched pending redeem FIRST. It must be replayable with its + // original key even when the count endpoint is temporarily unavailable — a lost + // first response may have consumed the last credit, and only replaying the same key + // recovers the true terminal state (already_redeemed). The fresh count gate applies + // ONLY to a brand-new operation. + redeemID, hasPending, perr := a.lookupPendingKeeperRedeem(ctx, identity.accountID) + if perr != nil { + return keeperQuotaResetResult{}, perr + } + doConsume := hasPending + if !hasPending { + // A NEW operation requires a known authoritative available_count: a NULL/stale + // stored count must not be read as a known 0, and an unknown fresh count blocks + // the operation instead of degrading to a cooldown-only clear. + count, _, ok := a.fetchKeeperResetCredits(ctx, cfg, identDetail) + if !ok { + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "credit_count_unknown", "auth_index", authIndex) + return keeperQuotaResetResult{}, validationError("无法确认可用重置额度(快照未知),请刷新后重试") + } + availableCount = count + if count > 0 { + newID, cerr := a.createKeeperRedeem(ctx, identity.accountID) + if cerr != nil { + return keeperQuotaResetResult{}, cerr + } + redeemID = newID + doConsume = true + } + } + if doConsume { + code, cerr := a.consumeKeeperResetCredit(ctx, cfg, authName, identity.authIndex, identDetail, redeemID) + if cerr != nil { + // Unknown outcome: keep the ledger pending (same redeemID) so the next + // attempt reuses it. Fail closed; never report a redemption. + a.auditKeeperOp("reset-quota", authName, "result", "error", "reason", "consume_failed", "auth_index", authIndex) + return keeperQuotaResetResult{}, cerr + } + switch code { + case keeperResetCreditCodeReset, keeperResetCreditCodeAlreadyRedeemed: + // A credit was consumed. Do NOT finalize the ledger yet — the operation is + // not done until the local cooldown is cleared. Keeping it pending lets a + // retry after a cooldown failure reuse the same id (already_redeemed) and + // never burn a second credit. + consumed = true + pendingRedeemID = redeemID + redeemCode = code + case keeperResetCreditCodeNoCredit, keeperResetCreditCodeNothingToReset: + // Nothing was redeemed (the fresh count was stale-high) — no credit is at + // risk, so finalize the ledger now. + redeemCode = code + if ferr := a.finishKeeperRedeem(ctx, identity.accountID, redeemID, code); ferr != nil { + a.auditKeeperOp("reset-redeem-ledger", authName, "result", "error", "reason", keeperSafeReason(ferr)) + } + } + } + + // Always clear the local CLIProxyAPI 429 cooldown for this auth. timeout := time.Duration(cfg.CodexKeeper.CPATimeoutSeconds) * time.Second _, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/reset-quota", nil, map[string]any{"auth_index": authIndex}, timeout) if err != nil { + if consumed { + // Irreversible partial: the credit was consumed but the cooldown clear + // failed. The ledger stays pending so a retry reuses the same id. Audit the + // partial and return a partial-coded error so the handler does NOT overwrite + // this line with a generic result=error. + a.auditKeeperOp("reset-quota", authName, "result", "partial", "reason", "cooldown_failed_after_consume", "code", redeemCode, "auth_index", authIndex) + return keeperQuotaResetResult{}, keeperResetPartialError() + } return keeperQuotaResetResult{}, err } - // A 2xx alone is not proof of a reset: require the CLIProxyAPI response to - // confirm status=ok for the exact auth_index we asked about, otherwise fail - // closed and do not count the reset. + // A 2xx alone is not proof of a reset: require the CLIProxyAPI response to confirm + // status=ok for the exact auth_index we asked about, otherwise fail closed. var cpaResult struct { Status string `json:"status"` AuthIndex string `json:"auth_index"` } - // authIndex was already trimmed before the request; require CPA to echo it - // exactly (no lenient trimming of the response) to honor the exact-match contract. if err := json.Unmarshal(payload, &cpaResult); err != nil || cpaResult.Status != "ok" || cpaResult.AuthIndex != authIndex { + if consumed { + a.auditKeeperOp("reset-quota", authName, "result", "partial", "reason", "cooldown_failed_after_consume", "code", redeemCode, "auth_index", authIndex) + return keeperQuotaResetResult{}, keeperResetPartialError() + } return keeperQuotaResetResult{}, validationError("CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)") } + // The whole operation succeeded — now finalize the redeem ledger. A ledger-write + // failure here is non-fatal: a later reset reuses the same id and OpenAI returns + // already_redeemed (idempotent). + if pendingRedeemID != "" { + if ferr := a.finishKeeperRedeem(ctx, identity.accountID, pendingRedeemID, redeemCode); ferr != nil { + a.auditKeeperOp("reset-redeem-ledger", authName, "result", "error", "reason", keeperSafeReason(ferr)) + } + } + // The outcome is the exact terminal code when a consume ran, else cooldown-only. + outcome := keeperResetOutcomeCooldownOnly + if redeemCode != "" { + outcome = redeemCode + } + a.auditKeeperOp("reset-quota", authName, "result", "ok", "outcome", outcome, "consumed", consumed, "available", availableCount, "auth_index", authIndex) + return keeperQuotaResetResult{Name: authName, Outcome: outcome}, nil +} + +// keeperResetIdentity is the validated account identity a reset acts on. +type keeperResetIdentity struct { + authIndex string + accountID string +} + +// resolveKeeperResetIdentity reads the account's list entry and download detail and +// resolves the (auth_index, account_id) to act on, validating each source's EXPLICIT +// identity separately. It never falls back to the auth name for an auth_index, fails +// closed when the two sources carry conflicting explicit identities, and fails closed +// when no explicit auth_index or no account_id can be determined. +func (a *App) resolveKeeperResetIdentity(ctx context.Context, cfg AppConfig, authName string) (keeperResetIdentity, error) { + items, err := a.listKeeperRemoteAuthFiles(ctx, cfg) + if err != nil { + return keeperResetIdentity{}, err + } + var authInfo map[string]any + matches := 0 + for _, item := range items { + if keeperString(item["name"]) == authName { + authInfo = item + matches++ + } + } + if authInfo == nil { + return keeperResetIdentity{}, validationError("账号在远端列表中不存在,请刷新后重试") + } + // A duplicate name in the remote list (a malformed/corrupt response) makes the + // "same-row binding" ambiguous — pick nothing, fail closed rather than an arbitrary row. + if matches > 1 { + return keeperResetIdentity{}, validationError("远端存在多个同名账号条目,无法安全重置,请核对后重试") + } + // listKeeperRemoteAuthFiles returns every provider. The reset consume targets + // OpenAI's Codex endpoint, so require this entry to be a Codex account — an + // auth_name reused by / drifted to another provider must never send a non-Codex + // token/index to the consume. + if keeperString(authInfo["type"]) != "codex" { + return keeperResetIdentity{}, validationError("账号不是 Codex 类型,无法主动重置") + } + detail, err := a.getKeeperRemoteAuthFile(ctx, cfg, authName) + if err != nil { + return keeperResetIdentity{}, err + } + if detail == nil { + return keeperResetIdentity{}, validationError("读取账号详情失败,未执行主动重置") + } + // If the download detail carries an explicit name, it MUST still be the account we + // asked for — a proxy misroute / mismatched response must not bind another + // credential's detail to this target. + if dn := strings.TrimSpace(keeperString(detail["name"])); dn != "" && dn != authName { + return keeperResetIdentity{}, validationError("账号详情名称不匹配,请刷新后重试") + } + // A download detail that carries an explicit, non-Codex type is a provider drift / + // list-vs-detail conflict — fail closed rather than act on it. + if dt := keeperString(detail["type"]); dt != "" && dt != "codex" { + return keeperResetIdentity{}, validationError("账号类型冲突(详情非 Codex),请刷新后重试") + } + // Require a Codex access_token in the auth JSON: the consume relies on CLIProxyAPI's + // $TOKEN$ substitution, which otherwise falls back to api_key / id_token / cookie and + // could send the wrong credential to wham. Reset is irreversible, so a missing token + // fails closed rather than depending on an upstream 401. + if strings.TrimSpace(keeperString(detail["access_token"])) == "" { + return keeperResetIdentity{}, validationError("账号缺少 access_token,无法安全核销,请刷新后重试") + } + listAuthIndex, err := keeperExplicitAuthIndex(authInfo) + if err != nil { + return keeperResetIdentity{}, validationError("列表条目 auth_index 字段自相矛盾,请刷新后重试") + } + detailAuthIndex, err := keeperExplicitAuthIndex(detail) + if err != nil { + return keeperResetIdentity{}, validationError("详情 auth_index 字段自相矛盾,请刷新后重试") + } + authIndex, err := keeperReconcileIdentityField(listAuthIndex, detailAuthIndex) + if err != nil { + return keeperResetIdentity{}, validationError("账号 auth_index 身份冲突(列表与详情不一致),请刷新后重试") + } + if authIndex == "" { + return keeperResetIdentity{}, validationError("无法确定账号 auth_index,请刷新账号列表后重试") + } + // The consume header's account_id comes from the download's account_id (top-level + // and/or id_token claim, which must agree) and is REQUIRED. The list side's account_id + // is an optional second exact cross-check when present — its absence must not block a + // legitimate older credential. + accountID, err := keeperExplicitAccountID(detail) + if err != nil { + return keeperResetIdentity{}, validationError("详情 account_id 字段自相矛盾,请刷新后重试") + } + if accountID == "" { + return keeperResetIdentity{}, validationError("账号缺少 account_id,无法安全核销,请刷新后重试") + } + listAccountID, err := keeperExplicitAccountID(authInfo) + if err != nil { + return keeperResetIdentity{}, validationError("列表条目 account_id 字段自相矛盾,请刷新后重试") + } + if listAccountID != "" && listAccountID != accountID { + return keeperResetIdentity{}, validationError("账号 account_id 身份冲突(列表与详情不一致),请刷新后重试") + } + return keeperResetIdentity{authIndex: authIndex, accountID: accountID}, nil +} + +var errKeeperIdentityConflict = errors.New("keeper identity conflict") + +// keeperReconcileIdentityField returns the agreed value of an identity field from two +// sources: when both are present they must be equal (else a conflict error); otherwise +// the present one, or "" when neither is present. +func keeperReconcileIdentityField(left, right string) (string, error) { + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + switch { + case left != "" && right != "" && left != right: + return "", errKeeperIdentityConflict + case left != "": + return left, nil + default: + return right, nil + } +} + +// keeperConsistentValue returns the single agreed non-empty value among candidates, or +// an error when two present candidates disagree. Reset is irreversible, so an object +// whose own alias fields conflict (a deceptive/corrupt entry) fails closed rather than +// silently taking a precedence winner. +func keeperConsistentValue(candidates ...string) (string, error) { + agreed := "" + for _, c := range candidates { + c = strings.TrimSpace(c) + if c == "" { + continue + } + if agreed == "" { + agreed = c + } else if agreed != c { + return "", errKeeperIdentityConflict + } + } + return agreed, nil +} + +// keeperInspectionIdentity is the per-source-validated identity for an inspection: the +// reconciled account_id and auth_index. Callers route (auth_index) and header (account_id) +// account-scoped requests using ONLY these normalized values, never re-deriving them from the +// raw right-biased merge (which can drop a list-only value or fall back to the auth name). +type keeperInspectionIdentity struct { + accountID string + authIndex string +} + +// keeperReconcileInspectionIdentity reconciles the account identity across BOTH sources +// during inspection — the list entry's explicit account_id (its id_token.chatgpt_account_id, +// the same source as the subscription claim) and auth_index, and the download detail's +// account_id and auth_index. It returns (identity, true) when each field agrees (or only one +// source has it, or neither), and ({}, false) when ANY field conflicts across sources or a +// single source is self-contradictory. A false result means the merged detail mixes two +// accounts, so the caller must not fetch/write account data from it. +func keeperReconcileInspectionIdentity(authInfo, detail map[string]any) (keeperInspectionIdentity, bool) { + listAcct, lerr := keeperExplicitAccountID(authInfo) + detailAcct, derr := keeperExplicitAccountID(detail) + listIdx, lierr := keeperExplicitAuthIndex(authInfo) + detailIdx, dierr := keeperExplicitAuthIndex(detail) + if lerr != nil || derr != nil || lierr != nil || dierr != nil { + return keeperInspectionIdentity{}, false + } + acct, rerr := keeperReconcileIdentityField(listAcct, detailAcct) + if rerr != nil { + return keeperInspectionIdentity{}, false + } + idx, ierr := keeperReconcileIdentityField(listIdx, detailIdx) + if ierr != nil { + return keeperInspectionIdentity{}, false + } + return keeperInspectionIdentity{accountID: acct, authIndex: idx}, true +} + +// keeperExplicitStringField reads an identity field that, WHEN PRESENT, must be a string. +// It distinguishes absent (key missing or null → "", nil) from present-but-invalid (present +// with a non-string type, e.g. a JSON number → errKeeperIdentityConflict). Identity fields +// gate an irreversible consume, so a present-but-wrong-type alias must fail closed rather than +// be silently ignored (keeperString maps it to "") in favor of a differently-typed sibling. +func keeperExplicitStringField(o map[string]any, key string) (string, error) { + value, present := o[key] + if !present || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", errKeeperIdentityConflict + } + return strings.TrimSpace(text), nil +} + +// keeperExplicitAuthIndex returns an object's explicit auth_index — NEVER the auth name — +// validating that its alias fields (auth_index/authIndex/index) all agree. Any alias that is +// present but not a string is illegal and fails closed (not silently skipped). +func keeperExplicitAuthIndex(o map[string]any) (string, error) { + ai, err := keeperExplicitStringField(o, "auth_index") + if err != nil { + return "", err + } + aiCamel, err := keeperExplicitStringField(o, "authIndex") + if err != nil { + return "", err + } + idx, err := keeperExplicitStringField(o, "index") + if err != nil { + return "", err + } + return keeperConsistentValue(ai, aiCamel, idx) +} + +// keeperExplicitAccountID returns an object's account_id, from the top-level field AND the +// id_token's chatgpt_account_id claim, validating they all agree. The id_token may be a map +// OR — as in CLIProxyAPI's real download auth JSON — a raw JWT string; both forms are decoded +// (keeperIDTokenClaims), so a deceptive entry with top-level account_id A but a JWT claim B is +// caught as a conflict instead of silently trusting A and mixing A's metadata with B's token. +// An id_token that is present but cannot be parsed leaves the identity INDETERMINATE, so it +// fails closed (returns a conflict) rather than trusting the un-cross-checked top-level value. +func keeperExplicitAccountID(o map[string]any) (string, error) { + top, err := keeperExplicitStringField(o, "account_id") + if err != nil { + return "", err + } + candidates := []string{top} + if raw, present := o["id_token"]; present && raw != nil { + claims := keeperIDTokenClaims(raw) + if claims == nil { + return "", errKeeperIdentityConflict + } + ids, err := keeperClaimsAccountIDs(claims) + if err != nil { + return "", err + } + candidates = append(candidates, ids...) + } + return keeperConsistentValue(candidates...) +} + +// consumeKeeperResetCredit redeems one reset credit for the given auth through +// the per-auth api-call egress (CLIProxyAPI injects the account's $TOKEN$ by +// auth_index). redeemID is the caller-owned idempotency key (from the redeem +// ledger); it is passed through so keeperRequest's internal retries AND a later +// reuse of a pending id both carry the same key, and OpenAI dedups — a lost +// response can never double-consume. +// +// It returns (terminalCode, err). A 2xx inner status alone is NOT proof of a +// redemption: the inner body's `code` decides, and only a recognized terminal +// code is returned. `reset` / `already_redeemed` mean a credit was redeemed; +// `no_credit` / `nothing_to_reset` mean nothing was redeemed (the caller still +// clears the cooldown). Any transport error, non-2xx outer status, unparseable +// body, non-ok inner status, or unrecognized code is an UNKNOWN outcome → returns +// ("", err), fails closed, and (per the ledger) keeps the redeem pending for an +// idempotent retry. No external body — not even truncated — is ever logged; the +// audit trail carries only a stable reason plus whitelisted inner status_code / +// recognized code. +func (a *App) consumeKeeperResetCredit(ctx context.Context, cfg AppConfig, authName, authIndex string, detail map[string]any, redeemID string) (string, error) { + header := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal", + } + if accountID := keeperString(detail["account_id"]); accountID != "" { + header["Chatgpt-Account-Id"] = accountID + } + body := map[string]any{ + "auth_index": authIndex, + "method": "POST", + "url": keeperResetCreditsConsumeURL, + "header": header, + "data": fmt.Sprintf(`{"redeem_request_id":%q}`, redeemID), + } + response, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/api-call", nil, body, time.Duration(cfg.CodexKeeper.UsageTimeoutSeconds)*time.Second) + if err != nil { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "transport_error") + return "", validationError("核销主动重置额度失败:网络异常,未确认是否已核销") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "management_status", "http_status", response.StatusCode) + return "", validationError("核销主动重置额度失败:管理接口异常") + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "invalid_response") + return "", validationError("核销主动重置额度失败:响应无效") + } + innerStatus := keeperInnerStatusCode(raw) + if !keeperInnerStatusOK(raw) { + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "inner_status", "status_code", innerStatus) + return "", validationError("核销主动重置额度失败:OpenAI 拒绝核销") + } + switch code := keeperString(keeperBodyJSON(raw["body"])["code"]); code { + case keeperResetCreditCodeReset, keeperResetCreditCodeAlreadyRedeemed, keeperResetCreditCodeNoCredit, keeperResetCreditCodeNothingToReset: + a.auditKeeperOp("reset-consume", authName, "result", "ok", "code", code, "status_code", innerStatus) + return code, nil + default: + // Do NOT echo the untrusted code value; log only the stable classification. + a.auditKeeperOp("reset-consume", authName, "result", "error", "reason", "unknown_code", "status_code", innerStatus) + return "", validationError("核销主动重置额度失败:响应状态未知") + } +} + +// keeperInnerStatusCode returns the api-call wrapper's inner status as an int, or +// -1 when absent/malformed. It is used only for whitelisted audit fields. +func keeperInnerStatusCode(raw map[string]any) int { + value, present := raw["status_code"] + if !present { + value = raw["statusCode"] + } + if status, ok := keeperStrictNonNegInt(value); ok { + return status + } + return -1 +} + +// lookupPendingKeeperRedeem returns the pending redeem_request_id for the OpenAI account +// accountID, if any. The ledger is keyed by the STABLE account_id (the resource identity), +// NOT by auth_name/auth_index (routing selectors that change on file rename/move/reorder), +// so a lost-response pending is still found after the account is re-routed. It is a pure +// read: it does NOT depend on the fresh credit count, so a pending can be replayed even +// when the count endpoint is temporarily unavailable. The money-critical caller (reset) MUST +// hold the per-account_id fence — the mutex that serializes same-account consume across +// different files/routes — around the lookup→consume→finish window (reset also holds the +// per-auth_name lock, but that is per-file and does not by itself serialize two files of the +// same account). +func (a *App) lookupPendingKeeperRedeem(ctx context.Context, accountID string) (redeemID string, ok bool, err error) { + var id, status string + qerr := a.db.QueryRowContext(ctx, `SELECT redeem_request_id, status FROM codex_keeper_reset_redeems WHERE account_id = ?`, accountID).Scan(&id, &status) + if qerr != nil && !errors.Is(qerr, sql.ErrNoRows) { + return "", false, qerr + } + if qerr == nil && status == keeperRedeemStatusPending && strings.TrimSpace(id) != "" { + return id, true, nil + } + return "", false, nil +} + +// createKeeperRedeem atomically claims a fresh pending redeem_request_id for the OpenAI +// account accountID and returns the WINNING row's id. Keyed by account_id, the same +// account converges on ONE key however it is routed (auth_name/auth_index may change). The +// upsert overwrites only this account's own non-pending (terminal) row; a concurrent +// pending row for the same account is left untouched, so two claimers (e.g. blue/green +// instances sharing the DB) converge on ONE request_id. SQLite serializes the writes, so +// the second upsert observes the first's committed row; within one instance the money-critical +// caller's per-account_id fence serializes same-account callers across different files/routes +// (the per-auth_name lock is per-file and does not, on its own, serialize two files that +// resolve to the same account). +// +// This HARDENS the concurrent-overlap window but is NOT a full cross-process operation +// lease: if one instance finalizes a redeem (terminal row) and a second, delayed request +// then claims, it legitimately starts a NEW operation. CPA-Helper's contract is a SINGLE +// ACTIVE INSTANCE on single-writer SQLite (no overlapping ingress during a blue/green +// swap); true cross-process single-operation idempotency would need a client-supplied +// idempotency key spanning the HTTP request, out of scope for this single-admin backend. +func (a *App) createKeeperRedeem(ctx context.Context, accountID string) (string, error) { + newID := uuid.NewString() now := dbTime(time.Now()) if _, err := a.db.ExecContext(ctx, ` - INSERT INTO codex_keeper_quota_resets (auth_name, reset_count, last_reset_at) - VALUES (?, 1, ?) - ON CONFLICT(auth_name) DO UPDATE SET - reset_count = codex_keeper_quota_resets.reset_count + 1, - last_reset_at = excluded.last_reset_at - `, authName, now); err != nil { - return keeperQuotaResetResult{}, err + INSERT INTO codex_keeper_reset_redeems (account_id, redeem_request_id, status, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(account_id) DO UPDATE SET + redeem_request_id = excluded.redeem_request_id, + status = excluded.status, + updated_at = excluded.updated_at + WHERE codex_keeper_reset_redeems.status != ? + `, accountID, newID, keeperRedeemStatusPending, now, keeperRedeemStatusPending); err != nil { + return "", err } - var count int - var lastAt sql.NullString - if err := a.db.QueryRowContext(ctx, `SELECT reset_count, CAST(last_reset_at AS TEXT) FROM codex_keeper_quota_resets WHERE auth_name = ?`, authName).Scan(&count, &lastAt); err != nil { - return keeperQuotaResetResult{}, err + // Read this account's winning row. It must now be pending — either ours (INSERT / + // terminal-overwrite won) or a concurrent claimer's (preserved by the guard). Anything + // else is a lost race we must not consume against. + var id, status string + if err := a.db.QueryRowContext(ctx, `SELECT redeem_request_id, status FROM codex_keeper_reset_redeems WHERE account_id = ?`, accountID).Scan(&id, &status); err != nil { + return "", err + } + if status != keeperRedeemStatusPending || strings.TrimSpace(id) == "" { + return "", validationError("重置额度核销状态异常,请稍后重试") } - a.auditKeeperOp("reset-quota", authName, "result", "ok", "reset_count", count, "auth_index", authIndex) - return keeperQuotaResetResult{ - Name: authName, - QuotaResetCount: count, - LastQuotaResetAt: apiDateTimePtr(timePtr(lastAt)), - }, nil + return id, nil +} + +// finishKeeperRedeem records the terminal code for a resolved redeem (by account_id) so the +// next reset mints a fresh redeem_request_id. It only updates the row when the stored +// request_id still matches, so a concurrent fresh redeem is never clobbered. +func (a *App) finishKeeperRedeem(ctx context.Context, accountID, redeemID, code string) error { + now := dbTime(time.Now()) + _, err := a.db.ExecContext(ctx, ` + UPDATE codex_keeper_reset_redeems SET status = ?, updated_at = ? + WHERE account_id = ? AND redeem_request_id = ? + `, code, now, accountID, redeemID) + return err } func (a *App) pruneKeeperMissingAuthStates(ctx context.Context, remoteNames map[string]bool) (int, error) { @@ -3453,29 +4204,42 @@ func (a *App) pruneKeeperMissingAuthStates(ctx context.Context, remoteNames map[ if len(stale) == 0 { return 0, nil } - tx, err := a.db.BeginTx(ctx, nil) - if err != nil { - return 0, err - } - defer tx.Rollback() - stmt, err := tx.PrepareContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`) - if err != nil { - return 0, err + // Prune deletes only the state row for accounts absent remotely; it never touches the + // account_id-keyed redeem ledger, so a stale/transient-empty remote list can no longer + // drop an in-flight idempotency key. It still takes the per-auth lock (skip a stale + // name a reset currently holds) so the state row is not removed mid-reset, and skips + // the whole pass when there is no runner. + if a.keeper == nil { + return 0, nil } - defer stmt.Close() pruned := 0 for _, name := range stale { - result, err := stmt.ExecContext(ctx, name) - if err != nil { - return 0, err + if !a.keeper.tryLockAuthName("prune", name) { + continue + } + affected, derr := a.deleteKeeperStateRow(ctx, name) + a.keeper.unlockAuthName(name) + if derr != nil { + return pruned, derr } - affected, _ := result.RowsAffected() - pruned += int(affected) + pruned += affected } - if err := tx.Commit(); err != nil { + return pruned, nil +} + +// deleteKeeperStateRow removes an account's state row, returning the number of rows +// deleted. It deliberately does NOT touch the redeem ledger: that ledger is keyed by the +// stable account_id, not by the auth_name/file, so deleting or pruning a file must not +// drop the account's in-flight idempotency key — if the account is re-imported (any +// filename) the pending redeem is still replayed. Callers needing the per-auth fence hold +// it around this call. +func (a *App) deleteKeeperStateRow(ctx context.Context, authName string) (int, error) { + res, err := a.db.ExecContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`, authName) + if err != nil { return 0, err } - return pruned, nil + affected, _ := res.RowsAffected() + return int(affected), nil } func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState, error) { @@ -3484,7 +4248,7 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), - reset_credit_count, CAST(reset_credits AS TEXT) + reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT) FROM codex_keeper_auth_states WHERE auth_name = ? `, name) if err != nil { @@ -3503,13 +4267,13 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthState, error) { var state keeperAuthState - var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt, resetCredits sql.NullString + var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt, resetCredits, subscriptionActiveUntil, accountID sql.NullString var priority, primaryUsed, secondaryUsed, quotaThreshold, lastStatus, primaryWindowSeconds, secondaryWindowSeconds, restorePriority, resetCreditCount sql.NullInt64 err := scanner.Scan( &state.Name, &email, &authIndex, &accountType, &state.Disabled, &priority, &primaryUsed, &secondaryUsed, &primaryReset, &secondaryReset, "aThreshold, &lastStatus, &lastError, &latestAction, &lastChecked, &lastHealthy, &primaryWindowSeconds, &secondaryWindowSeconds, &restorePriority, - &createdAt, &updatedAt, &resetCreditCount, &resetCredits, + &createdAt, &updatedAt, &resetCreditCount, &resetCredits, &subscriptionActiveUntil, &accountID, ) if err != nil { return keeperAuthState{}, err @@ -3533,6 +4297,8 @@ func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthSt state.RestorePriority = nullableInt(restorePriority) state.ResetCreditCount = nullableInt(resetCreditCount) state.ResetCredits = parseStoredKeeperResetCredits(resetCredits) + state.SubscriptionActiveUntil = timePtr(subscriptionActiveUntil) + state.AccountID = nullableString(accountID) if parsed, ok := parseDBTime(createdAt.String); ok { state.CreatedAt = parsed } @@ -3554,10 +4320,13 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) auth_name, email, auth_index, account_type, disabled, priority, restore_priority, latest_action, last_error, last_status_code, primary_used_percent, secondary_used_percent, quota_threshold, primary_reset_at, secondary_reset_at, primary_window_seconds, secondary_window_seconds, - reset_credit_count, reset_credits, + reset_credit_count, reset_credits, subscription_active_until, account_id, last_checked_at, last_healthy_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(auth_name) DO UPDATE SET + -- Track the current account identity, keeping the old value only on a + -- fetch where no account_id was observed. + account_id = COALESCE(excluded.account_id, codex_keeper_auth_states.account_id), email = excluded.email, auth_index = excluded.auth_index, account_type = excluded.account_type, @@ -3578,27 +4347,67 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) secondary_reset_at = excluded.secondary_reset_at, primary_window_seconds = excluded.primary_window_seconds, secondary_window_seconds = excluded.secondary_window_seconds, - -- Preserve-on-failed-fetch, scoped to auth identity. The snapshot is kept - -- (COALESCE) when the incoming auth_index is NULL (identity unknown — e.g. - -- a transient auth-file read failure on the same account must not drop the - -- schedule) OR still matches the stored one. Only a KNOWN, DIFFERENT - -- incoming auth_index (a genuine reassignment of auth_name to another - -- account) falls to ELSE and writes the incoming value, clearing the old - -- account's stale credits so they never surface on the new identity's row. + -- Preserve-on-failed-fetch, scoped to ACCOUNT identity (account_id) — CPA's file + -- auth_index is a hash of provider+path and stays the same when a filename is + -- swapped to a different account, so auth_index alone can't detect a swap: + -- 1. identity unconfirmed (auth_index NULL, detail read failed) → preserve. + -- 2. confirmed account SWAP (both account_ids known and DIFFERENT) → write the + -- incoming value, clearing the old account's stale credits (a failed fetch + -- writes NULL rather than inheriting the previous account's count/schedule). + -- 3. same/undeterminable account → COALESCE: write a fresh fetch, else preserve. reset_credit_count = CASE - WHEN excluded.auth_index IS NULL OR codex_keeper_auth_states.auth_index = excluded.auth_index - THEN COALESCE(excluded.reset_credit_count, codex_keeper_auth_states.reset_credit_count) - ELSE excluded.reset_credit_count + WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.reset_credit_count + WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL + AND codex_keeper_auth_states.account_id <> excluded.account_id + THEN excluded.reset_credit_count + ELSE COALESCE(excluded.reset_credit_count, codex_keeper_auth_states.reset_credit_count) END, reset_credits = CASE - WHEN excluded.auth_index IS NULL OR codex_keeper_auth_states.auth_index = excluded.auth_index - THEN COALESCE(excluded.reset_credits, codex_keeper_auth_states.reset_credits) - ELSE excluded.reset_credits + WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.reset_credits + WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL + AND codex_keeper_auth_states.account_id <> excluded.account_id + THEN excluded.reset_credits + ELSE COALESCE(excluded.reset_credits, codex_keeper_auth_states.reset_credits) + END, + -- Tri-state, scoped to ACCOUNT identity (account_id), because CPA's file + -- auth_index is a hash of provider+path and stays the same when a filename is + -- swapped to a different OpenAI account: + -- 1. known claim + confirmed identity (auth_index present) → write authoritatively. + -- 2. identity unconfirmed this inspection (auth_index NULL, detail read failed) + -- → preserve the previous snapshot (transient failure, not a change). + -- 3. confirmed account SWAP (both account_ids known and DIFFERENT) → write the + -- incoming value so the new account never inherits the old renewal date. + -- 4. otherwise (same/undeterminable account) → preserve on an unknown claim. + subscription_active_until = CASE + WHEN ? AND excluded.auth_index IS NOT NULL THEN excluded.subscription_active_until + WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.subscription_active_until + WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL + AND codex_keeper_auth_states.account_id <> excluded.account_id + THEN excluded.subscription_active_until + ELSE COALESCE(excluded.subscription_active_until, codex_keeper_auth_states.subscription_active_until) END, last_checked_at = excluded.last_checked_at, last_healthy_at = COALESCE(excluded.last_healthy_at, codex_keeper_auth_states.last_healthy_at), updated_at = excluded.updated_at - `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, result.ResetCreditCount, result.ResetCredits, checkedAt, lastHealthy, now, now, result.ClearRestorePriority) + `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, result.ResetCreditCount, result.ResetCredits, dbTimePtr(result.SubscriptionActiveUntil), result.AccountID, checkedAt, lastHealthy, now, now, result.ClearRestorePriority, boolValue(&result.SubscriptionKnown)) + return err +} + +// markKeeperIdentityError records an identity-conflict inspection outcome WITHOUT touching +// any business column. A list/detail identity conflict means the merged detail mixes two +// accounts, so the incoming email/auth_index/account_id/account_type/disabled/priority/usage/ +// quota/reset-credit/subscription values are untrustworthy; the prior snapshot is authoritative +// and must survive intact (a cleared auth_index would also block a later reset). This only +// stamps last_error/latest_action and the check time. It deliberately does NOT set +// last_healthy_at (an identity conflict is never a healthy refresh) and does NOT INSERT: if no +// row exists yet it touches zero rows rather than persisting a partial/ambiguous identity. +func (a *App) markKeeperIdentityError(ctx context.Context, authName string, message *string, checkedAt time.Time) error { + now := dbTime(time.Now()) + _, err := a.db.ExecContext(ctx, ` + UPDATE codex_keeper_auth_states + SET last_error = ?, latest_action = ?, last_checked_at = ?, updated_at = ? + WHERE auth_name = ? + `, message, message, dbTime(checkedAt), now, authName) return err } @@ -3607,6 +4416,18 @@ func (a *App) setKeeperAccountDisabled(ctx context.Context, authName string, dis if err != nil { return err } + // Share the per-auth fence: an enable/disable must not change the remote credential + // or DB identity while a reset holds the lock mid-consume (it would drift the row the + // consume/cooldown is bound to). This is handler-only; processKeeperAuth uses the + // lock-free setKeeperRemoteDisabled helper, so there is no self-conflict. + if a.keeper == nil { + return validationError("Keeper 未初始化,无法安全操作") + } + if !a.keeper.tryLockAuthName("toggle", authName) { + return conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return err @@ -3647,6 +4468,19 @@ func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { if err != nil { return err } + // Delete shares the per-auth fence with reset/inspection so it never removes the state + // row while a reset holds the lock mid-consume. It does NOT touch the redeem ledger: + // that ledger is keyed by the stable account_id, so deleting the file cannot drop the + // account's in-flight key (a re-import under any filename still replays it). Fail closed + // if the runner is missing. + if a.keeper == nil { + return validationError("Keeper 未初始化,无法安全删除") + } + if !a.keeper.tryLockAuthName("delete", authName) { + return conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return err @@ -3657,7 +4491,7 @@ func (a *App) deleteKeeperAccount(ctx context.Context, authName string) error { if err := a.deleteKeeperRemoteAuthFile(ctx, cfg, authName); err != nil { return err } - if _, err = a.db.ExecContext(ctx, `DELETE FROM codex_keeper_auth_states WHERE auth_name = ?`, authName); err != nil { + if _, err := a.deleteKeeperStateRow(ctx, authName); err != nil { return err } a.auditKeeperOp("delete", authName, "result", "ok") @@ -3692,6 +4526,16 @@ func (a *App) updateKeeperAccountPriority(ctx context.Context, authName string, if err != nil { return err } + // Share the per-auth fence (handler-only; processKeeperAuth uses the lock-free + // setKeeperRemotePriority helper, so no self-conflict). + if a.keeper == nil { + return validationError("Keeper 未初始化,无法安全操作") + } + if !a.keeper.tryLockAuthName("priority", authName) { + return conflictError("账号正在巡检或重置中,请稍后重试") + } + defer a.keeper.unlockAuthName(authName) + state, err := a.getKeeperState(ctx, authName) if err != nil { return err @@ -3982,6 +4826,32 @@ func keeperIDTokenPlanValues(value any) []string { return values } +// keeperClaimsAccountIDs pulls chatgpt_account_id from id_token claims, from BOTH the top +// level and the OpenAI auth namespace claim (https://api.openai.com/auth) where OpenAI's real +// id_token nests it. Both are returned so keeperConsistentValue validates they agree with each +// other and with the object's top-level account_id — any disagreement is an identity conflict. +// A present-but-wrong-type claim (a non-string account id, or a namespace that is present but +// not an object) is illegal and fails closed rather than being silently ignored. +func keeperClaimsAccountIDs(claims map[string]any) ([]string, error) { + top, err := keeperExplicitStringField(claims, "chatgpt_account_id") + if err != nil { + return nil, err + } + ids := []string{top} + if ns, present := claims["https://api.openai.com/auth"]; present && ns != nil { + auth, ok := ns.(map[string]any) + if !ok { + return nil, errKeeperIdentityConflict + } + nested, err := keeperExplicitStringField(auth, "chatgpt_account_id") + if err != nil { + return nil, err + } + ids = append(ids, nested) + } + return ids, nil +} + func keeperIDTokenClaims(value any) map[string]any { switch typed := value.(type) { case nil: diff --git a/backend/internal/app/codex_keeper_internal_test.go b/backend/internal/app/codex_keeper_internal_test.go index 0a89e7c3..c32267df 100644 --- a/backend/internal/app/codex_keeper_internal_test.go +++ b/backend/internal/app/codex_keeper_internal_test.go @@ -2263,8 +2263,8 @@ func keeperWebsocketUsageSuccessPayload(usedPercent int) map[string]any { // resetCreditSnapshotJSON is a single valid projected reset credit for identity tests. const resetCreditSnapshotJSON = `[{"id":"c1","reset_type":"codex_rate_limits","status":"available","granted_at":"2026-08-22T00:08:46.146320Z","expires_at":"2026-09-21T00:08:46.146320Z"}]` -func healthyResetResult(name, authIndex string, count *int, credits *string) keeperAccountResult { - return keeperAccountResult{ +func healthyResetResult(name, authIndex, accountID string, count *int, credits *string) keeperAccountResult { + r := keeperAccountResult{ Name: name, Result: "healthy", AuthIndex: stringPtr(authIndex), @@ -2272,13 +2272,17 @@ func healthyResetResult(name, authIndex string, count *int, credits *string) kee ResetCreditCount: count, ResetCredits: credits, } + if accountID != "" { + r.AccountID = stringPtr(accountID) + } + return r } // TestUpsertKeeperStateClearsResetCreditsOnIdentityChange pins the identity -// boundary: when an auth_name is reassigned a new auth_index and the new account's -// reset-credit fetch fails (nil count/credits), the previous identity's snapshot -// must NOT be preserved by COALESCE — it must be cleared so the wrong account's -// schedule never surfaces on the new index's row. +// boundary: when an auth_name is rebound to a different ACCOUNT (a new account_id) +// and the new account's reset-credit fetch fails (nil count/credits), the previous +// account's snapshot must NOT be preserved by COALESCE — it must be cleared so the +// wrong account's schedule never surfaces on the new identity's row. func TestUpsertKeeperStateClearsResetCreditsOnIdentityChange(t *testing.T) { t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) app, err := New() @@ -2290,7 +2294,7 @@ func TestUpsertKeeperStateClearsResetCreditsOnIdentityChange(t *testing.T) { // idx-1 inspects healthy with a populated reset-credit snapshot. two := 2 - if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-1", "acct-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { t.Fatalf("upsert idx-1: %v", err) } state, err := app.getKeeperState(ctx, "reused.json") @@ -2302,7 +2306,7 @@ func TestUpsertKeeperStateClearsResetCreditsOnIdentityChange(t *testing.T) { } // Same auth_name reassigned to idx-2; the new identity's fetch failed (nil). - if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-2", nil, nil)); err != nil { + if err := app.upsertKeeperState(ctx, healthyResetResult("reused.json", "idx-2", "acct-2", nil, nil)); err != nil { t.Fatalf("upsert idx-2: %v", err) } state, err = app.getKeeperState(ctx, "reused.json") @@ -2333,11 +2337,11 @@ func TestUpsertKeeperStatePreservesResetCreditsOnSameIdentity(t *testing.T) { ctx := context.Background() two := 2 - if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", "acct-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { t.Fatalf("upsert first: %v", err) } // Same identity, failed fetch (nil count/credits). - if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", nil, nil)); err != nil { + if err := app.upsertKeeperState(ctx, healthyResetResult("stable.json", "idx-1", "acct-1", nil, nil)); err != nil { t.Fatalf("upsert second: %v", err) } state, err := app.getKeeperState(ctx, "stable.json") @@ -2365,7 +2369,7 @@ func TestUpsertKeeperStatePreservesResetCreditsOnUnknownIdentity(t *testing.T) { ctx := context.Background() two := 2 - if err := app.upsertKeeperState(ctx, healthyResetResult("same.json", "idx-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { + if err := app.upsertKeeperState(ctx, healthyResetResult("same.json", "idx-1", "acct-1", &two, stringPtr(resetCreditSnapshotJSON))); err != nil { t.Fatalf("upsert idx-1: %v", err) } // A transport/404 failure on the auth-file read: nil AuthIndex, network_error. @@ -2650,10 +2654,10 @@ func TestKeeperResetCreditsFetchFailureFlagged(t *testing.T) { // (e.g. ResetCreditsUnavailable) is not silently dropped by the generalized // aggregation. Every field is given a distinct value and must sum. func TestKeeperStatsAddSumsEveryField(t *testing.T) { - base := keeperStats{Total: 1, Healthy: 2, StatusDisabled: 3, StatusEnabled: 4, PriorityDegraded: 5, PriorityRestored: 6, Skipped: 7, NetworkError: 8, ResetCreditsUnavailable: 9, StateWriteError: 11} - delta := keeperStats{Total: 10, Healthy: 20, StatusDisabled: 30, StatusEnabled: 40, PriorityDegraded: 50, PriorityRestored: 60, Skipped: 70, NetworkError: 80, ResetCreditsUnavailable: 90, StateWriteError: 110} + base := keeperStats{Total: 1, Healthy: 2, StatusDisabled: 3, StatusEnabled: 4, PriorityDegraded: 5, PriorityRestored: 6, Skipped: 7, NetworkError: 8, IdentityError: 12, ResetCreditsUnavailable: 9, StateWriteError: 11} + delta := keeperStats{Total: 10, Healthy: 20, StatusDisabled: 30, StatusEnabled: 40, PriorityDegraded: 50, PriorityRestored: 60, Skipped: 70, NetworkError: 80, IdentityError: 120, ResetCreditsUnavailable: 90, StateWriteError: 110} base.add(delta) - want := keeperStats{Total: 11, Healthy: 22, StatusDisabled: 33, StatusEnabled: 44, PriorityDegraded: 55, PriorityRestored: 66, Skipped: 77, NetworkError: 88, ResetCreditsUnavailable: 99, StateWriteError: 121} + want := keeperStats{Total: 11, Healthy: 22, StatusDisabled: 33, StatusEnabled: 44, PriorityDegraded: 55, PriorityRestored: 66, Skipped: 77, NetworkError: 88, IdentityError: 132, ResetCreditsUnavailable: 99, StateWriteError: 121} if base != want { t.Fatalf("add sum = %+v, want %+v", base, want) } @@ -2766,3 +2770,936 @@ func TestKeeperResetInspectStateWriteFailure(t *testing.T) { t.Fatal("expected a stable state_write_error marker in the Keeper log") } } + +// TestKeeperSubscriptionActiveUntil pins the tri-state contract: a parsed value +// is known, a confirmed-absent claim is known-and-nil (clears the stored value), +// and an unreadable/malformed claim is unknown (preserves the stored value). +func TestKeeperSubscriptionActiveUntil(t *testing.T) { + idToken := func(m map[string]any) map[string]any { + return map[string]any{"id_token": m} + } + want := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + cases := []struct { + name string + authInfo map[string]any + wantTime *time.Time + wantKnown bool + }{ + {"unix-seconds", idToken(map[string]any{"chatgpt_subscription_active_until": float64(want.Unix())}), &want, true}, + {"rfc3339", idToken(map[string]any{"chatgpt_subscription_active_until": "2026-10-01T00:00:00Z"}), &want, true}, + {"date-only", idToken(map[string]any{"chatgpt_subscription_active_until": "2026-10-01"}), &want, true}, + {"unix-string", idToken(map[string]any{"chatgpt_subscription_active_until": "1790812800"}), &want, true}, + // Confirmed absent: id_token readable but no claim -> known, nil (clear). + {"claim-absent", idToken(map[string]any{"plan_type": "pro"}), nil, true}, + {"claim-null", idToken(map[string]any{"chatgpt_subscription_active_until": nil}), nil, true}, + // Unknown: unreadable id_token or malformed claim -> preserve. + {"no-id-token", map[string]any{"name": "x"}, nil, false}, + {"id-token-not-map", map[string]any{"id_token": "raw.jwt.string"}, nil, false}, + {"empty-string", idToken(map[string]any{"chatgpt_subscription_active_until": ""}), nil, false}, + {"non-positive", idToken(map[string]any{"chatgpt_subscription_active_until": float64(0)}), nil, false}, + {"garbage-string", idToken(map[string]any{"chatgpt_subscription_active_until": "not-a-time"}), nil, false}, + {"wrong-type", idToken(map[string]any{"chatgpt_subscription_active_until": true}), nil, false}, + // Strict numeric: fractional, non-finite, and out-of-range epochs are rejected. + {"fractional", idToken(map[string]any{"chatgpt_subscription_active_until": float64(want.Unix()) + 0.5}), nil, false}, + {"nan", idToken(map[string]any{"chatgpt_subscription_active_until": math.NaN()}), nil, false}, + {"positive-inf", idToken(map[string]any{"chatgpt_subscription_active_until": math.Inf(1)}), nil, false}, + {"negative-inf", idToken(map[string]any{"chatgpt_subscription_active_until": math.Inf(-1)}), nil, false}, + {"below-range", idToken(map[string]any{"chatgpt_subscription_active_until": float64(100)}), nil, false}, // ~1970 + {"above-range", idToken(map[string]any{"chatgpt_subscription_active_until": float64(5000000000)}), nil, false}, // ~2128 + {"max-int64", idToken(map[string]any{"chatgpt_subscription_active_until": float64(math.MaxInt64)}), nil, false}, + {"negative", idToken(map[string]any{"chatgpt_subscription_active_until": float64(-1)}), nil, false}, + {"fractional-explicit", idToken(map[string]any{"chatgpt_subscription_active_until": float64(1700000000.5)}), nil, false}, + {"unix-string-out-of-range", idToken(map[string]any{"chatgpt_subscription_active_until": "100"}), nil, false}, + // String date forms are range-gated too, not just the numeric path. + {"rfc3339-year-0001", idToken(map[string]any{"chatgpt_subscription_active_until": "0001-01-01T00:00:00Z"}), nil, false}, + {"rfc3339-year-9999", idToken(map[string]any{"chatgpt_subscription_active_until": "9999-12-31T23:59:59Z"}), nil, false}, + {"date-year-1000", idToken(map[string]any{"chatgpt_subscription_active_until": "1000-01-01"}), nil, false}, + {"date-year-2500", idToken(map[string]any{"chatgpt_subscription_active_until": "2500-01-01"}), nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, known := keeperSubscriptionActiveUntil(tc.authInfo) + if known != tc.wantKnown { + t.Fatalf("known = %v, want %v", known, tc.wantKnown) + } + switch { + case tc.wantTime == nil && got != nil: + t.Fatalf("time = %v, want nil", got) + case tc.wantTime != nil && (got == nil || !got.Equal(*tc.wantTime)): + t.Fatalf("time = %v, want %v", got, tc.wantTime) + } + }) + } +} + +// TestUpsertSubscriptionPreservedWhenIdentityUnconfirmed proves the subscription write is +// gated on a confirmed identity: a later inspection whose detail read failed (AuthIndex +// nil) must NOT overwrite/clear the stored renewal time even though SubscriptionKnown was +// set early from the list claim. +func TestUpsertSubscriptionPreservedWhenIdentityUnconfirmed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + idx := "idx-sub" + known := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + // A confirmed inspection stores a known subscription renewal time. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "sub.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, SubscriptionActiveUntil: &known, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("first upsert: %v", err) + } + + // A later inspection whose DETAIL read failed: identity is unconfirmed (AuthIndex + // nil) but SubscriptionKnown is still true. The stored value must be preserved. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "sub.json", Result: "error", CheckedAt: time.Now(), + AuthIndex: nil, SubscriptionActiveUntil: nil, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("second upsert: %v", err) + } + + st, err := app.getKeeperState(ctx, "sub.json") + if err != nil { + t.Fatalf("get: %v", err) + } + if st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(known) { + t.Fatalf("subscription not preserved on unconfirmed identity: got %v, want %v", st.SubscriptionActiveUntil, known) + } +} + +// TestCreateKeeperRedeemConvergesAcrossApps proves the DB-atomic claim is truly +// cross-process, not just in-process: two App instances with independent DB handles on +// the SAME SQLite file concurrently claim a fresh redeem for the same identity and both +// converge on ONE winner request_id (so OpenAI dedups a single logical key). +func TestCreateKeeperRedeemConvergesAcrossApps(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app1, err := New() + if err != nil { + t.Fatalf("New() app1: %v", err) + } + defer app1.Close() + // Same data dir → same SQLite file, but a distinct *sql.DB handle (a second process). + app2, err := New() + if err != nil { + t.Fatalf("New() app2: %v", err) + } + defer app2.Close() + + ctx := context.Background() + var wg sync.WaitGroup + ids := make([]string, 2) + errs := make([]error, 2) + apps := []*App{app1, app2} + wg.Add(2) + for i := range apps { + go func(i int) { + defer wg.Done() + ids[i], errs[i] = apps[i].createKeeperRedeem(ctx, "acct-shared") + }(i) + } + wg.Wait() + for i, err := range errs { + if err != nil { + t.Fatalf("app%d claim: %v", i+1, err) + } + } + if ids[0] == "" || ids[0] != ids[1] { + t.Fatalf("cross-process claims did not converge on one request_id: %v", ids) + } +} + +// keeperInsertPendingRedeem seeds a pending redeem ledger row (keyed by account_id). +func keeperInsertPendingRedeem(t *testing.T, app *App, accountID, id string) { + t.Helper() + if _, err := app.db.ExecContext(context.Background(), + `INSERT INTO codex_keeper_reset_redeems (account_id, redeem_request_id, status, updated_at) VALUES (?, ?, 'pending', '2026-01-01 00:00:00')`, + accountID, id); err != nil { + t.Fatalf("seed pending redeem: %v", err) + } +} + +func keeperInsertStateRow(t *testing.T, app *App, authName string) { + t.Helper() + if err := app.upsertKeeperState(context.Background(), keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), + }); err != nil { + t.Fatalf("seed state row: %v", err) + } +} + +// TestPruneKeepsAccountLedger proves prune deletes an absent account's STATE row but never +// touches the account_id-keyed redeem ledger, so a pending idempotency key survives a +// transient/empty remote list and the account can replay it after re-import. +func TestPruneKeepsAccountLedger(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + keeperInsertStateRow(t, app, "gone.json") + keeperInsertPendingRedeem(t, app, "acct-gone", "rid-gone") + + // A transient/empty remote list marks the account stale. + pruned, err := app.pruneKeeperMissingAuthStates(ctx, map[string]bool{}) + if err != nil { + t.Fatalf("prune: %v", err) + } + if pruned != 1 { + t.Fatalf("pruned = %d, want 1", pruned) + } + if st, _ := app.getKeeperState(ctx, "gone.json"); st != nil { + t.Fatal("stale account state should have been pruned") + } + // The account_id-keyed ledger row must survive the prune. + if id, ok, _ := app.lookupPendingKeeperRedeem(ctx, "acct-gone"); !ok || id != "rid-gone" { + t.Fatalf("prune dropped the account's ledger key: got (%q,%v), want (rid-gone,true)", id, ok) + } +} + +// TestPruneFailsClosedWithoutRunner proves prune deletes nothing when there is no runner +// (no per-auth fence), rather than proceeding unlocked. +func TestPruneFailsClosedWithoutRunner(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + keeperInsertStateRow(t, app, "orphan.json") + app.keeper = nil // simulate a maintenance/test variant without a runner + + pruned, err := app.pruneKeeperMissingAuthStates(ctx, map[string]bool{}) + if err != nil { + t.Fatalf("prune: %v", err) + } + if pruned != 0 { + t.Fatalf("prune without a runner deleted %d rows; must fail closed", pruned) + } + if st, _ := app.getKeeperState(ctx, "orphan.json"); st == nil { + t.Fatal("prune without a fence deleted state; must skip") + } +} + +// TestDeleteStateRowKeepsLedger proves deleting a file's state row never drops the +// account_id-keyed redeem ledger, so a re-import (any filename) still replays the pending +// key rather than minting a new one. +func TestDeleteStateRowKeepsLedger(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + keeperInsertStateRow(t, app, "del.json") + keeperInsertPendingRedeem(t, app, "acct-del", "rid-del") + + if _, err := app.deleteKeeperStateRow(ctx, "del.json"); err != nil { + t.Fatalf("delete state row: %v", err) + } + if st, _ := app.getKeeperState(ctx, "del.json"); st != nil { + t.Fatal("state row was not deleted") + } + if id, ok, _ := app.lookupPendingKeeperRedeem(ctx, "acct-del"); !ok || id != "rid-del" { + t.Fatalf("delete dropped the account's ledger key: got (%q,%v), want (rid-del,true)", id, ok) + } +} + +// TestUpsertSubscriptionClearedOnAccountSwapSameIndex proves the subscription snapshot is +// scoped to the ACCOUNT, not just auth_index: when the same auth_name+auth_index is swapped +// to a different account_id and the new claim is unknown, the previous account's renewal +// date is NOT inherited (it is cleared); the same-account unknown case still preserves. +func TestUpsertSubscriptionClearedOnAccountSwapSameIndex(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + idx := "idx-fixed" + acctA := "acct-A" + acctB := "acct-B" + known := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + + // Account A stores a known renewal date. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "swap.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, SubscriptionActiveUntil: &known, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("seed A: %v", err) + } + + // Same auth_index, same account, unknown claim → preserve. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "swap.json", Result: "error", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, SubscriptionActiveUntil: nil, SubscriptionKnown: false, + }); err != nil { + t.Fatalf("same-account unknown: %v", err) + } + if st, _ := app.getKeeperState(ctx, "swap.json"); st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(known) { + t.Fatalf("same-account unknown must preserve; got %v", st.SubscriptionActiveUntil) + } + + // Same auth_index but the file now backs a DIFFERENT account, unknown claim → clear + // (must not inherit A's renewal date). + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "swap.json", Result: "error", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctB, SubscriptionActiveUntil: nil, SubscriptionKnown: false, + }); err != nil { + t.Fatalf("swap unknown: %v", err) + } + if st, _ := app.getKeeperState(ctx, "swap.json"); st.SubscriptionActiveUntil != nil { + t.Fatalf("account swap must clear the inherited renewal date; got %v", st.SubscriptionActiveUntil) + } +} + +// TestUpsertResetCreditClearedOnAccountSwapSameIndex proves the reset-credit snapshot is +// scoped to the ACCOUNT (account_id), not just auth_index: when the same auth_name+index is +// swapped to a different account and the new fetch failed, the old account's count/credits +// are cleared (not inherited); the same-account failed fetch still preserves. +func TestUpsertResetCreditClearedOnAccountSwapSameIndex(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + idx := "idx-rc" + acctA := "acct-A" + acctB := "acct-B" + count := 2 + credits := `[{"id":"c1","reset_type":"codex_rate_limits","status":"available"}]` + + // Account A stores a reset-credit snapshot. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "rc.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, ResetCreditCount: &count, ResetCredits: &credits, + }); err != nil { + t.Fatalf("seed A: %v", err) + } + + // Same account, fetch failed (nil snapshot) → preserve. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "rc.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctA, ResetCreditCount: nil, ResetCredits: nil, + }); err != nil { + t.Fatalf("same-account failed fetch: %v", err) + } + if st, _ := app.getKeeperState(ctx, "rc.json"); st.ResetCreditCount == nil || *st.ResetCreditCount != 2 { + t.Fatalf("same-account failed fetch must preserve count; got %v", st.ResetCreditCount) + } + + // Same auth_index, DIFFERENT account, fetch failed → clear (do not inherit). + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "rc.json", Result: "healthy", CheckedAt: time.Now(), + AuthIndex: &idx, AccountID: &acctB, ResetCreditCount: nil, ResetCredits: nil, + }); err != nil { + t.Fatalf("swap failed fetch: %v", err) + } + st, _ := app.getKeeperState(ctx, "rc.json") + if st.ResetCreditCount != nil { + t.Fatalf("account swap must clear the inherited reset-credit count; got %v", *st.ResetCreditCount) + } + if len(st.ResetCredits) != 0 { + t.Fatalf("account swap must clear the inherited reset-credit list; got %v", st.ResetCredits) + } +} + +// TestKeeperReconcileInspectionAccountID pins the cross-source identity reconciliation: +// the list id_token.chatgpt_account_id and the download account_id must AGREE (or only one +// present) to be trusted; a conflict — or a single source that is self-contradictory — +// yields (·, false) so the caller treats the identity as unknown. +func TestKeeperReconcileInspectionIdentity(t *testing.T) { + idTokenAcct := func(id string) map[string]any { + return map[string]any{"id_token": map[string]any{"chatgpt_account_id": id}} + } + cases := []struct { + name string + authInfo map[string]any + detail map[string]any + wantID string + wantOK bool + }{ + {"agree", idTokenAcct("acct-A"), map[string]any{"account_id": "acct-A"}, "acct-A", true}, + {"list-only", idTokenAcct("acct-A"), map[string]any{}, "acct-A", true}, + {"detail-only", map[string]any{}, map[string]any{"account_id": "acct-B"}, "acct-B", true}, + {"neither", map[string]any{}, map[string]any{}, "", true}, + {"account-conflict", idTokenAcct("acct-A"), map[string]any{"account_id": "acct-B"}, "", false}, + // A single source self-contradicting (top-level vs id_token claim) is also untrusted. + {"detail-self-conflict", map[string]any{}, map[string]any{"account_id": "acct-B", "id_token": map[string]any{"chatgpt_account_id": "acct-C"}}, "", false}, + // auth_index conflict alone (accounts agree) is also untrusted. + {"authindex-conflict", map[string]any{"auth_index": "idx-A", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, map[string]any{"auth_index": "idx-B", "account_id": "acct-A"}, "", false}, + // account AND auth_index agree. + {"both-agree", map[string]any{"auth_index": "idx-A", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, map[string]any{"auth_index": "idx-A", "account_id": "acct-A"}, "acct-A", true}, + // A raw JWT id_token string (CLIProxyAPI's real download form) is decoded and its + // chatgpt_account_id claim cross-checked against the top-level account_id. + {"rawjwt-agree", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"chatgpt_account_id": "acct-A"})}, "acct-A", true}, + // The deceptive case: top-level account_id A but a raw JWT claim B → conflict, untrusted. + {"rawjwt-conflict", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"chatgpt_account_id": "acct-B"})}, "", false}, + // The claim nested under the OpenAI auth namespace is also cross-checked. + {"rawjwt-namespace-conflict", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-B"}})}, "", false}, + // An id_token that is present but unparseable leaves the identity indeterminate → fail closed. + {"idtoken-unparseable", map[string]any{}, map[string]any{"account_id": "acct-A", "id_token": "not-a-jwt"}, "", false}, + // A present-but-wrong-type identity alias must fail closed, not be silently ignored in + // favor of a differently-typed sibling. + {"authindex-wrong-type", map[string]any{"id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, map[string]any{"account_id": "acct-A", "auth_index": float64(123), "authIndex": "idx-A"}, "", false}, + {"account-id-wrong-type", map[string]any{}, map[string]any{"account_id": float64(123), "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, "", false}, + // The JWT auth namespace present but not an object is illegal. + {"jwt-namespace-not-object", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": "not-object"})}, "", false}, + // The nested claim present but not a string is illegal. + {"jwt-nested-claim-wrong-type", map[string]any{"account_id": "acct-A"}, map[string]any{"account_id": "acct-A", "id_token": keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": float64(7)}})}, "", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := keeperReconcileInspectionIdentity(tc.authInfo, tc.detail) + if ok != tc.wantOK || got.accountID != tc.wantID { + t.Fatalf("reconcile = (%q,%v), want (%q,%v)", got.accountID, ok, tc.wantID, tc.wantOK) + } + }) + } +} + +// keeperTestJWT builds a raw JWT string (header.payload.sig, base64url) carrying the given +// claims — the shape CLIProxyAPI's real download auth JSON uses for id_token, so tests can +// exercise the raw-JWT identity cross-check. The signature is a placeholder (identity parsing +// reads the payload, it does not verify the signature). +func keeperTestJWT(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal jwt part: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]any{"alg": "none", "typ": "JWT"}) + "." + enc(claims) + ".sig" +} + +// TestKeeperLedgerPerAccountAndRouteAgnostic proves the redeem ledger keys on the stable +// account_id: distinct accounts keep separate rows, the SAME account converges on one key +// regardless of how it is routed (a claim reusing the same account_id returns the existing +// pending id), and a resolved (terminal) row lets the next claim mint a fresh id. +func TestKeeperLedgerPerAccountAndRouteAgnostic(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + // Two distinct accounts get distinct pending rows. + idA, err := app.createKeeperRedeem(ctx, "acct-A") + if err != nil { + t.Fatalf("claim A: %v", err) + } + idB, err := app.createKeeperRedeem(ctx, "acct-B") + if err != nil { + t.Fatalf("claim B: %v", err) + } + if idA == idB { + t.Fatalf("distinct accounts share a request_id %q; each must get its own", idA) + } + + // The SAME account, however it is now routed, converges on its existing pending id + // (route-agnostic) rather than minting a new one. + idAAgain, err := app.createKeeperRedeem(ctx, "acct-A") + if err != nil { + t.Fatalf("re-claim A: %v", err) + } + if idAAgain != idA { + t.Fatalf("re-claim of account A minted a new id %q (want existing %q)", idAAgain, idA) + } + if got, ok, _ := app.lookupPendingKeeperRedeem(ctx, "acct-A"); !ok || got != idA { + t.Fatalf("account A pending lookup = (%q,%v), want (%q,true)", got, ok, idA) + } + + // After A resolves (terminal), the next claim mints a fresh id. + if err := app.finishKeeperRedeem(ctx, "acct-A", idA, keeperResetCreditCodeReset); err != nil { + t.Fatalf("finish A: %v", err) + } + idAFresh, err := app.createKeeperRedeem(ctx, "acct-A") + if err != nil { + t.Fatalf("fresh claim A: %v", err) + } + if idAFresh == idA { + t.Fatalf("claim after a resolved redeem reused the terminal id %q; must mint fresh", idA) + } +} + +// TestKeeperInspectIdentityConflictPreservesSnapshot proves an inspection whose list and +// download identities conflict (list account_id A, detail account_id B) bails out with an +// identity_error: it does NOT fetch the reset credits, preserves the previous snapshot, and +// the refresh audit reports error (never a healthy/ok refresh). +func TestKeeperInspectIdentityConflictPreservesSnapshot(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "conflict.json" + var mu sync.Mutex + creditFetches := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // List identity: account A. + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "id_token": map[string]any{"chatgpt_account_id": "acct-LIST-A"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Download identity: account B (conflicts with the list). + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", "account_id": "acct-DETAIL-B", + "email": "c@example.com", "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "rate-limit-reset-credits") { + mu.Lock() + creditFetches++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a FULL good prior snapshot for account B: every business column set, so we can + // prove the identity-conflict write preserves all of them, not just reset credits. + idx, acctB, count := "idx-1", "acct-DETAIL-B", 5 + email, acctType := "b@example.com", "pro" + prio, prim, sec, qt := 1, 40, 20, 80 + dis := false + healthyAt := time.Now().Add(-time.Hour).Truncate(time.Second) + sub := time.Now().Add(720 * time.Hour).Truncate(time.Second) + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: healthyAt, Email: &email, AuthIndex: &idx, + AccountID: &acctB, AccountType: &acctType, Disabled: &dis, Priority: &prio, + PrimaryUsedPercent: &prim, SecondaryUsedPercent: &sec, QuotaThreshold: &qt, + SubscriptionActiveUntil: &sub, ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + }); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + before, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("read seeded state: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.IdentityError != 1 || stats.Healthy != 0 { + t.Fatalf("stats = %+v, want IdentityError=1, Healthy=0", stats) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "error" || reason != "identity_error" { + t.Fatalf("audit outcome = (%q,%q), want (error, identity_error)", result, reason) + } + mu.Lock() + fetches := creditFetches + mu.Unlock() + if fetches != 0 { + t.Fatalf("reset-credit fetched %d times on identity conflict; must not fetch from a mixed detail", fetches) + } + // EVERY business column of the prior snapshot must survive intact — an identity + // conflict must not clobber email/auth_index/account_id/account_type/disabled/priority/ + // usage/quota/reset-credit/subscription to NULL (a cleared auth_index would also block a + // later reset). Only last_error/latest_action/last_checked_at may change. + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.Email == nil || *st.Email != "b@example.com" || st.AuthIndex == nil || *st.AuthIndex != "idx-1" || + st.AccountID == nil || *st.AccountID != "acct-DETAIL-B" || st.AccountType == nil || *st.AccountType != "pro" || + st.Disabled != false || st.Priority == nil || *st.Priority != 1 { + t.Fatalf("identity fields not preserved on conflict: %+v", st) + } + if st.PrimaryUsedPercent == nil || *st.PrimaryUsedPercent != 40 || + st.SecondaryUsedPercent == nil || *st.SecondaryUsedPercent != 20 || + st.QuotaThreshold == nil || *st.QuotaThreshold != 80 { + t.Fatalf("usage/quota not preserved on conflict: %+v", st) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 5 || len(st.ResetCredits) != 1 { + t.Fatalf("reset credits not preserved on conflict: count=%v credits=%d", st.ResetCreditCount, len(st.ResetCredits)) + } + if st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(*before.SubscriptionActiveUntil) { + t.Fatalf("subscription not preserved on conflict: got=%v want=%v", st.SubscriptionActiveUntil, before.SubscriptionActiveUntil) + } + // last_healthy_at must NOT advance (a conflict is not a healthy refresh). + if st.LastHealthyAt == nil || before.LastHealthyAt == nil || !st.LastHealthyAt.Equal(*before.LastHealthyAt) { + t.Fatalf("last_healthy_at changed on conflict: got=%v want=%v", st.LastHealthyAt, before.LastHealthyAt) + } + // The error/latest_action IS updated, and the check time advances. + if st.LastError == nil { + t.Fatal("identity conflict did not record an error on the account") + } + if st.LastCheckedAt == nil || !st.LastCheckedAt.After(healthyAt) { + t.Fatalf("last_checked_at not advanced on conflict: got=%v seed=%v", st.LastCheckedAt, healthyAt) + } +} + +// TestKeeperInspectNeitherAccountIDSkipsAccountScopedWrites proves that when neither the list +// nor the download detail carries an account_id (a legacy auth_index-only credential), the +// inspection does NOT fetch a reset-credit snapshot (it cannot attribute it to a resource) and +// does NOT bind the list's subscription claim; it preserves the prior account-scoped snapshot +// (reset credits, subscription, the previously-known account_id) and reports the refresh as +// partial/reset_credits_unavailable — never a falsely-healthy ok that overwrote account state. +func TestKeeperInspectNeitherAccountIDSkipsAccountScopedWrites(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "legacy.json" + // The list carries a PARSEABLE renewal claim that DIFFERS from the seeded snapshot but + // still no account_id — so this pins the subscription guard: with an unknown account_id + // the list's renewal must NOT be bound; the old snapshot value must be preserved. + listSub := time.Now().Add(1000 * time.Hour).UTC().Truncate(time.Second) + var mu sync.Mutex + creditFetches := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // List entry: auth_index + a subscription claim, but NO account_id (the id_token + // has chatgpt_subscription_active_until yet no chatgpt_account_id). + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-1", + "id_token": map[string]any{"chatgpt_subscription_active_until": listSub.Format(time.RFC3339)}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Download detail: auth_index only, NO account_id. + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", + "email": "legacy@example.com", "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "rate-limit-reset-credits") { + mu.Lock() + creditFetches++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a prior account-scoped snapshot bound to a KNOWN account_id, with reset credits + // and a subscription renewal date — none of which this inspection may overwrite. + idx, priorAcct, count := "idx-1", "acct-KNOWN-X", 5 + sub := time.Now().Add(720 * time.Hour).Truncate(time.Second) + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), AuthIndex: &idx, AccountID: &priorAcct, + ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + SubscriptionActiveUntil: &sub, SubscriptionKnown: true, + }); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + // Usage ran (an ok-family outcome) but the reset-credit snapshot could not be refreshed, + // and this is never an identity/network error. + okFamily := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okFamily != 1 || stats.ResetCreditsUnavailable != 1 || stats.IdentityError != 0 || stats.NetworkError != 0 { + t.Fatalf("stats = %+v, want ok-family=1, ResetCreditsUnavailable=1, IdentityError=0, NetworkError=0", stats) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "partial" || reason != "reset_credits_unavailable" { + t.Fatalf("audit outcome = (%q,%q), want (partial, reset_credits_unavailable)", result, reason) + } + mu.Lock() + fetches := creditFetches + mu.Unlock() + if fetches != 0 { + t.Fatalf("reset-credit fetched %d times with no account_id; must not attribute a snapshot to an unknown resource", fetches) + } + // The prior account-scoped snapshot must survive: reset credits, subscription, AND the + // previously-known account_id (COALESCE preserves it when this inspection had none). + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 5 || len(st.ResetCredits) != 1 { + t.Fatalf("reset credits not preserved with unknown account_id: count=%v credits=%d", st.ResetCreditCount, len(st.ResetCredits)) + } + if st.SubscriptionActiveUntil == nil || !st.SubscriptionActiveUntil.Equal(sub) { + t.Fatalf("subscription not preserved with unknown account_id: got=%v want=%v", st.SubscriptionActiveUntil, sub) + } + if st.SubscriptionActiveUntil.Equal(listSub) { + t.Fatalf("list renewal claim was bound despite unknown account_id: got=%v (list=%v)", st.SubscriptionActiveUntil, listSub) + } + if st.AccountID == nil || *st.AccountID != "acct-KNOWN-X" { + t.Fatalf("prior account_id not preserved with unknown inspection identity: %v", st.AccountID) + } +} + +// TestKeeperInspectRawJWTAccountConflictPreservesSnapshot proves the inspection path also +// catches a deceptive raw-JWT identity: the download detail has top-level account_id=A but a +// raw JWT id_token whose account claim (nested under the OpenAI auth namespace — the real +// on-wire location) is B. The detail is self-contradictory, so the inspection bails as +// identity_error: NO reset-credit fetch, NO usage/credit snapshot write, the prior snapshot +// preserved, and the refresh audited as an error. +func TestKeeperInspectRawJWTAccountConflictPreservesSnapshot(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "jwt-conflict.json" + var mu sync.Mutex + creditFetches := 0 + deceptiveJWT := keeperTestJWT(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-B"}}) + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-1", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Top-level account_id=A, but the raw JWT's own claim is B → self-contradictory. + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", "account_id": "acct-A", + "id_token": deceptiveJWT, "email": "j@example.com", "account_type": "pro", + "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "rate-limit-reset-credits") { + mu.Lock() + creditFetches++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 99, "reset_after_seconds": 3600}}}}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a prior snapshot with a KNOWN usage percent + reset credits to prove they survive. + idx, acct, count, used := "idx-1", "acct-A", 5, 42 + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), AuthIndex: &idx, AccountID: &acct, + PrimaryUsedPercent: &used, ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + }); err != nil { + t.Fatalf("seed snapshot: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.IdentityError != 1 || stats.Healthy != 0 { + t.Fatalf("stats = %+v, want IdentityError=1, Healthy=0", stats) + } + if result, reason := keeperRefreshAuditOutcome(stats, nil); result != "error" || reason != "identity_error" { + t.Fatalf("audit outcome = (%q,%q), want (error, identity_error)", result, reason) + } + mu.Lock() + fetches := creditFetches + mu.Unlock() + if fetches != 0 { + t.Fatalf("reset-credit fetched %d times on a raw-JWT identity conflict; must not fetch", fetches) + } + // The usage snapshot must NOT be overwritten by the deceptive inspection's fresh 99%. + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.PrimaryUsedPercent == nil || *st.PrimaryUsedPercent != 42 { + t.Fatalf("usage snapshot overwritten on raw-JWT conflict: got=%v want=42", st.PrimaryUsedPercent) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 5 { + t.Fatalf("reset credits not preserved on raw-JWT conflict: %v", st.ResetCreditCount) + } + if st.LastError == nil { + t.Fatal("raw-JWT identity conflict did not record an error") + } +} + +// TestKeeperInspectListOnlyAccountIDSetsHeader proves that when the account_id is known only +// from the LIST entry's id_token claim (the download detail is a legacy raw file with no +// top-level account_id), the inspection still sends the Chatgpt-Account-Id header on BOTH the +// usage and reset-credit api-call egress (attributing the request to the confirmed account), +// AND writes the reset-credit snapshot — i.e. a known account_id never sends an account-less +// request while writing an account-scoped snapshot. +func TestKeeperInspectListOnlyAccountIDSetsHeader(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "list-only.json" + var mu sync.Mutex + usageHeader, creditHeader, creditRouteIndex := "", "", "" + creditFetches := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // account_id AND auth_index known ONLY from the list entry. + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-1", "id_token": map[string]any{"chatgpt_account_id": "acct-A"}}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + // Legacy raw detail: token only, NO top-level account_id, NO id_token, and an + // EXPLICIT-null auth_index (the dangerous case a right-biased merge would let + // overwrite the list's idx-1, then keeperAuthIndex would fall back to the name). + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "codex", "auth_index": nil, + "email": "a@example.com", "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + AuthIndex string `json:"auth_index"` + Header map[string]string `json:"header"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + switch { + case strings.Contains(p.URL, "rate-limit-reset-credits"): + mu.Lock() + creditFetches++ + creditHeader = p.Header["Chatgpt-Account-Id"] + creditRouteIndex = p.AuthIndex + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 1, "credits": []map[string]any{ + {"id": "RateLimitResetCredit_A", "reset_type": "codex_rate_limits", "status": "available", "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": "2026-09-21T00:08:46.146320Z"}, + }}}) + default: + mu.Lock() + usageHeader = p.Header["Chatgpt-Account-Id"] + mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) + } + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + okFamily := stats.Healthy + stats.StatusEnabled + stats.PriorityDegraded + stats.PriorityRestored + if okFamily != 1 || stats.ResetCreditsUnavailable != 0 || stats.IdentityError != 0 { + t.Fatalf("stats = %+v, want ok-family=1, ResetCreditsUnavailable=0, IdentityError=0", stats) + } + mu.Lock() + uh, ch, cri, fetches := usageHeader, creditHeader, creditRouteIndex, creditFetches + mu.Unlock() + if uh != "acct-A" { + t.Fatalf("usage Chatgpt-Account-Id = %q, want acct-A (known from list claim)", uh) + } + if fetches != 1 || ch != "acct-A" { + t.Fatalf("reset-credit fetch=%d header=%q, want 1 fetch with Chatgpt-Account-Id=acct-A", fetches, ch) + } + // Routing uses the reconciled list auth_index, not the auth NAME (the detail's explicit-null + // auth_index must not win the merge and force a name fallback). + if cri != "idx-1" { + t.Fatalf("reset-credit routed auth_index = %q, want idx-1 (reconciled from the list, not the name)", cri) + } + // The snapshot is written and account-scoped to the confirmed account_id. + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.AccountID == nil || *st.AccountID != "acct-A" { + t.Fatalf("stored account_id = %v, want acct-A", st.AccountID) + } + if st.ResetCreditCount == nil || *st.ResetCreditCount != 1 { + t.Fatalf("reset-credit snapshot not written: %v", st.ResetCreditCount) + } +} + +// TestKeeperResetNullAccountRefusedUntilInspected proves a legacy pre-account_id (NULL) state +// row cannot be reset: the account fence keys on the stored account_id and is taken before any +// remote resolve, so an unconfirmed identity is refused (fail closed) rather than fenced on an +// unknown key. No consume / cooldown is attempted. +func TestKeeperResetNullAccountRefusedUntilInspected(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "legacy-null.json" + var mu sync.Mutex + remoteCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + remoteCalls++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + http.NotFound(w, r) + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, func(cfg *AppConfig) { cfg.CodexKeeper.DryRun = false }) + ctx := context.Background() + + // Seed a legacy NULL-account row (auth_index set, account_id NULL). + idx := "idx-1" + if err := app.upsertKeeperState(ctx, keeperAccountResult{Name: authName, Result: "healthy", CheckedAt: time.Now(), AuthIndex: &idx}); err != nil { + t.Fatalf("seed: %v", err) + } + if _, rerr := app.resetKeeperQuota(ctx, authName); rerr == nil { + t.Fatal("reset on a NULL-account row must fail closed (require inspection first)") + } + // It must fail before ANY remote resolve/consume. + mu.Lock() + defer mu.Unlock() + if remoteCalls != 0 { + t.Fatalf("reset on an unconfirmed identity made %d remote calls; must refuse before resolving", remoteCalls) + } +} diff --git a/backend/internal/app/codex_keeper_reset_test.go b/backend/internal/app/codex_keeper_reset_test.go index 75eff989..decaf5bb 100644 --- a/backend/internal/app/codex_keeper_reset_test.go +++ b/backend/internal/app/codex_keeper_reset_test.go @@ -1,6 +1,7 @@ package app_test import ( + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" @@ -11,72 +12,234 @@ import ( backendApp "cpa-helper/backend/internal/app" ) +// rawJWTWithClaims builds a raw JWT string (header.payload.sig, base64url) carrying the given +// claims — the shape CLIProxyAPI's real download auth JSON uses for id_token. The signature is +// a placeholder; identity parsing reads the payload and does not verify the signature. +func rawJWTWithClaims(t *testing.T, claims map[string]any) string { + t.Helper() + enc := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal jwt part: %v", err) + } + return base64.RawURLEncoding.EncodeToString(b) + } + return enc(map[string]any{"alg": "none", "typ": "JWT"}) + "." + enc(claims) + ".sig" +} + +// keeperResetResponse is the minimal wire shape the reset route returns: only the +// account name plus the operation outcome (reset|already_redeemed|no_credit| +// nothing_to_reset|cooldown_only) for this operation. type keeperResetResponse struct { Status string `json:"status"` Account struct { - Name string `json:"name"` - QuotaResetCount int `json:"quota_reset_count"` - LastQuotaResetAt *string `json:"last_quota_reset_at"` + Name string `json:"name"` + Outcome string `json:"outcome"` } `json:"account"` } -type keeperResetAccountsResponse struct { - Items []struct { - Name string `json:"name"` - QuotaResetCount int `json:"quota_reset_count"` - LastQuotaResetAt *string `json:"last_quota_reset_at"` - } `json:"items"` -} +// keeperResetControl is the shared, mutex-guarded mock state that lets each +// sub-case steer the fake CLIProxyAPI: the authoritative available credit count, +// how the consume endpoint replies, and how /reset-quota replies. It also records +// call counts so a test can assert fail-closed ordering (e.g. a failed consume +// must never reach /reset-quota). +// keeperConsumeSensitiveSentinel is a fake sensitive token embedded in the mock's +// inner consume error body; the redaction canary asserts it NEVER reaches the log. +const keeperConsumeSensitiveSentinel = "SENSITIVE-sk-consume-secret-zzz" -// TestKeeperQuotaReset drives the real /api/codex-keeper/reset-quota route: -// a successful CLIProxyAPI reset-quota call increments the per-auth counter -// (visible via /accounts), a CLIProxyAPI failure surfaces the error WITHOUT -// incrementing, and bad requests are rejected before any CLIProxyAPI call. -func accountsResetCount(t *testing.T, handler http.Handler, cookies []*http.Cookie) int { - t.Helper() - accounts := keeperResetAccountsResponse{} - requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) - if len(accounts.Items) != 1 { - t.Fatalf("accounts listing has %d items, want 1", len(accounts.Items)) - } - return accounts.Items[0].QuotaResetCount +type keeperResetControl struct { + mu sync.Mutex + availableCount int // authoritative available_count returned by the fresh fetch + fetchMode string // ok | fail (fresh reset-credit GET) + consumeMode string // ok | http-fail | unknown-code | no-credit | lost + consumeSuccessCode string // terminal code returned on a successful consume (default "reset") + resetMode string // ok | http-fail | empty-body | wrong-index | bad-status | padded-index + consumeCalls int + consumeRequestIDs []string // redeem_request_id seen on each consume call, in order + resetCreditFetch int + resetQuotaCalls []string + // Concurrency gate: when armed, the first fresh reset-credit fetch to run under + // the per-auth lock signals gateReached (once) then blocks on gateRelease, so a + // test can deterministically hold the winner in-lock while contending requests hit + // tryLock and 409. + gateReached chan struct{} + gateRelease chan struct{} + gateOnce *sync.Once + // gateAtDownload moves the concurrency gate from the reset-credit fetch to the auth-files + // download stage, so a test can hold a reset inside identity resolution (before the fetch) + // and assert the account fence — taken before the resolve — already excludes a second route. + gateAtDownload bool + // authIndexOverride, when non-empty, replaces the downloaded detail's auth_index + // so a test can simulate a reassignment (fresh auth_index != stored DB row). + authIndexOverride string + // accountIDOverride, when non-empty, replaces the downloaded detail's account_id + // so a test can simulate an account_id change on the same auth_index. + accountIDOverride string + // omitAccountID drops account_id from the downloaded detail entirely. + omitAccountID bool + // listAccountIDClaim, when non-empty, adds id_token.chatgpt_account_id to the + // list entry so a test can exercise the list-vs-download account_id cross-check. + listAccountIDClaim string + // listTypeOverride, when non-empty, replaces the list entry's "type" (default + // "codex") so a test can simulate a non-Codex / drifted provider. + listTypeOverride string + // omitAccessToken drops access_token from the downloaded detail. + omitAccessToken bool + // duplicateListName emits a second list entry with the same name (different + // auth_index) to simulate a malformed/corrupt remote list. + duplicateListName bool + // detailAliasConflict adds a conflicting authIndex alias to the download detail + // (auth_index != authIndex) to simulate a deceptive intra-object identity. + detailAliasConflict bool + // detailNameOverride, when non-empty, replaces the download detail's "name" to + // simulate a proxy misroute binding another credential's detail to this target. + detailNameOverride string + // detailIDTokenOverride, when non-empty, sets the download detail's id_token to this + // raw value (a JWT string as CLIProxyAPI really returns), so a test can inject a token + // whose chatgpt_account_id claim conflicts with the top-level account_id. + detailIDTokenOverride string } -func TestKeeperQuotaReset(t *testing.T) { - t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) - - authName := "reset-me.json" - authDetail := map[string]any{ - "name": authName, - "type": "codex", - "auth_index": "idx-7", - "email": "reset@example.com", - "account_type": "plus", - "disabled": false, - "priority": 1, - "access_token": "test-token", +func newKeeperResetCPA(t *testing.T, authName string, authDetail map[string]any, ctrl *keeperResetControl) *httptest.Server { + t.Helper() + credit := func(id, expires string) map[string]any { + return map[string]any{ + "id": id, "reset_type": "codex_rate_limits", "status": "available", + "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": expires, + } } - - var mu sync.Mutex - resetCalls := []string{} - resetMode := "ok" // ok | http-fail | empty-body | wrong-index | bad-status - - cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": - _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + entry := map[string]any{"name": authName, "type": "codex"} + ctrl.mu.Lock() + claim := ctrl.listAccountIDClaim + typeOvr := ctrl.listTypeOverride + dup := ctrl.duplicateListName + ctrl.mu.Unlock() + if claim != "" { + entry["id_token"] = map[string]any{"chatgpt_account_id": claim} + } + if typeOvr != "" { + entry["type"] = typeOvr + } + files := []map[string]any{entry} + if dup { + files = append(files, map[string]any{"name": authName, "type": "codex", "auth_index": "idx-duplicate"}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"files": files}) case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": - _ = json.NewEncoder(w).Encode(authDetail) + detail := map[string]any{} + for k, v := range authDetail { + detail[k] = v + } + ctrl.mu.Lock() + ovr := ctrl.authIndexOverride + acctOvr := ctrl.accountIDOverride + omitAcct := ctrl.omitAccountID + omitToken := ctrl.omitAccessToken + aliasConflict := ctrl.detailAliasConflict + nameOvr := ctrl.detailNameOverride + idTokenOvr := ctrl.detailIDTokenOverride + ctrl.mu.Unlock() + if nameOvr != "" { + detail["name"] = nameOvr + } + if idTokenOvr != "" { + detail["id_token"] = idTokenOvr + } + if ovr != "" { + detail["auth_index"] = ovr + } + if acctOvr != "" { + detail["account_id"] = acctOvr + } + if omitAcct { + delete(detail, "account_id") + } + if omitToken { + delete(detail, "access_token") + } + if aliasConflict { + detail["authIndex"] = "idx-conflict" + } + _ = json.NewEncoder(w).Encode(detail) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": - _ = json.NewEncoder(w).Encode(map[string]any{ - "status_code": 200, - "body": map[string]any{ - "rate_limit": map[string]any{ - "primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}, - }, - }, - }) + var p struct { + URL string `json:"url"` + Data string `json:"data"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + switch { + // The consume URL also contains "rate-limit-reset-credits", so match the + // more specific "/consume" suffix first. + case strings.Contains(p.URL, "rate-limit-reset-credits/consume"): + var d struct { + RedeemRequestID string `json:"redeem_request_id"` + } + _ = json.Unmarshal([]byte(p.Data), &d) + ctrl.mu.Lock() + ctrl.consumeCalls++ + ctrl.consumeRequestIDs = append(ctrl.consumeRequestIDs, d.RedeemRequestID) + mode := ctrl.consumeMode + successCode := ctrl.consumeSuccessCode + // A real reset consumes one credit. + if mode == "ok" && (successCode == "" || successCode == "reset") && ctrl.availableCount > 0 { + ctrl.availableCount-- + } + ctrl.mu.Unlock() + if mode == "lost" { + // Every attempt of this operation loses its response (outer 5xx): + // even keeperRequest's idempotent retries fail, so the whole operation + // is unknown and the ledger stays pending for the next operation. + http.Error(w, "gateway", http.StatusBadGateway) + return + } + switch mode { + case "http-fail": + // Inner non-2xx carrying a sensitive sentinel: the canary asserts it + // never reaches the log. + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 403, "body": map[string]any{"error": map[string]any{"message": keeperConsumeSensitiveSentinel}}}) + case "unknown-code": + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": "surprise", "detail": keeperConsumeSensitiveSentinel}}) + case "no-credit": + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": "no_credit"}}) + default: + code := successCode + if code == "" { + code = "reset" + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": code, "windows_reset": []any{}}}) + } + case strings.Contains(p.URL, "rate-limit-reset-credits"): + ctrl.mu.Lock() + ctrl.resetCreditFetch++ + n := ctrl.availableCount + mode := ctrl.fetchMode + gReached, gRelease, gOnce := ctrl.gateReached, ctrl.gateRelease, ctrl.gateOnce + ctrl.mu.Unlock() + if gRelease != nil { + gOnce.Do(func() { close(gReached) }) + <-gRelease + } + if mode == "fail" { + http.Error(w, "boom", http.StatusInternalServerError) + return + } + credits := []map[string]any{} + if n >= 1 { + credits = append(credits, credit("RateLimitResetCredit_A", "2026-09-21T00:08:46.146320Z")) + } + if n >= 2 { + credits = append(credits, credit("RateLimitResetCredit_B", "2026-10-04T02:24:33.736521Z")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": n, "credits": credits}}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ + "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, + }}) + } case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/reset-quota": @@ -87,12 +250,10 @@ func TestKeeperQuotaReset(t *testing.T) { http.Error(w, "auth_index is required", http.StatusBadRequest) return } - mu.Lock() - mode := resetMode - if mode == "ok" { - resetCalls = append(resetCalls, payload.AuthIndex) - } - mu.Unlock() + ctrl.mu.Lock() + mode := ctrl.resetMode + ctrl.resetQuotaCalls = append(ctrl.resetQuotaCalls, payload.AuthIndex) + ctrl.mu.Unlock() switch mode { case "http-fail": http.Error(w, "boom", http.StatusBadRequest) @@ -111,50 +272,67 @@ func TestKeeperQuotaReset(t *testing.T) { http.NotFound(w, r) } })) - defer cpa.Close() +} +func setupKeeperResetApp(t *testing.T, cpaURL string) (http.Handler, []*http.Cookie, func()) { + t.Helper() app, err := backendApp.New() if err != nil { t.Fatalf("New() failed: %v", err) } - defer app.Close() handler := app.Routes() - cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{ - "username": "admin", - "password": "test-password", - "nickname": "Admin", + "username": "admin", "password": "test-password", "nickname": "Admin", }, nil, nil) requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{ - "cliaproxy_url": cpa.URL, - "management_key": "test-management-key", - "collector_enabled": false, + "cliaproxy_url": cpaURL, "management_key": "test-management-key", "collector_enabled": false, }, cookies, nil) requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ - "schedule_cron": "0 0 29 2 *", - "dry_run": false, - "quota_threshold": 100, - "worker_threads": 1, - "cpa_timeout_seconds": 1, + "schedule_cron": "0 0 29 2 *", "dry_run": false, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, }, cookies, nil) requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) waitForKeeperAccounts(t, handler, cookies, 1) + return handler, cookies, func() { app.Close() } +} - // Happy path: reset succeeds, counter becomes 1 and carries a timestamp. +// TestKeeperReset drives the real reset route through its new contract: when a +// credit is available it redeems one (consume) and reports outcome=reset; when +// none is available it clears only the local cooldown (outcome=cooldown_only); a +// failed consume fails closed WITHOUT clearing the cooldown; an unconfirmed +// CLIProxyAPI reset surfaces an error; and the response wire shape stays minimal. +func TestKeeperReset(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + + authName := "reset-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-7", + "email": "reset@example.com", "account_type": "plus", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-123", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Happy path: a credit is available, so one is redeemed (outcome=reset) and the + // cooldown is cleared exactly once. reset := keeperResetResponse{} requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) - if reset.Status != "ok" || reset.Account.Name != authName { - t.Fatalf("reset response = %+v, want ok for %s", reset, authName) + if reset.Status != "ok" || reset.Account.Name != authName || reset.Account.Outcome != "reset" { + t.Fatalf("reset response = %+v, want outcome=reset for %s", reset, authName) } - if reset.Account.QuotaResetCount != 1 || reset.Account.LastQuotaResetAt == nil { - t.Fatalf("first reset count = %d (lastAt=%v), want 1 with timestamp", reset.Account.QuotaResetCount, reset.Account.LastQuotaResetAt) + ctrl.mu.Lock() + if ctrl.consumeCalls != 1 { + ctrl.mu.Unlock() + t.Fatalf("consume calls = %d, want exactly 1", ctrl.consumeCalls) } - mu.Lock() - if len(resetCalls) != 1 || resetCalls[0] != "idx-7" { - mu.Unlock() - t.Fatalf("CLIProxyAPI reset calls = %v, want exactly one for auth_index %q", resetCalls, "idx-7") + if len(ctrl.resetQuotaCalls) != 1 || ctrl.resetQuotaCalls[0] != "idx-7" { + ctrl.mu.Unlock() + t.Fatalf("reset-quota calls = %v, want one for idx-7", ctrl.resetQuotaCalls) } - mu.Unlock() + ctrl.mu.Unlock() // Wire minimalism: the reset response must not leak internal account fields. raw := map[string]json.RawMessage{} @@ -165,62 +343,109 @@ func TestKeeperQuotaReset(t *testing.T) { } for key := range accountFields { switch key { - case "name", "quota_reset_count", "last_quota_reset_at": + case "name", "outcome": default: t.Fatalf("reset response leaks internal field %q (payload %v)", key, accountFields) } } - // Third reset (after the wire-minimalism reset above) increments to 3. + // No-credit path: the authoritative count is 0, so consume is skipped entirely + // and only the local cooldown is cleared (outcome=cooldown_only). + ctrl.mu.Lock() + ctrl.availableCount = 0 + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() reset = keeperResetResponse{} requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) - if reset.Account.QuotaResetCount != 3 { - t.Fatalf("third reset count = %d, want 3", reset.Account.QuotaResetCount) + if reset.Status != "ok" || reset.Account.Outcome != "cooldown_only" { + t.Fatalf("no-credit reset = %+v, want ok with outcome=cooldown_only", reset) + } + ctrl.mu.Lock() + if ctrl.consumeCalls != 0 { + ctrl.mu.Unlock() + t.Fatalf("no-credit path made %d consume calls, want 0", ctrl.consumeCalls) + } + if len(ctrl.resetQuotaCalls) != 1 { + ctrl.mu.Unlock() + t.Fatalf("no-credit path reset-quota calls = %v, want exactly one", ctrl.resetQuotaCalls) } + ctrl.mu.Unlock() - // The accounts listing carries the counter (3 successful resets so far). - accounts := keeperResetAccountsResponse{} - requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &accounts) - if len(accounts.Items) != 1 || accounts.Items[0].QuotaResetCount != 3 || accounts.Items[0].LastQuotaResetAt == nil { - t.Fatalf("accounts listing = %+v, want quota_reset_count 3 with timestamp", accounts.Items) + // Unknown available count (fresh fetch failed) blocks rather than degrading to a + // cooldown-only clear. This runs BEFORE any consume-failure leaves a replayable + // pending redeem (which would legitimately bypass the fresh-count gate). + ctrl.mu.Lock() + ctrl.availableCount = 2 + ctrl.fetchMode = "fail" + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + if len(ctrl.resetQuotaCalls) != 0 { + ctrl.mu.Unlock() + t.Fatalf("unknown-count path reached /reset-quota; it must block") } + ctrl.fetchMode = "ok" + ctrl.mu.Unlock() - // Any CLIProxyAPI outcome short of a confirmed reset (HTTP failure, or a - // deceptive 2xx whose body lacks status=ok for our exact auth_index) must - // surface an error and must NOT increment the counter. - baseline := accountsResetCount(t, handler, cookies) - for _, mode := range []string{"http-fail", "empty-body", "wrong-index", "bad-status", "padded-index"} { - mu.Lock() - resetMode = mode - mu.Unlock() + // Fail-closed: when a credit is available but the consume fails (inner non-2xx + // or an unrecognized code), the whole operation errors and NEVER reaches + // /reset-quota — the cooldown is not cleared on a half-done redemption. This leaves + // a pending redeem (unknown outcome), which is resolved right after. + for _, mode := range []string{"http-fail", "unknown-code"} { + ctrl.mu.Lock() + ctrl.availableCount = 2 + ctrl.consumeMode = mode + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) - if got := accountsResetCount(t, handler, cookies); got != baseline { - t.Fatalf("count after %s reset = %d, want unchanged %d", mode, got, baseline) + ctrl.mu.Lock() + calls := ctrl.resetQuotaCalls + ctrl.mu.Unlock() + if len(calls) != 0 { + t.Fatalf("consume mode %s reached /reset-quota (%v); it must fail closed first", mode, calls) } } - mu.Lock() - resetMode = "ok" - mu.Unlock() + // Resolve the pending left by the fail-closed loop with a clean successful reset so + // the following assertions start without a replayable pending. + ctrl.mu.Lock() + ctrl.consumeMode = "ok" + ctrl.availableCount = 2 + ctrl.mu.Unlock() + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &keeperResetResponse{}) - // Bad requests are rejected before any CLIProxyAPI call. + // A credit is available, so consume succeeds; any CLIProxyAPI outcome short of a + // confirmed cooldown clear is then an irreversible PARTIAL (409), not a plain error. + for _, mode := range []string{"http-fail", "empty-body", "wrong-index", "bad-status", "padded-index"} { + ctrl.mu.Lock() + ctrl.availableCount = 2 + ctrl.resetMode = mode + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusConflict) + } + ctrl.mu.Lock() + ctrl.resetMode = "ok" + ctrl.mu.Unlock() + + // Bad requests are rejected before any CLIProxyAPI reset call. requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": ""}, cookies, http.StatusUnprocessableEntity) requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": "nope.json"}, cookies, http.StatusNotFound) } -// keeperResetInspectAccountsResponse reads the reset_credit fields from the -// accounts endpoint so a test can assert the post-reset inspection refreshed them. +// keeperResetInspectAccountsResponse reads the reset_credit count from the +// accounts endpoint so a test can assert the post-reset inspection refreshed it. type keeperResetInspectAccountsResponse struct { Items []struct { Name string `json:"name"` - QuotaResetCount int `json:"quota_reset_count"` ResetCreditCount *int `json:"reset_credit_count"` } `json:"items"` } -// TestKeeperResetQuotaTriggersInspection proves that a successful reset-quota -// synchronously re-inspects just that account: the reset-credit snapshot in the DB -// is refreshed from a live fetch (not left at the pre-reset value), so the -// frontend's follow-up accounts reload shows the post-reset state. +// TestKeeperResetQuotaTriggersInspection proves a successful reset synchronously +// re-inspects just that account: after redeeming one of two credits the DB +// snapshot is refreshed from a live fetch (2 -> 1), so the frontend's follow-up +// accounts reload shows the post-reset state rather than the stale pre-reset one. func TestKeeperResetQuotaTriggersInspection(t *testing.T) { t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) @@ -228,56 +453,830 @@ func TestKeeperResetQuotaTriggersInspection(t *testing.T) { authDetail := map[string]any{ "name": authName, "type": "codex", "auth_index": "idx-9", "email": "inspect@example.com", "account_type": "pro", "disabled": false, - "priority": 1, "access_token": "test-token", + "priority": 1, "access_token": "test-token", "account_id": "acct-9", } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() - credit := func(id, expires string) map[string]any { - return map[string]any{ - "id": id, "reset_type": "codex_rate_limits", "status": "available", - "granted_at": "2026-08-22T00:08:46.146320Z", "expires_at": expires, + // After the initial inspection the snapshot shows 2 available credits. + before := keeperResetInspectAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &before) + if len(before.Items) != 1 || before.Items[0].ResetCreditCount == nil || *before.Items[0].ResetCreditCount != 2 { + t.Fatalf("pre-reset reset_credit_count = %+v, want 2", before.Items) + } + ctrl.mu.Lock() + fetchesBeforeReset := ctrl.resetCreditFetch + ctrl.mu.Unlock() + + // Reset succeeds; it redeems one credit and then re-inspects this account. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "reset" { + t.Fatalf("reset = %+v, want outcome=reset", reset) + } + + ctrl.mu.Lock() + fetchesAfterReset := ctrl.resetCreditFetch + ctrl.mu.Unlock() + if fetchesAfterReset <= fetchesBeforeReset { + t.Fatalf("reset-credit fetches did not increase after reset (%d -> %d): no post-reset inspection ran", fetchesBeforeReset, fetchesAfterReset) + } + + // The accounts readback now reflects the post-reset live fetch (1 credit left). + after := keeperResetInspectAccountsResponse{} + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &after) + if len(after.Items) != 1 || after.Items[0].ResetCreditCount == nil || *after.Items[0].ResetCreditCount != 1 { + t.Fatalf("post-reset reset_credit_count = %+v, want 1 (refreshed by the chained inspection)", after.Items) + } +} + +// postKeeperReset drives the reset route and returns only the HTTP status, so it +// is safe to call from goroutines (no t.Fatal off the test goroutine). +func postKeeperReset(handler http.Handler, cookies []*http.Cookie, authName string) int { + req := httptest.NewRequest(http.MethodPost, "/api/codex-keeper/reset-quota", strings.NewReader(`{"auth_name":"`+authName+`"}`)) + req.Header.Set("Content-Type", "application/json") + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code +} + +// TestKeeperResetConcurrentSingleConsume proves the per-auth lock makes concurrent +// resets of the SAME account redeem at most one credit: while one request holds the +// lock (blocked in its fresh reset-credit fetch), every contending request returns +// 409 without reaching consume. +func TestKeeperResetConcurrentSingleConsume(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "race-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-race", + "email": "race@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-race", + } + ctrl := &keeperResetControl{availableCount: 3, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Arm the gate only AFTER setup, so the run-once inspection's fetch is not held. + ctrl.mu.Lock() + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + // Winner: acquires the per-auth lock and blocks inside its fresh fetch. + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, authName) }() + <-ctrl.gateReached // the winner now holds the per-auth lock + + // Contenders: fire concurrently while the winner holds the lock; all must 409. + const contenders = 4 + var wg sync.WaitGroup + statuses := make([]int, contenders) + for i := 0; i < contenders; i++ { + wg.Add(1) + go func(idx int) { + defer wg.Done() + statuses[idx] = postKeeperReset(handler, cookies, authName) + }(i) + } + wg.Wait() + for i, s := range statuses { + if s != http.StatusConflict { + t.Fatalf("contender %d status = %d, want 409 (per-auth lock held)", i, s) } } - var mu sync.Mutex - // Before any reset: 2 available credits. After a reset consumes one: 1. - availableCount := 2 - resetCreditFetches := 0 - cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(ctrl.gateRelease) // let the winner finish + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("winner status = %d, want 200", s) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls under concurrency = %d, want exactly 1", ctrl.consumeCalls) + } +} + +// TestKeeperResetLostResponseReusesRedeemID proves the persistent redeem ledger +// closes the cross-operation double-consume window: when a consume's response is +// lost (outer 5xx = unknown), the redeem stays pending and the NEXT reset reuses the +// SAME redeem_request_id (OpenAI then returns already_redeemed idempotently) instead +// of minting a new key that could burn a second credit. +func TestKeeperResetLostResponseReusesRedeemID(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "lost-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-lost", + "email": "lost@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-lost", + } + ctrl := &keeperResetControl{ + availableCount: 2, fetchMode: "ok", consumeMode: "lost", resetMode: "ok", + } + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First attempt: every consume attempt loses its response (unknown) → fail closed, + // ledger left pending. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + + // The account recovers; the next attempt must reuse the pending redeem_request_id + // so OpenAI resolves it idempotently as already_redeemed instead of burning a + // second credit. + ctrl.mu.Lock() + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("retry reset = %+v, want outcome=already_redeemed", reset) + } + + ctrl.mu.Lock() + ids := append([]string{}, ctrl.consumeRequestIDs...) + ctrl.mu.Unlock() + if len(ids) < 2 { + t.Fatalf("consume calls = %d, want >= 2 (lost + retry)", len(ids)) + } + // Every consume attempt (the lost operation's retries AND the recovery operation) + // must carry the exact same redeem_request_id. + for i, id := range ids { + if id == "" || id != ids[0] { + t.Fatalf("redeem_request_id not reused (call %d = %q, first = %q); all=%v", i, id, ids[0], ids) + } + } +} + +// TestKeeperResetConsumeLogRedaction is the canary: a failed consume whose inner +// body carries a sensitive sentinel must never write that raw body to the Keeper +// log; only a stable classification (reset-consume … inner_status) is recorded. +func TestKeeperResetConsumeLogRedaction(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "redact-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-redact", + "email": "redact@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-redact", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "http-fail", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + + var status struct { + Logs []string `json:"logs"` + } + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/status", nil, cookies, &status) + joined := strings.Join(status.Logs, "\n") + if strings.Contains(joined, keeperConsumeSensitiveSentinel) { + t.Fatalf("sensitive inner body leaked into Keeper log") + } + if !strings.Contains(joined, "reset-consume") || !strings.Contains(joined, "inner_status") { + t.Fatalf("expected stable reset-consume/inner_status classification in log; logs=%v", status.Logs) + } +} + +// TestKeeperResetAuthIndexChangeProceeds proves auth_index is treated as a ROUTING +// selector, not the resource identity: when only the auth_index changes (file +// rename/move/reorder) but the account_id is unchanged, the reset proceeds normally using +// the fresh auth_index for routing — it does NOT fail closed. (Only an account_id change +// fails closed; see TestKeeperResetAccountIDMismatchFailsClosed.) +func TestKeeperResetAuthIndexChangeProceeds(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "moved-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-orig", + "email": "moved@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-moved", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // The account was inspected with idx-orig; the file is now routed via a different + // auth_index, but it is the SAME account (account_id unchanged). + ctrl.mu.Lock() + ctrl.authIndexOverride = "idx-different" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "reset" { + t.Fatalf("auth_index-only change reset = %+v, want ok/reset (index is routing, not identity)", reset) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls = %d, want 1 (index change should proceed)", ctrl.consumeCalls) + } + // The cooldown clear MUST route with the FRESH index (idx-different), not the stale DB + // value — otherwise a reindex clears the wrong account / leaves a partial. + if len(ctrl.resetQuotaCalls) != 1 || ctrl.resetQuotaCalls[0] != "idx-different" { + t.Fatalf("/reset-quota routed with %v, want [idx-different] (fresh index)", ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetMissingAccountID proves the reset fails closed when no account_id can +// be resolved (the consume header requires it) — no consume, no cooldown clear. +func TestKeeperResetMissingAccountID(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "noacct-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-noacct", + "email": "noacct@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-noacct", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Now the fresh detail drops account_id (the list entry never had one). + ctrl.mu.Lock() + ctrl.omitAccountID = true + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("missing account_id must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetAccountIDMismatchFailsClosed proves a stale page cannot reset a +// swapped-out account: when the fresh account_id differs from the DB row's stored +// account_id (the file was rebound to a different account under the same auth_index), the +// reset fails closed (account_id mismatch) and never consumes or clears the cooldown. +func TestKeeperResetAccountIDMismatchFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "switch-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-switch", + "email": "switch@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-A", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // The run-once inspection stored account_id=acct-A. The file is now rebound to a + // different account (same auth_index), but the DB row (and the user's page) still + // shows acct-A. + ctrl.mu.Lock() + ctrl.accountIDOverride = "acct-B" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("account_id mismatch must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetRawJWTAccountIDConflictFailsClosed proves the deceptive raw-JWT case: the +// download detail has top-level account_id=A but an id_token raw JWT whose account claim is B, +// carried in the REAL on-wire location — nested under the OpenAI auth namespace +// (https://api.openai.com/auth.chatgpt_account_id) rather than a flattened top-level claim (the +// list endpoint flattens; the raw download JWT nests). The identity resolver decodes the JWT, +// sees the top-level A conflict with the token's own B, and fails closed — NO consume, NO +// /reset-quota — instead of acting on A's header/route with B's token. +func TestKeeperResetRawJWTAccountIDConflictFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "deceptive-jwt.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-jwt", + "email": "jwt@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-A", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // After the clean seed inspection, the download starts returning a raw JWT id_token whose + // claim (acct-B) contradicts the still-top-level account_id (acct-A) — a deceptive entry. + ctrl.mu.Lock() + ctrl.detailIDTokenOverride = rawJWTWithClaims(t, map[string]any{"https://api.openai.com/auth": map[string]any{"chatgpt_account_id": "acct-B"}}) + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("raw-JWT account_id conflict must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetPendingReplayedWhenCountZero pins the control-flow rule that an +// identity-matched pending redeem is replayed with its original key even when the +// fresh available_count is 0: a lost first response that actually consumed the LAST +// credit is recovered as outcome=already_redeemed, never short-circuited into +// a cooldown-only clear. +func TestKeeperResetPendingReplayedWhenCountZero(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "zero-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-zero", + "email": "zero@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-zero", + } + ctrl := &keeperResetControl{availableCount: 1, fetchMode: "ok", consumeMode: "lost", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First reset: the only credit's consume response is lost → pending, fail closed. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + lostIDs := append([]string{}, ctrl.consumeRequestIDs...) + consumesBefore := ctrl.consumeCalls + // The lost attempt actually consumed the last credit, so the fresh count is now 0 + // and a replay resolves it idempotently. + ctrl.availableCount = 0 + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + // Second reset: count is 0, but the pending redeem MUST still be replayed. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("count-zero replay = %+v, want outcome=already_redeemed (not short-circuited to cooldown-only)", reset) + } + + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls <= consumesBefore { + t.Fatalf("pending redeem was NOT replayed at count=0 (consume calls %d -> %d)", consumesBefore, ctrl.consumeCalls) + } + if len(lostIDs) == 0 || ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] != lostIDs[0] { + t.Fatalf("replay used a different redeem_request_id than the pending one: pending=%v all=%v", lostIDs, ctrl.consumeRequestIDs) + } +} + +// TestKeeperResetAccountIDCrossCheck proves the list id_token.chatgpt_account_id is an +// OPTIONAL second cross-check: a matching claim passes, a conflicting claim fails +// closed, and (via the other reset tests where the list omits it) an absent claim never +// blocks a legitimate credential whose download account_id is present. +func TestKeeperResetAccountIDCrossCheck(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "xcheck-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-xcheck", + "email": "xcheck@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-xcheck", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Matching list claim: reset proceeds and consumes. + ctrl.mu.Lock() + ctrl.listAccountIDClaim = "acct-xcheck" + ctrl.mu.Unlock() + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "reset" { + t.Fatalf("matching list account_id claim reset = %+v, want outcome=reset", reset) + } + + // Conflicting list claim: fail closed, no consume, no cooldown clear. + ctrl.mu.Lock() + ctrl.listAccountIDClaim = "acct-DIFFERENT" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("account_id conflict must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetPendingReplayedWhenFetchUnavailable proves an identity-matched pending +// redeem is replayed with its original key even when the fresh reset-credit GET is +// temporarily unavailable (ok=false): the count gate applies only to a NEW operation, so +// a lost-response pending is not stranded while the count endpoint is down. +func TestKeeperResetPendingReplayedWhenFetchUnavailable(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "fetchdown-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-fetchdown", + "email": "fetchdown@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-fetchdown", + } + ctrl := &keeperResetControl{availableCount: 1, fetchMode: "ok", consumeMode: "lost", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First reset: the consume response is lost → pending, fail closed. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + lostIDs := append([]string{}, ctrl.consumeRequestIDs...) + // The count endpoint is now temporarily unavailable, but the account recovers. + ctrl.fetchMode = "fail" + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + // Second reset: fresh GET would fail, but the pending redeem MUST still be replayed. + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("pending replay with fetch down = %+v, want outcome=already_redeemed", reset) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if len(lostIDs) == 0 || ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] != lostIDs[0] { + t.Fatalf("replay used a different redeem_request_id: pending=%v all=%v", lostIDs, ctrl.consumeRequestIDs) + } +} + +// TestKeeperResetNonCodexFailsClosed proves the reset resolver only acts on a Codex +// list entry: if the auth_name's remote type has drifted to another provider, the reset +// fails closed and never sends a non-Codex token/index to the OpenAI consume. +func TestKeeperResetNonCodexFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "drift-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-drift", + "email": "drift@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-drift", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // The account was inspected as Codex; now its remote list type has drifted. + ctrl.mu.Lock() + ctrl.listTypeOverride = "gemini" + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("non-Codex reset must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetOutcomeCodes proves the reset DTO returns a distinct outcome per real +// business result — reset, already_redeemed, no_credit, nothing_to_reset are NOT +// collapsed into one another, and cooldown_only is distinct from no_credit. +func TestKeeperResetOutcomeCodes(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "outcome-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-outcome", + "email": "outcome@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-outcome", + } + ctrl := &keeperResetControl{availableCount: 5, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + cases := []struct { + name string + consumeMode string + successCode string + available int + want string + }{ + {"reset", "ok", "reset", 5, "reset"}, + {"already_redeemed", "ok", "already_redeemed", 5, "already_redeemed"}, + {"no_credit", "no-credit", "", 5, "no_credit"}, + {"nothing_to_reset", "ok", "nothing_to_reset", 5, "nothing_to_reset"}, + {"cooldown_only", "ok", "reset", 0, "cooldown_only"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ctrl.mu.Lock() + ctrl.availableCount = tc.available + ctrl.consumeMode = tc.consumeMode + ctrl.consumeSuccessCode = tc.successCode + ctrl.mu.Unlock() + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != tc.want { + t.Fatalf("outcome = %+v, want %q", reset, tc.want) + } + }) + } +} + +// TestKeeperResetDryRunFailsClosed proves a manual reset is blocked under dry-run so an +// admin testing the keeper never silently burns a paid credit. +func TestKeeperResetDryRunFailsClosed(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "dryrun-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-dryrun", + "email": "dryrun@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-dryrun", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Turn dry-run on. + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": true, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + ctrl.mu.Lock() + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("dry-run reset must fail closed (consume=%d, reset-quota=%v)", ctrl.consumeCalls, ctrl.resetQuotaCalls) + } +} + +// TestKeeperResetResolverGuardsFailClosed proves the identity resolver fails closed on an +// ambiguous/deceptive remote response: a duplicate name, a self-conflicting auth_index +// alias, or a missing access_token must never reach the consume. +func TestKeeperResetResolverGuardsFailClosed(t *testing.T) { + cases := []struct { + name string + apply func(*keeperResetControl) + }{ + {"duplicate-name", func(c *keeperResetControl) { c.duplicateListName = true }}, + {"alias-conflict", func(c *keeperResetControl) { c.detailAliasConflict = true }}, + {"missing-access-token", func(c *keeperResetControl) { c.omitAccessToken = true }}, + {"detail-name-mismatch", func(c *keeperResetControl) { c.detailNameOverride = "other-account.json" }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "guard-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-guard", + "email": "guard@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-guard", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + ctrl.mu.Lock() + tc.apply(ctrl) + ctrl.consumeCalls = 0 + ctrl.resetQuotaCalls = nil + ctrl.mu.Unlock() + + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 0 || len(ctrl.resetQuotaCalls) != 0 { + t.Fatalf("%s must fail closed (consume=%d, reset-quota=%v)", tc.name, ctrl.consumeCalls, ctrl.resetQuotaCalls) + } + }) + } +} + +// TestKeeperResetPartialAuditOnCooldownFailure proves that when a credit was consumed but +// the local cooldown clear then failed, the audit records an irreversible PARTIAL (not a +// plain error), so the money-affecting half-completion is not masked. +func TestKeeperResetPartialAuditOnCooldownFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "partial-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-partial", + "email": "partial@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-partial", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "http-fail"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Consume succeeds (reset) but the cooldown clear fails → 409 partial to the caller. + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusConflict) + + var status struct { + Logs []string `json:"logs"` + } + requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/status", nil, cookies, &status) + joined := strings.Join(status.Logs, "\n") + if !strings.Contains(joined, "cooldown_failed_after_consume") || !strings.Contains(joined, "result=partial") { + t.Fatalf("cooldown-after-consume must audit an irreversible partial; logs=%v", status.Logs) + } + // The handler must NOT overwrite the partial with a generic result=error line. + if strings.Contains(joined, "reset-quota") && strings.Contains(joined, "result=error reason=validation_error") { + t.Fatalf("partial was masked by a generic result=error audit; logs=%v", status.Logs) + } +} + +func deleteKeeperAccountReq(handler http.Handler, cookies []*http.Cookie, authName string) int { + req := httptest.NewRequest(http.MethodDelete, "/api/codex-keeper/accounts/"+authName, nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code +} + +// TestKeeperDeleteConflictsWithInFlightReset proves delete shares the per-auth fence with +// reset: while a reset holds the lock mid-consume, a concurrent delete of the same account +// is refused (409) so it can never drop the in-flight redeem ledger key; after the reset +// finishes the lock is free again. +func TestKeeperDeleteConflictsWithInFlightReset(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "fence-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-fence", + "email": "fence@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-fence", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "ok", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // Arm the gate so a reset holds the per-auth lock while blocked in its fresh fetch. + ctrl.mu.Lock() + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, authName) }() + <-ctrl.gateReached // the reset now holds the per-auth lock + + if s := deleteKeeperAccountReq(handler, cookies, authName); s != http.StatusConflict { + t.Fatalf("delete during in-flight reset = %d, want 409 (per-auth fence)", s) + } + + close(ctrl.gateRelease) + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("reset winner = %d, want 200", s) + } +} + +// TestKeeperResetReplaysPendingAcrossIndexChange proves the redeem ledger is keyed by the +// stable account_id, not the routing auth_index: after a lost consume leaves a pending +// redeem, a later reset whose auth_index has CHANGED (file rename/move/reorder) but whose +// account_id is unchanged still finds and replays the SAME redeem_request_id (recovered as +// already_redeemed) rather than minting a new key that could double-consume. +func TestKeeperResetReplaysPendingAcrossIndexChange(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + authName := "reindex-me.json" + authDetail := map[string]any{ + "name": authName, "type": "codex", "auth_index": "idx-1", + "email": "reindex@example.com", "account_type": "pro", "disabled": false, + "priority": 1, "access_token": "test-token", "account_id": "acct-stable", + } + ctrl := &keeperResetControl{availableCount: 2, fetchMode: "ok", consumeMode: "lost", resetMode: "ok"} + cpa := newKeeperResetCPA(t, authName, authDetail, ctrl) + defer cpa.Close() + handler, cookies, cleanup := setupKeeperResetApp(t, cpa.URL) + defer cleanup() + + // First reset at auth_index idx-1: consume lost → pending (keyed by acct-stable). + requestJSONExpectStatus(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, http.StatusUnprocessableEntity) + ctrl.mu.Lock() + lostID := ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] + // The file is renamed/reordered → CPA gives it a NEW auth_index, but it is the SAME + // OpenAI account. The account recovers so the replay resolves. + ctrl.authIndexOverride = "idx-2" + ctrl.consumeMode = "ok" + ctrl.consumeSuccessCode = "already_redeemed" + ctrl.mu.Unlock() + + reset := keeperResetResponse{} + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) + if reset.Status != "ok" || reset.Account.Outcome != "already_redeemed" { + t.Fatalf("post-reindex reset = %+v, want already_redeemed (pending replayed across index change)", reset) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + replayID := ctrl.consumeRequestIDs[len(ctrl.consumeRequestIDs)-1] + if lostID == "" || replayID != lostID { + t.Fatalf("index change lost the pending key: first %q, replay %q (should reuse the same key)", lostID, replayID) + } +} + +// newTwoFileSameAccountCPA serves two auth files (fileA idx-A, fileB idx-B) that BOTH map to +// the same OpenAI account_id, so a test can exercise the account-level fence: the fresh +// reset-credit fetch is gated (fileA blocks in it while holding the account lock). +func newTwoFileSameAccountCPA(t *testing.T, fileA, fileB, accountID string, ctrl *keeperResetControl) *httptest.Server { + t.Helper() + detailFor := func(name, idx string) map[string]any { + return map[string]any{ + "name": name, "type": "codex", "auth_index": idx, "account_id": accountID, + "account_type": "pro", "disabled": false, "priority": 1, "access_token": "test-token", + } + } + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch { case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": - _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{{"name": authName, "type": "codex"}}}) + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": fileA, "type": "codex"}, {"name": fileB, "type": "codex"}, + }}) case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": - _ = json.NewEncoder(w).Encode(authDetail) + ctrl.mu.Lock() + atDownload := ctrl.gateAtDownload + gReached, gRelease, gOnce := ctrl.gateReached, ctrl.gateRelease, ctrl.gateOnce + ctrl.mu.Unlock() + if atDownload && gRelease != nil { + gOnce.Do(func() { close(gReached) }) + <-gRelease + } + name := r.URL.Query().Get("name") + idx := "idx-A" + if name == fileB { + idx = "idx-B" + } + _ = json.NewEncoder(w).Encode(detailFor(name, idx)) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": - var payload struct { + var p struct { URL string `json:"url"` } - _ = json.NewDecoder(r.Body).Decode(&payload) - if strings.Contains(payload.URL, "rate-limit-reset-credits") { - mu.Lock() - resetCreditFetches++ - n := availableCount - mu.Unlock() - credits := []map[string]any{credit("RateLimitResetCredit_A", "2026-09-21T00:08:46.146320Z")} - if n >= 2 { - credits = append(credits, credit("RateLimitResetCredit_B", "2026-10-04T02:24:33.736521Z")) + _ = json.NewDecoder(r.Body).Decode(&p) + switch { + case strings.Contains(p.URL, "rate-limit-reset-credits/consume"): + ctrl.mu.Lock() + ctrl.consumeCalls++ + ctrl.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"code": "reset", "windows_reset": []any{}}}) + case strings.Contains(p.URL, "rate-limit-reset-credits"): + ctrl.mu.Lock() + atDownload := ctrl.gateAtDownload + gReached, gRelease, gOnce := ctrl.gateReached, ctrl.gateRelease, ctrl.gateOnce + ctrl.mu.Unlock() + if !atDownload && gRelease != nil { + gOnce.Do(func() { close(gReached) }) + <-gRelease } - _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": n, "credits": credits}}) - return + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"available_count": 2, "credits": []map[string]any{{"id": "c1", "reset_type": "codex_rate_limits", "status": "available", "granted_at": "2026-08-22T00:08:46Z", "expires_at": "2026-09-21T00:08:46Z"}}}}) + default: + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{"rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}}}) } - _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": map[string]any{ - "rate_limit": map[string]any{"primary_window": map[string]any{"used_percent": 10, "reset_after_seconds": 3600}}, - }}) case r.Method == http.MethodPost && r.URL.Path == "/v0/management/reset-quota": var payload struct { AuthIndex string `json:"auth_index"` } _ = json.NewDecoder(r.Body).Decode(&payload) - // A reset consumes one credit, so a fresh fetch afterward returns 1. - mu.Lock() - availableCount = 1 - mu.Unlock() + ctrl.mu.Lock() + ctrl.resetQuotaCalls = append(ctrl.resetQuotaCalls, payload.AuthIndex) + ctrl.mu.Unlock() _ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "auth_index": payload.AuthIndex, "models": []string{}}) case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) @@ -285,6 +1284,16 @@ func TestKeeperResetQuotaTriggersInspection(t *testing.T) { http.NotFound(w, r) } })) +} + +// TestKeeperResetAccountFenceAcrossTwoFiles proves the account-level fence: two different +// files (auth_names) that map to the SAME OpenAI account cannot both consume concurrently. +// While fileA holds the account fence (blocked in its fresh fetch), a reset of fileB — a +// different auth_name, same account — is refused (409), so at most one credit is consumed. +func TestKeeperResetAccountFenceAcrossTwoFiles(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + ctrl := &keeperResetControl{} + cpa := newTwoFileSameAccountCPA(t, "a.json", "b.json", "acct-shared", ctrl) defer cpa.Close() app, err := backendApp.New() @@ -293,7 +1302,6 @@ func TestKeeperResetQuotaTriggersInspection(t *testing.T) { } defer app.Close() handler := app.Routes() - cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{ "username": "admin", "password": "test-password", "nickname": "Admin", }, nil, nil) @@ -305,36 +1313,92 @@ func TestKeeperResetQuotaTriggersInspection(t *testing.T) { "worker_threads": 1, "cpa_timeout_seconds": 1, }, cookies, nil) requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) - waitForKeeperAccounts(t, handler, cookies, 1) + waitForKeeperAccounts(t, handler, cookies, 2) - // After the initial inspection the snapshot shows 2 available credits. - before := keeperResetInspectAccountsResponse{} - requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &before) - if len(before.Items) != 1 || before.Items[0].ResetCreditCount == nil || *before.Items[0].ResetCreditCount != 2 { - t.Fatalf("pre-reset reset_credit_count = %+v, want 2", before.Items) + // Both files were inspected (state.AccountID = acct-shared for each). Arm the gate so the + // first reset blocks inside its fresh fetch while holding the account fence. + ctrl.mu.Lock() + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, "a.json") }() + <-ctrl.gateReached // fileA now holds the account fence, blocked in fetch + + // fileB (different auth_name, SAME account) must be refused while fileA holds the fence. + if s := postKeeperReset(handler, cookies, "b.json"); s != http.StatusConflict { + t.Fatalf("second file for the same account = %d, want 409 (account fence)", s) } - mu.Lock() - fetchesBeforeReset := resetCreditFetches - mu.Unlock() - // Reset succeeds; the handler must synchronously re-inspect this account. - reset := keeperResetResponse{} - requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/reset-quota", map[string]any{"auth_name": authName}, cookies, &reset) - if reset.Status != "ok" { - t.Fatalf("reset status = %q, want ok", reset.Status) + close(ctrl.gateRelease) + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("fileA reset = %d, want 200", s) } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls across two files of one account = %d, want exactly 1", ctrl.consumeCalls) + } +} - mu.Lock() - fetchesAfterReset := resetCreditFetches - mu.Unlock() - if fetchesAfterReset <= fetchesBeforeReset { - t.Fatalf("reset-credit fetches did not increase after reset (%d -> %d): no post-reset inspection ran", fetchesBeforeReset, fetchesAfterReset) +// TestKeeperResetAccountFenceBeforeResolve is the deterministic PRE-fence-window right-cause: +// the account fence keys on the stored account_id and is taken BEFORE the fresh list/download +// resolve. fileA is held inside its auth-files DOWNLOAD (identity resolution, before any fetch) +// while already holding the fence; a reset of fileB — a different auth_name, same account — is +// refused (409) even though fileA has not reached its consume yet. Exactly one credit is +// consumed. Before the fix (fence taken after resolve), fileB could resolve and consume in this +// window, yielding a second consume. +func TestKeeperResetAccountFenceBeforeResolve(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + ctrl := &keeperResetControl{} + cpa := newTwoFileSameAccountCPA(t, "a.json", "b.json", "acct-shared", ctrl) + defer cpa.Close() + + app, err := backendApp.New() + if err != nil { + t.Fatalf("New() failed: %v", err) + } + defer app.Close() + handler := app.Routes() + cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{ + "username": "admin", "password": "test-password", "nickname": "Admin", + }, nil, nil) + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{ + "cliaproxy_url": cpa.URL, "management_key": "test-management-key", "collector_enabled": false, + }, cookies, nil) + requestJSON(t, handler, http.MethodPut, "/api/codex-keeper/settings", map[string]any{ + "schedule_cron": "0 0 29 2 *", "dry_run": false, "quota_threshold": 100, + "worker_threads": 1, "cpa_timeout_seconds": 1, + }, cookies, nil) + requestJSON(t, handler, http.MethodPost, "/api/codex-keeper/run-once", nil, cookies, nil) + waitForKeeperAccounts(t, handler, cookies, 2) + + // Gate at the DOWNLOAD stage so fileA is held inside identity resolution — before its fetch + // — while already holding the account fence. + ctrl.mu.Lock() + ctrl.gateAtDownload = true + ctrl.gateReached = make(chan struct{}) + ctrl.gateRelease = make(chan struct{}) + ctrl.gateOnce = &sync.Once{} + ctrl.mu.Unlock() + + winnerCh := make(chan int, 1) + go func() { winnerCh <- postKeeperReset(handler, cookies, "a.json") }() + <-ctrl.gateReached // fileA holds the account fence, blocked in its download (pre-fetch) + + if s := postKeeperReset(handler, cookies, "b.json"); s != http.StatusConflict { + t.Fatalf("second file for the same account, blocked in pre-resolve = %d, want 409 (fence before resolve)", s) } - // The accounts readback now reflects the post-reset live fetch (1 credit left). - after := keeperResetInspectAccountsResponse{} - requestJSON(t, handler, http.MethodGet, "/api/codex-keeper/accounts", nil, cookies, &after) - if len(after.Items) != 1 || after.Items[0].ResetCreditCount == nil || *after.Items[0].ResetCreditCount != 1 { - t.Fatalf("post-reset reset_credit_count = %+v, want 1 (refreshed by the chained inspection)", after.Items) + close(ctrl.gateRelease) + if s := <-winnerCh; s != http.StatusOK { + t.Fatalf("fileA reset = %d, want 200", s) + } + ctrl.mu.Lock() + defer ctrl.mu.Unlock() + if ctrl.consumeCalls != 1 { + t.Fatalf("consume calls with a pre-resolve fence = %d, want exactly 1", ctrl.consumeCalls) } } diff --git a/backend/internal/app/migrations_test.go b/backend/internal/app/migrations_test.go index b9435b57..568e2696 100644 --- a/backend/internal/app/migrations_test.go +++ b/backend/internal/app/migrations_test.go @@ -419,3 +419,84 @@ func testColumnExists(t *testing.T, db *sql.DB, table, column string) bool { func ensureTestDir(path string) error { return os.MkdirAll(path, 0o755) } + +// TestRollbackToPreConsumeRestoresCompatSchema pins the PR #12 rollback contract: +// the previous binary (a996697, target 202609040002) refuses to start against a +// newer goose version AND its /accounts SELECTs codex_keeper_quota_resets, so a +// rollback MUST run the Down migrations first. This proves Down from the PR #12 head +// back to 202609040002 restores the (empty) compat table the old binary needs and +// removes the PR #12 objects. Down restores schema only, never historical counts. +func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { + ctx := context.Background() + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "rollback.sqlite3")) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + defer db.Close() + + goose.SetBaseFS(backendMigrations.FS) + if err := goose.SetDialect("sqlite3"); err != nil { + t.Fatal(err) + } + if err := goose.UpToContext(ctx, db, ".", backendMigrations.LatestVersion); err != nil { + t.Fatalf("migrate up to head: %v", err) + } + // PR #12 head schema. + if !testColumnExists(t, db, "codex_keeper_auth_states", "subscription_active_until") { + t.Fatal("head is missing subscription_active_until") + } + if !testColumnExists(t, db, "codex_keeper_auth_states", "account_id") { + t.Fatal("head is missing codex_keeper_auth_states.account_id") + } + if !testTableExists(t, db, "codex_keeper_reset_redeems") { + t.Fatal("head is missing codex_keeper_reset_redeems") + } + if testTableExists(t, db, "codex_keeper_quota_resets") { + t.Fatal("head should have dropped codex_keeper_quota_resets") + } + + // Rollback: Down to the version the previous binary targets. + const preConsumeVersion int64 = 202609040002 + if err := goose.DownToContext(ctx, db, ".", preConsumeVersion); err != nil { + t.Fatalf("migrate down to %d: %v", preConsumeVersion, err) + } + var version int64 + if err := db.QueryRow(`SELECT MAX(version_id) FROM goose_db_version`).Scan(&version); err != nil { + t.Fatalf("query goose version: %v", err) + } + if version != preConsumeVersion { + t.Fatalf("post-rollback goose version = %d, want %d", version, preConsumeVersion) + } + // The old binary needs the compat table back (empty), and the PR #12 objects gone. + if !testTableExists(t, db, "codex_keeper_quota_resets") { + t.Fatal("rollback did not restore the codex_keeper_quota_resets compat table") + } + if testTableExists(t, db, "codex_keeper_reset_redeems") { + t.Fatal("rollback left codex_keeper_reset_redeems behind") + } + if testColumnExists(t, db, "codex_keeper_auth_states", "subscription_active_until") { + t.Fatal("rollback left subscription_active_until behind") + } + if testColumnExists(t, db, "codex_keeper_auth_states", "account_id") { + t.Fatal("rollback left codex_keeper_auth_states.account_id behind") + } + + // Replay: from the prod baseline (202609040002) migrate Up to head again — the whole + // release must be re-runnable after a rollback (040002 → head → 040002 → head). + if err := goose.UpToContext(ctx, db, ".", backendMigrations.LatestVersion); err != nil { + t.Fatalf("replay up to head after rollback: %v", err) + } + if v := func() int64 { + var v int64 + _ = db.QueryRow(`SELECT MAX(version_id) FROM goose_db_version`).Scan(&v) + return v + }(); v != backendMigrations.LatestVersion { + t.Fatalf("post-replay version = %d, want head %d", v, backendMigrations.LatestVersion) + } + if !testColumnExists(t, db, "codex_keeper_auth_states", "account_id") || + !testTableExists(t, db, "codex_keeper_reset_redeems") || + testTableExists(t, db, "codex_keeper_quota_resets") { + t.Fatal("replay to head did not restore the full head schema") + } +} diff --git a/backend/internal/app/startup.go b/backend/internal/app/startup.go index ff4721d9..239d768c 100644 --- a/backend/internal/app/startup.go +++ b/backend/internal/app/startup.go @@ -7,9 +7,12 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" backendMigrations "cpa-helper/backend/migrations" + + "github.com/pressly/goose/v3" ) var ( @@ -79,6 +82,110 @@ func Migrate(ctx context.Context) (MigrationReport, error) { }, nil } +// allowedRollbackTargets whitelists the goose versions `migrate down-to` may roll back +// to. A downgrade is destructive — its Down migrations drop the columns/tables (and the +// data in them) added since the target — so only explicitly reviewed rollback targets +// are permitted, never an arbitrary version. +var allowedRollbackTargets = map[int64]bool{ + 202609040002: true, // pre-reset-credit-consume baseline (see docs/migrations-rollback.md) +} + +func sortedRollbackTargets() []int64 { + targets := make([]int64, 0, len(allowedRollbackTargets)) + for v := range allowedRollbackTargets { + targets = append(targets, v) + } + sort.Slice(targets, func(i, j int) bool { return targets[i] < targets[j] }) + return targets +} + +// pendingRedeemCount returns how many in-flight (pending) rows the redeem ledger holds, +// or 0 when the table does not exist yet (a partially-migrated DB below 202609060003). +func pendingRedeemCount(ctx context.Context, db *sql.DB) (int, error) { + var name string + err := db.QueryRowContext(ctx, `SELECT name FROM sqlite_master WHERE type='table' AND name='codex_keeper_reset_redeems'`).Scan(&name) + if errors.Is(err, sql.ErrNoRows) { + return 0, nil + } + if err != nil { + return 0, err + } + var n int + if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM codex_keeper_reset_redeems WHERE status = ?`, keeperRedeemStatusPending).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + +// MigrateDownTo rolls the schema DOWN to target, which MUST be an allowlisted rollback +// target. It refuses to act when target is not whitelisted or is not strictly below the +// current version. DESTRUCTIVE: the Down migrations drop everything added since target +// (for the reset-credit-consume release that includes subscription_active_until and its +// data). By default it also REFUSES when the redeem ledger holds any pending (unresolved, +// unknown-outcome) redeem, because dropping the ledger loses that redeem's unique +// idempotency key — a later re-upgrade + reset would mint a NEW key and could double +// consume. Pass allowPending=true only after reconciling those redeems and backing up. +// +// This is an OFFLINE operation: stop the CPA-Helper service first. The pending-redeem +// check is a pre-flight guard, NOT atomic protection — a still-running service could +// create a pending redeem between the check and the drop. Quiescence is the operator's +// responsibility per docs/migrations-rollback.md. +func MigrateDownTo(ctx context.Context, target int64, allowPending bool) (MigrationReport, error) { + if ctx == nil { + ctx = context.Background() + } + if !allowedRollbackTargets[target] { + return MigrationReport{}, fmt.Errorf("refusing to roll back to unlisted version %d; allowed targets: %v", target, sortedRollbackTargets()) + } + paths, err := resolveRuntimePaths() + if err != nil { + return MigrationReport{}, err + } + db, err := openRuntimeDB(paths, false) + if err != nil { + return MigrationReport{}, err + } + defer db.Close() + + before, err := currentMigrationVersion(ctx, db) + if err != nil { + return MigrationReport{DBPath: paths.DBPath, TargetVersion: target}, err + } + if before <= target { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, CurrentVersion: before, TargetVersion: target}, + fmt.Errorf("current version %d is not newer than target %d; nothing to roll back", before, target) + } + // Refuse to roll back a DB that is newer than this binary knows about: its embedded + // Down migrations would not cover the extra versions, so the rollback would be + // incomplete/unsafe. Use a binary that embeds those migrations instead. + if before > backendMigrations.LatestVersion { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, + fmt.Errorf("database version %d is newer than this binary (%d); roll back with a binary that embeds those migrations", before, backendMigrations.LatestVersion) + } + if !allowPending { + pending, perr := pendingRedeemCount(ctx, db) + if perr != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, perr + } + if pending > 0 { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, + fmt.Errorf("refusing to roll back: %d pending redeem(s) in codex_keeper_reset_redeems whose idempotency keys would be lost; reconcile them, back up, then re-run with --allow-pending", pending) + } + } + goose.SetBaseFS(backendMigrations.FS) + if err := goose.SetDialect("sqlite3"); err != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, err + } + if err := goose.DownToContext(ctx, db, ".", target); err != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, err + } + after, err := currentMigrationVersion(ctx, db) + if err != nil { + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, TargetVersion: target}, err + } + return MigrationReport{DBPath: paths.DBPath, PreviousVersion: before, CurrentVersion: after, TargetVersion: target}, nil +} + func CheckStartup(ctx context.Context) (StartupCheck, error) { if ctx == nil { ctx = context.Background() diff --git a/backend/migrations/202609060001_keeper_subscription_active_until.sql b/backend/migrations/202609060001_keeper_subscription_active_until.sql new file mode 100644 index 00000000..e41ddf47 --- /dev/null +++ b/backend/migrations/202609060001_keeper_subscription_active_until.sql @@ -0,0 +1,5 @@ +-- +goose Up +ALTER TABLE codex_keeper_auth_states ADD COLUMN subscription_active_until TIMESTAMP NULL; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN subscription_active_until; diff --git a/backend/migrations/202609060002_drop_keeper_quota_resets.sql b/backend/migrations/202609060002_drop_keeper_quota_resets.sql new file mode 100644 index 00000000..4e92b2be --- /dev/null +++ b/backend/migrations/202609060002_drop_keeper_quota_resets.sql @@ -0,0 +1,14 @@ +-- +goose Up +-- The manual quota-reset counter carried no business value (it counted local +-- cooldown clears, not real credit redemptions). Drop it now that resetKeeperQuota +-- redeems a real OpenAI reset credit instead of incrementing this table. +DROP TABLE IF EXISTS codex_keeper_quota_resets; + +-- +goose Down +-- Recreate the empty table schema so a rollback restores the shape (the historical +-- counts are intentionally not restored — they were worthless cooldown-clear tallies). +CREATE TABLE IF NOT EXISTS codex_keeper_quota_resets ( + auth_name VARCHAR(500) PRIMARY KEY, + reset_count INTEGER NOT NULL DEFAULT 0, + last_reset_at TIMESTAMP NULL +); diff --git a/backend/migrations/202609060003_keeper_reset_redeems.sql b/backend/migrations/202609060003_keeper_reset_redeems.sql new file mode 100644 index 00000000..c5292e61 --- /dev/null +++ b/backend/migrations/202609060003_keeper_reset_redeems.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- Persistent redeem ledger for real reset-credit consumption, keyed by the STABLE OpenAI +-- account_id (the resource identity). auth_name (a filename) and auth_index (a CPA hash of +-- the file path) are only routing selectors — both can change for the same account (file +-- rename / move / reorder) — so the idempotency key must NOT depend on them. A pending row +-- is an in-flight/unknown-outcome redeem whose redeem_request_id must be reused +-- idempotently on the next attempt for the SAME account (however it is now routed), so a +-- lost response can never burn a second credit. +CREATE TABLE IF NOT EXISTS codex_keeper_reset_redeems ( + account_id TEXT PRIMARY KEY, + redeem_request_id TEXT NOT NULL, + status TEXT NOT NULL, + updated_at TIMESTAMP NOT NULL +); + +-- +goose Down +DROP TABLE IF EXISTS codex_keeper_reset_redeems; diff --git a/backend/migrations/202609060004_keeper_account_id.sql b/backend/migrations/202609060004_keeper_account_id.sql new file mode 100644 index 00000000..2d09fa2d --- /dev/null +++ b/backend/migrations/202609060004_keeper_account_id.sql @@ -0,0 +1,10 @@ +-- +goose Up +-- Store the account_id (ChatGPT account identity) alongside each keeper auth state so the +-- subscription renewal snapshot can be scoped to the ACCOUNT, not just the auth_index. +-- CPA's EnsureIndex() hashes file auth by provider + file path, so swapping the same +-- filename to a different OpenAI account keeps auth_index unchanged; without account_id a +-- preserve-on-unknown subscription write would inherit the previous account's renewal date. +ALTER TABLE codex_keeper_auth_states ADD COLUMN account_id TEXT NULL; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN account_id; diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index 3af97a4f..c0c00b42 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -3,7 +3,7 @@ package migrations import "embed" // LatestVersion is the newest embedded migration version this binary expects. -const LatestVersion int64 = 202609040002 +const LatestVersion int64 = 202609060004 // FS contains SQL migrations embedded into the application binary. // diff --git a/docs/migrations-rollback.md b/docs/migrations-rollback.md new file mode 100644 index 00000000..fe7e4e30 --- /dev/null +++ b/docs/migrations-rollback.md @@ -0,0 +1,78 @@ +# Migration rollback runbook + +The database schema version is tracked by goose in `goose_db_version`. On startup the +app runs migrations up to `migrations.LatestVersion` and **refuses to start when the DB +version is newer than the binary** (goose reports +`database migration version is newer than this application`). A binary rollback +therefore always requires migrating the schema **down first**. + +## Rolling back the reset-credit-consume release (migrations 202609060001–202609060004) + +This release added, on top of `202609040002`: + +- `202609060001` — `codex_keeper_auth_states.subscription_active_until` column. +- `202609060002` — **DROP** of the obsolete `codex_keeper_quota_resets` table. +- `202609060003` — `codex_keeper_reset_redeems` (redeem ledger) table. +- `202609060004` — `codex_keeper_auth_states.account_id` column (subscription identity scope). + +The previous binary (`a996697`, target version `202609040002`) both refuses to start +against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in +`/accounts`. So you cannot just swap the binary back. + +**Rollback procedure (do this in order):** + +0. **Stop the CPA-Helper service and back up the SQLite database.** The downgrade is an + **offline** operation: `migrate down-to` must run against a quiescent database. Its + pending-redeem pre-flight check (step 1) is a safety net, **not** atomic protection — + a still-running service could create a pending redeem between the check and the drop. + Ensure no CPA-Helper process is running before proceeding. + +1. Migrate the schema down to the previous binary's target version using the real + `migrate down-to` subcommand (run it from the **current**, this-release binary): + + ``` + cpa-helper migrate down-to 202609040002 + ``` + + **Pending redeems:** the command **refuses** to run when the redeem ledger holds any + `status='pending'` row, because dropping the ledger loses that redeem's unique + idempotency key (a later re-upgrade + reset could then double-consume). Reconcile + those redeems first; only after that (and a backup) re-run with `--allow-pending`: + + ``` + cpa-helper migrate down-to 202609040002 --allow-pending + ``` + + This runs the Down migrations for `202609060004`, `202609060003`, `202609060002`, and + `202609060001`: it drops the `account_id` column, drops `codex_keeper_reset_redeems`, + drops the `subscription_active_until` column, and **recreates an empty + `codex_keeper_quota_resets`** so the old binary's `/accounts` query works. + `202609040002` is the only allowlisted rollback target; the command refuses any other + version and refuses to run when the DB is not newer than the target. + + > Do NOT expect a bare `cpa-helper migrate` to downgrade — it only runs migrations + > **Up**. The dedicated `migrate down-to` subcommand is required. + +2. Deploy the previous binary (`a996697`). + +**Data semantics — what the rollback loses:** + +- `codex_keeper_quota_resets` is restored as an **empty** table. The historical + manual-reset counts are not recovered (they carried no business value and were + deliberately discarded). +- `subscription_active_until` (the column **and every collected subscription-renewal + timestamp**) is dropped and **not** recovered. If you need it, restore from the backup + taken in step 0; otherwise accept the loss. +- `account_id` (the column **and every stored account identity**) is dropped and **not** + recovered. It is re-derived on the next inspection after re-upgrading, so the loss is + transient; restore from the step-0 backup only if you need it before re-upgrading. +- `codex_keeper_reset_redeems` (the in-flight redeem ledger) is dropped. Any unresolved + pending redeem is lost; after rollback the old binary cannot consume credits at all, so + this is acceptable. +- Other data — auth states, credit snapshots, usage records — is untouched by these Down + migrations. + +This contract is covered by `TestRollbackToPreConsumeRestoresCompatSchema` (Down restores +the compat table and removes the new objects) and by +`TestMigrateDownToRollsBackToTarget` (the `migrate down-to` CLI really moves the version +from the head to `202609040002` and rejects unlisted targets). diff --git a/frontend/scripts/i18n-smoke.mjs b/frontend/scripts/i18n-smoke.mjs index e4685696..70706e36 100644 --- a/frontend/scripts/i18n-smoke.mjs +++ b/frontend/scripts/i18n-smoke.mjs @@ -143,6 +143,60 @@ try { localizedServerMessage('该账号缺少 auth_index,请先刷新账号列表'), 'This account has no auth_index yet; refresh the account list first', ) + // Reset-quota (real credit consume) errors must localize to specific recovery + // guidance, not degrade to the generic validation message. + assert.equal( + localizedServerMessage('账号正在巡检或重置中,请稍后重试'), + 'The account is being inspected or reset. Try again shortly.', + ) + assert.equal( + localizedServerMessage('账号 auth_index 已变化,请刷新账号列表后重试'), + 'The account auth_index has changed. Refresh the account list and try again.', + ) + assert.equal( + localizedServerMessage('同一 OpenAI 账号的另一路由正在重置,请稍后重试'), + 'Another route for the same OpenAI account is being reset. Try again shortly.', + ) + assert.equal( + localizedServerMessage('账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照'), + 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.', + ) + assert.equal( + localizedServerMessage('账号 account_id 身份冲突(列表与详情不一致),请刷新后重试'), + 'Account account_id identity conflict (list and detail disagree). Refresh and try again.', + ) + assert.equal( + localizedServerMessage('账号不是 Codex 类型,无法主动重置'), + 'This account is not a Codex account and cannot be reset.', + ) + assert.equal( + localizedServerMessage('当前为 dry-run 模式,已阻止真实核销/重置;请关闭 dry-run 后重试'), + 'Dry-run mode blocked the real redemption/reset. Turn dry-run off and try again.', + ) + assert.equal( + localizedServerMessage('重置额度核销状态异常,请稍后重试'), + 'The reset-credit redemption state is inconsistent. Try again shortly.', + ) + assert.equal( + localizedServerMessage('账号缺少 account_id,无法安全核销,请刷新后重试'), + 'The account has no account_id; a safe redemption is not possible. Refresh and try again.', + ) + assert.equal( + localizedServerMessage('账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置'), + 'The account identity is not confirmed yet (no account_id). Refresh the account list before resetting.', + ) + assert.equal( + localizedServerMessage('无法确认可用重置额度(快照未知),请刷新后重试'), + 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.', + ) + assert.equal( + localizedServerMessage('核销主动重置额度失败:网络异常,未确认是否已核销'), + 'Failed to redeem the reset credit: network error; redemption is unconfirmed.', + ) + assert.equal( + localizedServerMessage('核销主动重置额度失败:OpenAI 拒绝核销'), + 'Failed to redeem the reset credit: OpenAI rejected the redemption.', + ) assert.equal(localizedServerMessage('auth_name 不能为空'), 'auth_name must not be empty') assert.equal(localizedServerMessage('账号不存在'), 'Account not found') assert.equal(localizedKeeperStatusDetail(null), 'Not running') diff --git a/frontend/src/features/codex-keeper/api/codexKeeperApi.ts b/frontend/src/features/codex-keeper/api/codexKeeperApi.ts index 148c8002..c6e69564 100644 --- a/frontend/src/features/codex-keeper/api/codexKeeperApi.ts +++ b/frontend/src/features/codex-keeper/api/codexKeeperApi.ts @@ -76,8 +76,29 @@ export function refreshCodexKeeperAccounts(payload: CodexKeeperRefreshPayload): return apiClient.post<void>('/codex-keeper/accounts/refresh', payload) } -export function resetCodexKeeperQuota(authName: string): Promise<void> { - return apiClient.post<void>('/codex-keeper/reset-quota', { auth_name: authName }) +// CodexKeeperResetOutcome is the real business result of a reset. reset = a credit was +// redeemed now; already_redeemed = an in-flight redeem resolved idempotently; no_credit +// / nothing_to_reset = OpenAI reported nothing was redeemed (distinct reasons); +// cooldown_only = no credit available, only the local 429 cooldown was cleared. +export type CodexKeeperResetOutcome = + | 'reset' + | 'already_redeemed' + | 'no_credit' + | 'nothing_to_reset' + | 'cooldown_only' + +export interface CodexKeeperResetResult { + status: string + account: { + name: string + outcome: CodexKeeperResetOutcome + } +} + +export function resetCodexKeeperQuota(authName: string): Promise<CodexKeeperResetResult> { + return apiClient.post<CodexKeeperResetResult>('/codex-keeper/reset-quota', { + auth_name: authName, + }) } export function updateCodexKeeperPriority(authName: string, priority: number): Promise<void> { diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index 01841bf3..1c4378dd 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -48,6 +48,7 @@ import { resetCodexKeeperQuota, updateCodexKeeperPriority, } from '@/features/codex-keeper/api/codexKeeperApi' +import type { CodexKeeperResetResult } from '@/features/codex-keeper/api/codexKeeperApi' import type { CodexKeeperAccount, CodexKeeperPriorityRule, @@ -97,8 +98,6 @@ const ACCOUNT_TABLE_VIRTUAL_THRESHOLD = 200 const CODEX_FIVE_HOUR_WINDOW_SECONDS = 5 * 60 * 60 const CODEX_WEEK_WINDOW_SECONDS = 7 * 24 * 60 * 60 const CODEX_MONTH_WINDOW_SECONDS = 30 * 24 * 60 * 60 -const disabledTableScrollX = 1588 -const normalTableScrollX = 2092 const KEEPER_STATUS_POLL_INTERVAL_MS = 3000 const REFRESH_STATUS_POLL_INTERVAL_MS = 1500 const message = useMessage() @@ -1188,20 +1187,21 @@ function renderQuotaUsageCell(account: CodexKeeperAccount) { function renderResetCreditScheduleCell(account: CodexKeeperAccount) { const credits = account.reset_credits ?? [] const count = account.reset_credit_count + // The authoritative count is reset_credit_count. When it is null the snapshot is + // unknown/stale — NEVER substitute credits.length (a possibly-truncated detail list) + // for it; show "未知/陈旧" so a stale count is not misread as an exact number. + const countText = count === null || count === undefined + ? t('未知(快照陈旧)', 'unknown (stale)') + : String(count) if (credits.length === 0) { - // Authoritative count with no detail rows still deserves a "0 次" / count line - // rather than a bare dash, so a truncated-but-nonzero snapshot is visible. - if (count === null || count === undefined) { - return '-' - } return h('div', { class: 'quota-reset-schedule-cell' }, [ - h('span', { class: 'quota-reset-schedule-label' }, t(`主动重置次数:${count}`, `Manual resets: ${count}`)), + h('span', { class: 'quota-reset-schedule-label' }, t(`可用重置额度:${countText}`, `Available credits: ${countText}`)), ]) } const header = h( 'span', { class: 'quota-reset-schedule-label' }, - t(`主动重置次数:${count ?? credits.length}`, `Manual resets: ${count ?? credits.length}`), + t(`可用重置额度:${countText}`, `Available credits: ${countText}`), ) const rows = credits.map((credit, index) => { const label = t(`第 ${index + 1} 次`, `#${index + 1}`) @@ -1222,6 +1222,34 @@ function renderResetCreditScheduleCell(account: CodexKeeperAccount) { return h('div', { class: 'quota-reset-schedule-cell' }, [header, ...rows]) } +// renderSubscriptionCell shows the ChatGPT subscription renewal time (parsed by +// CPA from the account's id_token `chatgpt_subscription_active_until` claim) plus +// a coarse countdown. A null value (no subscription / unknown) renders a dash. +function renderSubscriptionCell(account: CodexKeeperAccount) { + const value = account.subscription_active_until + if (!value) { + return '-' + } + const time = formatQuotaResetTime(value) + const countdown = formatQuotaResetCountdown(value) + return h('div', { class: 'quota-reset-schedule-cell' }, [ + h('span', { class: 'quota-reset-schedule-time' }, time ?? '-'), + countdown ? h('span', { class: 'quota-reset-schedule-countdown' }, `(${countdown})`) : null, + ]) +} + +// subscriptionDetailText renders the subscription renewal time plus countdown for the +// account detail drawer (task #77 requires the renewal on both the list and the drawer). +function subscriptionDetailText(account: CodexKeeperAccount): string { + const value = account.subscription_active_until + if (!value) { + return '-' + } + const time = formatQuotaResetTime(value) ?? formatDateTime(value) + const countdown = formatQuotaResetCountdown(value) + return countdown ? `${time}(${countdown})` : time +} + function renderAccountIdentityCell(account: CodexKeeperAccount) { const primary = account.email ?? account.name const statusCode = disabledStatusCodeText(account) @@ -1568,12 +1596,28 @@ function confirmDeleteAccount(account: CodexKeeperAccount) { ) } +function resetQuotaOutcomeText(outcome: CodexKeeperResetResult['account']['outcome']): string { + switch (outcome) { + case 'reset': + return t('已真实核销 1 次主动重置额度,并清理了冷却', 'Redeemed one reset credit and cleared the cooldown') + case 'already_redeemed': + return t('该次重置此前已核销(幂等),已清理冷却', 'This reset was already redeemed (idempotent); cooldown cleared') + case 'no_credit': + return t('OpenAI 返回无可用额度,仅清理了冷却', 'OpenAI reported no available credit; only the cooldown was cleared') + case 'nothing_to_reset': + return t('OpenAI 返回无需重置,仅清理了冷却', 'OpenAI reported nothing to reset; only the cooldown was cleared') + case 'cooldown_only': + default: + return t('无可用额度,仅清理了本地冷却', 'No credit available; only the local cooldown was cleared') + } +} + function resetQuotaAccount(account: CodexKeeperAccount) { return runAccountAction( account, 'reset-quota', () => resetCodexKeeperQuota(account.name), - t('配额状态已重置', 'Quota state reset'), + (result) => resetQuotaOutcomeText(result.account.outcome), ) } @@ -1581,8 +1625,8 @@ function confirmResetQuota(account: CodexKeeperAccount) { openAccountConfirm( t('重置配额状态', 'Reset Quota State'), t( - `重置 ${account.name} 在 CPA 侧的配额/冷却状态?已重置 ${account.quota_reset_count ?? 0} 次。`, - `Reset the CPA-side quota/cooldown state of ${account.name}? Reset ${account.quota_reset_count ?? 0} times so far.`, + `确认重置 ${account.name} 的配额与冷却状态?当前可用重置额度 ${account.reset_credit_count ?? '未知'},有额度时会真实消耗 1 次。`, + `Reset the quota/cooldown state of ${account.name}? Available reset credits: ${account.reset_credit_count ?? 'unknown'}; one is really consumed when available.`, ), t('确认重置', 'Confirm Reset'), 'warning', @@ -1714,11 +1758,11 @@ function isRowActing(account: CodexKeeperAccount): boolean { ) } -async function runAccountAction( +async function runAccountAction<T>( account: CodexKeeperAccount, actionType: AccountAction, - action: () => Promise<void>, - successText: string, + action: () => Promise<T>, + successText: string | ((result: T) => string), ) { const key = accountActionKey(account, actionType) if (actingActions.value.has(key)) { @@ -1726,8 +1770,8 @@ async function runAccountAction( } actingActions.value = new Set(actingActions.value).add(key) try { - await action() - message.success(successText) + const result = await action() + message.success(typeof successText === 'function' ? successText(result) : successText) await loadAccounts() if (selectedAccount.value?.name === account.name) { const freshAccount = accounts.value.find((item) => item.name === account.name) ?? null @@ -1780,6 +1824,12 @@ const baseColumns = computed<DataTableColumns<CodexKeeperAccount>>(() => [ width: 230, render: (row) => renderResetCreditScheduleCell(row), }, + { + title: t('续期时间', 'Renews At'), + key: 'subscription_active_until', + width: 170, + render: (row) => renderSubscriptionCell(row), + }, { title: t('最近巡检', 'Last Inspection'), key: 'last_checked_at', @@ -1953,6 +2003,18 @@ const normalColumns = computed<DataTableColumns<CodexKeeperAccount>>(() => [ normalActionColumn.value, ]) +// Derive the horizontal scroll width by summing the actual column widths so it can never +// drift out of sync when a column is added/removed (fixed right action column relies on +// scroll-x matching the true total). +function sumColumnWidths(columns: DataTableColumns<CodexKeeperAccount>): number { + return columns.reduce((total, column) => { + const width = (column as { width?: number }).width + return total + (typeof width === 'number' ? width : 0) + }, 0) +} +const disabledTableScrollX = computed(() => sumColumnWidths(disabledColumns.value)) +const normalTableScrollX = computed(() => sumColumnWidths(normalColumns.value)) + restoreAccountStatusPreferences() watch( @@ -2561,6 +2623,9 @@ onBeforeUnmount(() => { <NDescriptionsItem :label="t('最近巡检', 'Last Inspection')"> {{ formatDateTime(selectedAccount.last_checked_at) }} </NDescriptionsItem> + <NDescriptionsItem :label="t('续期时间', 'Renews At')"> + {{ subscriptionDetailText(selectedAccount) }} + </NDescriptionsItem> <NDescriptionsItem :label="t('最近操作', 'Latest Action')"> {{ latestActionText(selectedAccount) }} </NDescriptionsItem> diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts index fda8f142..1844990a 100644 --- a/frontend/src/shared/i18n/messages.ts +++ b/frontend/src/shared/i18n/messages.ts @@ -13,6 +13,41 @@ const exactServerMessages: MessagePair[] = [ ['服务器内部错误', 'Internal server error'], ['CLIProxyAPI 未确认重置成功(响应缺少 status=ok 或 auth_index 不匹配)', 'CLIProxyAPI did not confirm the reset (response missing status=ok or auth_index mismatch)'], ['该账号缺少 auth_index,请先刷新账号列表', 'This account has no auth_index yet; refresh the account list first'], + // Reset-quota (real credit consume) errors — busy / identity / ledger / fail-closed. + ['账号正在巡检或重置中,请稍后重试', 'The account is being inspected or reset. Try again shortly.'], + ['同一 OpenAI 账号的另一路由正在重置,请稍后重试', 'Another route for the same OpenAI account is being reset. Try again shortly.'], + ['账号在远端列表中不存在,请刷新后重试', 'The account is not in the remote list. Refresh and try again.'], + ['读取账号详情失败,未执行主动重置', 'Failed to read account detail; the reset was not performed.'], + ['当前为 dry-run 模式,已阻止真实核销/重置;请关闭 dry-run 后重试', 'Dry-run mode blocked the real redemption/reset. Turn dry-run off and try again.'], + ['Keeper 未初始化,无法安全重置', 'The keeper is not initialized; a safe reset is not possible.'], + ['Keeper 未初始化,无法安全删除', 'The keeper is not initialized; a safe delete is not possible.'], + ['该账号存在未完成的核销记录,请先对账处理后再删除', 'This account has an unresolved redemption record; reconcile it before deleting.'], + ['Keeper 未初始化,无法安全操作', 'The keeper is not initialized; this operation is not safe.'], + ['重置额度核销状态异常,请稍后重试', 'The reset-credit redemption state is inconsistent. Try again shortly.'], + ['已消耗 1 次主动重置额度,但清理本地冷却失败;请稍后重试(系统会复用同一凭据幂等重试,不会重复消耗)', 'One reset credit was consumed but clearing the local cooldown failed; retry shortly (it idempotently reuses the same request and will not consume again).'], + ['账号不是 Codex 类型,无法主动重置', 'This account is not a Codex account and cannot be reset.'], + ['账号类型冲突(详情非 Codex),请刷新后重试', 'Account type conflict (the detail is not Codex). Refresh and try again.'], + ['远端存在多个同名账号条目,无法安全重置,请核对后重试', 'The remote list has multiple entries with this name; a safe reset is not possible. Verify and try again.'], + ['账号详情名称不匹配,请刷新后重试', 'The account detail name does not match. Refresh and try again.'], + ['账号缺少 access_token,无法安全核销,请刷新后重试', 'The account has no access_token; a safe redemption is not possible. Refresh and try again.'], + ['列表条目 auth_index 字段自相矛盾,请刷新后重试', 'The list entry has self-conflicting auth_index fields. Refresh and try again.'], + ['详情 auth_index 字段自相矛盾,请刷新后重试', 'The detail has self-conflicting auth_index fields. Refresh and try again.'], + ['详情 account_id 字段自相矛盾,请刷新后重试', 'The detail has self-conflicting account_id fields. Refresh and try again.'], + ['列表条目 account_id 字段自相矛盾,请刷新后重试', 'The list entry has self-conflicting account_id fields. Refresh and try again.'], + ['账号 auth_index 身份冲突(列表与详情不一致),请刷新后重试', 'Account auth_index identity conflict (list and detail disagree). Refresh and try again.'], + ['无法确定账号 auth_index,请刷新账号列表后重试', 'Cannot determine the account auth_index. Refresh the account list and try again.'], + ['账号 account_id 身份冲突(列表与详情不一致),请刷新后重试', 'Account account_id identity conflict (list and detail disagree). Refresh and try again.'], + ['账号缺少 account_id,无法安全核销,请刷新后重试', 'The account has no account_id; a safe redemption is not possible. Refresh and try again.'], + ['账号 auth_index 已变化,请刷新账号列表后重试', 'The account auth_index has changed. Refresh the account list and try again.'], + ['账号身份已变化(account_id 不一致),请刷新账号列表后重试', 'The account identity has changed (account_id mismatch). Refresh the account list and try again.'], + ['账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置', 'The account identity is not confirmed yet (no account_id). Refresh the account list before resetting.'], + ['账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照', 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.'], + ['无法确认可用重置额度(快照未知),请刷新后重试', 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.'], + ['核销主动重置额度失败:网络异常,未确认是否已核销', 'Failed to redeem the reset credit: network error; redemption is unconfirmed.'], + ['核销主动重置额度失败:管理接口异常', 'Failed to redeem the reset credit: management API error.'], + ['核销主动重置额度失败:响应无效', 'Failed to redeem the reset credit: invalid response.'], + ['核销主动重置额度失败:OpenAI 拒绝核销', 'Failed to redeem the reset credit: OpenAI rejected the redemption.'], + ['核销主动重置额度失败:响应状态未知', 'Failed to redeem the reset credit: unknown response status.'], ['auth_name 不能为空', 'auth_name must not be empty'], ['账号不存在', 'Account not found'], ['请求体不是有效 JSON', 'Request body is not valid JSON'], diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index ee8ee663..06133f68 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -251,10 +251,9 @@ export interface CodexKeeperAccount { latest_action: string | null last_checked_at: string | null last_healthy_at: string | null - quota_reset_count: number - last_quota_reset_at: string | null reset_credit_count: number | null reset_credits: CodexKeeperResetCredit[] | null + subscription_active_until: string | null } export interface CodexKeeperResetCredit { From 7884d3757bbd2c4c67120d9b546ccab59a469f5e Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Wed, 9 Sep 2026 21:55:56 +0800 Subject: [PATCH 19/25] feat(codex-keeper): support Antigravity quota inspection (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add provider-dispatched Antigravity quota inspection, identity-bound snapshot persistence, and localized quota presentation across Keeper account surfaces. Reviewed source exact: - base: da4484f6a72aae3145a80bd0953c731a5c0c1664 - head: bdb03e98ab2162626af2ddf1db88a7f452a99927 - tree: 1936ce8a4f96c7e0dee49ba69b38bc754399849f Signed-off-by: feiniu <a-9b3ff9ce@mail.build> Signed-off-by: 怪味胡豆 <raft-mobile-guaiweihudou@mail.build> --- backend/cmd/cpa-helper/main_test.go | 6 +- backend/internal/app/codex_keeper.go | 233 ++-- .../internal/app/codex_keeper_antigravity.go | 529 ++++++++++ .../codex_keeper_antigravity_internal_test.go | 997 ++++++++++++++++++ backend/internal/app/migrations_test.go | 13 + ...2609060005_keeper_provider_antigravity.sql | 17 + backend/migrations/migrations.go | 2 +- docs/migrations-rollback.md | 8 +- frontend/package.json | 6 +- .../scripts/antigravity-countdown-smoke.mjs | 73 ++ .../antigravity-quota-format-smoke.mjs | 39 + frontend/scripts/antigravity-window-smoke.mjs | 47 + frontend/scripts/i18n-smoke.mjs | 18 + .../scripts/keeper-quota-exhaustion-smoke.mjs | 50 + .../codex-keeper/antigravityCountdown.ts | 48 + .../codex-keeper/antigravityQuotaFormat.ts | 25 + .../codex-keeper/antigravityWindow.ts | 24 + .../codex-keeper/keeperQuotaExhaustion.ts | 18 + .../views/CodexKeeperInspectionView.vue | 6 +- .../views/CodexKeeperStatusView.vue | 376 ++++++- frontend/src/shared/i18n/messages.ts | 28 +- frontend/src/shared/types/api.ts | 17 + 22 files changed, 2456 insertions(+), 124 deletions(-) create mode 100644 backend/internal/app/codex_keeper_antigravity.go create mode 100644 backend/internal/app/codex_keeper_antigravity_internal_test.go create mode 100644 backend/migrations/202609060005_keeper_provider_antigravity.sql create mode 100644 frontend/scripts/antigravity-countdown-smoke.mjs create mode 100644 frontend/scripts/antigravity-quota-format-smoke.mjs create mode 100644 frontend/scripts/antigravity-window-smoke.mjs create mode 100644 frontend/scripts/keeper-quota-exhaustion-smoke.mjs create mode 100644 frontend/src/features/codex-keeper/antigravityCountdown.ts create mode 100644 frontend/src/features/codex-keeper/antigravityQuotaFormat.ts create mode 100644 frontend/src/features/codex-keeper/antigravityWindow.ts create mode 100644 frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts diff --git a/backend/cmd/cpa-helper/main_test.go b/backend/cmd/cpa-helper/main_test.go index 6529adba..3723f258 100644 --- a/backend/cmd/cpa-helper/main_test.go +++ b/backend/cmd/cpa-helper/main_test.go @@ -49,8 +49,8 @@ func TestMigrateDownToRollsBackToTarget(t *testing.T) { if !strings.Contains(out, "current_version=202609040002") { t.Fatalf("rollback did not reach 202609040002: %s", out) } - if !strings.Contains(out, "previous_version=202609060004") { - t.Fatalf("rollback did not start from head 202609060004: %s", out) + if !strings.Contains(out, "previous_version=202609060005") { + t.Fatalf("rollback did not start from head 202609060005: %s", out) } // A non-allowlisted target is refused. @@ -101,7 +101,7 @@ func TestMigrateDownToRefusesPendingRedeems(t *testing.T) { if err := run(ctx, []string{"migrate", "down-to", "202609040002"}, &bytes.Buffer{}); err == nil { t.Fatal("rollback should be refused while a pending redeem exists") } - if v := currentVersionForTest(t, dbPath); v != 202609060004 { + if v := currentVersionForTest(t, dbPath); v != 202609060005 { t.Fatalf("refused rollback still changed version to %d", v) } diff --git a/backend/internal/app/codex_keeper.go b/backend/internal/app/codex_keeper.go index 28d86954..00111eff 100644 --- a/backend/internal/app/codex_keeper.go +++ b/backend/internal/app/codex_keeper.go @@ -147,27 +147,37 @@ type keeperQuotaResetRequest struct { } type keeperAccount struct { - Name string `json:"name"` - Email *string `json:"email"` - AuthIndex *string `json:"auth_index"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *time.Time `json:"primary_reset_at"` - SecondaryResetAt *time.Time `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - ResetCreditCount *int `json:"reset_credit_count"` - ResetCredits []keeperResetCredit `json:"reset_credits"` - SubscriptionActiveUntil *time.Time `json:"subscription_active_until"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *time.Time `json:"last_checked_at"` - LastHealthyAt *time.Time `json:"last_healthy_at"` + Name string `json:"name"` + Email *string `json:"email"` + AuthIndex *string `json:"auth_index"` + AccountType *string `json:"account_type"` + // Provider is the upstream this account belongs to: "codex" or "antigravity". Nil/absent is + // treated as codex (rows predating multi-provider support). The frontend uses it to choose the + // Codex vs Antigravity quota UI and to hide Codex-only actions (reset/subscription). + Provider *string `json:"provider"` + // AntigravityQuota is the Antigravity quota summary (groups -> buckets) for antigravity + // accounts; nil for codex accounts. + AntigravityQuota []keeperAntigravityGroup `json:"antigravity_quota"` + // AntigravityIdentityDigest is the Google project the quota belongs to (the resource identity used + // to detect a project swap). Internal — not exposed in the API. + AntigravityIdentityDigest *string `json:"-"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *time.Time `json:"primary_reset_at"` + SecondaryResetAt *time.Time `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCredit `json:"reset_credits"` + SubscriptionActiveUntil *time.Time `json:"subscription_active_until"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *time.Time `json:"last_checked_at"` + LastHealthyAt *time.Time `json:"last_healthy_at"` } // keeperResetCredit is the safe projection of one entry from @@ -195,28 +205,30 @@ type keeperResetCreditResponse struct { } type keeperAccountResponse struct { - Name string `json:"name"` - Email *string `json:"email"` - AccountType *string `json:"account_type"` - Disabled bool `json:"disabled"` - Priority *int `json:"priority"` - PrimaryUsedPercent *int `json:"primary_used_percent"` - SecondaryUsedPercent *int `json:"secondary_used_percent"` - PrimaryResetAt *string `json:"primary_reset_at"` - SecondaryResetAt *string `json:"secondary_reset_at"` - PrimaryWindowSeconds *int `json:"primary_window_seconds"` - SecondaryWindowSeconds *int `json:"secondary_window_seconds"` - PrimaryWindowUsage *keeperQuotaWindowUsageResponse `json:"primary_window_usage"` - SecondaryWindowUsage *keeperQuotaWindowUsageResponse `json:"secondary_window_usage"` - QuotaThreshold *int `json:"quota_threshold"` - LastStatusCode *int `json:"last_status_code"` - LastError *string `json:"last_error"` - LatestAction *string `json:"latest_action"` - LastCheckedAt *string `json:"last_checked_at"` - LastHealthyAt *string `json:"last_healthy_at"` - ResetCreditCount *int `json:"reset_credit_count"` - ResetCredits []keeperResetCreditResponse `json:"reset_credits"` - SubscriptionActiveUntil *string `json:"subscription_active_until"` + Name string `json:"name"` + Email *string `json:"email"` + AccountType *string `json:"account_type"` + Provider *string `json:"provider"` + AntigravityQuota []keeperAntigravityGroupResponse `json:"antigravity_quota"` + Disabled bool `json:"disabled"` + Priority *int `json:"priority"` + PrimaryUsedPercent *int `json:"primary_used_percent"` + SecondaryUsedPercent *int `json:"secondary_used_percent"` + PrimaryResetAt *string `json:"primary_reset_at"` + SecondaryResetAt *string `json:"secondary_reset_at"` + PrimaryWindowSeconds *int `json:"primary_window_seconds"` + SecondaryWindowSeconds *int `json:"secondary_window_seconds"` + PrimaryWindowUsage *keeperQuotaWindowUsageResponse `json:"primary_window_usage"` + SecondaryWindowUsage *keeperQuotaWindowUsageResponse `json:"secondary_window_usage"` + QuotaThreshold *int `json:"quota_threshold"` + LastStatusCode *int `json:"last_status_code"` + LastError *string `json:"last_error"` + LatestAction *string `json:"latest_action"` + LastCheckedAt *string `json:"last_checked_at"` + LastHealthyAt *string `json:"last_healthy_at"` + ResetCreditCount *int `json:"reset_credit_count"` + ResetCredits []keeperResetCreditResponse `json:"reset_credits"` + SubscriptionActiveUntil *string `json:"subscription_active_until"` } type keeperQuotaWindowUsageResponse struct { @@ -343,6 +355,15 @@ type keeperAccountResult struct { // StateWriteFailed is set when persisting this result to the DB failed, so the // inspection did not actually update the stored state. StateWriteFailed bool + // Provider is the upstream this inspection processed ("codex" or "antigravity"). + Provider *string + // AntigravityQuota is the parsed Antigravity quota summary serialized to JSON; nil (a + // failed/skipped fetch) preserves the previous snapshot in upsertKeeperState via COALESCE. + AntigravityQuota *string + // AntigravityIdentityDigest is the resolved Google project id (resource identity). It is set even + // when the quota fetch fails, so upsertKeeperState can detect a project SWAP and clear the + // stale quota rather than preserving it. + AntigravityIdentityDigest *string } func NewKeeperRunner(app *App) *KeeperRunner { @@ -752,18 +773,18 @@ func keeperStatusModePtr(modes []string) *string { func keeperRunningDetail(modes []string) string { if len(modes) > 1 { - return "正在运行多个 Codex Keeper 任务" + return "正在运行多个 Keeper 任务" } if len(modes) == 0 { return "尚未运行" } switch modes[0] { case "accounts": - return "正在刷新 Codex 账号" + return "正在刷新账号" case "conditional": - return "正在按条件刷新 Codex 账号" + return "正在按条件刷新账号" default: - return "正在巡检 Codex 账号" + return "正在巡检账号" } } @@ -1319,6 +1340,8 @@ func keeperAccountResponses(accounts []keeperAccount, windowUsages map[string]ke Name: account.Name, Email: account.Email, AccountType: account.AccountType, + Provider: account.Provider, + AntigravityQuota: keeperAntigravityQuotaResponses(account.AntigravityQuota), Disabled: account.Disabled, Priority: keeperDisplayPriority(account.Priority), PrimaryUsedPercent: account.PrimaryUsedPercent, @@ -1873,11 +1896,11 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun targetSet[name] = true } if options.Mode == "conditional" { - logFn(fmt.Sprintf("开始按条件刷新 %d 个 Codex 账号", len(targetSet))) + logFn(fmt.Sprintf("开始按条件刷新 %d 个账号", len(targetSet))) } else if len(targetSet) > 0 { - logFn(fmt.Sprintf("开始刷新 %d 个 Codex 账号", len(targetSet))) + logFn(fmt.Sprintf("开始刷新 %d 个账号", len(targetSet))) } else { - logFn("开始 Codex 账号巡检") + logFn("开始账号巡检") } stats := keeperStats{} detail := "巡检完成" @@ -1891,7 +1914,7 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun filtered := make([]map[string]any, 0, len(authFiles)) remoteCodexNames := map[string]bool{} for _, item := range authFiles { - if keeperString(item["type"]) != "codex" { + if !keeperIsInspectableProvider(keeperString(item["type"])) { continue } name := keeperString(item["name"]) @@ -1911,13 +1934,24 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun return stats, "", err } if pruned > 0 { - logFn(fmt.Sprintf("清理本地已不存在的 Codex 账号 %d 个", pruned)) + logFn(fmt.Sprintf("清理本地已不存在的账号 %d 个", pruned)) } } stats.Total = len(filtered) if cfg.CodexKeeper.EnableCredentialWebsockets && !cfg.CodexKeeper.DryRun { + // The credential-websocket transport is Codex-specific; keep non-Codex (Antigravity) + // items out of it and recombine afterward so they are still inspected. + var codexItems, otherItems []map[string]any + for _, item := range filtered { + if keeperString(item["type"]) == keeperProviderAntigravity { + otherItems = append(otherItems, item) + } else { + codexItems = append(codexItems, item) + } + } var websocketFailures []keeperAccountResult - filtered, websocketFailures = a.ensureKeeperAuthWebsockets(ctx, cfg, options.Mode, filtered, logFn, options.TryLockAuthName, options.UnlockAuthName) + codexItems, websocketFailures = a.ensureKeeperAuthWebsockets(ctx, cfg, options.Mode, codexItems, logFn, options.TryLockAuthName, options.UnlockAuthName) + filtered = append(codexItems, otherItems...) for _, result := range websocketFailures { a.mergeKeeperStats(&stats, result) if runID > 0 { @@ -1948,11 +1982,11 @@ func (a *App) executeKeeperRunWithOptions(ctx context.Context, options keeperRun if stats.NetworkError > 0 { detail = fmt.Sprintf("巡检完成:网络错误 %d", stats.NetworkError) } else if stats.Total > 0 && options.UseRefreshCache { - detail = "缓存时间内没有需要自动刷新的 Codex auth file" + detail = "缓存时间内没有需要自动刷新的 auth file" } else if len(targetSet) > 0 { - detail = "未发现指定 Codex auth file" + detail = "未发现指定 auth file" } else { - detail = "未发现 Codex auth file" + detail = "未发现 auth file" } if runID > 0 { _ = a.finishKeeperRun(ctx, runID, "completed", detail, stats) @@ -2187,10 +2221,14 @@ func (a *App) reconcileKeeperConditionalRemoteAuthStates(ctx context.Context, cf if err != nil { return err } + // remoteNames is the EXISTENCE full-set used for prune protection — it must include every + // inspectable provider (codex + antigravity), otherwise a conditional tick would prune a + // still-present Antigravity row that a full inspection just wrote. refreshableRemoteNames is + // the set of enabled accounts eligible for a conditional refresh. remoteNames := map[string]bool{} refreshableRemoteNames := map[string]bool{} for _, item := range authFiles { - if keeperString(item["type"]) != "codex" { + if !keeperIsInspectableProvider(keeperString(item["type"])) { continue } name := keeperString(item["name"]) @@ -2604,8 +2642,13 @@ func (a *App) ensureKeeperAuthWebsockets( if err := a.setKeeperRemoteWebsockets(ctx, cfg, name); err != nil { message := "启用 WebSocket 传输失败:" + err.Error() disabled := keeperBool(item["disabled"]) + // This path only runs for Codex items (antigravity is split out before the websocket + // step), so tag the result Codex — otherwise upsertKeeperState's COALESCE would keep a + // stale provider/antigravity_quota from a row that was previously an Antigravity file. + codex := keeperProviderCodex result := keeperAccountResult{ Name: name, + Provider: &codex, AuthIndex: keeperRemoteAuthIndex(item), AccountType: accountTypeFromKeeperDetail(item, nil), Disabled: &disabled, @@ -2630,12 +2673,18 @@ func (a *App) ensureKeeperAuthWebsockets( } func (a *App) processKeeperAuth(ctx context.Context, cfg AppConfig, authInfo map[string]any, logFn func(string), manualRefresh bool) keeperAccountResult { + // Dispatch by provider: an Antigravity account takes a separate quota path with none of the + // ChatGPT-specific usage/reset/subscription logic below. + if keeperString(authInfo["type"]) == keeperProviderAntigravity { + return a.processKeeperAntigravityAuth(ctx, cfg, authInfo, logFn, manualRefresh) + } now := time.Now().In(appTimeLocation) name := keeperString(authInfo["name"]) if name == "" { name = "unknown" } - result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now} + codexProvider := keeperProviderCodex + result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now, Provider: &codexProvider} // Subscription renewal time comes from the auth-file list entry's parsed // id_token claims (available regardless of the usage-fetch outcome), so set it // up front to persist on every path. @@ -3084,7 +3133,12 @@ func (stats *keeperStats) mergeCachedState(state keeperAuthState) { stats.NetworkError++ return } - if state.Priority != nil && *state.Priority == -1 { + // The quota-usage → priority=-1 "degraded" semantic is Codex-only; Antigravity has no such + // mechanism (priority is managed differently and -1 is not a quota-exhaustion marker). So an + // Antigravity cached row at priority -1 must NOT be counted as degraded — it falls through to + // the healthy/timestamp branch like any other provider. Mirrors the frontend's + // isQuotaExhaustedAccount() !isAntigravity guard. + if *keeperProviderOrCodex(state.Provider) != keeperProviderAntigravity && state.Priority != nil && *state.Priority == -1 { stats.PriorityDegraded++ return } @@ -3522,7 +3576,8 @@ func (a *App) listKeeperAccounts(ctx context.Context) ([]keeperAccount, error) { secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), - reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT) + reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT), + provider, CAST(antigravity_quota AS TEXT), antigravity_identity_digest FROM codex_keeper_auth_states ORDER BY COALESCE(email, ''), auth_name `) @@ -4248,7 +4303,8 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState secondary_used_percent, CAST(primary_reset_at AS TEXT), CAST(secondary_reset_at AS TEXT), quota_threshold, last_status_code, last_error, latest_action, CAST(last_checked_at AS TEXT), CAST(last_healthy_at AS TEXT), primary_window_seconds, secondary_window_seconds, restore_priority, CAST(created_at AS TEXT), CAST(updated_at AS TEXT), - reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT) + reset_credit_count, CAST(reset_credits AS TEXT), CAST(subscription_active_until AS TEXT), CAST(account_id AS TEXT), + provider, CAST(antigravity_quota AS TEXT), antigravity_identity_digest FROM codex_keeper_auth_states WHERE auth_name = ? `, name) if err != nil { @@ -4267,17 +4323,20 @@ func (a *App) getKeeperState(ctx context.Context, name string) (*keeperAuthState func scanKeeperState(scanner interface{ Scan(dest ...any) error }) (keeperAuthState, error) { var state keeperAuthState - var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt, resetCredits, subscriptionActiveUntil, accountID sql.NullString + var email, authIndex, accountType, primaryReset, secondaryReset, lastError, latestAction, lastChecked, lastHealthy, createdAt, updatedAt, resetCredits, subscriptionActiveUntil, accountID, provider, antigravityQuota, antigravityProjectDigest sql.NullString var priority, primaryUsed, secondaryUsed, quotaThreshold, lastStatus, primaryWindowSeconds, secondaryWindowSeconds, restorePriority, resetCreditCount sql.NullInt64 err := scanner.Scan( &state.Name, &email, &authIndex, &accountType, &state.Disabled, &priority, &primaryUsed, &secondaryUsed, &primaryReset, &secondaryReset, "aThreshold, &lastStatus, &lastError, &latestAction, &lastChecked, &lastHealthy, &primaryWindowSeconds, &secondaryWindowSeconds, &restorePriority, - &createdAt, &updatedAt, &resetCreditCount, &resetCredits, &subscriptionActiveUntil, &accountID, + &createdAt, &updatedAt, &resetCreditCount, &resetCredits, &subscriptionActiveUntil, &accountID, &provider, &antigravityQuota, &antigravityProjectDigest, ) if err != nil { return keeperAuthState{}, err } + state.Provider = keeperProviderOrCodex(nullableString(provider)) + state.AntigravityQuota = parseStoredAntigravityQuota(antigravityQuota) + state.AntigravityIdentityDigest = nullableString(antigravityProjectDigest) state.Email = nullableString(email) state.AuthIndex = nullableString(authIndex) state.AccountType = keeperAccountTypeOrUnknown(nullableString(accountType)) @@ -4320,19 +4379,24 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) auth_name, email, auth_index, account_type, disabled, priority, restore_priority, latest_action, last_error, last_status_code, primary_used_percent, secondary_used_percent, quota_threshold, primary_reset_at, secondary_reset_at, primary_window_seconds, secondary_window_seconds, - reset_credit_count, reset_credits, subscription_active_until, account_id, + reset_credit_count, reset_credits, subscription_active_until, account_id, provider, antigravity_quota, antigravity_identity_digest, last_checked_at, last_healthy_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(auth_name) DO UPDATE SET -- Track the current account identity, keeping the old value only on a - -- fetch where no account_id was observed. - account_id = COALESCE(excluded.account_id, codex_keeper_auth_states.account_id), + -- fetch where no account_id was observed. An antigravity inspection has no ChatGPT + -- account_id, so a provider switch to antigravity clears the stale Codex identity. + account_id = CASE + WHEN excluded.provider = 'antigravity' THEN NULL + ELSE COALESCE(excluded.account_id, codex_keeper_auth_states.account_id) + END, email = excluded.email, auth_index = excluded.auth_index, account_type = excluded.account_type, disabled = excluded.disabled, priority = excluded.priority, restore_priority = CASE + WHEN excluded.provider = 'antigravity' THEN NULL WHEN ? THEN NULL WHEN excluded.restore_priority IS NOT NULL THEN excluded.restore_priority ELSE codex_keeper_auth_states.restore_priority @@ -4356,6 +4420,7 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) -- writes NULL rather than inheriting the previous account's count/schedule). -- 3. same/undeterminable account → COALESCE: write a fresh fetch, else preserve. reset_credit_count = CASE + WHEN excluded.provider = 'antigravity' THEN NULL WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.reset_credit_count WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL AND codex_keeper_auth_states.account_id <> excluded.account_id @@ -4363,6 +4428,7 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) ELSE COALESCE(excluded.reset_credit_count, codex_keeper_auth_states.reset_credit_count) END, reset_credits = CASE + WHEN excluded.provider = 'antigravity' THEN NULL WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.reset_credits WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL AND codex_keeper_auth_states.account_id <> excluded.account_id @@ -4379,6 +4445,7 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) -- incoming value so the new account never inherits the old renewal date. -- 4. otherwise (same/undeterminable account) → preserve on an unknown claim. subscription_active_until = CASE + WHEN excluded.provider = 'antigravity' THEN NULL WHEN ? AND excluded.auth_index IS NOT NULL THEN excluded.subscription_active_until WHEN excluded.auth_index IS NULL THEN codex_keeper_auth_states.subscription_active_until WHEN codex_keeper_auth_states.account_id IS NOT NULL AND excluded.account_id IS NOT NULL @@ -4386,10 +4453,40 @@ func (a *App) upsertKeeperState(ctx context.Context, result keeperAccountResult) THEN excluded.subscription_active_until ELSE COALESCE(excluded.subscription_active_until, codex_keeper_auth_states.subscription_active_until) END, + -- provider: keep the stored value when this inspection did not set it (a Codex path + -- leaves it nil); antigravity_quota: preserve on a failed/skipped fetch (nil) like + -- reset_credits, otherwise write the fresh snapshot. + provider = COALESCE(excluded.provider, codex_keeper_auth_states.provider), + -- The antigravity identity digest is the resource identity. A codex inspection clears it + -- (not a codex column). An antigravity inspection with a RESOLVED identity writes the + -- fresh digest; when the identity is UNKNOWN this inspection (excluded digest NULL, e.g. + -- the detail read failed) it must PRESERVE the stored digest — wiping it would erase the + -- swap-detection anchor and let the next inspection's COALESCE keep a different account's + -- quota. So COALESCE(fresh, stored) rather than force-writing the (possibly NULL) fresh. + antigravity_identity_digest = CASE + WHEN excluded.provider = 'codex' THEN NULL + ELSE COALESCE(excluded.antigravity_identity_digest, codex_keeper_auth_states.antigravity_identity_digest) + END, + antigravity_quota = CASE + WHEN excluded.provider = 'codex' THEN NULL + -- Incoming identity is KNOWN and the stored quota is NOT proven to belong to it — + -- either the stored digest is NULL (legacy/unbound snapshot: no identity binding, so + -- it cannot be shown to be this account's) OR the stored digest DIFFERS (confirmed + -- swap). Write the incoming value, which is NULL on a failed fetch, so an unprovable + -- or stale quota is CLEARED rather than inherited by the current identity. + WHEN excluded.antigravity_identity_digest IS NOT NULL + AND (codex_keeper_auth_states.antigravity_identity_digest IS NULL + OR codex_keeper_auth_states.antigravity_identity_digest <> excluded.antigravity_identity_digest) + THEN excluded.antigravity_quota + -- Same proven identity, or incoming identity UNKNOWN (excluded digest NULL): preserve + -- the stored quota across a transient fetch failure (COALESCE keeps stored when the + -- fresh value is NULL). Preserve-on-unknown pairs with the digest COALESCE above. + ELSE COALESCE(excluded.antigravity_quota, codex_keeper_auth_states.antigravity_quota) + END, last_checked_at = excluded.last_checked_at, last_healthy_at = COALESCE(excluded.last_healthy_at, codex_keeper_auth_states.last_healthy_at), updated_at = excluded.updated_at - `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, result.ResetCreditCount, result.ResetCredits, dbTimePtr(result.SubscriptionActiveUntil), result.AccountID, checkedAt, lastHealthy, now, now, result.ClearRestorePriority, boolValue(&result.SubscriptionKnown)) + `, result.Name, result.Email, result.AuthIndex, result.AccountType, boolValue(result.Disabled), result.Priority, result.RestorePriority, result.LatestAction, result.LastError, result.LastStatusCode, result.PrimaryUsedPercent, result.SecondaryUsedPercent, result.QuotaThreshold, dbTimePtr(result.PrimaryResetAt), dbTimePtr(result.SecondaryResetAt), result.PrimaryWindowSeconds, result.SecondaryWindowSeconds, result.ResetCreditCount, result.ResetCredits, dbTimePtr(result.SubscriptionActiveUntil), result.AccountID, result.Provider, result.AntigravityQuota, result.AntigravityIdentityDigest, checkedAt, lastHealthy, now, now, result.ClearRestorePriority, boolValue(&result.SubscriptionKnown)) return err } diff --git a/backend/internal/app/codex_keeper_antigravity.go b/backend/internal/app/codex_keeper_antigravity.go new file mode 100644 index 00000000..733277e0 --- /dev/null +++ b/backend/internal/app/codex_keeper_antigravity.go @@ -0,0 +1,529 @@ +package app + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "math" + "net/http" + "strings" + "time" +) + +// Provider identifiers for a keeper account. The keeper started as a Codex-only feature; it now +// dispatches per provider so non-Codex accounts (Antigravity) are inspected with their own quota +// source instead of being silently skipped. +const ( + keeperProviderCodex = "codex" + keeperProviderAntigravity = "antigravity" +) + +// Antigravity (Google Cloud Code) quota summary is fetched through the SAME per-auth +// /v0/management/api-call egress the Codex checks use — CPA injects the account's $TOKEN$ and +// auto-routes via the credential's proxy_url (WARP), so no explicit proxy is needed. The +// endpoint moves between daily/sandbox/stable hosts, so the candidates are tried in order and +// the first successful, parseable response wins. UA mirrors the Antigravity CLI. +const antigravityQuotaUserAgent = "antigravity/cli/1.0.13 (aidev_client; os_type=darwin; arch=arm64)" + +var antigravityQuotaURLs = []string{ + "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", + "https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:retrieveUserQuotaSummary", + "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary", +} + +// keeperAntigravityBucket is one quota window inside a group (e.g. a "weekly" or "5h" window): +// the fraction of the limit still remaining (0..1) and when it fully refreshes. +type keeperAntigravityBucket struct { + BucketID string `json:"bucket_id"` + DisplayName string `json:"display_name"` + Window string `json:"window"` + RemainingFraction float64 `json:"remaining_fraction"` + ResetAt *time.Time `json:"reset_at"` + Description string `json:"description,omitempty"` +} + +// keeperAntigravityGroup is a set of models that share quota windows (e.g. "Gemini Models", +// "Claude and GPT models"), each carrying its own buckets. +type keeperAntigravityGroup struct { + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Buckets []keeperAntigravityBucket `json:"buckets"` +} + +// keeperIsInspectableProvider reports whether an auth-file `type` is one the keeper inspects. +// Codex has always been inspected; Antigravity is now included so its accounts appear and get +// their quota refreshed. Other providers are still skipped. +func keeperIsInspectableProvider(authType string) bool { + switch authType { + case keeperProviderCodex, keeperProviderAntigravity: + return true + default: + return false + } +} + +// keeperAntigravityBucketResponse / keeperAntigravityGroupResponse are the API projection of the +// stored quota, with reset times formatted the same way as every other keeper API timestamp. +type keeperAntigravityBucketResponse struct { + BucketID string `json:"bucket_id"` + DisplayName string `json:"display_name"` + Window string `json:"window"` + RemainingFraction float64 `json:"remaining_fraction"` + ResetAt *string `json:"reset_at"` + Description string `json:"description,omitempty"` +} + +type keeperAntigravityGroupResponse struct { + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Buckets []keeperAntigravityBucketResponse `json:"buckets"` +} + +// keeperAntigravityQuotaResponses projects stored quota groups for the /accounts API response. +func keeperAntigravityQuotaResponses(groups []keeperAntigravityGroup) []keeperAntigravityGroupResponse { + if len(groups) == 0 { + return nil + } + out := make([]keeperAntigravityGroupResponse, 0, len(groups)) + for _, g := range groups { + buckets := make([]keeperAntigravityBucketResponse, 0, len(g.Buckets)) + for _, b := range g.Buckets { + buckets = append(buckets, keeperAntigravityBucketResponse{ + BucketID: b.BucketID, + DisplayName: b.DisplayName, + Window: b.Window, + RemainingFraction: b.RemainingFraction, + ResetAt: apiDateTimePtr(b.ResetAt), + Description: b.Description, + }) + } + out = append(out, keeperAntigravityGroupResponse{DisplayName: g.DisplayName, Description: g.Description, Buckets: buckets}) + } + return out +} + +// keeperProviderOrCodex normalizes a stored provider value to a non-nil pointer, defaulting a +// NULL/empty value to "codex" (rows written before multi-provider support). +func keeperProviderOrCodex(value *string) *string { + if value == nil || strings.TrimSpace(*value) == "" { + codex := keeperProviderCodex + return &codex + } + normalized := strings.ToLower(strings.TrimSpace(*value)) + return &normalized +} + +// parseStoredAntigravityQuota decodes the JSON antigravity_quota blob back into groups; a NULL, +// empty, or malformed blob yields nil (no quota) rather than an error. +func parseStoredAntigravityQuota(value sql.NullString) []keeperAntigravityGroup { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return nil + } + var groups []keeperAntigravityGroup + if err := json.Unmarshal([]byte(value.String), &groups); err != nil { + return nil + } + return groups +} + +// keeperStringFirst returns the first non-empty string among the candidates (used to accept +// both snake_case and camelCase aliases from the remote JSON). +func keeperStringFirst(values ...any) string { + for _, v := range values { + if s := keeperString(v); s != "" { + return s + } + } + return "" +} + +// keeperParseFraction returns the first candidate that is a finite JSON number in [0,1]. A +// present-but-out-of-range/non-finite value fails (false) so a garbage fraction never renders as +// a real remaining amount. +func keeperParseFraction(values ...any) (float64, bool) { + for _, v := range values { + if v == nil { + continue + } + f, ok := v.(float64) + if !ok { + return 0, false + } + if math.IsNaN(f) || math.IsInf(f, 0) || f < 0 || f > 1 { + return 0, false + } + return f, true + } + return 0, false +} + +// parseAntigravityQuotaGroups projects a retrieveUserQuotaSummary body into groups → buckets. +// A group is kept only when it has at least one valid bucket (a valid remaining fraction; a +// present-but-unparseable reset time drops just that bucket). ok=false means the body had no +// usable groups, so the caller preserves the previous snapshot rather than trusting an empty/ +// malformed response. +func parseAntigravityQuotaGroups(body map[string]any) ([]keeperAntigravityGroup, bool) { + if body == nil { + return nil, false + } + rawGroups, ok := body["groups"].([]any) + if !ok { + return nil, false + } + groups := make([]keeperAntigravityGroup, 0, len(rawGroups)) + for _, g := range rawGroups { + gm, ok := g.(map[string]any) + if !ok { + continue + } + rawBuckets, ok := gm["buckets"].([]any) + if !ok { + continue + } + buckets := make([]keeperAntigravityBucket, 0, len(rawBuckets)) + for _, b := range rawBuckets { + bm, ok := b.(map[string]any) + if !ok { + continue + } + fraction, ok := keeperParseFraction(bm["remainingFraction"], bm["remaining_fraction"]) + if !ok { + continue + } + var resetRaw any = bm["resetTime"] + if resetRaw == nil { + resetRaw = bm["reset_time"] + } + resetAt, ok := keeperParseOptionalTime(resetRaw) + if !ok { + continue + } + buckets = append(buckets, keeperAntigravityBucket{ + BucketID: keeperStringFirst(bm["bucketId"], bm["bucket_id"]), + DisplayName: keeperStringFirst(bm["displayName"], bm["display_name"]), + Window: keeperString(bm["window"]), + RemainingFraction: fraction, + ResetAt: resetAt, + Description: keeperString(bm["description"]), + }) + } + if len(buckets) == 0 { + continue + } + groups = append(groups, keeperAntigravityGroup{ + DisplayName: keeperStringFirst(gm["displayName"], gm["display_name"]), + Description: keeperString(gm["description"]), + Buckets: buckets, + }) + } + if len(groups) == 0 { + return nil, false + } + return groups, true +} + +// antigravityIdentity is the reconciled routing identity for an Antigravity inspection. +type antigravityIdentity struct { + authIndex string + projectID string + email string +} + +// keeperReconcileAntigravityIdentity validates that the list entry and download detail describe +// the SAME Antigravity account before any quota call: the list type is antigravity, the detail +// (when it states them) agrees on type and name, the auth_index is explicit on each source (never +// the auth NAME), reconciles across them, and is non-empty, and a project_id is resolvable from +// the detail. ok=false on any conflict/missing field so the caller fails closed. +func keeperReconcileAntigravityIdentity(authInfo, detail map[string]any, name string) (antigravityIdentity, bool) { + // Every identity field distinguishes absent/null from present-but-wrong-type: a present field + // that is not the expected type is illegal (a malformed/deceptive detail) and fails closed, + // rather than being treated as "missing" and silently backfilled from the list. + listType, lterr := keeperExplicitStringField(authInfo, "type") + if lterr != nil || listType != keeperProviderAntigravity { + return antigravityIdentity{}, false + } + detailType, dterr := keeperExplicitStringField(detail, "type") + if dterr != nil { + return antigravityIdentity{}, false + } + if detailType != "" && detailType != keeperProviderAntigravity { + return antigravityIdentity{}, false + } + // CPA emits `provider` as an authoritative alias of `type` (buildAuthFileEntry sets both from + // the same auth.Provider), so a present provider MUST agree with the antigravity type on each + // source. A present-but-conflicting provider (e.g. provider=codex on a type=antigravity entry) + // is a corrupt/deceptive entry — fail closed rather than dispatch a quota call on `type` alone. + listProvider, lpverr := keeperExplicitStringField(authInfo, "provider") + if lpverr != nil || (listProvider != "" && listProvider != keeperProviderAntigravity) { + return antigravityIdentity{}, false + } + detailProvider, dpverr := keeperExplicitStringField(detail, "provider") + if dpverr != nil || (detailProvider != "" && detailProvider != keeperProviderAntigravity) { + return antigravityIdentity{}, false + } + detailName, dnerr := keeperExplicitStringField(detail, "name") + if dnerr != nil { + return antigravityIdentity{}, false + } + if detailName != "" && detailName != name { + return antigravityIdentity{}, false + } + listIdx, lierr := keeperExplicitAuthIndex(authInfo) + detailIdx, dierr := keeperExplicitAuthIndex(detail) + if lierr != nil || dierr != nil { + return antigravityIdentity{}, false + } + idx, ierr := keeperReconcileIdentityField(listIdx, detailIdx) + if ierr != nil || strings.TrimSpace(idx) == "" { + return antigravityIdentity{}, false + } + // The CPA list entry also exposes project_id; when BOTH sources carry one they must agree + // (a same-name file swap / memory-vs-disk drift would otherwise let the detail's project run a + // quota call for a different account). A non-empty project must be resolvable. + listProject, lperr := keeperExplicitAntigravityProjectID(authInfo) + detailProject, dperr := keeperExplicitAntigravityProjectID(detail) + if lperr != nil || dperr != nil { + return antigravityIdentity{}, false + } + project, perr := keeperReconcileIdentityField(listProject, detailProject) + if perr != nil || strings.TrimSpace(project) == "" { + return antigravityIdentity{}, false + } + // A Google project can be shared across accounts, so the project alone does not identify the + // account. Reconcile the account email across list+detail (present must be a string; both + // present must agree) and fold it into the stored identity digest, so a credential swap to a + // different email under the same project is detected as an identity change. + listEmail, leerr := keeperExplicitAntigravityEmail(authInfo) + detailEmail, deerr := keeperExplicitAntigravityEmail(detail) + if leerr != nil || deerr != nil { + return antigravityIdentity{}, false + } + email, eerr := keeperReconcileIdentityField(listEmail, detailEmail) + if eerr != nil { + return antigravityIdentity{}, false + } + // The resource key is provider+project+email, and a Google project can be shared across + // accounts, so an empty email would make two distinct credentials under the same project + // indistinguishable (one could inherit the other's quota). CPA's Antigravity login requires a + // non-empty userinfo email, so a missing email means the identity is unproven: fail closed + // (identity_error / no quota call) rather than degrade to a project-only digest. + if strings.TrimSpace(email) == "" { + return antigravityIdentity{}, false + } + return antigravityIdentity{authIndex: idx, projectID: project, email: email}, true +} + +// keeperAntigravityIdentityDigest returns a stable, versioned one-way digest of the Antigravity +// account identity (provider + project id + normalized email). Only this digest is persisted — +// never the raw project id or email — so the DB can detect an identity swap (project OR email +// changed) and clear the stale quota without storing or exposing the identifiers. A change to any +// confirmed component yields a different digest. +func keeperAntigravityIdentityDigest(projectID, email string) string { + normEmail := strings.ToLower(strings.TrimSpace(email)) + sum := sha256.Sum256([]byte(keeperProviderAntigravity + "\x00" + projectID + "\x00" + normEmail)) + return "v1:" + hex.EncodeToString(sum[:]) +} + +// keeperExplicitAntigravityEmail resolves the account email across the same aliases the result +// layer reads (email / account_email / user_email), normalized to lowercase, with present-invalid +// detection: each present alias must be a string and they must all agree (case-insensitively). A +// present-but-wrong-type value or two disagreeing aliases fail closed, so the identity digest can't +// be bound to an arbitrary alias while a conflicting one is ignored. +func keeperExplicitAntigravityEmail(o map[string]any) (string, error) { + candidates := []string{} + for _, k := range []string{"email", "account_email", "user_email"} { + v, err := keeperExplicitStringField(o, k) + if err != nil { + return "", err + } + if v != "" { + candidates = append(candidates, strings.ToLower(v)) + } + } + return keeperConsistentValue(candidates...) +} + +// keeperExplicitAntigravityProjectID resolves the project id across every alias location (top-level +// project_id/projectId and the nested metadata/attributes/installed/web blocks, plus attributes' +// gemini_virtual_project) with present-invalid detection: each present alias must be a string and +// they must all agree. A present-but-wrong-type value or two disagreeing aliases fail closed. +func keeperExplicitAntigravityProjectID(o map[string]any) (string, error) { + candidates := []string{} + collect := func(m map[string]any, keys ...string) error { + for _, k := range keys { + v, err := keeperExplicitStringField(m, k) + if err != nil { + return err + } + if v != "" { + candidates = append(candidates, v) + } + } + return nil + } + if err := collect(o, "project_id", "projectId"); err != nil { + return "", err + } + for _, nestedKey := range []string{"metadata", "attributes", "installed", "web"} { + raw, present := o[nestedKey] + if !present || raw == nil { + continue + } + // A present container of the wrong type (e.g. a JSON number) is NOT absent — treating it as + // absent would let a malformed detail slip past the present-invalid contract. Fail closed. + nested, ok := raw.(map[string]any) + if !ok { + return "", errKeeperIdentityConflict + } + keys := []string{"project_id", "projectId"} + if nestedKey == "attributes" { + keys = append(keys, "gemini_virtual_project") + } + if err := collect(nested, keys...); err != nil { + return "", err + } + } + // Every present alias (top-level + nested) must be a string and they must all agree; a + // present-but-wrong-type value or two disagreeing aliases fail closed rather than silently + // binding the identity digest to an arbitrary first value. + return keeperConsistentValue(candidates...) +} + +// processKeeperAntigravityAuth inspects one Antigravity account: it reads the download detail, +// records the identity/health fields, and (unless disabled) fetches the quota summary. It is a +// SEPARATE path from the Codex inspection — no ChatGPT usage/reset-credit/subscription/priority +// logic applies. A failed quota fetch preserves the previous snapshot (AntigravityQuota nil → +// COALESCE) and records a network error rather than wiping the account. +func (a *App) processKeeperAntigravityAuth(ctx context.Context, cfg AppConfig, authInfo map[string]any, logFn func(string), manualRefresh bool) keeperAccountResult { + now := time.Now().In(appTimeLocation) + name := keeperString(authInfo["name"]) + if name == "" { + name = "unknown" + } + provider := keeperProviderAntigravity + result := keeperAccountResult{Name: name, Result: "skipped", CheckedAt: now, Provider: &provider} + persist := func(r keeperAccountResult) keeperAccountResult { + if err := a.upsertKeeperState(ctx, r); err != nil { + logFn(r.Name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper antigravity state write-back failed for %s: %v", r.Name, err) + r.StateWriteFailed = true + } + return r + } + detail, err := a.getKeeperRemoteAuthFile(ctx, cfg, name) + if err != nil || detail == nil { + message := "读取 auth file 详情失败" + if err != nil { + message += ":" + err.Error() + } + result.Result = "network_error" + result.LastError = &message + result.LatestAction = &message + result = persist(result) + logFn(name + ": " + message) + return result + } + // Bind identity FIRST (mirror the Codex rigor): the download must be for THIS account name, + // still be an Antigravity account, and carry an explicit auth_index consistent with the list + // entry (no filename fallback). Any mismatch mixes accounts, so fail closed as identity_error, + // preserve the prior snapshot, and make NO quota call. + identity, ok := keeperReconcileAntigravityIdentity(authInfo, detail, name) + if !ok { + message := "账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照" + result.Result = "identity_error" + result.LastError = &message + result.LatestAction = &message + if err := a.markKeeperIdentityError(ctx, name, &message, result.CheckedAt); err != nil { + logFn(name + ":状态写回失败(state_write_error)") + log.Printf("codex keeper antigravity identity-error write-back failed for %s: %v", name, err) + result.StateWriteFailed = true + } + logFn(name + ":" + message) + return result + } + merged := mergeKeeperObjects(authInfo, detail) + result.Email = keeperStringPtr(merged["email"], merged["account_email"], merged["user_email"]) + idx := identity.authIndex + result.AuthIndex = &idx + // Bind a one-way DIGEST of the resolved account identity (provider + project + email; never the + // raw values) regardless of the fetch outcome, so a later inspection can detect an identity swap + // (project OR email changed) and clear the stale quota even when the fresh quota fetch fails — + // without persisting the project or email. + digest := keeperAntigravityIdentityDigest(identity.projectID, identity.email) + result.AntigravityIdentityDigest = &digest + result.Priority = keeperIntPtr(merged["priority"]) + disabled := keeperBool(merged["disabled"]) + result.Disabled = &disabled + if disabled && !manualRefresh { + result.Result = "disabled" + result = persist(result) + return result + } + if groups, ok := a.fetchAntigravityQuota(ctx, cfg, identity.authIndex, identity.projectID); ok { + if encoded, err := json.Marshal(groups); err == nil { + payload := string(encoded) + result.AntigravityQuota = &payload + } + result.Result = "healthy" + logFn(fmt.Sprintf("%s:Antigravity 配额刷新成功(%d 组)", name, len(groups))) + } else { + message := "Antigravity 配额读取失败" + result.Result = "network_error" + result.LastError = &message + result.LatestAction = &message + logFn(name + ":" + message) + } + result = persist(result) + return result +} + +// fetchAntigravityQuota pulls the account's Antigravity quota summary through the per-auth +// api-call egress (same $TOKEN$/proxy plumbing as the Codex checks). It requires a resolvable +// project id; a transport error, non-2xx outer/inner status, or unparseable body on every +// candidate host leaves both return values empty (ok=false) so the caller preserves the prior +// snapshot instead of wiping it. +func (a *App) fetchAntigravityQuota(ctx context.Context, cfg AppConfig, authIndex, projectID string) ([]keeperAntigravityGroup, bool) { + if strings.TrimSpace(authIndex) == "" || strings.TrimSpace(projectID) == "" { + return nil, false + } + dataBytes, err := json.Marshal(map[string]string{"project": projectID}) + if err != nil { + return nil, false + } + header := map[string]string{ + "Authorization": "Bearer $TOKEN$", + "Content-Type": "application/json", + "User-Agent": antigravityQuotaUserAgent, + } + for _, quotaURL := range antigravityQuotaURLs { + body := map[string]any{ + "auth_index": authIndex, + "method": "POST", + "url": quotaURL, + "header": header, + "data": string(dataBytes), + } + response, payload, err := a.keeperRequest(ctx, cfg, http.MethodPost, "/v0/management/api-call", nil, body, time.Duration(cfg.CodexKeeper.UsageTimeoutSeconds)*time.Second) + if err != nil { + continue + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + continue + } + var raw map[string]any + if err := json.Unmarshal(payload, &raw); err != nil { + continue + } + if !keeperInnerStatusOK(raw) { + continue + } + if groups, ok := parseAntigravityQuotaGroups(keeperBodyJSON(raw["body"])); ok { + return groups, true + } + } + return nil, false +} diff --git a/backend/internal/app/codex_keeper_antigravity_internal_test.go b/backend/internal/app/codex_keeper_antigravity_internal_test.go new file mode 100644 index 00000000..06b81798 --- /dev/null +++ b/backend/internal/app/codex_keeper_antigravity_internal_test.go @@ -0,0 +1,997 @@ +package app + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// antigravityGoldenBody is the real retrieveUserQuotaSummary response captured from a production +// Antigravity account (values from @Friday's read-back), used as the golden parse fixture. +const antigravityGoldenBody = `{ + "groups": [ + { + "buckets": [ + {"bucketId":"gemini-weekly","displayName":"Weekly Limit Remaining","window":"weekly","resetTime":"2026-09-13T14:43:19Z","description":"You have used some of your weekly limit, it will fully refresh in 4 days, 6 hours.","remainingFraction":0.8308332}, + {"bucketId":"gemini-5h","displayName":"Five Hour Limit Remaining","window":"5h","resetTime":"2026-09-09T13:34:49Z","description":"You have used some of your 5-hour limit, it will fully refresh in 4 hours, 56 minutes.","remainingFraction":0.9927133} + ], + "displayName":"Gemini Models", + "description":"Models within this group: Gemini Flash, Gemini Pro" + }, + { + "buckets": [ + {"bucketId":"3p-weekly","displayName":"Weekly Limit Remaining","window":"weekly","resetTime":"2026-09-14T06:16:39Z","remainingFraction":1.0}, + {"bucketId":"3p-5h","displayName":"Five Hour Limit Remaining","window":"5h","resetTime":"2026-09-09T13:34:56Z","remainingFraction":1.0} + ], + "displayName":"Claude and GPT models", + "description":"Models within this group: Claude Opus, Claude Sonnet, GPT-OSS" + } + ], + "description":"Within each group, models share a weekly limit and a 5-hour limit." +}` + +func antigravityGoldenMap(t *testing.T) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(antigravityGoldenBody), &m); err != nil { + t.Fatalf("unmarshal golden body: %v", err) + } + return m +} + +func TestParseAntigravityQuotaGroupsGolden(t *testing.T) { + groups, ok := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + if !ok { + t.Fatal("golden body must parse") + } + if len(groups) != 2 { + t.Fatalf("groups = %d, want 2", len(groups)) + } + g0 := groups[0] + if g0.DisplayName != "Gemini Models" || len(g0.Buckets) != 2 { + t.Fatalf("group0 = %+v, want Gemini Models with 2 buckets", g0) + } + weekly := g0.Buckets[0] + if weekly.BucketID != "gemini-weekly" || weekly.Window != "weekly" || weekly.RemainingFraction != 0.8308332 { + t.Fatalf("gemini weekly bucket = %+v", weekly) + } + if weekly.ResetAt == nil || weekly.ResetAt.UTC().Format("2006-01-02T15:04:05Z") != "2026-09-13T14:43:19Z" { + t.Fatalf("gemini weekly resetAt = %v, want 2026-09-13T14:43:19Z", weekly.ResetAt) + } + if g0.Buckets[1].Window != "5h" || g0.Buckets[1].RemainingFraction != 0.9927133 { + t.Fatalf("gemini 5h bucket = %+v", g0.Buckets[1]) + } + // Second group: buckets without a description still parse; fraction 1.0 kept. + if groups[1].DisplayName != "Claude and GPT models" || len(groups[1].Buckets) != 2 || groups[1].Buckets[0].RemainingFraction != 1.0 { + t.Fatalf("group1 = %+v", groups[1]) + } +} + +func TestParseAntigravityQuotaGroupsEdgeCases(t *testing.T) { + // No groups → not ok. + if _, ok := parseAntigravityQuotaGroups(map[string]any{}); ok { + t.Fatal("missing groups must be ok=false") + } + if _, ok := parseAntigravityQuotaGroups(map[string]any{"groups": []any{}}); ok { + t.Fatal("empty groups must be ok=false") + } + // A group whose only bucket has an out-of-range fraction is dropped → no usable groups. + bad := map[string]any{"groups": []any{map[string]any{ + "displayName": "G", "buckets": []any{map[string]any{"window": "5h", "remainingFraction": 1.5}}, + }}} + if _, ok := parseAntigravityQuotaGroups(bad); ok { + t.Fatal("out-of-range fraction must drop the bucket and the empty group") + } + // A present-but-unparseable resetTime drops just that bucket. + badReset := map[string]any{"groups": []any{map[string]any{ + "displayName": "G", "buckets": []any{ + map[string]any{"window": "5h", "remainingFraction": 0.5, "resetTime": "not-a-time"}, + map[string]any{"window": "weekly", "remainingFraction": 0.9}, + }, + }}} + groups, ok := parseAntigravityQuotaGroups(badReset) + if !ok || len(groups) != 1 || len(groups[0].Buckets) != 1 || groups[0].Buckets[0].Window != "weekly" { + t.Fatalf("bad-reset case = (%v, %+v), want the weekly bucket only", ok, groups) + } + // snake_case aliases are accepted. + snake := map[string]any{"groups": []any{map[string]any{ + "display_name": "G", "buckets": []any{map[string]any{"bucket_id": "b", "display_name": "B", "window": "5h", "remaining_fraction": 0.25, "reset_time": "2026-01-01T00:00:00Z"}}, + }}} + sg, ok := parseAntigravityQuotaGroups(snake) + if !ok || sg[0].Buckets[0].BucketID != "b" || sg[0].Buckets[0].RemainingFraction != 0.25 { + t.Fatalf("snake_case aliases not accepted: %v %+v", ok, sg) + } +} + +func TestKeeperExplicitAntigravityProjectID(t *testing.T) { + cases := []struct { + name string + detail map[string]any + want string + wantErr bool + }{ + {"top-level", map[string]any{"project_id": "aicode-consumers"}, "aicode-consumers", false}, + {"camel", map[string]any{"projectId": "p2"}, "p2", false}, + {"metadata", map[string]any{"metadata": map[string]any{"project_id": "p3"}}, "p3", false}, + {"attributes-virtual", map[string]any{"attributes": map[string]any{"gemini_virtual_project": "p4"}}, "p4", false}, + {"installed", map[string]any{"installed": map[string]any{"project_id": "p5"}}, "p5", false}, + {"web", map[string]any{"web": map[string]any{"project_id": "p6"}}, "p6", false}, + {"none", map[string]any{"email": "x@y.com"}, "", false}, + // All present aliases must agree and be strings. + {"top-level-agree", map[string]any{"project_id": "p", "projectId": "p"}, "p", false}, + {"top-level-conflict", map[string]any{"project_id": "p", "projectId": "other"}, "", true}, + {"top-level-wrong-type", map[string]any{"project_id": float64(1)}, "", true}, + {"nested-wrong-type", map[string]any{"metadata": map[string]any{"project_id": float64(2)}}, "", true}, + {"nested-vs-top-conflict", map[string]any{"projectId": "other", "metadata": map[string]any{"project_id": "p"}}, "", true}, + // A nested container that is PRESENT but not an object is present-invalid, not absent — + // even when the top-level project id is valid, a wrong-type container must fail closed + // (the whole detail is untrustworthy) rather than being silently skipped. + {"metadata-non-object", map[string]any{"project_id": "p", "metadata": float64(7)}, "", true}, + {"attributes-non-object", map[string]any{"project_id": "p", "attributes": float64(7)}, "", true}, + {"installed-non-object", map[string]any{"project_id": "p", "installed": float64(7)}, "", true}, + {"web-non-object", map[string]any{"project_id": "p", "web": float64(7)}, "", true}, + // A null (or absent) container is genuinely absent and is skipped, not an error. + {"nested-null-skipped", map[string]any{"project_id": "p", "metadata": nil}, "p", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := keeperExplicitAntigravityProjectID(tc.detail) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error (conflict/wrong-type), got %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("projectID = %q, want %q", got, tc.want) + } + }) + } +} + +// TestFetchAntigravityQuota exercises the api-call egress: it requires a project id, tries the +// candidate hosts in order (first non-2xx is skipped), and parses the inner body. It also +// asserts the outgoing api-call carries the antigravity UA + {"project":...} data. +func TestFetchAntigravityQuota(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + var seenUA, seenData string + callCount := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost || r.URL.Path != "/v0/management/api-call" { + http.NotFound(w, r) + return + } + var p struct { + URL string `json:"url"` + Header map[string]string `json:"header"` + Data string `json:"data"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + mu.Lock() + callCount++ + n := callCount + seenUA = p.Header["User-Agent"] + seenData = p.Data + mu.Unlock() + // First candidate host fails (404) to exercise the fallback; second succeeds. + if n == 1 { + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 404, "body": map[string]any{"error": "not found"}}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + cfg, err := app.loadConfig(ctx) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + groups, ok := app.fetchAntigravityQuota(ctx, cfg, "idx-ag", "aicode-consumers") + if !ok || len(groups) != 2 { + t.Fatalf("fetch = (%v, %d groups), want ok with 2 groups", ok, len(groups)) + } + mu.Lock() + defer mu.Unlock() + if callCount != 2 { + t.Fatalf("call count = %d, want 2 (first host 404 → fallback)", callCount) + } + if seenUA != antigravityQuotaUserAgent { + t.Fatalf("outgoing UA = %q, want antigravity UA", seenUA) + } + if seenData != `{"project":"aicode-consumers"}` { + t.Fatalf("outgoing data = %q, want the project body", seenData) + } +} + +// TestKeeperInspectAntigravityAccount is the end-to-end path: an antigravity account is now +// INCLUDED in the inspection (not skipped), routed to the antigravity provider path, and stored +// with provider=antigravity + its quota groups — visible via listKeeperAccounts. +func TestKeeperInspectAntigravityAccount(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "antigravity-1.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // Real CPA emits both `type` and `provider` (same auth.Provider); include the matching + // provider so this happy path also proves a present-and-agreeing provider is accepted. + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "provider": "antigravity", "auth_index": "idx-ag"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "provider": "antigravity", "auth_index": "idx-ag", + "project_id": "aicode-consumers", "email": "eyo@example.com", "disabled": false, + "priority": 1, "access_token": "tok", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.Healthy != 1 { + t.Fatalf("stats = %+v, want Healthy=1 (antigravity inspected, not skipped)", stats) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get state: %v", err) + } + if st.Provider == nil || *st.Provider != "antigravity" { + t.Fatalf("stored provider = %v, want antigravity", st.Provider) + } + if len(st.AntigravityQuota) != 2 || st.AntigravityQuota[0].DisplayName != "Gemini Models" { + t.Fatalf("stored antigravity quota not persisted: %+v", st.AntigravityQuota) + } + // Codex-only fields stay empty for an antigravity account. + if st.PrimaryUsedPercent != nil || st.ResetCreditCount != nil || st.SubscriptionActiveUntil != nil { + t.Fatalf("codex-only fields must be nil for antigravity: %+v", st) + } + // It appears in the account list the frontend consumes. + accounts, err := app.listKeeperAccounts(ctx) + if err != nil { + t.Fatalf("list: %v", err) + } + var found *keeperAccount + for i := range accounts { + if accounts[i].Name == authName { + found = &accounts[i] + } + } + if found == nil || found.Provider == nil || *found.Provider != "antigravity" || len(found.AntigravityQuota) != 2 { + t.Fatalf("antigravity account not visible with quota in list: %+v", found) + } + // The API projection (the exact path the /accounts handler serializes) must carry both + // fields — otherwise the frontend never sees the provider or quota. + raw, err := json.Marshal(keeperAccountResponses(accounts, nil)) + if err != nil { + t.Fatalf("marshal responses: %v", err) + } + js := string(raw) + if !strings.Contains(js, `"provider":"antigravity"`) { + t.Fatalf("API response drops provider: %s", js) + } + if !strings.Contains(js, `"antigravity_quota":[`) || !strings.Contains(js, `"remaining_fraction"`) || !strings.Contains(js, `"reset_at"`) { + t.Fatalf("API response drops antigravity_quota buckets: %s", js) + } +} + +// TestKeeperInspectAntigravityIdentityConflictFailsClosed reproduces the reverse probes: when the +// download detail disagrees with the list entry on name/index, has drifted to type=codex, or when +// neither source carries an explicit auth_index, the inspection must fail closed — NO quota +// api-call, identity_error, and the prior quota snapshot preserved. +func TestKeeperInspectAntigravityIdentityConflictFailsClosed(t *testing.T) { + const authName = "antigravity-x.json" + cases := []struct { + name string + listEntry map[string]any + download map[string]any + }{ + {"name-only-mismatch", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": "other.json", "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"index-only-mismatch", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-other", "project_id": "p", "access_token": "t"}}, + {"type-drift-to-codex", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag"}, + map[string]any{"name": authName, "type": "codex", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"no-explicit-auth-index", + map[string]any{"name": authName, "type": "antigravity"}, + map[string]any{"name": authName, "type": "antigravity", "project_id": "p", "access_token": "t"}}, + {"project-id-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-A"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-B", "access_token": "t"}}, + // present-but-wrong-type detail fields must fail closed (not be treated as absent + backfilled). + {"detail-name-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": float64(123), "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"detail-type-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": float64(1), "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + {"detail-project-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": float64(7), "access_token": "t"}}, + // project-id aliases (top-level + nested) must all agree and be strings. + {"detail-project-alias-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "projectId": "other", "access_token": "t"}}, + {"detail-nested-project-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "metadata": map[string]any{"project_id": float64(5)}, "access_token": "t"}}, + {"detail-nested-vs-top-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "projectId": "other", "metadata": map[string]any{"project_id": "p"}, "access_token": "t"}}, + // email aliases (email/account_email/user_email) must agree and be strings, across sources too. + {"email-alias-cross-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "account_email": "b@x.com", "access_token": "t"}}, + {"detail-email-alias-self-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "account_email": "b@x.com", "access_token": "t"}}, + {"detail-email-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": float64(9), "access_token": "t"}}, + // CPA emits `provider` as an authoritative alias of `type` (both from auth.Provider). A + // present provider that disagrees with the antigravity type — on either source — is a + // corrupt/deceptive entry and must fail closed. Everything else here is consistent, so the + // provider conflict is the SOLE reason these fail (without the check they'd make a quota call). + {"list-provider-conflict", + map[string]any{"name": authName, "type": "antigravity", "provider": "codex", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "access_token": "t"}}, + {"detail-provider-conflict", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "provider": "codex", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "access_token": "t"}}, + {"detail-provider-wrong-type", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com"}, + map[string]any{"name": authName, "type": "antigravity", "provider": float64(3), "auth_index": "idx-ag", "project_id": "p", "email": "a@x.com", "access_token": "t"}}, + // Missing email on BOTH sides: the resource key is provider+project+email and a project can be + // shared, so an unbound (email-less) identity is unprovable and must fail closed — not degrade + // to a project-only digest that a second email-less credential could inherit. Everything else + // (name/type/index/project) is consistent, so the ONLY reason this fails is the missing email. + {"missing-email-both-sides", + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p"}, + map[string]any{"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "p", "access_token": "t"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + quotaCalls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{tc.listEntry}}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(tc.download) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + var p struct { + URL string `json:"url"` + } + _ = json.NewDecoder(r.Body).Decode(&p) + if strings.Contains(p.URL, "retrieveUserQuotaSummary") { + mu.Lock() + quotaCalls++ + mu.Unlock() + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed a prior good antigravity snapshot to prove it is preserved on the conflict. + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx := keeperProviderAntigravity, "idx-ag" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, AntigravityQuota: &blob, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.IdentityError != 1 || stats.Healthy != 0 { + t.Fatalf("stats = %+v, want IdentityError=1, Healthy=0", stats) + } + mu.Lock() + calls := quotaCalls + mu.Unlock() + if calls != 0 { + t.Fatalf("identity conflict made %d quota api-calls; must be 0", calls) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if len(st.AntigravityQuota) != 2 { + t.Fatalf("prior snapshot not preserved on conflict: %+v", st.AntigravityQuota) + } + if st.LastError == nil { + t.Fatal("identity conflict did not record an error") + } + }) + } +} + +// TestKeeperUpsertProviderSwitchClearsStaleFields proves the upsert enforces the provider +// invariant: switching a row codex→antigravity clears the stale Codex-only columns (account_id, +// reset credits, subscription, usage), and switching back antigravity→codex clears the stale +// antigravity_quota. So a filename reused for a different provider never shows mixed data. +func TestKeeperUpsertProviderSwitchClearsStaleFields(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + const authName = "switcher.json" + + // Seed a fully-populated Codex row. + codex := keeperProviderCodex + idx, used, count, acct, restore := "idx-1", 40, 3, "acct-A", 9 + sub := timeMust(t, "2026-10-01T00:00:00Z") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: timeMust(t, "2026-09-09T00:00:00Z"), Provider: &codex, + AuthIndex: &idx, PrimaryUsedPercent: &used, ResetCreditCount: &count, ResetCredits: stringPtr(resetCreditSnapshotJSON), + SubscriptionActiveUntil: &sub, SubscriptionKnown: true, AccountID: &acct, RestorePriority: &restore, + }); err != nil { + t.Fatalf("seed codex: %v", err) + } + if seeded, _ := app.getKeeperState(ctx, authName); seeded.RestorePriority == nil || *seeded.RestorePriority != 9 { + t.Fatalf("seed restore_priority not stored: %v", seeded.RestorePriority) + } + + // Same filename now inspected as antigravity. + antigravity := keeperProviderAntigravity + quota, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(quota) + blob := string(encoded) + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: timeMust(t, "2026-09-09T01:00:00Z"), Provider: &antigravity, + AuthIndex: &idx, AntigravityQuota: &blob, + }); err != nil { + t.Fatalf("upsert antigravity: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.Provider == nil || *st.Provider != "antigravity" || len(st.AntigravityQuota) != 2 { + t.Fatalf("switch to antigravity: provider/quota = %v/%d", st.Provider, len(st.AntigravityQuota)) + } + if st.PrimaryUsedPercent != nil || st.ResetCreditCount != nil || st.ResetCredits != nil || st.SubscriptionActiveUntil != nil || st.AccountID != nil { + t.Fatalf("codex-only fields not cleared on provider switch: %+v", st.keeperAccount) + } + if st.RestorePriority != nil { + t.Fatalf("codex restore_priority not cleared on provider switch: %v", st.RestorePriority) + } + + // Switch back to codex → the antigravity quota must be cleared. + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: timeMust(t, "2026-09-09T02:00:00Z"), Provider: &codex, + AuthIndex: &idx, PrimaryUsedPercent: &used, + }); err != nil { + t.Fatalf("upsert codex again: %v", err) + } + st, err = app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get2: %v", err) + } + if st.Provider == nil || *st.Provider != "codex" || len(st.AntigravityQuota) != 0 { + t.Fatalf("switch back to codex: provider=%v antigravity_quota len=%d (want cleared)", st.Provider, len(st.AntigravityQuota)) + } +} + +// TestKeeperConditionalReconcilePreservesAntigravity proves the conditional reconcile's prune +// existence-set includes Antigravity: a stored Antigravity row that the remote list still +// returns must NOT be pruned (the bug deleted it because the set was codex-only). +func TestKeeperConditionalReconcilePreservesAntigravity(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "antigravity-keep.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files" { + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag"}, + }}) + return + } + http.NotFound(w, r) + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + ag, idx := keeperProviderAntigravity, "idx-ag" + if err := app.upsertKeeperState(ctx, keeperAccountResult{Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx}); err != nil { + t.Fatalf("seed: %v", err) + } + cfg, err := app.loadConfig(ctx) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + if err := app.reconcileKeeperConditionalRemoteAuthStates(ctx, cfg, func(string) {}); err != nil { + t.Fatalf("reconcile: %v", err) + } + if _, err := app.getKeeperState(ctx, authName); err != nil { + t.Fatalf("conditional reconcile pruned a still-present antigravity row: %v", err) + } +} + +// TestKeeperWebsocketFailureClearsStaleAntigravityProvider reproduces the provider-switch probe on +// the credential-websocket failure path: a row stored as Antigravity whose remote file is now Codex +// and whose websocket-enable PATCH fails must still be re-tagged provider=codex (clearing the stale +// antigravity_quota), not left showing as Antigravity. +func TestKeeperWebsocketFailureClearsStaleAntigravityProvider(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "switched.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + // The file is now a Codex account (websockets not yet enabled). + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "codex", "auth_index": "idx-c", "websockets": false}, + }}) + case r.Method == http.MethodPatch && r.URL.Path == "/v0/management/auth-files/fields": + http.Error(w, "boom", http.StatusBadGateway) // enabling websockets fails + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, func(cfg *AppConfig) { + cfg.CodexKeeper.EnableCredentialWebsockets = true + cfg.CodexKeeper.DryRun = false + }) + ctx := context.Background() + + // Seed a prior Antigravity row with quota (the file used to be antigravity). + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx := keeperProviderAntigravity, "idx-c" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, AntigravityQuota: &blob, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + stats, err := app.keeper.InspectAccountsLocked([]string{authName}) + if err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + if stats.NetworkError != 1 { + t.Fatalf("stats = %+v, want NetworkError=1 (websocket enable failed)", stats) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.Provider == nil || *st.Provider != "codex" { + t.Fatalf("stale provider not switched to codex on websocket-failure path: %v", st.Provider) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("stale antigravity_quota not cleared on provider switch: %+v", st.AntigravityQuota) + } +} + +// TestKeeperInspectAntigravityProjectSwapClearsStaleQuotaOnFailure reproduces the cross-inspection +// project-swap probe: a row stored for project-A whose list+detail now consistently say project-B +// (name/index unchanged) and whose project-B quota fetch FAILS must clear A's quota (a confirmed +// project swap), not keep showing A's quota against B. +func TestKeeperInspectAntigravityProjectSwapClearsStaleQuotaOnFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "proj-swap.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-B", "email": "sw@example.com"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "project-B", "email": "sw@example.com", "access_token": "t", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + http.Error(w, "quota boom", http.StatusBadGateway) // project-B quota fetch fails + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed project-A with its quota bound to project-A. + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx, projectADigest := keeperProviderAntigravity, "idx-ag", keeperAntigravityIdentityDigest("project-A", "sw@example.com") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityQuota: &blob, AntigravityIdentityDigest: &projectADigest, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-B", "sw@example.com") { + t.Fatalf("project digest not rebound to project-B: %v", st.AntigravityIdentityDigest) + } + // The stored digest must NOT be the raw project id (privacy contract). + if st.AntigravityIdentityDigest != nil && *st.AntigravityIdentityDigest == "project-B" { + t.Fatalf("raw project id leaked into storage: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("stale project-A quota not cleared on project swap + failed fetch: %+v", st.AntigravityQuota) + } +} + +// TestKeeperInspectAntigravityEmailSwapClearsStaleQuotaOnFailure proves the identity binding also +// covers the account email: a shared Google project reused by a different email (name/index/project +// unchanged) whose new fetch fails must clear the old account's quota, not label it under the new +// email. +func TestKeeperInspectAntigravityEmailSwapClearsStaleQuotaOnFailure(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "email-swap.json" + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "shared-proj", "email": "b@example.com"}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": "shared-proj", "email": "b@example.com", "access_token": "t", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + http.Error(w, "quota boom", http.StatusBadGateway) // new account's fetch fails + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + // Seed the SAME project but a DIFFERENT email (a@example.com) with its quota. + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx, digestA := keeperProviderAntigravity, "idx-ag", keeperAntigravityIdentityDigest("shared-proj", "a@example.com") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityQuota: &blob, AntigravityIdentityDigest: &digestA, + }); err != nil { + t.Fatalf("seed: %v", err) + } + + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("shared-proj", "b@example.com") { + t.Fatalf("identity digest not rebound to the new email: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("stale quota kept under a different email (shared project): %+v", st.AntigravityQuota) + } +} + +// TestKeeperInspectAntigravityDetailFailurePreservesIdentityDigest reproduces the 3-phase probe: a +// transient detail-read failure (identity UNKNOWN) must NOT wipe the stored identity digest, or the +// next inspection loses its swap anchor and keeps the old account's quota. Phase 1 stores project-A +// + quota; phase 2 fails the download (list still A) — digest+quota preserved; phase 3 resolves +// project-B but the quota fetch fails — the A digest is still present so the swap is detected and +// A's quota is cleared (not shown under B). +func TestKeeperInspectAntigravityDetailFailurePreservesIdentityDigest(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + const authName = "digest-preserve.json" + const email = "a@x.com" + var mu sync.Mutex + project := "project-A" + downloadFail := false + quotaFail := false + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + mu.Lock() + curProject, dFail, qFail := project, downloadFail, quotaFail + mu.Unlock() + switch { + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files": + _ = json.NewEncoder(w).Encode(map[string]any{"files": []map[string]any{ + {"name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": curProject, "email": email}, + }}) + case r.Method == http.MethodGet && r.URL.Path == "/v0/management/auth-files/download": + if dFail { + http.Error(w, "download boom", http.StatusBadGateway) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "name": authName, "type": "antigravity", "auth_index": "idx-ag", "project_id": curProject, "email": email, "access_token": "t", + }) + case r.Method == http.MethodPost && r.URL.Path == "/v0/management/api-call": + if qFail { + http.Error(w, "quota boom", http.StatusBadGateway) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status_code": 200, "body": antigravityGoldenMap(t)}) + default: + http.NotFound(w, r) + } + })) + defer cpa.Close() + + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + ctx := context.Background() + + inspect := func() { + if _, err := app.keeper.InspectAccountsLocked([]string{authName}); err != nil { + t.Fatalf("InspectAccountsLocked: %v", err) + } + } + + // Phase 1: project-A resolves + quota fetched. + inspect() + st, _ := app.getKeeperState(ctx, authName) + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-A", email) || len(st.AntigravityQuota) != 2 { + t.Fatalf("phase1: digest/quota not stored: %v %d", st.AntigravityIdentityDigest, len(st.AntigravityQuota)) + } + + // Phase 2: download fails (identity unknown) — digest AND quota must be preserved. + mu.Lock() + downloadFail = true + mu.Unlock() + inspect() + st, _ = app.getKeeperState(ctx, authName) + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-A", email) { + t.Fatalf("phase2: identity digest was wiped on a transient detail failure: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 2 { + t.Fatalf("phase2: quota not preserved on transient failure: %d", len(st.AntigravityQuota)) + } + + // Phase 3: detail recovers as project-B but the quota fetch fails — swap detected, A quota cleared. + mu.Lock() + downloadFail = false + project = "project-B" + quotaFail = true + mu.Unlock() + inspect() + st, _ = app.getKeeperState(ctx, authName) + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != keeperAntigravityIdentityDigest("project-B", email) { + t.Fatalf("phase3: digest not rebound to project-B: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("phase3: stale project-A quota shown under project-B: %+v", st.AntigravityQuota) + } +} + +// TestKeeperUpsertLegacyUnboundQuotaClearedWhenIdentityKnown proves the digest-CASE fail-closed +// branch for a legacy/unbound snapshot: a stored antigravity_quota whose identity digest is NULL +// (a pre-digest row, or a snapshot never bound to an identity) has no provable owner. When a new +// inspection resolves a KNOWN identity but its quota fetch FAILS (incoming quota NULL), the unbound +// quota must be CLEARED rather than COALESCE-preserved and inherited by the now-known identity. The +// same-identity transient failure (preserve-on-unknown) is covered by the 3-phase test above. +func TestKeeperUpsertLegacyUnboundQuotaClearedWhenIdentityKnown(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + const authName = "legacy-unbound.json" + + // Seed a legacy row: quota present, identity digest NULL (never bound to an identity). + seed, _ := parseAntigravityQuotaGroups(antigravityGoldenMap(t)) + encoded, _ := json.Marshal(seed) + blob := string(encoded) + ag, idx := keeperProviderAntigravity, "idx-ag" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityQuota: &blob, // AntigravityIdentityDigest left nil (unbound) + }); err != nil { + t.Fatalf("seed: %v", err) + } + if seeded, _ := app.getKeeperState(ctx, authName); seeded.AntigravityIdentityDigest != nil || len(seeded.AntigravityQuota) != 2 { + t.Fatalf("seed precondition: digest=%v quota=%d (want NULL digest, 2 groups)", seeded.AntigravityIdentityDigest, len(seeded.AntigravityQuota)) + } + + // New inspection: identity now KNOWN, but the quota fetch failed (incoming quota NULL). + digest := keeperAntigravityIdentityDigest("proj", "e@x.com") + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: authName, Result: "network_error", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &idx, + AntigravityIdentityDigest: &digest, // AntigravityQuota nil (fetch failed) + }); err != nil { + t.Fatalf("upsert: %v", err) + } + st, err := app.getKeeperState(ctx, authName) + if err != nil { + t.Fatalf("get: %v", err) + } + if st.AntigravityIdentityDigest == nil || *st.AntigravityIdentityDigest != digest { + t.Fatalf("identity digest not bound: %v", st.AntigravityIdentityDigest) + } + if len(st.AntigravityQuota) != 0 { + t.Fatalf("unbound legacy quota not cleared once identity known + fetch failed: %+v", st.AntigravityQuota) + } +} + +// TestKeeperCachedAntigravityPriorityNotDegraded proves the cache-audit stat entry +// (keeperCachedAuthStats → mergeCachedState, used by conditional-refresh / cache-skip accounting) +// does NOT count an Antigravity account at priority -1 as PriorityDegraded: the Codex +// quota-usage→priority=-1 "degraded" semantic does not apply to Antigravity, so it falls through to +// healthy. A Codex control row at priority -1 IS still counted as degraded, proving the provider +// guard is load-bearing rather than blanket-suppressing the branch. +func TestKeeperCachedAntigravityPriorityNotDegraded(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + ctx := context.Background() + + minusOne := -1 + ag, codex := keeperProviderAntigravity, keeperProviderCodex + agIdx, cIdx := "idx-ag", "idx-c" + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "ag.json", Result: "healthy", CheckedAt: time.Now(), Provider: &ag, AuthIndex: &agIdx, Priority: &minusOne, + }); err != nil { + t.Fatalf("seed antigravity: %v", err) + } + if err := app.upsertKeeperState(ctx, keeperAccountResult{ + Name: "codex.json", Result: "healthy", CheckedAt: time.Now(), Provider: &codex, AuthIndex: &cIdx, Priority: &minusOne, + }); err != nil { + t.Fatalf("seed codex: %v", err) + } + + // Antigravity at priority -1: falls through to healthy, NOT degraded. + agStats, err := app.keeperCachedAuthStats(ctx, []string{"ag.json"}) + if err != nil { + t.Fatalf("ag stats: %v", err) + } + if agStats.PriorityDegraded != 0 || agStats.Healthy != 1 { + t.Fatalf("antigravity priority=-1 miscounted: %+v (want PriorityDegraded=0, Healthy=1)", agStats) + } + + // Codex control at priority -1: still counted as degraded (the guard is provider-specific). + cStats, err := app.keeperCachedAuthStats(ctx, []string{"codex.json"}) + if err != nil { + t.Fatalf("codex stats: %v", err) + } + if cStats.PriorityDegraded != 1 || cStats.Healthy != 0 { + t.Fatalf("codex priority=-1 should be degraded: %+v (want PriorityDegraded=1, Healthy=0)", cStats) + } +} + +func timeMust(t *testing.T, s string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatalf("parse time %q: %v", s, err) + } + return parsed +} + +// TestFetchAntigravityQuotaNoProject proves a missing project id fails closed with no remote call. +func TestFetchAntigravityQuotaNoProject(t *testing.T) { + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + calls := 0 + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + http.NotFound(w, r) + })) + defer cpa.Close() + app, err := New() + if err != nil { + t.Fatalf("New(): %v", err) + } + defer app.Close() + configureKeeperTestCPA(t, app, cpa.URL, nil) + cfg, err := app.loadConfig(context.Background()) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + if _, ok := app.fetchAntigravityQuota(context.Background(), cfg, "idx", ""); ok { + t.Fatal("missing project_id must fail closed") + } + mu.Lock() + defer mu.Unlock() + if calls != 0 { + t.Fatalf("no project_id made %d remote calls; want 0", calls) + } +} diff --git a/backend/internal/app/migrations_test.go b/backend/internal/app/migrations_test.go index 568e2696..9f6f487e 100644 --- a/backend/internal/app/migrations_test.go +++ b/backend/internal/app/migrations_test.go @@ -449,6 +449,11 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { if !testColumnExists(t, db, "codex_keeper_auth_states", "account_id") { t.Fatal("head is missing codex_keeper_auth_states.account_id") } + for _, col := range []string{"provider", "antigravity_quota", "antigravity_identity_digest"} { + if !testColumnExists(t, db, "codex_keeper_auth_states", col) { + t.Fatalf("head is missing codex_keeper_auth_states.%s (migration 202609060005)", col) + } + } if !testTableExists(t, db, "codex_keeper_reset_redeems") { t.Fatal("head is missing codex_keeper_reset_redeems") } @@ -481,6 +486,11 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { if testColumnExists(t, db, "codex_keeper_auth_states", "account_id") { t.Fatal("rollback left codex_keeper_auth_states.account_id behind") } + for _, col := range []string{"provider", "antigravity_quota", "antigravity_identity_digest"} { + if testColumnExists(t, db, "codex_keeper_auth_states", col) { + t.Fatalf("rollback left codex_keeper_auth_states.%s behind", col) + } + } // Replay: from the prod baseline (202609040002) migrate Up to head again — the whole // release must be re-runnable after a rollback (040002 → head → 040002 → head). @@ -495,6 +505,9 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { t.Fatalf("post-replay version = %d, want head %d", v, backendMigrations.LatestVersion) } if !testColumnExists(t, db, "codex_keeper_auth_states", "account_id") || + !testColumnExists(t, db, "codex_keeper_auth_states", "provider") || + !testColumnExists(t, db, "codex_keeper_auth_states", "antigravity_quota") || + !testColumnExists(t, db, "codex_keeper_auth_states", "antigravity_identity_digest") || !testTableExists(t, db, "codex_keeper_reset_redeems") || testTableExists(t, db, "codex_keeper_quota_resets") { t.Fatal("replay to head did not restore the full head schema") diff --git a/backend/migrations/202609060005_keeper_provider_antigravity.sql b/backend/migrations/202609060005_keeper_provider_antigravity.sql new file mode 100644 index 00000000..71845562 --- /dev/null +++ b/backend/migrations/202609060005_keeper_provider_antigravity.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- The Codex-Keeper now inspects more than Codex accounts. `provider` records which upstream a +-- row belongs to (codex | antigravity); existing rows predate this and are treated as codex when +-- NULL. `antigravity_quota` stores the parsed Antigravity quota summary (groups -> buckets) as a +-- JSON blob, mirroring how reset_credits is stored; it stays NULL for codex rows. +ALTER TABLE codex_keeper_auth_states ADD COLUMN provider TEXT; +ALTER TABLE codex_keeper_auth_states ADD COLUMN antigravity_quota TEXT; +-- antigravity_identity_digest is a stable, versioned one-way digest of the Antigravity account +-- identity (provider + Google project + normalized email). Only the digest is stored — never the +-- raw project id or email — so a later inspection can detect an identity SWAP (digest changed) and +-- clear the stale quota, without persisting the project or email. +ALTER TABLE codex_keeper_auth_states ADD COLUMN antigravity_identity_digest TEXT; + +-- +goose Down +ALTER TABLE codex_keeper_auth_states DROP COLUMN antigravity_identity_digest; +ALTER TABLE codex_keeper_auth_states DROP COLUMN antigravity_quota; +ALTER TABLE codex_keeper_auth_states DROP COLUMN provider; diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index c0c00b42..4623888a 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -3,7 +3,7 @@ package migrations import "embed" // LatestVersion is the newest embedded migration version this binary expects. -const LatestVersion int64 = 202609060004 +const LatestVersion int64 = 202609060005 // FS contains SQL migrations embedded into the application binary. // diff --git a/docs/migrations-rollback.md b/docs/migrations-rollback.md index fe7e4e30..9182804c 100644 --- a/docs/migrations-rollback.md +++ b/docs/migrations-rollback.md @@ -6,7 +6,7 @@ version is newer than the binary** (goose reports `database migration version is newer than this application`). A binary rollback therefore always requires migrating the schema **down first**. -## Rolling back the reset-credit-consume release (migrations 202609060001–202609060004) +## Rolling back the keeper releases (migrations 202609060001–202609060005) This release added, on top of `202609040002`: @@ -14,6 +14,7 @@ This release added, on top of `202609040002`: - `202609060002` — **DROP** of the obsolete `codex_keeper_quota_resets` table. - `202609060003` — `codex_keeper_reset_redeems` (redeem ledger) table. - `202609060004` — `codex_keeper_auth_states.account_id` column (subscription identity scope). +- `202609060005` — `codex_keeper_auth_states.provider` + `antigravity_quota` + `antigravity_identity_digest` columns (multi-provider inspection: Antigravity accounts; the identity column stores a one-way digest, never the raw project/email). Its Down drops all three columns; no data beyond the Antigravity quota snapshot / provider tag / identity digest is lost. The previous binary (`a996697`, target version `202609040002`) both refuses to start against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in @@ -43,8 +44,9 @@ against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in cpa-helper migrate down-to 202609040002 --allow-pending ``` - This runs the Down migrations for `202609060004`, `202609060003`, `202609060002`, and - `202609060001`: it drops the `account_id` column, drops `codex_keeper_reset_redeems`, + This runs the Down migrations for `202609060005`, `202609060004`, `202609060003`, + `202609060002`, and `202609060001`: it drops the `provider` + `antigravity_quota` + `antigravity_identity_digest` columns, + drops the `account_id` column, drops `codex_keeper_reset_redeems`, drops the `subscription_active_until` column, and **recreates an empty `codex_keeper_quota_resets`** so the old binary's `/accounts` query works. `202609040002` is the only allowlisted rollback target; the command refuses any other diff --git a/frontend/package.json b/frontend/package.json index 90c7ab46..811ae73f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,11 @@ "dev": "vite --host 0.0.0.0 --port 5173", "build": "vue-tsc -b && vite build", "lint": "eslint . --max-warnings=0", - "test:i18n": "node scripts/i18n-smoke.mjs" + "test:i18n": "node scripts/i18n-smoke.mjs", + "test:antigravity-countdown": "node scripts/antigravity-countdown-smoke.mjs", + "test:antigravity-window": "node scripts/antigravity-window-smoke.mjs", + "test:keeper-quota-exhaustion": "node scripts/keeper-quota-exhaustion-smoke.mjs", + "test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs" }, "dependencies": { "echarts": "^5.5.1", diff --git a/frontend/scripts/antigravity-countdown-smoke.mjs b/frontend/scripts/antigravity-countdown-smoke.mjs new file mode 100644 index 00000000..c719328c --- /dev/null +++ b/frontend/scripts/antigravity-countdown-smoke.mjs @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' + +import { createServer } from 'vite' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +const server = await createServer({ + root, + logLevel: 'error', + server: { middlewareMode: true }, +}) + +try { + const { formatAntigravityResetCountdown } = await server.ssrLoadModule( + '/src/features/codex-keeper/antigravityCountdown.ts', + ) + + const now = Date.UTC(2026, 0, 1, 0, 0, 0) + const MINUTE = 60_000 + const HOUR = 3_600_000 + const DAY = 86_400_000 + + const at = (delta) => new Date(now + delta).toISOString() + const zh = (delta) => formatAntigravityResetCountdown(at(delta), now, 'zh') + const en = (delta) => formatAntigravityResetCountdown(at(delta), now, 'en') + + // 4 days 6 hours (exact) → day + hour bucket + assert.equal(zh(4 * DAY + 6 * HOUR), '4 天 6 小时后刷新') + assert.equal(en(4 * DAY + 6 * HOUR), 'Refresh in 4d 6h') + + // 4 hours 56 min (exact) → hour + minute bucket + assert.equal(zh(4 * HOUR + 56 * MINUTE), '4 小时 56 分钟后刷新') + assert.equal(en(4 * HOUR + 56 * MINUTE), 'Refresh in 4h 56m') + + // 59 min → minute bucket + assert.equal(zh(59 * MINUTE), '59 分钟后刷新') + assert.equal(en(59 * MINUTE), 'Refresh in 59m') + + // --- non-integer-minute boundaries: CEIL must NOT under-report --- + + // 4h 56m 30s → ceils up into 4h57m (never floors down to 4h56m) + assert.equal(zh(4 * HOUR + 56 * MINUTE + 30_000), '4 小时 57 分钟后刷新') + assert.equal(en(4 * HOUR + 56 * MINUTE + 30_000), 'Refresh in 4h 57m') + + // 4d 6h 30s → the extra 30s ceils into minutes but stays within the same + // hour bucket (day+hour display drops the minutes); still not under-reported. + assert.equal(zh(4 * DAY + 6 * HOUR + 30_000), '4 天 6 小时后刷新') + assert.equal(en(4 * DAY + 6 * HOUR + 30_000), 'Refresh in 4d 6h') + + // exactly 60000 ms (1 min) → minute bucket + assert.equal(zh(60_000), '1 分钟后刷新') + assert.equal(en(60_000), 'Refresh in 1m') + + // 30 sec → ceils up to 1 minute (no under-report below true remaining) + assert.equal(zh(30_000), '1 分钟后刷新') + assert.equal(en(30_000), 'Refresh in 1m') + + // -5 min → expired / refreshable now + assert.equal(zh(-5 * MINUTE), '可刷新') + assert.equal(en(-5 * MINUTE), 'Refresh available') + + // null resetAtIso → empty string (caller renders a dash) + assert.equal(formatAntigravityResetCountdown(null, now, 'zh'), '') + assert.equal(formatAntigravityResetCountdown(null, now, 'en'), '') + + // unparseable string → empty string as well + assert.equal(formatAntigravityResetCountdown('not-a-date', now, 'zh'), '') + + console.log('antigravity-countdown-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/scripts/antigravity-quota-format-smoke.mjs b/frontend/scripts/antigravity-quota-format-smoke.mjs new file mode 100644 index 00000000..238c6f30 --- /dev/null +++ b/frontend/scripts/antigravity-quota-format-smoke.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' + +import { createServer } from 'vite' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +const server = await createServer({ + root, + logLevel: 'error', + server: { middlewareMode: true }, +}) + +try { + const { antigravityResetWithCountdown, joinAntigravityBuckets, antigravityGroupLine } = + await server.ssrLoadModule('/src/features/codex-keeper/antigravityQuotaFormat.ts') + + // Reset + countdown: Chinese uses full-width parens with no leading space; English uses ASCII + // parens with a leading space. This is the exact spot that rendered `…(Refresh in …)` in English. + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', '4 天 6 小时后刷新', 'zh'), '2026-09-13 14:43(4 天 6 小时后刷新)') + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', 'Refresh in 4d 6h', 'en'), '2026-09-13 14:43 (Refresh in 4d 6h)') + // Empty countdown returns the reset time unchanged (no dangling parens) in both locales. + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', '', 'zh'), '2026-09-13 14:43') + assert.equal(antigravityResetWithCountdown('2026-09-13 14:43', '', 'en'), '2026-09-13 14:43') + + // Bucket separator: full-width comma (zh) vs ", " (en). + assert.equal(joinAntigravityBuckets(['a', 'b', 'c'], 'zh'), 'a,b,c') + assert.equal(joinAntigravityBuckets(['a', 'b', 'c'], 'en'), 'a, b, c') + + // Group line: label + full-width colon (zh) vs ": " (en). No Chinese punctuation may leak into en. + assert.equal(antigravityGroupLine('Gemini 模型', 'a,b', 'zh'), 'Gemini 模型:a,b') + const en = antigravityGroupLine('Gemini Models', 'Weekly 83% remaining, refreshes X (Refresh in 4d 6h)', 'en') + assert.equal(en, 'Gemini Models: Weekly 83% remaining, refreshes X (Refresh in 4d 6h)') + assert.ok(!/[:,()]/.test(en), 'English group line must contain no full-width punctuation') + + console.log('antigravity-quota-format-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/scripts/antigravity-window-smoke.mjs b/frontend/scripts/antigravity-window-smoke.mjs new file mode 100644 index 00000000..7e819e48 --- /dev/null +++ b/frontend/scripts/antigravity-window-smoke.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' + +import { createServer } from 'vite' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +const server = await createServer({ + root, + logLevel: 'error', + server: { middlewareMode: true }, +}) + +try { + const { normalizeAntigravityWindow } = await server.ssrLoadModule( + '/src/features/codex-keeper/antigravityWindow.ts', + ) + + // 5-hour window: every spelling/alias collapses to the same key so the UI localizes it. + for (const alias of ['5h', '5H', '5 hour', '5-hour', '5_hours', ' 5H ']) { + assert.equal(normalizeAntigravityWindow(alias), '5h', `5h alias: ${alias}`) + } + + // Weekly. + for (const alias of ['weekly', 'Weekly', 'week', 'WEEK']) { + assert.equal(normalizeAntigravityWindow(alias), 'weekly', `weekly alias: ${alias}`) + } + + // Daily — a window the reference implementation covers but the old label() dropped to English. + for (const alias of ['daily', 'Daily', 'day', ' DAY ']) { + assert.equal(normalizeAntigravityWindow(alias), 'daily', `daily alias: ${alias}`) + } + + // Monthly. + for (const alias of ['monthly', 'Monthly', 'month', 'MONTH']) { + assert.equal(normalizeAntigravityWindow(alias), 'monthly', `monthly alias: ${alias}`) + } + + // Genuinely unknown windows / empty / null return null so the caller keeps display_name. + for (const unknown of ['yearly', 'hourly', '', ' ', null, undefined]) { + assert.equal(normalizeAntigravityWindow(unknown), null, `unknown window: ${unknown}`) + } + + console.log('antigravity-window-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/scripts/i18n-smoke.mjs b/frontend/scripts/i18n-smoke.mjs index 70706e36..4de958f6 100644 --- a/frontend/scripts/i18n-smoke.mjs +++ b/frontend/scripts/i18n-smoke.mjs @@ -161,6 +161,24 @@ try { localizedServerMessage('账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照'), 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.', ) + assert.equal( + localizedServerMessage('账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照'), + 'Account identity conflict: the Antigravity list and detail disagree on name/type/auth_index/project_id/email; the previous snapshot was preserved.', + ) + // Antigravity quota inspection log/last_error strings must localize (English account page + // must not show Chinese): a bare failure exact, plus name-prefixed success/failure log lines. + assert.equal( + localizedServerMessage('Antigravity 配额读取失败'), + 'Failed to read Antigravity quota', + ) + assert.equal( + localizedServerMessage('antigravity@example.com.json:Antigravity 配额刷新成功(2 组)'), + 'antigravity@example.com.json: Antigravity quota refreshed (2 groups)', + ) + assert.equal( + localizedServerMessage('antigravity@example.com.json:Antigravity 配额读取失败'), + 'antigravity@example.com.json: Failed to read Antigravity quota', + ) assert.equal( localizedServerMessage('账号 account_id 身份冲突(列表与详情不一致),请刷新后重试'), 'Account account_id identity conflict (list and detail disagree). Refresh and try again.', diff --git a/frontend/scripts/keeper-quota-exhaustion-smoke.mjs b/frontend/scripts/keeper-quota-exhaustion-smoke.mjs new file mode 100644 index 00000000..c4932381 --- /dev/null +++ b/frontend/scripts/keeper-quota-exhaustion-smoke.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' + +import { createServer } from 'vite' + +const root = fileURLToPath(new URL('..', import.meta.url)) + +const server = await createServer({ + root, + logLevel: 'error', + server: { middlewareMode: true }, +}) + +try { + const { isAntigravityAccount, isQuotaExhaustedAccount } = await server.ssrLoadModule( + '/src/features/codex-keeper/keeperQuotaExhaustion.ts', + ) + + // Codex account at priority -1 (not disabled) IS quota-exhausted. + assert.equal( + isQuotaExhaustedAccount({ provider: 'codex', disabled: false, priority: -1 }), + true, + 'codex priority -1 should be exhausted', + ) + + // Antigravity account at priority -1 must NOT be counted as quota-exhausted — Antigravity does + // not run the Codex quota->priority policy. This is the provider-isolation guard both the status + // page and the inspection-settings page share. + assert.equal( + isQuotaExhaustedAccount({ provider: 'antigravity', disabled: false, priority: -1 }), + false, + 'antigravity priority -1 must not be exhausted', + ) + + // Disabled codex at -1 is reported by its disabled state, not exhaustion. + assert.equal(isQuotaExhaustedAccount({ provider: 'codex', disabled: true, priority: -1 }), false) + + // Non -1 priority is never exhausted (including the ?? 0 default when priority is missing). + assert.equal(isQuotaExhaustedAccount({ provider: 'codex', disabled: false, priority: 5 }), false) + assert.equal(isQuotaExhaustedAccount({ provider: 'codex', disabled: false }), false) + + // Provider discriminator. + assert.equal(isAntigravityAccount({ provider: 'antigravity' }), true) + assert.equal(isAntigravityAccount({ provider: 'codex' }), false) + assert.equal(isAntigravityAccount({}), false) + + console.log('keeper-quota-exhaustion-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/src/features/codex-keeper/antigravityCountdown.ts b/frontend/src/features/codex-keeper/antigravityCountdown.ts new file mode 100644 index 00000000..69b103c9 --- /dev/null +++ b/frontend/src/features/codex-keeper/antigravityCountdown.ts @@ -0,0 +1,48 @@ +// formatAntigravityResetCountdown renders a FINE-GRAINED reset countdown for +// Antigravity quota buckets, mirroring upstream CPAMC's duration buckets: +// day+hour / hour+minute / minute. This deliberately differs from Codex's coarse +// formatQuotaResetCountdown ("1小时后" for 59m, etc.). Pure and dependency-free so +// it can be unit-tested in isolation. +// +// Uses CEIL-to-minute (CPAMC's algorithm) so we NEVER under-report the remaining +// time: e.g. 4h56m30s rounds up to 4h57m rather than flooring to 4h56m. +// +// - resetAtIso null/undefined/unparseable → '' (caller renders a dash). +// - deltaMs <= 0 → zh '可刷新' / en 'Refresh available'. +// - days >= 1 → zh '${d} 天 ${h} 小时后刷新' / en 'Refresh in ${d}d ${h}h'. +// - hours >= 1 → zh '${h} 小时 ${m} 分钟后刷新' / en 'Refresh in ${h}h ${m}m'. +// - otherwise → zh '${m} 分钟后刷新' / en 'Refresh in ${m}m'. +// (minutes is >= 1 here because Math.ceil of any positive delta is >= 1) +export function formatAntigravityResetCountdown( + resetAtIso: string | null | undefined, + nowMs: number, + lang: 'zh' | 'en', +): string { + if (!resetAtIso) { + return '' + } + const resetMs = new Date(resetAtIso).getTime() + if (Number.isNaN(resetMs)) { + return '' + } + const deltaMs = resetMs - nowMs + if (deltaMs <= 0) { + return lang === 'zh' ? '可刷新' : 'Refresh available' + } + const totalMinutes = Math.ceil(deltaMs / 60000) + const days = Math.floor(totalMinutes / 1440) + const remMin = totalMinutes % 1440 + const hours = Math.floor(remMin / 60) + const minutes = remMin % 60 + if (days >= 1) { + return lang === 'zh' + ? `${days} 天 ${hours} 小时后刷新` + : `Refresh in ${days}d ${hours}h` + } + if (hours >= 1) { + return lang === 'zh' + ? `${hours} 小时 ${minutes} 分钟后刷新` + : `Refresh in ${hours}h ${minutes}m` + } + return lang === 'zh' ? `${minutes} 分钟后刷新` : `Refresh in ${minutes}m` +} diff --git a/frontend/src/features/codex-keeper/antigravityQuotaFormat.ts b/frontend/src/features/codex-keeper/antigravityQuotaFormat.ts new file mode 100644 index 00000000..16455939 --- /dev/null +++ b/frontend/src/features/codex-keeper/antigravityQuotaFormat.ts @@ -0,0 +1,25 @@ +// Locale-correct punctuation for the Antigravity quota summary. The status table cell and the +// account drawer both stitch together bucket lines, so the separators/parentheses must follow the +// active UI language — Chinese uses full-width `:,()`, English uses ASCII `: , ()`. Hardcoding +// full-width punctuation made the English account page render e.g. `Gemini Models:Weekly …(Refresh +// in …)`. These helpers are pure so they can be unit-smoke-tested for both locales. +export type QuotaLang = 'zh' | 'en' + +// antigravityResetWithCountdown appends the countdown to the absolute reset time in locale-correct +// parentheses. An empty countdown returns the reset time unchanged. +export function antigravityResetWithCountdown(resetTime: string, countdown: string, lang: QuotaLang): string { + if (!countdown) { + return resetTime + } + return lang === 'zh' ? `${resetTime}(${countdown})` : `${resetTime} (${countdown})` +} + +// joinAntigravityBuckets joins bucket summaries within a group. +export function joinAntigravityBuckets(parts: string[], lang: QuotaLang): string { + return parts.join(lang === 'zh' ? ',' : ', ') +} + +// antigravityGroupLine prefixes a group's label to its joined buckets. +export function antigravityGroupLine(label: string, buckets: string, lang: QuotaLang): string { + return lang === 'zh' ? `${label}:${buckets}` : `${label}: ${buckets}` +} diff --git a/frontend/src/features/codex-keeper/antigravityWindow.ts b/frontend/src/features/codex-keeper/antigravityWindow.ts new file mode 100644 index 00000000..fecf229f --- /dev/null +++ b/frontend/src/features/codex-keeper/antigravityWindow.ts @@ -0,0 +1,24 @@ +// normalizeAntigravityWindow collapses the many aliases the Antigravity quota schema uses for the +// same reset window into a stable key, so the UI can localize it instead of leaking the raw English +// bucket display_name. CPAMC's reference implementation treats e.g. "5h"/"5 hour"/"5-hour" as one +// window and also surfaces "daily"/"monthly"; the schema may return any of these spellings. We +// lower-case and strip spaces, hyphens, and underscores before matching. A genuinely unknown window +// returns null so the caller can fall back to the bucket's own display_name. +export type AntigravityWindowKey = 'weekly' | '5h' | 'daily' | 'monthly' + +export function normalizeAntigravityWindow(window: string | null | undefined): AntigravityWindowKey | null { + const normalized = (window ?? '').toLowerCase().replace(/[\s_-]+/g, '') + if (normalized === '5h' || normalized === '5hour' || normalized === '5hours') { + return '5h' + } + if (normalized === 'weekly' || normalized === 'week') { + return 'weekly' + } + if (normalized === 'daily' || normalized === 'day') { + return 'daily' + } + if (normalized === 'monthly' || normalized === 'month') { + return 'monthly' + } + return null +} diff --git a/frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts b/frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts new file mode 100644 index 00000000..d056eab9 --- /dev/null +++ b/frontend/src/features/codex-keeper/keeperQuotaExhaustion.ts @@ -0,0 +1,18 @@ +import type { CodexKeeperAccount } from '@/shared/types/api' + +// isAntigravityAccount identifies an Antigravity-provider Keeper account. The Keeper now inspects +// both Codex and Antigravity accounts, so provider is the discriminator, not account type. +export function isAntigravityAccount(account: Pick<CodexKeeperAccount, 'provider'>): boolean { + return account.provider === 'antigravity' +} + +// isQuotaExhaustedAccount reports whether an account is in the Codex "quota exhausted" state. +// priority === -1 is the Codex quota-usage→priority policy's exhaustion marker; Antigravity does +// NOT run that policy, so a -1 on an Antigravity account must never be read as quota exhausted. +// Shared by every Keeper view (status + inspection settings) so the provider guard can't drift +// between them. +export function isQuotaExhaustedAccount( + account: Pick<CodexKeeperAccount, 'disabled' | 'priority' | 'provider'>, +): boolean { + return !account.disabled && !isAntigravityAccount(account) && (account.priority ?? 0) === -1 +} diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue index 85420e98..4d3cf5f5 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperInspectionView.vue @@ -37,6 +37,7 @@ import { stopCodexKeeper, updateCodexKeeperSettings, } from '@/features/codex-keeper/api/codexKeeperApi' +import { isQuotaExhaustedAccount } from '@/features/codex-keeper/keeperQuotaExhaustion' import type { CodexKeeperAccount, CodexKeeperPriorityRule, @@ -181,9 +182,6 @@ function applySettings(nextSettings: Awaited<ReturnType<typeof getCodexKeeperSet priorityRules.value = nextSettings.priority_rules.map((rule) => ({ ...rule })) } -function isQuotaExhaustedAccount(account: CodexKeeperAccount): boolean { - return !account.disabled && (account.priority ?? 0) === -1 -} async function loadAll() { isLoading.value = true @@ -511,7 +509,7 @@ onBeforeUnmount(() => { <div class="page-header"> <div> <h1 class="page-title">{{ t('巡检设置', 'Inspection Settings') }}</h1> - <p class="page-subtitle">{{ t('维护 Codex auth file 的健康状态和调度优先级', 'Maintain Codex auth file health and scheduling priorities') }}</p> + <p class="page-subtitle">{{ t('维护 Keeper 账号的健康状态和调度优先级', 'Maintain Keeper account health and scheduling priorities') }}</p> </div> <NSpace> <NButton secondary :loading="isLoading" @click="loadAll">{{ t('重新加载', 'Reload') }}</NButton> diff --git a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue index 1c4378dd..b43d0328 100644 --- a/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue +++ b/frontend/src/features/codex-keeper/views/CodexKeeperStatusView.vue @@ -49,7 +49,17 @@ import { updateCodexKeeperPriority, } from '@/features/codex-keeper/api/codexKeeperApi' import type { CodexKeeperResetResult } from '@/features/codex-keeper/api/codexKeeperApi' +import { formatAntigravityResetCountdown } from '@/features/codex-keeper/antigravityCountdown' +import { normalizeAntigravityWindow } from '@/features/codex-keeper/antigravityWindow' +import { + antigravityGroupLine, + antigravityResetWithCountdown, + joinAntigravityBuckets, +} from '@/features/codex-keeper/antigravityQuotaFormat' +import { isQuotaExhaustedAccount } from '@/features/codex-keeper/keeperQuotaExhaustion' import type { + AntigravityQuotaBucket, + AntigravityQuotaGroup, CodexKeeperAccount, CodexKeeperPriorityRule, CodexKeeperQuotaWindowUsage, @@ -696,9 +706,6 @@ function accountPriority(account: CodexKeeperAccount): number { return account.priority ?? 0 } -function isQuotaExhaustedAccount(account: CodexKeeperAccount): boolean { - return !account.disabled && accountPriority(account) === -1 -} function priorityTypeFilter(accountType: string): PriorityTypeFilter { return `type:${accountType}` @@ -834,6 +841,76 @@ function accountTypeLabel(accountType: string | null): string { return accountType ?? normalized } +// providerAwareAccountTypeLabel returns the antigravity provider label for +// antigravity accounts, otherwise the codex account-type label. +function providerAwareAccountTypeLabel(account: CodexKeeperAccount): string { + if (isAntigravityAccount(account)) return 'Antigravity' + return accountTypeLabel(account.account_type) +} + +function isAntigravityAccount(account: CodexKeeperAccount): boolean { + return account.provider === 'antigravity' +} + +// antigravityQuotaGroups returns the non-empty quota groups for an antigravity +// account, or an empty list for codex accounts / missing data. +function antigravityQuotaGroups(account: CodexKeeperAccount): AntigravityQuotaGroup[] { + if (!isAntigravityAccount(account)) { + return [] + } + return (account.antigravity_quota ?? []).filter((group) => (group.buckets?.length ?? 0) > 0) +} + +// antigravityWindowLabel maps the bucket window to a localized label. The quota schema uses +// several aliases for the same window (CPAMC's reference covers e.g. "5h"/"5 hour"/"5-hour", +// "weekly"/"week", "daily", "monthly"), so we normalize by lower-casing and stripping spaces, +// hyphens, and underscores before matching. Only a genuinely unknown window falls back to the +// bucket's own display_name (which would render in English and bypass i18n). +function antigravityWindowLabel(bucket: AntigravityQuotaBucket): string { + switch (normalizeAntigravityWindow(bucket.window)) { + case '5h': + return t('5 小时', '5-hour') + case 'weekly': + return t('每周', 'Weekly') + case 'daily': + return t('每日', 'Daily') + case 'monthly': + return t('每月', 'Monthly') + default: + return bucket.display_name + } +} + +// antigravityGroupLabel maps the group's known English display_name to a +// localized label, falling back to the raw display_name for unknown groups. +function antigravityGroupLabel(group: AntigravityQuotaGroup): string { + const name = (group.display_name ?? '').trim() + if (name === 'Gemini Models') return t('Gemini 模型', 'Gemini Models') + if (name === 'Claude and GPT models') return t('Claude 和 GPT 模型', 'Claude and GPT models') + return group.display_name +} + +// antigravityDescriptionText localizes the known upstream group description +// prefix ("Models within this group: ..."), falling back to the raw free-text +// description for any unknown wording. +function antigravityDescriptionText(description: string | undefined): string { + const raw = (description ?? '').trim() + if (!raw) return '' + const prefix = 'Models within this group:' + if (raw.startsWith(prefix)) { + const models = raw.slice(prefix.length).trim() + return t(`此分组包含:${models}`, `Models in this group: ${models}`) + } + return raw // unknown free-text description → raw fallback +} + +// antigravityRemainingPercent clamps remaining_fraction (0..1) to a 0..100 integer +// percentage for the remaining-quota bar. +function antigravityRemainingPercent(bucket: AntigravityQuotaBucket): number { + const fraction = Number.isFinite(bucket.remaining_fraction) ? bucket.remaining_fraction : 0 + return Math.max(0, Math.min(100, Math.round(fraction * 100))) +} + function isPaidQuotaWindowAccount(accountType: string | null): boolean { const normalized = accountType?.trim().toLowerCase() return normalized === 'plus' || normalized === 'team' || normalized === 'k12' || normalized?.startsWith('pro') === true @@ -1006,6 +1083,9 @@ function formatQuotaResetCountdown(value: string | null): string | null { } function quotaText(account: CodexKeeperAccount): string { + if (isAntigravityAccount(account)) { + return antigravityQuotaText(account) + } const items = quotaWindowItems(account) if (items.length === 0) { return '-' @@ -1057,6 +1137,19 @@ function quotaWindowResetText(item: QuotaWindowItem): string { return resetTime ? t(`刷新 ${resetTime}`, `Refreshes ${resetTime}`) : t('未记录刷新时间', 'No refresh time recorded') } +// antigravityCardResetText renders the reset time + countdown hint for an +// antigravity quota bucket inside the account card quota block. Null-safe. +function antigravityCardResetText(resetAt: string | null): string { + const resetTime = formatQuotaResetTime(resetAt) + if (!resetTime) { + return t('未记录刷新时间', 'No refresh time recorded') + } + const countdown = formatAntigravityResetCountdown(resetAt, nowMs.value, currentLanguage.value) + return countdown + ? t(`刷新 ${resetTime}(${countdown})`, `Refreshes ${resetTime} (${countdown})`) + : t(`刷新 ${resetTime}`, `Refreshes ${resetTime}`) +} + function quotaWindowUsageTitle(item: QuotaWindowItem): string { const usage = item.usage if (!item.resetAt || usage?.stale === true) { @@ -1107,7 +1200,97 @@ function disabledStatusCodeTitle(account: CodexKeeperAccount): string | null { return text === null ? null : `HTTP ${text}` } +// renderAntigravityQuotaCell renders the antigravity provider's quota groups and +// their buckets. Each bucket shows a remaining-quota bar (remaining_fraction * 100) +// reusing the codex quota-window bar styling, plus the reset time and countdown. +function renderAntigravityQuotaCell(account: CodexKeeperAccount) { + const groups = antigravityQuotaGroups(account) + if (groups.length === 0) { + return '-' + } + return h( + 'div', + { class: 'quota-window-cell' }, + groups.map((group) => + h('div', { class: 'quota-antigravity-group' }, [ + h('div', { class: 'quota-antigravity-group-title', title: antigravityDescriptionText(group.description) || group.display_name }, antigravityGroupLabel(group)), + ...group.buckets.map((bucket) => { + const remainingPercent = antigravityRemainingPercent(bucket) + const label = antigravityWindowLabel(bucket) + const resetTime = formatQuotaResetTime(bucket.reset_at) + const countdown = formatAntigravityResetCountdown(bucket.reset_at, nowMs.value, currentLanguage.value) + return h( + 'div', + { + class: 'quota-window-item', + title: bucket.description + ? t( + `${label} 剩余 ${remainingPercent}%;${bucket.description}`, + `${label} ${remainingPercent}% remaining; ${bucket.description}`, + ) + : t(`${label} 剩余 ${remainingPercent}%`, `${label} ${remainingPercent}% remaining`), + }, + [ + h('div', { class: 'quota-window-head' }, [ + h('span', { class: 'quota-window-label' }, label), + h('span', { class: 'quota-window-meta' }, [ + h('span', { class: 'quota-window-percent' }, t(`剩余 ${remainingPercent}%`, `${remainingPercent}% remaining`)), + resetTime ? h('span', { class: 'quota-window-reset' }, antigravityResetWithCountdown(resetTime, countdown, currentLanguage.value)) : null, + ]), + ]), + h('div', { class: 'quota-window-track' }, [ + h('div', { + class: ['quota-window-fill', quotaBarTone(remainingPercent)], + style: { width: `${remainingPercent}%` }, + }), + ]), + ], + ) + }), + ]), + ), + ) +} + +// antigravityQuotaText renders a compact single-line summary of the antigravity +// quota groups/buckets for the account detail drawer. +function antigravityQuotaText(account: CodexKeeperAccount): string { + const groups = antigravityQuotaGroups(account) + if (groups.length === 0) { + return '-' + } + return groups + .map((group) => { + const bucketParts = group.buckets.map((bucket) => { + const remainingPercent = antigravityRemainingPercent(bucket) + const resetTime = formatQuotaResetTime(bucket.reset_at) + const countdown = formatAntigravityResetCountdown(bucket.reset_at, nowMs.value, currentLanguage.value) + const label = antigravityWindowLabel(bucket) + if (resetTime) { + const reset = antigravityResetWithCountdown(resetTime, countdown, currentLanguage.value) + return t( + `${label}剩余 ${remainingPercent}%,刷新 ${reset}`, + `${label} ${remainingPercent}% remaining, refreshes ${reset}`, + ) + } + if (countdown) { + return t( + `${label}剩余 ${remainingPercent}%,${countdown}`, + `${label} ${remainingPercent}% remaining, ${countdown}`, + ) + } + return t(`${label}剩余 ${remainingPercent}%`, `${label} ${remainingPercent}% remaining`) + }) + const buckets = joinAntigravityBuckets(bucketParts, currentLanguage.value) + return antigravityGroupLine(antigravityGroupLabel(group), buckets, currentLanguage.value) + }) + .join(' / ') +} + function renderQuotaCell(account: CodexKeeperAccount) { + if (isAntigravityAccount(account)) { + return renderAntigravityQuotaCell(account) + } const items = quotaWindowItems(account) if (items.length === 0) { return '-' @@ -1152,6 +1335,9 @@ function renderQuotaCell(account: CodexKeeperAccount) { } function renderQuotaUsageCell(account: CodexKeeperAccount) { + if (isAntigravityAccount(account)) { + return '-' + } const items = quotaWindowItems(account) if (items.length === 0) { return '-' @@ -1185,6 +1371,10 @@ function renderQuotaUsageCell(account: CodexKeeperAccount) { // countdown — the account's "主动重置过期时间" from wham/rate-limit-reset-credits. // A null expires_at means the credit never expires; such entries are still shown. function renderResetCreditScheduleCell(account: CodexKeeperAccount) { + // Reset credits are a codex-only concept; antigravity accounts have none. + if (isAntigravityAccount(account)) { + return '—' + } const credits = account.reset_credits ?? [] const count = account.reset_credit_count // The authoritative count is reset_credit_count. When it is null the snapshot is @@ -1226,6 +1416,10 @@ function renderResetCreditScheduleCell(account: CodexKeeperAccount) { // CPA from the account's id_token `chatgpt_subscription_active_until` claim) plus // a coarse countdown. A null value (no subscription / unknown) renders a dash. function renderSubscriptionCell(account: CodexKeeperAccount) { + // Subscription renewal is codex-specific; antigravity accounts do not carry it. + if (isAntigravityAccount(account)) { + return '—' + } const value = account.subscription_active_until if (!value) { return '-' @@ -1275,7 +1469,7 @@ function renderAccountIdentityCell(account: CodexKeeperAccount) { } function renderAccountTypeCell(account: CodexKeeperAccount) { - const typeLabel = accountTypeLabel(account.account_type) + const typeLabel = providerAwareAccountTypeLabel(account) return h( 'span', { class: ['account-table-chip', 'is-type'], title: typeLabel }, @@ -1902,18 +2096,21 @@ const disabledActionColumn = computed<DataTableColumns<CodexKeeperAccount>[numbe }, { default: () => t('刷新', 'Refresh') }, ), - h( - NButton, - { - size: 'small', - quaternary: true, - type: 'warning', - disabled: isRowActing(row) || isBulkDeleting.value || isBulkRefreshing.value, - loading: isActionLoading(row, 'reset-quota'), - onClick: () => confirmResetQuota(row), - }, - { default: () => t('重置', 'Reset') }, - ), + // Reset credits do not apply to antigravity accounts, so omit the button. + isAntigravityAccount(row) + ? null + : h( + NButton, + { + size: 'small', + quaternary: true, + type: 'warning', + disabled: isRowActing(row) || isBulkDeleting.value || isBulkRefreshing.value, + loading: isActionLoading(row, 'reset-quota'), + onClick: () => confirmResetQuota(row), + }, + { default: () => t('重置', 'Reset') }, + ), ], }, ) @@ -1970,18 +2167,21 @@ const normalActionColumn = computed<DataTableColumns<CodexKeeperAccount>[number] }, { default: () => t('刷新', 'Refresh') }, ), - h( - NButton, - { - size: 'small', - quaternary: true, - type: 'warning', - disabled: isRowActing(row) || isBulkDeleting.value || isBulkRefreshing.value, - loading: isActionLoading(row, 'reset-quota'), - onClick: () => confirmResetQuota(row), - }, - { default: () => t('重置', 'Reset') }, - ), + // Reset credits do not apply to antigravity accounts, so omit the button. + isAntigravityAccount(row) + ? null + : h( + NButton, + { + size: 'small', + quaternary: true, + type: 'warning', + disabled: isRowActing(row) || isBulkDeleting.value || isBulkRefreshing.value, + loading: isActionLoading(row, 'reset-quota'), + onClick: () => confirmResetQuota(row), + }, + { default: () => t('重置', 'Reset') }, + ), ], }, ) @@ -2077,7 +2277,7 @@ onBeforeUnmount(() => { </NButton> </div> </div> - <p class="page-subtitle">{{ t('查看 Codex auth file 的健康、额度和优先级维护结果', 'View Codex auth file health, quota, and priority maintenance results') }}</p> + <p class="page-subtitle">{{ t('查看 Keeper 账号的健康、额度和优先级维护结果', 'View Keeper account health, quota, and priority maintenance results') }}</p> </div> </div> @@ -2465,7 +2665,7 @@ onBeforeUnmount(() => { <div class="account-card-meta-grid"> <div class="account-card-meta-item"> <span>{{ t('类型', 'Type') }}</span> - <strong>{{ accountTypeLabel(account.account_type) }}</strong> + <strong>{{ providerAwareAccountTypeLabel(account) }}</strong> </div> <div class="account-card-meta-item"> <span>{{ t('优先级', 'Priority') }}</span> @@ -2487,7 +2687,79 @@ onBeforeUnmount(() => { <strong>{{ disabledCardErrorText(account) }}</strong> </div> <div v-else-if="shouldShowQuotaWindow(account)" class="account-card-quota"> - <template v-if="quotaWindowItems(account).length > 0"> + <template v-if="isAntigravityAccount(account)"> + <template v-if="antigravityQuotaGroups(account).length > 0"> + <template v-if="isBarCardView"> + <div + v-for="group in antigravityQuotaGroups(account)" + :key="group.display_name" + class="card-quota-antigravity-group" + > + <div + class="quota-antigravity-group-title" + :title="antigravityDescriptionText(group.description) || group.display_name" + > + {{ antigravityGroupLabel(group) }} + </div> + <div + v-for="bucket in group.buckets" + :key="bucket.bucket_id" + class="card-quota-bar" + > + <div class="card-quota-head"> + <span>{{ antigravityWindowLabel(bucket) }}</span> + <strong>{{ t(`剩余 ${antigravityRemainingPercent(bucket)}%`, `${antigravityRemainingPercent(bucket)}% remaining`) }}</strong> + </div> + <div class="card-quota-track"> + <div + class="card-quota-fill" + :class="quotaBarTone(antigravityRemainingPercent(bucket))" + :style="{ width: `${antigravityRemainingPercent(bucket)}%` }" + /> + </div> + <span class="card-quota-reset"> + {{ antigravityCardResetText(bucket.reset_at) }} + </span> + </div> + </div> + </template> + <div v-else class="card-quota-rings"> + <div + v-for="group in antigravityQuotaGroups(account)" + :key="group.display_name" + class="card-quota-antigravity-group" + > + <div + class="quota-antigravity-group-title" + :title="antigravityDescriptionText(group.description) || group.display_name" + > + {{ antigravityGroupLabel(group) }} + </div> + <div + v-for="bucket in group.buckets" + :key="bucket.bucket_id" + class="card-quota-ring-item" + > + <div class="card-quota-ring-head"> + <div + class="quota-ring" + :class="quotaBarTone(antigravityRemainingPercent(bucket))" + :style="{ '--quota-deg': `${antigravityRemainingPercent(bucket) * 3.6}deg` }" + > + <span>{{ antigravityRemainingPercent(bucket) }}%</span> + </div> + <div class="quota-ring-caption"> + <strong>{{ antigravityWindowLabel(bucket) }}</strong> + <span>{{ antigravityCardResetText(bucket.reset_at) }}</span> + </div> + </div> + </div> + </div> + </div> + </template> + <div v-else class="card-quota-empty">{{ t('暂无额度窗口', 'No quota windows') }}</div> + </template> + <template v-else-if="quotaWindowItems(account).length > 0"> <template v-if="isBarCardView"> <div v-for="item in quotaWindowItems(account)" @@ -2600,7 +2872,7 @@ onBeforeUnmount(() => { <NDescriptionsItem :label="t('账号', 'Account')">{{ selectedAccount.name }}</NDescriptionsItem> <NDescriptionsItem :label="t('邮箱', 'Email')">{{ selectedAccount.email ?? '-' }}</NDescriptionsItem> <NDescriptionsItem :label="t('账号类型', 'Account Type')"> - {{ accountTypeLabel(selectedAccount.account_type) }} + {{ providerAwareAccountTypeLabel(selectedAccount) }} </NDescriptionsItem> <NDescriptionsItem :label="t('启用状态', 'Enabled Status')"> {{ selectedAccount.disabled ? t('已禁用', 'Disabled') : t('启用中', 'Enabled') }} @@ -2623,7 +2895,10 @@ onBeforeUnmount(() => { <NDescriptionsItem :label="t('最近巡检', 'Last Inspection')"> {{ formatDateTime(selectedAccount.last_checked_at) }} </NDescriptionsItem> - <NDescriptionsItem :label="t('续期时间', 'Renews At')"> + <NDescriptionsItem + v-if="!isAntigravityAccount(selectedAccount)" + :label="t('续期时间', 'Renews At')" + > {{ subscriptionDetailText(selectedAccount) }} </NDescriptionsItem> <NDescriptionsItem :label="t('最近操作', 'Latest Action')"> @@ -3333,6 +3608,25 @@ onBeforeUnmount(() => { padding-top: 2px; } +.card-quota-antigravity-group { + display: grid; + gap: 10px; + min-width: 0; + padding-top: 9px; + border-top: 1px solid var(--account-card-inner-border); +} + +.card-quota-antigravity-group:first-child { + padding-top: 0; + border-top: 0; +} + +.card-quota-antigravity-group .card-quota-bar, +.card-quota-antigravity-group .card-quota-bar:first-child { + padding-top: 0; + border-top: 0; +} + .card-quota-bar { display: grid; gap: 7px; @@ -3655,6 +3949,22 @@ onBeforeUnmount(() => { min-height: 38px; } +:global(.quota-antigravity-group) { + display: grid; + gap: 6px; + min-width: 0; +} + +:global(.quota-antigravity-group-title) { + min-width: 0; + overflow: hidden; + color: var(--cpa-text-muted); + font-size: 11px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + :global(.quota-window-head) { display: flex; align-items: center; diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts index 1844990a..8e444344 100644 --- a/frontend/src/shared/i18n/messages.ts +++ b/frontend/src/shared/i18n/messages.ts @@ -42,6 +42,8 @@ const exactServerMessages: MessagePair[] = [ ['账号身份已变化(account_id 不一致),请刷新账号列表后重试', 'The account identity has changed (account_id mismatch). Refresh the account list and try again.'], ['账号尚未确认身份(缺少 account_id),请先刷新账号列表后再重置', 'The account identity is not confirmed yet (no account_id). Refresh the account list before resetting.'], ['账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照', 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.'], + ['账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照', 'Account identity conflict: the Antigravity list and detail disagree on name/type/auth_index/project_id/email; the previous snapshot was preserved.'], + ['Antigravity 配额读取失败', 'Failed to read Antigravity quota'], ['无法确认可用重置额度(快照未知),请刷新后重试', 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.'], ['核销主动重置额度失败:网络异常,未确认是否已核销', 'Failed to redeem the reset credit: network error; redemption is unconfirmed.'], ['核销主动重置额度失败:管理接口异常', 'Failed to redeem the reset credit: management API error.'], @@ -63,14 +65,14 @@ const exactServerMessages: MessagePair[] = [ ['当前密码不正确', 'Current password is incorrect'], ['请先创建第一个管理员账号', 'Create the first admin account first'], ['尚未运行', 'Not run yet'], - ['正在运行多个 Codex Keeper 任务', 'Multiple Codex Keeper tasks are running'], - ['正在刷新 Codex 账号', 'Refreshing Codex accounts'], - ['正在按条件刷新 Codex 账号', 'Refreshing Codex accounts by condition'], - ['正在巡检 Codex 账号', 'Inspecting Codex accounts'], + ['正在运行多个 Keeper 任务', 'Multiple Keeper tasks are running'], + ['正在刷新账号', 'Refreshing accounts'], + ['正在按条件刷新账号', 'Refreshing accounts by condition'], + ['正在巡检账号', 'Inspecting accounts'], ['巡检完成', 'Inspection complete'], - ['缓存时间内没有需要自动刷新的 Codex auth file', 'No Codex auth files need automatic refresh inside the cache window'], - ['未发现指定 Codex auth file', 'No matching Codex auth file was found'], - ['未发现 Codex auth file', 'No Codex auth files were found'], + ['缓存时间内没有需要自动刷新的 auth file', 'No auth files need automatic refresh inside the cache window'], + ['未发现指定 auth file', 'No matching auth file was found'], + ['未发现 auth file', 'No auth files were found'], ['缺少 access token', 'Missing access token'], ['读取 auth file 详情失败', 'Failed to read auth file details'], ['管理密钥未设置,无法运行 Codex Keeper', 'Management key is not set, so Codex Keeper cannot run'], @@ -225,6 +227,10 @@ const serverTermTranslations: MessagePair[] = [ ] const serverMessagePatterns: ServerMessagePattern[] = [ + // Antigravity quota log lines must be matched BEFORE the generic `…失败` family below, or the + // name-prefixed failure line falls through to `(.+)失败` and renders half-translated. + [/^(.+?):Antigravity 配额刷新成功((\d+) 组)$/, ([, name, count]) => `${name}: Antigravity quota refreshed (${count} groups)`], + [/^(.+?):Antigravity 配额读取失败$/, ([, name]) => `${name}: Failed to read Antigravity quota`], [/^操作失败$/, () => 'Operation failed'], [/^加载(.+)失败$/, ([, subject]) => `Failed to load ${translateTerms(subject ?? '')}`], [/^保存(.+)失败$/, ([, subject]) => `Failed to save ${translateTerms(subject ?? '')}`], @@ -290,11 +296,11 @@ const serverMessagePatterns: ServerMessagePattern[] = [ [/^启用代理时必须填写代理地址$/, () => 'Proxy URL is required when proxy is enabled'], [/^Cron 表达式无效,请使用 5 段格式:分 时 日 月 周$/, () => 'Invalid Cron expression. Use the 5-field format: minute hour day month weekday'], [/^Cron 表达式无效,请使用 5 段格式$/, () => 'Invalid Cron expression. Use the 5-field format'], - [/^开始按条件刷新 (\d+) 个 Codex 账号$/, ([, count]) => `Started conditional refresh for ${count} Codex accounts`], - [/^开始刷新 (\d+) 个 Codex 账号$/, ([, count]) => `Started refreshing ${count} Codex accounts`], - [/^开始 Codex 账号巡检$/, () => 'Started Codex account inspection'], + [/^开始按条件刷新 (\d+) 个账号$/, ([, count]) => `Started conditional refresh for ${count} accounts`], + [/^开始刷新 (\d+) 个账号$/, ([, count]) => `Started refreshing ${count} accounts`], + [/^开始账号巡检$/, () => 'Started account inspection'], [/^下一轮计划:(.+)$/, ([, time]) => `Next scheduled run: ${time}`], - [/^清理本地已不存在的 Codex 账号 (\d+) 个$/, ([, count]) => `Cleaned up ${count} local Codex accounts that no longer exist`], + [/^清理本地已不存在的账号 (\d+) 个$/, ([, count]) => `Cleaned up ${count} local accounts that no longer exist`], [/^巡检完成:网络错误 (\d+)$/, ([, count]) => `Inspection complete: ${count} network errors`], [/^条件刷新完成:健康 (\d+),坏凭证禁用 (\d+),恢复启用 (\d+),优先级降级 (\d+),优先级恢复 (\d+),网络错误 (\d+),缓存跳过 (\d+)$/, ([, healthy, disabled, restored, degraded, priorityRestored, networkErrors, skipped]) => `Conditional refresh complete: ${healthy} healthy, ${disabled} bad credentials disabled, ${restored} restored, ${degraded} priorities lowered, ${priorityRestored} priorities restored, ${networkErrors} network errors, ${skipped} skipped by cache`], [/^账号刷新完成:健康 (\d+),凭证异常 (\d+),恢复启用 (\d+),优先级降级 (\d+),优先级恢复 (\d+),网络错误 (\d+)$/, ([, healthy, credentialErrors, restored, degraded, priorityRestored, networkErrors]) => `Account refresh complete: ${healthy} healthy, ${credentialErrors} credential errors, ${restored} restored, ${degraded} priorities lowered, ${priorityRestored} priorities restored, ${networkErrors} network errors`], diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 06133f68..277acf05 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -254,6 +254,23 @@ export interface CodexKeeperAccount { reset_credit_count: number | null reset_credits: CodexKeeperResetCredit[] | null subscription_active_until: string | null + provider: string | null + antigravity_quota: AntigravityQuotaGroup[] | null +} + +export interface AntigravityQuotaBucket { + bucket_id: string + display_name: string + window: string + remaining_fraction: number + reset_at: string | null + description?: string +} + +export interface AntigravityQuotaGroup { + display_name: string + description?: string + buckets: AntigravityQuotaBucket[] } export interface CodexKeeperResetCredit { From d50047412fb5a91c510cfa799af46b3b0c4a7383 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Thu, 17 Sep 2026 15:44:35 +0800 Subject: [PATCH 20/25] feat(settings): configurable prefix for generated API keys (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API keys minted by CPA-Helper were hardcoded to `sk-<random>`. Add an admin setting `api_key_prefix` so a deployment can brand its keys (ours will use `sk-cortex`, yielding `sk-cortex-...`). A generated key is `<prefix>-<52 random alphanumerics>`. - Default stays `sk`, so untouched deployments keep generating `sk-...` keys byte-for-byte as before; blank input resets to the default. - Only NEW keys use the prefix. Existing keys are never rewritten and keep working (tested: the legacy key stays listed with its original value). - Validation (422): letters, digits, `-` and `_`; must start with a letter or digit and must not end with `-` (the generator adds the joining dash); max 32 chars; whitespace trimmed; rejected values do not persist. - Migration 202609160001 adds `app_settings.api_key_prefix` (Down drops it; a rollback only loses the configured prefix). LatestVersion, the `migrate down-to` CLI test head, the Up/Down/replay schema test and the rollback runbook are updated. - The CLIProxyAPI key-sync path passes keys as opaque strings and makes no prefix assumption (verified). - Frontend: new "API KEY 设置 / API Key Settings" section with a live preview and rules; key masking is now prefix-aware — the random secret never contains `-`, so the last `-` separates any multi-segment prefix from the secret (`sk-cortex-` stays readable, >= 8 secret chars always masked). Extracted to a pure maskApiKey helper with a smoke test; i18n for the two validation messages plus smoke assertions. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> Co-authored-by: feiniu (Raft agent) <a-9b3ff9ce@mail.build> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> --- backend/cmd/cpa-helper/main_test.go | 6 +- backend/internal/app/api_key_prefix.go | 57 ++++++ backend/internal/app/api_key_prefix_test.go | 177 ++++++++++++++++++ backend/internal/app/app.go | 15 +- backend/internal/app/auth_settings.go | 8 + backend/internal/app/migrations_test.go | 7 + backend/internal/app/users.go | 11 +- ...2609160001_app_settings_api_key_prefix.sql | 9 + backend/migrations/migrations.go | 2 +- docs/migrations-rollback.md | 7 +- frontend/package.json | 3 +- frontend/scripts/i18n-smoke.mjs | 8 + frontend/scripts/mask-api-key-smoke.mjs | 46 +++++ .../features/api-keys/views/ApiKeysView.vue | 9 +- .../features/settings/views/SettingsView.vue | 28 +++ frontend/src/shared/i18n/messages.ts | 2 + frontend/src/shared/types/api.ts | 2 + frontend/src/shared/utils/maskApiKey.ts | 21 +++ 18 files changed, 395 insertions(+), 23 deletions(-) create mode 100644 backend/internal/app/api_key_prefix.go create mode 100644 backend/internal/app/api_key_prefix_test.go create mode 100644 backend/migrations/202609160001_app_settings_api_key_prefix.sql create mode 100644 frontend/scripts/mask-api-key-smoke.mjs create mode 100644 frontend/src/shared/utils/maskApiKey.ts diff --git a/backend/cmd/cpa-helper/main_test.go b/backend/cmd/cpa-helper/main_test.go index 3723f258..7f0259b9 100644 --- a/backend/cmd/cpa-helper/main_test.go +++ b/backend/cmd/cpa-helper/main_test.go @@ -49,8 +49,8 @@ func TestMigrateDownToRollsBackToTarget(t *testing.T) { if !strings.Contains(out, "current_version=202609040002") { t.Fatalf("rollback did not reach 202609040002: %s", out) } - if !strings.Contains(out, "previous_version=202609060005") { - t.Fatalf("rollback did not start from head 202609060005: %s", out) + if !strings.Contains(out, "previous_version=202609160001") { + t.Fatalf("rollback did not start from head 202609160001: %s", out) } // A non-allowlisted target is refused. @@ -101,7 +101,7 @@ func TestMigrateDownToRefusesPendingRedeems(t *testing.T) { if err := run(ctx, []string{"migrate", "down-to", "202609040002"}, &bytes.Buffer{}); err == nil { t.Fatal("rollback should be refused while a pending redeem exists") } - if v := currentVersionForTest(t, dbPath); v != 202609060005 { + if v := currentVersionForTest(t, dbPath); v != 202609160001 { t.Fatalf("refused rollback still changed version to %d", v) } diff --git a/backend/internal/app/api_key_prefix.go b/backend/internal/app/api_key_prefix.go new file mode 100644 index 00000000..efeb31a3 --- /dev/null +++ b/backend/internal/app/api_key_prefix.go @@ -0,0 +1,57 @@ +package app + +import ( + "fmt" + "regexp" + "strings" +) + +// defaultAPIKeyPrefix is used for generated API keys when no prefix is configured. A generated +// key is `<prefix>-<random>`, so the default yields `sk-...` — byte-for-byte the shape produced +// before the prefix became configurable. +const defaultAPIKeyPrefix = "sk" + +// maxAPIKeyPrefixLength bounds the configurable prefix so keys stay a sane length. +const maxAPIKeyPrefixLength = 32 + +// apiKeyPrefixPattern allows letters, digits, `-` and `_`; it must start with a letter or digit +// and must not END with `-` (the generator appends the joining dash itself, so `sk-cortex` is the +// canonical form and `sk-cortex-` would double the dash). +var apiKeyPrefixPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?$`) + +const ( + apiKeyPrefixTooLongMessage = "api_key_prefix 超出最大长度 32" + apiKeyPrefixInvalidMessage = "api_key_prefix 只能包含字母、数字、- 和 _,且不能以 - 开头或结尾" +) + +// normalizeAPIKeyPrefix trims the configured prefix and substitutes the default for an empty +// value. It does NOT validate — callers that accept user input must call validateAPIKeyPrefix +// first; stored values are already validated. +func normalizeAPIKeyPrefix(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return defaultAPIKeyPrefix + } + return trimmed +} + +// validateAPIKeyPrefix rejects a user-supplied prefix that is too long or malformed. An empty +// (or blank) value is accepted and means "use the default". +func validateAPIKeyPrefix(value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + if len(trimmed) > maxAPIKeyPrefixLength { + return validationError(apiKeyPrefixTooLongMessage) + } + if !apiKeyPrefixPattern.MatchString(trimmed) { + return validationError(apiKeyPrefixInvalidMessage) + } + return nil +} + +// buildAPIKey joins a (normalized) prefix and the random secret with a single dash. +func buildAPIKey(prefix, secret string) string { + return fmt.Sprintf("%s-%s", prefix, secret) +} diff --git a/backend/internal/app/api_key_prefix_test.go b/backend/internal/app/api_key_prefix_test.go new file mode 100644 index 00000000..91641f57 --- /dev/null +++ b/backend/internal/app/api_key_prefix_test.go @@ -0,0 +1,177 @@ +package app_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + backendApp "cpa-helper/backend/internal/app" +) + +// generatedAPIKeySecretLength mirrors the backend's random-secret length; a generated key is +// `<prefix>-<secret>`, so its total length is len(prefix)+1+52. +const generatedAPIKeySecretLength = 52 + +// newAPIKeyPrefixTestApp boots the app with an admin session and a permissive fake CPA that +// accepts key sync, so API keys can actually be created. +func newAPIKeyPrefixTestApp(t *testing.T) (http.Handler, []*http.Cookie, func()) { + t.Helper() + t.Setenv("CPA_HELPER_DATA_DIR", t.TempDir()) + var mu sync.Mutex + remoteKeys := []string{} + cpa := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v0/management/api-keys" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + mu.Lock() + defer mu.Unlock() + switch r.Method { + case http.MethodPatch: + var payload struct { + New string `json:"new"` + } + _ = json.NewDecoder(r.Body).Decode(&payload) + remoteKeys = append(remoteKeys, payload.New) + _ = json.NewEncoder(w).Encode(map[string]any{"api-keys": remoteKeys}) + case http.MethodGet: + _ = json.NewEncoder(w).Encode(map[string]any{"api-keys": remoteKeys}) + case http.MethodPut: + var keys []string + _ = json.NewDecoder(r.Body).Decode(&keys) + remoteKeys = keys + _ = json.NewEncoder(w).Encode(map[string]any{"api-keys": remoteKeys}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + })) + app, err := backendApp.New() + if err != nil { + cpa.Close() + t.Fatalf("New() failed: %v", err) + } + handler := app.Routes() + cookies := requestJSON(t, handler, http.MethodPost, "/api/auth/setup", map[string]any{ + "username": "admin", "password": "test-password", "nickname": "Admin", + }, nil, nil) + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{ + "cliaproxy_url": cpa.URL, "management_key": "test-management-key", "collector_enabled": false, + }, cookies, nil) + return handler, cookies, func() { app.Close(); cpa.Close() } +} + +func createAPIKeyForTest(t *testing.T, handler http.Handler, cookies []*http.Cookie) apiKeyCreateResponse { + t.Helper() + created := apiKeyCreateResponse{} + requestJSON(t, handler, http.MethodPost, "/api/api-keys", map[string]any{"description": "prefix test"}, cookies, &created) + if created.APIKey == "" || created.APIKeyHash == "" { + t.Fatalf("create returned empty key/hash: %+v", created) + } + return created +} + +func settingsPrefixForTest(t *testing.T, handler http.Handler, cookies []*http.Cookie) string { + t.Helper() + var settings struct { + APIKeyPrefix string `json:"api_key_prefix"` + } + requestJSON(t, handler, http.MethodGet, "/api/settings", nil, cookies, &settings) + return settings.APIKeyPrefix +} + +func TestAPIKeyPrefixDefaultsToSkAndKeepsLegacyShape(t *testing.T) { + handler, cookies, cleanup := newAPIKeyPrefixTestApp(t) + defer cleanup() + if got := settingsPrefixForTest(t, handler, cookies); got != "sk" { + t.Fatalf("default api_key_prefix = %q, want sk", got) + } + created := createAPIKeyForTest(t, handler, cookies) + if !strings.HasPrefix(created.APIKey, "sk-") { + t.Fatalf("default key %q must start with sk-", created.APIKey) + } + if len(created.APIKey) != len("sk-")+generatedAPIKeySecretLength { + t.Fatalf("default key length = %d, want %d (legacy shape preserved)", len(created.APIKey), len("sk-")+generatedAPIKeySecretLength) + } +} + +func TestAPIKeyPrefixConfiguredIsUsedForNewKeysOnly(t *testing.T) { + handler, cookies, cleanup := newAPIKeyPrefixTestApp(t) + defer cleanup() + legacy := createAPIKeyForTest(t, handler, cookies) + + var saved struct { + APIKeyPrefix string `json:"api_key_prefix"` + } + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": " sk-cortex "}, cookies, &saved) + if saved.APIKeyPrefix != "sk-cortex" { + t.Fatalf("saved api_key_prefix = %q, want sk-cortex (trimmed)", saved.APIKeyPrefix) + } + if got := settingsPrefixForTest(t, handler, cookies); got != "sk-cortex" { + t.Fatalf("api_key_prefix did not persist: %q", got) + } + + created := createAPIKeyForTest(t, handler, cookies) + if !strings.HasPrefix(created.APIKey, "sk-cortex-") || strings.HasPrefix(created.APIKey, "sk-cortex--") { + t.Fatalf("new key %q must be sk-cortex-<secret> with exactly one joining dash", created.APIKey) + } + if len(created.APIKey) != len("sk-cortex-")+generatedAPIKeySecretLength { + t.Fatalf("new key length = %d, want %d", len(created.APIKey), len("sk-cortex-")+generatedAPIKeySecretLength) + } + + // Existing keys are never rewritten: the legacy key is still listed with its original value. + var keys []struct { + APIKey string `json:"api_key"` + APIKeyHash string `json:"api_key_hash"` + } + requestJSON(t, handler, http.MethodGet, "/api/api-keys", nil, cookies, &keys) + foundLegacy, foundNew := false, false + for _, key := range keys { + if key.APIKeyHash == legacy.APIKeyHash && strings.HasPrefix(key.APIKey, "sk-") && !strings.HasPrefix(key.APIKey, "sk-cortex-") { + foundLegacy = true + } + if key.APIKeyHash == created.APIKeyHash && strings.HasPrefix(key.APIKey, "sk-cortex-") { + foundNew = true + } + } + if !foundLegacy || !foundNew { + t.Fatalf("expected both the untouched legacy sk- key and the new sk-cortex- key; got %+v", keys) + } + + // Blank resets to the default. + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": ""}, cookies, &saved) + if saved.APIKeyPrefix != "sk" { + t.Fatalf("blank api_key_prefix should reset to sk, got %q", saved.APIKeyPrefix) + } +} + +func TestAPIKeyPrefixRejectsMalformedValues(t *testing.T) { + handler, cookies, cleanup := newAPIKeyPrefixTestApp(t) + defer cleanup() + for _, bad := range []string{ + "-sk", // leading dash + "sk-", // trailing dash (the generator adds the joining dash) + "sk cortex", // whitespace + "sk/cortex", // slash + "sk.cortex", // dot + "sk:cortex", // non-ASCII + strings.Repeat("a", 33), // too long + "sk-" + strings.Repeat("b", 30), // too long (33) + } { + requestJSONExpectStatus(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": bad}, cookies, http.StatusUnprocessableEntity) + } + // The rejections must not have changed the stored value. + if got := settingsPrefixForTest(t, handler, cookies); got != "sk" { + t.Fatalf("rejected values must not persist; api_key_prefix = %q", got) + } + // Valid edge cases: single char, underscore, digits, 32 chars. + for _, ok := range []string{"a", "team_42", "SK-Cortex_2", strings.Repeat("z", 32)} { + requestJSON(t, handler, http.MethodPut, "/api/settings", map[string]any{"api_key_prefix": ok}, cookies, nil) + if got := settingsPrefixForTest(t, handler, cookies); got != ok { + t.Fatalf("valid prefix %q not stored, got %q", ok, got) + } + } +} diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index effb2d57..86af5a03 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -587,6 +587,9 @@ type AppConfig struct { SessionSecret string `json:"session_secret"` ProductName string `json:"product_name"` ProductLogo string `json:"product_logo"` + // APIKeyPrefix is the prefix for NEWLY generated API keys (`<prefix>-<random>`), without the + // joining dash. Empty means the default (`sk`). Existing keys are never rewritten. + APIKeyPrefix string `json:"api_key_prefix"` } func defaultConfig() (AppConfig, error) { @@ -624,6 +627,7 @@ func defaultConfig() (AppConfig, error) { }, ModelRequestURL: defaultCPAURL, SessionSecret: secret, + APIKeyPrefix: defaultAPIKeyPrefix, }, nil } @@ -642,15 +646,15 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) { SELECT collector_enabled, cliaproxy_url, management_key, queue_name, batch_size, poll_interval_seconds, retry_interval_seconds, codex_keeper_settings, codex_keeper_priority_rules, litellm_proxy_enabled, litellm_proxy_url, - model_request_url, session_secret, product_name, product_logo + model_request_url, session_secret, product_name, product_logo, api_key_prefix FROM app_settings WHERE id = 1 `) var collectorEnabled, litellmProxyEnabled bool var cliaproxyURL, managementKey, queueName, keeperJSON, rulesJSON, litellmProxyURL, modelRequestURL, sessionSecret string - var productName, productLogo string + var productName, productLogo, apiKeyPrefix string var batchSize int var pollInterval, retryInterval float64 - if err := row.Scan(&collectorEnabled, &cliaproxyURL, &managementKey, &queueName, &batchSize, &pollInterval, &retryInterval, &keeperJSON, &rulesJSON, &litellmProxyEnabled, &litellmProxyURL, &modelRequestURL, &sessionSecret, &productName, &productLogo); err != nil { + if err := row.Scan(&collectorEnabled, &cliaproxyURL, &managementKey, &queueName, &batchSize, &pollInterval, &retryInterval, &keeperJSON, &rulesJSON, &litellmProxyEnabled, &litellmProxyURL, &modelRequestURL, &sessionSecret, &productName, &productLogo, &apiKeyPrefix); err != nil { if errors.Is(err, sql.ErrNoRows) { return AppConfig{}, fmt.Errorf("%w: app_settings id=1 is missing; run `cpa-helper migrate`", ErrAppSettingsMissing) } @@ -689,6 +693,7 @@ func (a *App) loadConfig(ctx context.Context) (AppConfig, error) { cfg.ModelRequestURL = nonBlank(strings.TrimRight(strings.TrimSpace(modelRequestURL), "/"), cfg.Collector.CLIProxyURL) cfg.ProductName = strings.TrimSpace(productName) cfg.ProductLogo = strings.TrimSpace(productLogo) + cfg.APIKeyPrefix = normalizeAPIKeyPrefix(apiKeyPrefix) return cfg, nil } @@ -744,9 +749,9 @@ func (a *App) saveConfig(ctx context.Context, cfg AppConfig) error { codex_keeper_settings = ?, codex_keeper_priority_rules = ?, litellm_proxy_enabled = ?, litellm_proxy_url = ?, model_request_url = ?, session_secret = ?, - product_name = ?, product_logo = ?, updated_at = ? + product_name = ?, product_logo = ?, api_key_prefix = ?, updated_at = ? WHERE id = 1 - `, cfg.Collector.Enabled, strings.TrimRight(strings.TrimSpace(cfg.Collector.CLIProxyURL), "/"), strings.TrimSpace(cfg.Collector.ManagementKey), strings.TrimSpace(cfg.Collector.QueueName), cfg.Collector.BatchSize, cfg.Collector.PollIntervalSeconds, cfg.Collector.RetryIntervalSeconds, string(keeperBytes), string(rulesBytes), cfg.LiteLLMProxy.Enabled, strings.TrimSpace(cfg.LiteLLMProxy.ProxyURL), strings.TrimRight(strings.TrimSpace(cfg.ModelRequestURL), "/"), cfg.SessionSecret, cfg.ProductName, cfg.ProductLogo, dbTime(time.Now())) + `, cfg.Collector.Enabled, strings.TrimRight(strings.TrimSpace(cfg.Collector.CLIProxyURL), "/"), strings.TrimSpace(cfg.Collector.ManagementKey), strings.TrimSpace(cfg.Collector.QueueName), cfg.Collector.BatchSize, cfg.Collector.PollIntervalSeconds, cfg.Collector.RetryIntervalSeconds, string(keeperBytes), string(rulesBytes), cfg.LiteLLMProxy.Enabled, strings.TrimSpace(cfg.LiteLLMProxy.ProxyURL), strings.TrimRight(strings.TrimSpace(cfg.ModelRequestURL), "/"), cfg.SessionSecret, cfg.ProductName, cfg.ProductLogo, normalizeAPIKeyPrefix(cfg.APIKeyPrefix), dbTime(time.Now())) return err } diff --git a/backend/internal/app/auth_settings.go b/backend/internal/app/auth_settings.go index c8048391..e25ac1cd 100644 --- a/backend/internal/app/auth_settings.go +++ b/backend/internal/app/auth_settings.go @@ -267,6 +267,7 @@ type settingsUpdateRequest struct { RetryIntervalSeconds *float64 `json:"retry_interval_seconds"` ProductName *string `json:"product_name"` ProductLogo *string `json:"product_logo"` + APIKeyPrefix *string `json:"api_key_prefix"` } type modelRequestTestPayload struct { @@ -365,6 +366,12 @@ func (a *App) handleSettings(w http.ResponseWriter, r *http.Request) error { } cfg.ProductLogo = logo } + if payload.APIKeyPrefix != nil { + if err := validateAPIKeyPrefix(*payload.APIKeyPrefix); err != nil { + return err + } + cfg.APIKeyPrefix = normalizeAPIKeyPrefix(*payload.APIKeyPrefix) + } if err := a.saveConfig(r.Context(), cfg); err != nil { return err } @@ -389,6 +396,7 @@ func settingsResponse(cfg AppConfig) map[string]any { "retry_interval_seconds": collector.RetryIntervalSeconds, "product_name": cfg.ProductName, "product_logo": cfg.ProductLogo, + "api_key_prefix": normalizeAPIKeyPrefix(cfg.APIKeyPrefix), } } diff --git a/backend/internal/app/migrations_test.go b/backend/internal/app/migrations_test.go index 9f6f487e..bc88d18e 100644 --- a/backend/internal/app/migrations_test.go +++ b/backend/internal/app/migrations_test.go @@ -460,6 +460,9 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { if testTableExists(t, db, "codex_keeper_quota_resets") { t.Fatal("head should have dropped codex_keeper_quota_resets") } + if !testColumnExists(t, db, "app_settings", "api_key_prefix") { + t.Fatal("head is missing app_settings.api_key_prefix (migration 202609160001)") + } // Rollback: Down to the version the previous binary targets. const preConsumeVersion int64 = 202609040002 @@ -491,6 +494,9 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { t.Fatalf("rollback left codex_keeper_auth_states.%s behind", col) } } + if testColumnExists(t, db, "app_settings", "api_key_prefix") { + t.Fatal("rollback left app_settings.api_key_prefix behind") + } // Replay: from the prod baseline (202609040002) migrate Up to head again — the whole // release must be re-runnable after a rollback (040002 → head → 040002 → head). @@ -508,6 +514,7 @@ func TestRollbackToPreConsumeRestoresCompatSchema(t *testing.T) { !testColumnExists(t, db, "codex_keeper_auth_states", "provider") || !testColumnExists(t, db, "codex_keeper_auth_states", "antigravity_quota") || !testColumnExists(t, db, "codex_keeper_auth_states", "antigravity_identity_digest") || + !testColumnExists(t, db, "app_settings", "api_key_prefix") || !testTableExists(t, db, "codex_keeper_reset_redeems") || testTableExists(t, db, "codex_keeper_quota_resets") { t.Fatal("replay to head did not restore the full head schema") diff --git a/backend/internal/app/users.go b/backend/internal/app/users.go index 6291654b..b2a7793c 100644 --- a/backend/internal/app/users.go +++ b/backend/internal/app/users.go @@ -12,7 +12,6 @@ import ( "time" ) -const generatedAPIKeyPrefix = "sk-" const generatedAPIKeyLength = 52 const generatedAPIKeyAlphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -665,9 +664,15 @@ func (a *App) upsertUserAPIKey(ctx context.Context, userID int, apiKeyHash, apiK } func (a *App) generateUniqueAPIKey(ctx context.Context) (string, error) { + // The prefix is a per-deployment setting (e.g. `sk-cortex`); the default keeps the historical + // `sk-...` shape. Only keys minted from now on use it — existing keys are never rewritten. + cfg, err := a.loadConfig(ctx) + if err != nil { + return "", err + } + prefix := normalizeAPIKeyPrefix(cfg.APIKeyPrefix) for i := 0; i < 10; i++ { var builder strings.Builder - builder.WriteString(generatedAPIKeyPrefix) for j := 0; j < generatedAPIKeyLength; j++ { index, err := rand.Int(rand.Reader, big.NewInt(int64(len(generatedAPIKeyAlphabet)))) if err != nil { @@ -675,7 +680,7 @@ func (a *App) generateUniqueAPIKey(ctx context.Context) (string, error) { } builder.WriteByte(generatedAPIKeyAlphabet[index.Int64()]) } - apiKey := builder.String() + apiKey := buildAPIKey(prefix, builder.String()) var count int if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_api_keys WHERE api_key_hash = ?`, hashAPIKey(apiKey)).Scan(&count); err != nil { return "", err diff --git a/backend/migrations/202609160001_app_settings_api_key_prefix.sql b/backend/migrations/202609160001_app_settings_api_key_prefix.sql new file mode 100644 index 00000000..4e118cc1 --- /dev/null +++ b/backend/migrations/202609160001_app_settings_api_key_prefix.sql @@ -0,0 +1,9 @@ +-- +goose Up +-- API keys minted by CPA-Helper carry a configurable prefix (e.g. `sk-cortex-...`). The value +-- stored here is the prefix WITHOUT the joining dash; an empty value means the default `sk`, +-- so every existing deployment keeps generating `sk-...` keys exactly as before. Only NEW keys +-- are affected — existing keys are never rewritten. +ALTER TABLE app_settings ADD COLUMN api_key_prefix TEXT NOT NULL DEFAULT ''; + +-- +goose Down +ALTER TABLE app_settings DROP COLUMN api_key_prefix; diff --git a/backend/migrations/migrations.go b/backend/migrations/migrations.go index 4623888a..3a1cbf50 100644 --- a/backend/migrations/migrations.go +++ b/backend/migrations/migrations.go @@ -3,7 +3,7 @@ package migrations import "embed" // LatestVersion is the newest embedded migration version this binary expects. -const LatestVersion int64 = 202609060005 +const LatestVersion int64 = 202609160001 // FS contains SQL migrations embedded into the application binary. // diff --git a/docs/migrations-rollback.md b/docs/migrations-rollback.md index 9182804c..f54ccc55 100644 --- a/docs/migrations-rollback.md +++ b/docs/migrations-rollback.md @@ -6,7 +6,7 @@ version is newer than the binary** (goose reports `database migration version is newer than this application`). A binary rollback therefore always requires migrating the schema **down first**. -## Rolling back the keeper releases (migrations 202609060001–202609060005) +## Rolling back the keeper releases (migrations 202609060001–202609160001) This release added, on top of `202609040002`: @@ -15,6 +15,7 @@ This release added, on top of `202609040002`: - `202609060003` — `codex_keeper_reset_redeems` (redeem ledger) table. - `202609060004` — `codex_keeper_auth_states.account_id` column (subscription identity scope). - `202609060005` — `codex_keeper_auth_states.provider` + `antigravity_quota` + `antigravity_identity_digest` columns (multi-provider inspection: Antigravity accounts; the identity column stores a one-way digest, never the raw project/email). Its Down drops all three columns; no data beyond the Antigravity quota snapshot / provider tag / identity digest is lost. +- `202609160001` — `app_settings.api_key_prefix` column (configurable prefix for NEWLY generated API keys, e.g. `sk-cortex`; empty = default `sk`). Its Down drops the column; the only thing lost is the configured prefix — existing API keys are never rewritten and keep working. The previous binary (`a996697`, target version `202609040002`) both refuses to start against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in @@ -44,8 +45,8 @@ against a newer version **and** still `SELECT`s `codex_keeper_quota_resets` in cpa-helper migrate down-to 202609040002 --allow-pending ``` - This runs the Down migrations for `202609060005`, `202609060004`, `202609060003`, - `202609060002`, and `202609060001`: it drops the `provider` + `antigravity_quota` + `antigravity_identity_digest` columns, + This runs the Down migrations for `202609160001`, `202609060005`, `202609060004`, `202609060003`, + `202609060002`, and `202609060001`: it drops the `api_key_prefix` column, drops the `provider` + `antigravity_quota` + `antigravity_identity_digest` columns, drops the `account_id` column, drops `codex_keeper_reset_redeems`, drops the `subscription_active_until` column, and **recreates an empty `codex_keeper_quota_resets`** so the old binary's `/accounts` query works. diff --git a/frontend/package.json b/frontend/package.json index 811ae73f..f5931840 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,8 @@ "test:antigravity-countdown": "node scripts/antigravity-countdown-smoke.mjs", "test:antigravity-window": "node scripts/antigravity-window-smoke.mjs", "test:keeper-quota-exhaustion": "node scripts/keeper-quota-exhaustion-smoke.mjs", - "test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs" + "test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs", + "test:mask-api-key": "node scripts/mask-api-key-smoke.mjs" }, "dependencies": { "echarts": "^5.5.1", diff --git a/frontend/scripts/i18n-smoke.mjs b/frontend/scripts/i18n-smoke.mjs index 4de958f6..bd8c20c8 100644 --- a/frontend/scripts/i18n-smoke.mjs +++ b/frontend/scripts/i18n-smoke.mjs @@ -171,6 +171,14 @@ try { localizedServerMessage('Antigravity 配额读取失败'), 'Failed to read Antigravity quota', ) + assert.equal( + localizedServerMessage('api_key_prefix 超出最大长度 32'), + 'api_key_prefix exceeds the maximum length of 32', + ) + assert.equal( + localizedServerMessage('api_key_prefix 只能包含字母、数字、- 和 _,且不能以 - 开头或结尾'), + 'api_key_prefix may only contain letters, digits, - and _, and must not start or end with -', + ) assert.equal( localizedServerMessage('antigravity@example.com.json:Antigravity 配额刷新成功(2 组)'), 'antigravity@example.com.json: Antigravity quota refreshed (2 groups)', diff --git a/frontend/scripts/mask-api-key-smoke.mjs b/frontend/scripts/mask-api-key-smoke.mjs new file mode 100644 index 00000000..c4532566 --- /dev/null +++ b/frontend/scripts/mask-api-key-smoke.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import { fileURLToPath } from 'node:url' + +import { createServer } from 'vite' + +const root = fileURLToPath(new URL('..', import.meta.url)) +const server = await createServer({ root, logLevel: 'error', server: { middlewareMode: true } }) + +try { + const { maskApiKey } = await server.ssrLoadModule('/src/shared/utils/maskApiKey.ts') + const secret = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' // 52 chars, like generated keys + + // Default prefix: the whole prefix + dash stays visible, secret masked, last 4 kept. + const sk = maskApiKey(`sk-${secret}`) + assert.equal(sk.length, `sk-${secret}`.length) + assert.ok(sk.startsWith('sk-*'), sk) + assert.ok(sk.endsWith('WXYZ'), sk) + assert.ok(!sk.includes('abcd'), 'secret head must be masked') + + // Custom multi-segment prefix (the reason this helper exists): `sk-cortex-` stays readable. + const cortex = maskApiKey(`sk-cortex-${secret}`) + assert.equal(cortex.length, `sk-cortex-${secret}`.length) + assert.ok(cortex.startsWith('sk-cortex-*'), cortex) + assert.ok(cortex.endsWith('WXYZ'), cortex) + + // A prefix containing '_' and digits. + assert.ok(maskApiKey(`team_42-${secret}`).startsWith('team_42-*')) + + // Foreign key with no dash: short fixed head, still at least 8 masked, same length. + const foreign = maskApiKey('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123') + assert.equal(foreign.length, 30) + assert.equal(foreign.slice(0, 6), 'ABCDEF') + assert.ok(/^\*{8,}/.test(foreign.slice(6, -4))) + + // A dash placed too late must not expose the secret: prefix is capped so ≥ 8 chars are masked. + const lateDash = maskApiKey('abcdefghijklmnop-xyz1') + assert.equal(lateDash.length, 21) + assert.ok(lateDash.slice(-4) === 'xyz1' && (lateDash.match(/\*/g) || []).length >= 8, lateDash) + + // Short keys keep the legacy 3-visible behaviour. + assert.equal(maskApiKey('sk-short1234'), 'sk-*********') + + console.log('mask-api-key-smoke: OK') +} finally { + await server.close() +} diff --git a/frontend/src/features/api-keys/views/ApiKeysView.vue b/frontend/src/features/api-keys/views/ApiKeysView.vue index f579414f..192bb878 100644 --- a/frontend/src/features/api-keys/views/ApiKeysView.vue +++ b/frontend/src/features/api-keys/views/ApiKeysView.vue @@ -53,6 +53,7 @@ import type { import { useI18n } from '@/shared/i18n' import { copyToClipboard } from '@/shared/utils/clipboard' import { formatCompact, formatDateTime, formatInteger, formatUsd } from '@/shared/utils/format' +import { maskApiKey } from '@/shared/utils/maskApiKey' const message = useMessage() const dialog = useDialog() @@ -364,13 +365,7 @@ function maskDisplayedApiKey(apiKey: string | null | undefined): string { if (!apiKey) { return t('未知', 'Unknown') } - if (apiKey.length <= 12) { - return `${apiKey.slice(0, 3)}${'*'.repeat(Math.max(apiKey.length - 3, 0))}` - } - const visiblePrefix = apiKey.startsWith('sk-') ? 4 : 6 - const visibleSuffix = 4 - const maskedLength = Math.max(apiKey.length - visiblePrefix - visibleSuffix, 8) - return `${apiKey.slice(0, visiblePrefix)}${'*'.repeat(maskedLength)}${apiKey.slice(-visibleSuffix)}` + return maskApiKey(apiKey) } function renderMaskedKeyTitle() { diff --git a/frontend/src/features/settings/views/SettingsView.vue b/frontend/src/features/settings/views/SettingsView.vue index e73a3af3..7d994b9c 100644 --- a/frontend/src/features/settings/views/SettingsView.vue +++ b/frontend/src/features/settings/views/SettingsView.vue @@ -42,8 +42,11 @@ const settingsForm = reactive({ retry_interval_seconds: 10, product_name: '', product_logo: '', + api_key_prefix: 'sk', }) +const apiKeyPrefixPreview = computed(() => `${(settingsForm.api_key_prefix || 'sk').trim() || 'sk'}-xxxxxxxx…`) + const logoPreview = computed(() => settingsForm.product_logo || null) function handleLogoFile(event: Event) { @@ -108,6 +111,7 @@ async function refresh() { settingsForm.retry_interval_seconds = settings.retry_interval_seconds settingsForm.product_name = settings.product_name ?? '' settingsForm.product_logo = settings.product_logo ?? '' + settingsForm.api_key_prefix = settings.api_key_prefix || 'sk' collectorStatus.value = status } catch (error) { message.error(errorText(error, '加载设置失败', 'Failed to load settings')) @@ -129,9 +133,11 @@ async function saveSettings() { retry_interval_seconds: settingsForm.retry_interval_seconds, product_name: settingsForm.product_name, product_logo: settingsForm.product_logo, + api_key_prefix: settingsForm.api_key_prefix, } const saved = await updateSettings(payload) settingsForm.management_key = saved.management_key + settingsForm.api_key_prefix = saved.api_key_prefix || 'sk' setProductInfo(saved.product_name ?? '', saved.product_logo ?? '') message.success(t('设置已保存', 'Settings saved')) await refresh() @@ -278,6 +284,28 @@ onMounted(refresh) </section> </div> + <section class="panel"> + <div class="panel-inner"> + <h2 class="section-title">{{ t('API KEY 设置', 'API Key Settings') }}</h2> + <NForm :model="settingsForm" label-placement="top"> + <div class="form-grid"> + <div class="field-stack"> + <div class="field-label">{{ t('API KEY 前缀', 'API key prefix') }}</div> + <NInput + v-model:value="settingsForm.api_key_prefix" + :placeholder="t('留空使用默认前缀 sk', 'Leave blank to use the default prefix sk')" + :maxlength="32" + show-count + /> + <div class="form-help"> + {{ t(`新生成的 API KEY 形如 ${apiKeyPrefixPreview}。只允许字母、数字、- 和 _,不能以 - 开头或结尾;只影响之后新建的 KEY,已有 KEY 不变。`, `New API keys look like ${apiKeyPrefixPreview}. Letters, digits, - and _ only; must not start or end with -. Only affects keys created from now on; existing keys are unchanged.`) }} + </div> + </div> + </div> + </NForm> + </div> + </section> + <section class="panel"> <div class="panel-inner"> <h2 class="section-title">{{ t('产品信息', 'Product Branding') }}</h2> diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts index 8e444344..242d1f46 100644 --- a/frontend/src/shared/i18n/messages.ts +++ b/frontend/src/shared/i18n/messages.ts @@ -44,6 +44,8 @@ const exactServerMessages: MessagePair[] = [ ['账号身份冲突:列表与详情的 account_id/auth_index 不一致,已保留原快照', 'Account identity conflict: the list and detail disagree on account_id/auth_index; the previous snapshot was preserved.'], ['账号身份冲突:Antigravity 列表与详情的 name/type/auth_index/project_id/email 不一致,已保留原快照', 'Account identity conflict: the Antigravity list and detail disagree on name/type/auth_index/project_id/email; the previous snapshot was preserved.'], ['Antigravity 配额读取失败', 'Failed to read Antigravity quota'], + ['api_key_prefix 超出最大长度 32', 'api_key_prefix exceeds the maximum length of 32'], + ['api_key_prefix 只能包含字母、数字、- 和 _,且不能以 - 开头或结尾', 'api_key_prefix may only contain letters, digits, - and _, and must not start or end with -'], ['无法确认可用重置额度(快照未知),请刷新后重试', 'Cannot confirm available reset credits (snapshot unknown). Refresh and try again.'], ['核销主动重置额度失败:网络异常,未确认是否已核销', 'Failed to redeem the reset credit: network error; redemption is unconfirmed.'], ['核销主动重置额度失败:管理接口异常', 'Failed to redeem the reset credit: management API error.'], diff --git a/frontend/src/shared/types/api.ts b/frontend/src/shared/types/api.ts index 277acf05..5d0b3c3d 100644 --- a/frontend/src/shared/types/api.ts +++ b/frontend/src/shared/types/api.ts @@ -39,6 +39,7 @@ export interface SettingsResponse { retry_interval_seconds: number product_name: string product_logo: string + api_key_prefix: string } export interface SettingsUpdatePayload { @@ -52,6 +53,7 @@ export interface SettingsUpdatePayload { retry_interval_seconds?: number product_name?: string product_logo?: string + api_key_prefix?: string } export interface ModelRequestGuide { diff --git a/frontend/src/shared/utils/maskApiKey.ts b/frontend/src/shared/utils/maskApiKey.ts new file mode 100644 index 00000000..86d674a2 --- /dev/null +++ b/frontend/src/shared/utils/maskApiKey.ts @@ -0,0 +1,21 @@ +// maskApiKey hides the secret part of an API key while keeping its prefix recognisable. +// +// Keys minted by CPA-Helper are `<prefix>-<random>` where the random alphabet never contains +// `-`, so the LAST `-` reliably separates the (possibly multi-segment, configurable) prefix +// from the secret — `sk-…`, `sk-cortex-…`, or any custom prefix all mask correctly without the +// UI having to know the configured prefix. Keys with no `-` (foreign / observed keys) fall back +// to a short fixed head. The output always has the same length as the input, and at least 8 +// characters are masked for any key longer than 12. +export function maskApiKey(apiKey: string): string { + if (apiKey.length <= 12) { + return `${apiKey.slice(0, 3)}${'*'.repeat(Math.max(apiKey.length - 3, 0))}` + } + const visibleSuffix = 4 + const minMasked = 8 + const dash = apiKey.lastIndexOf('-') + const wantedPrefix = dash > 0 ? dash + 1 : 6 + const maxPrefix = apiKey.length - visibleSuffix - minMasked + const visiblePrefix = Math.max(1, Math.min(wantedPrefix, maxPrefix)) + const maskedLength = apiKey.length - visiblePrefix - visibleSuffix + return `${apiKey.slice(0, visiblePrefix)}${'*'.repeat(maskedLength)}${apiKey.slice(-visibleSuffix)}` +} From ad01478f912389f23ed92012221e4ac2220c519f Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Thu, 17 Sep 2026 15:57:09 +0800 Subject: [PATCH 21/25] ci(frontend): run lint and all smoke tests in CI and the Docker build (#15) The frontend smoke scripts (i18n, Antigravity countdown/window/quota-format, quota-exhaustion, API-key masking) were only run by hand. Add an aggregate `test:smoke` script and run `npm run lint` + `npm run test:smoke` before the frontend build in both the release workflow and the Dockerfile frontend stage, so a regression blocks the release. No runtime behaviour change. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> Co-authored-by: feiniu (Raft agent) <a-9b3ff9ce@mail.build> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> --- .github/workflows/build-and-release.yml | 9 +++++++++ Dockerfile | 2 ++ frontend/package.json | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml index cbda8345..33187f10 100644 --- a/.github/workflows/build-and-release.yml +++ b/.github/workflows/build-and-release.yml @@ -142,6 +142,15 @@ jobs: working-directory: frontend run: npm ci --prefer-offline + # Frontend gates: lint plus every smoke script (i18n, Antigravity countdown/window/ + # quota-format, quota-exhaustion, API-key masking). These used to be run by hand only; + # a failure here now blocks the release build. + - name: Frontend gates (lint + smoke tests) + working-directory: frontend + run: | + npm run lint + npm run test:smoke + - name: Build frontend working-directory: frontend run: npm run build diff --git a/Dockerfile b/Dockerfile index 67dcebe1..e4ba49cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,8 @@ RUN --mount=type=cache,id=cpa-helper-npm,target=/root/.npm,sharing=locked \ COPY VERSION ../VERSION COPY frontend/ ./ +# Frontend gates (lint + all smoke scripts) run before the build so a regression fails the image. +RUN npm run lint && npm run test:smoke RUN npm run build diff --git a/frontend/package.json b/frontend/package.json index f5931840..60a979d5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,7 +12,8 @@ "test:antigravity-window": "node scripts/antigravity-window-smoke.mjs", "test:keeper-quota-exhaustion": "node scripts/keeper-quota-exhaustion-smoke.mjs", "test:antigravity-quota-format": "node scripts/antigravity-quota-format-smoke.mjs", - "test:mask-api-key": "node scripts/mask-api-key-smoke.mjs" + "test:mask-api-key": "node scripts/mask-api-key-smoke.mjs", + "test:smoke": "npm run test:i18n && npm run test:antigravity-countdown && npm run test:antigravity-window && npm run test:keeper-quota-exhaustion && npm run test:antigravity-quota-format && npm run test:mask-api-key" }, "dependencies": { "echarts": "^5.5.1", From 994254cdd3d60ee6be03d62ecf478a0afa3bf2f4 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Thu, 17 Sep 2026 18:37:41 +0800 Subject: [PATCH 22/25] feat(pricing): associate reverse-proxied model variants with canonical prices (#16) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usage from reverse-proxied accounts is reported under the proxy's provider name and often a reasoning-tier suffix (provider `antigravity`, model `gemini-3.8-flash-high`), while the price list synced from LiteLLM only knows the canonical vendor entry (provider `gemini`, model `gemini/gemini-3.8-flash`). Price lookup was exact-match only, so these models were never priced. findMatchingPrice now falls back through ordered candidates, most specific first; an exact (provider, model) price always wins, so a manual price can override any association: - model: the name itself, then progressively stripped variant suffixes (-thinking, -minimal, -low, -medium, -high, -xhigh), which do not change the per-token price; - provider: itself, then its canonical vendor (codex→openai, claude→anthropic, gemini-cli/aistudio→gemini, antigravity→gemini/anthropic/openai by model family); - each pair is also tried with LiteLLM's `<provider>/<model>` key form. No schema change. Cost is computed at query time, so existing history is priced as soon as this ships. All callers go through findMatchingPrice (usage cost, quota charging, model catalog), so they stay consistent. Signed-off-by: feiniu <a-9b3ff9ce@mail.build> Co-authored-by: feiniu (Raft agent) <a-9b3ff9ce@mail.build> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> --- backend/internal/app/pricing.go | 21 ++-- backend/internal/app/pricing_association.go | 75 ++++++++++++ .../app/pricing_association_internal_test.go | 108 ++++++++++++++++++ 3 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 backend/internal/app/pricing_association.go create mode 100644 backend/internal/app/pricing_association_internal_test.go diff --git a/backend/internal/app/pricing.go b/backend/internal/app/pricing.go index 979bfe9c..05a3ba87 100644 --- a/backend/internal/app/pricing.go +++ b/backend/internal/app/pricing.go @@ -769,16 +769,17 @@ func findMatchingPrice(prices map[[2]string]ModelPrice, provider, model *string) if providerKey == "" || modelKey == "" { return nil } - candidates := []string{providerKey} - if providerKey == "codex" { - candidates = append(candidates, "openai") - } - if providerKey == "claude" { - candidates = append(candidates, "anthropic") - } - for _, candidate := range candidates { - if price, ok := prices[[2]string{candidate, modelKey}]; ok { - return &price + // An exact (provider, model) price always wins; the association fallbacks below only apply + // when nothing matches exactly, so a manually created exact price can always override them. + for _, modelCandidate := range priceModelCandidates(modelKey) { + for _, providerCandidate := range priceProviderCandidates(providerKey, modelCandidate) { + if price, ok := prices[[2]string{providerCandidate, modelCandidate}]; ok { + return &price + } + // LiteLLM keys many models as "<provider>/<model>" (e.g. gemini/gemini-3.8-flash). + if price, ok := prices[[2]string{providerCandidate, providerCandidate + "/" + modelCandidate}]; ok { + return &price + } } } return nil diff --git a/backend/internal/app/pricing_association.go b/backend/internal/app/pricing_association.go new file mode 100644 index 00000000..f0ee1b19 --- /dev/null +++ b/backend/internal/app/pricing_association.go @@ -0,0 +1,75 @@ +package app + +import "strings" + +// Price association: reverse-proxied models are reported under the proxy's own provider name and +// often with a reasoning-tier suffix (provider "antigravity", model "gemini-3.8-flash-high"), +// while the price list — typically synced from LiteLLM — knows the canonical vendor entry +// (provider "gemini", model "gemini/gemini-3.8-flash"). These helpers produce the ordered lookup +// candidates that associate the former with the latter. Order is most-specific first, and the +// caller tries every provider candidate for one model candidate before relaxing the model name. + +// priceModelVariantSuffixes are request-variant suffixes that select a reasoning tier / mode but +// do not change the per-token price, so a variant may fall back to its base model's price. +var priceModelVariantSuffixes = []string{"-thinking", "-minimal", "-low", "-medium", "-high", "-xhigh"} + +// priceModelCandidates returns the model itself followed by progressively stripped base names +// ("x-thinking-high" → "x-thinking" → "x"). Input must already be lower-cased and trimmed. +func priceModelCandidates(model string) []string { + candidates := []string{model} + current := model + for i := 0; i < 3; i++ { + stripped := current + for _, suffix := range priceModelVariantSuffixes { + if strings.HasSuffix(current, suffix) && len(current) > len(suffix) { + stripped = strings.TrimSuffix(current, suffix) + break + } + } + if stripped == current { + break + } + candidates = append(candidates, stripped) + current = stripped + } + return candidates +} + +// priceProviderCandidates returns the provider itself followed by the canonical vendors whose +// price entries it may use. Multi-vendor reverse proxies (antigravity) are resolved by the model +// family. Input must already be lower-cased and trimmed. +func priceProviderCandidates(provider, model string) []string { + candidates := []string{provider} + add := func(values ...string) { + for _, value := range values { + duplicate := false + for _, existing := range candidates { + if existing == value { + duplicate = true + break + } + } + if !duplicate { + candidates = append(candidates, value) + } + } + } + switch provider { + case "codex": + add("openai") + case "claude": + add("anthropic") + case "gemini-cli", "aistudio": + add("gemini") + case "antigravity": + switch { + case strings.HasPrefix(model, "gemini"): + add("gemini") + case strings.HasPrefix(model, "claude"): + add("anthropic") + case strings.HasPrefix(model, "gpt"), strings.HasPrefix(model, "o1"), strings.HasPrefix(model, "o3"), strings.HasPrefix(model, "o4"): + add("openai") + } + } + return candidates +} diff --git a/backend/internal/app/pricing_association_internal_test.go b/backend/internal/app/pricing_association_internal_test.go new file mode 100644 index 00000000..20aa33cb --- /dev/null +++ b/backend/internal/app/pricing_association_internal_test.go @@ -0,0 +1,108 @@ +package app + +import ( + "reflect" + "testing" +) + +func priceTable(entries ...[3]any) map[[2]string]ModelPrice { + table := map[[2]string]ModelPrice{} + for _, entry := range entries { + provider, model, input := entry[0].(string), entry[1].(string), entry[2].(float64) + table[priceKey(provider, model)] = ModelPrice{Provider: provider, Model: model, InputUSDPerMillion: input} + } + return table +} + +func matchInput(t *testing.T, prices map[[2]string]ModelPrice, provider, model string) (float64, bool) { + t.Helper() + price := findMatchingPrice(prices, &provider, &model) + if price == nil { + return 0, false + } + return price.InputUSDPerMillion, true +} + +func TestFindMatchingPriceAssociatesReverseProxiedVariantWithLiteLLMEntry(t *testing.T) { + // The reported field case: antigravity + tiered model vs LiteLLM's gemini/<model> key. + prices := priceTable([3]any{"gemini", "gemini/gemini-3.8-flash", 0.3}) + for _, model := range []string{"gemini-3.8-flash-high", "gemini-3.8-flash-low", "gemini-3.8-flash", "Gemini-3.8-Flash-HIGH "} { + if got, ok := matchInput(t, prices, "antigravity", model); !ok || got != 0.3 { + t.Fatalf("antigravity/%q → %v,%v; want 0.3", model, got, ok) + } + } + // Other gemini reverse-proxy providers associate the same way. + for _, provider := range []string{"gemini-cli", "aistudio", "gemini"} { + if got, ok := matchInput(t, prices, provider, "gemini-3.8-flash-high"); !ok || got != 0.3 { + t.Fatalf("%s → %v,%v; want 0.3", provider, got, ok) + } + } +} + +func TestFindMatchingPriceExactAlwaysWinsOverAssociation(t *testing.T) { + prices := priceTable( + [3]any{"gemini", "gemini/gemini-3.8-flash", 0.3}, + [3]any{"gemini", "gemini-3.8-flash-high", 0.9}, // exact tiered model on the alias provider + [3]any{"antigravity", "gemini-3.8-flash-high", 1.5}, // exact provider + model (manual override) + ) + if got, _ := matchInput(t, prices, "antigravity", "gemini-3.8-flash-high"); got != 1.5 { + t.Fatalf("exact provider+model must win, got %v", got) + } + delete(prices, priceKey("antigravity", "gemini-3.8-flash-high")) + if got, _ := matchInput(t, prices, "antigravity", "gemini-3.8-flash-high"); got != 0.9 { + t.Fatalf("exact model on the associated provider must beat the stripped base model, got %v", got) + } +} + +func TestFindMatchingPriceAntigravityResolvesVendorByModelFamily(t *testing.T) { + prices := priceTable( + [3]any{"anthropic", "claude-sonnet-4-5", 3.0}, + [3]any{"openai", "gpt-5.2", 1.25}, + [3]any{"gemini", "gemini/gemini-3.8-flash", 0.3}, + ) + if got, ok := matchInput(t, prices, "antigravity", "claude-sonnet-4-5-thinking"); !ok || got != 3.0 { + t.Fatalf("claude via antigravity → %v,%v", got, ok) + } + if got, ok := matchInput(t, prices, "antigravity", "gpt-5.2-high"); !ok || got != 1.25 { + t.Fatalf("gpt via antigravity → %v,%v", got, ok) + } + // A gemini model must never borrow another vendor's price, and unknown families stay unpriced. + if _, ok := matchInput(t, priceTable([3]any{"openai", "gemini-3.8-flash", 9.0}), "antigravity", "gemini-3.8-flash-high"); ok { + t.Fatal("gemini model must not match an openai price") + } + if _, ok := matchInput(t, prices, "antigravity", "mystery-model-high"); ok { + t.Fatal("unknown model family must stay unpriced") + } +} + +func TestFindMatchingPriceKeepsExistingBehaviour(t *testing.T) { + prices := priceTable([3]any{"openai", "gpt-5.2", 1.25}, [3]any{"anthropic", "claude-sonnet-4-5", 3.0}) + if got, ok := matchInput(t, prices, "codex", "gpt-5.2"); !ok || got != 1.25 { + t.Fatalf("codex→openai alias regressed: %v,%v", got, ok) + } + if got, ok := matchInput(t, prices, "claude", "claude-sonnet-4-5"); !ok || got != 3.0 { + t.Fatalf("claude→anthropic alias regressed: %v,%v", got, ok) + } + // No cross-provider leakage for providers without an association. + if _, ok := matchInput(t, prices, "kimi", "gpt-5.2"); ok { + t.Fatal("unrelated provider must not match") + } + // A suffix is only stripped when something remains, and non-variant names are untouched. + if _, ok := matchInput(t, priceTable([3]any{"openai", "", 1.0}), "openai", "-high"); ok { + t.Fatal("bare suffix must not match") + } + if nilPrice := findMatchingPrice(prices, nil, nil); nilPrice != nil { + t.Fatal("nil inputs must not match") + } +} + +func TestPriceModelCandidatesOrder(t *testing.T) { + got := priceModelCandidates("claude-opus-4-5-thinking-high") + want := []string{"claude-opus-4-5-thinking-high", "claude-opus-4-5-thinking", "claude-opus-4-5"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("candidates = %v, want %v", got, want) + } + if got := priceModelCandidates("gpt-5.2"); !reflect.DeepEqual(got, []string{"gpt-5.2"}) { + t.Fatalf("plain model candidates = %v", got) + } +} From 533d3e462890dcc11e3364ee766708fe4245fe00 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Sun, 20 Sep 2026 16:36:49 +0800 Subject: [PATCH 23/25] feat(cli): read-only usage-cost subcommand (cost grouped by model/provider/endpoint/source-account) (#19) * feat(cli): read-only usage-cost subcommand reporting cost grouped by model/provider/endpoint/source-account Prices records with the production recordCost derivation via a narrow export so the report cannot drift from what was recorded; unpriced records are reported separately and never read as free. --db defaults to the service database path, overridable for analysis of other copies. Golden vectors pin the wired cost function against production. * fix(cli): bind dbTime-layout string for usage-cost --since window Review finding (PR #19): LoadRecords bound since.UTC() as time.Time; the driver serialises it space-separated UTC while production writes timestamps as 'T'-separated Asia/Shanghai text, and ' ' < 'T' made the lexicographic >= admit rows up to ~16h older than --since. Export app.UsageDBTime so the reader binds the same byte shape production writes, and make fixtures write timestamps through the same helper so the test exercises the real byte layout instead of the reader's own. --------- Co-authored-by: feiniu <a-9b3ff9ce@mail.build> Co-authored-by: feiniu (Raft agent) <admin@oranix.io> --- backend/cmd/cpa-helper/main.go | 8 + backend/internal/app/usage_cost_export.go | 32 ++ backend/internal/usagecost/aggregate.go | 98 +++++ backend/internal/usagecost/aggregate_test.go | 106 ++++++ backend/internal/usagecost/golden_test.go | 136 +++++++ backend/internal/usagecost/options.go | 133 +++++++ backend/internal/usagecost/options_test.go | 103 ++++++ backend/internal/usagecost/render.go | 92 +++++ backend/internal/usagecost/render_test.go | 97 +++++ backend/internal/usagecost/run.go | 40 +++ backend/internal/usagecost/store.go | 129 +++++++ backend/internal/usagecost/store_test.go | 290 +++++++++++++++ .../usagecost/testdata/cost_golden.json | 338 ++++++++++++++++++ backend/internal/usagecost/types.go | 52 +++ backend/internal/usagecost/wiring.go | 33 ++ 15 files changed, 1687 insertions(+) create mode 100644 backend/internal/app/usage_cost_export.go create mode 100644 backend/internal/usagecost/aggregate.go create mode 100644 backend/internal/usagecost/aggregate_test.go create mode 100644 backend/internal/usagecost/golden_test.go create mode 100644 backend/internal/usagecost/options.go create mode 100644 backend/internal/usagecost/options_test.go create mode 100644 backend/internal/usagecost/render.go create mode 100644 backend/internal/usagecost/render_test.go create mode 100644 backend/internal/usagecost/run.go create mode 100644 backend/internal/usagecost/store.go create mode 100644 backend/internal/usagecost/store_test.go create mode 100644 backend/internal/usagecost/testdata/cost_golden.json create mode 100644 backend/internal/usagecost/types.go create mode 100644 backend/internal/usagecost/wiring.go diff --git a/backend/cmd/cpa-helper/main.go b/backend/cmd/cpa-helper/main.go index cf2557e2..22a7e96a 100644 --- a/backend/cmd/cpa-helper/main.go +++ b/backend/cmd/cpa-helper/main.go @@ -12,6 +12,7 @@ import ( backendApp "cpa-helper/backend/internal/app" "cpa-helper/backend/internal/httpserver" + "cpa-helper/backend/internal/usagecost" ) func main() { @@ -87,6 +88,10 @@ func run(ctx context.Context, args []string, stdout io.Writer) error { } fmt.Fprintf(stdout, "ready: db=%s current_version=%d target_version=%d\n", report.DBPath, report.CurrentVersion, report.TargetVersion) return nil + case "usage-cost": + // Read-only cost report over the same database the service writes to; + // --db only overrides where it reads, never what it records. + return usagecost.Run(ctx, args[1:], stdout) case "help", "-h", "--help": printUsage(stdout) return nil @@ -150,5 +155,8 @@ func printUsage(w io.Writer) { Roll the schema DOWN to an allowlisted version (destructive) cpa-helper serve Start only after read-only startup checks pass cpa-helper doctor Run read-only startup checks and exit + cpa-helper usage-cost [--db path] --group-by model|provider|endpoint|source-account + --since <days> [--json] + Report usage cost over recorded usage (read-only) `) } diff --git a/backend/internal/app/usage_cost_export.go b/backend/internal/app/usage_cost_export.go new file mode 100644 index 00000000..445c3111 --- /dev/null +++ b/backend/internal/app/usage_cost_export.go @@ -0,0 +1,32 @@ +package app + +import "time" + +// UsageRecordCost exports recordCost so the `cpa-helper usage-cost` subcommand +// (internal/usagecost) can price records with the SAME derivation production +// uses -- the report must reproduce production's number, and a copied +// implementation would drift silently. The seam is narrow on purpose: only +// the record->cost question is exported, not the matching internals. +func UsageRecordCost(record UsageRecord, prices map[[2]string]ModelPrice) (usd float64, unpriced bool) { + return recordCost(record, prices) +} + +// UsageDBTime formats a timestamp the way production writes it to TEXT columns +// (dbTime(): Asia/Shanghai offset, 'T' separator). Read-side comparisons against +// those columns must bind this exact byte shape -- binding a time.Time lets the +// driver serialise it differently (space separator, UTC) and a lexicographic +// comparison then stops being a time comparison. +func UsageDBTime(t time.Time) string { + return dbTime(t) +} + +// UsageDBPath resolves the database path the same way the service does +// (CPA_HELPER_DATA_DIR, else <repo>/data) so `usage-cost` defaults to the +// database the service actually writes to. +func UsageDBPath() (string, error) { + paths, err := resolveRuntimePaths() + if err != nil { + return "", err + } + return paths.DBPath, nil +} diff --git a/backend/internal/usagecost/aggregate.go b/backend/internal/usagecost/aggregate.go new file mode 100644 index 00000000..256d1328 --- /dev/null +++ b/backend/internal/usagecost/aggregate.go @@ -0,0 +1,98 @@ +package usagecost + +import "sort" + +// Group is one row of the report. +type Group struct { + Key string `json:"key"` + // Requests counts every record in the group, Failed the subset that failed. + // Both are reported because a group's cost is only interpretable next to how + // many calls produced it. + Requests int `json:"requests"` + Failed int `json:"failed"` + // TotalTokens is summed from the records, not recomputed from the parts. + TotalTokens int64 `json:"total_tokens"` + // CostUSD covers only the PRICED records in this group. + CostUSD float64 `json:"cost_usd"` + // UnpricedRequests counts records that consumed something billable but + // matched no price. They contribute 0 to CostUSD, so without this column a + // group with no prices configured is indistinguishable from a free one. + UnpricedRequests int `json:"unpriced_requests"` +} + +// Report is the whole answer, including what was asked for. +type Report struct { + GroupBy string `json:"group_by"` + SinceDays int `json:"since_days"` + Since string `json:"since"` + Groups []Group `json:"groups"` + // TotalCostUSD and TotalUnpriced are summed over groups so a reader never + // has to add the column up by hand and get a different answer. + TotalCostUSD float64 `json:"total_cost_usd"` + TotalUnpriced int `json:"total_unpriced_requests"` +} + +// unattributed labels records whose grouping dimension is NULL or blank. +// They are kept in the report rather than dropped: silently discarding them +// would make the report's total disagree with the database's, and nobody would +// see why. +const unattributed = "(unattributed)" + +// Aggregate groups records and costs them. It returns ErrNoCostFunc when no +// pricing implementation is available, rather than a report full of zeros. +func Aggregate(records []Record, prices map[PriceKey]ModelPrice, by GroupBy, cost CostFunc) ([]Group, error) { + if cost == nil { + return nil, ErrNoCostFunc + } + byKey := map[string]*Group{} + for _, record := range records { + key := groupKey(record, by) + group, ok := byKey[key] + if !ok { + group = &Group{Key: key} + byKey[key] = group + } + group.Requests++ + if record.Failed { + group.Failed++ + } + group.TotalTokens += int64(record.TotalTokens) + usd, unpriced := cost(record, prices) + if unpriced { + group.UnpricedRequests++ + continue + } + group.CostUSD += usd + } + groups := make([]Group, 0, len(byKey)) + for _, group := range byKey { + groups = append(groups, *group) + } + // Most expensive first; ties broken by key so the output is stable and two + // runs over the same data can be diffed. + sort.Slice(groups, func(i, j int) bool { + if groups[i].CostUSD != groups[j].CostUSD { + return groups[i].CostUSD > groups[j].CostUSD + } + return groups[i].Key < groups[j].Key + }) + return groups, nil +} + +func groupKey(record Record, by GroupBy) string { + var value *string + switch by { + case GroupByModel: + value = record.Model + case GroupByProvider: + value = record.Provider + case GroupByEndpoint: + value = record.Endpoint + case GroupBySourceAccount: + value = record.SourceAccount + } + if value == nil || *value == "" { + return unattributed + } + return *value +} diff --git a/backend/internal/usagecost/aggregate_test.go b/backend/internal/usagecost/aggregate_test.go new file mode 100644 index 00000000..59dfca19 --- /dev/null +++ b/backend/internal/usagecost/aggregate_test.go @@ -0,0 +1,106 @@ +package usagecost + +import ( + "errors" + "testing" +) + +func strptr(s string) *string { return &s } + +// fixedCost is a test-only pricing stub. It exists so the aggregation layer can +// be tested WITHOUT deciding where the real derivation comes from -- and it is +// deliberately trivial (1 USD per priced request) so any arithmetic asserted +// below is the aggregator's, not the stub's. +func fixedCost(record Record, prices map[PriceKey]ModelPrice) (float64, bool) { + if record.Model == nil { + return 0, record.TotalTokens > 0 + } + if _, ok := prices[PriceKey{"p", *record.Model}]; !ok { + return 0, record.TotalTokens > 0 + } + return 1, false +} + +var testPrices = map[PriceKey]ModelPrice{{"p", "priced"}: {}} + +func TestAggregateWithoutACostFuncRefusesInsteadOfReportingZero(t *testing.T) { + // The whole point of the seam: "I cannot price this" must never leave the + // package looking like "this cost nothing". + _, err := Aggregate([]Record{{Model: strptr("priced"), TotalTokens: 10}}, testPrices, GroupByModel, nil) + if !errors.Is(err, ErrNoCostFunc) { + t.Fatalf("err = %v, want ErrNoCostFunc", err) + } +} + +func TestAggregateKeepsUnpricedRequestsOutOfCostButVisible(t *testing.T) { + records := []Record{ + {Model: strptr("priced"), TotalTokens: 10}, + {Model: strptr("priced"), TotalTokens: 5, Failed: true}, + {Model: strptr("nameless"), TotalTokens: 7}, + } + groups, err := Aggregate(records, testPrices, GroupByModel, fixedCost) + if err != nil { + t.Fatal(err) + } + if len(groups) != 2 { + t.Fatalf("groups = %+v, want 2", groups) + } + priced, unpricedGroup := groups[0], groups[1] + if priced.Key != "priced" || priced.Requests != 2 || priced.Failed != 1 || + priced.TotalTokens != 15 || priced.CostUSD != 2 || priced.UnpricedRequests != 0 { + t.Fatalf("priced = %+v", priced) + } + // The unpriced group must NOT be dropped and must NOT be costed: a group + // that matched no price looks exactly like a free one unless it is counted. + if unpricedGroup.Key != "nameless" || unpricedGroup.Requests != 1 || + unpricedGroup.CostUSD != 0 || unpricedGroup.UnpricedRequests != 1 { + t.Fatalf("unpriced = %+v", unpricedGroup) + } +} + +func TestAggregateKeepsRowsWhoseDimensionIsMissing(t *testing.T) { + // Dropping these would make the report's request count disagree with the + // database's, with nothing to point at. + blank := "" + records := []Record{ + {Model: strptr("priced"), Endpoint: nil, TotalTokens: 1}, + {Model: strptr("priced"), Endpoint: &blank, TotalTokens: 1}, + {Model: strptr("priced"), Endpoint: strptr("/v1/chat"), TotalTokens: 1}, + } + groups, err := Aggregate(records, testPrices, GroupByEndpoint, fixedCost) + if err != nil { + t.Fatal(err) + } + total := 0 + seen := map[string]int{} + for _, group := range groups { + total += group.Requests + seen[group.Key] = group.Requests + } + if total != len(records) { + t.Fatalf("requests = %d, want %d (groups %+v)", total, len(records), groups) + } + // NULL and "" are the same state for reporting -- both mean "we do not know + // which endpoint" -- so they must land in ONE bucket, not two look-alikes. + if seen[unattributed] != 2 || seen["/v1/chat"] != 1 { + t.Fatalf("buckets = %+v", seen) + } +} + +func TestAggregateOrdersByCostThenKeySoRunsAreDiffable(t *testing.T) { + prices := map[PriceKey]ModelPrice{{"p", "a"}: {}, {"p", "b"}: {}, {"p", "c"}: {}} + records := []Record{ + {Model: strptr("b"), TotalTokens: 1}, {Model: strptr("b"), TotalTokens: 1}, + {Model: strptr("c"), TotalTokens: 1}, + {Model: strptr("a"), TotalTokens: 1}, + } + groups, err := Aggregate(records, prices, GroupByModel, fixedCost) + if err != nil { + t.Fatal(err) + } + got := []string{groups[0].Key, groups[1].Key, groups[2].Key} + // b costs 2; a and c both cost 1 and must then sort by key, not by map order. + if got[0] != "b" || got[1] != "a" || got[2] != "c" { + t.Fatalf("order = %v, want [b a c]", got) + } +} diff --git a/backend/internal/usagecost/golden_test.go b/backend/internal/usagecost/golden_test.go new file mode 100644 index 00000000..5dd0dc84 --- /dev/null +++ b/backend/internal/usagecost/golden_test.go @@ -0,0 +1,136 @@ +package usagecost + +import ( + "encoding/json" + "math" + "os" + "testing" +) + +// The golden vectors are the numbers CPA-Helper's own recordCost produced. +// Whatever implementation this repo wires into costFunc must reproduce them +// exactly -- that equivalence is the entire reason this tool is allowed to +// report a cost at all. +type goldenFile struct { + GeneratedFrom struct { + Repo string `json:"repo"` + Commit string `json:"commit"` + } `json:"generated_from"` + Cases []goldenCase `json:"cases"` +} + +type goldenCase struct { + Name string `json:"name"` + Why string `json:"why"` + Prices []struct { + Provider string `json:"provider"` + Model string `json:"model"` + InputUSDPerMillion float64 `json:"input_usd_per_million"` + OutputUSDPerMillion float64 `json:"output_usd_per_million"` + CacheReadUSDPerMillion float64 `json:"cache_read_usd_per_million"` + CacheCreationUSDPerMillion float64 `json:"cache_creation_usd_per_million"` + RequestUSD *float64 `json:"request_usd"` + } `json:"prices"` + Record Record `json:"record"` + WantUSD float64 `json:"want_usd"` + WantUnpriced bool `json:"want_unpriced"` +} + +func loadGolden(t *testing.T) goldenFile { + t.Helper() + raw, err := os.ReadFile("testdata/cost_golden.json") + if err != nil { + t.Fatal(err) + } + var file goldenFile + if err := json.Unmarshal(raw, &file); err != nil { + t.Fatal(err) + } + if len(file.Cases) == 0 || file.GeneratedFrom.Commit == "" { + // A vector file that lost its provenance cannot be trusted as evidence: + // it would still pass, and nobody could tell which behaviour it pinned. + t.Fatalf("golden file must carry cases and the commit they came from: %+v", file.GeneratedFrom) + } + return file +} + +// TestGoldenVectorsCoverTheBranchesThatMatter guards the corpus itself. A +// shrinking vector file is the quiet way this tooth stops biting: the +// equivalence test below keeps passing while covering less and less. +func TestGoldenVectorsCoverTheBranchesThatMatter(t *testing.T) { + file := loadGolden(t) + required := []string{ + "token/non-claude/cached-bounded", + "token/non-claude/cached-exceeds-input", + "token/claude/cache-creation", + "token/no-price/tokens-used", + "token/no-price/no-tokens", + "token/nil-provider-and-model", + "alias/antigravity-to-gemini-family", + "alias/litellm-slash-key", + "request/image-success", + "request/image-failed", + "request/image-no-request-price", + "token/rounding-to-8dp", + } + present := map[string]bool{} + for _, c := range file.Cases { + present[c.Name] = true + } + for _, name := range required { + if !present[name] { + t.Fatalf("golden vectors no longer cover %q", name) + } + } + // The two "looks like zero" cases must disagree on Unpriced, or the corpus + // cannot detect an implementation that collapses them. + var noPriceUsed, noPriceIdle *goldenCase + for i := range file.Cases { + switch file.Cases[i].Name { + case "token/no-price/tokens-used": + noPriceUsed = &file.Cases[i] + case "token/no-price/no-tokens": + noPriceIdle = &file.Cases[i] + } + } + if noPriceUsed.WantUSD != 0 || noPriceIdle.WantUSD != 0 { + t.Fatal("both no-price cases must cost 0 -- that is what makes them look alike") + } + if !noPriceUsed.WantUnpriced || noPriceIdle.WantUnpriced { + t.Fatalf("the no-price cases must differ on Unpriced (%v vs %v), else 'no price configured' "+ + "and 'nothing was used' are indistinguishable", + noPriceUsed.WantUnpriced, noPriceIdle.WantUnpriced) + } +} + +// TestWiredCostFuncMatchesCPAHelper is the drift detector. It is skipped while +// no implementation is wired in -- and the skip is loud, because a silently +// skipped equivalence test is exactly how two implementations drift apart +// without anybody noticing. +func TestWiredCostFuncMatchesCPAHelper(t *testing.T) { + file := loadGolden(t) + cost := WiredCostFunc() + if cost == nil { + t.Skipf("no pricing implementation wired in yet; %d golden vectors from %s@%s are waiting", + len(file.Cases), file.GeneratedFrom.Repo, file.GeneratedFrom.Commit[:12]) + } + for _, c := range file.Cases { + prices := map[PriceKey]ModelPrice{} + for _, p := range c.Prices { + prices[PriceKey{p.Provider, p.Model}] = ModelPrice{ + InputUSDPerMillion: p.InputUSDPerMillion, + OutputUSDPerMillion: p.OutputUSDPerMillion, + CacheReadUSDPerMillion: p.CacheReadUSDPerMillion, + CacheCreationUSDPerMillion: p.CacheCreationUSDPerMillion, + RequestUSD: p.RequestUSD, + } + } + usd, unpriced := cost(c.Record, prices) + // Exact, not approximate: CPA-Helper rounds to 8 decimal places, so the + // two implementations either agree to the cent-of-a-cent or they do not. + if math.Abs(usd-c.WantUSD) > 1e-12 || unpriced != c.WantUnpriced { + t.Fatalf("%s (%s): got (%.10f, %v), want (%.10f, %v)", + c.Name, c.Why, usd, unpriced, c.WantUSD, c.WantUnpriced) + } + } +} diff --git a/backend/internal/usagecost/options.go b/backend/internal/usagecost/options.go new file mode 100644 index 00000000..e869a942 --- /dev/null +++ b/backend/internal/usagecost/options.go @@ -0,0 +1,133 @@ +package usagecost + +import ( + "fmt" + "strconv" + "strings" + "time" + + backendApp "cpa-helper/backend/internal/app" +) + +// GroupBy is the closed set of dimensions this tool aggregates over. It is +// closed on purpose: an unrecognised value is rejected at parse time rather +// than silently producing a report grouped by something else. +type GroupBy string + +const ( + GroupByModel GroupBy = "model" + GroupByProvider GroupBy = "provider" + GroupByEndpoint GroupBy = "endpoint" + // GroupBySourceAccount answers "which upstream account is carrying the + // traffic", which none of the other three can: a single model or provider + // is served by several underlying accounts. + GroupBySourceAccount GroupBy = "source-account" +) + +var groupByValues = []GroupBy{GroupByModel, GroupByProvider, GroupByEndpoint, GroupBySourceAccount} + +// Options is the fully validated command line. Nothing downstream re-checks +// these, so ParseArgs must leave no invalid state behind. +type Options struct { + DBPath string + GroupBy GroupBy + // Since is the start of the reporting window, already resolved against the + // caller's clock. Storing the instant rather than the day count means the + // window cannot shift underneath a long-running report. + Since time.Time + // SinceDays is kept for the report header so the output can say what was + // asked for, not only what it resolved to. + SinceDays int + JSON bool +} + +// ParseArgs validates the command line. `now` is injected so tests do not +// depend on the wall clock. +func ParseArgs(args []string, now time.Time) (Options, error) { + opts := Options{} + var ( + groupByRaw string + sinceRaw string + ) + for i := 0; i < len(args); i++ { + arg := args[i] + name, inlineValue, hasInline := strings.Cut(arg, "=") + value := func() (string, error) { + if hasInline { + if inlineValue == "" { + return "", fmt.Errorf("%s needs a value", name) + } + return inlineValue, nil + } + if i+1 >= len(args) { + return "", fmt.Errorf("%s needs a value", name) + } + i++ + return args[i], nil + } + var err error + switch name { + case "--db": + opts.DBPath, err = value() + case "--group-by": + groupByRaw, err = value() + case "--since": + sinceRaw, err = value() + case "--json": + if hasInline { + err = fmt.Errorf("--json takes no value") + } + opts.JSON = true + default: + err = fmt.Errorf("unknown flag %q", name) + } + if err != nil { + return Options{}, err + } + } + + if opts.DBPath == "" { + // Same resolution the service uses (CPA_HELPER_DATA_DIR, else + // <repo>/data): omitting --db must report on the database the service + // writes to, not fail for want of a path the user would only guess at. + defaultPath, err := backendApp.UsageDBPath() + if err != nil { + return Options{}, fmt.Errorf("--db not given and the default could not be resolved: %w", err) + } + opts.DBPath = defaultPath + } + if groupByRaw == "" { + return Options{}, fmt.Errorf("--group-by is required (one of %s)", joinGroupBy()) + } + matched := false + for _, candidate := range groupByValues { + if groupByRaw == string(candidate) { + opts.GroupBy, matched = candidate, true + break + } + } + if !matched { + return Options{}, fmt.Errorf("--group-by %q is not one of %s", groupByRaw, joinGroupBy()) + } + if sinceRaw == "" { + return Options{}, fmt.Errorf("--since is required (a whole number of days)") + } + // Deliberately strict: "7d", "7.0" and " 7" are all rejected rather than + // guessed at, because a misread window silently changes every number in the + // report and nothing downstream can detect it. + days, err := strconv.Atoi(sinceRaw) + if err != nil || days <= 0 { + return Options{}, fmt.Errorf("--since %q must be a positive whole number of days", sinceRaw) + } + opts.SinceDays = days + opts.Since = now.Add(-time.Duration(days) * 24 * time.Hour) + return opts, nil +} + +func joinGroupBy() string { + parts := make([]string, 0, len(groupByValues)) + for _, value := range groupByValues { + parts = append(parts, string(value)) + } + return strings.Join(parts, "|") +} diff --git a/backend/internal/usagecost/options_test.go b/backend/internal/usagecost/options_test.go new file mode 100644 index 00000000..0a3ba6a6 --- /dev/null +++ b/backend/internal/usagecost/options_test.go @@ -0,0 +1,103 @@ +package usagecost + +import ( + "testing" + "time" +) + +var testNow = time.Date(2026, 9, 20, 4, 0, 0, 0, time.UTC) + +func TestParseArgsAcceptsSeparatedAndInlineValues(t *testing.T) { + // Both spellings must land on the same Options. The separated form consumes + // the NEXT argv entry, so this also pins that the parser's cursor really + // advances past a consumed value -- if it did not, "model" would be read a + // second time as a flag and the parse would fail. + for _, args := range [][]string{ + {"--db", "/tmp/x.sqlite3", "--group-by", "model", "--since", "7"}, + {"--db=/tmp/x.sqlite3", "--group-by=model", "--since=7"}, + } { + opts, err := ParseArgs(args, testNow) + if err != nil { + t.Fatalf("ParseArgs(%v) = %v", args, err) + } + if opts.DBPath != "/tmp/x.sqlite3" || opts.GroupBy != GroupByModel || opts.SinceDays != 7 { + t.Fatalf("ParseArgs(%v) = %+v", args, opts) + } + // Assert the same arithmetic the parser does (a rolling 7x24h window), + // not a calendar-day equivalent that only coincides in UTC. + if want := testNow.Add(-7 * 24 * time.Hour); !opts.Since.Equal(want) { + t.Fatalf("Since = %s, want %s", opts.Since, want) + } + if opts.JSON { + t.Fatal("JSON must default to false") + } + } +} + +func TestParseArgsAcceptsEveryDimensionInTheClosedSet(t *testing.T) { + // Every advertised dimension must actually parse. Adding a constant without + // adding it to groupByValues would leave a flag the help text offers and the + // parser rejects. + for _, want := range []GroupBy{GroupByModel, GroupByProvider, GroupByEndpoint, GroupBySourceAccount} { + opts, err := ParseArgs([]string{"--db", "x", "--group-by", string(want), "--since", "1"}, testNow) + if err != nil { + t.Fatalf("--group-by %s: %v", want, err) + } + if opts.GroupBy != want { + t.Fatalf("GroupBy = %s, want %s", opts.GroupBy, want) + } + } +} + +func TestParseArgsJSONIsAFlagNotAValue(t *testing.T) { + opts, err := ParseArgs([]string{"--db", "x", "--group-by", "provider", "--since", "1", "--json"}, testNow) + if err != nil { + t.Fatal(err) + } + if !opts.JSON || opts.GroupBy != GroupByProvider { + t.Fatalf("got %+v", opts) + } + // --json must not swallow a following argument, or "--json --since 1" would + // silently lose the window. + if _, err := ParseArgs([]string{"--db", "x", "--group-by", "endpoint", "--json", "--since", "3"}, testNow); err != nil { + t.Fatalf("--json before another flag: %v", err) + } +} + +func TestParseArgsDefaultsDBToTheServicePath(t *testing.T) { + // Omitting --db must land on the same database the service writes to + // (CPA_HELPER_DATA_DIR / <repo>/data), not fail for want of a path. + t.Setenv("CPA_HELPER_DATA_DIR", "/tmp/feiniu-usagecost-data") + opts, err := ParseArgs([]string{"--group-by", "model", "--since", "7"}, testNow) + if err != nil { + t.Fatal(err) + } + want := "/tmp/feiniu-usagecost-data/db/cpa_helper.sqlite3" + if opts.DBPath != want { + t.Fatalf("DBPath = %q, want the service default %q", opts.DBPath, want) + } +} + +func TestParseArgsRejectsEveryInvalidShape(t *testing.T) { + // Each of these must FAIL. A report that silently groups by the wrong + // dimension or covers the wrong window looks exactly like a correct one. + cases := map[string][]string{ + "missing group-by": {"--db", "x", "--since", "7"}, + "missing since": {"--db", "x", "--group-by", "model"}, + "unknown group-by": {"--db", "x", "--group-by", "user", "--since", "7"}, + "group-by underscore": {"--db", "x", "--group-by", "source_account", "--since", "7"}, + "since zero": {"--db", "x", "--group-by", "model", "--since", "0"}, + "since negative": {"--db", "x", "--group-by", "model", "--since", "-3"}, + "since with suffix": {"--db", "x", "--group-by", "model", "--since", "7d"}, + "since fractional": {"--db", "x", "--group-by", "model", "--since", "7.0"}, + "unknown flag": {"--db", "x", "--group-by", "model", "--since", "7", "--limit", "5"}, + "dangling value": {"--db", "x", "--group-by", "model", "--since"}, + "empty inline value": {"--db=", "--group-by", "model", "--since", "7"}, + "json with value": {"--db", "x", "--group-by", "model", "--since", "7", "--json=true"}, + } + for name, args := range cases { + if _, err := ParseArgs(args, testNow); err == nil { + t.Fatalf("%s: ParseArgs(%v) must fail", name, args) + } + } +} diff --git a/backend/internal/usagecost/render.go b/backend/internal/usagecost/render.go new file mode 100644 index 00000000..ce497da4 --- /dev/null +++ b/backend/internal/usagecost/render.go @@ -0,0 +1,92 @@ +package usagecost + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "time" +) + +// BuildReport assembles the answer, including the totals, so every reader gets +// the same total instead of adding the column up themselves. +func BuildReport(opts Options, groups []Group) Report { + report := Report{ + GroupBy: string(opts.GroupBy), + SinceDays: opts.SinceDays, + Since: opts.Since.UTC().Format(time.RFC3339), + Groups: groups, + } + for _, group := range groups { + report.TotalCostUSD += group.CostUSD + report.TotalUnpriced += group.UnpricedRequests + } + return report +} + +// WriteJSON emits the machine-readable form. Groups is never nil so the field +// marshals as [] rather than null: a consumer testing `Array.isArray` (or Go +// ranging over a decoded nil) must not have to special-case an empty report. +func WriteJSON(w io.Writer, report Report) error { + if report.Groups == nil { + report.Groups = []Group{} + } + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(report) +} + +// WriteText emits the human form. +func WriteText(w io.Writer, report Report) error { + header := fmt.Sprintf("usage cost by %s, since %s (%d day(s))", + report.GroupBy, report.Since, report.SinceDays) + if _, err := fmt.Fprintln(w, header); err != nil { + return err + } + if len(report.Groups) == 0 { + // Said out loud, because an empty table and a table that failed to load + // look identical once the header scrolls away. + _, err := fmt.Fprintln(w, "no usage records in this window") + return err + } + rows := [][]string{{"KEY", "REQUESTS", "FAILED", "TOKENS", "COST_USD", "UNPRICED"}} + for _, group := range report.Groups { + rows = append(rows, []string{ + group.Key, + fmt.Sprintf("%d", group.Requests), + fmt.Sprintf("%d", group.Failed), + fmt.Sprintf("%d", group.TotalTokens), + fmt.Sprintf("%.6f", group.CostUSD), + fmt.Sprintf("%d", group.UnpricedRequests), + }) + } + widths := make([]int, len(rows[0])) + for _, row := range rows { + for i, cell := range row { + if len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + for _, row := range rows { + parts := make([]string, len(row)) + for i, cell := range row { + parts[i] = fmt.Sprintf("%-*s", widths[i], cell) + } + if _, err := fmt.Fprintln(w, strings.TrimRight(strings.Join(parts, " "), " ")); err != nil { + return err + } + } + if _, err := fmt.Fprintf(w, "\ntotal %.6f USD\n", report.TotalCostUSD); err != nil { + return err + } + if report.TotalUnpriced > 0 { + // The qualifier travels in the same sentence as the total, not in a + // column the reader may have skipped: a total that silently excludes + // unpriced usage reads as the whole bill. + _, err := fmt.Fprintf(w, + "warning: %d request(s) matched no price and are NOT in that total\n", report.TotalUnpriced) + return err + } + return nil +} diff --git a/backend/internal/usagecost/render_test.go b/backend/internal/usagecost/render_test.go new file mode 100644 index 00000000..5141bef3 --- /dev/null +++ b/backend/internal/usagecost/render_test.go @@ -0,0 +1,97 @@ +package usagecost + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" +) + +func testOptions() Options { + return Options{ + GroupBy: GroupByModel, + SinceDays: 7, + Since: time.Date(2026, 9, 13, 4, 0, 0, 0, time.UTC), + } +} + +func TestBuildReportTotalsMatchTheRows(t *testing.T) { + groups := []Group{ + {Key: "a", CostUSD: 1.25, UnpricedRequests: 2}, + {Key: "b", CostUSD: 0.75, UnpricedRequests: 1}, + } + report := BuildReport(testOptions(), groups) + if report.TotalCostUSD != 2 || report.TotalUnpriced != 3 { + t.Fatalf("totals = %v / %v", report.TotalCostUSD, report.TotalUnpriced) + } + if report.GroupBy != "model" || report.SinceDays != 7 || + report.Since != "2026-09-13T04:00:00Z" { + t.Fatalf("header = %+v", report) + } +} + +func TestWriteJSONEmitsAnEmptyArrayNotNull(t *testing.T) { + var buf bytes.Buffer + if err := WriteJSON(&buf, BuildReport(testOptions(), nil)); err != nil { + t.Fatal(err) + } + // Asserted on the serialized text: a nil slice also has length zero, but it + // marshals to null, and a consumer that ranges or calls Array.isArray on the + // decoded value then has to special-case an empty report. + if !strings.Contains(buf.String(), `"groups": []`) { + t.Fatalf("groups must serialize as []: %s", buf.String()) + } + var round Report + if err := json.Unmarshal(buf.Bytes(), &round); err != nil { + t.Fatal(err) + } +} + +func TestWriteTextSaysWhenTheWindowIsEmpty(t *testing.T) { + var buf bytes.Buffer + if err := WriteText(&buf, BuildReport(testOptions(), nil)); err != nil { + t.Fatal(err) + } + // An empty table and a table that failed to load look identical once the + // header scrolls away, so the empty case says so in words. + if !strings.Contains(buf.String(), "no usage records") { + t.Fatalf("empty report must say so: %q", buf.String()) + } +} + +func TestWriteTextPutsTheUnpricedWarningNextToTheTotal(t *testing.T) { + var buf bytes.Buffer + report := BuildReport(testOptions(), []Group{ + {Key: "gemini", Requests: 3, TotalTokens: 30, CostUSD: 1.5}, + {Key: "mystery", Requests: 2, TotalTokens: 20, UnpricedRequests: 2}, + }) + if err := WriteText(&buf, report); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "total 1.500000 USD") { + t.Fatalf("missing total: %q", out) + } + // The qualifier must travel WITH the total. A total that silently excludes + // unpriced usage reads as the whole bill, and the column alone is easy to + // skip past. + warningAt := strings.Index(out, "matched no price") + totalAt := strings.Index(out, "total 1.500000 USD") + if warningAt < 0 || warningAt < totalAt { + t.Fatalf("warning must follow the total and mention the count: %q", out) + } + if !strings.Contains(out, "2 request(s)") { + t.Fatalf("warning must carry the count: %q", out) + } + + // The opposite direction: a fully priced report must NOT carry the warning, + // or it degrades into noise everyone learns to ignore. + buf.Reset() + if err := WriteText(&buf, BuildReport(testOptions(), []Group{{Key: "gemini", CostUSD: 1}})); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), "matched no price") { + t.Fatalf("no warning expected: %q", buf.String()) + } +} diff --git a/backend/internal/usagecost/run.go b/backend/internal/usagecost/run.go new file mode 100644 index 00000000..04af5e81 --- /dev/null +++ b/backend/internal/usagecost/run.go @@ -0,0 +1,40 @@ +package usagecost + +import ( + "context" + "fmt" + "io" + "time" +) + +// Run executes `cpa-helper usage-cost <args>`: load prices and records, cost +// every record with the production derivation, and write the report to `out`. +func Run(ctx context.Context, args []string, out io.Writer) error { + opts, err := ParseArgs(args, time.Now().UTC()) + if err != nil { + return err + } + db, err := OpenReadOnly(ctx, opts.DBPath) + if err != nil { + return err + } + defer db.Close() + + prices, err := LoadPrices(ctx, db) + if err != nil { + return fmt.Errorf("load prices: %w", err) + } + records, err := LoadRecords(ctx, db, opts.Since) + if err != nil { + return fmt.Errorf("load usage records: %w", err) + } + groups, err := Aggregate(records, prices, opts.GroupBy, WiredCostFunc()) + if err != nil { + return err + } + report := BuildReport(opts, groups) + if opts.JSON { + return WriteJSON(out, report) + } + return WriteText(out, report) +} diff --git a/backend/internal/usagecost/store.go b/backend/internal/usagecost/store.go new file mode 100644 index 00000000..8916c664 --- /dev/null +++ b/backend/internal/usagecost/store.go @@ -0,0 +1,129 @@ +package usagecost + +import ( + "context" + "database/sql" + "fmt" + "time" + + backendApp "cpa-helper/backend/internal/app" + + _ "modernc.org/sqlite" +) + +// OpenReadOnly opens the CPA-Helper database for reporting only. +// +// Two independent guards, because this points at production data: the DSN asks +// SQLite for a read-only connection, and `query_only` is then asserted on the +// connection that was actually handed back. The second is not redundant -- a +// future change to the DSN (adding a parameter, switching helper) can quietly +// drop `mode=ro`, and without the assertion the first write would succeed +// instead of failing. +func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)", path)) + if err != nil { + return nil, err + } + // One connection: a pool would need the pragma re-asserted per connection, + // and this tool has no concurrency to gain from more. + db.SetMaxOpenConns(1) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("open %s read-only: %w", path, err) + } + var queryOnly int + if err := db.QueryRowContext(ctx, "PRAGMA query_only").Scan(&queryOnly); err != nil { + db.Close() + return nil, fmt.Errorf("read query_only pragma: %w", err) + } + if queryOnly != 1 { + db.Close() + return nil, fmt.Errorf("refusing to continue: connection to %s is not query_only", path) + } + return db, nil +} + +// LoadPrices reads the price table into the map shape the cost derivation +// expects. Keys are lowercased and trimmed by SQLite so the lookup matches +// CPA-Helper's, which does the same normalisation in Go. +func LoadPrices(ctx context.Context, db *sql.DB) (map[PriceKey]ModelPrice, error) { + rows, err := db.QueryContext(ctx, ` + SELECT lower(trim(provider)), lower(trim(model)), + input_usd_per_million, output_usd_per_million, + cache_read_usd_per_million, cache_creation_usd_per_million, + request_usd + FROM model_prices`) + if err != nil { + return nil, err + } + defer rows.Close() + prices := map[PriceKey]ModelPrice{} + for rows.Next() { + var ( + provider, model string + price ModelPrice + requestUSD sql.NullFloat64 + ) + if err := rows.Scan(&provider, &model, + &price.InputUSDPerMillion, &price.OutputUSDPerMillion, + &price.CacheReadUSDPerMillion, &price.CacheCreationUSDPerMillion, + &requestUSD); err != nil { + return nil, err + } + if requestUSD.Valid { + value := requestUSD.Float64 + price.RequestUSD = &value + } + prices[PriceKey{provider, model}] = price + } + return prices, rows.Err() +} + +// LoadRecords reads every usage row at or after `since`. The bound is the +// dbTime() byte shape production writes to the TEXT `timestamp` column -- a +// time.Time bound would be serialised by the driver as "2006-01-02 15:04:05 +// +0000 UTC" (space separator), and ' ' < 'T' makes the lexicographic >= let +// older same-date rows through. Binding the same layout production writes is +// the only way the comparison means what it says. +func LoadRecords(ctx context.Context, db *sql.DB, since time.Time) ([]Record, error) { + rows, err := db.QueryContext(ctx, ` + SELECT provider, model, endpoint, source_account, failed, + input_tokens, output_tokens, cached_tokens, + cache_read_tokens, cache_creation_tokens, reasoning_tokens, total_tokens + FROM usage_records + WHERE timestamp >= ? + ORDER BY id`, backendApp.UsageDBTime(since)) + if err != nil { + return nil, err + } + defer rows.Close() + records := []Record{} + for rows.Next() { + var ( + record Record + provider, model, endpoint, sourceAccount sql.NullString + failed bool + ) + if err := rows.Scan(&provider, &model, &endpoint, &sourceAccount, &failed, + &record.InputTokens, &record.OutputTokens, &record.CachedTokens, + &record.CacheReadTokens, &record.CacheCreationTokens, + &record.ReasoningTokens, &record.TotalTokens); err != nil { + return nil, err + } + record.Provider = nullableString(provider) + record.Model = nullableString(model) + record.Endpoint = nullableString(endpoint) + record.SourceAccount = nullableString(sourceAccount) + record.Failed = failed + records = append(records, record) + } + return records, rows.Err() +} + +func nullableString(value sql.NullString) *string { + if !value.Valid { + return nil + } + text := value.String + return &text +} diff --git a/backend/internal/usagecost/store_test.go b/backend/internal/usagecost/store_test.go new file mode 100644 index 00000000..a3c783dc --- /dev/null +++ b/backend/internal/usagecost/store_test.go @@ -0,0 +1,290 @@ +package usagecost + +import ( + "context" + "database/sql" + "path/filepath" + "strings" + "testing" + "time" + + backendApp "cpa-helper/backend/internal/app" +) + +// newFixtureDB writes a database with the columns this tool reads, using the +// same names and types as CPA-Helper's schema +// (backend/migrations/202605160001_initial_schema.sql). +func newFixtureDB(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "cpa_helper.sqlite3") + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(` + CREATE TABLE usage_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME NOT NULL, + timestamp DATETIME NOT NULL, + provider VARCHAR(120), model VARCHAR(180), endpoint VARCHAR(240), + source_account VARCHAR(320), + failed BOOLEAN NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cached_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_creation_tokens INTEGER NOT NULL DEFAULT 0, + reasoning_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + dedupe_key VARCHAR(80) NOT NULL UNIQUE, + raw_json TEXT NOT NULL + ); + CREATE TABLE model_prices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider VARCHAR(120) NOT NULL, model VARCHAR(180) NOT NULL, + input_usd_per_million REAL NOT NULL DEFAULT 0, + output_usd_per_million REAL NOT NULL DEFAULT 0, + cache_read_usd_per_million REAL NOT NULL DEFAULT 0, + cache_creation_usd_per_million REAL NOT NULL DEFAULT 0, + request_usd REAL, + source VARCHAR(40) NOT NULL DEFAULT 'manual', + updated_at DATETIME NOT NULL + );`); err != nil { + t.Fatal(err) + } + return path +} + +func TestOpenReadOnlyRefusesWrites(t *testing.T) { + path := newFixtureDB(t) + ctx := context.Background() + db, err := OpenReadOnly(ctx, path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + // This points at production data in real use, so the guard is asserted, not + // assumed: a DSN change that drops mode=ro would otherwise go unnoticed + // until the first write succeeded. + _, err = db.ExecContext(ctx, + `INSERT INTO model_prices (provider, model, updated_at) VALUES ('p','m','2026-09-20')`) + if err == nil { + t.Fatal("a write succeeded on a read-only connection") + } +} + +func TestLoadRecordsHonoursTheWindowAndNullDimensions(t *testing.T) { + path := newFixtureDB(t) + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 20, 4, 0, 0, 0, time.UTC) + insert := func(key string, ts time.Time, provider, model, endpoint any, total int) { + // Write timestamps via the SAME serialisation production uses. Binding + // time.Time here would reproduce the reader's own byte shape, not the + // database's -- which is exactly how the space-separator bound bug was + // invisible to this test. + dbTs := backendApp.UsageDBTime(ts) + if _, err := db.Exec(`INSERT INTO usage_records + (created_at, timestamp, provider, model, endpoint, source_account, failed, total_tokens, dedupe_key, raw_json) + VALUES (?,?,?,?,?,?,0,?,?,'{}')`, dbTs, dbTs, provider, model, endpoint, "acct-"+key, total, key); err != nil { + t.Fatal(err) + } + } + insert("inside", now.Add(-1*time.Hour), "antigravity", "gemini", "/v1/chat", 10) + insert("edge", now.Add(-48*time.Hour), "xai", "grok", "/v1/chat", 20) + insert("outside", now.Add(-72*time.Hour), "devin", "swe", "/v1/chat", 40) + insert("nulls", now.Add(-2*time.Hour), nil, nil, nil, 5) + db.Close() + + ctx := context.Background() + ro, err := OpenReadOnly(ctx, path) + if err != nil { + t.Fatal(err) + } + defer ro.Close() + records, err := LoadRecords(ctx, ro, now.Add(-48*time.Hour)) + if err != nil { + t.Fatal(err) + } + // The boundary row must be INCLUDED (>= since) and the older one excluded -- + // an off-by-one here shifts every number in the report with nothing to show + // for it. + if len(records) != 3 { + t.Fatalf("records = %d, want 3: %+v", len(records), records) + } + var sawNulls bool + for _, record := range records { + if record.Provider == nil && record.Model == nil && record.Endpoint == nil { + sawNulls = true + } + } + if !sawNulls { + t.Fatal("a row with NULL provider/model/endpoint must survive the load, not be dropped") + } +} + +// A record older than `since` in real time must be excluded even when its +// stored dbTime() string starts with the same UTC calendar date as the bound. +// This is the exact escape the space-separator bound allowed: the driver writes +// `since` as "2026-09-19 20:00:00 +0000 UTC" while production writes the record +// as "2026-09-20T13:00:00+08:00", and ' ' < 'T' keeps it in the window. +func TestLoadRecordsExcludesPreSinceRowStoredInDbTimeShape(t *testing.T) { + path := newFixtureDB(t) + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + // 2026-09-20 20:00 +08:00 -- stored as "2026-09-20T20:00:00+08:00". + since := time.Date(2026, 9, 20, 20, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + // 7h earlier in real time, same UTC calendar date as the buggy UTC bound. + old := since.Add(-7 * time.Hour) + for key, ts := range map[string]time.Time{"old": old, "new": since.Add(1 * time.Hour)} { + dbTs := backendApp.UsageDBTime(ts) + if _, err := db.Exec(`INSERT INTO usage_records + (created_at, timestamp, provider, model, endpoint, failed, total_tokens, dedupe_key, raw_json) + VALUES (?,?,?,?,?,0,1,?,'{}')`, dbTs, dbTs, "p", "m", "e", key); err != nil { + t.Fatal(err) + } + } + db.Close() + + ctx := context.Background() + ro, err := OpenReadOnly(ctx, path) + if err != nil { + t.Fatal(err) + } + defer ro.Close() + records, err := LoadRecords(ctx, ro, since) + if err != nil { + t.Fatal(err) + } + if len(records) != 1 { + t.Fatalf("records = %d, want 1 -- the pre-since row leaked through the bound", len(records)) + } +} + +func TestLoadPricesNormalisesKeysLikeCPAHelperDoes(t *testing.T) { + path := newFixtureDB(t) + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(`INSERT INTO model_prices + (provider, model, input_usd_per_million, request_usd, updated_at) + VALUES (' Antigravity ', ' Gemini-3.8-Flash ', 1.5, NULL, '2026-09-20'), + ('xai', 'grok-4.6', 0, 0.002, '2026-09-20')`); err != nil { + t.Fatal(err) + } + db.Close() + + ctx := context.Background() + ro, err := OpenReadOnly(ctx, path) + if err != nil { + t.Fatal(err) + } + defer ro.Close() + prices, err := LoadPrices(ctx, ro) + if err != nil { + t.Fatal(err) + } + price, ok := prices[PriceKey{"antigravity", "gemini-3.8-flash"}] + if !ok { + t.Fatalf("key was not lowercased/trimmed: %+v", prices) + } + if price.InputUSDPerMillion != 1.5 { + t.Fatalf("input price = %v", price.InputUSDPerMillion) + } + // NULL request_usd and 0 request_usd are different states: the first means + // "not configured", the second means "configured as free". Collapsing them + // changes which billing branch a record takes. + if price.RequestUSD != nil { + t.Fatalf("NULL request_usd must stay nil, got %v", *price.RequestUSD) + } + grok := prices[PriceKey{"xai", "grok-4.6"}] + if grok.RequestUSD == nil || *grok.RequestUSD != 0.002 { + t.Fatalf("request_usd = %v", grok.RequestUSD) + } +} + +func TestReportRefusesWhenNoPricingIsWired(t *testing.T) { + // End to end through the real store: with no cost derivation available the + // tool must fail, not print a table of zeros that reads like a $0 bill. + path := newFixtureDB(t) + ctx := context.Background() + db, err := OpenReadOnly(ctx, path) + if err != nil { + t.Fatal(err) + } + defer db.Close() + prices, err := LoadPrices(ctx, db) + if err != nil { + t.Fatal(err) + } + records, err := LoadRecords(ctx, db, time.Unix(0, 0)) + if err != nil { + t.Fatal(err) + } + if _, err := Aggregate(records, prices, GroupByModel, nil); err == nil || + !strings.Contains(err.Error(), "pricing implementation") { + t.Fatalf("err = %v, want the no-pricing refusal", err) + } +} + +func TestLoadRecordsCarriesSourceAccountForAttribution(t *testing.T) { + // artin asked for load per UNDERLYING account, which none of + // model/provider/endpoint can answer: one model is served by several + // accounts. The column has to survive the load for the dimension to exist. + path := newFixtureDB(t) + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 9, 20, 4, 0, 0, 0, time.UTC) + dbNow := backendApp.UsageDBTime(now) + if _, err := db.Exec(`INSERT INTO usage_records + (created_at, timestamp, provider, model, endpoint, source_account, failed, total_tokens, dedupe_key, raw_json) + VALUES (?,?,'antigravity','gemini','/v1/chat','acct-a',0,10,'a','{}'), + (?,?,'antigravity','gemini','/v1/chat','acct-b',0,20,'b','{}'), + (?,?,'antigravity','gemini','/v1/chat',NULL,0,30,'c','{}')`, + dbNow, dbNow, dbNow, dbNow, dbNow, dbNow); err != nil { + t.Fatal(err) + } + db.Close() + + ctx := context.Background() + ro, err := OpenReadOnly(ctx, path) + if err != nil { + t.Fatal(err) + } + defer ro.Close() + records, err := LoadRecords(ctx, ro, now.Add(-time.Hour)) + if err != nil { + t.Fatal(err) + } + groups, err := Aggregate(records, map[PriceKey]ModelPrice{}, GroupBySourceAccount, fixedCost) + if err != nil { + t.Fatal(err) + } + seen := map[string]int64{} + for _, group := range groups { + seen[group.Key] = group.TotalTokens + } + // Three rows that are identical on every other dimension must still split + // three ways here -- otherwise the new flag is decorative. + if seen["acct-a"] != 10 || seen["acct-b"] != 20 || seen[unattributed] != 30 { + t.Fatalf("source-account buckets = %+v", seen) + } + // And the same rows must collapse to ONE group on a dimension they share, + // which is what proves the split above came from source_account and not + // from the rows differing somewhere else. + byModel, err := Aggregate(records, map[PriceKey]ModelPrice{}, GroupByModel, fixedCost) + if err != nil { + t.Fatal(err) + } + if len(byModel) != 1 || byModel[0].Requests != 3 { + t.Fatalf("by model = %+v, want one group of 3", byModel) + } +} diff --git a/backend/internal/usagecost/testdata/cost_golden.json b/backend/internal/usagecost/testdata/cost_golden.json new file mode 100644 index 00000000..a5054864 --- /dev/null +++ b/backend/internal/usagecost/testdata/cost_golden.json @@ -0,0 +1,338 @@ +{ + "_README": [ + "Golden cost vectors: the numbers CPA-Helper's own recordCost produced for these inputs.", + "Whatever pricing implementation this repo wires in MUST reproduce them exactly.", + "", + "BOUNDARY: these freeze CPA-Helper's behaviour AT THE COMMIT BELOW. They are not a", + "statement about what the cost SHOULD be -- if CPA-Helper deliberately changes its", + "pricing rules, this file is stale and must be regenerated, not worked around.", + "A stale vector file and a correct one look identical until someone checks the commit.", + "", + "Regenerate: add the generator test to CPA-Helper backend/internal/app (it must live", + "there because recordCost is unexported and under internal/), then:", + " GOLDEN_OUT=<this file> go test ./internal/app/ -run TestZZGenerateGoldenVectors -count=1" + ], + "generated_from": { + "repo": "CPA-Helper", + "commit": "994254cdd3d60ee6be03d62ecf478a0afa3bf2f4", + "function": "backend/internal/app/pricing.go recordCost" + }, + "cases": [ + { + "name": "token/non-claude/cached-bounded", + "why": "non-claude splits cached out of input_tokens", + "prices": [ + { + "provider": "xai", + "model": "grok-4.6", + "input_usd_per_million": 3, + "output_usd_per_million": 15, + "cache_read_usd_per_million": 0.3, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": "xai", + "model": "grok-4.6", + "failed": false, + "input_tokens": 1000, + "output_tokens": 500, + "cached_tokens": 400, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 1500 + }, + "want_usd": 0.00942, + "want_unpriced": false + }, + { + "name": "token/non-claude/cached-exceeds-input", + "why": "cached is clamped to input_tokens, never negative input", + "prices": [ + { + "provider": "xai", + "model": "grok-4.6", + "input_usd_per_million": 3, + "output_usd_per_million": 15, + "cache_read_usd_per_million": 0.3, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": "xai", + "model": "grok-4.6", + "failed": false, + "input_tokens": 100, + "output_tokens": 10, + "cached_tokens": 5000, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 110 + }, + "want_usd": 0.00018, + "want_unpriced": false + }, + { + "name": "token/claude/cache-creation", + "why": "claude path adds cache_creation and uses cache_read, not cached", + "prices": [ + { + "provider": "anthropic", + "model": "claude-sonnet-5", + "input_usd_per_million": 3, + "output_usd_per_million": 15, + "cache_read_usd_per_million": 0.3, + "cache_creation_usd_per_million": 3.75, + "request_usd": null + } + ], + "record": { + "provider": "anthropic", + "model": "claude-sonnet-5", + "failed": false, + "input_tokens": 1000, + "output_tokens": 200, + "cached_tokens": 900, + "cache_read_tokens": 300, + "cache_creation_tokens": 50, + "total_tokens": 1200 + }, + "want_usd": 0.0062775, + "want_unpriced": false + }, + { + "name": "token/no-price/tokens-used", + "why": "billable usage with no price is UNPRICED, not free", + "prices": null, + "record": { + "provider": "nobody", + "model": "nothing", + "failed": false, + "input_tokens": 10, + "output_tokens": 0, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 10 + }, + "want_usd": 0, + "want_unpriced": true + }, + { + "name": "token/no-price/no-tokens", + "why": "zero usage with no price is genuinely zero, not unpriced", + "prices": null, + "record": { + "provider": "nobody", + "model": "nothing", + "failed": false, + "input_tokens": 0, + "output_tokens": 0, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 0 + }, + "want_usd": 0, + "want_unpriced": false + }, + { + "name": "token/nil-provider-and-model", + "why": "nil identity can never match a price", + "prices": [ + { + "provider": "xai", + "model": "grok-4.6", + "input_usd_per_million": 3, + "output_usd_per_million": 15, + "cache_read_usd_per_million": 0.3, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": null, + "model": null, + "failed": false, + "input_tokens": 10, + "output_tokens": 0, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 10 + }, + "want_usd": 0, + "want_unpriced": true + }, + { + "name": "alias/antigravity-to-gemini-family", + "why": "reverse proxy provider + variant suffix both resolve", + "prices": [ + { + "provider": "gemini", + "model": "gemini-3.8-flash", + "input_usd_per_million": 0.3, + "output_usd_per_million": 2.5, + "cache_read_usd_per_million": 0.075, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": "antigravity", + "model": "gemini-3.8-flash-high", + "failed": false, + "input_tokens": 2000, + "output_tokens": 1000, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 3000 + }, + "want_usd": 0.0031, + "want_unpriced": false + }, + { + "name": "alias/litellm-slash-key", + "why": "LiteLLM keys models as <provider>/<model>", + "prices": [ + { + "provider": "gemini", + "model": "gemini/gemini-3.8-flash", + "input_usd_per_million": 0.4, + "output_usd_per_million": 2.6, + "cache_read_usd_per_million": 0, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": "gemini", + "model": "gemini-3.8-flash", + "failed": false, + "input_tokens": 1000, + "output_tokens": 100, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 1100 + }, + "want_usd": 0.00066, + "want_unpriced": false + }, + { + "name": "request/image-success", + "why": "image models bill per request, not per token", + "prices": [ + { + "provider": "openai", + "model": "gpt-image-1", + "input_usd_per_million": 0, + "output_usd_per_million": 0, + "cache_read_usd_per_million": 0, + "cache_creation_usd_per_million": 0, + "request_usd": 0.011 + } + ], + "record": { + "provider": "openai", + "model": "gpt-image-1", + "failed": false, + "input_tokens": 9999, + "output_tokens": 9999, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 19998 + }, + "want_usd": 0.011, + "want_unpriced": false + }, + { + "name": "request/image-failed", + "why": "a failed per-request call costs nothing and is NOT unpriced", + "prices": [ + { + "provider": "openai", + "model": "gpt-image-1", + "input_usd_per_million": 0, + "output_usd_per_million": 0, + "cache_read_usd_per_million": 0, + "cache_creation_usd_per_million": 0, + "request_usd": 0.011 + } + ], + "record": { + "provider": "openai", + "model": "gpt-image-1", + "failed": true, + "input_tokens": 0, + "output_tokens": 0, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 0 + }, + "want_usd": 0, + "want_unpriced": false + }, + { + "name": "request/image-no-request-price", + "why": "per-request model without request_usd is UNPRICED", + "prices": [ + { + "provider": "openai", + "model": "gpt-image-2", + "input_usd_per_million": 5, + "output_usd_per_million": 0, + "cache_read_usd_per_million": 0, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": "openai", + "model": "gpt-image-2", + "failed": false, + "input_tokens": 100, + "output_tokens": 0, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 100 + }, + "want_usd": 0, + "want_unpriced": true + }, + { + "name": "token/rounding-to-8dp", + "why": "pins the rounding, which silently shifts every total", + "prices": [ + { + "provider": "xai", + "model": "tiny", + "input_usd_per_million": 0.3333333333333333, + "output_usd_per_million": 0, + "cache_read_usd_per_million": 0, + "cache_creation_usd_per_million": 0, + "request_usd": null + } + ], + "record": { + "provider": "xai", + "model": "tiny", + "failed": false, + "input_tokens": 7, + "output_tokens": 0, + "cached_tokens": 0, + "cache_read_tokens": 0, + "cache_creation_tokens": 0, + "total_tokens": 7 + }, + "want_usd": 2.33e-06, + "want_unpriced": false + } + ] +} diff --git a/backend/internal/usagecost/types.go b/backend/internal/usagecost/types.go new file mode 100644 index 00000000..271e00bb --- /dev/null +++ b/backend/internal/usagecost/types.go @@ -0,0 +1,52 @@ +package usagecost + +import ( + "errors" + + backendApp "cpa-helper/backend/internal/app" +) + +// ModelPrice is CPA-Helper's own price row type. The report does not get a +// second definition: two shapes for the same priced columns is how a report +// and the production billing branch drift apart. +type ModelPrice = backendApp.ModelPrice + +// PriceKey is (provider, model), both lowercased and trimmed -- the same shape +// CPA-Helper keys its price map by. It is an alias, not a new type: the price +// map must be passable to app.UsageRecordCost without a rebuild, or the wiring +// could silently hand it a different map than the one that was loaded. +type PriceKey = [2]string + +// Record is one usage row, narrowed to the fields the report reads. It stays +// narrow on purpose: the report's own contract (grouping dimensions plus the +// token counts cost depends on) is visible at a glance instead of being +// implied by a 30-field struct. The JSON tags are load-bearing: the golden +// vectors are stored snake_case, and untagged fields would silently decode to +// zero while still "passing" a compile. +type Record struct { + Provider *string `json:"provider"` + Model *string `json:"model"` + Endpoint *string `json:"endpoint"` + // SourceAccount is the upstream account the request was served by. It is + // not part of cost, only of attribution. + SourceAccount *string `json:"source_account"` + Failed bool `json:"failed"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CachedTokens int `json:"cached_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheCreationTokens int `json:"cache_creation_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` + TotalTokens int `json:"total_tokens"` +} + +// CostFunc computes one record's estimated cost. `unpriced` reports that the +// record consumed something billable but no price matched -- that is NOT the +// same as a cost of zero, and the report keeps the two apart so a missing price +// can never be read as free usage. +type CostFunc func(Record, map[PriceKey]ModelPrice) (usd float64, unpriced bool) + +// ErrNoCostFunc is returned instead of a number when no pricing implementation +// is available. "I cannot price this" must never leave the package looking +// like "this cost nothing". +var ErrNoCostFunc = errors.New("no pricing implementation is wired in") diff --git a/backend/internal/usagecost/wiring.go b/backend/internal/usagecost/wiring.go new file mode 100644 index 00000000..054b8243 --- /dev/null +++ b/backend/internal/usagecost/wiring.go @@ -0,0 +1,33 @@ +package usagecost + +import ( + backendApp "cpa-helper/backend/internal/app" +) + +// WiredCostFunc returns CPA-Helper's own cost derivation adapted to the +// report's narrow Record shape. Reusing app.UsageRecordCost (which wraps the +// unexported recordCost) is the whole point of living in this module: the +// report must produce the SAME number production recorded, and the only +// implementation that cannot drift from recordCost is recordCost itself. +func WiredCostFunc() CostFunc { return usageRecordCost } + +func usageRecordCost(record Record, prices map[PriceKey]ModelPrice) (float64, bool) { + return backendApp.UsageRecordCost(toUsageRecord(record), prices) +} + +func toUsageRecord(record Record) backendApp.UsageRecord { + return backendApp.UsageRecord{ + Provider: record.Provider, + Model: record.Model, + Endpoint: record.Endpoint, + SourceAccount: record.SourceAccount, + Failed: record.Failed, + InputTokens: record.InputTokens, + OutputTokens: record.OutputTokens, + CachedTokens: record.CachedTokens, + CacheReadTokens: record.CacheReadTokens, + CacheCreationTokens: record.CacheCreationTokens, + ReasoningTokens: record.ReasoningTokens, + TotalTokens: record.TotalTokens, + } +} From d6cf7b86801fb1612192efa02fc7bbf6a4513fa0 Mon Sep 17 00:00:00 2001 From: Jiacheng <artin@cat.ms> Date: Sun, 20 Sep 2026 19:12:29 +0800 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20account-runway=20=E5=8F=AA?= =?UTF-8?q?=E8=AF=BB=E5=AD=90=E5=91=BD=E4=BB=A4=EF=BC=88=E9=85=8D=E9=A2=9D?= =?UTF-8?q?=E7=BB=AD=E8=88=AA=E6=B5=8B=E7=AE=97+=E5=8A=A0=E5=8F=B7?= =?UTF-8?q?=E5=BB=BA=E8=AE=AE=EF=BC=89=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(account-runway): read-only per-account quota runway report * fix(usagecost): add busy_timeout to read-only DSN for delete-journal DB Same one-line class as accountrunway: production runs journal_mode=delete with frequent writer traffic, and a bare read-only open can hit SQLITE_BUSY(5) during a writer transaction. Wait up to 5s for the lock. --------- Co-authored-by: feiniu (Raft agent) <admin@oranix.io> --- backend/cmd/cpa-helper/main.go | 7 + backend/cmd/cpa-helper/main_test.go | 2 +- backend/internal/accountrunway/run.go | 164 ++++++ backend/internal/accountrunway/runway.go | 479 ++++++++++++++++++ backend/internal/accountrunway/runway_test.go | 313 ++++++++++++ backend/internal/accountrunway/store.go | 176 +++++++ backend/internal/accountrunway/store_test.go | 300 +++++++++++ backend/internal/accountrunway/table.go | 25 + backend/internal/app/usage_cost_export.go | 4 + backend/internal/usagecost/store.go | 2 +- 10 files changed, 1470 insertions(+), 2 deletions(-) create mode 100644 backend/internal/accountrunway/run.go create mode 100644 backend/internal/accountrunway/runway.go create mode 100644 backend/internal/accountrunway/runway_test.go create mode 100644 backend/internal/accountrunway/store.go create mode 100644 backend/internal/accountrunway/store_test.go create mode 100644 backend/internal/accountrunway/table.go diff --git a/backend/cmd/cpa-helper/main.go b/backend/cmd/cpa-helper/main.go index 22a7e96a..4f32cf84 100644 --- a/backend/cmd/cpa-helper/main.go +++ b/backend/cmd/cpa-helper/main.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "cpa-helper/backend/internal/accountrunway" backendApp "cpa-helper/backend/internal/app" "cpa-helper/backend/internal/httpserver" "cpa-helper/backend/internal/usagecost" @@ -92,6 +93,10 @@ func run(ctx context.Context, args []string, stdout io.Writer) error { // Read-only cost report over the same database the service writes to; // --db only overrides where it reads, never what it records. return usagecost.Run(ctx, args[1:], stdout) + case "account-runway": + // Read-only runway report over the same database the service writes to; + // --db only overrides where it reads, never what it records. + return accountrunway.Run(ctx, args[1:], stdout) case "help", "-h", "--help": printUsage(stdout) return nil @@ -158,5 +163,7 @@ func printUsage(w io.Writer) { cpa-helper usage-cost [--db path] --group-by model|provider|endpoint|source-account --since <days> [--json] Report usage cost over recorded usage (read-only) + cpa-helper account-runway [--db path] [--since days] [--provider antigravity|codex|all] [--json] + Estimate per-account quota runway until next reset (read-only) `) } diff --git a/backend/cmd/cpa-helper/main_test.go b/backend/cmd/cpa-helper/main_test.go index 7f0259b9..d0a74ad4 100644 --- a/backend/cmd/cpa-helper/main_test.go +++ b/backend/cmd/cpa-helper/main_test.go @@ -15,7 +15,7 @@ func TestRunHelpListsOperationalSubcommands(t *testing.T) { t.Fatalf("run help failed: %v", err) } text := output.String() - for _, want := range []string{"migrate", "serve", "doctor"} { + for _, want := range []string{"migrate", "serve", "doctor", "account-runway", "usage-cost"} { if !strings.Contains(text, want) { t.Fatalf("help output missing %q: %s", want, text) } diff --git a/backend/internal/accountrunway/run.go b/backend/internal/accountrunway/run.go new file mode 100644 index 00000000..8382663f --- /dev/null +++ b/backend/internal/accountrunway/run.go @@ -0,0 +1,164 @@ +package accountrunway + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "math" + "strconv" + "strings" + "time" + + backendApp "cpa-helper/backend/internal/app" +) + +// Options carries parsed account-runway flags. +type Options struct { + DBPath string + Since int // days + Provider string + JSON bool +} + +// ParseArgs parses the subcommand flags. `now` is injectable for tests. +func ParseArgs(args []string, now time.Time) (Options, error) { + _ = now + opts := Options{Since: 1, Provider: "all"} + fs := flag.NewFlagSet("account-runway", flag.ContinueOnError) + fs.StringVar(&opts.DBPath, "db", opts.DBPath, "SQLite database path") + fs.IntVar(&opts.Since, "since", opts.Since, "burn-rate window in days") + fs.StringVar(&opts.Provider, "provider", opts.Provider, "antigravity|codex|all") + fs.BoolVar(&opts.JSON, "json", false, "emit JSON") + if err := fs.Parse(args); err != nil { + return Options{}, err + } + if opts.Since <= 0 { + return Options{}, fmt.Errorf("--since must be a positive number of days, got %d", opts.Since) + } + switch opts.Provider { + case "antigravity", "codex", "all": + default: + return Options{}, fmt.Errorf("--provider must be antigravity, codex or all, got %q", opts.Provider) + } + if opts.DBPath == "" { + // Same resolution the service uses (CPA_HELPER_DATA_DIR, else + // <repo>/data): omitting --db must report on the database the service + // writes to, not fail for want of a path the user would only guess at. + p, err := backendApp.UsageDBPath() + if err != nil { + return Options{}, fmt.Errorf("--db not given and the default could not be resolved: %w", err) + } + opts.DBPath = p + } + return opts, nil +} + +// Run executes `cpa-helper account-runway <args>`. +func Run(ctx context.Context, args []string, out io.Writer) error { + return RunAt(ctx, args, out, time.Now().UTC()) +} + +// RunAt is Run with an injectable clock for tests. +func RunAt(ctx context.Context, args []string, out io.Writer, now time.Time) error { + opts, err := ParseArgs(args, now) + if err != nil { + return err + } + db, err := OpenReadOnly(ctx, opts.DBPath) + if err != nil { + return err + } + defer db.Close() + + accounts, err := LoadAccounts(ctx, db) + if err != nil { + return fmt.Errorf("load keeper accounts: %w", err) + } + rows, err := LoadUsageRows(ctx, db, now.Add(-time.Duration(opts.Since)*24*time.Hour)) + if err != nil { + return fmt.Errorf("load usage rows: %w", err) + } + report := Compute(accounts, rows, opts.Provider, opts.Since, now) + if opts.JSON { + return WriteJSON(out, report) + } + return WriteText(out, report) +} + +// WriteJSON emits the stable machine-readable report. +func WriteJSON(out io.Writer, report Report) error { + encoder := json.NewEncoder(out) + encoder.SetIndent("", " ") + return encoder.Encode(report) +} + +// WriteText renders the report as a terminal table. +func WriteText(out io.Writer, report Report) error { + w := newTableWriter(out) + w.row("ACCOUNT", "PROVIDER", "BUCKET", "REMAIN%", "RESET", "BURN/H", "RUNWAY_H", "COVERAGE", "STATUS") + for _, account := range report.Accounts { + name := account.Name + if account.Disabled { + name += " (DISABLED)" + } + for _, bucket := range account.Buckets { + w.row( + name, + account.Provider, + bucket.Name, + formatPercent(bucket.RemainingFraction), + formatReset(bucket.ResetAtText), + formatFloat(account.BurnPerHour, 0), + formatFloatPtr(bucket.RunwayHours, 1), + formatFloatPtr(bucket.Coverage, 2), + bucketStatusText(bucket), + ) + } + } + w.flush() + if report.UnattributedRows > 0 { + fmt.Fprintf(out, "\nunattributed usage: %d rows / %d tokens (no unique account match)\n", + report.UnattributedRows, report.UnattributedTokens) + } + fmt.Fprintf(out, "\npool: %d enabled / %d disabled accounts; recommendation: %s", + report.Pool.EnabledAccounts, report.Pool.DisabledAccounts, report.Pool.Recommendation) + if report.Pool.AdditionalAccounts > 0 { + fmt.Fprintf(out, " (add ~%d account(s), gap %.0f tokens)", report.Pool.AdditionalAccounts, report.Pool.GapTokens) + } + fmt.Fprintln(out) + return nil +} + +func formatPercent(fraction float64) string { + return strconv.FormatFloat(fraction*100, 'f', 0, 64) + "%" +} + +func formatReset(text string) string { + if text == "" { + return "-" + } + return text +} + +func formatFloat(value float64, precision int) string { + return strconv.FormatFloat(value, 'f', precision, 64) +} + +func formatFloatPtr(value *float64, precision int) string { + if value == nil { + return "-" + } + if math.IsInf(*value, 1) { + return "inf" + } + return strconv.FormatFloat(*value, 'f', precision, 64) +} + +func bucketStatusText(bucket BucketReport) string { + if len(bucket.Notes) == 0 { + return bucket.Status + } + return bucket.Status + " (" + strings.Join(bucket.Notes, ",") + ")" +} diff --git a/backend/internal/accountrunway/runway.go b/backend/internal/accountrunway/runway.go new file mode 100644 index 00000000..810c86be --- /dev/null +++ b/backend/internal/accountrunway/runway.go @@ -0,0 +1,479 @@ +package accountrunway + +import ( + "database/sql" + "encoding/json" + "math" + "regexp" + "sort" + "strings" + "time" + + backendApp "cpa-helper/backend/internal/app" +) + +// emailPattern mirrors app.usageEmailPattern: production writes source_account +// as the lowercased email extracted from the usage `source` field. +var emailPattern = regexp.MustCompile(`(?i)[a-z0-9._%+\-]+@[a-z0-9.\-]+\.[a-z]{2,}`) + +const ( + // Default window fallbacks when a codex account row carries no window + // seconds: the 5h primary bucket and the weekly secondary bucket. + codexPrimaryWindowFallbackSeconds = int64(18000) + codexSecondaryWindowFallbackSeconds = int64(604800) + secondsPerWeek = float64(604800) + + StatusHealthy = "HEALTHY" + StatusCritical = "CRITICAL" + StatusUnknown = "UNKNOWN" +) + +var statusRank = map[string]int{StatusCritical: 0, StatusUnknown: 1, StatusHealthy: 2} + +type antigravityGroup struct { + DisplayName string `json:"display_name"` + Description string `json:"description,omitempty"` + Buckets []antigravityBucket `json:"buckets"` +} + +type antigravityBucket struct { + BucketID string `json:"bucket_id"` + DisplayName string `json:"display_name"` + Window string `json:"window"` + RemainingFraction float64 `json:"remaining_fraction"` + ResetAt *time.Time `json:"reset_at"` + Description string `json:"description,omitempty"` +} + +// parseAntigravityQuota decodes the stored antigravity_quota JSON blob. NULL, +// empty, or malformed yields nil (no quota snapshot). +func parseAntigravityQuota(value sql.NullString) []antigravityGroup { + if !value.Valid || strings.TrimSpace(value.String) == "" { + return nil + } + var groups []antigravityGroup + if err := json.Unmarshal([]byte(value.String), &groups); err != nil { + return nil + } + return groups +} + +// BucketReport is the computed runway state of one quota window of one account. +type BucketReport struct { + Name string `json:"name"` + RemainingFraction float64 `json:"remaining_fraction"` + ResetAtText string `json:"reset_at,omitempty"` + WindowSeconds int64 `json:"window_seconds,omitempty"` + Cap *float64 `json:"cap_tokens,omitempty"` + RemainingTokens *float64 `json:"remaining_tokens,omitempty"` + RunwayHours *float64 `json:"runway_hours,omitempty"` + HoursUntilReset *float64 `json:"hours_until_reset,omitempty"` + Coverage *float64 `json:"coverage,omitempty"` + Status string `json:"status"` + Notes []string `json:"notes,omitempty"` + + resetAt *time.Time +} + +// AccountReport is one keeper account's runway summary. +type AccountReport struct { + Name string `json:"name"` + Email string `json:"email"` + Provider string `json:"provider"` + Disabled bool `json:"disabled"` + BurnTokens int64 `json:"burn_tokens"` + BurnPerHour float64 `json:"burn_per_hour"` + Status string `json:"status"` + Buckets []BucketReport `json:"buckets"` +} + +// Report is the full account-runway result. +type Report struct { + GeneratedAt string `json:"generated_at"` + SinceDays int `json:"since_days"` + Provider string `json:"provider"` + Accounts []AccountReport `json:"accounts"` + UnattributedRows int `json:"unattributed_rows"` + UnattributedTokens int64 `json:"unattributed_tokens"` + Pool PoolReport `json:"pool"` +} + +type PoolReport struct { + EnabledAccounts int `json:"enabled_accounts"` + DisabledAccounts int `json:"disabled_accounts"` + CriticalAccounts []string `json:"critical_accounts"` + GapTokens float64 `json:"gap_tokens"` + AvgWeeklyQuotaTokens *float64 `json:"avg_weekly_quota_tokens,omitempty"` + AdditionalAccounts int `json:"additional_accounts"` + Recommendation string `json:"recommendation"` +} + +// Compute builds the runway report from loaded rows. +func Compute(accounts []Account, rows []UsageRow, providerFilter string, sinceDays int, now time.Time) Report { + burn := attributeBurn(accounts, rows) + report := Report{ + GeneratedAt: backendApp.UsageDBTime(now), + SinceDays: sinceDays, + Provider: providerFilter, + } + windowHours := float64(sinceDays) * 24 + + for _, account := range accounts { + if providerFilter != "all" && account.Provider != providerFilter { + continue + } + ar := AccountReport{ + Name: account.Name, + Email: account.Email, + Provider: account.Provider, + Disabled: account.Disabled, + BurnTokens: burn.tokens[account.Name], + BurnPerHour: float64(burn.tokens[account.Name]) / windowHours, + } + ar.Buckets = bucketsForAccount(account, ar.BurnTokens, float64(sinceDays)*3600, now) + ar.Status = worstStatus(ar.Buckets) + report.Accounts = append(report.Accounts, ar) + } + sortAccounts(report.Accounts) + report.UnattributedRows = burn.rows + report.UnattributedTokens = burn.unattributedTokens + report.Pool = poolRecommendation(report.Accounts) + return report +} + +// poolRecommendation computes the quota gap (enabled accounts that run dry +// before their next reset) and how many extra accounts -- sized at the average +// enabled account's weekly-equivalent cap -- would cover it. +func poolRecommendation(accounts []AccountReport) PoolReport { + pool := PoolReport{} + var gap, weeklyCapSum float64 + var weeklyCapCount int + for _, ar := range accounts { + if ar.Disabled { + pool.DisabledAccounts++ + continue + } + pool.EnabledAccounts++ + if ar.Status == StatusCritical { + pool.CriticalAccounts = append(pool.CriticalAccounts, ar.Name) + } + var bestWeekly float64 + for _, b := range ar.Buckets { + if b.Cap != nil && b.WindowSeconds > 0 { + if weekly := *b.Cap * (secondsPerWeek / float64(b.WindowSeconds)); weekly > bestWeekly { + bestWeekly = weekly + } + } + if b.Status != StatusCritical || b.HoursUntilReset == nil || b.RemainingTokens == nil { + continue + } + need := ar.BurnPerHour * *b.HoursUntilReset + if need > *b.RemainingTokens { + gap += need - *b.RemainingTokens + } + } + if bestWeekly > 0 { + weeklyCapSum += bestWeekly + weeklyCapCount++ + } + } + pool.GapTokens = gap + if weeklyCapCount > 0 { + avg := weeklyCapSum / float64(weeklyCapCount) + pool.AvgWeeklyQuotaTokens = &avg + } + switch { + case gap <= 0: + pool.Recommendation = "pool covers all enabled accounts until next reset" + case pool.AvgWeeklyQuotaTokens == nil || *pool.AvgWeeklyQuotaTokens <= 0: + pool.Recommendation = "pool runs dry before next reset; additional accounts needed but average weekly quota is unknown" + default: + pool.AdditionalAccounts = int(math.Ceil(gap / *pool.AvgWeeklyQuotaTokens)) + pool.Recommendation = "pool runs dry before next reset; add accounts to cover the quota gap" + } + return pool +} + +type burnAttribution struct { + tokens map[string]int64 + rows int + unattributedTokens int64 +} + +// attributeBurn attributes usage rows to accounts the way production does +// (keeperAccountNameForUsageRecord): source_account (already the extracted +// email) wins; otherwise the email is extracted from `source`, else auth_index. +// A row whose key matches no account, or matches several, is unattributed. +func attributeBurn(accounts []Account, rows []UsageRow) burnAttribution { + type aliasSet struct { + byKey map[string]string + ambiguous map[string]bool + add func(key, name string) + } + newSet := func() *aliasSet { + set := &aliasSet{byKey: map[string]string{}, ambiguous: map[string]bool{}} + set.add = func(key, name string) { + key = strings.ToLower(strings.TrimSpace(key)) + if key == "" { + return + } + if existing, ok := set.byKey[key]; ok { + if existing != name { + set.ambiguous[key] = true + } + return + } + set.byKey[key] = name + } + return set + } + emails, indexes := newSet(), newSet() + for _, account := range accounts { + emails.add(account.Email, account.Name) + if match := emailPattern.FindString(account.Name); match != "" { + emails.add(match, account.Name) + } + indexes.add(account.Name, account.Name) + indexes.add(account.AuthIndex, account.Name) + } + + result := burnAttribution{tokens: map[string]int64{}} + unattributed := func(row UsageRow) { + result.rows++ + result.unattributedTokens += row.TotalTokens + } + for _, row := range rows { + email := strings.ToLower(strings.TrimSpace(row.SourceAccount)) + if email == "" { + email = strings.ToLower(emailPattern.FindString(row.Source)) + } + var name string + switch { + case email != "": + if emails.ambiguous[email] { + unattributed(row) + continue + } + n, ok := emails.byKey[email] + if !ok { + unattributed(row) + continue + } + name = n + case strings.TrimSpace(row.AuthIndex) != "": + index := strings.ToLower(strings.TrimSpace(row.AuthIndex)) + if indexes.ambiguous[index] { + unattributed(row) + continue + } + n, ok := indexes.byKey[index] + if !ok { + unattributed(row) + continue + } + name = n + default: + unattributed(row) + continue + } + result.tokens[name] += row.TotalTokens + } + return result +} + +func bucketsForAccount(account Account, burnTokens int64, burnWindowSeconds float64, now time.Time) []BucketReport { + if account.Provider == "antigravity" { + return antigravityBuckets(account, burnTokens, burnWindowSeconds, now) + } + return codexBuckets(account, burnTokens, burnWindowSeconds, now) +} + +func codexBuckets(account Account, burnTokens int64, burnWindowSeconds float64, now time.Time) []BucketReport { + out := []BucketReport{} + if account.PrimaryUsedPercent != nil { + window := codexPrimaryWindowFallbackSeconds + if account.PrimaryWindowSeconds != nil && *account.PrimaryWindowSeconds > 0 { + window = *account.PrimaryWindowSeconds + } + out = append(out, finishBucket(BucketReport{ + Name: "primary", + RemainingFraction: fractionFromPercent(*account.PrimaryUsedPercent), + WindowSeconds: window, + resetAt: account.PrimaryResetAt, + }, burnTokens, burnWindowSeconds, now)) + } + if account.SecondaryUsedPercent != nil { + window := codexSecondaryWindowFallbackSeconds + if account.SecondaryWindowSeconds != nil && *account.SecondaryWindowSeconds > 0 { + window = *account.SecondaryWindowSeconds + } + out = append(out, finishBucket(BucketReport{ + Name: "secondary", + RemainingFraction: fractionFromPercent(*account.SecondaryUsedPercent), + WindowSeconds: window, + resetAt: account.SecondaryResetAt, + }, burnTokens, burnWindowSeconds, now)) + } + if len(out) == 0 { + out = append(out, BucketReport{Name: "quota", Status: StatusUnknown, Notes: []string{"no quota snapshot recorded"}}) + } + return out +} + +func fractionFromPercent(used int64) float64 { + fraction := 1 - float64(used)/100 + return math.Max(0, math.Min(1, fraction)) +} + +func antigravityBuckets(account Account, burnTokens int64, burnWindowSeconds float64, now time.Time) []BucketReport { + out := []BucketReport{} + for _, group := range account.AntigravityGroups { + for _, bucket := range group.Buckets { + name := bucket.DisplayName + if name == "" { + name = bucket.BucketID + } + if name == "" { + name = "bucket" + } + if group.DisplayName != "" { + name = group.DisplayName + "/" + name + } + report := BucketReport{ + Name: name, + RemainingFraction: bucket.RemainingFraction, + resetAt: bucket.ResetAt, + } + window, ok := parseAntigravityWindow(bucket.Window) + if !ok { + report.Status = StatusUnknown + report.Notes = append(report.Notes, "window_unknown") + if bucket.ResetAt != nil { + report.ResetAtText = backendApp.UsageDBTime(*bucket.ResetAt) + } + out = append(out, report) + continue + } + report.WindowSeconds = window + out = append(out, finishBucket(report, burnTokens, burnWindowSeconds, now)) + } + } + if len(out) == 0 { + out = append(out, BucketReport{Name: "quota", Status: StatusUnknown, Notes: []string{"no quota snapshot recorded"}}) + } + return out +} + +// parseAntigravityWindow maps the bucket's `window` label to seconds. Known +// labels: named windows ("weekly", "monthly", "daily", "5h") and Go durations. +// Anything else is window_unknown -- excluded from runway math, still shown. +func parseAntigravityWindow(window string) (int64, bool) { + text := strings.ToLower(strings.TrimSpace(window)) + if text == "" { + return 0, false + } + switch { + case strings.Contains(text, "month"): + return 2592000, true + case strings.Contains(text, "week"): + return 604800, true + case strings.Contains(text, "day"): + return 86400, true + } + if d, err := time.ParseDuration(text); err == nil && d > 0 { + return int64(d.Seconds()), true + } + return 0, false +} + +// finishBucket fills in derived fields: reset text, hours until reset, cap +// estimate, runway hours, coverage, status. +// +// Cap estimation (方案 B): no absolute quota is stored, so the cap is inferred +// as consumed_in_window / (1 - remaining_fraction). The window's consumption is +// approximated from the observed burn: elapsed_in_window / burn_window of the +// measured tokens. Consumption of zero (or fraction == 1) makes the cap +// indeterminate -- marked cap_unknown rather than guessed. +func finishBucket(b BucketReport, burnTokens int64, burnWindowSeconds float64, now time.Time) BucketReport { + var elapsed float64 + if b.resetAt != nil { + b.ResetAtText = backendApp.UsageDBTime(*b.resetAt) + hours := b.resetAt.Sub(now).Hours() + b.HoursUntilReset = &hours + elapsed = float64(b.WindowSeconds) - b.resetAt.Sub(now).Seconds() + if elapsed < 0 { + // Reset lies further out than the window length: snapshot is + // inconsistent, treat the whole window as consumed-at-risk. + elapsed = float64(b.WindowSeconds) + } + if elapsed > float64(b.WindowSeconds) { + elapsed = float64(b.WindowSeconds) + } + } else { + b.Notes = append(b.Notes, "reset_unknown") + } + + var consumed float64 + if elapsed > 0 { + if elapsed <= burnWindowSeconds { + consumed = float64(burnTokens) + } else { + consumed = float64(burnTokens) * elapsed / burnWindowSeconds + // The cap estimate leans on scaling a short observation up to the + // elapsed window; flag it so readers weigh confidence accordingly. + b.Notes = append(b.Notes, "short_observation") + } + } + if consumed > 0 && b.RemainingFraction < 1 { + cap := consumed / (1 - b.RemainingFraction) + b.Cap = &cap + remaining := cap * b.RemainingFraction + b.RemainingTokens = &remaining + burnPerHour := float64(burnTokens) / (burnWindowSeconds / 3600) + runway := math.Inf(1) + if burnPerHour > 0 { + runway = remaining / burnPerHour + } + b.RunwayHours = &runway + if b.HoursUntilReset != nil && *b.HoursUntilReset > 0 { + coverage := runway / *b.HoursUntilReset + b.Coverage = &coverage + if coverage >= 1 { + b.Status = StatusHealthy + } else { + b.Status = StatusCritical + } + } else { + // Reset already due/passed: the window refreshes imminently, so the + // bucket is not what limits the account. + b.Status = StatusHealthy + } + } else { + b.Notes = append(b.Notes, "cap_unknown") + } + if b.Status == "" { + b.Status = StatusUnknown + } + return b +} + +func worstStatus(buckets []BucketReport) string { + worst := StatusHealthy + for _, b := range buckets { + if statusRank[b.Status] < statusRank[worst] { + worst = b.Status + } + } + return worst +} + +// sortAccounts orders reports by status severity then name for stable output. +func sortAccounts(accounts []AccountReport) { + sort.SliceStable(accounts, func(i, j int) bool { + ri, rj := statusRank[accounts[i].Status], statusRank[accounts[j].Status] + if ri != rj { + return ri < rj + } + return accounts[i].Name < accounts[j].Name + }) +} diff --git a/backend/internal/accountrunway/runway_test.go b/backend/internal/accountrunway/runway_test.go new file mode 100644 index 00000000..65f2abd9 --- /dev/null +++ b/backend/internal/accountrunway/runway_test.go @@ -0,0 +1,313 @@ +package accountrunway + +import ( + "bytes" + "context" + "encoding/json" + "math" + "testing" + "time" + + backendApp "cpa-helper/backend/internal/app" +) + +var fixedNow = time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) + +func codexAccount(name, email string, primaryUsed int64, reset time.Time, window int64) Account { + return Account{ + Name: name, + Email: email, + Provider: "codex", + PrimaryUsedPercent: i64(primaryUsed), + PrimaryResetAt: &reset, + PrimaryWindowSeconds: i64(window), + } +} + +// TestComputeBurnAttribution covers the production attribution order: +// source_account email wins, then email extracted from source, then auth_index; +// unmatched and ambiguous rows are counted as unattributed. +func TestComputeBurnAttribution(t *testing.T) { + idx := codexAccount("idx-7", "b@x.com", 50, fixedNow.Add(2*time.Hour), 18000) + idx.AuthIndex = "hash-zzz" + accounts := []Account{ + codexAccount("acct-a@x.com", "a@x.com", 50, fixedNow.Add(2*time.Hour), 18000), + idx, + } + rows := []UsageRow{ + {SourceAccount: "a@x.com", TotalTokens: 100}, // by source_account + {Source: "cli user b@x.com tail", TotalTokens: 40}, // email from source + {AuthIndex: "hash-zzz", TotalTokens: 20}, // via stored auth_index alias + {SourceAccount: "ghost@x.com", TotalTokens: 999}, // no match + {TotalTokens: 5}, // nothing to match on + {SourceAccount: "A@X.COM", TotalTokens: 10, Failed: true}, // case + failed still counts + } + report := Compute(accounts, rows, "all", 1, fixedNow) + + byName := map[string]AccountReport{} + for _, a := range report.Accounts { + byName[a.Name] = a + } + if got := byName["acct-a@x.com"].BurnTokens; got != 110 { + t.Fatalf("acct-a burn = %d, want 110 (100 + failed 10)", got) + } + if got := byName["idx-7"].BurnTokens; got != 60 { + t.Fatalf("idx-7 burn = %d, want 60 (40 via source email + 20 via stored auth_index alias)", got) + } + if report.UnattributedRows != 2 || report.UnattributedTokens != 1004 { + t.Fatalf("unattributed = %d rows / %d tokens, want 2 / 1004", + report.UnattributedRows, report.UnattributedTokens) + } +} + +// TestComputeAmbiguousEmailUnattributed: two accounts sharing an email alias +// must not silently absorb burn — rows keyed to the shared email are +// unattributed. +func TestComputeAmbiguousEmailUnattributed(t *testing.T) { + accounts := []Account{ + codexAccount("one", "shared@x.com", 50, fixedNow.Add(time.Hour), 18000), + codexAccount("two", "shared@x.com", 50, fixedNow.Add(time.Hour), 18000), + } + rows := []UsageRow{{SourceAccount: "shared@x.com", TotalTokens: 77}} + report := Compute(accounts, rows, "all", 1, fixedNow) + if report.UnattributedRows != 1 || report.UnattributedTokens != 77 { + t.Fatalf("ambiguous email should be unattributed: %+v", report) + } +} + +// TestComputeFractionOneIsCapUnknown guards the fraction==1 division: a bucket +// at 0% used has an indeterminate cap — UNKNOWN with cap_unknown, no Inf/NaN. +func TestComputeFractionOneIsCapUnknown(t *testing.T) { + accounts := []Account{ + codexAccount("fresh@x.com", "fresh@x.com", 0, fixedNow.Add(3*time.Hour), 18000), + } + rows := []UsageRow{{SourceAccount: "fresh@x.com", TotalTokens: 500}} + report := Compute(accounts, rows, "all", 1, fixedNow) + + b := report.Accounts[0].Buckets[0] + if b.Status != StatusUnknown { + t.Fatalf("status = %q, want UNKNOWN", b.Status) + } + if b.Cap != nil || b.RemainingTokens != nil || b.RunwayHours != nil { + t.Fatalf("fraction==1 must not produce cap/runway numbers: %+v", b) + } + found := false + for _, n := range b.Notes { + if n == "cap_unknown" { + found = true + } + } + if !found { + t.Fatalf("expected cap_unknown note, got %v", b.Notes) + } + if math.IsInf(b.RemainingFraction, 0) || math.IsNaN(b.RemainingFraction) { + t.Fatal("fraction became Inf/NaN") + } + if report.Accounts[0].Status != StatusUnknown { + t.Fatalf("account status = %q, want UNKNOWN", report.Accounts[0].Status) + } +} + +// TestComputeHealthyAndCritical: remaining tokens vs burn-until-reset decides +// status. Account "tight" burns so fast its bucket empties before reset. +func TestComputeHealthyAndCritical(t *testing.T) { + reset := fixedNow.Add(4 * time.Hour) + accounts := []Account{ + codexAccount("rich@x.com", "rich@x.com", 10, reset, 18000), // 90% left + codexAccount("tight@x.com", "tight@x.com", 95, reset, 18000), // 5% left + } + // 1-day window, both accounts burning the same absolute rate; the tight + // account's remaining sliver is what turns CRITICAL. + rows := []UsageRow{ + {SourceAccount: "rich@x.com", TotalTokens: 2400}, + {SourceAccount: "tight@x.com", TotalTokens: 2400}, + } + report := Compute(accounts, rows, "all", 1, fixedNow) + byName := map[string]AccountReport{} + for _, a := range report.Accounts { + byName[a.Name] = a + } + if byName["rich@x.com"].Status != StatusHealthy { + t.Fatalf("rich should be HEALTHY, got %q (%+v)", byName["rich@x.com"].Status, byName["rich@x.com"].Buckets[0]) + } + if byName["tight@x.com"].Status != StatusCritical { + t.Fatalf("tight should be CRITICAL, got %q (%+v)", byName["tight@x.com"].Status, byName["tight@x.com"].Buckets[0]) + } + // Sorted: critical first. + if report.Accounts[0].Name != "tight@x.com" { + t.Fatalf("critical account should sort first, got %q", report.Accounts[0].Name) + } + // Only the enabled critical account lands in pool.critical_accounts. + found := false + for _, n := range report.Pool.CriticalAccounts { + if n == "tight@x.com" { + found = true + } + } + if !found { + t.Fatalf("pool critical_accounts missing tight: %v", report.Pool.CriticalAccounts) + } + if report.Pool.GapTokens <= 0 { + t.Fatalf("expected positive gap, got %v", report.Pool.GapTokens) + } +} + +// TestComputeDisabledExcludedFromGap: disabled accounts are counted but never +// contribute to the pool gap or additional-accounts recommendation. +func TestComputeDisabledExcludedFromGap(t *testing.T) { + reset := fixedNow.Add(4 * time.Hour) + disabled := codexAccount("dead@x.com", "dead@x.com", 95, reset, 18000) + disabled.Disabled = true + accounts := []Account{ + codexAccount("rich@x.com", "rich@x.com", 10, reset, 18000), + disabled, + } + rows := []UsageRow{ + {SourceAccount: "rich@x.com", TotalTokens: 100}, + {SourceAccount: "dead@x.com", TotalTokens: 99999}, // huge burn, must not widen gap + } + report := Compute(accounts, rows, "all", 1, fixedNow) + + if report.Pool.DisabledAccounts != 1 || report.Pool.EnabledAccounts != 1 { + t.Fatalf("pool counts wrong: %+v", report.Pool) + } + for _, n := range report.Pool.CriticalAccounts { + if n == "dead@x.com" { + t.Fatal("disabled account listed as critical") + } + } + if report.Pool.AdditionalAccounts != 0 { + t.Fatalf("disabled account influenced additional_accounts: %+v", report.Pool) + } + if report.Pool.GapTokens != 0 { + t.Fatalf("disabled account contributed to gap: %v", report.Pool.GapTokens) + } +} + +// TestComputeProviderFilter keeps only the requested provider's accounts. +func TestComputeProviderFilter(t *testing.T) { + accounts := []Account{ + codexAccount("c@x.com", "c@x.com", 50, fixedNow.Add(time.Hour), 18000), + {Name: "a@x.com", Email: "a@x.com", Provider: "antigravity"}, + } + report := Compute(accounts, nil, "codex", 1, fixedNow) + if len(report.Accounts) != 1 || report.Accounts[0].Provider != "codex" { + t.Fatalf("provider filter leaked accounts: %+v", report.Accounts) + } +} + +// TestComputeAntigravityBuckets: groups/buckets decode to named buckets; +// an unparseable window label is shown but excluded from runway math. +func TestComputeAntigravityBuckets(t *testing.T) { + reset := fixedNow.Add(24 * time.Hour) + account := Account{ + Name: "anti@x.com", + Email: "anti@x.com", + Provider: "antigravity", + AntigravityGroups: []antigravityGroup{{ + DisplayName: "Gemini", + Buckets: []antigravityBucket{ + {BucketID: "w", DisplayName: "Weekly", Window: "weekly", RemainingFraction: 0.8, ResetAt: &reset}, + {BucketID: "x", DisplayName: "Odd", Window: "fortnightly-ish", RemainingFraction: 0.5}, + }, + }}, + } + report := Compute([]Account{account}, nil, "all", 1, fixedNow) + buckets := report.Accounts[0].Buckets + if len(buckets) != 2 { + t.Fatalf("expected 2 buckets, got %+v", buckets) + } + if buckets[0].Name != "Gemini/Weekly" || buckets[0].WindowSeconds != 604800 { + t.Fatalf("weekly bucket wrong: %+v", buckets[0]) + } + if buckets[1].Status != StatusUnknown { + t.Fatalf("unparseable window should be UNKNOWN, got %+v", buckets[1]) + } + hasWindowNote := false + for _, n := range buckets[1].Notes { + if n == "window_unknown" { + hasWindowNote = true + } + } + if !hasWindowNote { + t.Fatalf("expected window_unknown note, got %v", buckets[1].Notes) + } +} + +// TestRunAtGoldenJSON drives the full subcommand against a fixture DB with a +// fixed clock and asserts the decoded report fields. +func TestRunAtGoldenJSON(t *testing.T) { + writable, path := newFixtureDB(t) + reset := fixedNow.Add(4 * time.Hour) + insertAccount(t, writable, accountFixture{ + name: "solo@x.com", + email: "solo@x.com", + provider: "codex", + primaryUsed: i64(50), + primaryReset: &reset, + primaryWin: i64(18000), + }) + insertUsage(t, writable, usageFixture{at: fixedNow.Add(-time.Hour), sourceAccount: "solo@x.com", tokens: 1000}) + insertUsage(t, writable, usageFixture{at: fixedNow.Add(-30 * time.Hour), sourceAccount: "solo@x.com", tokens: 9999}) + if err := writable.Close(); err != nil { + t.Fatalf("close writable: %v", err) + } + + var out bytes.Buffer + err := RunAt(context.Background(), []string{"--db", path, "--since", "1", "--json"}, &out, fixedNow) + if err != nil { + t.Fatalf("RunAt: %v", err) + } + var report Report + if err := json.Unmarshal(out.Bytes(), &report); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, out.String()) + } + if report.GeneratedAt != backendApp.UsageDBTime(fixedNow) { + t.Fatalf("generated_at = %q, want %q", report.GeneratedAt, backendApp.UsageDBTime(fixedNow)) + } + if report.SinceDays != 1 || report.Provider != "all" { + t.Fatalf("report meta wrong: %+v", report) + } + if len(report.Accounts) != 1 { + t.Fatalf("expected 1 account, got %+v", report.Accounts) + } + acct := report.Accounts[0] + if acct.Name != "solo@x.com" || acct.BurnTokens != 1000 { + t.Fatalf("account wrong: %+v", acct) + } + if len(acct.Buckets) != 1 || acct.Buckets[0].Name != "primary" { + t.Fatalf("buckets wrong: %+v", acct.Buckets) + } + if acct.Buckets[0].Cap == nil { + t.Fatalf("expected a cap estimate: %+v", acct.Buckets[0]) + } + if report.UnattributedRows != 0 { + t.Fatalf("unexpected unattributed rows: %d", report.UnattributedRows) + } +} + +// TestRunAtTextOutput exercises the human-readable path end to end. +func TestRunAtTextOutput(t *testing.T) { + writable, path := newFixtureDB(t) + reset := fixedNow.Add(4 * time.Hour) + insertAccount(t, writable, accountFixture{ + name: "solo@x.com", + email: "solo@x.com", + primaryUsed: i64(50), + primaryReset: &reset, + }) + insertUsage(t, writable, usageFixture{at: fixedNow.Add(-time.Hour), sourceAccount: "solo@x.com", tokens: 100}) + if err := writable.Close(); err != nil { + t.Fatalf("close writable: %v", err) + } + var out bytes.Buffer + if err := RunAt(context.Background(), []string{"--db", path}, &out, fixedNow); err != nil { + t.Fatalf("RunAt: %v", err) + } + text := out.String() + for _, want := range []string{"ACCOUNT", "solo@x.com", "primary", "pool:"} { + if !bytes.Contains(out.Bytes(), []byte(want)) { + t.Fatalf("text output missing %q:\n%s", want, text) + } + } +} diff --git a/backend/internal/accountrunway/store.go b/backend/internal/accountrunway/store.go new file mode 100644 index 00000000..f87949fd --- /dev/null +++ b/backend/internal/accountrunway/store.go @@ -0,0 +1,176 @@ +package accountrunway + +import ( + "context" + "database/sql" + "fmt" + "time" + + backendApp "cpa-helper/backend/internal/app" + + _ "modernc.org/sqlite" +) + +// OpenReadOnly opens the CPA-Helper database for reporting only. +// +// Two independent guards, because this points at production data: the DSN asks +// SQLite for a read-only connection, and `query_only` is then asserted on the +// connection that was actually handed back. The second is not redundant -- a +// future change to the DSN (adding a parameter, switching helper) can quietly +// drop `mode=ro`, and without the assertion the first write would succeed +// instead of failing. +func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) { + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)", path)) + if err != nil { + return nil, err + } + // One connection: a pool would need the pragma re-asserted per connection, + // and this tool has no concurrency to gain from more. + db.SetMaxOpenConns(1) + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("open %s read-only: %w", path, err) + } + var queryOnly int + if err := db.QueryRowContext(ctx, "PRAGMA query_only").Scan(&queryOnly); err != nil { + db.Close() + return nil, fmt.Errorf("read query_only pragma: %w", err) + } + if queryOnly != 1 { + db.Close() + return nil, fmt.Errorf("refusing to continue: connection to %s is not query_only", path) + } + return db, nil +} + +// Account is one keeper account row (codex_keeper_auth_states) plus the +// decoded antigravity quota snapshot when the account is an antigravity one. +type Account struct { + Name string + Email string + AuthIndex string + Disabled bool + Provider string // "codex" (default) or "antigravity" + PrimaryUsedPercent *int64 + SecondaryUsedPercent *int64 + PrimaryResetAt *time.Time + SecondaryResetAt *time.Time + PrimaryWindowSeconds *int64 + SecondaryWindowSeconds *int64 + AntigravityGroups []antigravityGroup +} + +// UsageRow is a usage_records row inside the burn-rate window, carrying only +// what attribution and aggregation need. +type UsageRow struct { + SourceAccount string + Source string + AuthIndex string + TotalTokens int64 + Failed bool +} + +// LoadAccounts reads every keeper account. Provider NULL/empty normalises to +// "codex" -- rows predating multi-provider support are codex. +func LoadAccounts(ctx context.Context, db *sql.DB) ([]Account, error) { + rows, err := db.QueryContext(ctx, ` + SELECT auth_name, auth_index, email, disabled, provider, + primary_used_percent, secondary_used_percent, + primary_reset_at, secondary_reset_at, + primary_window_seconds, secondary_window_seconds, + antigravity_quota + FROM codex_keeper_auth_states + ORDER BY auth_name`) + if err != nil { + return nil, err + } + defer rows.Close() + + accounts := []Account{} + for rows.Next() { + var ( + account Account + authName, authIndex, email sql.NullString + disabled bool + provider, primaryReset, secondaryReset sql.NullString + primaryUsed, secondaryUsed sql.NullInt64 + primaryWindow, secondaryWindow sql.NullInt64 + antigravityQuota sql.NullString + ) + if err := rows.Scan(&authName, &authIndex, &email, &disabled, &provider, + &primaryUsed, &secondaryUsed, &primaryReset, &secondaryReset, + &primaryWindow, &secondaryWindow, &antigravityQuota); err != nil { + return nil, err + } + account.Name = nullableString(authName) + account.Email = nullableString(email) + account.AuthIndex = nullableString(authIndex) + account.Disabled = disabled + account.Provider = "codex" + if p := nullableString(provider); p != "" { + account.Provider = p + } + account.PrimaryUsedPercent = nullableInt(primaryUsed) + account.SecondaryUsedPercent = nullableInt(secondaryUsed) + account.PrimaryWindowSeconds = nullableInt(primaryWindow) + account.SecondaryWindowSeconds = nullableInt(secondaryWindow) + if t, ok := backendApp.UsageParseDBTime(primaryReset.String); primaryReset.Valid && ok { + account.PrimaryResetAt = &t + } + if t, ok := backendApp.UsageParseDBTime(secondaryReset.String); secondaryReset.Valid && ok { + account.SecondaryResetAt = &t + } + account.AntigravityGroups = parseAntigravityQuota(antigravityQuota) + accounts = append(accounts, account) + } + return accounts, rows.Err() +} + +// LoadUsageRows reads usage rows at or after `since`. The bound is the UsageDBTime() +// byte shape production writes to the TEXT `timestamp` column -- a time.Time +// bound would be serialised by the driver as "2006-01-02 15:04:05 +0000 UTC" +// (space separator), and ' ' < 'T' makes the lexicographic >= let older +// same-date rows through. Binding the same layout production writes is the +// only way the comparison means what it says. +func LoadUsageRows(ctx context.Context, db *sql.DB, since time.Time) ([]UsageRow, error) { + rows, err := db.QueryContext(ctx, ` + SELECT source_account, source, auth_index, total_tokens, failed + FROM usage_records + WHERE timestamp >= ? + ORDER BY id`, backendApp.UsageDBTime(since)) + if err != nil { + return nil, err + } + defer rows.Close() + + result := []UsageRow{} + for rows.Next() { + var ( + row UsageRow + sourceAccount, source, authIndex sql.NullString + ) + if err := rows.Scan(&sourceAccount, &source, &authIndex, &row.TotalTokens, &row.Failed); err != nil { + return nil, err + } + row.SourceAccount = nullableString(sourceAccount) + row.Source = nullableString(source) + row.AuthIndex = nullableString(authIndex) + result = append(result, row) + } + return result, rows.Err() +} + +func nullableString(v sql.NullString) string { + if !v.Valid { + return "" + } + return v.String +} + +func nullableInt(v sql.NullInt64) *int64 { + if !v.Valid { + return nil + } + value := v.Int64 + return &value +} diff --git a/backend/internal/accountrunway/store_test.go b/backend/internal/accountrunway/store_test.go new file mode 100644 index 00000000..f8d1c8bb --- /dev/null +++ b/backend/internal/accountrunway/store_test.go @@ -0,0 +1,300 @@ +package accountrunway + +import ( + "context" + "database/sql" + "fmt" + "path/filepath" + "testing" + "time" + + backendApp "cpa-helper/backend/internal/app" + + _ "modernc.org/sqlite" +) + +// newFixtureDB creates a writable database with the minimal schema the report +// queries touch, then returns its path. Rows must be inserted through the +// writable handle (via insertAccount / insertUsage) before OpenReadOnly reads. +func newFixtureDB(t *testing.T) (*sql.DB, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "cpa_helper.sqlite3") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatalf("open writable db: %v", err) + } + _, err = db.Exec(` + CREATE TABLE codex_keeper_auth_states ( + auth_name TEXT PRIMARY KEY, + auth_index TEXT, + email TEXT, + disabled BOOLEAN NOT NULL DEFAULT 0, + provider TEXT, + primary_used_percent INTEGER, + secondary_used_percent INTEGER, + primary_reset_at TEXT, + secondary_reset_at TEXT, + primary_window_seconds INTEGER, + secondary_window_seconds INTEGER, + antigravity_quota TEXT + ); + CREATE TABLE usage_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp DATETIME, + source_account TEXT, + source TEXT, + auth_index TEXT, + total_tokens INTEGER NOT NULL DEFAULT 0, + failed BOOLEAN NOT NULL DEFAULT 0 + )`) + if err != nil { + db.Close() + t.Fatalf("create schema: %v", err) + } + return db, path +} + +type accountFixture struct { + name string + authIndex string + email string + disabled bool + provider string + primaryUsed *int64 + secondaryUsed *int64 + primaryReset *time.Time + secondaryRst *time.Time + primaryWin *int64 + secondaryWin *int64 + antiQuota string +} + +func insertAccount(t *testing.T, db *sql.DB, a accountFixture) { + t.Helper() + var primaryReset, secondaryReset interface{} + if a.primaryReset != nil { + primaryReset = backendApp.UsageDBTime(*a.primaryReset) + } + if a.secondaryRst != nil { + secondaryReset = backendApp.UsageDBTime(*a.secondaryRst) + } + var provider interface{} + if a.provider != "" { + provider = a.provider + } + var authIndex interface{} + if a.authIndex != "" { + authIndex = a.authIndex + } + var quota interface{} + if a.antiQuota != "" { + quota = a.antiQuota + } + _, err := db.Exec(`INSERT INTO codex_keeper_auth_states + (auth_name, auth_index, email, disabled, provider, primary_used_percent, secondary_used_percent, + primary_reset_at, secondary_reset_at, primary_window_seconds, secondary_window_seconds, + antigravity_quota) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + a.name, authIndex, a.email, a.disabled, provider, a.primaryUsed, a.secondaryUsed, + primaryReset, secondaryReset, a.primaryWin, a.secondaryWin, quota) + if err != nil { + t.Fatalf("insert account %s: %v", a.name, err) + } +} + +type usageFixture struct { + at time.Time + sourceAccount string + source string + authIndex string + tokens int64 + failed bool +} + +func insertUsage(t *testing.T, db *sql.DB, u usageFixture) { + t.Helper() + var sourceAccount, source, authIndex interface{} + if u.sourceAccount != "" { + sourceAccount = u.sourceAccount + } + if u.source != "" { + source = u.source + } + if u.authIndex != "" { + authIndex = u.authIndex + } + _, err := db.Exec(`INSERT INTO usage_records + (timestamp, source_account, source, auth_index, total_tokens, failed) + VALUES (?,?,?,?,?,?)`, + backendApp.UsageDBTime(u.at), sourceAccount, source, authIndex, u.tokens, u.failed) + if err != nil { + t.Fatalf("insert usage: %v", err) + } +} + +func i64(v int64) *int64 { return &v } + +func TestOpenReadOnlyEnforcesQueryOnly(t *testing.T) { + writable, path := newFixtureDB(t) + if err := writable.Close(); err != nil { + t.Fatalf("close writable: %v", err) + } + + db, err := OpenReadOnly(context.Background(), path) + if err != nil { + t.Fatalf("OpenReadOnly: %v", err) + } + defer db.Close() + + if _, err := db.Exec(`INSERT INTO codex_keeper_auth_states (auth_name) VALUES ('x')`); err == nil { + t.Fatal("write succeeded on read-only connection") + } +} + +func TestOpenReadOnlyMissingDB(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing.sqlite3") + if _, err := OpenReadOnly(context.Background(), path); err == nil { + t.Fatal("OpenReadOnly accepted a missing database file") + } +} + +// TestLoadUsageRowsSinceBoundIsDbTime is the mutation-teeth test for the +// timestamp comparison. The trap row stores the production '+08:00' layout with +// the same UTC calendar date as the bound but an instant two hours older: a +// buggy time.Time bound serialises as 'YYYY-MM-DD HH:MM:SS +0000 UTC', and +// ' ' < 'T' makes the lexicographic >= wrongly include that row. The fixed +// UsageDBTime bind ('T' separator) excludes it. +func TestLoadUsageRowsSinceBoundIsDbTime(t *testing.T) { + writable, path := newFixtureDB(t) + now := time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC) + since := now.Add(-24 * time.Hour) + // Stored literal: bound instant minus 2h, formatted at +08:00 so its UTC + // calendar date matches the bound's -- the ' ' < 'T' same-date trap. + stored := since.Add(-2 * time.Hour).In(time.FixedZone("Asia/Shanghai", 8*60*60)).Format("2006-01-02T15:04:05-07:00") + if _, err := writable.Exec(`INSERT INTO usage_records (timestamp, source_account, total_tokens) VALUES ('` + stored + `','a@x.com',111)`); err != nil { + t.Fatalf("insert trap row: %v", err) + } + insertUsage(t, writable, usageFixture{at: now.Add(-12 * time.Hour), sourceAccount: "a@x.com", tokens: 222}) + if err := writable.Close(); err != nil { + t.Fatalf("close writable: %v", err) + } + + db, err := OpenReadOnly(context.Background(), path) + if err != nil { + t.Fatalf("OpenReadOnly: %v", err) + } + defer db.Close() + + rows, err := LoadUsageRows(context.Background(), db, since) + if err != nil { + t.Fatalf("LoadUsageRows: %v", err) + } + if len(rows) != 1 || rows[0].TotalTokens != 222 { + t.Fatalf("expected only the in-window row (222 tokens), got %+v", rows) + } +} + +func TestLoadAccountsNormalisesProviderAndParsesResets(t *testing.T) { + writable, path := newFixtureDB(t) + reset := time.Date(2026, 9, 21, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*60*60)) + insertAccount(t, writable, accountFixture{ + name: "codex-a@x.com", + email: "a@x.com", + primaryUsed: i64(40), + primaryReset: &reset, + primaryWin: i64(18000), + }) + insertAccount(t, writable, accountFixture{ + name: "anti-b@x.com", + email: "b@x.com", + provider: "antigravity", + antiQuota: `[{"display_name":"Gemini","buckets":[{"bucket_id":"b1","display_name":"Pro","window":"weekly","remaining_fraction":0.5,"reset_at":"2026-09-22T00:00:00+08:00"}]}]`, + }) + if err := writable.Close(); err != nil { + t.Fatalf("close writable: %v", err) + } + + db, err := OpenReadOnly(context.Background(), path) + if err != nil { + t.Fatalf("OpenReadOnly: %v", err) + } + defer db.Close() + + accounts, err := LoadAccounts(context.Background(), db) + if err != nil { + t.Fatalf("LoadAccounts: %v", err) + } + if len(accounts) != 2 { + t.Fatalf("expected 2 accounts, got %d", len(accounts)) + } + var codex, anti Account + for _, a := range accounts { + switch a.Name { + case "codex-a@x.com": + codex = a + case "anti-b@x.com": + anti = a + } + } + if codex.Provider != "codex" { + t.Fatalf("NULL provider should normalise to codex, got %q", codex.Provider) + } + if codex.PrimaryResetAt == nil || !codex.PrimaryResetAt.Equal(reset) { + t.Fatalf("primary reset not parsed: %+v", codex.PrimaryResetAt) + } + if anti.Provider != "antigravity" || len(anti.AntigravityGroups) != 1 { + t.Fatalf("antigravity account not decoded: %+v", anti) + } + if got := anti.AntigravityGroups[0].Buckets[0].RemainingFraction; got != 0.5 { + t.Fatalf("antigravity fraction = %v, want 0.5", got) + } +} + +func TestLoadAccountsMalformedQuotaYieldsNoGroups(t *testing.T) { + writable, path := newFixtureDB(t) + insertAccount(t, writable, accountFixture{ + name: "anti-bad@x.com", + provider: "antigravity", + antiQuota: `{not json`, + }) + if err := writable.Close(); err != nil { + t.Fatalf("close writable: %v", err) + } + db, err := OpenReadOnly(context.Background(), path) + if err != nil { + t.Fatalf("OpenReadOnly: %v", err) + } + defer db.Close() + accounts, err := LoadAccounts(context.Background(), db) + if err != nil { + t.Fatalf("LoadAccounts: %v", err) + } + if len(accounts) != 1 || len(accounts[0].AntigravityGroups) != 0 { + t.Fatalf("malformed quota should yield no groups: %+v", accounts) + } +} + +func TestParseArgsValidation(t *testing.T) { + if _, err := ParseArgs([]string{"--since", "0"}, time.Now()); err == nil { + t.Fatal("--since 0 accepted") + } + if _, err := ParseArgs([]string{"--provider", "claude"}, time.Now()); err == nil { + t.Fatal("unknown provider accepted") + } + opts, err := ParseArgs([]string{"--since", "7", "--provider", "codex", "--json"}, time.Now()) + if err != nil { + t.Fatalf("valid args rejected: %v", err) + } + if opts.Since != 7 || opts.Provider != "codex" || !opts.JSON { + t.Fatalf("parsed options wrong: %+v", opts) + } +} + +// Sanity check that UsageDBTime emits the 'T'-separated layout the bound relies on. +func TestDbTimeLayout(t *testing.T) { + ts := backendApp.UsageDBTime(time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC)) + want := fmt.Sprintf("2026-09-20T%02d:00:00", 12+8) // UTC+8 + if ts[:len(want)] != want { + t.Fatalf("UsageDBTime layout unexpected: %q", ts) + } +} diff --git a/backend/internal/accountrunway/table.go b/backend/internal/accountrunway/table.go new file mode 100644 index 00000000..05c92766 --- /dev/null +++ b/backend/internal/accountrunway/table.go @@ -0,0 +1,25 @@ +package accountrunway + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// tableWriter is a thin text/tabwriter helper for aligned terminal output. +type tableWriter struct { + w *tabwriter.Writer +} + +func newTableWriter(out io.Writer) *tableWriter { + return &tableWriter{w: tabwriter.NewWriter(out, 0, 4, 2, ' ', 0)} +} + +func (t *tableWriter) row(cells ...string) { + fmt.Fprintln(t.w, strings.Join(cells, "\t")) +} + +func (t *tableWriter) flush() { + _ = t.w.Flush() +} diff --git a/backend/internal/app/usage_cost_export.go b/backend/internal/app/usage_cost_export.go index 445c3111..284836a6 100644 --- a/backend/internal/app/usage_cost_export.go +++ b/backend/internal/app/usage_cost_export.go @@ -30,3 +30,7 @@ func UsageDBPath() (string, error) { } return paths.DBPath, nil } + +// UsageParseDBTime parses stored timestamps the same way production does +// (parseDBTime); *_reset_at columns on disk use the same layouts. +func UsageParseDBTime(value string) (time.Time, bool) { return parseDBTime(value) } diff --git a/backend/internal/usagecost/store.go b/backend/internal/usagecost/store.go index 8916c664..fab9d080 100644 --- a/backend/internal/usagecost/store.go +++ b/backend/internal/usagecost/store.go @@ -20,7 +20,7 @@ import ( // drop `mode=ro`, and without the assertion the first write would succeed // instead of failing. func OpenReadOnly(ctx context.Context, path string) (*sql.DB, error) { - db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)", path)) + db, err := sql.Open("sqlite", fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(1)&_pragma=busy_timeout(5000)", path)) if err != nil { return nil, err } From e0dfc0fc039688f4bd2af05b0f8e168758a4f794 Mon Sep 17 00:00:00 2001 From: "feiniu (Raft agent)" <admin@oranix.io> Date: Sun, 27 Sep 2026 16:20:23 +0000 Subject: [PATCH 25/25] ci(backend): add minimal go build+test CI for PRs --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..fd842e8f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + build-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v6 + with: + go-version-file: backend/go.mod + - name: Build + run: go build ./... + working-directory: backend + - name: Test + run: go test ./... + working-directory: backend