Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
}
}
53 changes: 51 additions & 2 deletions apps/manager-server/internal/service/modelprice/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand Down
209 changes: 209 additions & 0 deletions apps/manager-server/internal/service/modelprice/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@
text-overflow: ellipsis;
white-space: nowrap;
}

.metricExtraAction {
margin-left: auto;
display: flex;
align-items: center;
z-index: 2;
}
}

.metricBody {
Expand Down
Loading
Loading