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 (
+
+ );
+}
diff --git a/apps/web/src/features/model-price-attention/ModelPriceAttentionLink.test.tsx b/apps/web/src/features/model-price-attention/ModelPriceAttentionLink.test.tsx
new file mode 100644
index 000000000..2fb4b4dd0
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/ModelPriceAttentionLink.test.tsx
@@ -0,0 +1,158 @@
+import { MemoryRouter } from 'react-router-dom';
+import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ModelPriceAttentionLink } from './ModelPriceAttentionLink';
+import * as attentionHook from './useModelPriceAttention';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal
();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, options?: { count?: number; defaultValue?: string }) => {
+ if (key === 'usage_stats.model_price_attention_tooltip') {
+ return `发现 ${options?.count} 个新模型待同步价格`;
+ }
+ if (key === 'usage_stats.model_price_settings_short') {
+ return '模型价格';
+ }
+ if (key === 'usage_stats.model_price_settings') {
+ return '模型价格设置';
+ }
+ return options?.defaultValue || key;
+ },
+ }),
+ };
+});
+
+describe('ModelPriceAttentionLink', () => {
+ let mockAttentionState: ReturnType;
+
+ beforeEach(() => {
+ mockAttentionState = {
+ runtimeModels: [],
+ unpricedModels: [],
+ acknowledgedModels: [],
+ pendingModels: [],
+ pendingCount: 0,
+ hasAttention: false,
+ modelPricesAvailable: true,
+ loading: false,
+ lastCheckedAtMs: null,
+ check: vi.fn(),
+ capturePendingSnapshot: vi.fn(),
+ acknowledgeSnapshot: vi.fn(),
+ };
+
+ vi.spyOn(attentionHook, 'useModelPriceAttention').mockImplementation(
+ () => mockAttentionState
+ );
+ });
+
+ it('renders nothing if modelPricesAvailable is false', () => {
+ mockAttentionState.modelPricesAvailable = false;
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+
+ );
+ });
+ expect(renderer!.toJSON()).toBeNull();
+ });
+
+ describe('action-bar variant', () => {
+ it('renders normal link without dot when pendingCount = 0', () => {
+ mockAttentionState.pendingCount = 0;
+ mockAttentionState.hasAttention = false;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'monitoring-model-prices-link' });
+ expect(link.props.to).toBe('/model-prices');
+ expect(link.props.title).toBe('模型价格设置');
+ expect(link.props['data-has-attention']).toBe(false);
+
+ // No attention dot
+ expect(root.findAllByProps({ 'data-testid': 'model-price-attention-dot' })).toHaveLength(0);
+ });
+
+ it('renders link with amber dot and filter=missing when pendingCount > 0', () => {
+ mockAttentionState.pendingModels = ['gpt-6-sol', 'claude-4'];
+ mockAttentionState.pendingCount = 2;
+ mockAttentionState.hasAttention = true;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'monitoring-model-prices-link' });
+ expect(link.props.to).toBe('/model-prices?filter=missing');
+ expect(link.props.title).toBe('发现 2 个新模型待同步价格');
+ expect(link.props['data-has-attention']).toBe(true);
+
+ // Has attention dot
+ const dot = root.findByProps({ 'data-testid': 'model-price-attention-dot' });
+ expect(dot).toBeDefined();
+ });
+ });
+
+ describe('inline variant', () => {
+ it('renders null when pendingCount = 0', () => {
+ mockAttentionState.pendingCount = 0;
+ mockAttentionState.hasAttention = false;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ expect(renderer!.toJSON()).toBeNull();
+ });
+
+ it('renders clickable link when pendingCount > 0', () => {
+ mockAttentionState.pendingModels = ['gpt-6-sol'];
+ mockAttentionState.pendingCount = 1;
+ mockAttentionState.hasAttention = true;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'inline-model-price-attention-link' });
+ expect(link.props.to).toBe('/model-prices?filter=missing');
+ expect(link.props.title).toBe('发现 1 个新模型待同步价格');
+
+ const dot = root.findByProps({ 'data-testid': 'model-price-attention-dot' });
+ expect(dot).toBeDefined();
+ });
+ });
+});
diff --git a/apps/web/src/features/model-price-attention/ModelPriceAttentionLink.tsx b/apps/web/src/features/model-price-attention/ModelPriceAttentionLink.tsx
new file mode 100644
index 000000000..f58236289
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/ModelPriceAttentionLink.tsx
@@ -0,0 +1,81 @@
+import { Link } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
+import { IconDollarSign } from '@/components/ui/icons';
+import { useModelPriceAttention } from './useModelPriceAttention';
+import { ModelPriceAttentionDot } from './ModelPriceAttentionDot';
+import styles from './ModelPriceAttention.module.scss';
+
+export interface ModelPriceAttentionLinkProps {
+ variant?: 'action-bar' | 'inline';
+ className?: string;
+ onClick?: () => void;
+}
+
+const resolveShortLabel = (t: TFunction) => {
+ const fallback = t('usage_stats.model_price_settings');
+ const label = t('usage_stats.model_price_settings_short', { defaultValue: fallback });
+ return label === 'usage_stats.model_price_settings_short' ? fallback : label;
+};
+
+export function ModelPriceAttentionLink({
+ variant = 'inline',
+ className,
+ onClick,
+}: ModelPriceAttentionLinkProps) {
+ const { t } = useTranslation();
+ const attention = useModelPriceAttention();
+
+ if (!attention.modelPricesAvailable) {
+ return null;
+ }
+
+ const shortLabel = resolveShortLabel(t);
+
+ if (variant === 'action-bar') {
+ const hasAttention = attention.hasAttention;
+ const to = hasAttention ? '/model-prices?filter=missing' : '/model-prices';
+ const title = hasAttention
+ ? t('usage_stats.model_price_attention_tooltip', { count: attention.pendingCount })
+ : t('usage_stats.model_price_settings');
+
+ return (
+
+
+ {shortLabel}
+ {hasAttention ? : null}
+
+ );
+ }
+
+ // Inline variant for Dashboard & Usage Analytics: only shown when pending > 0
+ if (!attention.hasAttention) {
+ return null;
+ }
+
+ const tooltip = t('usage_stats.model_price_attention_tooltip', {
+ count: attention.pendingCount,
+ });
+
+ return (
+
+ {shortLabel}
+
+
+ );
+}
diff --git a/apps/web/src/features/model-price-attention/index.ts b/apps/web/src/features/model-price-attention/index.ts
new file mode 100644
index 000000000..8e053fcf6
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/index.ts
@@ -0,0 +1,7 @@
+export * from './modelPriceAttentionTypes';
+export * from './modelPriceAttentionStorage';
+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
new file mode 100644
index 000000000..28fe54253
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/modelPriceAttention.test.ts
@@ -0,0 +1,417 @@
+import { describe, expect, it, beforeEach, vi, type Mock } from 'vitest';
+import { ModelPriceAttentionStore } from './modelPriceAttention';
+import type { RuntimeModelPricingStatusResponse } from '@/services/api/usageService';
+
+type GetRuntimeModelPricingStatusFn = (
+ base: string,
+ managementKey?: string,
+ signal?: AbortSignal
+) => Promise;
+
+describe('ModelPriceAttentionStore', () => {
+ class MockStorage implements Storage {
+ private store = new Map();
+ get length() {
+ return this.store.size;
+ }
+ clear() {
+ this.store.clear();
+ }
+ getItem(key: string) {
+ return this.store.has(key) ? this.store.get(key)! : null;
+ }
+ key(index: number) {
+ return Array.from(this.store.keys())[index] ?? null;
+ }
+ removeItem(key: string) {
+ this.store.delete(key);
+ }
+ setItem(key: string, value: string) {
+ this.store.set(key, value);
+ }
+ }
+
+ let storage: MockStorage;
+ let mockApi: {
+ getRuntimeModelPricingStatus: Mock;
+ };
+
+ const base = 'http://localhost:18317';
+
+ beforeEach(() => {
+ storage = new MockStorage();
+ mockApi = {
+ getRuntimeModelPricingStatus: vi.fn(),
+ };
+ });
+
+ it('calculates pending models as unpriced minus acknowledged', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ const response: RuntimeModelPricingStatusResponse = {
+ models: ['gpt-5.6-sol', 'gpt-6-sol'],
+ unpricedModels: ['gpt-6-sol'],
+ count: 2,
+ unpricedCount: 1,
+ };
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce(response);
+
+ await store.check({ force: true });
+
+ const state = store.getState();
+ // gpt-5.6-sol is priced, so it was auto-acknowledged
+ expect(state.acknowledgedModels).toContain('gpt-5.6-sol');
+ expect(state.pendingModels).toEqual(['gpt-6-sol']);
+ });
+
+ it('auto-acknowledges runtime models that already have explicit prices', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ // All runtime models already priced
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['model-a', 'model-b'],
+ unpricedModels: [],
+ count: 2,
+ unpricedCount: 0,
+ });
+
+ await store.check({ force: true });
+
+ const state = store.getState();
+ expect(state.acknowledgedModels).toEqual(['model-a', 'model-b']);
+ expect(state.pendingModels).toEqual([]);
+ });
+
+ it('preserves existing pending models on discovery failure', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['gpt-6-sol'],
+ unpricedModels: ['gpt-6-sol'],
+ count: 1,
+ unpricedCount: 1,
+ });
+
+ await store.check({ force: true });
+ expect(store.getState().pendingModels).toEqual(['gpt-6-sol']);
+
+ // Next check fails
+ mockApi.getRuntimeModelPricingStatus.mockRejectedValueOnce(new Error('Network error'));
+ await store.check({ force: true });
+
+ // Pending models are not wiped
+ expect(store.getState().pendingModels).toEqual(['gpt-6-sol']);
+ expect(store.getState().loading).toBe(false);
+ });
+
+ it('acknowledges snapshot after sync and clears attention', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['gpt-6-sol'],
+ unpricedModels: ['gpt-6-sol'],
+ count: 1,
+ unpricedCount: 1,
+ });
+
+ await store.check({ force: true });
+ expect(store.getState().pendingModels).toEqual(['gpt-6-sol']);
+
+ // Capture snapshot at sync start
+ const snapshot = store.capturePendingSnapshot();
+ 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({
+ models: ['gpt-6-sol'],
+ unpricedModels: ['gpt-6-sol'], // unmatched, still unpriced in DB
+ count: 1,
+ unpricedCount: 1,
+ });
+
+ await store.acknowledgeSnapshot(snapshot);
+
+ const state = store.getState();
+ expect(state.acknowledgedModels).toContain('gpt-6-sol');
+ // Global attention badge cleared because it has been checked!
+ expect(state.pendingModels).toEqual([]);
+ });
+
+ it('does not acknowledge models that appeared concurrently after sync started', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['model-A'],
+ unpricedModels: ['model-A'],
+ count: 1,
+ unpricedCount: 1,
+ });
+
+ await store.check({ force: true });
+ const snapshot = store.capturePendingSnapshot(); // ['model-A']
+
+ // Concurrently, model-B appeared in runtime
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['model-A', 'model-B'],
+ unpricedModels: ['model-A', 'model-B'],
+ count: 2,
+ unpricedCount: 2,
+ });
+
+ await store.acknowledgeSnapshot(snapshot);
+
+ const state = store.getState();
+ expect(state.acknowledgedModels).toEqual(['model-A']);
+ // model-B was NOT in snapshot, so it remains pending!
+ expect(state.pendingModels).toEqual(['model-B']);
+ });
+
+ it('prevents concurrent duplicate checks', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ let resolveApi: (value: RuntimeModelPricingStatusResponse) => void;
+ const pendingPromise = new Promise((resolve) => {
+ resolveApi = resolve;
+ });
+ mockApi.getRuntimeModelPricingStatus.mockReturnValueOnce(pendingPromise);
+
+ const check1 = store.check({ force: true });
+ const check2 = store.check({ force: true });
+
+ expect(mockApi.getRuntimeModelPricingStatus).toHaveBeenCalledTimes(1);
+
+ resolveApi!({
+ models: ['m1'],
+ unpricedModels: ['m1'],
+ count: 1,
+ unpricedCount: 1,
+ });
+
+ await Promise.all([check1, check2]);
+ expect(store.getState().pendingModels).toEqual(['m1']);
+ });
+
+ it('does not re-notify when a previously acknowledged model disappears and reappears', async () => {
+ const store = new ModelPriceAttentionStore({
+ base,
+ storage,
+ api: mockApi,
+ });
+
+ // 1. Initial check: model-x is unpriced
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['model-x'],
+ unpricedModels: ['model-x'],
+ count: 1,
+ unpricedCount: 1,
+ });
+ await store.check({ force: true });
+ expect(store.getState().pendingModels).toEqual(['model-x']);
+
+ // 2. User acknowledges model-x
+ await store.acknowledgeSnapshot(['model-x']);
+ expect(store.getState().pendingModels).toEqual([]);
+
+ // 3. Model-x disappears
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: [],
+ unpricedModels: [],
+ count: 0,
+ unpricedCount: 0,
+ });
+ await store.check({ force: true });
+ expect(store.getState().pendingModels).toEqual([]);
+
+ // 4. Model-x reappears
+ mockApi.getRuntimeModelPricingStatus.mockResolvedValueOnce({
+ models: ['model-x'],
+ unpricedModels: ['model-x'],
+ count: 1,
+ unpricedCount: 1,
+ });
+ await store.check({ force: true });
+ // 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
new file mode 100644
index 000000000..29bebb459
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/modelPriceAttention.ts
@@ -0,0 +1,323 @@
+import { usageServiceApi } from '@/services/api/usageService';
+import type {
+ ModelPriceAttentionState,
+ ModelPriceAttentionSnapshot,
+} from './modelPriceAttentionTypes';
+import {
+ loadAcknowledgedModels,
+ saveAcknowledgedModels,
+} from './modelPriceAttentionStorage';
+
+export const ATTENTION_CHECK_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
+
+export interface AttentionStoreOptions {
+ base?: string;
+ managementKey?: string;
+ storage?: Storage;
+ api?: Pick;
+ checkIntervalMs?: number;
+}
+
+export class ModelPriceAttentionStore {
+ private base = '';
+ private managementKey: string | undefined = undefined;
+ private storage?: Storage;
+ private api: Pick;
+ private checkIntervalMs: number;
+
+ private scopeGeneration = 0;
+ private lastAttemptAtMs: number | null = null;
+
+ private state: ModelPriceAttentionState = {
+ runtimeModels: [],
+ unpricedModels: [],
+ acknowledgedModels: [],
+ pendingModels: [],
+ loading: false,
+ lastCheckedAtMs: null,
+ };
+
+ private listeners = new Set<() => void>();
+ private activeCheckPromise: Promise | null = null;
+ private timerId: ReturnType | null = null;
+ private focusHandlerAttached = false;
+ private boundFocusHandler: (() => void) | null = null;
+
+ constructor(options: AttentionStoreOptions = {}) {
+ this.base = options.base ?? '';
+ this.managementKey = options.managementKey;
+ this.storage = options.storage;
+ this.api = options.api ?? usageServiceApi;
+ this.checkIntervalMs = options.checkIntervalMs ?? ATTENTION_CHECK_INTERVAL_MS;
+
+ if (this.base) {
+ this.initAcknowledgedFromStorage();
+ }
+ }
+
+ public configure(options: {
+ base: string;
+ managementKey?: string;
+ modelPricesAvailable: boolean;
+ }): void {
+ const baseChanged = this.base !== options.base;
+ if (baseChanged) {
+ 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) {
+ this.stopAutoCheck();
+ return;
+ }
+
+ this.ensureAutoCheck();
+ }
+
+ public getState(): ModelPriceAttentionState {
+ return this.state;
+ }
+
+ public subscribe(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ private notify(): void {
+ this.listeners.forEach((listener) => {
+ try {
+ listener();
+ } catch {
+ // Prevent listener errors from breaking store execution
+ }
+ });
+ }
+
+ private initAcknowledgedFromStorage(): void {
+ const acknowledged = loadAcknowledgedModels(this.base, this.storage);
+ const pending = this.computePending(this.state.unpricedModels, acknowledged);
+ this.state = {
+ ...this.state,
+ acknowledgedModels: acknowledged,
+ pendingModels: pending,
+ };
+ this.notify();
+ }
+
+ private computePending(unpriced: string[], acknowledged: string[]): string[] {
+ const ackSet = new Set(acknowledged);
+ return unpriced.filter((m) => !ackSet.has(m));
+ }
+
+ public isCacheExpired(): boolean {
+ if (this.lastAttemptAtMs === null) return true;
+ return Date.now() - this.lastAttemptAtMs >= this.checkIntervalMs;
+ }
+
+ public check(options?: { force?: boolean }): Promise {
+ if (!this.base) {
+ return Promise.resolve();
+ }
+
+ if (!options?.force && !this.isCacheExpired()) {
+ return Promise.resolve();
+ }
+
+ // Deduplicate concurrent in-flight checks
+ if (this.activeCheckPromise) {
+ 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(
+ 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(requestBase, this.storage));
+ const unpricedSet = new Set(unpricedModels);
+ for (const model of runtimeModels) {
+ if (!unpricedSet.has(model)) {
+ currentAck.add(model);
+ }
+ }
+
+ const nextAcknowledged = Array.from(currentAck).sort();
+ saveAcknowledgedModels(requestBase, nextAcknowledged, this.storage);
+
+ const pending = this.computePending(unpricedModels, nextAcknowledged);
+
+ this.state = {
+ runtimeModels,
+ unpricedModels,
+ acknowledgedModels: nextAcknowledged,
+ pendingModels: pending,
+ loading: false,
+ lastCheckedAtMs: Date.now(),
+ };
+ 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,
+ loading: false,
+ };
+ this.notify();
+ } finally {
+ if (requestGeneration === this.scopeGeneration) {
+ this.activeCheckPromise = null;
+ }
+ }
+ })();
+
+ return this.activeCheckPromise;
+ }
+
+ public capturePendingSnapshot(): ModelPriceAttentionSnapshot {
+ return {
+ scope: this.base,
+ models: [...this.state.pendingModels],
+ };
+ }
+
+ 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));
+ modelsToAck.forEach((m) => {
+ if (m && m.trim()) {
+ currentAck.add(m.trim());
+ }
+ });
+
+ const nextAcknowledged = Array.from(currentAck).sort();
+ saveAcknowledgedModels(this.base, nextAcknowledged, this.storage);
+
+ // Re-evaluate pending with current unpriced models
+ const pending = this.computePending(this.state.unpricedModels, nextAcknowledged);
+
+ this.state = {
+ ...this.state,
+ acknowledgedModels: nextAcknowledged,
+ pendingModels: pending,
+ };
+ this.notify();
+
+ // Immediately refresh runtime model status to reconcile latest state
+ await this.check({ force: true });
+ }
+
+ public ensureAutoCheck(): void {
+ if (typeof window === 'undefined') return;
+
+ if (!this.timerId) {
+ this.timerId = setInterval(() => {
+ if (typeof document !== 'undefined' && document.hidden) {
+ return;
+ }
+ if (this.isCacheExpired()) {
+ void this.check();
+ }
+ }, 60 * 1000); // Check every minute whether the 30-min window expired
+ }
+
+ if (!this.focusHandlerAttached) {
+ this.boundFocusHandler = () => {
+ if (this.isCacheExpired()) {
+ void this.check();
+ }
+ };
+ window.addEventListener('focus', this.boundFocusHandler);
+ this.focusHandlerAttached = true;
+ }
+ }
+
+ public stopAutoCheck(): void {
+ if (this.timerId) {
+ clearInterval(this.timerId);
+ this.timerId = null;
+ }
+ if (this.focusHandlerAttached && this.boundFocusHandler && typeof window !== 'undefined') {
+ window.removeEventListener('focus', this.boundFocusHandler);
+ this.focusHandlerAttached = false;
+ this.boundFocusHandler = null;
+ }
+ }
+
+ public reset(): void {
+ this.stopAutoCheck();
+ this.scopeGeneration += 1;
+ this.base = '';
+ this.managementKey = undefined;
+ this.lastAttemptAtMs = null;
+ this.state = {
+ runtimeModels: [],
+ unpricedModels: [],
+ acknowledgedModels: [],
+ pendingModels: [],
+ loading: false,
+ lastCheckedAtMs: null,
+ };
+ this.activeCheckPromise = null;
+ this.listeners.clear();
+ }
+}
+
+// Global shared store singleton for all pages
+export const sharedModelPriceAttentionStore = new ModelPriceAttentionStore();
diff --git a/apps/web/src/features/model-price-attention/modelPriceAttentionStorage.test.ts b/apps/web/src/features/model-price-attention/modelPriceAttentionStorage.test.ts
new file mode 100644
index 000000000..b4d7bb698
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/modelPriceAttentionStorage.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it, beforeEach } from 'vitest';
+import {
+ loadAcknowledgedModels,
+ saveAcknowledgedModels,
+ MODEL_PRICE_ATTENTION_STORAGE_KEY,
+} from './modelPriceAttentionStorage';
+
+describe('modelPriceAttentionStorage', () => {
+ class MockStorage implements Storage {
+ private store = new Map();
+ get length() {
+ return this.store.size;
+ }
+ clear() {
+ this.store.clear();
+ }
+ getItem(key: string) {
+ return this.store.has(key) ? this.store.get(key)! : null;
+ }
+ key(index: number) {
+ return Array.from(this.store.keys())[index] ?? null;
+ }
+ removeItem(key: string) {
+ this.store.delete(key);
+ }
+ setItem(key: string, value: string) {
+ this.store.set(key, value);
+ }
+ }
+
+ let storage: MockStorage;
+
+ beforeEach(() => {
+ storage = new MockStorage();
+ });
+
+ it('returns empty array when storage is empty', () => {
+ expect(loadAcknowledgedModels('http://localhost:18317', storage)).toEqual([]);
+ });
+
+ it('safely recovers from corrupted json', () => {
+ storage.setItem(MODEL_PRICE_ATTENTION_STORAGE_KEY, '{ invalid json');
+ expect(loadAcknowledgedModels('http://localhost:18317', storage)).toEqual([]);
+ });
+
+ it('safely recovers from invalid schema or version', () => {
+ storage.setItem(
+ MODEL_PRICE_ATTENTION_STORAGE_KEY,
+ JSON.stringify({ version: 99, scopes: {} })
+ );
+ expect(loadAcknowledgedModels('http://localhost:18317', storage)).toEqual([]);
+ });
+
+ it('saves and loads acknowledged models scoped by manager base', () => {
+ const baseA = 'http://localhost:18317';
+ const baseB = 'https://manager.example.com/';
+
+ saveAcknowledgedModels(baseA, ['gpt-4o', 'claude-3-5-sonnet'], storage);
+ saveAcknowledgedModels(baseB, ['deepseek-chat'], storage);
+
+ expect(loadAcknowledgedModels(baseA, storage)).toEqual(['gpt-4o', 'claude-3-5-sonnet']);
+ expect(loadAcknowledgedModels(baseB, storage)).toEqual(['deepseek-chat']);
+ expect(loadAcknowledgedModels('http://other:18317', storage)).toEqual([]);
+
+ // Raw verification of stored structure
+ const raw = JSON.parse(storage.getItem(MODEL_PRICE_ATTENTION_STORAGE_KEY)!);
+ expect(raw.version).toBe(1);
+ expect(Object.keys(raw.scopes)).toHaveLength(2);
+ // Assure no credential/auth info is stored
+ expect(raw).not.toHaveProperty('apiKey');
+ expect(raw).not.toHaveProperty('managementKey');
+ });
+
+ it('deduplicates and trims model names', () => {
+ saveAcknowledgedModels(
+ 'http://localhost:18317',
+ ['gpt-4o', ' gpt-4o ', '', ' ', 'claude-3-5-sonnet'],
+ storage
+ );
+ expect(loadAcknowledgedModels('http://localhost:18317', storage)).toEqual([
+ 'gpt-4o',
+ 'claude-3-5-sonnet',
+ ]);
+ });
+});
diff --git a/apps/web/src/features/model-price-attention/modelPriceAttentionStorage.ts b/apps/web/src/features/model-price-attention/modelPriceAttentionStorage.ts
new file mode 100644
index 000000000..937cd4147
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/modelPriceAttentionStorage.ts
@@ -0,0 +1,90 @@
+import { normalizeApiBase } from '@/utils/connection';
+import type { ModelPriceAttentionStorageData } from './modelPriceAttentionTypes';
+
+export const MODEL_PRICE_ATTENTION_STORAGE_KEY = 'cpamp-model-price-attention-v1';
+
+export const normalizeModelPriceAttentionScope = (base: string): string => {
+ return normalizeApiBase(base).trim().toLowerCase();
+};
+
+const sanitizeModelList = (models: unknown): string[] => {
+ if (!Array.isArray(models)) return [];
+ const seen = new Set();
+ const list: string[] = [];
+ models.forEach((item) => {
+ if (typeof item === 'string') {
+ const trimmed = item.trim();
+ if (trimmed && !seen.has(trimmed)) {
+ seen.add(trimmed);
+ list.push(trimmed);
+ }
+ }
+ });
+ return list;
+};
+
+export const readModelPriceAttentionStorage = (
+ storage?: Storage
+): ModelPriceAttentionStorageData => {
+ const targetStorage = storage ?? (typeof window !== 'undefined' ? window.localStorage : undefined);
+ if (!targetStorage) {
+ return { version: 1, scopes: {} };
+ }
+
+ try {
+ const raw = targetStorage.getItem(MODEL_PRICE_ATTENTION_STORAGE_KEY);
+ if (!raw) {
+ return { version: 1, scopes: {} };
+ }
+ const parsed = JSON.parse(raw);
+ if (!parsed || typeof parsed !== 'object' || parsed.version !== 1 || !parsed.scopes || typeof parsed.scopes !== 'object') {
+ return { version: 1, scopes: {} };
+ }
+
+ const sanitizedScopes: Record = {};
+ for (const [scopeKey, scopeValue] of Object.entries(parsed.scopes)) {
+ if (scopeValue && typeof scopeValue === 'object') {
+ const models = sanitizeModelList((scopeValue as { acknowledgedModels?: unknown }).acknowledgedModels);
+ sanitizedScopes[scopeKey] = { acknowledgedModels: models };
+ }
+ }
+
+ return { version: 1, scopes: sanitizedScopes };
+ } catch {
+ return { version: 1, scopes: {} };
+ }
+};
+
+export const writeModelPriceAttentionStorage = (
+ data: ModelPriceAttentionStorageData,
+ storage?: Storage
+): void => {
+ const targetStorage = storage ?? (typeof window !== 'undefined' ? window.localStorage : undefined);
+ if (!targetStorage) return;
+
+ try {
+ targetStorage.setItem(MODEL_PRICE_ATTENTION_STORAGE_KEY, JSON.stringify(data));
+ } catch {
+ // Gracefully ignore write failures (e.g. quota exceeded or restricted)
+ }
+};
+
+export const loadAcknowledgedModels = (base: string, storage?: Storage): string[] => {
+ const scope = normalizeModelPriceAttentionScope(base);
+ if (!scope) return [];
+ const data = readModelPriceAttentionStorage(storage);
+ return data.scopes[scope]?.acknowledgedModels ?? [];
+};
+
+export const saveAcknowledgedModels = (
+ base: string,
+ acknowledgedModels: string[],
+ storage?: Storage
+): void => {
+ const scope = normalizeModelPriceAttentionScope(base);
+ if (!scope) return;
+ const data = readModelPriceAttentionStorage(storage);
+ const sanitized = sanitizeModelList(acknowledgedModels);
+ data.scopes[scope] = { acknowledgedModels: sanitized };
+ writeModelPriceAttentionStorage(data, storage);
+};
diff --git a/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts b/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts
new file mode 100644
index 000000000..f60e649a3
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/modelPriceAttentionTypes.ts
@@ -0,0 +1,23 @@
+export interface ModelPriceAttentionState {
+ runtimeModels: string[];
+ unpricedModels: string[];
+ acknowledgedModels: string[];
+ pendingModels: string[];
+ loading: boolean;
+ lastCheckedAtMs: number | null;
+}
+
+export interface ModelPriceAttentionStorageData {
+ version: 1;
+ scopes: Record<
+ string,
+ {
+ acknowledgedModels: string[];
+ }
+ >;
+}
+
+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
new file mode 100644
index 000000000..a8414dd6c
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/modelPriceAttentionUi.test.tsx
@@ -0,0 +1,291 @@
+import type { ComponentProps } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { TFunction } from 'i18next';
+import { MonitoringActionBar } from '@/features/monitoring/components/MonitoringActionBar';
+import { UsageMetricsCard } from '@/features/dashboard/components/UsageMetricsCard';
+import { UsageSummaryCardView } from '@/features/usage-analytics/components/UsageSummaryCards';
+import * as attentionHook from './useModelPriceAttention';
+import enLocale from '@/i18n/locales/en.json';
+import zhCNLocale from '@/i18n/locales/zh-CN.json';
+import zhTWLocale from '@/i18n/locales/zh-TW.json';
+import ruLocale from '@/i18n/locales/ru.json';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, options?: { count?: number; defaultValue?: string }) => {
+ if (key === 'usage_stats.model_price_attention_tooltip') {
+ return `发现 ${options?.count} 个新模型待同步价格`;
+ }
+ if (key === 'usage_stats.model_price_settings_short') {
+ return '模型价格';
+ }
+ if (key === 'usage_stats.model_price_settings') {
+ return '模型价格设置';
+ }
+ if (key === 'model_prices.pending_sync_badge') {
+ return '待同步';
+ }
+ if (key === 'dashboard.today_cost') {
+ return '今日成本';
+ }
+ if (key === 'usage_analytics.metric_estimated_cost') {
+ return '预估成本';
+ }
+ return options?.defaultValue ?? key;
+ },
+ i18n: { language: 'zh-CN' },
+ }),
+ };
+});
+
+import type { ModelPriceAttentionSnapshot } from './modelPriceAttentionTypes';
+
+describe('ModelPriceAttention UI Integration', () => {
+ let mockAttentionState: {
+ runtimeModels: string[];
+ unpricedModels: string[];
+ acknowledgedModels: string[];
+ pendingModels: string[];
+ pendingCount: number;
+ hasAttention: boolean;
+ modelPricesAvailable: boolean;
+ loading: boolean;
+ lastCheckedAtMs: number | null;
+ check: () => Promise;
+ capturePendingSnapshot: () => ModelPriceAttentionSnapshot;
+ acknowledgeSnapshot: (snapshot: ModelPriceAttentionSnapshot | string[]) => Promise;
+ };
+
+ beforeEach(() => {
+ mockAttentionState = {
+ runtimeModels: [],
+ unpricedModels: [],
+ acknowledgedModels: [],
+ pendingModels: [],
+ pendingCount: 0,
+ hasAttention: false,
+ modelPricesAvailable: true,
+ loading: false,
+ lastCheckedAtMs: Date.now(),
+ check: vi.fn(async () => {}),
+ capturePendingSnapshot: vi.fn(() => ({
+ scope: 'http://localhost:18317',
+ models: [],
+ })),
+ acknowledgeSnapshot: vi.fn(async () => {}),
+ };
+
+ vi.spyOn(attentionHook, 'useModelPriceAttention').mockImplementation(
+ () => mockAttentionState
+ );
+ });
+
+ describe('MonitoringActionBar', () => {
+ const defaultProps = {
+ usageTransferAvailable: true,
+ usageExporting: false,
+ usageImporting: false,
+ loggingToFile: false,
+ modelPricesAvailable: true,
+ usageImportInputRef: { current: null },
+ t: ((key: string) => key) as unknown as TFunction,
+ onUsageExport: vi.fn(),
+ onUsageImportClick: vi.fn(),
+ onUsageImportChange: vi.fn(),
+ statusSummary: null,
+ };
+
+ it('renders normal link without dot when pending = 0', () => {
+ mockAttentionState.pendingCount = 0;
+ mockAttentionState.hasAttention = false;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'monitoring-model-prices-link' });
+ expect(link.props.to).toBe('/model-prices');
+ expect(root.findAllByProps({ 'data-testid': 'model-price-attention-dot' })).toHaveLength(0);
+ });
+
+ it('renders amber dot and links to /model-prices?filter=missing when pending > 0', () => {
+ mockAttentionState.pendingModels = ['gpt-6-sol'];
+ mockAttentionState.pendingCount = 1;
+ mockAttentionState.hasAttention = true;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'monitoring-model-prices-link' });
+ expect(link.props.to).toBe('/model-prices?filter=missing');
+ expect(link.props.title).toBe('发现 1 个新模型待同步价格');
+ const dot = root.findByProps({ 'data-testid': 'model-price-attention-dot' });
+ expect(dot).toBeDefined();
+ });
+ });
+
+ describe('Dashboard UsageMetricsCard', () => {
+ const defaultSummary = {
+ today: {
+ total_calls: 100,
+ success_calls: 95,
+ total_tokens: 10000,
+ total_cost: 12.34,
+ average_latency_ms: 120,
+ zero_token_calls: 2,
+ success_rate: 0.95,
+ },
+ rolling_30m: {
+ rpm: 10,
+ tpm: 500,
+ total_tokens: 2000,
+ },
+ top_models_today: [],
+ model_cost_rank: [],
+ };
+
+ it('keeps cost UI untouched when pending = 0', () => {
+ mockAttentionState.pendingCount = 0;
+ mockAttentionState.hasAttention = false;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+ ['summary']}
+ topModels={[]}
+ modelCostRank={[]}
+ loading={false}
+ lastRefreshedAt={new Date()}
+ mode="metrics-only"
+ />
+
+ );
+ });
+
+ const root = renderer!.root;
+ expect(root.findAllByProps({ 'data-testid': 'inline-model-price-attention-link' })).toHaveLength(0);
+ });
+
+ it('renders inline attention link in cost area when pending > 0', () => {
+ mockAttentionState.pendingModels = ['new-model'];
+ mockAttentionState.pendingCount = 1;
+ mockAttentionState.hasAttention = true;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+ ['summary']}
+ topModels={[]}
+ modelCostRank={[]}
+ loading={false}
+ lastRefreshedAt={new Date()}
+ mode="metrics-only"
+ />
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'inline-model-price-attention-link' });
+ expect(link.props.to).toBe('/model-prices?filter=missing');
+ expect(link.props.title).toBe('发现 1 个新模型待同步价格');
+ });
+ });
+
+ describe('Usage Analytics UsageSummaryCardView', () => {
+ it('does not render attention link for cost card when pending = 0', () => {
+ mockAttentionState.pendingCount = 0;
+ mockAttentionState.hasAttention = false;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ expect(root.findAllByProps({ 'data-testid': 'inline-model-price-attention-link' })).toHaveLength(0);
+ });
+
+ it('renders attention link inside cost card header when pending > 0', () => {
+ mockAttentionState.pendingModels = ['gpt-6-preview'];
+ mockAttentionState.pendingCount = 1;
+ mockAttentionState.hasAttention = true;
+
+ let renderer: ReactTestRenderer;
+ act(() => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const link = root.findByProps({ 'data-testid': 'inline-model-price-attention-link' });
+ expect(link.props.to).toBe('/model-prices?filter=missing');
+ });
+ });
+
+ describe('Locales consistency', () => {
+ it('defines model_price_settings_short as Model Prices across all locales', () => {
+ expect(zhCNLocale.usage_stats.model_price_settings_short).toBe('模型价格');
+ expect(enLocale.usage_stats.model_price_settings_short).toBe('Model Prices');
+ expect(zhTWLocale.usage_stats.model_price_settings_short).toBe('模型定價');
+ expect(ruLocale.usage_stats.model_price_settings_short).toBe('Цены моделей');
+ });
+
+ it('defines pending_sync_badge across all locales', () => {
+ expect(zhCNLocale.model_prices.pending_sync_badge).toBe('待同步');
+ expect(enLocale.model_prices.pending_sync_badge).toBe('New');
+ expect(zhTWLocale.model_prices.pending_sync_badge).toBe('待同步');
+ expect(ruLocale.model_prices.pending_sync_badge).toBe('Новый');
+ });
+
+ it('defines model_price_attention_tooltip across all locales', () => {
+ expect(zhCNLocale.usage_stats.model_price_attention_tooltip).toContain('{{count}}');
+ expect(enLocale.usage_stats.model_price_attention_tooltip).toContain('{{count}}');
+ expect(zhTWLocale.usage_stats.model_price_attention_tooltip).toContain('{{count}}');
+ expect(ruLocale.usage_stats.model_price_attention_tooltip).toContain('{{count}}');
+ });
+ });
+});
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
new file mode 100644
index 000000000..c43498288
--- /dev/null
+++ b/apps/web/src/features/model-price-attention/useModelPriceAttention.ts
@@ -0,0 +1,64 @@
+import { useEffect, useSyncExternalStore } from 'react';
+import { usePanelFeatureAvailability } from '@/hooks/usePanelFeatureAvailability';
+import { useAuthStore } from '@/stores';
+import {
+ sharedModelPriceAttentionStore,
+ type ModelPriceAttentionStore,
+} from './modelPriceAttention';
+import type {
+ ModelPriceAttentionState,
+ ModelPriceAttentionSnapshot,
+} from './modelPriceAttentionTypes';
+
+export interface UseModelPriceAttentionOptions {
+ store?: ModelPriceAttentionStore;
+}
+
+export interface UseModelPriceAttentionResult extends ModelPriceAttentionState {
+ pendingCount: number;
+ hasAttention: boolean;
+ modelPricesAvailable: boolean;
+ check: (options?: { force?: boolean }) => Promise;
+ capturePendingSnapshot: () => ModelPriceAttentionSnapshot;
+ acknowledgeSnapshot: (snapshot: ModelPriceAttentionSnapshot | string[]) => Promise;
+}
+
+export function useModelPriceAttention(
+ options: UseModelPriceAttentionOptions = {}
+): UseModelPriceAttentionResult {
+ const store = options.store ?? sharedModelPriceAttentionStore;
+ const featureAvailability = usePanelFeatureAvailability();
+ const managementKey = useAuthStore((state) => state.managementKey);
+
+ const base = featureAvailability.modelPricesAvailable
+ ? featureAvailability.managerServiceBase
+ : '';
+ const modelPricesAvailable = featureAvailability.modelPricesAvailable;
+
+ useEffect(() => {
+ store.configure({
+ base,
+ managementKey,
+ modelPricesAvailable,
+ });
+ if (modelPricesAvailable && base) {
+ void store.check();
+ }
+ }, [base, managementKey, modelPricesAvailable, store]);
+
+ const state = useSyncExternalStore(
+ (onStoreChange) => store.subscribe(onStoreChange),
+ () => store.getState(),
+ () => store.getState()
+ );
+
+ return {
+ ...state,
+ pendingCount: state.pendingModels.length,
+ hasAttention: modelPricesAvailable && state.pendingModels.length > 0,
+ modelPricesAvailable,
+ check: (checkOptions) => store.check(checkOptions),
+ capturePendingSnapshot: () => store.capturePendingSnapshot(),
+ acknowledgeSnapshot: (snapshot) => store.acknowledgeSnapshot(snapshot),
+ };
+}
diff --git a/apps/web/src/features/monitoring/ModelPricesPage.test.tsx b/apps/web/src/features/monitoring/ModelPricesPage.test.tsx
new file mode 100644
index 000000000..8c8fde5f1
--- /dev/null
+++ b/apps/web/src/features/monitoring/ModelPricesPage.test.tsx
@@ -0,0 +1,342 @@
+import { MemoryRouter } from 'react-router-dom';
+import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ModelPricesPage } from './ModelPricesPage';
+import * as attentionHook from '@/features/model-price-attention/useModelPriceAttention';
+import * as usageDataHook from './hooks/useUsageData';
+import { usageServiceApi } from '@/services/api/usageService';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+vi.mock('react-i18next', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ useTranslation: () => ({
+ t: (key: string, options?: { count?: number; defaultValue?: string }) => {
+ if (key === 'usage_stats.model_price_sync') return '同步价格';
+ if (key === 'model_prices.pending_sync_badge') return '待同步';
+ if (key === 'model_prices.filter_missing') return '缺价格';
+ if (key === 'model_prices.filter_all') return '全部';
+ return options?.defaultValue || key;
+ },
+ }),
+ };
+});
+
+vi.mock('@/hooks/usePanelFeatureAvailability', () => ({
+ usePanelFeatureAvailability: () => ({
+ modelPricesAvailable: true,
+ requestMonitoringAvailable: true,
+ managerServiceBase: 'http://localhost:18317',
+ }),
+}));
+
+vi.mock('@/stores', () => ({
+ useAuthStore: (selector: (state: { managementKey: string }) => unknown) =>
+ selector({ managementKey: 'test-key' }),
+ useNotificationStore: () => ({
+ showNotification: vi.fn(),
+ }),
+}));
+
+describe('ModelPricesPage Attention UI', () => {
+ let mockAttentionState: ReturnType;
+ let mockSyncModelPrices: ReturnType;
+
+ beforeEach(() => {
+ vi.spyOn(usageServiceApi, 'getModelPriceUsageSummary').mockResolvedValue({
+ sampled_events: 0,
+ total_events: 0,
+ truncated: false,
+ models: [],
+ });
+
+ mockSyncModelPrices = vi.fn().mockResolvedValue({
+ imported: 1,
+ skipped: 0,
+ prices: {},
+ });
+
+ vi.spyOn(usageDataHook, 'useUsageData').mockReturnValue({
+ loading: false,
+ modelPrices: {},
+ setModelPrices: vi.fn(),
+ syncModelPrices: mockSyncModelPrices,
+ usageServiceAvailable: true,
+ } as unknown as ReturnType);
+
+ mockAttentionState = {
+ runtimeModels: ['runtime-new-model'],
+ unpricedModels: ['runtime-new-model'],
+ acknowledgedModels: [],
+ pendingModels: ['runtime-new-model'],
+ pendingCount: 1,
+ hasAttention: true,
+ modelPricesAvailable: true,
+ loading: false,
+ lastCheckedAtMs: null,
+ check: vi.fn().mockResolvedValue(undefined),
+ capturePendingSnapshot: vi.fn().mockReturnValue({
+ scope: 'http://localhost:18317',
+ models: ['runtime-new-model'],
+ }),
+ acknowledgeSnapshot: vi.fn().mockResolvedValue(undefined),
+ };
+
+ vi.spyOn(attentionHook, 'useModelPriceAttention').mockImplementation(
+ () => mockAttentionState
+ );
+ });
+
+ it('renders pending count badge on Sync Prices button when pendingCount > 0', async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const badge = root.findByProps({ 'data-testid': 'sync-pending-badge' });
+ expect(badge).toBeDefined();
+ expect(badge.props.children).toBe(1);
+ });
+
+ it('does not render pending count badge on Sync Prices button when pendingCount = 0', async () => {
+ mockAttentionState.pendingCount = 0;
+ mockAttentionState.pendingModels = [];
+ mockAttentionState.hasAttention = false;
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ expect(root.findAllByProps({ 'data-testid': 'sync-pending-badge' })).toHaveLength(0);
+ });
+
+ it('renders pending badge next to pending runtime model in the table', async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const modelBadge = root.findByProps({
+ 'data-testid': 'pending-badge-runtime-new-model',
+ });
+ expect(modelBadge).toBeDefined();
+ expect(modelBadge.props.children).toBe('待同步');
+ });
+
+ 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(
+
+
+
+ );
+ });
+
+ 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 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(
+
+
+
+ );
+ });
+
+ const root = renderer!.root;
+ const syncButton = root.findByProps({ 'data-testid': 'sync-prices-button' });
+
+ await act(async () => {
+ syncButton.props.onClick();
+ });
+
+ expect(mockAttentionState.capturePendingSnapshot).toHaveBeenCalled();
+ expect(mockSyncModelPrices).toHaveBeenCalled();
+ 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 a01b31ff5..517149d2b 100644
--- a/apps/web/src/features/monitoring/ModelPricesPage.tsx
+++ b/apps/web/src/features/monitoring/ModelPricesPage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
-import { Link } from 'react-router-dom';
+import { Link, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
@@ -13,6 +13,11 @@ import {
} from '@/services/api/usageService';
import { useAuthStore, useNotificationStore } from '@/stores';
import { useUsageData } from '@/features/monitoring/hooks/useUsageData';
+import {
+ useModelPriceAttention,
+ resolveAcknowledgedPendingModelsAfterSync,
+} from '@/features/model-price-attention';
+import attentionStyles from '@/features/model-price-attention/ModelPriceAttention.module.scss';
import {
applyCandidatePrice,
buildModelPriceRows,
@@ -31,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'];
@@ -50,13 +55,22 @@ export function ModelPricesPage() {
const { showNotification } = useNotificationStore();
const managementKey = useAuthStore((state) => state.managementKey);
const featureAvailability = usePanelFeatureAvailability();
+ const attention = useModelPriceAttention();
const { loading, modelPrices, setModelPrices, syncModelPrices, usageServiceAvailable } =
useUsageData({ loadUsageEvents: false });
const [usageSummary, setUsageSummary] = useState(null);
const [usageSummaryLoading, setUsageSummaryLoading] = useState(false);
+ const [searchParams] = useSearchParams();
+ const queryFilter = searchParams.get('filter');
+ const validQueryFilter =
+ queryFilter && FILTERS.includes(queryFilter as ModelPriceFilter)
+ ? (queryFilter as ModelPriceFilter)
+ : null;
const initialUiState = useRef(readModelPricesPageUiState());
const [search, setSearch] = useState(() => initialUiState.current.search);
- const [filter, setFilter] = useState(() => initialUiState.current.filter);
+ const [filter, setFilter] = useState(
+ () => validQueryFilter ?? initialUiState.current.filter
+ );
const [syncing, setSyncing] = useState(false);
const [syncResult, setSyncResult] = useState(null);
const [selectedCandidates, setSelectedCandidates] = useState>({});
@@ -73,8 +87,8 @@ export function ModelPricesPage() {
const candidateSets = useMemo(() => syncResult?.candidates ?? [], [syncResult?.candidates]);
const rows = useMemo(
- () => buildModelPriceRows(usageSummary, modelPrices, candidateSets),
- [candidateSets, modelPrices, usageSummary]
+ () => buildModelPriceRows(usageSummary, modelPrices, candidateSets, attention.runtimeModels),
+ [attention.runtimeModels, candidateSets, modelPrices, usageSummary]
);
const summary = useMemo(() => buildModelPriceSummary(rows), [rows]);
const visibleRows = useMemo(
@@ -130,11 +144,20 @@ export function ModelPricesPage() {
const handleSync = async () => {
setSyncing(true);
+ const pendingSnapshot = attention.capturePendingSnapshot();
try {
const result = await syncModelPrices(syncModels, {
includeRuntimeModels: true,
});
setSyncResult(result);
+ const acknowledgedSnapshot = resolveAcknowledgedPendingModelsAfterSync({
+ pendingSnapshot,
+ syncModels,
+ runtimeModelDiscoveryError: result?.runtimeModelDiscoveryError,
+ });
+ if (acknowledgedSnapshot.models.length > 0) {
+ await attention.acknowledgeSnapshot(acknowledgedSnapshot);
+ }
const notification = resolveModelPriceSyncNotification({
result,
syncModels,
@@ -158,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
? {
@@ -183,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');
@@ -236,11 +261,22 @@ export function ModelPricesPage() {
variant="secondary"
onClick={() => openManualEditor()}
className={styles.toolbarButton}
+ data-testid="add-price-button"
>
{t('model_prices.add_manual')}
-
@@ -265,6 +301,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]}
@@ -301,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"
/>
- void handleSaveDraft()}>
+ void handleSaveDraft()} data-testid="save-draft-button">
{t('common.save')}
@@ -421,7 +462,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,
|