diff --git a/apps/manager-server/internal/http/controller/modelprice/handler.go b/apps/manager-server/internal/http/controller/modelprice/handler.go index 1f2efb466..3a9c41ee0 100644 --- a/apps/manager-server/internal/http/controller/modelprice/handler.go +++ b/apps/manager-server/internal/http/controller/modelprice/handler.go @@ -24,6 +24,13 @@ func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) { path := strings.TrimRight(r.URL.Path, "/") switch { + case path == "/v0/management/model-prices/runtime-models" && r.Method == http.MethodGet: + status, err := h.App.ModelPriceService.RuntimeModelPricingStatus(r.Context()) + if err != nil { + response.Error(w, http.StatusInternalServerError, err) + return + } + response.JSON(w, http.StatusOK, status) case path == "/v0/management/model-prices/usage-summary" && r.Method == http.MethodGet: summary, err := h.App.ModelPriceService.UsageSummary(r.Context(), h.App.Config.QueryLimit) if err != nil { diff --git a/apps/manager-server/internal/http/controller/modelprice/handler_test.go b/apps/manager-server/internal/http/controller/modelprice/handler_test.go index 66f66f947..1b2b9bdd8 100644 --- a/apps/manager-server/internal/http/controller/modelprice/handler_test.go +++ b/apps/manager-server/internal/http/controller/modelprice/handler_test.go @@ -11,6 +11,7 @@ import ( "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/model" adminauthsvc "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/adminauth" modelpricesvc "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/service/modelprice" + "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/store" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/testutil" "github.com/seakee/cpa-manager-plus/apps/manager-server/internal/usage" ) @@ -57,3 +58,69 @@ func TestHandleUsageSummaryUsesQueryLimitAndPanelAuthorization(t *testing.T) { t.Fatalf("models = %#v", summary.Models) } } + +type staticSetupResolver struct { + setup store.Setup +} + +func (r staticSetupResolver) ResolveSetup(ctx context.Context) (store.Setup, bool, error) { + return r.setup, true, nil +} + +func TestHandleRuntimeModels_AuthorizationAndResponse(t *testing.T) { + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"test-key"}, + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "m1"}, + {"id": "m2"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + cfg := testutil.NewConfig(t) + st := testutil.NewStore(t, cfg) + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + + handler := &Handler{App: &app.Context{ + Config: cfg, + AdminAuthService: adminauthsvc.New(cfg, st), + ModelPriceService: modelpricesvc.New(st, nil, resolver), + }} + + unauth := httptest.NewRecorder() + handler.Handle(unauth, httptest.NewRequest(http.MethodGet, "/v0/management/model-prices/runtime-models", nil)) + if unauth.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", unauth.Code) + } + + req := httptest.NewRequest(http.MethodGet, "/v0/management/model-prices/runtime-models", nil) + req.Header.Set("Authorization", "Bearer "+testutil.AdminKey) + recorder := httptest.NewRecorder() + handler.Handle(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body: %s", recorder.Code, recorder.Body.String()) + } + + var status modelpricesvc.RuntimeModelPricingStatus + if err := json.NewDecoder(recorder.Body).Decode(&status); err != nil { + t.Fatalf("decode: %v", err) + } + if status.Count != 2 || status.UnpricedCount != 2 { + t.Fatalf("expected count=2 and unpricedCount=2, got %d, %d", status.Count, status.UnpricedCount) + } +} diff --git a/apps/manager-server/internal/service/modelprice/service.go b/apps/manager-server/internal/service/modelprice/service.go index ee3b1885a..426dd7b37 100644 --- a/apps/manager-server/internal/service/modelprice/service.go +++ b/apps/manager-server/internal/service/modelprice/service.go @@ -319,7 +319,7 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) discoveryTimeout = defaultRuntimeModelDiscoveryTimeout } discoveryCtx, cancel := context.WithTimeout(ctx, discoveryTimeout) - runtimeModels, err := s.discoverRuntimeModels(discoveryCtx) + runtimeModels, err := s.DiscoverRuntimeModels(discoveryCtx) cancel() if err != nil { @@ -403,7 +403,56 @@ func (s *Service) Sync(ctx context.Context, req SyncRequest) (SyncResult, error) }, nil } -func (s *Service) discoverRuntimeModels(ctx context.Context) ([]string, error) { +type RuntimeModelPricingStatus struct { + Models []string `json:"models"` + UnpricedModels []string `json:"unpricedModels"` + Count int `json:"count"` + UnpricedCount int `json:"unpricedCount"` +} + +func (s *Service) RuntimeModelPricingStatus(ctx context.Context) (RuntimeModelPricingStatus, error) { + discoveryTimeout := s.runtimeModelDiscoveryTimeout + if discoveryTimeout <= 0 { + discoveryTimeout = defaultRuntimeModelDiscoveryTimeout + } + discoveryCtx, cancel := context.WithTimeout(ctx, discoveryTimeout) + models, err := s.DiscoverRuntimeModels(discoveryCtx) + cancel() + if err != nil { + return RuntimeModelPricingStatus{}, err + } + + prices, err := s.store.LoadModelPrices(ctx) + if err != nil { + return RuntimeModelPricingStatus{}, err + } + + normalizedModels := normalizedRequestedModels(models) + sort.Strings(normalizedModels) + + unpricedModels := make([]string, 0, len(normalizedModels)) + for _, m := range normalizedModels { + if _, exists := prices[m]; !exists { + unpricedModels = append(unpricedModels, m) + } + } + + if normalizedModels == nil { + normalizedModels = []string{} + } + if unpricedModels == nil { + unpricedModels = []string{} + } + + return RuntimeModelPricingStatus{ + Models: normalizedModels, + UnpricedModels: unpricedModels, + Count: len(normalizedModels), + UnpricedCount: len(unpricedModels), + }, nil +} + +func (s *Service) DiscoverRuntimeModels(ctx context.Context) ([]string, error) { if s.setupResolver == nil { return nil, errors.New("runtime model discovery failed: missing setup resolver") } diff --git a/apps/manager-server/internal/service/modelprice/service_test.go b/apps/manager-server/internal/service/modelprice/service_test.go index d276b5ce4..b4f3b3f4f 100644 --- a/apps/manager-server/internal/service/modelprice/service_test.go +++ b/apps/manager-server/internal/service/modelprice/service_test.go @@ -1844,3 +1844,212 @@ func TestSyncPreferredSourceFailurePreservationWithRuntimeModels(t *testing.T) { t.Fatalf("expected models.dev price to be preserved, got %#v", p) } } + +func TestRuntimeModelPricingStatus(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + initialPrices := map[string]store.ModelPrice{ + "synced-model": { + Prompt: 1.0, + Completion: 2.0, + Source: SyncSourceLiteLLM, + }, + "manual-model": { + Prompt: 0.5, + Completion: 1.5, + Source: "manual", + }, + } + if err := st.SaveModelPrices(context.Background(), initialPrices); err != nil { + t.Fatalf("save initial prices: %v", err) + } + + remoteCalled := false + remoteServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + remoteCalled = true + http.Error(w, "should not call remote price sources", http.StatusInternalServerError) + })) + defer remoteServer.Close() + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"secret-key-1", "secret-key-2"}, + }) + case "/v1/models": + if r.Header.Get("Authorization") != "Bearer secret-key-1" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "zebra-model"}, + {"id": "synced-model"}, + {"id": "alpha-model"}, + {"id": "manual-model"}, + {"id": "alpha-model"}, + {"id": " "}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-secret-key", + }, + } + remoteURL := remoteServer.URL + svc := New(st, &remoteURL, resolver) + + status, err := svc.RuntimeModelPricingStatus(context.Background()) + if err != nil { + t.Fatalf("RuntimeModelPricingStatus failed: %v", err) + } + + if remoteCalled { + t.Fatal("RuntimeModelPricingStatus must not call remote price sources") + } + + expectedModels := []string{"alpha-model", "manual-model", "synced-model", "zebra-model"} + if len(status.Models) != len(expectedModels) { + t.Fatalf("expected %d models, got %d: %#v", len(expectedModels), len(status.Models), status.Models) + } + for i, m := range expectedModels { + if status.Models[i] != m { + t.Fatalf("expected model at %d to be %s, got %s", i, m, status.Models[i]) + } + } + if status.Count != 4 { + t.Fatalf("expected count 4, got %d", status.Count) + } + + expectedUnpriced := []string{"alpha-model", "zebra-model"} + if len(status.UnpricedModels) != len(expectedUnpriced) { + t.Fatalf("expected %d unpriced models, got %d: %#v", len(expectedUnpriced), len(status.UnpricedModels), status.UnpricedModels) + } + for i, m := range expectedUnpriced { + if status.UnpricedModels[i] != m { + t.Fatalf("expected unpriced model at %d to be %s, got %s", i, m, status.UnpricedModels[i]) + } + } + if status.UnpricedCount != 2 { + t.Fatalf("expected unpricedCount 2, got %d", status.UnpricedCount) + } + + storedPrices, err := st.LoadModelPrices(context.Background()) + if err != nil { + t.Fatalf("load prices: %v", err) + } + if len(storedPrices) != 2 { + t.Fatalf("expected 2 stored prices, got %d", len(storedPrices)) + } +} + +func TestRuntimeModelPricingStatus_AllUnpricedWhenNoPrices(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"test-key"}, + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": "m1"}, + {"id": "m2"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + svc := New(st, nil, resolver) + + status, err := svc.RuntimeModelPricingStatus(context.Background()) + if err != nil { + t.Fatalf("RuntimeModelPricingStatus failed: %v", err) + } + if status.Count != 2 || status.UnpricedCount != 2 { + t.Fatalf("expected count=2 and unpricedCount=2, got count=%d, unpriced=%d", status.Count, status.UnpricedCount) + } + if len(status.UnpricedModels) != 2 || status.UnpricedModels[0] != "m1" || status.UnpricedModels[1] != "m2" { + t.Fatalf("unexpected unpriced models: %#v", status.UnpricedModels) + } +} + +func TestRuntimeModelPricingStatus_ZeroModels(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v0/management/api-keys": + _ = json.NewEncoder(w).Encode(map[string]any{ + "api-keys": []string{"test-key"}, + }) + case "/v1/models": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{}, + }) + default: + http.NotFound(w, r) + } + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + svc := New(st, nil, resolver) + + status, err := svc.RuntimeModelPricingStatus(context.Background()) + if err != nil { + t.Fatalf("RuntimeModelPricingStatus failed: %v", err) + } + if status.Count != 0 || status.UnpricedCount != 0 { + t.Fatalf("expected count=0, unpricedCount=0, got %d, %d", status.Count, status.UnpricedCount) + } + if status.Models == nil || status.UnpricedModels == nil { + t.Fatal("expected models and unpricedModels to be non-nil empty slices") + } +} + +func TestRuntimeModelPricingStatus_DiscoveryFailure(t *testing.T) { + st := testutil.NewStore(t, testutil.NewConfig(t)) + + cpaServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "cpa internal error", http.StatusInternalServerError) + })) + defer cpaServer.Close() + + resolver := staticSetupResolver{ + setup: store.Setup{ + CPAUpstreamURL: cpaServer.URL, + ManagementKey: "mgmt-key", + }, + } + svc := New(st, nil, resolver) + + _, err := svc.RuntimeModelPricingStatus(context.Background()) + if err == nil { + t.Fatal("expected error on discovery failure, got nil") + } +} diff --git a/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss b/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss index 5679a61b6..59e04fb34 100644 --- a/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss +++ b/apps/web/src/features/dashboard/components/UsageMetricsCard.module.scss @@ -54,6 +54,13 @@ text-overflow: ellipsis; white-space: nowrap; } + + .metricExtraAction { + margin-left: auto; + display: flex; + align-items: center; + z-index: 2; + } } .metricBody { diff --git a/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx b/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx index 8c4dc4e8f..da44c9a8b 100644 --- a/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx +++ b/apps/web/src/features/dashboard/components/UsageMetricsCard.tsx @@ -11,6 +11,7 @@ import { useThemeStore } from '@/stores'; import type { DashboardSummaryResponse } from '@/services/api/usageService'; import { getDataPalette } from '@/utils/dataPalette'; import { formatCompactNumber, formatDurationMs, formatUsd } from '@/utils/usage'; +import { ModelPriceAttentionLink } from '@/features/model-price-attention'; import styles from './UsageMetricsCard.module.scss'; interface UsageMetricsCardProps { @@ -43,17 +44,19 @@ interface MetricCardProps { icon: ReactNode; color: string; loading: boolean; + extraAction?: ReactNode; } type MetricStyle = CSSProperties & Record<'--accent-color', string>; type RankStyle = CSSProperties & Record<'--share', number>; -function MetricCard({ label, value, subValue, icon, color, loading }: MetricCardProps) { +function MetricCard({ label, value, subValue, icon, color, loading, extraAction }: MetricCardProps) { return (
{icon}
{label} + {extraAction ?
{extraAction}
: null}
{loading ? '...' : value}
@@ -125,6 +128,7 @@ export function UsageMetricsCard({ : undefined, icon: , color: dataPalette.amber, + extraAction: , }, { label: t('dashboard.success_rate'), diff --git a/apps/web/src/features/demo/demoFixtures.empty.ts b/apps/web/src/features/demo/demoFixtures.empty.ts index 64dacad39..9400ee599 100644 --- a/apps/web/src/features/demo/demoFixtures.empty.ts +++ b/apps/web/src/features/demo/demoFixtures.empty.ts @@ -35,6 +35,12 @@ export const getDemoModelPriceUsageSummary = () => ({ truncated: false, models: [], }); +export const getDemoRuntimeModelPricingStatus = () => ({ + models: [], + unpricedModels: [], + count: 0, + unpricedCount: 0, +}); export const getDemoUsagePayload = () => emptyObject; export const getDemoUsageServiceInfo = () => emptyObject; export const getDemoUsageServiceStatus = () => emptyObject; diff --git a/apps/web/src/features/demo/demoFixtures.ts b/apps/web/src/features/demo/demoFixtures.ts index 7d5e5565a..1ec4f0a08 100644 --- a/apps/web/src/features/demo/demoFixtures.ts +++ b/apps/web/src/features/demo/demoFixtures.ts @@ -16,6 +16,7 @@ import type { MonitoringAnalyticsRequest, MonitoringAnalyticsResponse, QuotaCooldownInfo, + RuntimeModelPricingStatusResponse, UsageHeaderSnapshotsResponse, UsageServiceInfo, UsageServiceStatus, @@ -5674,6 +5675,15 @@ export const getDemoAccountWindowUsage = ( }; export const getDemoModelPrices = () => clone(demoModelPrices); export const getDemoModelPriceUsageSummary = () => clone(demoModelPriceUsageSummary); +export const getDemoRuntimeModelPricingStatus = (): RuntimeModelPricingStatusResponse => { + const models = Object.keys(demoModelPrices.prices).sort(); + return { + models, + unpricedModels: [], + count: models.length, + unpricedCount: 0, + }; +}; export const getDemoUsagePayload = () => { const dashboard = dashboardBase(); return { diff --git a/apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss b/apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss new file mode 100644 index 000000000..840210b64 --- /dev/null +++ b/apps/web/src/features/model-price-attention/ModelPriceAttention.module.scss @@ -0,0 +1,71 @@ +@use "@/styles/variables.scss" as *; + +.attentionDot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); + flex-shrink: 0; + vertical-align: middle; +} + +.inlineLink { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 7px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: #d97706; + background-color: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.25); + text-decoration: none; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.15s ease; + white-space: nowrap; + + &:hover { + background-color: rgba(245, 158, 11, 0.18); + border-color: rgba(245, 158, 11, 0.4); + transform: translateY(-1px); + color: #b45309; + } + + &:focus-visible { + outline: 2px solid #f59e0b; + outline-offset: 1px; + } +} + +.pendingBadge { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 4px; + background-color: rgba(245, 158, 11, 0.12); + color: #d97706; + border: 1px solid rgba(245, 158, 11, 0.3); + margin-left: 6px; + vertical-align: middle; +} + +.syncButtonBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 11px; + font-weight: 700; + background-color: #f59e0b; + color: #ffffff; + margin-left: 6px; + line-height: 1; +} diff --git a/apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx b/apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx new file mode 100644 index 000000000..c278e0f7f --- /dev/null +++ b/apps/web/src/features/model-price-attention/ModelPriceAttentionDot.tsx @@ -0,0 +1,15 @@ +import styles from './ModelPriceAttention.module.scss'; + +export interface ModelPriceAttentionDotProps { + className?: string; +} + +export function ModelPriceAttentionDot({ className }: ModelPriceAttentionDotProps) { + return ( +
@@ -265,6 +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" />
-
@@ -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,