feat(scheduler): 支持原生与转换路由混合权重调度 (#570)
* feat(scheduler): 支持原生与转换路由混合权重调度 * fix(settings): 补齐路由策略当前有效值提示
This commit is contained in:
1 parent
ad8be749e3
commit
c86a9ad1da
24 files changed
+690
-46
No files matched your search
@@ -460,6 +460,10 @@ func TestGroupSettingsHTTPRejectsStrictJSONAndUnauthorizedWithoutMutation(t *tes
|
||||
`{"unknown":true}`,
|
||||
`{"name":"one","name":"two"}`,
|
||||
`{"weight_manual":0}`,
|
||||
`{"route_strategy":"weighted_mix"}`,
|
||||
`{"overrides":{"route_strategy":"weighted_mix"}}`,
|
||||
`{"overrides":{"route_strategy":"native_first"}}`,
|
||||
`{"overrides":{"route_strategy":null}}`,
|
||||
} {
|
||||
recorder := serveGroupSettingsRequest(t, engine, http.MethodPut, path, "test-auth-key", body)
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
|
||||
@@ -55,6 +55,7 @@ type routeInspectGroupResponse struct {
|
||||
type routeInspectResponse struct {
|
||||
ObservedAtMS int64 `json:"observed_at_ms"`
|
||||
SnapshotRevision uint64 `json:"snapshot_revision"`
|
||||
RouteStrategy state.RouteStrategy `json:"route_strategy"`
|
||||
Protocol protocol.Protocol `json:"protocol"`
|
||||
Operation execution.Operation `json:"operation"`
|
||||
RouteRequirement execution.RouteRequirement `json:"route_requirement"`
|
||||
@@ -162,6 +163,7 @@ func mapRouteInspectResponse(
|
||||
result := routeInspectResponse{
|
||||
ObservedAtMS: observedAtMS,
|
||||
SnapshotRevision: observation.snapshot.Revision,
|
||||
RouteStrategy: observation.snapshot.Settings.RouteStrategy,
|
||||
Protocol: request.Protocol,
|
||||
Operation: explanation.Operation,
|
||||
RouteRequirement: explanation.RouteRequirement,
|
||||
|
||||
@@ -83,6 +83,54 @@ func routeModelValue(value *string) string {
|
||||
return *value
|
||||
}
|
||||
|
||||
func TestRouteInspectReportsSnapshotRouteStrategy(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
engine := gin.New()
|
||||
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
||||
|
||||
for _, strategy := range []string{"native_first", "weighted_mix"} {
|
||||
t.Run(strategy, func(t *testing.T) {
|
||||
settings := config.Settings{}
|
||||
if strategy != "native_first" {
|
||||
settings["route_strategy"] = strategy
|
||||
}
|
||||
snapshot, err := fixture.manager.Publish(state.CompileInput{
|
||||
ChannelRegistry: fixture.channelRegistry,
|
||||
SystemSettings: settings,
|
||||
Groups: []state.GroupConfig{{
|
||||
ID: 1, Name: "openai", ChannelID: channel.OpenAI, ConnectionType: "api_key",
|
||||
Params: json.RawMessage(`{}`), Models: []state.ModelConfig{{ID: "model"}}, Enabled: true,
|
||||
}},
|
||||
AccessKeys: []state.AccessKeyConfig{{
|
||||
ID: 10, Name: "client", KeyHash: "hash", Status: state.AccessKeyStatusActive,
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("publish route strategy: %v", err)
|
||||
}
|
||||
recorder := performRouteInspectRequest(engine, "test-auth-key",
|
||||
`{"protocol":"openai-completions","external_model":"model","access_key_id":10}`)
|
||||
got := decodeRouteInspectSuccess(t, recorder)
|
||||
if got.SnapshotRevision != snapshot.Revision {
|
||||
t.Fatalf("snapshot revision = %d, want %d", got.SnapshotRevision, snapshot.Revision)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
RouteStrategy string `json:"route_strategy"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Data.RouteStrategy != strategy {
|
||||
t.Fatalf("route strategy = %q, want %q", envelope.Data.RouteStrategy, strategy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteInspectEndpointRejectsMalformedAndInvalidRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
|
||||
@@ -45,6 +45,7 @@ type SettingsValuesResponse struct {
|
||||
ResponseHeaderRules HeaderRulesResponse `json:"response_header_rules"`
|
||||
InjectUsageOptions bool `json:"inject_usage_options"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
RouteStrategy state.RouteStrategy `json:"route_strategy"`
|
||||
BlacklistThreshold int `json:"blacklist_threshold"`
|
||||
AffinityEnabled bool `json:"affinity_enabled"`
|
||||
AffinityTTL int64 `json:"affinity_ttl"`
|
||||
@@ -354,6 +355,7 @@ func mapSettingsResponse(
|
||||
},
|
||||
InjectUsageOptions: settings.InjectUsageOptions,
|
||||
RetryCount: settings.RetryCount,
|
||||
RouteStrategy: settings.RouteStrategy,
|
||||
BlacklistThreshold: settings.BlacklistThreshold,
|
||||
AffinityEnabled: settings.AffinityEnabled,
|
||||
AffinityTTL: durationSeconds(settings.AffinityTTL),
|
||||
|
||||
@@ -13,8 +13,50 @@ import (
|
||||
|
||||
"gpt-load/internal/platform/config"
|
||||
"gpt-load/internal/state"
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
func TestSettingsHTTPRouteStrategyRejectsInvalidValuesWithoutMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
fixture := newServiceFixture(t)
|
||||
engine := gin.New()
|
||||
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
|
||||
|
||||
updated := serveLocalizedSettingsRequest(t, engine, http.MethodPut, "test-auth-key", "en-US",
|
||||
`{"settings":{"route_strategy":"weighted_mix"}}`)
|
||||
if updated.Code != http.StatusOK {
|
||||
t.Fatalf("PUT weighted_mix = %d %s", updated.Code, updated.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Data SettingsResponse `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(updated.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Data.Values.RouteStrategy != state.RouteStrategyWeightedMix {
|
||||
t.Fatalf("response route strategy = %q", envelope.Data.Values.RouteStrategy)
|
||||
}
|
||||
before := fixture.manager.Current()
|
||||
for _, raw := range []string{`""`, `"unknown"`, `"Native_First"`, `" weighted_mix "`, "true", "1", "[]", "{}"} {
|
||||
rejected := serveLocalizedSettingsRequest(t, engine, http.MethodPut, "test-auth-key", "en-US",
|
||||
`{"settings":{"route_strategy":`+raw+`,"retry_count":9}}`)
|
||||
if rejected.Code != http.StatusBadRequest || !strings.Contains(rejected.Body.String(), `"code":"VALIDATION_FAILED"`) {
|
||||
t.Fatalf("PUT route_strategy=%s = %d %s", raw, rejected.Code, rejected.Body.String())
|
||||
}
|
||||
}
|
||||
if fixture.manager.Current() != before {
|
||||
t.Fatal("invalid route strategy published a new Snapshot")
|
||||
}
|
||||
var rows []models.SystemSetting
|
||||
if err := fixture.db.Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != state.SettingRouteStrategy || rows[0].Value != `"weighted_mix"` {
|
||||
t.Fatalf("invalid updates changed persisted settings: %#v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsHTTPLastWriteWinsWithoutPrecondition(t *testing.T) {
|
||||
t.Parallel()
|
||||
initControlI18n(t)
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"gpt-load/internal/platform/config"
|
||||
app_errors "gpt-load/internal/platform/errors"
|
||||
"gpt-load/internal/state"
|
||||
stateloader "gpt-load/internal/state/loader"
|
||||
"gpt-load/internal/storage/models"
|
||||
)
|
||||
|
||||
@@ -77,6 +78,56 @@ func TestSettingsProxyConfigIsEncryptedMaskedAndResettable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateSettingsRouteStrategyPersistsPublishesReloadsAndResets(t *testing.T) {
|
||||
t.Parallel()
|
||||
fixture := newServiceFixture(t)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
raw json.RawMessage
|
||||
strategy state.RouteStrategy
|
||||
}{
|
||||
{name: "weighted mix", raw: json.RawMessage(`"weighted_mix"`), strategy: state.RouteStrategyWeightedMix},
|
||||
{name: "explicit native first", raw: json.RawMessage(`"native_first"`), strategy: state.RouteStrategyNativeFirst},
|
||||
{name: "weighted mix again", raw: json.RawMessage(`"weighted_mix"`), strategy: state.RouteStrategyWeightedMix},
|
||||
{name: "reset", raw: json.RawMessage(`null`), strategy: state.RouteStrategyNativeFirst},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
before := fixture.manager.Current()
|
||||
previousStrategy := before.Settings.RouteStrategy
|
||||
got, err := fixture.service.UpdateSettings(t.Context(), SettingsUpdateRequest{
|
||||
Settings: map[string]json.RawMessage{state.SettingRouteStrategy: test.raw},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := fixture.manager.Current()
|
||||
if got.Values.RouteStrategy != test.strategy || after.Settings.RouteStrategy != test.strategy ||
|
||||
after.Revision != before.Revision+1 || before.Settings.RouteStrategy != previousStrategy {
|
||||
t.Fatalf("response/snapshots = %#v / %#v / %#v", got, before.Settings, after.Settings)
|
||||
}
|
||||
var rows []models.SystemSetting
|
||||
if err := fixture.db.Where("key = ?", state.SettingRouteStrategy).Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(test.raw) == "null" {
|
||||
if len(got.Overrides) != 0 || len(rows) != 0 {
|
||||
t.Fatalf("reset overrides/rows = %#v / %#v", got.Overrides, rows)
|
||||
}
|
||||
} else if !reflect.DeepEqual(got.Overrides, []string{state.SettingRouteStrategy}) ||
|
||||
len(rows) != 1 || rows[0].Value != string(test.raw) {
|
||||
t.Fatalf("persisted overrides/rows = %#v / %#v", got.Overrides, rows)
|
||||
}
|
||||
reloaded := state.NewManager()
|
||||
if err := stateloader.New(fixture.db, reloaded, state.NewCredentialRegistry()).Load(t.Context()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := reloaded.Current().Settings.RouteStrategy; got != test.strategy {
|
||||
t.Fatalf("reloaded route strategy = %q, want %q", got, test.strategy)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsProxyRejectsInvalidConfigWithoutMutation(t *testing.T) {
|
||||
t.Parallel()
|
||||
fixture := newServiceFixture(t)
|
||||
@@ -110,6 +161,7 @@ func TestGetSettingsReturnsSnapshotDefaultsAndNoOverrides(t *testing.T) {
|
||||
if got.Values.FirstByteTimeout != 120 ||
|
||||
got.Values.RequestTimeout != 600 || got.Values.StreamIdleTimeout != 300 ||
|
||||
got.Values.ValidationInterval != 600 ||
|
||||
got.Values.RouteStrategy != state.RouteStrategyNativeFirst ||
|
||||
got.Values.RequestLogRetentionDays != 7 || !got.Values.InjectUsageOptions {
|
||||
t.Fatalf("values = %#v", got.Values)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/execution"
|
||||
"gpt-load/internal/platform/config"
|
||||
"gpt-load/internal/state"
|
||||
"gpt-load/internal/testutil/encryptiontest"
|
||||
)
|
||||
|
||||
func TestHandlerUsesPublishedRouteStrategyAndPreservesSelectedRoute(t *testing.T) {
|
||||
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
||||
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
||||
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
||||
}}
|
||||
engine, manager, registry := newHandlerTestRuntime(t, forwarder)
|
||||
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
||||
input := state.CompileInput{
|
||||
ChannelRegistry: channel.NewRegistry(),
|
||||
AccessKeys: []state.AccessKeyConfig{{
|
||||
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"), Status: state.AccessKeyStatusActive,
|
||||
}},
|
||||
}
|
||||
entries := make([]state.CredentialEntry, 0, 2)
|
||||
for index, target := range []struct {
|
||||
channelID channel.ID
|
||||
model string
|
||||
weight int
|
||||
}{
|
||||
{channel.OpenAI, "native-model", 1},
|
||||
{channel.Anthropic, "converted-model", 100},
|
||||
} {
|
||||
id := uint(index + 1)
|
||||
input.Groups = append(input.Groups, state.GroupConfig{
|
||||
ID: id, Name: string(target.channelID), ChannelID: target.channelID, ConnectionType: "api_key",
|
||||
Params: json.RawMessage(`{}`), Enabled: true, WeightManual: &target.weight,
|
||||
Models: []state.ModelConfig{{ID: target.model, Alias: "public"}},
|
||||
})
|
||||
credential := testCredentialConfig(id, id)
|
||||
input.Credentials = append(input.Credentials, credential)
|
||||
encrypted, err := keyService.Encrypt(`{"api_key":"test-upstream-key"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries = append(entries, state.CredentialEntry{
|
||||
ID: id, GroupID: id, Status: credential.Status, Version: credential.Version,
|
||||
IdentityGeneration: credential.IdentityGeneration, Fingerprint: credential.Fingerprint,
|
||||
EncryptedValue: encrypted,
|
||||
})
|
||||
}
|
||||
if err := registry.ReplaceCredentials(entries); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index, strategy := range []state.RouteStrategy{state.RouteStrategyNativeFirst, state.RouteStrategyWeightedMix} {
|
||||
input.SystemSettings = config.Settings{state.SettingRouteStrategy: string(strategy)}
|
||||
if _, err := manager.Publish(input); err != nil {
|
||||
t.Fatalf("publish %s: %v", strategy, err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
||||
bytes.NewBufferString(`{"model":"public","messages":[{"role":"user","content":"hello"}]}`))
|
||||
request.Header.Set("Authorization", "Bearer gl-client")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK || len(forwarder.inputs) != index+1 {
|
||||
t.Fatalf("%s response/attempts = %d %s / %d", strategy, recorder.Code, recorder.Body, len(forwarder.inputs))
|
||||
}
|
||||
got := forwarder.inputs[index]
|
||||
wantChannel, wantMode, wantModel := channel.OpenAI, execution.RouteNative, "native-model"
|
||||
if strategy == state.RouteStrategyWeightedMix {
|
||||
wantChannel, wantMode, wantModel = channel.Anthropic, execution.RouteConverted, "converted-model"
|
||||
}
|
||||
if got.ChannelID != string(wantChannel) || got.RouteMode != wantMode || got.UpstreamModelID != wantModel {
|
||||
t.Fatalf("%s selected channel/mode/model = %s/%s/%s", strategy, got.ChannelID, got.RouteMode, got.UpstreamModelID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math/rand"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gpt-load/internal/channel"
|
||||
"gpt-load/internal/execution"
|
||||
"gpt-load/internal/platform/config"
|
||||
"gpt-load/internal/protocol"
|
||||
"gpt-load/internal/state"
|
||||
)
|
||||
|
||||
type routeStrategyRandSource int64
|
||||
|
||||
func (source routeStrategyRandSource) Int63() int64 { return int64(source) }
|
||||
func (routeStrategyRandSource) Seed(int64) {}
|
||||
|
||||
func setSnapshotRouteStrategy(t *testing.T, snapshot *state.ConfigSnapshot, strategy string) {
|
||||
t.Helper()
|
||||
settings, err := state.ResolveRuntimeSettings(config.Settings{"route_strategy": strategy})
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveRuntimeSettings() error = %v", err)
|
||||
}
|
||||
snapshot.Settings = settings
|
||||
}
|
||||
|
||||
func TestIteratorRouteStrategyUsesEffectiveWeightsAcrossModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
strategy string
|
||||
manualWeights bool
|
||||
tickets int64
|
||||
wantConverted int
|
||||
}{
|
||||
{name: "native first", strategy: "native_first", manualWeights: true, tickets: 101},
|
||||
{name: "mixed manual weights", strategy: "weighted_mix", manualWeights: true, tickets: 101, wantConverted: 100},
|
||||
{name: "mixed default weights", strategy: "weighted_mix", tickets: 100, wantConverted: 50},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
snapshot := channelSchedulerSnapshot(t)
|
||||
setSnapshotRouteStrategy(t, snapshot, test.strategy)
|
||||
if test.manualWeights {
|
||||
for groupID, weight := range map[uint]int{1: 100, 2: 1} {
|
||||
group := snapshot.Groups[groupID]
|
||||
group.WeightManual = &weight
|
||||
snapshot.Groups[groupID] = group
|
||||
}
|
||||
}
|
||||
source := fakeCredentialSource{keys: []state.CredentialMeta{
|
||||
{ID: 11, GroupID: 1, WeightAuto: 1},
|
||||
{ID: 21, GroupID: 2, WeightAuto: 1},
|
||||
}}
|
||||
converted := 0
|
||||
for ticket := range test.tickets {
|
||||
selection, err := New(snapshot, source, Query{
|
||||
ClientProtocol: protocol.OpenAICompletions,
|
||||
ExternalModel: modelPointer("public"),
|
||||
}, rand.New(routeStrategyRandSource(ticket))).Next()
|
||||
if err != nil {
|
||||
t.Fatalf("ticket %d Next() error = %v", ticket, err)
|
||||
}
|
||||
wantMode, wantChannel, wantModel := channel.RouteNative, channel.OpenAI, "native-model"
|
||||
if selection.CredentialID == 11 {
|
||||
converted++
|
||||
wantMode, wantChannel, wantModel = channel.RouteConverted, channel.Anthropic, "converted-model"
|
||||
}
|
||||
if selection.RouteMode != wantMode || selection.ChannelID != wantChannel ||
|
||||
selection.ResolvedTarget.ChannelID != wantChannel ||
|
||||
selection.UpstreamModelID == nil || *selection.UpstreamModelID != wantModel {
|
||||
t.Fatalf("ticket %d Selection lost route target: %#v", ticket, selection)
|
||||
}
|
||||
}
|
||||
if converted != test.wantConverted {
|
||||
t.Fatalf("converted selections = %d/%d, want %d", converted, test.tickets, test.wantConverted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIteratorWeightedMixFreezesStrategyAndPrefersEligibleConvertedCredential(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshot := channelSchedulerSnapshot(t)
|
||||
setSnapshotRouteStrategy(t, snapshot, "weighted_mix")
|
||||
iterator := New(snapshot, fakeCredentialSource{keys: []state.CredentialMeta{
|
||||
{ID: 11, GroupID: 1}, {ID: 21, GroupID: 2},
|
||||
}}, Query{
|
||||
ClientProtocol: protocol.OpenAICompletions,
|
||||
ExternalModel: modelPointer("public"),
|
||||
PreferredCredentialID: 11,
|
||||
}, rand.New(routeStrategyRandSource(2500)))
|
||||
setSnapshotRouteStrategy(t, snapshot, "native_first")
|
||||
for index, want := range []uint{11, 21} {
|
||||
selection, err := iterator.Next()
|
||||
if err != nil || selection.CredentialID != want {
|
||||
t.Fatalf("Next() %d = (%#v, %v), want credential %d", index, selection, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := iterator.Next(); !errors.Is(err, ErrExhausted) {
|
||||
t.Fatalf("Next() after exhaustion error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIteratorWeightedMixPreservesStoredResponsesPriority(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, preferred := range []uint{0, 31} {
|
||||
snapshot := responsesStoreSchedulerSnapshot(t, true)
|
||||
setSnapshotRouteStrategy(t, snapshot, "weighted_mix")
|
||||
weight := 100
|
||||
group := snapshot.Groups[3]
|
||||
group.WeightManual = &weight
|
||||
snapshot.Groups[3] = group
|
||||
iterator := New(snapshot, fakeCredentialSource{keys: []state.CredentialMeta{
|
||||
{ID: 11, GroupID: 1, WeightAuto: 1}, {ID: 21, GroupID: 2, WeightAuto: 1},
|
||||
{ID: 31, GroupID: 3, WeightAuto: 1}, {ID: 41, GroupID: 4, WeightAuto: 1},
|
||||
}}, Query{
|
||||
ClientProtocol: protocol.OpenAIResponses,
|
||||
Operation: execution.OperationResponsesCreate,
|
||||
ResponsesStorePreference: execution.ResponsesStorePreferencePreferStored,
|
||||
ExternalModel: modelPointer("gpt"),
|
||||
PreferredCredentialID: preferred,
|
||||
}, rand.New(routeStrategyRandSource(50)))
|
||||
for index, want := range []uint{21, 31} {
|
||||
selection, err := iterator.Next()
|
||||
if err != nil || selection.CredentialID != want || selection.ResponsesStoreDowngraded != (index > 0) {
|
||||
t.Fatalf("preferred %d Next() %d = (%#v, %v), want credential %d with preserved store semantics", preferred, index, selection, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIteratorWeightedMixKeepsNativeRequirement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
snapshot := responsesStoreSchedulerSnapshot(t, true)
|
||||
setSnapshotRouteStrategy(t, snapshot, "weighted_mix")
|
||||
query := Query{
|
||||
ClientProtocol: protocol.OpenAIResponses,
|
||||
Operation: execution.OperationResponsesCreate,
|
||||
RouteRequirement: execution.RouteRequirementNative,
|
||||
ExternalModel: modelPointer("gpt"),
|
||||
PreferredCredentialID: 31,
|
||||
}
|
||||
if got := CandidateGroupIDsForQuery(snapshot, query); !slices.Equal(got, []uint{2}) {
|
||||
t.Fatalf("CandidateGroupIDsForQuery() = %#v, want lifecycle-capable native group [2]", got)
|
||||
}
|
||||
iterator := New(snapshot, fakeCredentialSource{keys: []state.CredentialMeta{
|
||||
{ID: 11, GroupID: 1}, {ID: 21, GroupID: 2}, {ID: 31, GroupID: 3}, {ID: 41, GroupID: 4},
|
||||
}}, query, rand.New(zeroRandSource{}))
|
||||
selection, err := iterator.Next()
|
||||
if err != nil || selection.CredentialID != 21 || selection.RouteMode != channel.RouteNative {
|
||||
t.Fatalf("Next() = (%#v, %v), want lifecycle-capable native credential 21", selection, err)
|
||||
}
|
||||
if _, err := iterator.Next(); !errors.Is(err, ErrExhausted) {
|
||||
t.Fatalf("Next() after native exhaustion error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIteratorWeightedMixHonorsLiveHealthAndRequestExclusions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.Unix(100, 0)
|
||||
snapshot := channelSchedulerSnapshot(t)
|
||||
setSnapshotRouteStrategy(t, snapshot, "weighted_mix")
|
||||
registry := state.NewCredentialRegistry()
|
||||
entries := make([]state.CredentialEntry, 0, 6)
|
||||
zero := 0
|
||||
for _, credential := range []struct {
|
||||
id, groupID uint
|
||||
}{
|
||||
{11, 1}, {12, 1}, {13, 1}, {14, 1}, {21, 2}, {22, 2},
|
||||
} {
|
||||
entry := state.CredentialEntry{
|
||||
ID: credential.id, GroupID: credential.groupID, Status: state.CredentialStatusActive,
|
||||
Version: 1, IdentityGeneration: 1, Fingerprint: "test-fingerprint", EncryptedValue: "cipher",
|
||||
}
|
||||
if entry.ID == 14 {
|
||||
entry.WeightManual = &zero
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := registry.ReplaceCredentials(entries); err != nil {
|
||||
t.Fatalf("ReplaceCredentials() error = %v", err)
|
||||
}
|
||||
allowed := map[uint]struct{}{11: {}, 12: {}, 14: {}, 21: {}, 22: {}}
|
||||
iterator := newWithClock(snapshot, registry, Query{
|
||||
ClientProtocol: protocol.OpenAICompletions,
|
||||
ExternalModel: modelPointer("public"),
|
||||
AllowedCredentialIDs: allowed,
|
||||
}, rand.New(zeroRandSource{}), func() time.Time { return now })
|
||||
allowed[13] = struct{}{}
|
||||
for index, want := range []uint{11, 21} {
|
||||
selection, err := iterator.Next()
|
||||
if err != nil || selection.CredentialID != want {
|
||||
t.Fatalf("Next() %d = (%#v, %v), want credential %d", index, selection, err, want)
|
||||
}
|
||||
if index == 0 && !registry.SetCooldown(12, now.Add(time.Minute)) {
|
||||
t.Fatal("SetCooldown() did not find converted credential 12")
|
||||
}
|
||||
}
|
||||
iterator.SkipGroup(2)
|
||||
if _, err := iterator.Next(); !errors.Is(err, ErrExhausted) {
|
||||
t.Fatalf("Next() after skip and exclusions error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ type weightedCredential struct {
|
||||
}
|
||||
|
||||
type candidatePool struct {
|
||||
targetsByMode map[channel.RouteMode]map[uint]candidateTarget
|
||||
targetsByGroup map[uint]candidateTarget
|
||||
groupIDsByMode map[channel.RouteMode][]uint
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ type Iterator struct {
|
||||
random *rand.Rand
|
||||
regular candidatePool
|
||||
storeDowngraded candidatePool
|
||||
routeModeTiers [][]channel.RouteMode
|
||||
allowedCredentialIDs map[uint]struct{}
|
||||
preferredCredentialID uint
|
||||
tried map[uint]struct{}
|
||||
@@ -118,12 +119,16 @@ func newWithClock(
|
||||
random: random,
|
||||
regular: newCandidatePool(),
|
||||
storeDowngraded: newCandidatePool(),
|
||||
routeModeTiers: [][]channel.RouteMode{{channel.RouteNative}, {channel.RouteConverted}},
|
||||
allowedCredentialIDs: cloneAllowedCredentialIDs(query),
|
||||
preferredCredentialID: query.PreferredCredentialID,
|
||||
tried: make(map[uint]struct{}),
|
||||
skippedGroups: make(map[uint]struct{}),
|
||||
now: now,
|
||||
}
|
||||
if snapshot != nil && snapshot.Settings.RouteStrategy == state.RouteStrategyWeightedMix {
|
||||
iterator.routeModeTiers = [][]channel.RouteMode{{channel.RouteNative, channel.RouteConverted}}
|
||||
}
|
||||
targets, staticReason := filterTargetsWithReason(snapshot, query)
|
||||
iterator.staticReason = staticReason
|
||||
for _, target := range targets {
|
||||
@@ -132,10 +137,7 @@ func newWithClock(
|
||||
pool = &iterator.storeDowngraded
|
||||
}
|
||||
mode := target.target.Mode
|
||||
if pool.targetsByMode[mode] == nil {
|
||||
pool.targetsByMode[mode] = make(map[uint]candidateTarget)
|
||||
}
|
||||
pool.targetsByMode[mode][target.target.GroupID] = target
|
||||
pool.targetsByGroup[target.target.GroupID] = target
|
||||
pool.groupIDsByMode[mode] = append(pool.groupIDsByMode[mode], target.target.GroupID)
|
||||
}
|
||||
return iterator
|
||||
@@ -143,7 +145,7 @@ func newWithClock(
|
||||
|
||||
func newCandidatePool() candidatePool {
|
||||
return candidatePool{
|
||||
targetsByMode: make(map[channel.RouteMode]map[uint]candidateTarget),
|
||||
targetsByGroup: make(map[uint]candidateTarget),
|
||||
groupIDsByMode: make(map[channel.RouteMode][]uint),
|
||||
}
|
||||
}
|
||||
@@ -184,18 +186,21 @@ func (iterator *Iterator) weightedPoolForMode(
|
||||
if iterator == nil {
|
||||
return nil, 0
|
||||
}
|
||||
return iterator.weightedPoolForCandidatePool(&iterator.regular, mode, now)
|
||||
return iterator.weightedPoolForCandidatePool(&iterator.regular, []channel.RouteMode{mode}, now)
|
||||
}
|
||||
|
||||
func (iterator *Iterator) weightedPoolForCandidatePool(
|
||||
candidates *candidatePool,
|
||||
mode channel.RouteMode,
|
||||
modes []channel.RouteMode,
|
||||
now time.Time,
|
||||
) ([]weightedCredential, int64) {
|
||||
if iterator == nil || iterator.credentials == nil {
|
||||
return nil, 0
|
||||
}
|
||||
groupIDs := candidates.groupIDsByMode[mode]
|
||||
var groupIDs []uint
|
||||
for _, mode := range modes {
|
||||
groupIDs = append(groupIDs, candidates.groupIDsByMode[mode]...)
|
||||
}
|
||||
if len(groupIDs) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
@@ -213,7 +218,7 @@ func (iterator *Iterator) weightedPoolForCandidatePool(
|
||||
if _, skipped := iterator.skippedGroups[credential.GroupID]; skipped {
|
||||
continue
|
||||
}
|
||||
target, ok := candidates.targetsByMode[mode][credential.GroupID]
|
||||
target, ok := candidates.targetsByGroup[credential.GroupID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -239,8 +244,8 @@ func (iterator *Iterator) Next() (Selection, error) {
|
||||
return Selection{}, ErrExhausted
|
||||
}
|
||||
for _, pool := range []*candidatePool{&iterator.regular, &iterator.storeDowngraded} {
|
||||
for _, mode := range []channel.RouteMode{channel.RouteNative, channel.RouteConverted} {
|
||||
weighted, total := iterator.weightedPoolForCandidatePool(pool, mode, iterator.now())
|
||||
for _, modes := range iterator.routeModeTiers {
|
||||
weighted, total := iterator.weightedPoolForCandidatePool(pool, modes, iterator.now())
|
||||
if total <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -261,7 +266,7 @@ func (iterator *Iterator) Next() (Selection, error) {
|
||||
}
|
||||
}
|
||||
iterator.tried[selected.ID] = struct{}{}
|
||||
target := pool.targetsByMode[mode][selected.GroupID]
|
||||
target := pool.targetsByGroup[selected.GroupID]
|
||||
return newSelection(selected, target), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ func TestLoaderPublishesDefaultsFromEmptyDatabase(t *testing.T) {
|
||||
if got.FirstByteTimeout != 120*time.Second ||
|
||||
got.RequestTimeout != 600*time.Second ||
|
||||
got.StreamIdleTimeout != 300*time.Second ||
|
||||
got.RouteStrategy != state.RouteStrategyNativeFirst ||
|
||||
got.RequestLogRetentionDays != 7 {
|
||||
t.Fatalf("Settings = %#v", got)
|
||||
}
|
||||
@@ -264,6 +265,9 @@ func TestLoaderRejectsInvalidKnownPublicSystemSettingsWithoutPublishing(t *testi
|
||||
}{
|
||||
{name: "null header rules", key: state.SettingHeaderRules, value: "null"},
|
||||
{name: "fractional request timeout", key: state.SettingRequestTimeout, value: "1.5"},
|
||||
{name: "unknown route strategy", key: state.SettingRouteStrategy, value: `"unknown"`},
|
||||
{name: "null route strategy", key: state.SettingRouteStrategy, value: "null"},
|
||||
{name: "boolean route strategy", key: state.SettingRouteStrategy, value: "true"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
SettingResponseHeaderRules = "response_header_rules"
|
||||
SettingInjectUsageOptions = "inject_usage_options"
|
||||
SettingRetryCount = "retry_count"
|
||||
SettingRouteStrategy = "route_strategy"
|
||||
SettingBlacklistThreshold = "blacklist_threshold"
|
||||
SettingAffinityEnabled = "affinity_enabled"
|
||||
SettingAffinityTTL = "affinity_ttl"
|
||||
@@ -33,6 +34,13 @@ const (
|
||||
SettingParameterOverrides = "parameter_overrides"
|
||||
)
|
||||
|
||||
type RouteStrategy string
|
||||
|
||||
const (
|
||||
RouteStrategyNativeFirst RouteStrategy = "native_first"
|
||||
RouteStrategyWeightedMix RouteStrategy = "weighted_mix"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRequestLogRetentionDays = 7
|
||||
minRequestLogRetentionDays = 1
|
||||
@@ -51,6 +59,7 @@ type RuntimeSettings struct {
|
||||
ResponseHeaderRules HeaderRules
|
||||
InjectUsageOptions bool
|
||||
RetryCount int
|
||||
RouteStrategy RouteStrategy
|
||||
BlacklistThreshold int
|
||||
AffinityEnabled bool
|
||||
AffinityTTL time.Duration
|
||||
@@ -80,6 +89,7 @@ func DefaultRuntimeSettings() RuntimeSettings {
|
||||
ResponseHeaderRules: HeaderRules{Set: map[string]string{}},
|
||||
InjectUsageOptions: true,
|
||||
RetryCount: 2,
|
||||
RouteStrategy: RouteStrategyNativeFirst,
|
||||
BlacklistThreshold: 3,
|
||||
AffinityEnabled: true,
|
||||
AffinityTTL: time.Hour,
|
||||
@@ -100,6 +110,7 @@ func IsRuntimeSettingKey(key string) bool {
|
||||
SettingResponseHeaderRules,
|
||||
SettingInjectUsageOptions,
|
||||
SettingRetryCount,
|
||||
SettingRouteStrategy,
|
||||
SettingBlacklistThreshold,
|
||||
SettingAffinityEnabled,
|
||||
SettingAffinityTTL,
|
||||
@@ -165,6 +176,12 @@ func ResolveRuntimeSettings(settings config.Settings) (RuntimeSettings, error) {
|
||||
return RuntimeSettings{}, err
|
||||
}
|
||||
resolved.RetryCount = count
|
||||
case SettingRouteStrategy:
|
||||
strategy, err := parseRouteStrategy(value)
|
||||
if err != nil {
|
||||
return RuntimeSettings{}, err
|
||||
}
|
||||
resolved.RouteStrategy = strategy
|
||||
case SettingBlacklistThreshold:
|
||||
threshold, err := nonNegativeWholeNumber(key, value)
|
||||
if err != nil {
|
||||
@@ -321,6 +338,9 @@ func ValidateRuntimeSetting(key string, value any) error {
|
||||
case SettingRetryCount, SettingBlacklistThreshold:
|
||||
_, err := nonNegativeWholeNumber(key, value)
|
||||
return err
|
||||
case SettingRouteStrategy:
|
||||
_, err := parseRouteStrategy(value)
|
||||
return err
|
||||
case SettingAffinityEnabled:
|
||||
_, err := strictBoolean(key, value)
|
||||
return err
|
||||
@@ -346,6 +366,17 @@ func ValidateRuntimeSetting(key string, value any) error {
|
||||
}
|
||||
}
|
||||
|
||||
func parseRouteStrategy(value any) (RouteStrategy, error) {
|
||||
text, ok := value.(string)
|
||||
if ok {
|
||||
switch strategy := RouteStrategy(text); strategy {
|
||||
case RouteStrategyNativeFirst, RouteStrategyWeightedMix:
|
||||
return strategy, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("%s must be native_first or weighted_mix", SettingRouteStrategy)
|
||||
}
|
||||
|
||||
func strictBoolean(path string, value any) (bool, error) {
|
||||
parsed, ok := value.(bool)
|
||||
if !ok {
|
||||
|
||||
@@ -11,6 +11,33 @@ import (
|
||||
"gpt-load/internal/platform/config"
|
||||
)
|
||||
|
||||
func TestRouteStrategyAcceptsOnlyExplicitGlobalPolicies(t *testing.T) {
|
||||
const key = "route_strategy"
|
||||
if !IsRuntimeSettingKey(key) {
|
||||
t.Fatal("route_strategy is not a public runtime setting")
|
||||
}
|
||||
for _, value := range []string{"native_first", "weighted_mix"} {
|
||||
if err := ValidateRuntimeSetting(key, value); err != nil {
|
||||
t.Errorf("ValidateRuntimeSetting(%q) error = %v", value, err)
|
||||
}
|
||||
resolved, err := ResolveRuntimeSettings(config.Settings{key: value})
|
||||
if err != nil || string(resolved.RouteStrategy) != value {
|
||||
t.Errorf("ResolveRuntimeSettings(%q) = %#v, %v", value, resolved, err)
|
||||
}
|
||||
if _, err := ResolveGroupRuntimeSettings(DefaultRuntimeSettings(), config.Settings{key: value}); err == nil {
|
||||
t.Errorf("Group accepted system-only route_strategy %q", value)
|
||||
}
|
||||
}
|
||||
for _, value := range []any{nil, "", "unknown", "Native_First", " weighted_mix ", true, 1, []any{}, map[string]any{}} {
|
||||
if err := ValidateRuntimeSetting(key, value); err == nil {
|
||||
t.Errorf("ValidateRuntimeSetting(%#v) accepted invalid route strategy", value)
|
||||
}
|
||||
if _, err := ResolveRuntimeSettings(config.Settings{key: value}); err == nil {
|
||||
t.Errorf("ResolveRuntimeSettings(%#v) accepted invalid route strategy", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompilePublishesDefaultRuntimeSettingsWithoutGroups(t *testing.T) {
|
||||
snapshot, err := Compile(CompileInput{})
|
||||
if err != nil {
|
||||
@@ -29,6 +56,7 @@ func TestCompilePublishesDefaultRuntimeSettingsWithoutGroups(t *testing.T) {
|
||||
ResponseHeaderRules: HeaderRules{Set: map[string]string{}},
|
||||
InjectUsageOptions: true,
|
||||
RetryCount: 2,
|
||||
RouteStrategy: RouteStrategyNativeFirst,
|
||||
BlacklistThreshold: 3,
|
||||
AffinityEnabled: true,
|
||||
AffinityTTL: time.Hour,
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ProtocolValue } from './protocols'
|
||||
|
||||
export type GroupProtocol = ProtocolValue
|
||||
export type AccessProtocol = ProtocolValue
|
||||
export const routeStrategies = ['native_first', 'weighted_mix'] as const
|
||||
export type RouteStrategy = (typeof routeStrategies)[number]
|
||||
export type FailureCategory =
|
||||
| 'ok'
|
||||
| 'rate_limited'
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import { enabledDataProtocols } from '@/api/control/protocols'
|
||||
import type { AccessKeyDto, AccessProtocol } from '@/api/control/types'
|
||||
import {
|
||||
routeStrategies,
|
||||
type AccessKeyDto,
|
||||
type AccessProtocol,
|
||||
type RouteStrategy,
|
||||
} from '@/api/control/types'
|
||||
import { InvalidResponseError } from '@/api/errors'
|
||||
|
||||
import {
|
||||
@@ -87,6 +92,7 @@ export interface RouteInspectGroupDto {
|
||||
export interface RouteInspectResponseDto {
|
||||
observed_at_ms: number
|
||||
snapshot_revision: number
|
||||
route_strategy: RouteStrategy
|
||||
protocol: AccessProtocol
|
||||
operation: RouteInspectOperation
|
||||
route_requirement: RouteInspectRequirement
|
||||
@@ -231,6 +237,7 @@ export function projectRouteInspection(value: unknown): RouteInspectResponseDto
|
||||
assertNoSecretLikeFields(record, [
|
||||
'observed_at_ms',
|
||||
'snapshot_revision',
|
||||
'route_strategy',
|
||||
'protocol',
|
||||
'operation',
|
||||
'route_requirement',
|
||||
@@ -243,6 +250,7 @@ export function projectRouteInspection(value: unknown): RouteInspectResponseDto
|
||||
return {
|
||||
observed_at_ms: projectEpochMilliseconds(record.observed_at_ms),
|
||||
snapshot_revision: projectSafeInteger(record.snapshot_revision, { minimum: 1 }),
|
||||
route_strategy: projectEnum(record.route_strategy, routeStrategies),
|
||||
protocol: projectEnum(record.protocol, enabledDataProtocols),
|
||||
operation: projectEnum(record.operation, routeInspectOperations),
|
||||
route_requirement: projectEnum(record.route_requirement, routeInspectRequirements),
|
||||
|
||||
@@ -2,7 +2,12 @@ import { queryOptions } from '@tanstack/vue-query'
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { ProxyMutation, ProxyViewDto } from '@/api/control/types'
|
||||
import {
|
||||
routeStrategies,
|
||||
type ProxyMutation,
|
||||
type ProxyViewDto,
|
||||
type RouteStrategy,
|
||||
} from '@/api/control/types'
|
||||
import { InvalidResponseError } from '@/api/errors'
|
||||
import { controlQueryKeys } from '@/app/query-keys'
|
||||
|
||||
@@ -11,6 +16,7 @@ import {
|
||||
assertNoSecretLikeFields,
|
||||
projectArray,
|
||||
projectBoolean,
|
||||
projectEnum,
|
||||
projectRecord,
|
||||
projectSafeInteger,
|
||||
projectString,
|
||||
@@ -18,6 +24,7 @@ import {
|
||||
import { projectProxyView } from './proxy'
|
||||
|
||||
export const runtimeSettingKeys = [
|
||||
'route_strategy',
|
||||
'first_byte_timeout',
|
||||
'request_timeout',
|
||||
'stream_idle_timeout',
|
||||
@@ -38,6 +45,7 @@ export const runtimeSettingKeys = [
|
||||
export type RuntimeSettingKey = (typeof runtimeSettingKeys)[number]
|
||||
export type TimeoutSettingKey = Exclude<
|
||||
RuntimeSettingKey,
|
||||
| 'route_strategy'
|
||||
| 'retry_count'
|
||||
| 'blacklist_threshold'
|
||||
| 'header_rules'
|
||||
@@ -62,6 +70,7 @@ export interface CORSConfigDto {
|
||||
}
|
||||
|
||||
export interface SettingsValues {
|
||||
route_strategy: RouteStrategy
|
||||
first_byte_timeout: number
|
||||
request_timeout: number
|
||||
stream_idle_timeout: number
|
||||
@@ -87,6 +96,7 @@ export interface SettingsDto {
|
||||
}
|
||||
|
||||
export type SettingsPatch = Partial<{
|
||||
route_strategy: RouteStrategy | null
|
||||
first_byte_timeout: number | null
|
||||
request_timeout: number | null
|
||||
stream_idle_timeout: number | null
|
||||
@@ -186,6 +196,7 @@ export function projectSettings(value: unknown): SettingsDto {
|
||||
|
||||
return {
|
||||
values: {
|
||||
route_strategy: projectEnum(values.route_strategy, routeStrategies),
|
||||
first_byte_timeout: projectSafeInteger(values.first_byte_timeout, { minimum: 1 }),
|
||||
request_timeout: projectSafeInteger(values.request_timeout, { minimum: 1 }),
|
||||
stream_idle_timeout: projectSafeInteger(values.stream_idle_timeout, { minimum: 1 }),
|
||||
|
||||
@@ -181,6 +181,7 @@ const includedGroups = computed(() =>
|
||||
const excludedGroups = computed(() =>
|
||||
(observation.value?.groups ?? []).filter((group) => !group.included),
|
||||
)
|
||||
const weightedMix = computed(() => observation.value?.route_strategy === 'weighted_mix')
|
||||
const orderedIncludedGroups = computed(() =>
|
||||
[...includedGroups.value].sort((left, right) => {
|
||||
const routeModeOrder = routeModePriority(left) - routeModePriority(right)
|
||||
@@ -199,18 +200,12 @@ const activeRouteMode = computed<'native' | 'converted' | null>(() => {
|
||||
}
|
||||
return null
|
||||
})
|
||||
const activeTierGroups = computed(() =>
|
||||
activeRouteMode.value === null
|
||||
? []
|
||||
: includedGroups.value.filter(
|
||||
(group) => group.routable && group.route_mode === activeRouteMode.value,
|
||||
),
|
||||
)
|
||||
const activeGroups = computed(() => includedGroups.value.filter(isActiveCandidate))
|
||||
const availableCredentialCount = computed(() =>
|
||||
activeTierGroups.value.reduce((total, group) => total + groupAvailableCredentialCount(group), 0),
|
||||
activeGroups.value.reduce((total, group) => total + groupAvailableCredentialCount(group), 0),
|
||||
)
|
||||
const totalEffectiveWeight = computed(() =>
|
||||
activeTierGroups.value.reduce((total, group) => total + groupEffectiveWeight(group), 0),
|
||||
activeGroups.value.reduce((total, group) => total + groupEffectiveWeight(group), 0),
|
||||
)
|
||||
|
||||
function readProtocol(raw: unknown): AccessProtocol | '' {
|
||||
@@ -437,22 +432,27 @@ function accessKeyStatusTone(status: 'active' | 'disabled'): 'success' | 'neutra
|
||||
}
|
||||
|
||||
function routeModePriority(group: RouteInspectGroupDto): number {
|
||||
return group.route_mode === 'native' ? 0 : 1
|
||||
return weightedMix.value || group.route_mode === 'native' ? 0 : 1
|
||||
}
|
||||
|
||||
function isActiveCandidate(group: RouteInspectGroupDto): boolean {
|
||||
return group.routable && (weightedMix.value || group.route_mode === activeRouteMode.value)
|
||||
}
|
||||
|
||||
function routePriorityTone(group: RouteInspectGroupDto): StatusTone {
|
||||
if (!group.routable) return 'neutral'
|
||||
if (group.route_mode !== activeRouteMode.value) return 'neutral'
|
||||
if (!isActiveCandidate(group)) return 'neutral'
|
||||
return group.route_mode === 'native' ? 'success' : 'warning'
|
||||
}
|
||||
|
||||
function routePriorityLabel(group: RouteInspectGroupDto): string {
|
||||
return t(`monitor.inspector.groups.priority.${group.route_mode}`)
|
||||
return weightedMix.value
|
||||
? t(`monitor.inspector.routeModes.${group.route_mode}`)
|
||||
: t(`monitor.inspector.groups.priority.${group.route_mode}`)
|
||||
}
|
||||
|
||||
function groupStatusLabel(group: RouteInspectGroupDto): string {
|
||||
if (!group.routable) return t('monitor.inspector.result.notRoutable')
|
||||
return group.route_mode === activeRouteMode.value
|
||||
return isActiveCandidate(group)
|
||||
? t('monitor.inspector.groups.weightedCandidate')
|
||||
: t('monitor.inspector.groups.fallbackCandidate')
|
||||
}
|
||||
@@ -482,14 +482,14 @@ function groupEffectiveWeight(group: RouteInspectGroupDto): number {
|
||||
}
|
||||
|
||||
function groupShare(group: RouteInspectGroupDto): number {
|
||||
if (group.route_mode !== activeRouteMode.value) return 0
|
||||
if (!isActiveCandidate(group)) return 0
|
||||
const total = totalEffectiveWeight.value
|
||||
if (total <= 0) return 0
|
||||
return Math.round((groupEffectiveWeight(group) / total) * 1_000) / 10
|
||||
}
|
||||
|
||||
function groupShareLabel(group: RouteInspectGroupDto): string {
|
||||
if (group.route_mode !== activeRouteMode.value) return t('monitor.inspector.groups.standbyShare')
|
||||
if (!isActiveCandidate(group)) return t('monitor.inspector.groups.standbyShare')
|
||||
return formatPercent(groupEffectiveWeight(group), totalEffectiveWeight.value, locale.value)
|
||||
}
|
||||
|
||||
@@ -637,6 +637,13 @@ onBeforeUnmount(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="route-summary__meta">
|
||||
<span>
|
||||
{{
|
||||
t('monitor.inspector.result.routeStrategy', {
|
||||
strategy: t(`settings.runtime.routeStrategies.${observation.route_strategy}`),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<time :datetime="observationDateTime">
|
||||
{{
|
||||
t('monitor.inspector.result.observedAt', {
|
||||
@@ -725,7 +732,7 @@ onBeforeUnmount(() => {
|
||||
<MonitorSectionHeading
|
||||
id="route-candidates-title"
|
||||
:title="t('monitor.inspector.groups.title')"
|
||||
:description="t('monitor.inspector.groups.description')"
|
||||
:description="t(`monitor.inspector.groups.description.${observation.route_strategy}`)"
|
||||
:meta="
|
||||
t('monitor.inspector.groups.count', {
|
||||
count: formattedInteger(includedGroups.length),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ProxyConfiguredMode, ProxyViewDto } from '@/api/control/types'
|
||||
import { routeStrategies, type ProxyConfiguredMode, type ProxyViewDto } from '@/api/control/types'
|
||||
import { proxyOverrideToggleMode } from '@/app/resources/proxy'
|
||||
import type {
|
||||
PolicyCountSettingKey,
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
} from '@/app/resources/settings'
|
||||
import ProxyOverrideControl from '@/components/config/ProxyOverrideControl.vue'
|
||||
import RuntimeOverrideRow from '@/components/config/RuntimeOverrideRow.vue'
|
||||
import AppSelect from '@/components/ui/AppSelect.vue'
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue'
|
||||
import AppTextInput from '@/components/ui/AppTextInput.vue'
|
||||
import CompactFieldError from '@/components/ui/CompactFieldError.vue'
|
||||
@@ -40,6 +41,12 @@ const emit = defineEmits<{
|
||||
'update:proxyEndpoint': [value: string]
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const routeStrategyOptions = computed(() =>
|
||||
routeStrategies.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.runtime.routeStrategies.${value}`),
|
||||
})),
|
||||
)
|
||||
|
||||
// 代理沿用其它设置项的覆盖语义:inherit 即“未覆盖”,direct/custom 即“显式覆盖”。
|
||||
const proxyOverridden = computed(() => props.proxyMode !== 'inherit')
|
||||
@@ -107,6 +114,14 @@ function setInjectUsage(value: boolean): void {
|
||||
publish('inject_usage_options', draft)
|
||||
}
|
||||
|
||||
function setRouteStrategy(value: string): void {
|
||||
const strategy = routeStrategies.find((candidate) => candidate === value)
|
||||
if (strategy === undefined) return
|
||||
const draft = cloneDraft()
|
||||
draft.values.route_strategy = strategy
|
||||
publish('route_strategy', draft)
|
||||
}
|
||||
|
||||
function setPolicyCount(key: PolicyCountSettingKey, value: string): void {
|
||||
const draft = cloneDraft()
|
||||
draft.values[key] = Number(value)
|
||||
@@ -144,6 +159,53 @@ function policyCountError(key: PolicyCountSettingKey): string | undefined {
|
||||
</header>
|
||||
|
||||
<div class="settings-runtime__rows">
|
||||
<div class="settings-runtime__entry">
|
||||
<RuntimeOverrideRow
|
||||
appearance="ledger"
|
||||
:label="t('settings.runtime.route_strategy')"
|
||||
:detail="t('settings.runtime.routeStrategyHelp')"
|
||||
:source-label="
|
||||
hasOverride('route_strategy')
|
||||
? t('settings.runtime.overrideSource')
|
||||
: isPendingRestore('route_strategy')
|
||||
? t('settings.runtime.pendingRestoreSource')
|
||||
: t('settings.runtime.defaultSource')
|
||||
"
|
||||
:action-label="
|
||||
hasOverride('route_strategy')
|
||||
? t('settings.runtime.restoreDefault')
|
||||
: t('settings.runtime.override')
|
||||
"
|
||||
:overridden="hasOverride('route_strategy')"
|
||||
:pending-restore="isPendingRestore('route_strategy')"
|
||||
:disabled="disabled"
|
||||
@toggle="toggleOverride('route_strategy')"
|
||||
>
|
||||
<template #value>
|
||||
<div class="settings-runtime__choice">
|
||||
<AppSelect
|
||||
v-if="hasOverride('route_strategy')"
|
||||
:model-value="draft.values.route_strategy"
|
||||
:options="routeStrategyOptions"
|
||||
:label="t('settings.runtime.route_strategy')"
|
||||
:disabled="disabled"
|
||||
size="compact"
|
||||
@update:model-value="setRouteStrategy"
|
||||
/>
|
||||
<strong v-else-if="isPendingRestore('route_strategy')">{{
|
||||
t('settings.runtime.resetPending')
|
||||
}}</strong>
|
||||
<strong v-else>{{
|
||||
t(`settings.runtime.routeStrategies.${base.settings.values.route_strategy}`)
|
||||
}}</strong>
|
||||
<small v-if="!hasOverride('route_strategy') && !isPendingRestore('route_strategy')">
|
||||
{{ t('settings.runtime.currentEffective') }}
|
||||
</small>
|
||||
<small>{{ t('settings.runtime.routeStrategyHelp') }}</small>
|
||||
</div>
|
||||
</template>
|
||||
</RuntimeOverrideRow>
|
||||
</div>
|
||||
<div class="settings-runtime__entry">
|
||||
<RuntimeOverrideRow
|
||||
appearance="ledger"
|
||||
@@ -447,6 +509,7 @@ function policyCountError(key: PolicyCountSettingKey): string | undefined {
|
||||
.settings-section__heading,
|
||||
.settings-runtime__rows,
|
||||
.settings-runtime__entry,
|
||||
.settings-runtime__choice,
|
||||
.settings-runtime__boolean {
|
||||
display: grid;
|
||||
}
|
||||
@@ -479,7 +542,8 @@ function policyCountError(key: PolicyCountSettingKey): string | undefined {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.settings-runtime__boolean {
|
||||
.settings-runtime__boolean,
|
||||
.settings-runtime__choice {
|
||||
justify-items: start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RouteStrategy } from '@/api/control/types'
|
||||
import type { HeaderRulesDto } from '@/app/resources/groups'
|
||||
import type {
|
||||
RuntimeSettingKey,
|
||||
@@ -21,6 +22,7 @@ export interface SettingsDraft {
|
||||
}
|
||||
|
||||
const requestForwardingKeys: RuntimeSettingKey[] = [
|
||||
'route_strategy',
|
||||
'first_byte_timeout',
|
||||
'request_timeout',
|
||||
'stream_idle_timeout',
|
||||
@@ -80,7 +82,9 @@ export function setSettingsOverride(
|
||||
if (next.readOnly.has(key)) return next
|
||||
if (enabled) {
|
||||
next.overrides.add(key)
|
||||
if (key === 'inject_usage_options') {
|
||||
if (key === 'route_strategy') {
|
||||
next.values.route_strategy = base.values.route_strategy
|
||||
} else if (key === 'inject_usage_options') {
|
||||
next.values.inject_usage_options = base.values.inject_usage_options
|
||||
} else if (key === 'affinity_enabled') {
|
||||
next.values.affinity_enabled = base.values.affinity_enabled
|
||||
@@ -112,7 +116,7 @@ function normalizeHeaderRules(value: HeaderRulesDto): HeaderRulesDto {
|
||||
function normalizedWireValue(
|
||||
settings: SettingsValues,
|
||||
key: RuntimeSettingKey,
|
||||
): number | boolean | HeaderRulesDto | CORSConfigDto {
|
||||
): number | boolean | RouteStrategy | HeaderRulesDto | CORSConfigDto {
|
||||
if (key === 'header_rules' || key === 'response_header_rules')
|
||||
return normalizeHeaderRules(settings[key])
|
||||
if (key === 'cors') return normalizeCORSConfig(settings.cors)
|
||||
@@ -144,7 +148,7 @@ function canonicalHeaderRulesIdentity(value: HeaderRulesDto): HeaderRulesDto {
|
||||
function normalizedIdentityValue(
|
||||
settings: SettingsValues,
|
||||
key: RuntimeSettingKey,
|
||||
): number | boolean | HeaderRulesDto | CORSConfigDto {
|
||||
): number | boolean | RouteStrategy | HeaderRulesDto | CORSConfigDto {
|
||||
if (key === 'header_rules' || key === 'response_header_rules')
|
||||
return canonicalHeaderRulesIdentity(settings[key])
|
||||
if (key === 'cors') return canonicalCORSIdentity(settings.cors)
|
||||
|
||||
@@ -466,6 +466,7 @@ export default {
|
||||
reasonLine: 'Reason: {reason}',
|
||||
observedAt: 'Observed at {time}',
|
||||
revision: 'Revision {revision}',
|
||||
routeStrategy: 'Route strategy: {strategy}',
|
||||
protocol: 'Protocol',
|
||||
externalModel: 'Client model',
|
||||
modelNotSpecified: 'Not specified',
|
||||
@@ -481,8 +482,12 @@ export default {
|
||||
},
|
||||
groups: {
|
||||
title: 'Candidate Groups',
|
||||
description:
|
||||
'Native routes are tried first, with protocol conversion used only when no native candidate is available. Within a tier, selection is weighted random; rows are sorted by weight, not fixed hit order.',
|
||||
description: {
|
||||
native_first:
|
||||
'Native routes are tried first; converted routes are used when no native candidate is available. Selection within a tier is weighted random; rows are sorted by tier and weight. Request affinity still applies, so weight shares are not actual traffic ratios.',
|
||||
weighted_mix:
|
||||
'Eligible native and converted candidates compete in one pool by effective weight; rows are sorted by weight. Request affinity still applies, so weight shares are not actual traffic ratios.',
|
||||
},
|
||||
count: '{count}',
|
||||
tableLabel: 'Candidate Group route explanation',
|
||||
completeEmpty:
|
||||
@@ -503,7 +508,7 @@ export default {
|
||||
viewGroup: 'View Group',
|
||||
columns: {
|
||||
group: 'Group',
|
||||
status: 'Route tier',
|
||||
status: 'Route mode',
|
||||
credentials: 'Credentials',
|
||||
weight: 'Effective weight',
|
||||
share: 'Weight share',
|
||||
|
||||
@@ -40,6 +40,13 @@ export default {
|
||||
title: 'Request and forwarding',
|
||||
description:
|
||||
'Explicit values override built-in defaults. Restoring a default uses the current version; all time values are in seconds.',
|
||||
route_strategy: 'Route strategy',
|
||||
routeStrategies: {
|
||||
native_first: 'Native first',
|
||||
weighted_mix: 'Weighted mix',
|
||||
},
|
||||
routeStrategyHelp:
|
||||
'Native first favors native capabilities. Weighted mix lets native and converted candidates compete by effective weight; conversion may differ in capabilities. Request affinity still applies, so traffic shares are not guaranteed.',
|
||||
first_byte_timeout: 'Native response / stream first-event timeout',
|
||||
request_timeout: 'Total request timeout',
|
||||
stream_idle_timeout: 'Stream idle timeout',
|
||||
|
||||
@@ -466,6 +466,7 @@ export default {
|
||||
reasonLine: '理由:{reason}',
|
||||
observedAt: '観測時刻 {time}',
|
||||
revision: 'リビジョン {revision}',
|
||||
routeStrategy: 'ルート戦略:{strategy}',
|
||||
protocol: 'プロトコル',
|
||||
externalModel: 'クライアントモデル',
|
||||
modelNotSpecified: '未指定',
|
||||
@@ -479,8 +480,12 @@ export default {
|
||||
},
|
||||
groups: {
|
||||
title: '候補グループ',
|
||||
description:
|
||||
'まずネイティブルートを試し、候補がない場合のみプロトコル変換を使用します。同じ階層では有効ウェイトによる加重ランダムで、一覧はウェイト順ですが固定のヒット順ではありません。',
|
||||
description: {
|
||||
native_first:
|
||||
'ネイティブルートを先に試し、利用可能な候補がない場合に変換を使用します。同じ階層では有効ウェイトによる加重ランダムで、階層とウェイト順に表示します。リクエスト親和性は引き続き有効で、ウェイト比率は実際のトラフィック比率とは異なります。',
|
||||
weighted_mix:
|
||||
'要求を満たすネイティブと変換候補が同じプールで有効ウェイトにより競合し、ウェイト順に表示します。リクエスト親和性は引き続き有効で、ウェイト比率は実際のトラフィック比率とは異なります。',
|
||||
},
|
||||
count: '{count} 件',
|
||||
tableLabel: '候補グループのルート説明',
|
||||
completeEmpty:
|
||||
@@ -501,7 +506,7 @@ export default {
|
||||
viewGroup: 'グループを表示',
|
||||
columns: {
|
||||
group: 'グループ',
|
||||
status: 'ルート階層',
|
||||
status: 'ルートモード',
|
||||
credentials: '認証情報',
|
||||
weight: '有効ウェイト',
|
||||
share: 'ウェイト比率',
|
||||
|
||||
@@ -40,6 +40,13 @@ export default {
|
||||
title: 'リクエストと転送',
|
||||
description:
|
||||
'明示値は組み込み既定値を上書きします。既定値の復元後は現在のバージョンが有効値を決めます。すべての時間設定は秒単位です。',
|
||||
route_strategy: 'ルート戦略',
|
||||
routeStrategies: {
|
||||
native_first: 'ネイティブ優先',
|
||||
weighted_mix: '混合ウェイト',
|
||||
},
|
||||
routeStrategyHelp:
|
||||
'ネイティブ優先は本来の機能をできるだけ維持します。混合ウェイトではネイティブと変換候補が有効ウェイトで競合し、変換による機能差が生じる場合があります。リクエスト親和性は引き続き有効で、厳密なトラフィック比率は保証されません。',
|
||||
first_byte_timeout: 'ネイティブ応答 / ストリーム初回イベントのタイムアウト',
|
||||
request_timeout: 'リクエスト全体のタイムアウト',
|
||||
stream_idle_timeout: 'ストリームアイドルタイムアウト',
|
||||
|
||||
@@ -448,6 +448,7 @@ export default {
|
||||
reasonLine: '原因:{reason}',
|
||||
observedAt: '观测时间 {time}',
|
||||
revision: '修订 {revision}',
|
||||
routeStrategy: '路由策略:{strategy}',
|
||||
protocol: '协议',
|
||||
externalModel: '客户端模型',
|
||||
modelNotSpecified: '未指定',
|
||||
@@ -461,8 +462,12 @@ export default {
|
||||
},
|
||||
groups: {
|
||||
title: '候选分组',
|
||||
description:
|
||||
'先尝试原生路由,仅在原生无可用候选时使用协议转换;同一层级按有效权重加权随机,列表按权重降序展示,并非固定命中次序。',
|
||||
description: {
|
||||
native_first:
|
||||
'先尝试原生路由,原生无可用候选时使用协议转换;同层按有效权重加权随机。列表按层级和权重排序;请求亲和仍生效,权重份额不等于实际流量比例。',
|
||||
weighted_mix:
|
||||
'符合请求要求的原生与转换候选在同一池按有效权重加权随机,列表按权重排序。请求亲和仍生效,权重份额不等于实际流量比例。',
|
||||
},
|
||||
count: '{count} 个',
|
||||
tableLabel: '候选分组路由解释',
|
||||
completeEmpty: '未返回候选分组;这是该输入的完整当前运行时解释,并非空态。',
|
||||
@@ -482,7 +487,7 @@ export default {
|
||||
viewGroup: '查看分组',
|
||||
columns: {
|
||||
group: '分组',
|
||||
status: '路由层级',
|
||||
status: '路由模式',
|
||||
credentials: '凭据',
|
||||
weight: '有效权重',
|
||||
share: '权重份额',
|
||||
|
||||
@@ -39,6 +39,13 @@ export default {
|
||||
runtime: {
|
||||
title: '请求与转发',
|
||||
description: '显式值覆盖内置默认;恢复默认后由当前版本决定有效值。所有时间设置均以秒为单位。',
|
||||
route_strategy: '路由策略',
|
||||
routeStrategies: {
|
||||
native_first: '原生优先',
|
||||
weighted_mix: '混合权重',
|
||||
},
|
||||
routeStrategyHelp:
|
||||
'原生优先尽量保留原生能力;混合权重让原生与转换候选按有效权重竞争,转换可能存在能力差异。请求亲和仍生效,流量不保证严格按权重分配。',
|
||||
first_byte_timeout: '原生响应 / 流式首事件超时',
|
||||
request_timeout: '请求总时长',
|
||||
stream_idle_timeout: '流空闲超时',
|
||||
|
||||
Reference in new issue
Block a user