Skip to content

Commit cb3b09c

Browse files
authored
Merge pull request #792 from seakee/fix/515-runtime-model-price-sync
🐛 fix(pricing): sync currently exposed CPA runtime models before first use (#515)
2 parents 4004861 + 1e999cc commit cb3b09c

13 files changed

Lines changed: 1075 additions & 68 deletions

File tree

apps/manager-server/internal/service/cpa/client.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"errors"
77
"fmt"
88
"net/http"
9+
"sort"
910
"strconv"
1011
"strings"
1112
"time"
@@ -193,3 +194,95 @@ func readStringField(raw map[string]any, keys ...string) string {
193194
}
194195
return ""
195196
}
197+
198+
func FetchAPIKeys(ctx context.Context, baseURL string, managementKey string) ([]string, error) {
199+
normalizedURL := NormalizeBaseURL(baseURL)
200+
if normalizedURL == "" {
201+
return nil, errors.New("CPA base URL cannot be empty")
202+
}
203+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizedURL+"/v0/management/api-keys", nil)
204+
if err != nil {
205+
return nil, err
206+
}
207+
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(managementKey))
208+
client := &http.Client{Timeout: 30 * time.Second}
209+
res, err := client.Do(req)
210+
if err != nil {
211+
return nil, err
212+
}
213+
defer res.Body.Close()
214+
if res.StatusCode < 200 || res.StatusCode >= 300 {
215+
return nil, fmt.Errorf("management API api-keys request failed: %s", res.Status)
216+
}
217+
218+
var payload struct {
219+
APIKeys []string `json:"api-keys"`
220+
}
221+
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
222+
return nil, err
223+
}
224+
225+
keys := make([]string, 0, len(payload.APIKeys))
226+
seen := make(map[string]struct{}, len(payload.APIKeys))
227+
for _, rawKey := range payload.APIKeys {
228+
trimmed := strings.TrimSpace(rawKey)
229+
if trimmed == "" {
230+
continue
231+
}
232+
if _, exists := seen[trimmed]; exists {
233+
continue
234+
}
235+
seen[trimmed] = struct{}{}
236+
keys = append(keys, trimmed)
237+
}
238+
return keys, nil
239+
}
240+
241+
func FetchModels(ctx context.Context, baseURL string, apiKey string) ([]string, error) {
242+
normalizedURL := NormalizeBaseURL(baseURL)
243+
if normalizedURL == "" {
244+
return nil, errors.New("CPA base URL cannot be empty")
245+
}
246+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizedURL+"/v1/models", nil)
247+
if err != nil {
248+
return nil, err
249+
}
250+
trimmedKey := strings.TrimSpace(apiKey)
251+
if trimmedKey != "" {
252+
req.Header.Set("Authorization", "Bearer "+trimmedKey)
253+
}
254+
client := &http.Client{Timeout: 30 * time.Second}
255+
res, err := client.Do(req)
256+
if err != nil {
257+
return nil, err
258+
}
259+
defer res.Body.Close()
260+
if res.StatusCode < 200 || res.StatusCode >= 300 {
261+
return nil, fmt.Errorf("runtime models request failed: %s", res.Status)
262+
}
263+
264+
var payload struct {
265+
Data []struct {
266+
ID string `json:"id"`
267+
} `json:"data"`
268+
}
269+
if err := json.NewDecoder(res.Body).Decode(&payload); err != nil {
270+
return nil, err
271+
}
272+
273+
seen := make(map[string]struct{}, len(payload.Data))
274+
models := make([]string, 0, len(payload.Data))
275+
for _, item := range payload.Data {
276+
trimmedID := strings.TrimSpace(item.ID)
277+
if trimmedID == "" {
278+
continue
279+
}
280+
if _, exists := seen[trimmedID]; exists {
281+
continue
282+
}
283+
seen[trimmedID] = struct{}{}
284+
models = append(models, trimmedID)
285+
}
286+
sort.Strings(models)
287+
return models, nil
288+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package cpa
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"reflect"
9+
"testing"
10+
)
11+
12+
func TestFetchAPIKeys(t *testing.T) {
13+
expectedToken := "test-mgmt-key"
14+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
15+
if r.URL.Path != "/v0/management/api-keys" {
16+
t.Errorf("unexpected path: %s", r.URL.Path)
17+
http.NotFound(w, r)
18+
return
19+
}
20+
auth := r.Header.Get("Authorization")
21+
if auth != "Bearer "+expectedToken {
22+
t.Errorf("unexpected auth header: %s", auth)
23+
http.Error(w, "unauthorized", http.StatusUnauthorized)
24+
return
25+
}
26+
w.Header().Set("Content-Type", "application/json")
27+
_ = json.NewEncoder(w).Encode(map[string]any{
28+
"api-keys": []string{
29+
" key-1 ",
30+
"",
31+
"key-2",
32+
"key-1",
33+
" ",
34+
"key-3",
35+
},
36+
})
37+
}))
38+
defer server.Close()
39+
40+
ctx := context.Background()
41+
keys, err := FetchAPIKeys(ctx, server.URL, expectedToken)
42+
if err != nil {
43+
t.Fatalf("FetchAPIKeys failed: %v", err)
44+
}
45+
46+
expected := []string{"key-1", "key-2", "key-3"}
47+
if !reflect.DeepEqual(keys, expected) {
48+
t.Errorf("expected keys %v, got %v", expected, keys)
49+
}
50+
}
51+
52+
func TestFetchModelsWithAPIKey(t *testing.T) {
53+
expectedToken := "client-key"
54+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
55+
if r.URL.Path != "/v1/models" {
56+
t.Errorf("unexpected path: %s", r.URL.Path)
57+
http.NotFound(w, r)
58+
return
59+
}
60+
auth := r.Header.Get("Authorization")
61+
if auth != "Bearer "+expectedToken {
62+
t.Errorf("unexpected auth header: %s", auth)
63+
http.Error(w, "unauthorized", http.StatusUnauthorized)
64+
return
65+
}
66+
w.Header().Set("Content-Type", "application/json")
67+
_ = json.NewEncoder(w).Encode(map[string]any{
68+
"object": "list",
69+
"data": []map[string]any{
70+
{"id": "gpt-b"},
71+
{"id": " gpt-a "},
72+
{"id": "gpt-a"},
73+
{"id": ""},
74+
},
75+
})
76+
}))
77+
defer server.Close()
78+
79+
ctx := context.Background()
80+
models, err := FetchModels(ctx, server.URL, expectedToken)
81+
if err != nil {
82+
t.Fatalf("FetchModels failed: %v", err)
83+
}
84+
85+
expected := []string{"gpt-a", "gpt-b"}
86+
if !reflect.DeepEqual(models, expected) {
87+
t.Errorf("expected models %v, got %v", expected, models)
88+
}
89+
}
90+
91+
func TestFetchModelsAnonymous(t *testing.T) {
92+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
93+
if r.URL.Path != "/v1/models" {
94+
t.Errorf("unexpected path: %s", r.URL.Path)
95+
http.NotFound(w, r)
96+
return
97+
}
98+
if auth := r.Header.Get("Authorization"); auth != "" {
99+
t.Errorf("expected no authorization header, got: %s", auth)
100+
}
101+
w.Header().Set("Content-Type", "application/json")
102+
_ = json.NewEncoder(w).Encode(map[string]any{
103+
"object": "list",
104+
"data": []map[string]any{
105+
{"id": "anon-model-2"},
106+
{"id": "anon-model-1"},
107+
},
108+
})
109+
}))
110+
defer server.Close()
111+
112+
ctx := context.Background()
113+
models, err := FetchModels(ctx, server.URL, "")
114+
if err != nil {
115+
t.Fatalf("FetchModels failed: %v", err)
116+
}
117+
118+
expected := []string{"anon-model-1", "anon-model-2"}
119+
if !reflect.DeepEqual(models, expected) {
120+
t.Errorf("expected models %v, got %v", expected, models)
121+
}
122+
}

0 commit comments

Comments
 (0)