feat(scheduler): 支持原生与转换路由混合权重调度 (#570)

* feat(scheduler): 支持原生与转换路由混合权重调度

* fix(settings): 补齐路由策略当前有效值提示
This commit is contained in:
tbphp authored and GitHub committed 2026-09-05 12:58:22 +08:00
1 parent ad8be749e3
commit c86a9ad1da
24 files changed
+690 -46

No files matched your search

+4
View File
@@ -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 {
+2
View File
@@ -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,
+48
View File
@@ -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)
+2
View File
@@ -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),
+42
View File
@@ -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)
+52
View File
@@ -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)
}
+82
View File
@@ -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)
}
}
}
+212
View File
@@ -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)
}
}
+18 -13
View File
@@ -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
}
}
+4
View File
@@ -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) {
+31
View File
@@ -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 {
+28
View File
@@ -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
View File
@@ -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'
+9 -1
View File
@@ -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),
+12 -1
View File
@@ -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 }),
+24 -17
View File
@@ -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);
}
+7 -3
View File
@@ -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)
+8 -3
View File
@@ -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',
+7
View File
@@ -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',
+8 -3
View File
@@ -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: 'ウェイト比率',
+7
View File
@@ -40,6 +40,13 @@ export default {
title: 'リクエストと転送',
description:
'明示値は組み込み既定値を上書きします。既定値の復元後は現在のバージョンが有効値を決めます。すべての時間設定は秒単位です。',
route_strategy: 'ルート戦略',
routeStrategies: {
native_first: 'ネイティブ優先',
weighted_mix: '混合ウェイト',
},
routeStrategyHelp:
'ネイティブ優先は本来の機能をできるだけ維持します。混合ウェイトではネイティブと変換候補が有効ウェイトで競合し、変換による機能差が生じる場合があります。リクエスト親和性は引き続き有効で、厳密なトラフィック比率は保証されません。',
first_byte_timeout: 'ネイティブ応答 / ストリーム初回イベントのタイムアウト',
request_timeout: 'リクエスト全体のタイムアウト',
stream_idle_timeout: 'ストリームアイドルタイムアウト',
+8 -3
View File
@@ -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: '权重份额',
+7
View File
@@ -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: '流空闲超时',