From 01bef5aaab2e32aa528b201d25939efd8561d1e0 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 16 Sep 2026 15:46:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20feat(pricing):=20surface=20newl?= =?UTF-8?q?y=20discovered=20models=20awaiting=20price=20sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add read-only runtime model pricing status endpoint in manager-server and low-friction model price attention notifications across Monitoring, Dashboard, and Usage Analytics. - manager-server: expose GET /v0/management/model-prices/runtime-models to discover unpriced models from CPA runtime without mutating database or calling external pricing sources - web: add modelPriceAttention store with localStorage isolation, 30m cache, and tab-focus refresh - web: add unified ModelPriceAttentionLink component and wire into MonitoringActionBar, Dashboard UsageMetricsCard, and Usage Analytics - web: update ModelPricesPage to support ?filter=missing, display pending sync badge, and acknowledge attention on sync - tests: add end-to-end unit and integration tests across server and web --- .../http/controller/modelprice/handler.go | 7 + .../controller/modelprice/handler_test.go | 67 ++++ .../internal/service/modelprice/service.go | 53 +++- .../service/modelprice/service_test.go | 209 +++++++++++++ .../components/UsageMetricsCard.module.scss | 7 + .../dashboard/components/UsageMetricsCard.tsx | 6 +- .../src/features/demo/demoFixtures.empty.ts | 6 + apps/web/src/features/demo/demoFixtures.ts | 10 + .../ModelPriceAttention.module.scss | 71 +++++ .../ModelPriceAttentionDot.tsx | 15 + .../ModelPriceAttentionLink.test.tsx | 158 ++++++++++ .../ModelPriceAttentionLink.tsx | 81 +++++ .../features/model-price-attention/index.ts | 6 + .../modelPriceAttention.test.ts | 260 ++++++++++++++++ .../modelPriceAttention.ts | 273 +++++++++++++++++ .../modelPriceAttentionStorage.test.ts | 85 ++++++ .../modelPriceAttentionStorage.ts | 90 ++++++ .../modelPriceAttentionTypes.ts | 18 ++ .../modelPriceAttentionUi.test.tsx | 286 ++++++++++++++++++ .../useModelPriceAttention.ts | 58 ++++ .../monitoring/ModelPricesPage.test.tsx | 178 +++++++++++ .../features/monitoring/ModelPricesPage.tsx | 57 +++- .../components/MonitoringActionBar.tsx | 16 +- .../model/modelPricesPageModel.test.ts | 17 ++ .../monitoring/model/modelPricesPageModel.ts | 7 +- .../UsageAnalyticsPage.module.scss | 7 + .../components/UsageSummaryCards.tsx | 8 +- apps/web/src/i18n/locales/en.json | 4 +- apps/web/src/i18n/locales/ru.json | 4 +- apps/web/src/i18n/locales/zh-CN.json | 4 +- apps/web/src/i18n/locales/zh-TW.json | 4 +- apps/web/src/services/api/usageService.ts | 30 ++ 32 files changed, 2072 insertions(+), 30 deletions(-) create mode 100644 apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss create mode 100644 apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx create mode 100644 apps/web/src/features/model-price-attention/ModelPriceAttentionLink.test.tsx create mode 100644 apps/web/src/features/model-price-attention/ModelPriceAttentionLink.tsx create mode 100644 apps/web/src/features/model-price-attention/index.ts create mode 100644 apps/web/src/features/model-price-attention/modelPriceAttention.test.ts create mode 100644 apps/web/src/features/model-price-attention/modelPriceAttention.ts create mode 100644 apps/web/src/features/model-price-attention/modelPriceAttentionStorage.test.ts create mode 100644 apps/web/src/features/model-price-attention/modelPriceAttentionStorage.ts create mode 100644 apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts create mode 100644 apps/web/src/features/model-price-attention/modelPriceAttentionUi.test.tsx create mode 100644 apps/web/src/features/model-price-attention/useModelPriceAttention.ts create mode 100644 apps/web/src/features/monitoring/ModelPricesPage.test.tsx diff --git a/apps/manager-server/internal/http/controller/modelprice/handler.go b/apps/manager-server/internal/http/controller/modelprice/handler.go index 1f2efb466..3a9c41ee0 100644 --- a/apps/manager-server/internal/http/controller/modelprice/handler.go +++ b/apps/manager-server/internal/http/controller/modelprice/handler.go @@ -24,6 +24,13 @@ func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) { path := strings.TrimRight(r.URL.Path, "/") switch { + case path == "/v0/management/model-prices/runtime-models" && r.Method == http.MethodGet: + status, err := h.App.ModelPriceService.RuntimeModelPricingStatus(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, err) + return + } + response.JSON(w, http.StatusOK, status) case path == "/v0/management/model-prices/usage-summary" && r.Method == http.MethodGet: summary, err := h.App.ModelPriceService.UsageSummary(r.Context(), h.App.Config.QueryLimit) if err != nil { diff --git a/apps/manager-server/internal/http/controller/modelprice/handler_test.go b/apps/manager-server/internal/http/controller/modelprice/handler_test.go index 66f66f947..1b2b9bdd8 100644 --- a/apps/manager-server/internal/http/controller/modelprice/handler_test.go +++ b/apps/manager-server/internal/http/controller/modelprice/handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" adminauthsvc "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/adminauth" modelpricesvc "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/modelprice" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/testutil" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) @@ -57,3 +58,69 @@ func TestHandleUsageSummaryUsesQueryLimitAndPanelAuthorization(t *testing.T) { t.Fatalf("models = %#v", summary.Models) } } + +type staticSetupResolver struct { + setup store.Setup +} + +func (r staticSetupResolver) ResolveSetup(ctx context.Context) (store.Setup, bool, error) { + return r.setup, true, nil +} + +func TestHandleRuntimeModels_AuthorizationAndResponse(t *testing.T) { + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"test-key"}, + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "m1"}, + {"id": "m2"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + cfg := testutil.NewConfig(t) + st := testutil.NewStore(t, cfg) + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + + handler := &Handler{App: &app.Context{ + Config: cfg, + AdminAuthService: adminauthsvc.New(cfg, st), + ModelPriceService: modelpricesvc.New(st, nil, resolver), + }} + + unauth := httptest.NewRecorder() + handler.Handle(unauth, httptest.NewRequest(http.MethodGet, "/v0/management/model-prices/runtime-models", nil)) + if unauth.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", unauth.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/v0/management/model-prices/runtime-models", nil) + req.Header.Set("Authorization", "Bearer "+testutil.AdminKey) + recorder := httptest.NewRecorder() + handler.Handle(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body: %s", recorder.Code, recorder.Body.String()) + } + + var status modelpricesvc.RuntimeModelPricingStatus + if err := json.NewDecoder(recorder.Body).Decode(&status); err != nil { + t.Fatalf("decode: %v", err) + } + if status.Count != 2 || status.UnpricedCount != 2 { + t.Fatalf("expected count=2 and unpricedCount=2, got %d, %d", status.Count, status.UnpricedCount) + } +} diff --git a/apps/manager-server/internal/service/modelprice/service.go b/apps/manager-server/internal/service/modelprice/service.go index ee3b1885a..426dd7b37 100644 --- a/apps/manager-server/internal/service/modelprice/service.go +++ b/apps/manager-server/internal/service/modelprice/service.go @@ -319,7 +319,7 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) discoveryTimeout = defaultRuntimeModelDiscoveryTimeout } discoveryCtx, cancel := context.WithTimeout(ctx, discoveryTimeout) - runtimeModels, err := s.discoverRuntimeModels(discoveryCtx) + runtimeModels, err := s.DiscoverRuntimeModels(discoveryCtx) cancel() if err != nil { @@ -403,7 +403,56 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) }, nil } -func (s *Service) discoverRuntimeModels(ctx context.Context) ([]string, error) { +type RuntimeModelPricingStatus struct { + Models []string `json:"models"` + UnpricedModels []string `json:"unpricedModels"` + Count int `json:"count"` + UnpricedCount int `json:"unpricedCount"` +} + +func (s *Service) RuntimeModelPricingStatus(ctx context.Context) (RuntimeModelPricingStatus, error) { + discoveryTimeout := s.runtimeModelDiscoveryTimeout + if discoveryTimeout <= 0 { + discoveryTimeout = defaultRuntimeModelDiscoveryTimeout + } + discoveryCtx, cancel := context.WithTimeout(ctx, discoveryTimeout) + models, err := s.DiscoverRuntimeModels(discoveryCtx) + cancel() + if err != nil { + return RuntimeModelPricingStatus{}, err + } + + prices, err := s.store.LoadModelPrices(ctx) + if err != nil { + return RuntimeModelPricingStatus{}, err + } + + normalizedModels := normalizedRequestedModels(models) + sort.Strings(normalizedModels) + + unpricedModels := make([]string, 0, len(normalizedModels)) + for _, m := range normalizedModels { + if _, exists := prices[m]; !exists { + unpricedModels = append(unpricedModels, m) + } + } + + if normalizedModels == nil { + normalizedModels = []string{} + } + if unpricedModels == nil { + unpricedModels = []string{} + } + + return RuntimeModelPricingStatus{ + Models: normalizedModels, + UnpricedModels: unpricedModels, + Count: len(normalizedModels), + UnpricedCount: len(unpricedModels), + }, nil +} + +func (s *Service) DiscoverRuntimeModels(ctx context.Context) ([]string, error) { if s.setupResolver == nil { return nil, errors.New("runtime model discovery failed: missing setup resolver") } diff --git a/apps/manager-server/internal/service/modelprice/service_test.go b/apps/manager-server/internal/service/modelprice/service_test.go index d276b5ce4..b4f3b3f4f 100644 --- a/apps/manager-server/internal/service/modelprice/service_test.go +++ b/apps/manager-server/internal/service/modelprice/service_test.go @@ -1844,3 +1844,212 @@ func TestSyncPreferredSourceFailurePreservationWithRuntimeModels(t *testing.T) { t.Fatalf("expected models.dev price to be preserved, got %#v", p) } } + +func TestRuntimeModelPricingStatus(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + initialPrices := map[string]store.ModelPrice{ + "synced-model": { + Prompt: 1.0, + Completion: 2.0, + Source: SyncSourceLiteLLM, + }, + "manual-model": { + Prompt: 0.5, + Completion: 1.5, + Source: "manual", + }, + } + if err := st.SaveModelPrices(context.Background(), initialPrices); err != nil { + t.Fatalf("save initial prices: %v", err) + } + + remoteCalled := false + remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + remoteCalled = true + http.Error(w, "should not call remote price sources", http.StatusInternalServerError) + })) + defer remoteServer.Close() + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"secret-key-1", "secret-key-2"}, + }) + case "/v1/models": + if r.Header.Get("Authorization") != "Bearer secret-key-1" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "zebra-model"}, + {"id": "synced-model"}, + {"id": "alpha-model"}, + {"id": "manual-model"}, + {"id": "alpha-model"}, + {"id": " "}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-secret-key", + }, + } + remoteURL := remoteServer.URL + svc := New(st, &remoteURL, resolver) + + status, err := svc.RuntimeModelPricingStatus(context.Background()) + if err != nil { + t.Fatalf("RuntimeModelPricingStatus failed: %v", err) + } + + if remoteCalled { + t.Fatal("RuntimeModelPricingStatus must not call remote price sources") + } + + expectedModels := []string{"alpha-model", "manual-model", "synced-model", "zebra-model"} + if len(status.Models) != len(expectedModels) { + t.Fatalf("expected %d models, got %d: %#v", len(expectedModels), len(status.Models), status.Models) + } + for i, m := range expectedModels { + if status.Models[i] != m { + t.Fatalf("expected model at %d to be %s, got %s", i, m, status.Models[i]) + } + } + if status.Count != 4 { + t.Fatalf("expected count 4, got %d", status.Count) + } + + expectedUnpriced := []string{"alpha-model", "zebra-model"} + if len(status.UnpricedModels) != len(expectedUnpriced) { + t.Fatalf("expected %d unpriced models, got %d: %#v", len(expectedUnpriced), len(status.UnpricedModels), status.UnpricedModels) + } + for i, m := range expectedUnpriced { + if status.UnpricedModels[i] != m { + t.Fatalf("expected unpriced model at %d to be %s, got %s", i, m, status.UnpricedModels[i]) + } + } + if status.UnpricedCount != 2 { + t.Fatalf("expected unpricedCount 2, got %d", status.UnpricedCount) + } + + storedPrices, err := st.LoadModelPrices(context.Background()) + if err != nil { + t.Fatalf("load prices: %v", err) + } + if len(storedPrices) != 2 { + t.Fatalf("expected 2 stored prices, got %d", len(storedPrices)) + } +} + +func TestRuntimeModelPricingStatus_AllUnpricedWhenNoPrices(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"test-key"}, + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "m1"}, + {"id": "m2"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + svc := New(st, nil, resolver) + + status, err := svc.RuntimeModelPricingStatus(context.Background()) + if err != nil { + t.Fatalf("RuntimeModelPricingStatus failed: %v", err) + } + if status.Count != 2 || status.UnpricedCount != 2 { + t.Fatalf("expected count=2 and unpricedCount=2, got count=%d, unpriced=%d", status.Count, status.UnpricedCount) + } + if len(status.UnpricedModels) != 2 || status.UnpricedModels[0] != "m1" || status.UnpricedModels[1] != "m2" { + t.Fatalf("unexpected unpriced models: %#v", status.UnpricedModels) + } +} + +func TestRuntimeModelPricingStatus_ZeroModels(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"test-key"}, + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{}, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + svc := New(st, nil, resolver) + + status, err := svc.RuntimeModelPricingStatus(context.Background()) + if err != nil { + t.Fatalf("RuntimeModelPricingStatus failed: %v", err) + } + if status.Count != 0 || status.UnpricedCount != 0 { + t.Fatalf("expected count=0, unpricedCount=0, got %d, %d", status.Count, status.UnpricedCount) + } + if status.Models == nil || status.UnpricedModels == nil { + t.Fatal("expected models and unpricedModels to be non-nil empty slices") + } +} + +func TestRuntimeModelPricingStatus_DiscoveryFailure(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "cpa internal error", http.StatusInternalServerError) + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + svc := New(st, nil, resolver) + + _, err := svc.RuntimeModelPricingStatus(context.Background()) + if err == nil { + t.Fatal("expected error on discovery failure, got nil") + } +} diff --git a/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss b/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss index 5679a61b6..59e04fb34 100644 --- a/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss +++ b/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss @@ -54,6 +54,13 @@ text-overflow: ellipsis; white-space: nowrap; } + + .metricExtraAction { + margin-left: auto; + display: flex; + align-items: center; + z-index: 2; + } } .metricBody { diff --git a/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx b/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx index 8c4dc4e8f..da44c9a8b 100644 --- a/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx +++ b/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx @@ -11,6 +11,7 @@ import { useThemeStore } from '@/stores'; import type { DashboardSummaryResponse } from '@/services/api/usageService'; import { getDataPalette } from '@/utils/dataPalette'; import { formatCompactNumber, formatDurationMs, formatUsd } from '@/utils/usage'; +import { ModelPriceAttentionLink } from '@/features/model-price-attention'; import styles from './UsageMetricsCard.module.scss'; interface UsageMetricsCardProps { @@ -43,17 +44,19 @@ interface MetricCardProps { icon: ReactNode; color: string; loading: boolean; + extraAction?: ReactNode; } type MetricStyle = CSSProperties & Record<'--accent-color', string>; type RankStyle = CSSProperties & Record<'--share', number>; -function MetricCard({ label, value, subValue, icon, color, loading }: MetricCardProps) { +function MetricCard({ label, value, subValue, icon, color, loading, extraAction }: MetricCardProps) { return (
{icon}
{label} + {extraAction ?
{extraAction}
: null}
{loading ? '...' : value}
@@ -125,6 +128,7 @@ export function UsageMetricsCard({ : undefined, icon: , color: dataPalette.amber, + extraAction: , }, { label: t('dashboard.success_rate'), diff --git a/apps/web/src/features/demo/demoFixtures.empty.ts b/apps/web/src/features/demo/demoFixtures.empty.ts index 64dacad39..9400ee599 100644 --- a/apps/web/src/features/demo/demoFixtures.empty.ts +++ b/apps/web/src/features/demo/demoFixtures.empty.ts @@ -35,6 +35,12 @@ export const getDemoModelPriceUsageSummary = () => ({ truncated: false, models: [], }); +export const getDemoRuntimeModelPricingStatus = () => ({ + models: [], + unpricedModels: [], + count: 0, + unpricedCount: 0, +}); export const getDemoUsagePayload = () => emptyObject; export const getDemoUsageServiceInfo = () => emptyObject; export const getDemoUsageServiceStatus = () => emptyObject; diff --git a/apps/web/src/features/demo/demoFixtures.ts b/apps/web/src/features/demo/demoFixtures.ts index 7d5e5565a..1ec4f0a08 100644 --- a/apps/web/src/features/demo/demoFixtures.ts +++ b/apps/web/src/features/demo/demoFixtures.ts @@ -16,6 +16,7 @@ import type { MonitoringAnalyticsRequest, MonitoringAnalyticsResponse, QuotaCooldownInfo, + RuntimeModelPricingStatusResponse, UsageHeaderSnapshotsResponse, UsageServiceInfo, UsageServiceStatus, @@ -5674,6 +5675,15 @@ export const getDemoAccountWindowUsage = ( }; export const getDemoModelPrices = () => clone(demoModelPrices); export const getDemoModelPriceUsageSummary = () => clone(demoModelPriceUsageSummary); +export const getDemoRuntimeModelPricingStatus = (): RuntimeModelPricingStatusResponse => { + const models = Object.keys(demoModelPrices.prices).sort(); + return { + models, + unpricedModels: [], + count: models.length, + unpricedCount: 0, + }; +}; export const getDemoUsagePayload = () => { const dashboard = dashboardBase(); return { diff --git a/apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss b/apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss new file mode 100644 index 000000000..840210b64 --- /dev/null +++ b/apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss @@ -0,0 +1,71 @@ +@use "@/styles/variables.scss" as *; + +.attentionDot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); + flex-shrink: 0; + vertical-align: middle; +} + +.inlineLink { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: #d97706; + background-color: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.25); + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; + white-space: nowrap; + + &:hover { + background-color: rgba(245, 158, 11, 0.18); + border-color: rgba(245, 158, 11, 0.4); + transform: translateY(-1px); + color: #b45309; + } + + &:focus-visible { + outline: 2px solid #f59e0b; + outline-offset: 1px; + } +} + +.pendingBadge { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + background-color: rgba(245, 158, 11, 0.12); + color: #d97706; + border: 1px solid rgba(245, 158, 11, 0.3); + margin-left: 6px; + vertical-align: middle; +} + +.syncButtonBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 11px; + font-weight: 700; + background-color: #f59e0b; + color: #ffffff; + margin-left: 6px; + line-height: 1; +} diff --git a/apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx b/apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx new file mode 100644 index 000000000..c278e0f7f --- /dev/null +++ b/apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx @@ -0,0 +1,15 @@ +import styles from './ModelPriceAttention.module.scss'; + +export interface ModelPriceAttentionDotProps { + className?: string; +} + +export function ModelPriceAttentionDot({ className }: ModelPriceAttentionDotProps) { + return ( +
@@ -265,6 +296,8 @@ export function ModelPricesPage() { filter === item ? styles.filterButtonActive : '' }`} onClick={() => setFilter(item)} + data-filter={item} + data-active={filter === item} > {t(`model_prices.filter_${item}`)} {filterCounts[item]} @@ -421,7 +454,17 @@ export function ModelPricesPage() {
- {row.model} + + {row.model} + {attention.pendingModels.includes(row.model) ? ( + + {t('model_prices.pending_sync_badge')} + + ) : null} + {!row.hasPrice && candidates.length > 0 ? ( {t('model_prices.needs_confirmation')} ) : !row.hasPrice ? ( diff --git a/apps/web/src/features/monitoring/components/MonitoringActionBar.tsx b/apps/web/src/features/monitoring/components/MonitoringActionBar.tsx index 56a16d203..b75a9561a 100644 --- a/apps/web/src/features/monitoring/components/MonitoringActionBar.tsx +++ b/apps/web/src/features/monitoring/components/MonitoringActionBar.tsx @@ -6,8 +6,8 @@ import { IconExternalLink, IconFileText, IconInbox, - IconSettings, } from '@/components/ui/icons'; +import { ModelPriceAttentionLink } from '@/features/model-price-attention'; import styles from '../MonitoringCenterPage.module.scss'; type MonitoringActionBarProps = { @@ -43,11 +43,6 @@ export function MonitoringActionBar({ onUsageImportChange, statusSummary, }: MonitoringActionBarProps) { - const modelPriceSettingsLabel = shortLabel( - t, - 'usage_stats.model_price_settings_short', - 'usage_stats.model_price_settings' - ); const accountActionsLabel = shortLabel(t, 'nav.account_actions_short', 'nav.account_actions'); return ( @@ -82,14 +77,7 @@ export function MonitoringActionBar({ {usageImporting ? t('common.loading') : t('usage_stats.import')} {modelPricesAvailable ? ( - - - {modelPriceSettingsLabel} - + ) : null} { ]); }); + it('includes runtime models without prior usage in row universe', () => { + const rows = buildModelPriceRows( + null, + {}, + [], + ['runtime-only-model'] + ); + expect(rows).toEqual([ + expect.objectContaining({ + model: 'runtime-only-model', + calls: 0, + hasPrice: false, + candidateCount: 0, + }), + ]); + }); + it('marks missing models with candidates before saved rows', () => { const rows = buildModelPriceRows( usageSummary, diff --git a/apps/web/src/features/monitoring/model/modelPricesPageModel.ts b/apps/web/src/features/monitoring/model/modelPricesPageModel.ts index 52372bdc0..d7cf8fcc3 100644 --- a/apps/web/src/features/monitoring/model/modelPricesPageModel.ts +++ b/apps/web/src/features/monitoring/model/modelPricesPageModel.ts @@ -151,7 +151,8 @@ export const buildCandidateMap = (candidateSets: ModelPriceSyncCandidateSet[] = export const buildModelPriceRows = ( summary: ModelPriceUsageSummaryResponse | null, prices: Record, - candidateSets: ModelPriceSyncCandidateSet[] = [] + candidateSets: ModelPriceSyncCandidateSet[] = [], + runtimeModels: string[] = [] ): ModelPriceRow[] => { const rowMap = new Map(); const candidateMap = buildCandidateMap(candidateSets); @@ -175,6 +176,10 @@ export const buildModelPriceRows = ( Object.keys(prices).forEach(ensureRow); candidateMap.forEach((_candidates, model) => ensureRow(model)); + runtimeModels.forEach((model) => { + const trimmed = typeof model === 'string' ? model.trim() : ''; + if (trimmed) ensureRow(trimmed); + }); summary?.models?.forEach((item) => { if (!item.model) return; diff --git a/apps/web/src/features/usage-analytics/UsageAnalyticsPage.module.scss b/apps/web/src/features/usage-analytics/UsageAnalyticsPage.module.scss index 06d38269a..74031f296 100644 --- a/apps/web/src/features/usage-analytics/UsageAnalyticsPage.module.scss +++ b/apps/web/src/features/usage-analytics/UsageAnalyticsPage.module.scss @@ -373,6 +373,13 @@ min-width: 0; } +.usageSummaryCardExtra { + margin-left: auto; + display: flex; + align-items: center; + z-index: 2; +} + .usageSummaryIcon { display: inline-flex; align-items: center; diff --git a/apps/web/src/features/usage-analytics/components/UsageSummaryCards.tsx b/apps/web/src/features/usage-analytics/components/UsageSummaryCards.tsx index feb812899..eabea91fa 100644 --- a/apps/web/src/features/usage-analytics/components/UsageSummaryCards.tsx +++ b/apps/web/src/features/usage-analytics/components/UsageSummaryCards.tsx @@ -21,6 +21,7 @@ import type { UsageSummaryCardAccent, UsageSummaryCardIcon, } from '../usageAnalyticsPresentation'; +import { ModelPriceAttentionLink } from '@/features/model-price-attention'; import styles from '../UsageAnalyticsPage.module.scss'; type UsageSummaryDensity = 'default' | 'compact'; @@ -51,7 +52,7 @@ const summaryAccentClassMap: Record = { teal: styles.summaryAccentTeal, }; -function UsageSummaryCardView({ +export function UsageSummaryCardView({ accent = 'blue', dataAttributes, density = 'default', @@ -90,6 +91,11 @@ function UsageSummaryCardView({ {label} + {icon === 'cost' ? ( + + + + ) : null}
diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 27987ce06..c82ebb8f3 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -2250,6 +2250,7 @@ "pricing_rules": "Pricing rules", "manual_clears_pricing_rules": "Saving a manual price removes {{count}} synchronized context or service-tier rule(s).", "needs_confirmation": "Candidate confirmation needed", + "pending_sync_badge": "New", "no_price": "No price set", "candidate_select": "Select candidate price", "confirm_candidate": "Confirm", @@ -2272,7 +2273,8 @@ "rate_30m": "Rate (last 30 min)", "model_name": "Model Name", "model_price_settings": "Model Pricing Settings", - "model_price_settings_short": "Pricing", + "model_price_settings_short": "Model Prices", + "model_price_attention_tooltip": "Discovered {{count}} new models awaiting price sync", "saved_prices": "Saved Prices", "requests_trend": "Request Trends", "tokens_trend": "Token Usage Trends", diff --git a/apps/web/src/i18n/locales/ru.json b/apps/web/src/i18n/locales/ru.json index 4b3f3b1f0..6b578d41b 100644 --- a/apps/web/src/i18n/locales/ru.json +++ b/apps/web/src/i18n/locales/ru.json @@ -2252,6 +2252,7 @@ "pricing_rules": "Правила тарификации", "manual_clears_pricing_rules": "Сохранение ручной цены удалит {{count}} синхронизированных контекстных правил или правил уровня обслуживания.", "needs_confirmation": "Нужно подтвердить цену", + "pending_sync_badge": "Новый", "no_price": "Цена не задана", "candidate_select": "Выберите цену", "confirm_candidate": "Подтвердить", @@ -2274,7 +2275,8 @@ "rate_30m": "Скорость (последние 30 мин)", "model_name": "Название модели", "model_price_settings": "Настройки стоимости моделей", - "model_price_settings_short": "Цены", + "model_price_settings_short": "Цены моделей", + "model_price_attention_tooltip": "Обнаружено моделей, ожидающих синхронизации цен: {{count}}", "saved_prices": "Сохранённые цены", "requests_trend": "Динамика запросов", "tokens_trend": "Динамика токенов", diff --git a/apps/web/src/i18n/locales/zh-CN.json b/apps/web/src/i18n/locales/zh-CN.json index da5efd8bf..5d7a4c933 100644 --- a/apps/web/src/i18n/locales/zh-CN.json +++ b/apps/web/src/i18n/locales/zh-CN.json @@ -2248,6 +2248,7 @@ "pricing_rules": "计费规则", "manual_clears_pricing_rules": "保存手动价格将移除 {{count}} 条已同步的上下文或服务层级规则。", "needs_confirmation": "需要确认价格", + "pending_sync_badge": "待同步", "no_price": "未设置价格", "candidate_select": "选择价格", "confirm_candidate": "确认", @@ -2270,7 +2271,8 @@ "rate_30m": "近30分钟速率", "model_name": "模型名称", "model_price_settings": "模型价格设置", - "model_price_settings_short": "价格", + "model_price_settings_short": "模型价格", + "model_price_attention_tooltip": "发现 {{count}} 个新模型待同步价格", "saved_prices": "已保存的价格", "requests_trend": "请求趋势", "tokens_trend": "Token 使用趋势", diff --git a/apps/web/src/i18n/locales/zh-TW.json b/apps/web/src/i18n/locales/zh-TW.json index f8e8d6500..1f16d7377 100644 --- a/apps/web/src/i18n/locales/zh-TW.json +++ b/apps/web/src/i18n/locales/zh-TW.json @@ -2248,6 +2248,7 @@ "pricing_rules": "計費規則", "manual_clears_pricing_rules": "儲存手動價格將移除 {{count}} 條已同步的上下文或服務層級規則。", "needs_confirmation": "需要確認價格", + "pending_sync_badge": "待同步", "no_price": "未設定價格", "candidate_select": "選擇價格", "confirm_candidate": "確認", @@ -2270,7 +2271,8 @@ "rate_30m": "近 30 分鐘速率", "model_name": "模型名稱", "model_price_settings": "模型定價設定", - "model_price_settings_short": "定價", + "model_price_settings_short": "模型定價", + "model_price_attention_tooltip": "發現 {{count}} 個新模型待同步價格", "saved_prices": "已儲存的定價", "requests_trend": "請求趨勢", "tokens_trend": "Token 使用趨勢", diff --git a/apps/web/src/services/api/usageService.ts b/apps/web/src/services/api/usageService.ts index 4d4517f3a..5f041e7d7 100644 --- a/apps/web/src/services/api/usageService.ts +++ b/apps/web/src/services/api/usageService.ts @@ -15,6 +15,7 @@ import { getDemoModelPrices, getDemoMonitoringAnalytics, getDemoQuotaCooldowns, + getDemoRuntimeModelPricingStatus, getDemoUsagePayload, getDemoUsageServiceInfo, getDemoUsageServiceStatus, @@ -440,6 +441,13 @@ export interface ModelPriceSyncResponse extends ModelPricesResponse { runtimeModelDiscoveryError?: string; } +export interface RuntimeModelPricingStatusResponse { + models: string[]; + unpricedModels: string[]; + count: number; + unpricedCount: number; +} + export interface ApiKeyAlias { apiKeyHash: string; alias: string; @@ -2950,6 +2958,28 @@ export const usageServiceApi = { }); }, + getRuntimeModelPricingStatus: async ( + base: string, + managementKey?: string, + signal?: AbortSignal + ): Promise => { + if (__DEMO_SITE__ && isDemoMode()) { + return getDemoRuntimeModelPricingStatus(); + } + + return withUsageServiceError(async () => { + const response = await axios.get( + buildUrl(base, '/v0/management/model-prices/runtime-models'), + { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + signal, + } + ); + return response.data; + }); + }, + saveModelPrices: async ( base: string, prices: Record, From c87ac88b2549524cac609a896645bce185f651a3 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 16 Sep 2026 16:09:03 +0800 Subject: [PATCH 2/2] fix(pricing): harden model price attention scope isolation, fallback acknowledgment, and deep-link filter - only acknowledge pending models that actually participated in fallback sync - add scope generation guard against cross-connection races and stale in-flight responses - bind attention snapshots to manager server scope - throttle failed discovery retry attempts to 30 minutes - decouple ?filter=missing from ongoing tab selection in ModelPricesPage - trigger background forced attention re-check after saving manual model price - add regression tests covering fallback acknowledgment, scope switches, and throttle --- .../features/model-price-attention/index.ts | 1 + .../modelPriceAttention.test.ts | 159 +++++++++++++++- .../modelPriceAttention.ts | 92 ++++++--- .../modelPriceAttentionTypes.ts | 5 + .../modelPriceAttentionUi.test.tsx | 11 +- ...AcknowledgedPendingModelsAfterSync.test.ts | 70 +++++++ ...solveAcknowledgedPendingModelsAfterSync.ts | 47 +++++ .../useModelPriceAttention.ts | 12 +- .../monitoring/ModelPricesPage.test.tsx | 176 +++++++++++++++++- .../features/monitoring/ModelPricesPage.tsx | 32 ++-- 10 files changed, 559 insertions(+), 46 deletions(-) create mode 100644 apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.test.ts create mode 100644 apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.ts diff --git a/apps/web/src/features/model-price-attention/index.ts b/apps/web/src/features/model-price-attention/index.ts index 659549ff3..8e053fcf6 100644 --- a/apps/web/src/features/model-price-attention/index.ts +++ b/apps/web/src/features/model-price-attention/index.ts @@ -4,3 +4,4 @@ export * from './modelPriceAttention'; export * from './useModelPriceAttention'; export * from './ModelPriceAttentionDot'; export * from './ModelPriceAttentionLink'; +export * from './resolveAcknowledgedPendingModelsAfterSync'; diff --git a/apps/web/src/features/model-price-attention/modelPriceAttention.test.ts b/apps/web/src/features/model-price-attention/modelPriceAttention.test.ts index 437403f66..28fe54253 100644 --- a/apps/web/src/features/model-price-attention/modelPriceAttention.test.ts +++ b/apps/web/src/features/model-price-attention/modelPriceAttention.test.ts @@ -135,7 +135,10 @@ describe('ModelPriceAttentionStore', () => { // Capture snapshot at sync start const snapshot = store.capturePendingSnapshot(); - expect(snapshot).toEqual(['gpt-6-sol']); + expect(snapshot).toEqual({ + scope: base, + models: ['gpt-6-sol'], + }); // Simulate successful sync completion: status refreshed and now gpt-6-sol may be priced or unpriced (unmatched) mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({ @@ -257,4 +260,158 @@ describe('ModelPriceAttentionStore', () => { // Still acknowledged, no pending notification! expect(store.getState().pendingModels).toEqual([]); }); + + describe('scope management and async race guards', () => { + it('resets volatile state immediately when scope changes, allowing fresh check on new scope', async () => { + const store = new ModelPriceAttentionStore({ + base: 'http://server-a', + storage, + api: mockApi, + }); + + mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({ + models: ['model-a'], + unpricedModels: ['model-a'], + count: 1, + unpricedCount: 1, + }); + await store.check({ force: true }); + + const stateA = store.getState(); + expect(stateA.runtimeModels).toEqual(['model-a']); + expect(stateA.pendingModels).toEqual(['model-a']); + expect(stateA.lastCheckedAtMs).not.toBeNull(); + + // Configure to server-b + store.configure({ + base: 'http://server-b', + modelPricesAvailable: true, + }); + + // Volatile state should be reset immediately + const stateAfterSwitch = store.getState(); + expect(stateAfterSwitch.runtimeModels).toEqual([]); + expect(stateAfterSwitch.unpricedModels).toEqual([]); + expect(stateAfterSwitch.pendingModels).toEqual([]); + expect(stateAfterSwitch.lastCheckedAtMs).toBeNull(); + expect(stateAfterSwitch.loading).toBe(false); + + // Fresh check on server-b should succeed immediately without force + mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({ + models: ['model-b'], + unpricedModels: ['model-b'], + count: 1, + unpricedCount: 1, + }); + await store.check(); + + const stateB = store.getState(); + expect(stateB.runtimeModels).toEqual(['model-b']); + expect(stateB.pendingModels).toEqual(['model-b']); + }); + + it('ignores stale response when scope switches while check is in-flight', async () => { + const store = new ModelPriceAttentionStore({ + base: 'http://server-a', + storage, + api: mockApi, + }); + + let resolveServerA: (value: RuntimeModelPricingStatusResponse) => void; + const pendingServerA = new Promise((resolve) => { + resolveServerA = resolve; + }); + mockApi.getRuntimeModelPricingStatus.mockReturnValueOnce(pendingServerA); + + // Start check on server-a + const checkA = store.check({ force: true }); + + // User switches to server-b while check on server-a is in flight + store.configure({ + base: 'http://server-b', + modelPricesAvailable: true, + }); + + // Server A finally resolves + resolveServerA!({ + models: ['model-from-a'], + unpricedModels: ['model-from-a'], + count: 1, + unpricedCount: 1, + }); + await checkA; + + // Server B's state should NOT be modified by server A's response + const stateB = store.getState(); + expect(stateB.runtimeModels).toEqual([]); + expect(stateB.pendingModels).toEqual([]); + expect(stateB.lastCheckedAtMs).toBeNull(); + }); + + it('ignores snapshot acknowledgment if snapshot belongs to an older scope', async () => { + const store = new ModelPriceAttentionStore({ + base: 'http://server-a', + storage, + api: mockApi, + }); + + mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({ + models: ['model-a'], + unpricedModels: ['model-a'], + count: 1, + unpricedCount: 1, + }); + await store.check({ force: true }); + + const snapshotA = store.capturePendingSnapshot(); + expect(snapshotA).toEqual({ + scope: 'http://server-a', + models: ['model-a'], + }); + + // Switch to server-b + store.configure({ + base: 'http://server-b', + modelPricesAvailable: true, + }); + + // Acknowledging snapshotA on server-b should do nothing + await store.acknowledgeSnapshot(snapshotA); + + expect(store.getState().acknowledgedModels).toEqual([]); + }); + }); + + describe('failed discovery retry throttling', () => { + it('throttles automatic re-checks for 30 minutes after a failed discovery attempt, while force bypasses throttle', async () => { + const store = new ModelPriceAttentionStore({ + base: 'http://localhost:18317', + storage, + api: mockApi, + checkIntervalMs: 30 * 60 * 1000, + }); + + // 1. Initial attempt fails + mockApi.getRuntimeModelPricingStatus.mockRejectedValueOnce(new Error('Network error')); + await store.check(); + expect(mockApi.getRuntimeModelPricingStatus).toHaveBeenCalledTimes(1); + expect(store.getState().lastCheckedAtMs).toBeNull(); + + // 2. Regular check 1 minute later should be throttled because lastAttemptAtMs is fresh + await store.check(); + expect(mockApi.getRuntimeModelPricingStatus).toHaveBeenCalledTimes(1); + + // 3. Force check bypasses throttle + mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({ + models: ['recovered-model'], + unpricedModels: ['recovered-model'], + count: 1, + unpricedCount: 1, + }); + await store.check({ force: true }); + expect(mockApi.getRuntimeModelPricingStatus).toHaveBeenCalledTimes(2); + expect(store.getState().pendingModels).toEqual(['recovered-model']); + expect(store.getState().lastCheckedAtMs).not.toBeNull(); + }); + }); }); diff --git a/apps/web/src/features/model-price-attention/modelPriceAttention.ts b/apps/web/src/features/model-price-attention/modelPriceAttention.ts index 112addba3..29bebb459 100644 --- a/apps/web/src/features/model-price-attention/modelPriceAttention.ts +++ b/apps/web/src/features/model-price-attention/modelPriceAttention.ts @@ -1,5 +1,8 @@ import { usageServiceApi } from '@/services/api/usageService'; -import type { ModelPriceAttentionState } from './modelPriceAttentionTypes'; +import type { + ModelPriceAttentionState, + ModelPriceAttentionSnapshot, +} from './modelPriceAttentionTypes'; import { loadAcknowledgedModels, saveAcknowledgedModels, @@ -22,6 +25,9 @@ export class ModelPriceAttentionStore { private api: Pick; private checkIntervalMs: number; + private scopeGeneration = 0; + private lastAttemptAtMs: number | null = null; + private state: ModelPriceAttentionState = { runtimeModels: [], unpricedModels: [], @@ -55,11 +61,23 @@ export class ModelPriceAttentionStore { modelPricesAvailable: boolean; }): void { const baseChanged = this.base !== options.base; - this.base = options.base; - this.managementKey = options.managementKey; - if (baseChanged) { - this.initAcknowledgedFromStorage(); + this.scopeGeneration += 1; + this.base = options.base; + this.managementKey = options.managementKey; + this.lastAttemptAtMs = null; + this.activeCheckPromise = null; + this.state = { + runtimeModels: [], + unpricedModels: [], + pendingModels: [], + loading: false, + lastCheckedAtMs: null, + acknowledgedModels: this.base ? loadAcknowledgedModels(this.base, this.storage) : [], + }; + this.notify(); + } else { + this.managementKey = options.managementKey; } if (!options.modelPricesAvailable || !this.base) { @@ -68,10 +86,6 @@ export class ModelPriceAttentionStore { } this.ensureAutoCheck(); - // Trigger initial check if stale or never checked - if (this.isCacheExpired()) { - void this.check(); - } } public getState(): ModelPriceAttentionState { @@ -112,8 +126,8 @@ export class ModelPriceAttentionStore { } public isCacheExpired(): boolean { - if (this.state.lastCheckedAtMs === null) return true; - return Date.now() - this.state.lastCheckedAtMs >= this.checkIntervalMs; + if (this.lastAttemptAtMs === null) return true; + return Date.now() - this.lastAttemptAtMs >= this.checkIntervalMs; } public check(options?: { force?: boolean }): Promise { @@ -130,22 +144,35 @@ export class ModelPriceAttentionStore { return this.activeCheckPromise; } + const requestGeneration = this.scopeGeneration; + const requestBase = this.base; + const requestManagementKey = this.managementKey; + + this.lastAttemptAtMs = Date.now(); this.state = { ...this.state, loading: true }; this.notify(); this.activeCheckPromise = (async () => { try { const res = await this.api.getRuntimeModelPricingStatus( - this.base, - this.managementKey + requestBase, + requestManagementKey ); + // Discard stale response if scope has switched + if ( + requestGeneration !== this.scopeGeneration || + requestBase !== this.base + ) { + return; + } + const runtimeModels = Array.isArray(res.models) ? res.models : []; const unpricedModels = Array.isArray(res.unpricedModels) ? res.unpricedModels : []; // Invariant: Already priced runtime models (runtimeModels \ unpricedModels) // are automatically treated as acknowledged. - const currentAck = new Set(loadAcknowledgedModels(this.base, this.storage)); + const currentAck = new Set(loadAcknowledgedModels(requestBase, this.storage)); const unpricedSet = new Set(unpricedModels); for (const model of runtimeModels) { if (!unpricedSet.has(model)) { @@ -154,7 +181,7 @@ export class ModelPriceAttentionStore { } const nextAcknowledged = Array.from(currentAck).sort(); - saveAcknowledgedModels(this.base, nextAcknowledged, this.storage); + saveAcknowledgedModels(requestBase, nextAcknowledged, this.storage); const pending = this.computePending(unpricedModels, nextAcknowledged); @@ -168,6 +195,12 @@ export class ModelPriceAttentionStore { }; this.notify(); } catch { + if ( + requestGeneration !== this.scopeGeneration || + requestBase !== this.base + ) { + return; + } // Discovery failure: do not toast, do not clear previous pending state this.state = { ...this.state, @@ -175,24 +208,39 @@ export class ModelPriceAttentionStore { }; this.notify(); } finally { - this.activeCheckPromise = null; + if (requestGeneration === this.scopeGeneration) { + this.activeCheckPromise = null; + } } })(); return this.activeCheckPromise; } - public capturePendingSnapshot(): string[] { - return [...this.state.pendingModels]; + public capturePendingSnapshot(): ModelPriceAttentionSnapshot { + return { + scope: this.base, + models: [...this.state.pendingModels], + }; } - public async acknowledgeSnapshot(snapshot: string[]): Promise { - if (!this.base || !snapshot || snapshot.length === 0) { + public async acknowledgeSnapshot( + snapshot: ModelPriceAttentionSnapshot | string[] + ): Promise { + const isArray = Array.isArray(snapshot); + const snapshotScope = isArray ? this.base : snapshot?.scope; + const modelsToAck = isArray ? snapshot : (snapshot?.models ?? []); + + if (!snapshotScope || snapshotScope !== this.base) { + return; + } + + if (!modelsToAck || modelsToAck.length === 0) { return; } const currentAck = new Set(loadAcknowledgedModels(this.base, this.storage)); - snapshot.forEach((m) => { + modelsToAck.forEach((m) => { if (m && m.trim()) { currentAck.add(m.trim()); } @@ -254,8 +302,10 @@ export class ModelPriceAttentionStore { public reset(): void { this.stopAutoCheck(); + this.scopeGeneration += 1; this.base = ''; this.managementKey = undefined; + this.lastAttemptAtMs = null; this.state = { runtimeModels: [], unpricedModels: [], diff --git a/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts b/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts index bb009128d..f60e649a3 100644 --- a/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts +++ b/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts @@ -16,3 +16,8 @@ export interface ModelPriceAttentionStorageData { } >; } + +export interface ModelPriceAttentionSnapshot { + scope: string; + models: string[]; +} diff --git a/apps/web/src/features/model-price-attention/modelPriceAttentionUi.test.tsx b/apps/web/src/features/model-price-attention/modelPriceAttentionUi.test.tsx index b7422e897..a8414dd6c 100644 --- a/apps/web/src/features/model-price-attention/modelPriceAttentionUi.test.tsx +++ b/apps/web/src/features/model-price-attention/modelPriceAttentionUi.test.tsx @@ -45,6 +45,8 @@ vi.mock('react-i18next', async (importOriginal) => { }; }); +import type { ModelPriceAttentionSnapshot } from './modelPriceAttentionTypes'; + describe('ModelPriceAttention UI Integration', () => { let mockAttentionState: { runtimeModels: string[]; @@ -57,8 +59,8 @@ describe('ModelPriceAttention UI Integration', () => { loading: boolean; lastCheckedAtMs: number | null; check: () => Promise; - capturePendingSnapshot: () => string[]; - acknowledgeSnapshot: (models: string[]) => Promise; + capturePendingSnapshot: () => ModelPriceAttentionSnapshot; + acknowledgeSnapshot: (snapshot: ModelPriceAttentionSnapshot | string[]) => Promise; }; beforeEach(() => { @@ -73,7 +75,10 @@ describe('ModelPriceAttention UI Integration', () => { loading: false, lastCheckedAtMs: Date.now(), check: vi.fn(async () => {}), - capturePendingSnapshot: vi.fn(() => []), + capturePendingSnapshot: vi.fn(() => ({ + scope: 'http://localhost:18317', + models: [], + })), acknowledgeSnapshot: vi.fn(async () => {}), }; diff --git a/apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.test.ts b/apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.test.ts new file mode 100644 index 000000000..5fcb79c7c --- /dev/null +++ b/apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { resolveAcknowledgedPendingModelsAfterSync } from './resolveAcknowledgedPendingModelsAfterSync'; + +describe('resolveAcknowledgedPendingModelsAfterSync', () => { + const scope = 'http://localhost:18317'; + + it('acknowledges all pending snapshot models when runtime discovery succeeds', () => { + const result = resolveAcknowledgedPendingModelsAfterSync({ + pendingSnapshot: { + scope, + models: ['model-A', 'model-B'], + }, + syncModels: ['model-A'], + runtimeModelDiscoveryError: null, + }); + + expect(result).toEqual({ + scope, + models: ['model-A', 'model-B'], + }); + }); + + it('acknowledges only (pendingSnapshot ∩ syncModels) when runtime discovery fails', () => { + const result = resolveAcknowledgedPendingModelsAfterSync({ + pendingSnapshot: { + scope, + models: ['model-A', 'model-B'], + }, + syncModels: ['model-A'], + runtimeModelDiscoveryError: '504 gateway timeout fetching runtime models', + }); + + expect(result).toEqual({ + scope, + models: ['model-A'], + }); + }); + + it('acknowledges nothing when runtime discovery fails and pending models were not in syncModels', () => { + const result = resolveAcknowledgedPendingModelsAfterSync({ + pendingSnapshot: { + scope, + models: ['model-B'], + }, + syncModels: ['model-A'], + runtimeModelDiscoveryError: 'network error', + }); + + expect(result).toEqual({ + scope, + models: [], + }); + }); + + it('handles empty pending snapshot gracefully', () => { + const result = resolveAcknowledgedPendingModelsAfterSync({ + pendingSnapshot: { + scope, + models: [], + }, + syncModels: ['model-A'], + runtimeModelDiscoveryError: null, + }); + + expect(result).toEqual({ + scope, + models: [], + }); + }); +}); diff --git a/apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.ts b/apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.ts new file mode 100644 index 000000000..7fb7c0c1a --- /dev/null +++ b/apps/web/src/features/model-price-attention/resolveAcknowledgedPendingModelsAfterSync.ts @@ -0,0 +1,47 @@ +import type { ModelPriceAttentionSnapshot } from './modelPriceAttentionTypes'; + +export interface ResolveAcknowledgedPendingModelsParams { + pendingSnapshot: ModelPriceAttentionSnapshot; + syncModels: string[]; + runtimeModelDiscoveryError?: string | null; +} + +/** + * Resolves which pending models should be acknowledged after a model price sync. + * + * Case A (Discovery succeeded): + * All pending snapshot models participated in the sync via runtime discovery, + * so all pending models are acknowledged. + * + * Case B (Discovery failed, fallback to known models succeeded): + * Only pending models that were already part of syncModels (saved / usage known models) + * actually participated in the fallback sync. Only acknowledge (pendingSnapshot ∩ syncModels). + * Models in (pendingSnapshot - syncModels) were never checked and must remain pending. + */ +export function resolveAcknowledgedPendingModelsAfterSync({ + pendingSnapshot, + syncModels, + runtimeModelDiscoveryError, +}: ResolveAcknowledgedPendingModelsParams): ModelPriceAttentionSnapshot { + if (!pendingSnapshot || !pendingSnapshot.models || pendingSnapshot.models.length === 0) { + return { + scope: pendingSnapshot?.scope ?? '', + models: [], + }; + } + + if (!runtimeModelDiscoveryError) { + return { + scope: pendingSnapshot.scope, + models: [...pendingSnapshot.models], + }; + } + + const syncSet = new Set(syncModels); + const acknowledgedModels = pendingSnapshot.models.filter((model) => syncSet.has(model)); + + return { + scope: pendingSnapshot.scope, + models: acknowledgedModels, + }; +} diff --git a/apps/web/src/features/model-price-attention/useModelPriceAttention.ts b/apps/web/src/features/model-price-attention/useModelPriceAttention.ts index ba7f690dd..c43498288 100644 --- a/apps/web/src/features/model-price-attention/useModelPriceAttention.ts +++ b/apps/web/src/features/model-price-attention/useModelPriceAttention.ts @@ -5,7 +5,10 @@ import { sharedModelPriceAttentionStore, type ModelPriceAttentionStore, } from './modelPriceAttention'; -import type { ModelPriceAttentionState } from './modelPriceAttentionTypes'; +import type { + ModelPriceAttentionState, + ModelPriceAttentionSnapshot, +} from './modelPriceAttentionTypes'; export interface UseModelPriceAttentionOptions { store?: ModelPriceAttentionStore; @@ -16,8 +19,8 @@ export interface UseModelPriceAttentionResult extends ModelPriceAttentionState { hasAttention: boolean; modelPricesAvailable: boolean; check: (options?: { force?: boolean }) => Promise; - capturePendingSnapshot: () => string[]; - acknowledgeSnapshot: (snapshot: string[]) => Promise; + capturePendingSnapshot: () => ModelPriceAttentionSnapshot; + acknowledgeSnapshot: (snapshot: ModelPriceAttentionSnapshot | string[]) => Promise; } export function useModelPriceAttention( @@ -38,6 +41,9 @@ export function useModelPriceAttention( managementKey, modelPricesAvailable, }); + if (modelPricesAvailable && base) { + void store.check(); + } }, [base, managementKey, modelPricesAvailable, store]); const state = useSyncExternalStore( diff --git a/apps/web/src/features/monitoring/ModelPricesPage.test.tsx b/apps/web/src/features/monitoring/ModelPricesPage.test.tsx index 0618c8826..8c8fde5f1 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.test.tsx +++ b/apps/web/src/features/monitoring/ModelPricesPage.test.tsx @@ -76,8 +76,11 @@ describe('ModelPricesPage Attention UI', () => { modelPricesAvailable: true, loading: false, lastCheckedAtMs: null, - check: vi.fn(), - capturePendingSnapshot: vi.fn().mockReturnValue(['runtime-new-model']), + check: vi.fn().mockResolvedValue(undefined), + capturePendingSnapshot: vi.fn().mockReturnValue({ + scope: 'http://localhost:18317', + models: ['runtime-new-model'], + }), acknowledgeSnapshot: vi.fn().mockResolvedValue(undefined), }; @@ -138,7 +141,7 @@ describe('ModelPricesPage Attention UI', () => { expect(modelBadge.props.children).toBe('待同步'); }); - it('activates filter=missing when provided in URL query parameters', async () => { + it('activates filter=missing initially from URL query, and allows switching to other tabs without being locked', async () => { let renderer: ReactTestRenderer; await act(async () => { renderer = create( @@ -151,9 +154,31 @@ describe('ModelPricesPage Attention UI', () => { const root = renderer!.root; const missingBtn = root.findByProps({ 'data-filter': 'missing' }); expect(missingBtn.props['data-active']).toBe(true); + + // Click 'all' button + const allBtn = root.findByProps({ 'data-filter': 'all' }); + await act(async () => { + allBtn.props.onClick(); + }); + + expect(allBtn.props['data-active']).toBe(true); + expect(missingBtn.props['data-active']).toBe(false); + + // Click 'candidates' button + const candidatesBtn = root.findByProps({ 'data-filter': 'candidates' }); + await act(async () => { + candidatesBtn.props.onClick(); + }); + expect(candidatesBtn.props['data-active']).toBe(true); + expect(allBtn.props['data-active']).toBe(false); }); - it('acknowledges pending snapshot upon clicking Sync Prices', async () => { + it('acknowledges all pending snapshot models when runtime discovery succeeds', async () => { + mockAttentionState.capturePendingSnapshot = vi.fn().mockReturnValue({ + scope: 'http://localhost:18317', + models: ['model-A', 'model-B'], + }); + let renderer: ReactTestRenderer; await act(async () => { renderer = create( @@ -165,7 +190,6 @@ describe('ModelPricesPage Attention UI', () => { const root = renderer!.root; const syncButton = root.findByProps({ 'data-testid': 'sync-prices-button' }); - expect(syncButton).toBeDefined(); await act(async () => { syncButton.props.onClick(); @@ -173,6 +197,146 @@ describe('ModelPricesPage Attention UI', () => { expect(mockAttentionState.capturePendingSnapshot).toHaveBeenCalled(); expect(mockSyncModelPrices).toHaveBeenCalled(); - expect(mockAttentionState.acknowledgeSnapshot).toHaveBeenCalledWith(['runtime-new-model']); + expect(mockAttentionState.acknowledgeSnapshot).toHaveBeenCalledWith({ + scope: 'http://localhost:18317', + models: ['model-A', 'model-B'], + }); + }); + + it('acknowledges only (pendingSnapshot ∩ syncModels) when runtime discovery fails with fallback sync success', async () => { + mockAttentionState.capturePendingSnapshot = vi.fn().mockReturnValue({ + scope: 'http://localhost:18317', + models: ['known-model-A', 'runtime-only-model-B'], + }); + + // Provide usage summary containing known-model-A so it enters syncModels + vi.spyOn(usageServiceApi, 'getModelPriceUsageSummary').mockResolvedValue({ + sampled_events: 1, + total_events: 1, + truncated: false, + models: [{ model: 'known-model-A', calls: 1, requested_calls: 1, resolved_calls: 1 }], + }); + + // Sync succeeds with runtimeModelDiscoveryError + mockSyncModelPrices.mockResolvedValueOnce({ + imported: 1, + skipped: 0, + prices: {}, + runtimeModelDiscoveryError: '504 gateway timeout', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + + + + ); + }); + + const root = renderer!.root; + const syncButton = root.findByProps({ 'data-testid': 'sync-prices-button' }); + + await act(async () => { + syncButton.props.onClick(); + }); + + // Only known-model-A was actually synced; runtime-only-model-B must NOT be acknowledged + expect(mockAttentionState.acknowledgeSnapshot).toHaveBeenCalledWith({ + scope: 'http://localhost:18317', + models: ['known-model-A'], + }); + }); + + it('does not acknowledge runtime-only pending models when discovery fails and syncModels has no overlap', async () => { + mockAttentionState.capturePendingSnapshot = vi.fn().mockReturnValue({ + scope: 'http://localhost:18317', + models: ['runtime-only-model-B'], + }); + + mockSyncModelPrices.mockResolvedValueOnce({ + imported: 0, + skipped: 0, + prices: {}, + runtimeModelDiscoveryError: 'discovery failed', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + + + + ); + }); + + const root = renderer!.root; + const syncButton = root.findByProps({ 'data-testid': 'sync-prices-button' }); + + await act(async () => { + syncButton.props.onClick(); + }); + + // Acknowledge should not be called because acknowledged models is empty + expect(mockAttentionState.acknowledgeSnapshot).not.toHaveBeenCalled(); + }); + + it('does not acknowledge any pending snapshot when syncModelPrices rejects', async () => { + mockSyncModelPrices.mockRejectedValueOnce(new Error('Sync failed')); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + + + + ); + }); + + const root = renderer!.root; + const syncButton = root.findByProps({ 'data-testid': 'sync-prices-button' }); + + await act(async () => { + syncButton.props.onClick(); + }); + + expect(mockAttentionState.acknowledgeSnapshot).not.toHaveBeenCalled(); + }); + + it('triggers attention.check({ force: true }) after saving manual model price', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create( + + + + ); + }); + + const root = renderer!.root; + // Open add price modal + const addPriceBtn = root.findByProps({ 'data-testid': 'add-price-button' }); + await act(async () => { + addPriceBtn.props.onClick(); + }); + + // Fill draft model and price + const modelInput = root.findByProps({ 'data-testid': 'draft-model-input' }); + const inputPrice = root.findByProps({ 'data-testid': 'draft-input-price' }); + const outputPrice = root.findByProps({ 'data-testid': 'draft-output-price' }); + + await act(async () => { + modelInput.props.onChange({ target: { value: 'custom-model' } }); + inputPrice.props.onChange({ target: { value: '1.5' } }); + outputPrice.props.onChange({ target: { value: '3.0' } }); + }); + + // Click save + const saveBtn = root.findByProps({ 'data-testid': 'save-draft-button' }); + await act(async () => { + saveBtn.props.onClick(); + }); + + expect(mockAttentionState.check).toHaveBeenCalledWith({ force: true }); }); }); diff --git a/apps/web/src/features/monitoring/ModelPricesPage.tsx b/apps/web/src/features/monitoring/ModelPricesPage.tsx index f8f7d2e96..517149d2b 100644 --- a/apps/web/src/features/monitoring/ModelPricesPage.tsx +++ b/apps/web/src/features/monitoring/ModelPricesPage.tsx @@ -13,7 +13,10 @@ import { } from '@/services/api/usageService'; import { useAuthStore, useNotificationStore } from '@/stores'; import { useUsageData } from '@/features/monitoring/hooks/useUsageData'; -import { useModelPriceAttention } from '@/features/model-price-attention'; +import { + useModelPriceAttention, + resolveAcknowledgedPendingModelsAfterSync, +} from '@/features/model-price-attention'; import attentionStyles from '@/features/model-price-attention/ModelPriceAttention.module.scss'; import { applyCandidatePrice, @@ -33,9 +36,9 @@ import { resolveServiceTierDisplayPrice, type ModelPriceFilter, type PriceDraft, -} from '@/features/monitoring/model/modelPricesPageModel'; +} from './model/modelPricesPageModel'; import { readModelPricesPageUiState, writeModelPricesPageUiState } from './modelPricesPageUiState'; -import { resolveModelPriceSyncNotification } from '@/features/monitoring/model/modelPriceSyncFeedback'; +import { resolveModelPriceSyncNotification } from './model/modelPriceSyncFeedback'; import styles from './ModelPricesPage.module.scss'; const FILTERS: ModelPriceFilter[] = ['all', 'missing', 'candidates', 'saved']; @@ -77,12 +80,6 @@ export function ModelPricesPage() { ? featureAvailability.managerServiceBase : ''; - useEffect(() => { - if (validQueryFilter && validQueryFilter !== filter) { - setFilter(validQueryFilter); - } - }, [filter, validQueryFilter]); - const syncModels = useMemo( () => buildSyncPriceModelsFromSummary(usageSummary, modelPrices), [modelPrices, usageSummary] @@ -153,8 +150,13 @@ export function ModelPricesPage() { includeRuntimeModels: true, }); setSyncResult(result); - if (pendingSnapshot.length > 0) { - await attention.acknowledgeSnapshot(pendingSnapshot); + const acknowledgedSnapshot = resolveAcknowledgedPendingModelsAfterSync({ + pendingSnapshot, + syncModels, + runtimeModelDiscoveryError: result?.runtimeModelDiscoveryError, + }); + if (acknowledgedSnapshot.models.length > 0) { + await attention.acknowledgeSnapshot(acknowledgedSnapshot); } const notification = resolveModelPriceSyncNotification({ result, @@ -179,6 +181,7 @@ export function ModelPricesPage() { const handleConfirmCandidate = async (model: string, candidate: ModelPriceSyncCandidate) => { await setModelPrices(applyCandidatePrice(modelPrices, model, candidate)); + void attention.check({ force: true }).catch(() => {}); setSyncResult((previous) => previous ? { @@ -204,6 +207,7 @@ export function ModelPricesPage() { ...price, }, }); + void attention.check({ force: true }).catch(() => {}); setDraft(createEmptyPriceDraft()); setManualEditorOpen(false); showNotification(t('usage_stats.model_price_saved'), 'success'); @@ -257,6 +261,7 @@ export function ModelPricesPage() { variant="secondary" onClick={() => openManualEditor()} className={styles.toolbarButton} + data-testid="add-price-button" > {t('model_prices.add_manual')} @@ -334,6 +339,7 @@ export function ModelPricesPage() { value={draft.model} onChange={(event) => setDraftField('model', event.target.value)} placeholder="gpt-5.5" + data-testid="draft-model-input" /> setDraftField('prompt', event.target.value)} placeholder="0.0000" step="0.0001" + data-testid="draft-input-price" /> setDraftField('completion', event.target.value)} placeholder="0.0000" step="0.0001" + data-testid="draft-output-price" />
-