Files
gpt-load/internal/gateway/execution_boundary_test.go
tbphp dad1f05016 feat(codex): support standalone search (#674)
* feat(codex): 支持订阅渠道独立搜索

* fix(codex): 保留搜索读体失败的响应状态

* feat(logs): 添加独立搜索请求图标
2026-09-17 16:58:58 +08:00

250 lines
9.7 KiB
Go

package gateway
import (
"context"
"errors"
"fmt"
"net/http"
"reflect"
"strings"
"testing"
"time"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/health"
subscriptionproviders "gpt-load/internal/subscription/providers"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)
func TestNormalizeChannelCredentialRequiresCanonicalStoredObject(t *testing.T) {
t.Parallel()
channels, subscriptions := testCredentialRuntimes(t)
credential, err := normalizeChannelCredential(
channels,
subscriptions,
channel.OpenAI,
"api_key",
` {"api_key":"sk-typed"} `,
)
if err != nil {
t.Fatalf("normalizeChannelCredential() error = %v", err)
}
if credential.apiKey != "sk-typed" || string(credential.payload) != `{"api_key":"sk-typed"}` {
t.Fatalf("normalizeChannelCredential() = %q %s", credential.apiKey, credential.payload)
}
for _, invalid := range []string{"", " ", "sk-legacy", `"sk-legacy"`, `[]`, `{}`, `{"api_key":""}`} {
if credential, err := normalizeChannelCredential(channels, subscriptions, channel.OpenAI, "api_key", invalid); err == nil {
t.Fatalf("normalizeChannelCredential(%q) = %#v, nil", invalid, credential)
}
}
}
func TestCodexSearchErrorsDoNotAffectModelHealth(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusTooManyRequests, http.StatusBadGateway} {
t.Run(http.StatusText(status), func(t *testing.T) {
evidence := &execution.ErrorEvidence{Kind: execution.ErrorKindHTTP, StatusCode: status, Summary: "search unavailable"}
if status == http.StatusTooManyRequests {
evidence.Hint = execution.FailureHintRateLimited
evidence.ScopeHint = execution.ErrorScopeModel
}
decision := judgeUpstreamResult(UpstreamResult{
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true, StatusCode: status, ExecutionError: evidence,
}, time.Now(), health.DecisionContext{Method: http.MethodPost, Operation: execution.OperationWebSearch})
wantEffect := health.EffectNone
if status == http.StatusBadGateway {
wantEffect = health.EffectSkipGroup
}
if decision.Effect != wantEffect || !decision.CooldownUntil.IsZero() {
t.Fatalf("search must not penalize model health: %#v", decision)
}
})
}
}
func TestNormalizeChannelCredentialPreservesStructuredCloudSecrets(t *testing.T) {
t.Parallel()
channels, subscriptions := testCredentialRuntimes(t)
got, err := normalizeChannelCredential(
channels,
subscriptions,
channel.AWSBedrock,
"api_key",
` {"access_key":"AKIA_TEST","secret_key":"bedrock-secret","session_token":"bedrock-session"} `,
)
if err != nil {
t.Fatalf("normalizeChannelCredential() error = %v", err)
}
if got.apiKey != "" || string(got.payload) !=
`{"access_key":"AKIA_TEST","secret_key":"bedrock-secret","session_token":"bedrock-session"}` {
t.Fatalf("normalized credential = %#v / %s", got, got.payload)
}
if want := []string{"AKIA_TEST", "bedrock-secret", "bedrock-session"}; !reflect.DeepEqual(got.secrets, want) {
t.Fatalf("secrets = %#v, want %#v", got.secrets, want)
}
}
func TestNormalizeChannelCredentialUsesBoundSubscriptionDriver(t *testing.T) {
channels, subscriptions := testCredentialRuntimes(t)
got, err := normalizeChannelCredential(
channels,
subscriptions,
channel.Codex,
"subscription",
`{"type":"codex","access_token":"access-secret","refresh_token":"refresh-secret","account_id":"account-one"}`,
)
if err != nil {
t.Fatal(err)
}
if got.apiKey != "" || len(got.payload) == 0 || !reflect.DeepEqual(got.secrets[:2], []string{"access-secret", "refresh-secret"}) {
t.Fatalf("normalized subscription credential = %#v", got)
}
}
func testCredentialRuntimes(t *testing.T) (*channel.Registry, *subscriptionruntime.Runtime) {
t.Helper()
channels := channel.NewRegistry()
subscriptions, err := subscriptionruntime.NewRuntime(channels, subscriptionproviders.Implementations()...)
if err != nil {
t.Fatal(err)
}
return channels, subscriptions
}
func TestJudgeUpstreamResultUsesNeutralExecutionEvidence(t *testing.T) {
t.Parallel()
now := time.Unix(1_800_000_000, 0)
result := judgeUpstreamResult(UpstreamResult{
DispatchState: execution.DispatchMaybeSent,
ResponseStarted: true,
StatusCode: http.StatusTooManyRequests,
ExecutionError: &execution.ErrorEvidence{
Kind: execution.ErrorKindHTTP, StatusCode: http.StatusTooManyRequests,
Summary: "upstream rejected request", RetryAfter: 7 * time.Second,
},
}, now, health.DecisionContext{DefaultRateLimitCooldown: time.Minute})
if result.Category != health.FailureCategoryRateLimited ||
result.Effect != health.EffectCooldownCredential ||
!result.CooldownUntil.Equal(now.Add(7*time.Second)) {
t.Fatalf("JudgeExecution() = %#v", result)
}
result = judgeUpstreamResult(UpstreamResult{
DispatchState: execution.DispatchNotSent,
ExecutionError: &execution.ErrorEvidence{
Kind: execution.ErrorKindTransport, Summary: "connection failed",
},
}, now, health.DecisionContext{DefaultRateLimitCooldown: time.Minute})
if result.Category != health.FailureCategoryUpstreamHostError || result.Effect != health.EffectSkipGroup {
t.Fatalf("JudgeExecution(not sent) = %#v", result)
}
}
func TestJudgeUpstreamResultKeepsUpstreamTimeoutOutOfDownstreamCancellation(t *testing.T) {
t.Parallel()
evidence := &execution.ErrorEvidence{
Kind: execution.ErrorKindTimeout, OriginHint: execution.ErrorOriginUpstream,
ScopeHint: execution.ErrorScopeGroup, Summary: "upstream connection timed out",
}
upstream := upstreamFromExecutionResult(
context.Background(),
ForwardInput{},
execution.AttemptResult{
DispatchState: execution.DispatchNotSent,
Error: evidence,
},
)
decision := judgeUpstreamResult(upstream, time.Now(), health.DecisionContext{})
if decision.Category != health.FailureCategoryUpstreamHostError ||
decision.Origin != execution.ErrorOriginUpstream ||
decision.Scope != execution.ErrorScopeGroup ||
decision.Retry != health.RetryNextCandidate ||
decision.Effect != health.EffectSkipGroup ||
decision.RuleID != "transport.not_sent" {
t.Fatalf("timeout decision = %#v", decision)
}
}
func TestJudgeUpstreamResultClassifiesUncommittedProtocolFailureAsUpstream(t *testing.T) {
t.Parallel()
decision := judgeUpstreamResult(UpstreamResult{
Err: fmt.Errorf("%w: invalid execution stream event", ErrUpstreamProtocol),
DispatchState: execution.DispatchMaybeSent,
ResponseStarted: true,
StatusCode: http.StatusOK,
}, time.Now(), health.DecisionContext{})
if decision.Category != health.FailureCategoryAmbiguous ||
decision.Origin != execution.ErrorOriginUpstream ||
decision.Scope != execution.ErrorScopeRequest ||
decision.Retry != health.RetryNone ||
decision.Effect != health.EffectNone ||
decision.RuleID != "stream.protocol_error" {
t.Fatalf("protocol decision = %#v", decision)
}
}
func TestJudgeUpstreamResultUsesStableStreamTerminalRules(t *testing.T) {
t.Parallel()
tests := []struct {
name string
endReason StreamEndReason
wantRule health.RuleID
wantOrigin execution.ErrorOrigin
wantScope execution.ErrorScope
}{
{name: "provider error", endReason: StreamEndSSEError, wantRule: "safety.committed", wantOrigin: execution.ErrorOriginUpstream, wantScope: execution.ErrorScopeRequest},
{name: "upstream terminated", endReason: StreamEndUpstreamTerminated, wantRule: "stream.upstream_terminated", wantOrigin: execution.ErrorOriginUpstream, wantScope: execution.ErrorScopeRequest},
{name: "protocol error", endReason: StreamEndUpstreamProtocolError, wantRule: "stream.protocol_error", wantOrigin: execution.ErrorOriginUpstream, wantScope: execution.ErrorScopeRequest},
{name: "idle timeout", endReason: StreamEndIdleTimeout, wantRule: "stream.idle_timeout", wantOrigin: execution.ErrorOriginUpstream, wantScope: execution.ErrorScopeRequest},
{name: "provider incomplete", endReason: StreamEndProviderIncomplete, wantRule: "stream.provider_incomplete", wantOrigin: execution.ErrorOriginUpstream, wantScope: execution.ErrorScopeRequest},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
decision := judgeUpstreamResult(UpstreamResult{
DispatchState: execution.DispatchMaybeSent,
ResponseStarted: true,
StatusCode: http.StatusOK,
Committed: true,
Stream: streamTerminalObservation(test.endReason),
}, time.Now(), health.DecisionContext{Method: http.MethodPost, Operation: execution.OperationChatCompletion})
if decision.Category != health.FailureCategoryAmbiguous ||
decision.Origin != test.wantOrigin || decision.Scope != test.wantScope ||
decision.Retry != health.RetryNone || decision.Effect != health.EffectNone ||
decision.RuleID != test.wantRule {
t.Fatalf("judgeUpstreamResult() = %#v", decision)
}
})
}
}
func TestNormalizeUpstreamResultContractFailsClosedWithoutBodyInference(t *testing.T) {
t.Parallel()
result := normalizeUpstreamResultContract(UpstreamResult{
StatusCode: http.StatusTooManyRequests,
Body: []byte("private raw body"),
ClassificationBody: []byte("private classification body"),
Err: errors.New("private raw error"),
})
if result.DispatchState != execution.DispatchMaybeSent || result.ExecutionError == nil ||
result.ExecutionError.Kind != execution.ErrorKindInternal ||
result.ExecutionError.Code != "attempt_result_contract_invalid" ||
result.ExecutionError.Summary != "Attempt forwarder returned an invalid result." ||
result.Body != nil || result.ClassificationBody != nil {
t.Fatalf("normalized result = %#v", result)
}
for _, value := range []string{result.ErrorSummary, result.ExecutionError.Summary, fmt.Sprint(result.Err)} {
if strings.Contains(value, "private") {
t.Fatalf("invalid forwarder contract leaked private detail: %q", value)
}
}
}