Files
tbphp c55312e155 feat(groups): 支持选择测试协议和模型并修复禁用分组测试 (#623)
* feat(groups): 支持配置和选择密钥测试协议

* fix(groups): 完善测试模型选择并遵循渠道协议声明

* fix(groups): 修复禁用分组测试并统一测试设置布局

* fix(groups): 统一测试控件样式并复用模型输入
2026-09-11 12:59:01 +08:00

498 lines
14 KiB
Go

package control
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/binary"
"hash"
"net/http"
"net/textproto"
"slices"
"sort"
"strings"
"sync"
"github.com/sirupsen/logrus"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/health"
"gpt-load/internal/outboundproxy"
"gpt-load/internal/platform/encryption"
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/platform/utils"
"gpt-load/internal/protocol"
"gpt-load/internal/state"
"gpt-load/internal/storage/models"
)
const validationConcurrency = 8
type validationSweep interface {
Validate(context.Context)
}
type validationRegistry interface {
BlacklistedCredentials() []state.CredentialRef
RecoverIfMatch(ref state.CredentialRef) bool
}
type statsResetter interface {
Reset(uint)
}
type snapshotSource interface {
Current() *state.ConfigSnapshot
WithCurrentSnapshot(func(*state.ConfigSnapshot) bool) bool
}
type credentialDecryptor interface {
Decrypt(string) (string, error)
}
type validationWorker struct {
snapshots snapshotSource
registry validationRegistry
stats statsResetter
mutations credentialMutationCoordinator
decryptor credentialDecryptor
channels *channel.Registry
executor execution.Executor
}
type groupValidationSignature [sha256.Size]byte
type groupValidationTarget struct {
protocol protocol.Protocol
fallbackProtocols []protocol.Protocol
model string
signature groupValidationSignature
}
var _ validationSweep = (*validationWorker)(nil)
func newValidationWorker(
manager *state.Manager,
registry *state.CredentialRegistry,
stats *health.StatsStore,
mutations *health.MutationCoordinator,
decryptor encryption.Service,
channels *channel.Registry,
executor execution.Executor,
) *validationWorker {
return &validationWorker{
snapshots: manager,
registry: registry,
stats: stats,
mutations: mutations,
decryptor: decryptor,
channels: channels,
executor: executor,
}
}
func (worker *validationWorker) Validate(ctx context.Context) {
if worker == nil || worker.snapshots == nil || worker.registry == nil {
return
}
snapshot := worker.snapshots.Current()
if snapshot == nil {
return
}
refs := worker.registry.BlacklistedCredentials()
if len(refs) == 0 {
return
}
concurrency := min(validationConcurrency, len(refs))
jobs := make(chan state.CredentialRef)
var workers sync.WaitGroup
workers.Add(concurrency)
for range concurrency {
go func() {
defer workers.Done()
worker.consumeValidationJobs(ctx, snapshot, jobs)
}()
}
dispatch:
for _, ref := range refs {
if ctx.Err() != nil {
break
}
select {
case <-ctx.Done():
break dispatch
case jobs <- ref:
}
}
close(jobs)
workers.Wait()
}
func (worker *validationWorker) consumeValidationJobs(
ctx context.Context,
snapshot *state.ConfigSnapshot,
jobs <-chan state.CredentialRef,
) {
for {
select {
case <-ctx.Done():
return
case ref, ok := <-jobs:
if !ok || ctx.Err() != nil {
return
}
worker.validateRef(ctx, snapshot, ref)
}
}
}
func (worker *validationWorker) validateRef(ctx context.Context, snapshot *state.ConfigSnapshot, ref state.CredentialRef) {
if ctx.Err() != nil {
return
}
group, ok := snapshot.Groups[ref.GroupID]
if !ok {
logValidationFailure(ref, "", "missing_group")
return
}
target, ok := buildGroupValidationTarget(group)
if !ok {
logValidationFailure(ref, "", "missing_target_or_model")
return
}
if worker.executor == nil || worker.channels == nil {
logValidationFailure(ref, string(target.protocol), "missing_executor")
return
}
if worker.decryptor == nil {
logValidationFailure(ref, string(target.protocol), "decrypt")
return
}
if worker.stats == nil {
logValidationFailure(ref, string(target.protocol), "conditional_recover")
return
}
probe := newCredentialProbeExecutor(
worker.decryptor,
worker.channels,
worker.executor,
)
executed, err := probe.Probe(ctx, group, target, ref)
if err != nil {
if ctx.Err() == nil {
logValidationFailure(ref, string(target.protocol), credentialProbeFailureStage(err))
}
return
}
if ctx.Err() != nil {
return
}
if !credentialProbePassed(executed.result) {
if ctx.Err() == nil {
logValidationFailure(ref, string(executed.protocol), "probe")
}
return
}
if worker.mutations == nil {
if ctx.Err() == nil {
logValidationFailure(ref, string(executed.protocol), "conditional_recover")
}
return
}
// This callback follows Manager publishMu -> coordinator stripe ->
// Registry/Stats locks. Keep it to current reads, pure signature work, and
// coordinated recover/reset; decrypt, probe, DB/network, and logging stay
// outside the publication boundary.
recovered := worker.snapshots.WithCurrentSnapshot(func(current *state.ConfigSnapshot) bool {
if current == nil {
return false
}
currentGroup, exists := current.Groups[ref.GroupID]
if !exists {
return false
}
currentTarget, valid := buildGroupValidationTarget(currentGroup)
if !valid || currentTarget.signature != target.signature {
return false
}
var matched bool
worker.mutations.Do(ref.ID, func() {
matched = worker.registry.RecoverIfMatch(ref)
if matched {
worker.stats.Reset(ref.ID)
}
})
return matched
})
if !recovered && ctx.Err() == nil {
logValidationFailure(ref, string(executed.protocol), "conditional_recover")
return
}
if recovered {
logValidationRecovery(ref, string(executed.protocol))
}
}
func validationAttemptProxy(
decryptor credentialDecryptor,
groupProxy outboundproxy.Effective,
ref state.CredentialRef,
) (outboundproxy.Effective, string, error) {
if groupProxy.Config.Mode == "" && ref.EncryptedProxy == "" {
return outboundproxy.Effective{}, "", nil
}
if ref.EncryptedProxy != "" {
hasher, ok := decryptor.(interface{ Hash(string) string })
if !ok {
return outboundproxy.Effective{}, "", app_errors.ErrInternalServer
}
plaintext, err := decryptor.Decrypt(ref.EncryptedProxy)
if err != nil {
return outboundproxy.Effective{}, "", err
}
fingerprint := hasher.Hash(plaintext)
if subtle.ConstantTimeCompare([]byte(fingerprint), []byte(ref.ProxyFingerprint)) != 1 {
plaintext = ""
return outboundproxy.Effective{}, "", app_errors.ErrInternalServer
}
config, err := outboundproxy.Decode(plaintext)
plaintext = ""
if err != nil {
return outboundproxy.Effective{}, "", err
}
effective, err := outboundproxy.Resolve(&config, nil, nil, nil)
return effective, fingerprint, err
}
effective, err := outboundproxy.NormalizeEffective(groupProxy)
if err != nil {
return outboundproxy.Effective{}, "", err
}
hasher, ok := decryptor.(interface{ Hash(string) string })
if !ok {
return effective, "", nil
}
identity := `{"mode":"environment"}`
if effective.Config.Mode != outboundproxy.ModeEnvironment {
identity, err = outboundproxy.Encode(effective.Config)
if err != nil {
return outboundproxy.Effective{}, "", err
}
}
return effective, hasher.Hash(identity), nil
}
func buildGroupValidationTarget(group state.GroupView) (groupValidationTarget, bool) {
if strings.TrimSpace(group.ConnectionType) == string(models.ConnectionTypeSubscription) {
return groupValidationTarget{}, false
}
if group.ChannelID == "" || group.ResolvedTarget.ChannelID != group.ChannelID ||
!group.ResolvedTarget.ProviderKind.Valid() {
return groupValidationTarget{}, false
}
probeModel := strings.TrimSpace(group.ValidationModel)
if probeModel == "" && len(group.Models) > 0 {
probeModel = strings.TrimSpace(group.Models[0].ID)
}
if probeModel == "" {
return groupValidationTarget{}, false
}
selectedProtocol, ok := validationProtocol(group.ResolvedTarget, probeModel)
if group.ValidationProtocol != "" {
selectedProtocol = group.ValidationProtocol
if !slices.Contains(availableValidationProtocols(group.ResolvedTarget), selectedProtocol) {
return groupValidationTarget{}, false
}
_, ok = group.ResolvedTarget.ModeForModel(selectedProtocol, execution.OperationProbe, probeModel)
}
if !ok {
return groupValidationTarget{}, false
}
var fallbackProtocols []protocol.Protocol
for _, candidate := range []protocol.Protocol{protocol.OpenAIEmbeddings, protocol.Rerank} {
if group.ValidationProtocol != "" || candidate == selectedProtocol {
continue
}
if mode, supported := group.ResolvedTarget.ModeForModel(candidate, execution.OperationProbe, probeModel); supported && mode == channel.RouteNative {
fallbackProtocols = append(fallbackProtocols, candidate)
}
}
return groupValidationTarget{
protocol: selectedProtocol, fallbackProtocols: fallbackProtocols,
model: probeModel,
signature: computeGroupValidationSignature(group, selectedProtocol, probeModel),
}, true
}
func validationProtocol(target channel.ResolvedTarget, model string) (protocol.Protocol, bool) {
return target.PreferredProtocol(execution.OperationProbe, model)
}
func validationProbeNeedsProtocolFallback(result execution.AttemptResult) bool {
if result.Validate() != nil || result.Error == nil ||
result.DispatchState != execution.DispatchMaybeSent || !result.ResponseStarted {
return false
}
if result.Error.OriginHint != execution.ErrorOriginUpstream &&
result.Error.OriginHint != execution.ErrorOriginClient {
return false
}
evidence := result.Error
status := result.StatusCode
if status == 0 {
status = evidence.StatusCode
}
switch status {
case http.StatusUnauthorized, http.StatusPaymentRequired, http.StatusForbidden,
http.StatusRequestTimeout, http.StatusConflict, http.StatusTooEarly,
http.StatusTooManyRequests:
return false
case http.StatusMethodNotAllowed, http.StatusNotImplemented:
return true
}
if status >= http.StatusInternalServerError ||
evidence.Hint == execution.FailureHintInvalidCredential ||
evidence.Hint == execution.FailureHintRefreshRequired ||
evidence.Hint == execution.FailureHintReauthorizationRequired ||
evidence.Hint == execution.FailureHintRateLimited ||
evidence.Hint == execution.FailureHintCandidateUnavailable ||
evidence.Hint == execution.FailureHintHostError {
return false
}
if evidence.Hint == execution.FailureHintModelUnavailable {
return true
}
for _, value := range []string{evidence.Type, evidence.Code} {
switch strings.ToLower(strings.TrimSpace(value)) {
case "model_not_found", "model_not_available", "deployment_not_found",
"unsupported_model", "unsupported_operation", "operation_not_supported",
"not_implemented":
return true
}
}
if evidence.Hint != execution.FailureHintRequestRejected {
return false
}
summary := strings.ToLower(evidence.Summary)
for _, marker := range []string{
"not a chat model",
"does not support chat completions",
"chat completions are not supported",
"not supported in the v1/chat/completions endpoint",
"operation is not supported",
} {
if strings.Contains(summary, marker) {
return true
}
}
return false
}
func computeGroupValidationSignature(
group state.GroupView,
selectedProtocol protocol.Protocol,
probeModel string,
) groupValidationSignature {
hasher := sha256.New()
writeValidationSignatureUint64(hasher, uint64(group.ID))
writeValidationSignaturePart(hasher, []byte(group.ChannelID))
writeValidationSignaturePart(hasher, []byte(group.ResolvedTarget.ProviderKind))
writeValidationSignaturePart(hasher, group.ResolvedTarget.TargetConfig)
writeValidationSignaturePart(hasher, []byte(selectedProtocol))
writeValidationSignaturePart(hasher, []byte(probeModel))
writeValidationSignaturePart(hasher, []byte(group.Proxy.Source))
writeValidationSignaturePart(hasher, []byte(group.Proxy.Config.Mode))
writeValidationSignaturePart(hasher, []byte(group.Proxy.Config.URL))
type headerSetPart struct {
name string
value string
}
setParts := make([]headerSetPart, 0, len(group.HeaderRules.Set))
for name, value := range group.HeaderRules.Set {
setParts = append(setParts, headerSetPart{
name: normalizeValidationHeaderName(name),
value: value,
})
}
sort.Slice(setParts, func(i, j int) bool {
if setParts[i].name != setParts[j].name {
return setParts[i].name < setParts[j].name
}
return setParts[i].value < setParts[j].value
})
writeValidationSignatureUint64(hasher, uint64(len(setParts)))
for _, part := range setParts {
writeValidationSignaturePart(hasher, []byte(part.name))
writeValidationSignaturePart(hasher, []byte(part.value))
}
removeParts := make([]string, len(group.HeaderRules.Remove))
for index, name := range group.HeaderRules.Remove {
removeParts[index] = normalizeValidationHeaderName(name)
}
sort.Strings(removeParts)
writeValidationSignatureUint64(hasher, uint64(len(removeParts)))
for _, name := range removeParts {
writeValidationSignaturePart(hasher, []byte(name))
}
var signature groupValidationSignature
copy(signature[:], hasher.Sum(nil))
return signature
}
func writeValidationSignatureUint64(hasher hash.Hash, value uint64) {
var encoded [8]byte
binary.BigEndian.PutUint64(encoded[:], value)
writeValidationSignaturePart(hasher, encoded[:])
}
func writeValidationSignaturePart(hasher hash.Hash, value []byte) {
var encodedLength [8]byte
binary.BigEndian.PutUint64(encodedLength[:], uint64(len(value)))
_, _ = hasher.Write(encodedLength[:])
_, _ = hasher.Write(value)
}
func normalizeValidationHeaderName(name string) string {
return strings.ToLower(textproto.CanonicalMIMEHeaderKey(name))
}
func logValidationRecovery(ref state.CredentialRef, protocol string) {
utils.LogPlaneBestEffort(
logrus.StandardLogger(),
logrus.InfoLevel,
utils.LogPlaneControl,
logrus.Fields{
"event": "credential_recovered",
"credential_id": ref.ID,
"group_id": ref.GroupID,
"protocol": protocol,
},
"Credential recovered",
)
}
func logValidationFailure(ref state.CredentialRef, protocol, stage string) {
utils.LogPlaneBestEffort(
logrus.StandardLogger(),
logrus.WarnLevel,
utils.LogPlaneControl,
logrus.Fields{
"credential_id": ref.ID,
"group_id": ref.GroupID,
"protocol": protocol,
"stage": stage,
},
"Validation failed",
)
}