feat(vnext): complete vNext.1 release gate — default chain admission, idempotent init, user key skeleton
- DEFAULT_CHAIN_ADMISSION.md: reviewed and approved, real artifact refs added - DEFAULT_DATA_IDEMPOTENT_RELEASE_GATE.md: reviewed and approved - scripts/setup_default_data.sh: idempotent init with --dry-run/--apply/artifact - scripts/test/test_default_data.sh: 4 test cases all pass - scripts/acceptance/verify_user_key_self_service.sh: Phase 0 skeleton - .gitignore: add generated artifact directories
This commit is contained in:
68
internal/host/sub2api/capability_inventory.go
Normal file
68
internal/host/sub2api/capability_inventory.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package sub2api
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/probe"
|
||||
)
|
||||
|
||||
const (
|
||||
SupportLevelDirect = "supported-direct"
|
||||
SupportLevelWithPluginAdapter = "supported-with-plugin-adapter"
|
||||
SupportLevelUnsupportedByHost = "unsupported-by-host"
|
||||
SupportLevelUpstreamUnhealthy = "upstream-unhealthy"
|
||||
)
|
||||
|
||||
type CapabilityInventory struct {
|
||||
HostReady bool `json:"host_ready"`
|
||||
Host HostCapabilities `json:"host"`
|
||||
Models []ModelCapabilitySummary `json:"models"`
|
||||
}
|
||||
|
||||
type ModelCapabilitySummary struct {
|
||||
RawModelID string `json:"raw_model_id"`
|
||||
CanonicalModelFamily string `json:"canonical_model_family"`
|
||||
SmokeChatOK bool `json:"smoke_chat_ok"`
|
||||
SupportLevel string `json:"support_level"`
|
||||
KnownAdvisories []string `json:"known_advisories,omitempty"`
|
||||
}
|
||||
|
||||
func BuildCapabilityInventory(hostCaps HostCapabilities, profile *probe.CapabilityProfile) CapabilityInventory {
|
||||
inventory := CapabilityInventory{
|
||||
HostReady: hasMinimumHostCapabilities(hostCaps),
|
||||
Host: hostCaps,
|
||||
Models: []ModelCapabilitySummary{},
|
||||
}
|
||||
if profile == nil {
|
||||
return inventory
|
||||
}
|
||||
|
||||
advisories := append([]string(nil), profile.TransportProfile.KnownAdvisories...)
|
||||
for _, model := range profile.ModelProfiles {
|
||||
inventory.Models = append(inventory.Models, ModelCapabilitySummary{
|
||||
RawModelID: strings.TrimSpace(model.RawModelID),
|
||||
CanonicalModelFamily: strings.TrimSpace(model.CanonicalModelFamily),
|
||||
SmokeChatOK: model.SmokeChatOK,
|
||||
SupportLevel: classifySupportLevel(profile.TransportProfile, model),
|
||||
KnownAdvisories: advisories,
|
||||
})
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
func hasMinimumHostCapabilities(c HostCapabilities) bool {
|
||||
return c.Groups && c.Channels && c.Accounts && c.AccountTest
|
||||
}
|
||||
|
||||
func classifySupportLevel(transport probe.TransportProfile, model probe.ModelCapabilityProfile) string {
|
||||
if !model.SmokeChatOK {
|
||||
return SupportLevelUpstreamUnhealthy
|
||||
}
|
||||
if transport.SupportsOpenAIChatCompletions && transport.SupportsOpenAIResponses {
|
||||
return SupportLevelDirect
|
||||
}
|
||||
if transport.SupportsOpenAIChatCompletions {
|
||||
return SupportLevelWithPluginAdapter
|
||||
}
|
||||
return SupportLevelUnsupportedByHost
|
||||
}
|
||||
76
internal/host/sub2api/capability_inventory_test.go
Normal file
76
internal/host/sub2api/capability_inventory_test.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package sub2api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/probe"
|
||||
)
|
||||
|
||||
func TestBuildCapabilityInventoryClassifiesSupportedWithAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inventory := BuildCapabilityInventory(
|
||||
HostCapabilities{
|
||||
Groups: true,
|
||||
Channels: true,
|
||||
Plans: true,
|
||||
Accounts: true,
|
||||
AccountTest: true,
|
||||
AccountModels: true,
|
||||
Subscriptions: true,
|
||||
},
|
||||
&probe.CapabilityProfile{
|
||||
TransportProfile: probe.TransportProfile{
|
||||
SupportsOpenAIModels: true,
|
||||
SupportsOpenAIChatCompletions: true,
|
||||
SupportsOpenAIResponses: false,
|
||||
KnownAdvisories: []string{"responses_unsupported_but_chat_ok"},
|
||||
},
|
||||
ModelProfiles: []probe.ModelCapabilityProfile{{
|
||||
RawModelID: "kimi-k2.6",
|
||||
CanonicalModelFamily: "kimi-k2.6",
|
||||
SmokeChatOK: true,
|
||||
}},
|
||||
},
|
||||
)
|
||||
|
||||
if !inventory.HostReady {
|
||||
t.Fatal("HostReady = false, want true")
|
||||
}
|
||||
if len(inventory.Models) != 1 {
|
||||
t.Fatalf("len(Models) = %d, want 1", len(inventory.Models))
|
||||
}
|
||||
if inventory.Models[0].SupportLevel != SupportLevelWithPluginAdapter {
|
||||
t.Fatalf("SupportLevel = %q, want %q", inventory.Models[0].SupportLevel, SupportLevelWithPluginAdapter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCapabilityInventoryClassifiesUpstreamUnhealthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inventory := BuildCapabilityInventory(
|
||||
HostCapabilities{Groups: true, Channels: true, Accounts: true, AccountTest: false},
|
||||
&probe.CapabilityProfile{
|
||||
TransportProfile: probe.TransportProfile{
|
||||
SupportsOpenAIModels: true,
|
||||
SupportsOpenAIChatCompletions: false,
|
||||
SupportsOpenAIResponses: false,
|
||||
},
|
||||
ModelProfiles: []probe.ModelCapabilityProfile{{
|
||||
RawModelID: "glm-4.5",
|
||||
CanonicalModelFamily: "glm-4.5",
|
||||
SmokeChatOK: false,
|
||||
}},
|
||||
},
|
||||
)
|
||||
|
||||
if inventory.HostReady {
|
||||
t.Fatal("HostReady = true, want false when required host capabilities are missing")
|
||||
}
|
||||
if len(inventory.Models) != 1 {
|
||||
t.Fatalf("len(Models) = %d, want 1", len(inventory.Models))
|
||||
}
|
||||
if inventory.Models[0].SupportLevel != SupportLevelUpstreamUnhealthy {
|
||||
t.Fatalf("SupportLevel = %q, want %q", inventory.Models[0].SupportLevel, SupportLevelUpstreamUnhealthy)
|
||||
}
|
||||
}
|
||||
218
internal/provision/model_pool.go
Normal file
218
internal/provision/model_pool.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
"sub2api-cn-relay-manager/internal/pack"
|
||||
)
|
||||
|
||||
type ModelPoolBuildRequest struct {
|
||||
PublicModel string
|
||||
AllowPluginAdapterCandidates bool
|
||||
Candidates []ModelPoolCandidate
|
||||
}
|
||||
|
||||
type ModelPoolCandidate struct {
|
||||
RouteID string
|
||||
Provider pack.ProviderManifest
|
||||
Priority int
|
||||
Schedulable *bool
|
||||
AdvertisedModel string
|
||||
CallableModel string
|
||||
Inventory sub2api.CapabilityInventory
|
||||
CooldownUntil string
|
||||
DisableReason string
|
||||
}
|
||||
|
||||
type ModelPool struct {
|
||||
PublicModel string
|
||||
CanonicalModelFamily string
|
||||
Routes []PoolRoute
|
||||
}
|
||||
|
||||
type PoolRoute struct {
|
||||
RouteID string
|
||||
ProviderID string
|
||||
DisplayName string
|
||||
BaseURL string
|
||||
PublicModel string
|
||||
AdvertisedModel string
|
||||
CallableModel string
|
||||
CanonicalModelFamily string
|
||||
Priority int
|
||||
Schedulable bool
|
||||
SupportLevel string
|
||||
SupportedModels []string
|
||||
SupportsChat bool
|
||||
SupportsResponses bool
|
||||
CooldownUntil string
|
||||
DisableReason string
|
||||
KnownAdvisories []string
|
||||
}
|
||||
|
||||
func BuildModelPool(req ModelPoolBuildRequest) (ModelPool, error) {
|
||||
publicModel := strings.TrimSpace(req.PublicModel)
|
||||
if publicModel == "" {
|
||||
return ModelPool{}, fmt.Errorf("public_model is required")
|
||||
}
|
||||
|
||||
routes := make([]PoolRoute, 0, len(req.Candidates))
|
||||
canonicalFamily := ""
|
||||
for _, candidate := range req.Candidates {
|
||||
route, ok, err := buildPoolRoute(publicModel, req.AllowPluginAdapterCandidates, candidate)
|
||||
if err != nil {
|
||||
return ModelPool{}, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if canonicalFamily == "" {
|
||||
canonicalFamily = route.CanonicalModelFamily
|
||||
}
|
||||
routes = append(routes, route)
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
return ModelPool{}, fmt.Errorf("no eligible routes for public_model %q", publicModel)
|
||||
}
|
||||
|
||||
sort.SliceStable(routes, func(i, j int) bool {
|
||||
if routes[i].Priority != routes[j].Priority {
|
||||
return routes[i].Priority < routes[j].Priority
|
||||
}
|
||||
return routes[i].RouteID < routes[j].RouteID
|
||||
})
|
||||
|
||||
if canonicalFamily == "" {
|
||||
canonicalFamily = publicModel
|
||||
}
|
||||
return ModelPool{
|
||||
PublicModel: publicModel,
|
||||
CanonicalModelFamily: canonicalFamily,
|
||||
Routes: routes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildPoolRoute(publicModel string, allowPluginAdapter bool, candidate ModelPoolCandidate) (PoolRoute, bool, error) {
|
||||
routeID := strings.TrimSpace(candidate.RouteID)
|
||||
if routeID == "" {
|
||||
return PoolRoute{}, false, fmt.Errorf("route_id is required")
|
||||
}
|
||||
if !candidate.Inventory.HostReady {
|
||||
return PoolRoute{}, false, nil
|
||||
}
|
||||
if candidate.Schedulable != nil && !*candidate.Schedulable {
|
||||
return PoolRoute{}, false, nil
|
||||
}
|
||||
|
||||
modelSummary, found := findModelSummary(candidate.Inventory, publicModel)
|
||||
if !found {
|
||||
return PoolRoute{}, false, nil
|
||||
}
|
||||
if !isEligibleSupportLevel(modelSummary.SupportLevel, allowPluginAdapter) {
|
||||
return PoolRoute{}, false, nil
|
||||
}
|
||||
|
||||
callableModel := strings.TrimSpace(candidate.CallableModel)
|
||||
if callableModel == "" {
|
||||
callableModel = resolveCallableModel(publicModel, candidate.Provider)
|
||||
}
|
||||
advertisedModel := strings.TrimSpace(candidate.AdvertisedModel)
|
||||
if advertisedModel == "" {
|
||||
advertisedModel = publicModel
|
||||
}
|
||||
schedulable := true
|
||||
if candidate.Schedulable != nil {
|
||||
schedulable = *candidate.Schedulable
|
||||
}
|
||||
|
||||
supportedModels := collectSupportedModels(candidate.Inventory)
|
||||
supportsResponses := !contains(modelSummary.KnownAdvisories, "responses_unsupported_but_chat_ok")
|
||||
|
||||
return PoolRoute{
|
||||
RouteID: routeID,
|
||||
ProviderID: strings.TrimSpace(candidate.Provider.ProviderID),
|
||||
DisplayName: strings.TrimSpace(candidate.Provider.DisplayName),
|
||||
BaseURL: strings.TrimSpace(candidate.Provider.BaseURL),
|
||||
PublicModel: publicModel,
|
||||
AdvertisedModel: advertisedModel,
|
||||
CallableModel: callableModel,
|
||||
CanonicalModelFamily: strings.TrimSpace(modelSummary.CanonicalModelFamily),
|
||||
Priority: candidate.Priority,
|
||||
Schedulable: schedulable,
|
||||
SupportLevel: strings.TrimSpace(modelSummary.SupportLevel),
|
||||
SupportedModels: supportedModels,
|
||||
SupportsChat: modelSummary.SmokeChatOK,
|
||||
SupportsResponses: supportsResponses,
|
||||
CooldownUntil: strings.TrimSpace(candidate.CooldownUntil),
|
||||
DisableReason: strings.TrimSpace(candidate.DisableReason),
|
||||
KnownAdvisories: append([]string(nil), modelSummary.KnownAdvisories...),
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func findModelSummary(inventory sub2api.CapabilityInventory, publicModel string) (sub2api.ModelCapabilitySummary, bool) {
|
||||
trimmed := strings.TrimSpace(publicModel)
|
||||
for _, model := range inventory.Models {
|
||||
if strings.EqualFold(strings.TrimSpace(model.RawModelID), trimmed) || strings.EqualFold(strings.TrimSpace(model.CanonicalModelFamily), trimmed) {
|
||||
return model, true
|
||||
}
|
||||
}
|
||||
return sub2api.ModelCapabilitySummary{}, false
|
||||
}
|
||||
|
||||
func isEligibleSupportLevel(level string, allowPluginAdapter bool) bool {
|
||||
switch strings.TrimSpace(level) {
|
||||
case sub2api.SupportLevelDirect:
|
||||
return true
|
||||
case sub2api.SupportLevelWithPluginAdapter:
|
||||
return allowPluginAdapter
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveCallableModel(publicModel string, provider pack.ProviderManifest) string {
|
||||
trimmed := strings.TrimSpace(publicModel)
|
||||
if mapped, ok := provider.ChannelTemplate.ModelMapping[trimmed]; ok && strings.TrimSpace(mapped) != "" {
|
||||
return strings.TrimSpace(mapped)
|
||||
}
|
||||
if smoke := strings.TrimSpace(provider.SmokeTestModel); smoke != "" {
|
||||
return smoke
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func collectSupportedModels(inventory sub2api.CapabilityInventory) []string {
|
||||
models := make([]string, 0, len(inventory.Models))
|
||||
seen := make(map[string]struct{}, len(inventory.Models))
|
||||
for _, model := range inventory.Models {
|
||||
candidate := strings.TrimSpace(model.RawModelID)
|
||||
if candidate == "" {
|
||||
candidate = strings.TrimSpace(model.CanonicalModelFamily)
|
||||
}
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
models = append(models, candidate)
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
target = strings.TrimSpace(target)
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
251
internal/provision/model_pool_test.go
Normal file
251
internal/provision/model_pool_test.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
"sub2api-cn-relay-manager/internal/pack"
|
||||
)
|
||||
|
||||
func TestBuildModelPoolFiltersUnsupportedRoutesAndSortsByPriority(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
providerA := sampleProviderManifest()
|
||||
providerA.ProviderID = "deepseek-official"
|
||||
providerA.DisplayName = "DeepSeek Official"
|
||||
providerA.BaseURL = "https://api.deepseek.com/v1"
|
||||
|
||||
providerB := sampleProviderManifest()
|
||||
providerB.ProviderID = "deepseek-backup"
|
||||
providerB.DisplayName = "DeepSeek Backup"
|
||||
providerB.BaseURL = "https://backup.deepseek.example.com/v1"
|
||||
|
||||
providerC := sampleProviderManifest()
|
||||
providerC.ProviderID = "deepseek-bad"
|
||||
providerC.DisplayName = "DeepSeek Bad"
|
||||
providerC.BaseURL = "https://bad.deepseek.example.com/v1"
|
||||
|
||||
pool, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "deepseek-chat",
|
||||
Candidates: []ModelPoolCandidate{
|
||||
{
|
||||
RouteID: "route-backup",
|
||||
Provider: providerB,
|
||||
Priority: 20,
|
||||
Inventory: capabilityInventoryWithSupport("deepseek-chat", "deepseek-chat", sub2api.SupportLevelDirect, true, true),
|
||||
},
|
||||
{
|
||||
RouteID: "route-primary",
|
||||
Provider: providerA,
|
||||
Priority: 10,
|
||||
Inventory: capabilityInventoryWithSupport("deepseek-chat", "deepseek-chat", sub2api.SupportLevelDirect, true, true),
|
||||
},
|
||||
{
|
||||
RouteID: "route-bad",
|
||||
Provider: providerC,
|
||||
Priority: 5,
|
||||
Inventory: capabilityInventoryWithSupport("deepseek-chat", "deepseek-chat", sub2api.SupportLevelUpstreamUnhealthy, true, false),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModelPool() error = %v", err)
|
||||
}
|
||||
if len(pool.Routes) != 2 {
|
||||
t.Fatalf("len(pool.Routes) = %d, want 2", len(pool.Routes))
|
||||
}
|
||||
if pool.Routes[0].RouteID != "route-primary" || pool.Routes[1].RouteID != "route-backup" {
|
||||
t.Fatalf("route order = [%s %s], want [route-primary route-backup]", pool.Routes[0].RouteID, pool.Routes[1].RouteID)
|
||||
}
|
||||
if pool.Routes[0].SupportLevel != sub2api.SupportLevelDirect {
|
||||
t.Fatalf("pool.Routes[0].SupportLevel = %q, want %q", pool.Routes[0].SupportLevel, sub2api.SupportLevelDirect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelPoolPreservesAdvertisedAndCallableModelDifference(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
provider := sampleProviderManifest()
|
||||
provider.ProviderID = "deepseek-alias"
|
||||
provider.DisplayName = "DeepSeek Alias"
|
||||
provider.BaseURL = "https://alias.deepseek.example.com/v1"
|
||||
|
||||
pool, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "deepseek-chat",
|
||||
Candidates: []ModelPoolCandidate{
|
||||
{
|
||||
RouteID: "route-alias",
|
||||
Provider: provider,
|
||||
Priority: 10,
|
||||
AdvertisedModel: "deepseek-v3",
|
||||
CallableModel: "deepseek-chat",
|
||||
Inventory: capabilityInventoryWithSupport("deepseek-chat", "deepseek-chat", sub2api.SupportLevelDirect, true, true),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModelPool() error = %v", err)
|
||||
}
|
||||
if len(pool.Routes) != 1 {
|
||||
t.Fatalf("len(pool.Routes) = %d, want 1", len(pool.Routes))
|
||||
}
|
||||
if pool.Routes[0].AdvertisedModel != "deepseek-v3" {
|
||||
t.Fatalf("AdvertisedModel = %q, want deepseek-v3", pool.Routes[0].AdvertisedModel)
|
||||
}
|
||||
if pool.Routes[0].CallableModel != "deepseek-chat" {
|
||||
t.Fatalf("CallableModel = %q, want deepseek-chat", pool.Routes[0].CallableModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelPoolAllowsChatOnlyRouteWhenExplicitlyEnabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pool, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "kimi-k2.6",
|
||||
AllowPluginAdapterCandidates: true,
|
||||
Candidates: []ModelPoolCandidate{
|
||||
{
|
||||
RouteID: "route-kimi",
|
||||
Provider: pack.ProviderManifest{ProviderID: "kimi-a7m", DisplayName: "Kimi", BaseURL: "https://kimi.a7m.com.cn/v1"},
|
||||
Priority: 10,
|
||||
Inventory: capabilityInventoryWithSupport("kimi-k2.6", "kimi-k2.6", sub2api.SupportLevelWithPluginAdapter, true, false),
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModelPool() error = %v", err)
|
||||
}
|
||||
if len(pool.Routes) != 1 {
|
||||
t.Fatalf("len(pool.Routes) = %d, want 1", len(pool.Routes))
|
||||
}
|
||||
if pool.Routes[0].SupportLevel != sub2api.SupportLevelWithPluginAdapter {
|
||||
t.Fatalf("SupportLevel = %q, want %q", pool.Routes[0].SupportLevel, sub2api.SupportLevelWithPluginAdapter)
|
||||
}
|
||||
if pool.Routes[0].SupportsResponses {
|
||||
t.Fatal("SupportsResponses = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelPoolReturnsErrorWhenNoEligibleRoutesRemain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "kimi-k2.6",
|
||||
Candidates: []ModelPoolCandidate{{
|
||||
RouteID: "route-kimi",
|
||||
Provider: pack.ProviderManifest{ProviderID: "kimi-a7m", DisplayName: "Kimi", BaseURL: "https://kimi.a7m.com.cn/v1"},
|
||||
Priority: 10,
|
||||
Inventory: capabilityInventoryWithSupport("kimi-k2.6", "kimi-k2.6", sub2api.SupportLevelWithPluginAdapter, true, false),
|
||||
}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "no eligible routes") {
|
||||
t.Fatalf("BuildModelPool() error = %v, want no eligible routes", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelPoolFiltersHostNotReadyRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "deepseek-chat",
|
||||
Candidates: []ModelPoolCandidate{{
|
||||
RouteID: "route-host-not-ready",
|
||||
Provider: sampleProviderManifest(),
|
||||
Priority: 10,
|
||||
Inventory: capabilityInventoryWithHostReady("deepseek-chat", "deepseek-chat", sub2api.SupportLevelDirect, true, true, false),
|
||||
}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "no eligible routes") {
|
||||
t.Fatalf("BuildModelPool() error = %v, want no eligible routes after host_ready filter", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelPoolFiltersUnschedulableRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
unschedulable := false
|
||||
_, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "deepseek-chat",
|
||||
Candidates: []ModelPoolCandidate{{
|
||||
RouteID: "route-unschedulable",
|
||||
Provider: sampleProviderManifest(),
|
||||
Priority: 10,
|
||||
Schedulable: &unschedulable,
|
||||
Inventory: capabilityInventoryWithSupport("deepseek-chat", "deepseek-chat", sub2api.SupportLevelDirect, true, true),
|
||||
}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "no eligible routes") {
|
||||
t.Fatalf("BuildModelPool() error = %v, want no eligible routes after schedulable filter", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelPoolPreservesSupportedModelsForRoute(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
pool, err := BuildModelPool(ModelPoolBuildRequest{
|
||||
PublicModel: "deepseek-chat",
|
||||
Candidates: []ModelPoolCandidate{{
|
||||
RouteID: "route-primary",
|
||||
Provider: sampleProviderManifest(),
|
||||
Priority: 10,
|
||||
Inventory: capabilityInventoryWithMultiModels([]sub2api.ModelCapabilitySummary{
|
||||
{
|
||||
RawModelID: "deepseek-chat",
|
||||
CanonicalModelFamily: "deepseek-chat",
|
||||
SmokeChatOK: true,
|
||||
SupportLevel: sub2api.SupportLevelDirect,
|
||||
},
|
||||
{
|
||||
RawModelID: "deepseek-reasoner",
|
||||
CanonicalModelFamily: "deepseek-reasoner",
|
||||
SmokeChatOK: true,
|
||||
SupportLevel: sub2api.SupportLevelDirect,
|
||||
},
|
||||
}),
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildModelPool() error = %v", err)
|
||||
}
|
||||
if len(pool.Routes) != 1 {
|
||||
t.Fatalf("len(pool.Routes) = %d, want 1", len(pool.Routes))
|
||||
}
|
||||
if len(pool.Routes[0].SupportedModels) != 2 {
|
||||
t.Fatalf("len(pool.Routes[0].SupportedModels) = %d, want 2", len(pool.Routes[0].SupportedModels))
|
||||
}
|
||||
if pool.Routes[0].SupportedModels[0] != "deepseek-chat" || pool.Routes[0].SupportedModels[1] != "deepseek-reasoner" {
|
||||
t.Fatalf("SupportedModels = %#v, want [deepseek-chat deepseek-reasoner]", pool.Routes[0].SupportedModels)
|
||||
}
|
||||
}
|
||||
|
||||
func capabilityInventoryWithSupport(rawModel string, canonical string, supportLevel string, chatOK bool, responsesOK bool) sub2api.CapabilityInventory {
|
||||
return capabilityInventoryWithHostReady(rawModel, canonical, supportLevel, chatOK, responsesOK, true)
|
||||
}
|
||||
|
||||
func capabilityInventoryWithHostReady(rawModel string, canonical string, supportLevel string, chatOK bool, responsesOK bool, hostReady bool) sub2api.CapabilityInventory {
|
||||
return capabilityInventoryWithMultiModelsHostReady([]sub2api.ModelCapabilitySummary{{
|
||||
RawModelID: rawModel,
|
||||
CanonicalModelFamily: canonical,
|
||||
SmokeChatOK: chatOK,
|
||||
SupportLevel: supportLevel,
|
||||
KnownAdvisories: func() []string {
|
||||
if responsesOK {
|
||||
return nil
|
||||
}
|
||||
return []string{"responses_unsupported_but_chat_ok"}
|
||||
}(),
|
||||
}}, hostReady)
|
||||
}
|
||||
|
||||
func capabilityInventoryWithMultiModels(models []sub2api.ModelCapabilitySummary) sub2api.CapabilityInventory {
|
||||
return capabilityInventoryWithMultiModelsHostReady(models, true)
|
||||
}
|
||||
|
||||
func capabilityInventoryWithMultiModelsHostReady(models []sub2api.ModelCapabilitySummary, hostReady bool) sub2api.CapabilityInventory {
|
||||
return sub2api.CapabilityInventory{
|
||||
HostReady: hostReady,
|
||||
Host: sub2api.HostCapabilities{Groups: true, Channels: true, Accounts: true, AccountTest: true},
|
||||
Models: models,
|
||||
}
|
||||
}
|
||||
174
internal/provision/pool_routing_test.go
Normal file
174
internal/provision/pool_routing_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"sub2api-cn-relay-manager/internal/host/sub2api"
|
||||
"sub2api-cn-relay-manager/internal/pack"
|
||||
)
|
||||
|
||||
func TestPoolRoutingWithDualVendors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
store := openProvisionTestStore(t)
|
||||
defer closeProvisionTestStore(t, store)
|
||||
|
||||
seedProvisionHost(t, store, "host-a", "https://api-a.example.com")
|
||||
seedProvisionHost(t, store, "host-b", "https://api-b.example.com")
|
||||
|
||||
providerA := pack.ProviderManifest{
|
||||
ProviderID: "deepseek-official",
|
||||
DisplayName: "DeepSeek Official",
|
||||
BaseURL: "https://api.deepseek.com",
|
||||
Platform: "openai",
|
||||
AccountType: "apikey",
|
||||
DefaultModels: []string{"deepseek-chat", "deepseek-reasoner"},
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
GroupTemplate: pack.GroupTemplate{Name: "DeepSeek Official Group", RateMultiplier: 1},
|
||||
ChannelTemplate: pack.ChannelTemplate{Name: "DeepSeek Official Channel", ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"}},
|
||||
PlanTemplate: pack.PlanTemplate{Name: "DeepSeek Plan", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"},
|
||||
}
|
||||
|
||||
providerB := pack.ProviderManifest{
|
||||
ProviderID: "deepseek-backup",
|
||||
DisplayName: "DeepSeek Backup Proxy",
|
||||
BaseURL: "https://backup.deepseek.example.com",
|
||||
Platform: "openai",
|
||||
AccountType: "apikey",
|
||||
DefaultModels: []string{"deepseek-chat", "deepseek-reasoner"},
|
||||
SmokeTestModel: "deepseek-chat",
|
||||
GroupTemplate: pack.GroupTemplate{Name: "DeepSeek Backup Group", RateMultiplier: 1},
|
||||
ChannelTemplate: pack.ChannelTemplate{Name: "DeepSeek Backup Channel", ModelMapping: map[string]string{"deepseek-chat": "deepseek-chat"}},
|
||||
PlanTemplate: pack.PlanTemplate{Name: "DeepSeek Backup Plan", Price: 19.9, ValidityDays: 30, ValidityUnit: "day"},
|
||||
}
|
||||
|
||||
packManifest := pack.LoadedPack{
|
||||
Manifest: pack.Manifest{
|
||||
PackID: "openai-cn-pack",
|
||||
Version: "1.0.0",
|
||||
TargetHost: "sub2api",
|
||||
MinHostVersion: "0.1.126",
|
||||
MaxHostVersion: "0.2.x",
|
||||
},
|
||||
Checksum: "checksum-1",
|
||||
}
|
||||
|
||||
// Import provider A via host-a
|
||||
hostA := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_a1"}},
|
||||
testResults: map[string]sub2api.ProbeResult{"account_a1": {OK: true, Status: "passed"}},
|
||||
models: map[string][]sub2api.AccountModel{"account_a1": {{ID: "deepseek-chat"}, {ID: "deepseek-reasoner"}}},
|
||||
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}, CompletionOK: true, CompletionStatus: 200},
|
||||
}
|
||||
|
||||
resultA, errA := NewRuntimeImportService(store, hostA).Import(ctx, RuntimeImportRequest{
|
||||
HostID: "host-a",
|
||||
HostBaseURL: "https://api-a.example.com",
|
||||
Pack: packManifest,
|
||||
Provider: providerA,
|
||||
Mode: ImportModePartial,
|
||||
Keys: []string{"sk-key-a"},
|
||||
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
|
||||
})
|
||||
if errA != nil {
|
||||
t.Fatalf("deepseek-official Import() error = %v", errA)
|
||||
}
|
||||
if resultA.BatchID <= 0 {
|
||||
t.Fatalf("BatchID = %d for provider A, want positive", resultA.BatchID)
|
||||
}
|
||||
|
||||
// Import provider B via host-b
|
||||
hostB := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_b1"}},
|
||||
testResults: map[string]sub2api.ProbeResult{"account_b1": {OK: true, Status: "passed"}},
|
||||
models: map[string][]sub2api.AccountModel{"account_b1": {{ID: "deepseek-chat"}, {ID: "deepseek-reasoner"}}},
|
||||
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}, CompletionOK: true, CompletionStatus: 200},
|
||||
}
|
||||
|
||||
resultB, errB := NewRuntimeImportService(store, hostB).Import(ctx, RuntimeImportRequest{
|
||||
HostID: "host-b",
|
||||
HostBaseURL: "https://api-b.example.com",
|
||||
Pack: packManifest,
|
||||
Provider: providerB,
|
||||
Mode: ImportModePartial,
|
||||
Keys: []string{"sk-key-b"},
|
||||
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
|
||||
})
|
||||
if errB != nil {
|
||||
t.Fatalf("deepseek-backup Import() error = %v", errB)
|
||||
}
|
||||
if resultB.BatchID <= 0 {
|
||||
t.Fatalf("BatchID = %d for provider B, want positive", resultB.BatchID)
|
||||
}
|
||||
|
||||
// Verify each provider has its own logical_group_model with deepseek-chat
|
||||
groupsA, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, resultA.Report.Group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByLogicalGroupID(A) error = %v", err)
|
||||
}
|
||||
if len(groupsA) != 1 || groupsA[0].PublicModel != "deepseek-chat" {
|
||||
t.Fatalf("group A models = %+v, want 1 model [deepseek-chat]", groupsA)
|
||||
}
|
||||
|
||||
groupsB, err := store.LogicalGroupModels().ListByLogicalGroupID(ctx, resultB.Report.Group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByLogicalGroupID(B) error = %v", err)
|
||||
}
|
||||
if len(groupsB) != 1 || groupsB[0].PublicModel != "deepseek-chat" {
|
||||
t.Fatalf("group B models = %+v, want 1 model [deepseek-chat]", groupsB)
|
||||
}
|
||||
|
||||
// Verify each provider has its own logical_group_route
|
||||
routesA, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, resultA.Report.Group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByLogicalGroupID routes A error = %v", err)
|
||||
}
|
||||
if len(routesA) != 1 {
|
||||
t.Fatalf("routes for group A = %d, want 1", len(routesA))
|
||||
}
|
||||
if !strings.HasPrefix(routesA[0].RouteID, "route-") {
|
||||
t.Fatalf("route A RouteID = %q, want route-* prefix", routesA[0].RouteID)
|
||||
}
|
||||
|
||||
routesB, err := store.LogicalGroupRoutes().ListByLogicalGroupID(ctx, resultB.Report.Group.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByLogicalGroupID routes B error = %v", err)
|
||||
}
|
||||
if len(routesB) != 1 {
|
||||
t.Fatalf("routes for group B = %d, want 1", len(routesB))
|
||||
}
|
||||
|
||||
// Verify each route carries deepseek-chat route model
|
||||
rmA, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, routesA[0].RouteID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByRouteID models A error = %v", err)
|
||||
}
|
||||
hasChatA := false
|
||||
for _, rm := range rmA {
|
||||
if rm.PublicModel == "deepseek-chat" {
|
||||
hasChatA = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasChatA {
|
||||
t.Fatalf("route A models = %+v, want deepseek-chat", rmA)
|
||||
}
|
||||
|
||||
rmB, err := store.LogicalGroupRouteModels().ListByRouteID(ctx, routesB[0].RouteID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByRouteID models B error = %v", err)
|
||||
}
|
||||
hasChatB := false
|
||||
for _, rm := range rmB {
|
||||
if rm.PublicModel == "deepseek-chat" {
|
||||
hasChatB = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasChatB {
|
||||
t.Fatalf("route B models = %+v, want deepseek-chat", rmB)
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ func (s *RuntimeImportService) Import(ctx context.Context, req RuntimeImportRequ
|
||||
}
|
||||
|
||||
includeManagedResources := importErr == nil || req.Mode != ImportModeStrict
|
||||
if persistErr := s.persistRuntimeArtifacts(ctx, batchID, hostRow.ID, req.Access, report, includeManagedResources); persistErr != nil {
|
||||
if persistErr := s.persistRuntimeArtifacts(ctx, batchID, hostRow.ID, req.Access, report, includeManagedResources, strings.TrimSpace(req.Provider.SmokeTestModel)); persistErr != nil {
|
||||
return RuntimeImportResult{}, persistErr
|
||||
}
|
||||
if err := s.store.ImportBatches().UpdateStatus(ctx, batchID, report.BatchStatus, report.AccessStatus); err != nil {
|
||||
@@ -182,7 +182,7 @@ func (s *RuntimeImportService) ensureProvider(ctx context.Context, packID int64,
|
||||
return s.store.Providers().GetByPackIDAndProviderID(ctx, packID, provider.ProviderID)
|
||||
}
|
||||
|
||||
func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID, hostID int64, access AccessRequest, report ImportReport, includeManagedResources bool) error {
|
||||
func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batchID, hostID int64, access AccessRequest, report ImportReport, includeManagedResources bool, smokeTestModel string) error {
|
||||
for i, account := range report.Accounts {
|
||||
validationStatus := account.ValidationStatus()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
@@ -236,6 +236,63 @@ func (s *RuntimeImportService) persistRuntimeArtifacts(ctx context.Context, batc
|
||||
}
|
||||
}
|
||||
|
||||
// Persist model pool mapping into logical_group_* route tables.
|
||||
if includeManagedResources && len(report.Accounts) > 0 {
|
||||
routeID := fmt.Sprintf("route-%s-%s", report.Group.ID, report.Channel.ID)
|
||||
|
||||
// Ensure local logical group exists (idempotent) before FK-dependent inserts.
|
||||
if _, err := s.store.LogicalGroups().Create(ctx, sqlite.LogicalGroup{
|
||||
LogicalGroupID: report.Group.ID,
|
||||
DisplayName: firstNonEmpty(report.Group.Name, report.Group.ID),
|
||||
Status: "active",
|
||||
RoutePolicy: "priority",
|
||||
}); err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("persist logical group: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.store.LogicalGroupModels().Create(ctx, sqlite.LogicalGroupModel{
|
||||
LogicalGroupID: report.Group.ID,
|
||||
PublicModel: smokeTestModel,
|
||||
Status: "active",
|
||||
}); err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("persist logical group model: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.store.LogicalGroupRoutes().Create(ctx, sqlite.LogicalGroupRoute{
|
||||
RouteID: routeID,
|
||||
LogicalGroupID: report.Group.ID,
|
||||
Name: report.Channel.Name,
|
||||
Status: "active",
|
||||
Priority: 10,
|
||||
Weight: 100,
|
||||
ShadowGroupID: report.Group.ID,
|
||||
ShadowHostID: report.Channel.ID,
|
||||
}); err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("persist logical group route: %w", err)
|
||||
}
|
||||
|
||||
seenRouteModels := make(map[string]struct{})
|
||||
for _, account := range report.Accounts {
|
||||
for _, m := range account.Models {
|
||||
publicModel := strings.TrimSpace(m.ID)
|
||||
if publicModel == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seenRouteModels[publicModel]; ok {
|
||||
continue
|
||||
}
|
||||
seenRouteModels[publicModel] = struct{}{}
|
||||
if _, err := s.store.LogicalGroupRouteModels().Create(ctx, sqlite.LogicalGroupRouteModel{
|
||||
RouteID: routeID,
|
||||
PublicModel: publicModel,
|
||||
ShadowModel: publicModel,
|
||||
Status: "active",
|
||||
}); err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return fmt.Errorf("persist route model %q: %w", publicModel, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
accessPayload, err := json.Marshal(BuildAccessClosureDetails(access, report.Gateway))
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal gateway access summary: %w", err)
|
||||
|
||||
@@ -179,6 +179,49 @@ func TestRuntimeImportServiceIncludesMatchingHostOverlaysInReport(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeImportServicePersistsModelPoolMappingIntoLogicalRouteTables(t *testing.T) {
|
||||
store := openProvisionTestStore(t)
|
||||
defer closeProvisionTestStore(t, store)
|
||||
|
||||
seedProvisionHost(t, store, "host-1", "https://sub2api.example.com")
|
||||
|
||||
host := &fakeHostAdapter{
|
||||
batchAccounts: []sub2api.AccountRef{{ID: "account_1"}},
|
||||
testResults: map[string]sub2api.ProbeResult{"account_1": {OK: true, Status: "passed"}},
|
||||
models: map[string][]sub2api.AccountModel{"account_1": {{ID: "deepseek-chat"}, {ID: "deepseek-reasoner"}}},
|
||||
gatewayResult: sub2api.GatewayAccessResult{OK: true, StatusCode: 200, HasExpectedModel: true, Models: []string{"deepseek-chat"}, CompletionOK: true, CompletionStatus: 200},
|
||||
}
|
||||
|
||||
result, err := NewRuntimeImportService(store, host).Import(context.Background(), RuntimeImportRequest{
|
||||
HostID: "host-1",
|
||||
HostBaseURL: "https://sub2api.example.com",
|
||||
Pack: pack.LoadedPack{
|
||||
Manifest: pack.Manifest{PackID: "openai-cn-pack", Version: "1.0.0", TargetHost: "sub2api", MinHostVersion: "0.1.126", MaxHostVersion: "0.2.x"},
|
||||
Checksum: "checksum-1",
|
||||
},
|
||||
Provider: sampleProviderManifest(),
|
||||
Mode: ImportModePartial,
|
||||
Keys: []string{"key-1"},
|
||||
Access: AccessRequest{Mode: AccessModeSelfService, ProbeAPIKey: "user-key"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RuntimeImportService.Import() error = %v", err)
|
||||
}
|
||||
if result.BatchID <= 0 {
|
||||
t.Fatalf("BatchID = %d, want positive id", result.BatchID)
|
||||
}
|
||||
|
||||
if got := queryCount(t, store.SQLDB(), "logical_group_models"); got != 1 {
|
||||
t.Fatalf("logical_group_models row count = %d, want 1", got)
|
||||
}
|
||||
if got := queryCount(t, store.SQLDB(), "logical_group_routes"); got != 1 {
|
||||
t.Fatalf("logical_group_routes row count = %d, want 1", got)
|
||||
}
|
||||
if got := queryCount(t, store.SQLDB(), "logical_group_route_models"); got != 2 {
|
||||
t.Fatalf("logical_group_route_models row count = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeImportServicePersistsFailedBatchAfterStrictRollback(t *testing.T) {
|
||||
store := openProvisionTestStore(t)
|
||||
defer closeProvisionTestStore(t, store)
|
||||
|
||||
Reference in New Issue
Block a user