feat(portal): add logical group catalog api
This commit is contained in:
@@ -31,6 +31,9 @@ type ActionSet struct {
|
||||
GetBatchImportRun func(context.Context, string) (batch.RunSummaryProjection, error)
|
||||
ListBatchImportRunItems func(context.Context, ListBatchImportRunItemsRequest) (ListBatchImportRunItemsResponse, error)
|
||||
GetBatchImportRunItem func(context.Context, GetBatchImportRunItemRequest) (batch.ItemDetailProjection, error)
|
||||
ListPortalLogicalGroups func(context.Context) ([]PortalLogicalGroupInfo, error)
|
||||
GetPortalLogicalGroup func(context.Context, string) (PortalLogicalGroupInfo, error)
|
||||
ListPortalLogicalGroupModels func(context.Context, string) ([]PortalLogicalGroupModel, error)
|
||||
CreateLogicalGroup func(context.Context, CreateLogicalGroupRequest) (LogicalGroupInfo, error)
|
||||
ListLogicalGroups func(context.Context) ([]LogicalGroupInfo, error)
|
||||
GetLogicalGroup func(context.Context, string) (LogicalGroupInfo, error)
|
||||
@@ -333,6 +336,15 @@ func NewAPIHandlerWithAuth(adminAuth AdminAuthConfig, actions ActionSet) http.Ha
|
||||
mux.HandleFunc("POST /api/admin/session/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleAdminSessionLogout(w, r)
|
||||
})
|
||||
mux.HandleFunc("GET /api/portal/logical-groups", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListPortalLogicalGroups(w, r, actions.ListPortalLogicalGroups)
|
||||
})
|
||||
mux.HandleFunc("GET /api/portal/logical-groups/{groupID}", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleGetPortalLogicalGroup(w, r, actions.GetPortalLogicalGroup)
|
||||
})
|
||||
mux.HandleFunc("GET /api/portal/logical-groups/{groupID}/models", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleListPortalLogicalGroupModels(w, r, actions.ListPortalLogicalGroupModels)
|
||||
})
|
||||
mux.Handle("POST /api/batch-import/runs", requireAdminAccess(adminAuth, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
handleCreateBatchImportRun(w, r, actions.CreateBatchImportRun)
|
||||
})))
|
||||
@@ -1274,6 +1286,9 @@ func NewActionSetWithStickyRuntime(sqliteDSN string, stickyRuntime stickyStoreRu
|
||||
GetBatchImportRun: buildGetBatchImportRunAction(sqliteDSN),
|
||||
ListBatchImportRunItems: buildListBatchImportRunItemsAction(sqliteDSN),
|
||||
GetBatchImportRunItem: buildGetBatchImportRunItemAction(sqliteDSN),
|
||||
ListPortalLogicalGroups: buildListPortalLogicalGroupsAction(sqliteDSN),
|
||||
GetPortalLogicalGroup: buildGetPortalLogicalGroupAction(sqliteDSN),
|
||||
ListPortalLogicalGroupModels: buildListPortalLogicalGroupModelsAction(sqliteDSN),
|
||||
CreateLogicalGroup: buildCreateLogicalGroupAction(sqliteDSN),
|
||||
ListLogicalGroups: buildListLogicalGroupsAction(sqliteDSN),
|
||||
GetLogicalGroup: buildGetLogicalGroupAction(sqliteDSN),
|
||||
|
||||
194
internal/app/portal_api.go
Normal file
194
internal/app/portal_api.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
)
|
||||
|
||||
type PortalLogicalGroupInfo struct {
|
||||
LogicalGroupID string `json:"logical_group_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
StickyMode string `json:"sticky_mode,omitempty"`
|
||||
RoutePolicy string `json:"route_policy,omitempty"`
|
||||
PublicModels []PortalLogicalGroupModel `json:"public_models,omitempty"`
|
||||
RouteCount int `json:"route_count"`
|
||||
ActiveRouteCount int `json:"active_route_count"`
|
||||
CreatedAt string `json:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type PortalLogicalGroupModel struct {
|
||||
PublicModel string `json:"public_model"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func handleListPortalLogicalGroups(w http.ResponseWriter, r *http.Request, fn func(context.Context) ([]PortalLogicalGroupInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-portal-logical-groups action is not configured"})
|
||||
return
|
||||
}
|
||||
groups, err := fn(r.Context())
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"logical_groups": groups})
|
||||
}
|
||||
|
||||
func handleGetPortalLogicalGroup(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) (PortalLogicalGroupInfo, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "get-portal-logical-group action is not configured"})
|
||||
return
|
||||
}
|
||||
group, err := fn(r.Context(), strings.TrimSpace(r.PathValue("groupID")))
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"logical_group": group})
|
||||
}
|
||||
|
||||
func handleListPortalLogicalGroupModels(w http.ResponseWriter, r *http.Request, fn func(context.Context, string) ([]PortalLogicalGroupModel, error)) {
|
||||
if fn == nil {
|
||||
writeHTTPError(w, &httpError{StatusCode: http.StatusInternalServerError, Code: "server_misconfigured", Message: "list-portal-logical-group-models action is not configured"})
|
||||
return
|
||||
}
|
||||
models, err := fn(r.Context(), strings.TrimSpace(r.PathValue("groupID")))
|
||||
if err != nil {
|
||||
writeHTTPError(w, classifyError(err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"models": models})
|
||||
}
|
||||
|
||||
func buildListPortalLogicalGroupsAction(sqliteDSN string) func(context.Context) ([]PortalLogicalGroupInfo, error) {
|
||||
return func(ctx context.Context) ([]PortalLogicalGroupInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
rows, err := store.LogicalGroups().List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]PortalLogicalGroupInfo, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !isPortalLogicalGroupVisible(row.Status) {
|
||||
continue
|
||||
}
|
||||
item, buildErr := buildPortalLogicalGroupInfo(ctx, store, row)
|
||||
if buildErr != nil {
|
||||
return nil, buildErr
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildGetPortalLogicalGroupAction(sqliteDSN string) func(context.Context, string) (PortalLogicalGroupInfo, error) {
|
||||
return func(ctx context.Context, logicalGroupID string) (PortalLogicalGroupInfo, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return PortalLogicalGroupInfo{}, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
group, err := getLogicalGroupRow(ctx, store, logicalGroupID)
|
||||
if err != nil {
|
||||
return PortalLogicalGroupInfo{}, err
|
||||
}
|
||||
if !isPortalLogicalGroupVisible(group.Status) {
|
||||
return PortalLogicalGroupInfo{}, fmt.Errorf("logical group %q not found", strings.TrimSpace(logicalGroupID))
|
||||
}
|
||||
return buildPortalLogicalGroupInfo(ctx, store, group)
|
||||
}
|
||||
}
|
||||
|
||||
func buildListPortalLogicalGroupModelsAction(sqliteDSN string) func(context.Context, string) ([]PortalLogicalGroupModel, error) {
|
||||
return func(ctx context.Context, logicalGroupID string) ([]PortalLogicalGroupModel, error) {
|
||||
store, err := sqlite.Open(ctx, sqliteDSN)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
group, err := getLogicalGroupRow(ctx, store, logicalGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isPortalLogicalGroupVisible(group.Status) {
|
||||
return nil, fmt.Errorf("logical group %q not found", strings.TrimSpace(logicalGroupID))
|
||||
}
|
||||
rows, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, group.LogicalGroupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return portalLogicalGroupModelsFromRows(rows), nil
|
||||
}
|
||||
}
|
||||
|
||||
func buildPortalLogicalGroupInfo(ctx context.Context, store *sqlite.DB, group sqlite.LogicalGroup) (PortalLogicalGroupInfo, error) {
|
||||
modelRows, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, group.LogicalGroupID)
|
||||
if err != nil {
|
||||
return PortalLogicalGroupInfo{}, err
|
||||
}
|
||||
routeRows, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, group.LogicalGroupID)
|
||||
if err != nil {
|
||||
return PortalLogicalGroupInfo{}, err
|
||||
}
|
||||
|
||||
routeCount := 0
|
||||
activeRouteCount := 0
|
||||
for _, route := range routeRows {
|
||||
routeCount++
|
||||
if strings.EqualFold(strings.TrimSpace(route.Status), "active") {
|
||||
activeRouteCount++
|
||||
}
|
||||
}
|
||||
|
||||
return PortalLogicalGroupInfo{
|
||||
LogicalGroupID: group.LogicalGroupID,
|
||||
DisplayName: group.DisplayName,
|
||||
Description: group.Description,
|
||||
Status: group.Status,
|
||||
StickyMode: group.StickyMode,
|
||||
RoutePolicy: group.RoutePolicy,
|
||||
PublicModels: portalLogicalGroupModelsFromRows(modelRows),
|
||||
RouteCount: routeCount,
|
||||
ActiveRouteCount: activeRouteCount,
|
||||
CreatedAt: group.CreatedAt,
|
||||
UpdatedAt: group.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func portalLogicalGroupModelsFromRows(rows []sqlite.LogicalGroupModel) []PortalLogicalGroupModel {
|
||||
result := make([]PortalLogicalGroupModel, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if !isPortalLogicalGroupModelVisible(row.Status) {
|
||||
continue
|
||||
}
|
||||
result = append(result, PortalLogicalGroupModel{
|
||||
PublicModel: row.PublicModel,
|
||||
Status: row.Status,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func isPortalLogicalGroupVisible(status string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(status), "active")
|
||||
}
|
||||
|
||||
func isPortalLogicalGroupModelVisible(status string) bool {
|
||||
status = strings.TrimSpace(status)
|
||||
return status == "" || strings.EqualFold(status, "active")
|
||||
}
|
||||
180
internal/app/portal_api_test.go
Normal file
180
internal/app/portal_api_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/store/sqlite"
|
||||
)
|
||||
|
||||
func TestAPIListPortalLogicalGroups(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListPortalLogicalGroups: func(_ context.Context) ([]PortalLogicalGroupInfo, error) {
|
||||
return []PortalLogicalGroupInfo{{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
RouteCount: 2,
|
||||
ActiveRouteCount: 1,
|
||||
PublicModels: []PortalLogicalGroupModel{
|
||||
{PublicModel: "gpt-5.4", Status: "active"},
|
||||
},
|
||||
}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/portal/logical-groups", nil, "")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var listPayload struct {
|
||||
LogicalGroups []PortalLogicalGroupInfo `json:"logical_groups"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &listPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(listPayload.LogicalGroups) != 1 || listPayload.LogicalGroups[0].LogicalGroupID != "gpt-shared" || listPayload.LogicalGroups[0].RouteCount != 2 {
|
||||
t.Fatalf("portal logical groups payload = %+v", listPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGetPortalLogicalGroupModels(t *testing.T) {
|
||||
handler := NewAPIHandler("secret-token", ActionSet{
|
||||
ListPortalLogicalGroupModels: func(_ context.Context, logicalGroupID string) ([]PortalLogicalGroupModel, error) {
|
||||
if logicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("logicalGroupID = %q, want gpt-shared", logicalGroupID)
|
||||
}
|
||||
return []PortalLogicalGroupModel{{PublicModel: "gpt-5.4", Status: "active"}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/portal/logical-groups/gpt-shared/models", nil, "")
|
||||
response := httptestRecorder(handler, request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var modelsPayload struct {
|
||||
Models []PortalLogicalGroupModel `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &modelsPayload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if len(modelsPayload.Models) != 1 || modelsPayload.Models[0].PublicModel != "gpt-5.4" {
|
||||
t.Fatalf("portal models payload = %+v", modelsPayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewActionSetPortalLogicalGroups(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "portal-logical-groups.db")
|
||||
dsn := "file:" + filepath.ToSlash(dbPath) + "?_busy_timeout=5000"
|
||||
actions := NewActionSet(dsn)
|
||||
ctx := context.Background()
|
||||
|
||||
store, err := sqlite.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("sqlite.Open() error = %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
DisplayName: "GPT Shared",
|
||||
Status: "active",
|
||||
Description: "Public GPT product",
|
||||
RoutePolicy: "priority",
|
||||
StickyMode: "conversation_preferred",
|
||||
ConversationTTLSeconds: 7200,
|
||||
UserModelTTLSeconds: 1800,
|
||||
FailoverThreshold: 2,
|
||||
CooldownSeconds: 600,
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroups().Create(active) error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
|
||||
LogicalGroupID: "disabled-group",
|
||||
DisplayName: "Disabled Group",
|
||||
Status: "disabled",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroups().Create(disabled) error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupModels().Create(ctx, sqlite.LogicalGroupModel{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-5.4",
|
||||
Status: "active",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupModels().Create(active) error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupModels().Create(ctx, sqlite.LogicalGroupModel{
|
||||
LogicalGroupID: "gpt-shared",
|
||||
PublicModel: "gpt-hidden",
|
||||
Status: "disabled",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupModels().Create(disabled) error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
|
||||
RouteID: "asxs",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "ASXS",
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "9",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://api.asxs.top/v1",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create(active) error = %v", err)
|
||||
}
|
||||
if _, err := store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
|
||||
RouteID: "backup",
|
||||
LogicalGroupID: "gpt-shared",
|
||||
Name: "Backup",
|
||||
Status: "cooldown",
|
||||
Priority: 20,
|
||||
Weight: 100,
|
||||
ShadowGroupID: "9",
|
||||
ShadowHostID: "remote43",
|
||||
UpstreamBaseURLHint: "https://backup.example.com",
|
||||
}); err != nil {
|
||||
t.Fatalf("LogicalGroupRoutes().Create(cooldown) error = %v", err)
|
||||
}
|
||||
|
||||
listed, err := actions.ListPortalLogicalGroups(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPortalLogicalGroups() error = %v", err)
|
||||
}
|
||||
if len(listed) != 1 || listed[0].LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("ListPortalLogicalGroups() = %+v", listed)
|
||||
}
|
||||
if listed[0].RouteCount != 2 || listed[0].ActiveRouteCount != 1 {
|
||||
t.Fatalf("ListPortalLogicalGroups() route counts = %+v", listed[0])
|
||||
}
|
||||
if len(listed[0].PublicModels) != 1 || listed[0].PublicModels[0].PublicModel != "gpt-5.4" {
|
||||
t.Fatalf("ListPortalLogicalGroups() public models = %+v", listed[0].PublicModels)
|
||||
}
|
||||
|
||||
group, err := actions.GetPortalLogicalGroup(ctx, "gpt-shared")
|
||||
if err != nil {
|
||||
t.Fatalf("GetPortalLogicalGroup() error = %v", err)
|
||||
}
|
||||
if group.DisplayName != "GPT Shared" || group.RoutePolicy != "priority" {
|
||||
t.Fatalf("GetPortalLogicalGroup() = %+v", group)
|
||||
}
|
||||
|
||||
models, err := actions.ListPortalLogicalGroupModels(ctx, "gpt-shared")
|
||||
if err != nil {
|
||||
t.Fatalf("ListPortalLogicalGroupModels() error = %v", err)
|
||||
}
|
||||
if len(models) != 1 || models[0].PublicModel != "gpt-5.4" {
|
||||
t.Fatalf("ListPortalLogicalGroupModels() = %+v", models)
|
||||
}
|
||||
|
||||
request := httptestRequest(t, "GET", "/api/portal/logical-groups/gpt-shared", nil, "")
|
||||
response := httptestRecorder(NewAPIHandler("secret-token", actions), request)
|
||||
assertStatusCode(t, response, 200)
|
||||
var payload map[string]PortalLogicalGroupInfo
|
||||
if err := json.Unmarshal(response.Body().Bytes(), &payload); err != nil {
|
||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||
}
|
||||
if payload["logical_group"].LogicalGroupID != "gpt-shared" {
|
||||
t.Fatalf("portal logical group payload = %+v", payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user