* fix(health): 放宽上游错误重试并保留健康边界 * fix(health): 补齐资源操作重放与上下文拒绝规则 * fix(execution): 保留明确请求拒绝的分类优先级 * fix(health): 补齐流内 HTTP 错误重试判断
5417 lines
196 KiB
Go
5417 lines
196 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/dialect"
|
|
"gpt-load/internal/execution"
|
|
"gpt-load/internal/health"
|
|
"gpt-load/internal/platform/config"
|
|
"gpt-load/internal/platform/contentcoding"
|
|
"gpt-load/internal/platform/encryption"
|
|
platformhttp "gpt-load/internal/platform/httpclient"
|
|
"gpt-load/internal/platform/utils"
|
|
"gpt-load/internal/protocol"
|
|
"gpt-load/internal/ratelimit"
|
|
"gpt-load/internal/state"
|
|
"gpt-load/internal/testutil/encryptiontest"
|
|
"gpt-load/internal/testutil/fakeupstream"
|
|
"gpt-load/internal/usage"
|
|
)
|
|
|
|
func withProviderErrorBeforeCommit(result UpstreamResult) UpstreamResult {
|
|
result.ProviderErrorBeforeCommit = true
|
|
return result
|
|
}
|
|
|
|
func TestHandlerForwardsStructuredCloudCredentialWithoutAPIKeyAssumption(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"ok","model":"anthropic.claude-test"}`),
|
|
}}}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder)
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1, Name: "bedrock", ChannelID: channel.AWSBedrock,
|
|
Params: json.RawMessage(`{"region":"us-east-1"}`),
|
|
Models: []state.ModelConfig{{ID: "anthropic.claude-test"}}, Enabled: true,
|
|
}},
|
|
Credentials: []state.CredentialConfig{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "bedrock-credential",
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
const canonical = `{"access_key":"AKIA_TEST","secret_key":"bedrock-secret","session_token":"bedrock-session"}`
|
|
encrypted, err := handler.encryption.Encrypt(canonical)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt() error = %v", err)
|
|
}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "bedrock-credential",
|
|
EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatalf("ReplaceCredentials() error = %v", err)
|
|
}
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"anthropic.claude-test","messages":[{"role":"user","content":"ping"}]}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("status/inputs = %d/%d; body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
input := forwarder.inputs[0]
|
|
if input.ChannelID != string(channel.AWSBedrock) || input.APIKey != "" ||
|
|
string(input.Credential.Data()) != canonical {
|
|
t.Fatalf("forward input = %#v; credential=%s", input, input.Credential.Data())
|
|
}
|
|
if want := []string{"AKIA_TEST", "bedrock-secret", "bedrock-session"}; !reflect.DeepEqual(input.CredentialSecrets, want) {
|
|
t.Fatalf("credential secrets = %#v, want %#v", input.CredentialSecrets, want)
|
|
}
|
|
}
|
|
|
|
type scriptedForwarder struct {
|
|
results []UpstreamResult
|
|
streamResults []UpstreamResult
|
|
inputs []ForwardInput
|
|
streamInputs []ForwardInput
|
|
onCall func(int)
|
|
onStreamCall func(int, http.ResponseWriter)
|
|
invokeStreamReady bool
|
|
}
|
|
|
|
type streamReadyBlockingForwarder struct {
|
|
result UpstreamResult
|
|
ready chan struct{}
|
|
release chan struct{}
|
|
releaseOnce sync.Once
|
|
}
|
|
|
|
type recordingDecryptEncryption struct {
|
|
encryption.Service
|
|
ciphertexts []string
|
|
}
|
|
|
|
func (service *recordingDecryptEncryption) Decrypt(ciphertext string) (string, error) {
|
|
service.ciphertexts = append(service.ciphertexts, ciphertext)
|
|
return service.Service.Decrypt(ciphertext)
|
|
}
|
|
|
|
type blockingRequestBody struct {
|
|
payload []byte
|
|
started chan struct{}
|
|
release chan struct{}
|
|
onFirstRead func() error
|
|
firstReadErr error
|
|
once sync.Once
|
|
offset int
|
|
}
|
|
|
|
func newBlockingRequestBody(payload string, onFirstRead func() error) *blockingRequestBody {
|
|
return &blockingRequestBody{
|
|
payload: []byte(payload),
|
|
started: make(chan struct{}),
|
|
release: make(chan struct{}),
|
|
onFirstRead: onFirstRead,
|
|
}
|
|
}
|
|
|
|
func (body *blockingRequestBody) Read(buffer []byte) (int, error) {
|
|
body.once.Do(func() {
|
|
if body.onFirstRead != nil {
|
|
body.firstReadErr = body.onFirstRead()
|
|
}
|
|
close(body.started)
|
|
})
|
|
<-body.release
|
|
if body.firstReadErr != nil {
|
|
return 0, body.firstReadErr
|
|
}
|
|
if body.offset >= len(body.payload) {
|
|
return 0, io.EOF
|
|
}
|
|
read := copy(buffer, body.payload[body.offset:])
|
|
body.offset += read
|
|
return read, nil
|
|
}
|
|
|
|
func (*blockingRequestBody) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type mutatingRuntimeRegistry struct {
|
|
*state.CredentialRegistry
|
|
mutate func()
|
|
mutated bool
|
|
}
|
|
|
|
type recordingRuntimeRegistry struct {
|
|
*state.CredentialRegistry
|
|
cooldownCredentialID uint
|
|
cooldownUntil time.Time
|
|
cooldownCalls int
|
|
incrFailureCalls int
|
|
lastFailureCount int
|
|
blacklistCalls int
|
|
clearCalls int
|
|
}
|
|
|
|
type captureCountingRuntimeRegistry struct {
|
|
runtimeCredentialRegistry
|
|
captureCalls int
|
|
}
|
|
|
|
type cancelingSuccessfulInspectDialect struct {
|
|
dialect.Dialect
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func (value *cancelingSuccessfulInspectDialect) InspectRequest(
|
|
request *dialect.ParsedRequest,
|
|
) (dialect.RequestMetadata, error) {
|
|
metadata, err := value.Dialect.InspectRequest(request)
|
|
if err == nil {
|
|
value.cancel()
|
|
}
|
|
return metadata, err
|
|
}
|
|
|
|
func (registry *captureCountingRuntimeRegistry) CaptureActiveCredentialRefs(
|
|
groupIDs []uint,
|
|
) []state.CredentialRef {
|
|
registry.captureCalls++
|
|
return registry.runtimeCredentialRegistry.CaptureActiveCredentialRefs(groupIDs)
|
|
}
|
|
|
|
type gatewayMutationObservation struct {
|
|
cooldownCalls int
|
|
clearCalls int
|
|
incrFailureCalls int
|
|
lastFailureCount int
|
|
blacklistCalls int
|
|
stats health.CredentialStats
|
|
}
|
|
|
|
func TestHandlerCoordinatesCooldownMutation(t *testing.T) {
|
|
now := time.Date(2026, time.August, 1, 12, 0, 0, 0, time.UTC)
|
|
registry := &recordingRuntimeRegistry{CredentialRegistry: state.NewCredentialRegistry()}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive, EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stats := health.NewStatsStore()
|
|
coordinator := newBarrierGatewayMutationCoordinator(func() gatewayMutationObservation {
|
|
return gatewayMutationObservation{
|
|
cooldownCalls: registry.cooldownCalls,
|
|
stats: stats.Snapshot(1, now),
|
|
}
|
|
})
|
|
handler := &Handler{registry: registry, stats: stats, mutations: coordinator}
|
|
done := make(chan struct{})
|
|
go func() {
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{
|
|
Category: health.FailureCategoryRateLimited,
|
|
Effect: health.EffectCooldownCredential,
|
|
}, http.StatusTooManyRequests, now)
|
|
close(done)
|
|
}()
|
|
|
|
receiveTestSignal(t, coordinator.entered, "cooldown coordinator entry")
|
|
if registry.cooldownCalls != 0 || stats.Snapshot(1, now) != (health.CredentialStats{}) {
|
|
t.Fatal("cooldown bundle changed state before coordinator callback")
|
|
}
|
|
close(coordinator.releaseEntry)
|
|
observed := receiveTestSignal(t, coordinator.observed, "cooldown mutation observation")
|
|
if observed.cooldownCalls != 1 || observed.stats != (health.CredentialStats{
|
|
Problem: 1,
|
|
ConsecutiveProblem: 1,
|
|
LastFailureCategory: health.FailureCategoryRateLimited,
|
|
LastStatusCode: http.StatusTooManyRequests,
|
|
}) {
|
|
t.Fatalf("coordinator callback observation = %#v", observed)
|
|
}
|
|
close(coordinator.releaseExit)
|
|
receiveTestSignal(t, done, "cooldown mutation completion")
|
|
}
|
|
|
|
func TestHandlerSkipsCooldownFromStaleCredentialVersion(t *testing.T) {
|
|
now := time.Date(2026, time.August, 30, 12, 0, 0, 0, time.UTC)
|
|
registry := state.NewCredentialRegistry()
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "credential-v1", Status: state.CredentialStatusActive,
|
|
EncryptedValue: "cipher-v1",
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ref, ok := registry.CredentialRef(1)
|
|
if !ok {
|
|
t.Fatal("CredentialRef() = false")
|
|
}
|
|
if !registry.ReplaceCredentialSecretIfMatch(1, 1, 2, "credential-v2", "cipher-v2") {
|
|
t.Fatal("ReplaceCredentialSecretIfMatch() = false")
|
|
}
|
|
stats := health.NewStatsStore()
|
|
handler := &Handler{
|
|
registry: registry, stats: stats, mutations: health.NewMutationCoordinator(),
|
|
}
|
|
handler.applyGroupDecisionEffect(
|
|
state.GroupView{},
|
|
ref,
|
|
ref.Version,
|
|
health.Decision{
|
|
Category: health.FailureCategoryRateLimited,
|
|
Effect: health.EffectCooldownCredential,
|
|
CooldownUntil: now.Add(30 * time.Minute),
|
|
},
|
|
http.StatusTooManyRequests,
|
|
now,
|
|
"",
|
|
)
|
|
|
|
views := registry.Snapshot()
|
|
if len(views) != 1 || !views[0].CooldownUntil.IsZero() {
|
|
t.Fatalf("runtime views = %#v", views)
|
|
}
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("stale credential stats = %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestRefreshCooldownCredentialVersion(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
want uint64
|
|
}{
|
|
{
|
|
name: "refresh unavailable",
|
|
result: UpstreamResult{
|
|
DispatchState: execution.DispatchNotSent,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Hint: execution.FailureHintRefreshUnavailable,
|
|
},
|
|
},
|
|
want: 7,
|
|
},
|
|
{
|
|
name: "model rate limited",
|
|
result: UpstreamResult{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Hint: execution.FailureHintRateLimited,
|
|
},
|
|
},
|
|
want: 0,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
if got := refreshCooldownCredentialVersion(test.result, 7); got != test.want {
|
|
t.Fatalf("refreshCooldownCredentialVersion() = %d, want %d", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type barrierGatewayMutationCoordinator struct {
|
|
entered chan struct{}
|
|
releaseEntry chan struct{}
|
|
observe func() gatewayMutationObservation
|
|
observed chan gatewayMutationObservation
|
|
releaseExit chan struct{}
|
|
}
|
|
|
|
type gatewayFailureOrderRegistry struct {
|
|
*state.CredentialRegistry
|
|
failureEntered chan struct{}
|
|
releaseFailure chan struct{}
|
|
enterOnce sync.Once
|
|
}
|
|
|
|
func (registry *gatewayFailureOrderRegistry) IncrFailure(credentialID uint) (int, bool) {
|
|
registry.enterOnce.Do(func() {
|
|
close(registry.failureEntered)
|
|
<-registry.releaseFailure
|
|
})
|
|
return registry.CredentialRegistry.IncrFailure(credentialID)
|
|
}
|
|
|
|
func newBarrierGatewayMutationCoordinator(
|
|
observe func() gatewayMutationObservation,
|
|
) *barrierGatewayMutationCoordinator {
|
|
return &barrierGatewayMutationCoordinator{
|
|
entered: make(chan struct{}), releaseEntry: make(chan struct{}),
|
|
observe: observe, observed: make(chan gatewayMutationObservation, 1),
|
|
releaseExit: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
func (coordinator *barrierGatewayMutationCoordinator) Do(_ uint, fn func()) {
|
|
close(coordinator.entered)
|
|
<-coordinator.releaseEntry
|
|
fn()
|
|
coordinator.observed <- coordinator.observe()
|
|
<-coordinator.releaseExit
|
|
}
|
|
|
|
func receiveTestSignal[T any](t *testing.T, signal <-chan T, name string) T {
|
|
t.Helper()
|
|
select {
|
|
case value := <-signal:
|
|
return value
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("timed out after 1s waiting for %s", name)
|
|
var zero T
|
|
return zero
|
|
}
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) SetCooldown(credentialID uint, until time.Time) bool {
|
|
registry.cooldownCredentialID = credentialID
|
|
registry.cooldownUntil = until
|
|
registry.cooldownCalls++
|
|
return registry.CredentialRegistry.SetCooldown(credentialID, until)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) SetCooldownWithChange(
|
|
credentialID uint,
|
|
until time.Time,
|
|
) (bool, bool) {
|
|
registry.cooldownCredentialID = credentialID
|
|
registry.cooldownUntil = until
|
|
registry.cooldownCalls++
|
|
return registry.CredentialRegistry.SetCooldownWithChange(credentialID, until)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) SetCooldownWithChangeIfVersion(
|
|
credentialID uint,
|
|
expectedVersion uint64,
|
|
until time.Time,
|
|
) (bool, bool) {
|
|
registry.cooldownCredentialID = credentialID
|
|
registry.cooldownUntil = until
|
|
registry.cooldownCalls++
|
|
return registry.CredentialRegistry.SetCooldownWithChangeIfVersion(
|
|
credentialID,
|
|
expectedVersion,
|
|
until,
|
|
)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) IncrFailure(credentialID uint) (int, bool) {
|
|
registry.incrFailureCalls++
|
|
count, ok := registry.CredentialRegistry.IncrFailure(credentialID)
|
|
registry.lastFailureCount = count
|
|
return count, ok
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) SetBlacklisted(credentialID uint) bool {
|
|
registry.blacklistCalls++
|
|
return registry.CredentialRegistry.SetBlacklisted(credentialID)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) SetBlacklistedWithChange(credentialID uint) (bool, bool) {
|
|
registry.blacklistCalls++
|
|
return registry.CredentialRegistry.SetBlacklistedWithChange(credentialID)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) ClearFailure(credentialID uint) bool {
|
|
registry.clearCalls++
|
|
return registry.CredentialRegistry.ClearFailure(credentialID)
|
|
}
|
|
|
|
func TestHandlerCoordinatesSuccessMutation(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
registry := &recordingRuntimeRegistry{CredentialRegistry: state.NewCredentialRegistry()}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive, FailureCount: 2, EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
stats := health.NewStatsStore()
|
|
coordinator := newBarrierGatewayMutationCoordinator(func() gatewayMutationObservation {
|
|
return gatewayMutationObservation{
|
|
clearCalls: registry.clearCalls,
|
|
stats: stats.Snapshot(1, now),
|
|
}
|
|
})
|
|
handler := &Handler{registry: registry, stats: stats, mutations: coordinator}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
handler.recordCredentialSuccess(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, now)
|
|
close(done)
|
|
}()
|
|
receiveTestSignal(t, coordinator.entered, "success coordinator entry")
|
|
if registry.clearCalls != 0 || stats.Snapshot(1, now) != (health.CredentialStats{}) {
|
|
t.Fatalf("success bundle changed state before coordinator callback")
|
|
}
|
|
close(coordinator.releaseEntry)
|
|
observed := receiveTestSignal(t, coordinator.observed, "success mutation observation")
|
|
if observed.clearCalls != 1 || observed.stats != (health.CredentialStats{Success: 1}) {
|
|
t.Fatalf("coordinator callback observation = %#v", observed)
|
|
}
|
|
select {
|
|
case <-done:
|
|
t.Fatal("success mutation returned before coordinator interval was released")
|
|
default:
|
|
}
|
|
close(coordinator.releaseExit)
|
|
receiveTestSignal(t, done, "success mutation completion")
|
|
}
|
|
|
|
func TestHandlerLogsCredentialStateChanges(t *testing.T) {
|
|
now := time.Date(2026, time.August, 11, 18, 0, 0, 0, time.UTC)
|
|
registry := state.NewCredentialRegistry()
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "test-1", Status: state.CredentialStatusActive,
|
|
EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var logs bytes.Buffer
|
|
handler := &Handler{
|
|
registry: registry,
|
|
stats: health.NewStatsStore(),
|
|
mutations: health.NewMutationCoordinator(),
|
|
logger: newGatewayJSONLogger(&logs),
|
|
}
|
|
cooldown := health.Decision{
|
|
Category: health.FailureCategoryRateLimited,
|
|
Effect: health.EffectCooldownCredential,
|
|
CooldownUntil: now.Add(time.Minute),
|
|
}
|
|
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, cooldown, http.StatusTooManyRequests, now)
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, cooldown, http.StatusTooManyRequests, now)
|
|
blacklistThreshold := state.DefaultRuntimeSettings().BlacklistThreshold
|
|
for range blacklistThreshold + 1 {
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{
|
|
Category: health.FailureCategoryInvalidKey,
|
|
Effect: health.EffectRecordCredentialFailure,
|
|
}, http.StatusUnauthorized, now)
|
|
}
|
|
|
|
events := decodeGatewayJSONLogs(t, logs.Bytes())
|
|
if got := gatewayEventsNamed(events, "credential_cooldown"); len(got) != 1 {
|
|
t.Fatalf("credential_cooldown events = %#v, want one", got)
|
|
} else if event := got[0]; event["credential_id"] != float64(1) ||
|
|
event["category"] != "rate_limited" ||
|
|
event["status_code"] != float64(http.StatusTooManyRequests) ||
|
|
event["level"] != "warning" ||
|
|
event["msg"] != "[DATA] Credential entered cooldown" {
|
|
t.Fatalf("credential_cooldown event = %#v", event)
|
|
}
|
|
if got := gatewayEventsNamed(events, "credential_blacklisted"); len(got) != 1 {
|
|
t.Fatalf("credential_blacklisted events = %#v, want one", got)
|
|
} else if event := got[0]; event["credential_id"] != float64(1) ||
|
|
event["failures"] != float64(blacklistThreshold) ||
|
|
event["category"] != "invalid_key" ||
|
|
event["status_code"] != float64(http.StatusUnauthorized) ||
|
|
event["level"] != "warning" ||
|
|
event["msg"] != "[DATA] Credential blacklisted" {
|
|
t.Fatalf("credential_blacklisted event = %#v", event)
|
|
}
|
|
if len(events) != 2 {
|
|
t.Fatalf("credential state events = %#v, want only two", events)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRecordsCooldownFailureContext(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
registry := &recordingRuntimeRegistry{CredentialRegistry: state.NewCredentialRegistry()}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive, EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
stats := health.NewStatsStore()
|
|
handler := &Handler{registry: registry, stats: stats}
|
|
until := now.Add(30 * time.Second)
|
|
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{
|
|
Category: health.FailureCategoryRateLimited,
|
|
Effect: health.EffectCooldownCredential,
|
|
CooldownUntil: until,
|
|
}, http.StatusTooManyRequests, now)
|
|
|
|
if registry.cooldownCalls != 1 || registry.cooldownCredentialID != 1 ||
|
|
!registry.cooldownUntil.Equal(until) {
|
|
t.Fatalf(
|
|
"cooldown mutation = calls:%d key:%d until:%s",
|
|
registry.cooldownCalls,
|
|
registry.cooldownCredentialID,
|
|
registry.cooldownUntil,
|
|
)
|
|
}
|
|
if got, want := stats.Snapshot(1, now), (health.CredentialStats{
|
|
Problem: 1,
|
|
ConsecutiveProblem: 1,
|
|
LastFailureCategory: health.FailureCategoryRateLimited,
|
|
LastStatusCode: http.StatusTooManyRequests,
|
|
}); got != want {
|
|
t.Fatalf("cooldown stats = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSkipsStatsWhenRegistryKeyWasDeletedBeforeCompletion(t *testing.T) {
|
|
now := time.Date(2026, time.August, 1, 14, 30, 0, 0, time.UTC)
|
|
for _, test := range []struct {
|
|
name string
|
|
mutate func(*Handler)
|
|
}{
|
|
{
|
|
name: "cooldown",
|
|
mutate: func(handler *Handler) {
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{
|
|
Category: health.FailureCategoryRateLimited,
|
|
Effect: health.EffectCooldownCredential,
|
|
}, http.StatusTooManyRequests, now)
|
|
},
|
|
},
|
|
{
|
|
name: "attributable failure",
|
|
mutate: func(handler *Handler) {
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{
|
|
Category: health.FailureCategoryInvalidKey,
|
|
Effect: health.EffectRecordCredentialFailure,
|
|
}, http.StatusUnauthorized, now)
|
|
},
|
|
},
|
|
{
|
|
name: "success",
|
|
mutate: func(handler *Handler) {
|
|
handler.recordCredentialSuccess(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, now)
|
|
},
|
|
},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
stats := health.NewStatsStore()
|
|
handler := &Handler{
|
|
registry: state.NewCredentialRegistry(),
|
|
stats: stats,
|
|
mutations: health.NewMutationCoordinator(),
|
|
}
|
|
test.mutate(handler)
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("stats after deleted-key completion = %#v, want zero", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerCoordinatesAttributableFailureMutation(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
registry := &recordingRuntimeRegistry{CredentialRegistry: state.NewCredentialRegistry()}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive, FailureCount: 2, EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
stats := health.NewStatsStore()
|
|
coordinator := newBarrierGatewayMutationCoordinator(func() gatewayMutationObservation {
|
|
return gatewayMutationObservation{
|
|
incrFailureCalls: registry.incrFailureCalls,
|
|
lastFailureCount: registry.lastFailureCount,
|
|
blacklistCalls: registry.blacklistCalls,
|
|
stats: stats.Snapshot(1, now),
|
|
}
|
|
})
|
|
handler := &Handler{registry: registry, stats: stats, mutations: coordinator}
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{
|
|
Category: health.FailureCategoryInvalidKey,
|
|
Effect: health.EffectRecordCredentialFailure,
|
|
}, http.StatusUnauthorized, now)
|
|
close(done)
|
|
}()
|
|
receiveTestSignal(t, coordinator.entered, "failure coordinator entry")
|
|
if registry.incrFailureCalls != 0 || registry.blacklistCalls != 0 ||
|
|
stats.Snapshot(1, now) != (health.CredentialStats{}) {
|
|
t.Fatalf("failure bundle changed state before coordinator callback")
|
|
}
|
|
close(coordinator.releaseEntry)
|
|
observed := receiveTestSignal(t, coordinator.observed, "failure mutation observation")
|
|
if observed.incrFailureCalls != 1 || observed.lastFailureCount != 3 ||
|
|
observed.blacklistCalls != 1 ||
|
|
observed.stats != (health.CredentialStats{
|
|
Failure: 1,
|
|
Problem: 1,
|
|
ConsecutiveFailure: 1,
|
|
ConsecutiveProblem: 1,
|
|
LastFailureCategory: health.FailureCategoryInvalidKey,
|
|
LastStatusCode: http.StatusUnauthorized,
|
|
}) {
|
|
t.Fatalf("coordinator callback observation = %#v", observed)
|
|
}
|
|
select {
|
|
case <-done:
|
|
t.Fatal("failure mutation returned before coordinator interval was released")
|
|
default:
|
|
}
|
|
close(coordinator.releaseExit)
|
|
receiveTestSignal(t, done, "failure mutation completion")
|
|
}
|
|
|
|
func TestGatewayFailureAndValidationRecoveryFailureFirstKeepsRegistryAndStatsFailed(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
baseRegistry := state.NewCredentialRegistry()
|
|
if err := baseRegistry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive,
|
|
Blacklisted: true, FailureCount: 3, EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
ref := baseRegistry.BlacklistedCredentials()[0]
|
|
registry := &gatewayFailureOrderRegistry{
|
|
CredentialRegistry: baseRegistry,
|
|
failureEntered: make(chan struct{}),
|
|
releaseFailure: make(chan struct{}),
|
|
}
|
|
stats := health.NewStatsStore()
|
|
stats.RecordFailure(1, health.FailureCategoryAmbiguous, 0, now)
|
|
mutations := health.NewMutationCoordinator()
|
|
handler := &Handler{registry: registry, stats: stats, mutations: mutations}
|
|
|
|
failureDone := make(chan struct{})
|
|
go func() {
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{Effect: health.EffectRecordCredentialFailure}, 0, now)
|
|
close(failureDone)
|
|
}()
|
|
receiveTestSignal(t, registry.failureEntered, "gateway failure mutation")
|
|
|
|
recoveryAttempted := make(chan struct{})
|
|
recoveryResult := make(chan bool, 1)
|
|
go func() {
|
|
close(recoveryAttempted)
|
|
var recovered bool
|
|
mutations.Do(1, func() {
|
|
recovered = baseRegistry.RecoverIfMatch(ref)
|
|
if recovered {
|
|
stats.Reset(1)
|
|
}
|
|
})
|
|
recoveryResult <- recovered
|
|
}()
|
|
receiveTestSignal(t, recoveryAttempted, "validation recovery attempt")
|
|
close(registry.releaseFailure)
|
|
receiveTestSignal(t, failureDone, "gateway failure completion")
|
|
if recovered := receiveTestSignal(t, recoveryResult, "validation recovery completion"); recovered {
|
|
t.Fatal("stale validation recovery = true after gateway failure, want false")
|
|
}
|
|
|
|
if got, want := baseRegistry.BlacklistedCredentials(), []state.CredentialRef{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "test-1", EncryptedValue: "cipher", FailureGeneration: 1,
|
|
}}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("blacklisted keys = %#v, want %#v", got, want)
|
|
}
|
|
if got, want := stats.Snapshot(1, now), (health.CredentialStats{
|
|
Failure: 2, Problem: 2, ConsecutiveFailure: 2, ConsecutiveProblem: 2,
|
|
}); got != want {
|
|
t.Fatalf("stats = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestGatewayFailureAndValidationRecoveryRecoveryFirstLeavesNewFailure(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
registry := state.NewCredentialRegistry()
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive,
|
|
Blacklisted: true, FailureCount: 3, EncryptedValue: "cipher",
|
|
}}); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
ref := registry.BlacklistedCredentials()[0]
|
|
stats := health.NewStatsStore()
|
|
stats.RecordFailure(1, health.FailureCategoryAmbiguous, 0, now)
|
|
mutations := health.NewMutationCoordinator()
|
|
handler := &Handler{registry: registry, stats: stats, mutations: mutations}
|
|
|
|
recoveryEntered := make(chan struct{})
|
|
releaseRecovery := make(chan struct{})
|
|
recoveryResult := make(chan bool, 1)
|
|
go func() {
|
|
var recovered bool
|
|
mutations.Do(1, func() {
|
|
close(recoveryEntered)
|
|
<-releaseRecovery
|
|
recovered = registry.RecoverIfMatch(ref)
|
|
if recovered {
|
|
stats.Reset(1)
|
|
}
|
|
})
|
|
recoveryResult <- recovered
|
|
}()
|
|
receiveTestSignal(t, recoveryEntered, "validation recovery mutation")
|
|
|
|
failureAttempted := make(chan struct{})
|
|
failureDone := make(chan struct{})
|
|
go func() {
|
|
close(failureAttempted)
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{Effect: health.EffectRecordCredentialFailure}, 0, now)
|
|
close(failureDone)
|
|
}()
|
|
receiveTestSignal(t, failureAttempted, "gateway failure attempt")
|
|
close(releaseRecovery)
|
|
if recovered := receiveTestSignal(t, recoveryResult, "validation recovery completion"); !recovered {
|
|
t.Fatal("fresh validation recovery = false, want true")
|
|
}
|
|
receiveTestSignal(t, failureDone, "gateway failure completion")
|
|
|
|
if got := registry.BlacklistedCredentials(); len(got) != 0 {
|
|
t.Fatalf("blacklisted keys = %#v, want recovered key with new sub-threshold failure", got)
|
|
}
|
|
if refs := registry.CaptureActiveCredentialRefs([]uint{1}); len(refs) != 1 || refs[0].FailureGeneration != 2 {
|
|
t.Fatalf("active refs = %#v, want generation 2 after recovery then failure", refs)
|
|
}
|
|
if got, want := stats.Snapshot(1, now), (health.CredentialStats{
|
|
Failure: 1, Problem: 1, ConsecutiveFailure: 1, ConsecutiveProblem: 1,
|
|
}); got != want {
|
|
t.Fatalf("stats = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func (registry *mutatingRuntimeRegistry) WithCredentialCandidates(groups []uint, excluded func(uint) bool, now time.Time, fn func([]state.CredentialMeta)) {
|
|
// 故意在收集后改变身份/状态,覆盖较弱来源返回旧候选的防线。
|
|
fn(registry.CollectCredentialCandidates(groups, excluded, now))
|
|
}
|
|
|
|
func (registry *mutatingRuntimeRegistry) CollectCredentialCandidates(
|
|
groupIDs []uint,
|
|
excluded func(uint) bool,
|
|
now time.Time,
|
|
) []state.CredentialMeta {
|
|
candidates := registry.CredentialRegistry.CollectCredentialCandidates(groupIDs, excluded, now)
|
|
if !registry.mutated && len(candidates) > 0 {
|
|
registry.mutated = true
|
|
registry.mutate()
|
|
}
|
|
return candidates
|
|
}
|
|
|
|
func (forwarder *scriptedForwarder) Forward(_ context.Context, input ForwardInput) UpstreamResult {
|
|
index := len(forwarder.inputs)
|
|
forwarder.inputs = append(forwarder.inputs, input)
|
|
if forwarder.onCall != nil {
|
|
forwarder.onCall(index)
|
|
}
|
|
if index >= len(forwarder.results) {
|
|
return completeScriptedUpstreamResult(UpstreamResult{Err: errors.New("script exhausted")})
|
|
}
|
|
return completeScriptedUpstreamResult(forwarder.results[index])
|
|
}
|
|
|
|
func (forwarder *scriptedForwarder) ForwardStream(
|
|
_ context.Context,
|
|
input ForwardInput,
|
|
writer http.ResponseWriter,
|
|
) UpstreamResult {
|
|
index := len(forwarder.streamInputs)
|
|
forwarder.streamInputs = append(forwarder.streamInputs, input)
|
|
if forwarder.onStreamCall != nil {
|
|
forwarder.onStreamCall(index, writer)
|
|
}
|
|
if index >= len(forwarder.streamResults) {
|
|
return completeScriptedUpstreamResult(UpstreamResult{Err: errors.New("stream script exhausted")})
|
|
}
|
|
result := completeScriptedUpstreamResult(forwarder.streamResults[index])
|
|
if forwarder.invokeStreamReady && result.Committed && input.OnStreamReady != nil {
|
|
input.OnStreamReady()
|
|
}
|
|
return result
|
|
}
|
|
|
|
func completeScriptedUpstreamResult(result UpstreamResult) UpstreamResult {
|
|
if !result.DispatchState.Valid() {
|
|
if result.RequestWritten || result.Committed || result.ResponseStarted || result.StatusCode != 0 {
|
|
result.DispatchState = execution.DispatchMaybeSent
|
|
} else {
|
|
result.DispatchState = execution.DispatchNotSent
|
|
}
|
|
}
|
|
if result.StatusCode != 0 {
|
|
result.ResponseStarted = true
|
|
}
|
|
if result.ExecutionError != nil {
|
|
evidence := result.ExecutionError.Clone()
|
|
if evidence.Kind == "" {
|
|
evidence.Kind = execution.ErrorKindInternal
|
|
}
|
|
if evidence.StatusCode == 0 && result.ResponseStarted {
|
|
evidence.StatusCode = result.StatusCode
|
|
}
|
|
if evidence.Summary == "" {
|
|
evidence.Summary = "scripted upstream failure"
|
|
}
|
|
result.ExecutionError = &evidence
|
|
if result.ErrorSummary == "" {
|
|
result.ErrorSummary = evidence.Summary
|
|
}
|
|
return result
|
|
}
|
|
if result.Err == nil && result.ResponseStarted &&
|
|
result.StatusCode >= http.StatusOK && result.StatusCode < http.StatusMultipleChoices &&
|
|
!result.ProviderErrorBeforeCommit {
|
|
return result
|
|
}
|
|
|
|
evidence := execution.ErrorEvidence{Summary: result.ErrorSummary}
|
|
if evidence.Summary == "" {
|
|
evidence.Summary = sanitizeErrorSummary(nil, allowedErrorSummary(result.ClassificationBody))
|
|
}
|
|
if evidence.Summary == "" {
|
|
evidence.Summary = sanitizeErrorSummary(nil, allowedErrorSummary(result.Body))
|
|
}
|
|
if evidence.Summary == "" {
|
|
evidence.Summary = "scripted upstream failure"
|
|
}
|
|
if result.ResponseStarted {
|
|
evidence.Kind = execution.ErrorKindHTTP
|
|
evidence.StatusCode = result.StatusCode
|
|
evidence.Hint = streamErrorFailureHint(
|
|
result.StatusCode,
|
|
string(result.ClassificationBody),
|
|
string(result.Body),
|
|
result.ErrorSummary,
|
|
)
|
|
if result.ProviderErrorBeforeCommit {
|
|
evidence.Kind = execution.ErrorKindProvider
|
|
}
|
|
} else {
|
|
switch {
|
|
case errors.Is(result.Err, context.Canceled):
|
|
evidence.Kind = execution.ErrorKindCanceled
|
|
case errors.Is(result.Err, context.DeadlineExceeded):
|
|
evidence.Kind = execution.ErrorKindTimeout
|
|
default:
|
|
evidence.Kind = execution.ErrorKindTransport
|
|
}
|
|
}
|
|
result.ExecutionError = &evidence
|
|
if result.ErrorSummary == "" {
|
|
result.ErrorSummary = evidence.Summary
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (forwarder *streamReadyBlockingForwarder) Forward(
|
|
context.Context,
|
|
ForwardInput,
|
|
) UpstreamResult {
|
|
return completeScriptedUpstreamResult(UpstreamResult{Err: errors.New("unexpected non-streaming forward")})
|
|
}
|
|
|
|
func (forwarder *streamReadyBlockingForwarder) ForwardStream(
|
|
_ context.Context,
|
|
input ForwardInput,
|
|
_ http.ResponseWriter,
|
|
) UpstreamResult {
|
|
if input.OnStreamReady != nil {
|
|
input.OnStreamReady()
|
|
}
|
|
close(forwarder.ready)
|
|
<-forwarder.release
|
|
return completeScriptedUpstreamResult(forwarder.result)
|
|
}
|
|
|
|
func (forwarder *streamReadyBlockingForwarder) Release() {
|
|
forwarder.releaseOnce.Do(func() { close(forwarder.release) })
|
|
}
|
|
|
|
func TestHandlerRecordsNonStreamingResultStatsByAction(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
want health.CredentialStats
|
|
}{
|
|
{
|
|
name: "2xx success",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
},
|
|
want: health.CredentialStats{Success: 1},
|
|
},
|
|
{
|
|
name: "local 2xx does not mutate credential health",
|
|
result: UpstreamResult{
|
|
DispatchState: execution.DispatchLocal, ResponseStarted: true,
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`),
|
|
},
|
|
want: health.CredentialStats{},
|
|
},
|
|
{
|
|
name: "invalid key",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: []byte(`{"error":"invalid key"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid key"}`), RequestWritten: true,
|
|
},
|
|
want: health.CredentialStats{
|
|
Failure: 1,
|
|
Problem: 1,
|
|
ConsecutiveFailure: 1,
|
|
ConsecutiveProblem: 1,
|
|
LastFailureCategory: health.FailureCategoryInvalidKey,
|
|
LastStatusCode: http.StatusUnauthorized,
|
|
},
|
|
},
|
|
{
|
|
name: "client error",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusBadRequest, Header: make(http.Header), Body: []byte(`{"error":"invalid input"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid input"}`), RequestWritten: true,
|
|
},
|
|
want: health.CredentialStats{},
|
|
},
|
|
{
|
|
name: "rate limited",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusTooManyRequests, Header: make(http.Header), Body: []byte(`{"error":"rate limit"}`),
|
|
ClassificationBody: []byte(`{"error":"rate limit"}`), RequestWritten: true,
|
|
},
|
|
want: health.CredentialStats{
|
|
Problem: 1,
|
|
ConsecutiveProblem: 1,
|
|
LastFailureCategory: health.FailureCategoryRateLimited,
|
|
LastStatusCode: http.StatusTooManyRequests,
|
|
},
|
|
},
|
|
{
|
|
name: "model unavailable",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusNotFound, Header: make(http.Header), Body: []byte(`{"error":"model not found"}`),
|
|
ClassificationBody: []byte(`{"error":"model not found"}`), RequestWritten: true,
|
|
},
|
|
want: health.CredentialStats{},
|
|
},
|
|
{
|
|
name: "host error",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusInternalServerError, Header: make(http.Header), Body: []byte(`{"error":"overloaded"}`),
|
|
ClassificationBody: []byte(`{"error":"overloaded"}`), RequestWritten: true,
|
|
},
|
|
want: health.CredentialStats{},
|
|
},
|
|
{
|
|
name: "pre-write transport",
|
|
result: UpstreamResult{
|
|
Err: errors.New("dial upstream"),
|
|
},
|
|
want: health.CredentialStats{},
|
|
},
|
|
{
|
|
name: "context canceled",
|
|
result: UpstreamResult{
|
|
Err: context.Canceled,
|
|
},
|
|
want: health.CredentialStats{},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{test.result}}
|
|
engine, handler, _, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-one")
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if got := stats.Snapshot(1, now); got != test.want {
|
|
t.Fatalf("stats = %#v, want %#v", got, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRecordsInvalidKeyPerAttempt(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: []byte(`{"error":"invalid key"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid key"}`), RequestWritten: true,
|
|
},
|
|
{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
},
|
|
}}
|
|
engine, handler, _, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{
|
|
Failure: 1,
|
|
Problem: 1,
|
|
ConsecutiveFailure: 1,
|
|
ConsecutiveProblem: 1,
|
|
LastFailureCategory: health.FailureCategoryInvalidKey,
|
|
LastStatusCode: http.StatusUnauthorized,
|
|
}) {
|
|
t.Fatalf("first key stats = %#v, want one failure", got)
|
|
}
|
|
if got := stats.Snapshot(2, now); got != (health.CredentialStats{Success: 1}) {
|
|
t.Fatalf("second key stats = %#v, want one success", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotRotateOrPenalizeRequestRejected429(t *testing.T) {
|
|
now := time.Date(2026, time.August, 16, 12, 0, 0, 0, time.UTC)
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"error":{"type":"rate_limit_error","message":"Usage credits are required for fast mode."}}`),
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP,
|
|
Hint: execution.FailureHintRequestRejected,
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Type: "rate_limit_error",
|
|
Summary: "Usage credits are required for fast mode.",
|
|
},
|
|
},
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
|
}}
|
|
engine, handler, registry, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
recording := &recordingRuntimeRegistry{CredentialRegistry: registry}
|
|
handler.registry = recording
|
|
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusTooManyRequests || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("response/attempts = %d/%d, want 429/1; body=%s", recorder.Code, len(forwarder.inputs), recorder.Body.String())
|
|
}
|
|
if recording.cooldownCalls != 0 || recording.incrFailureCalls != 0 || recording.blacklistCalls != 0 {
|
|
t.Fatalf("credential mutations = cooldown:%d failure:%d blacklist:%d, want none",
|
|
recording.cooldownCalls, recording.incrFailureCalls, recording.blacklistCalls)
|
|
}
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("request-rejected credential stats = %#v, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRetriesCandidateUnavailableWithoutCredentialPenalty(t *testing.T) {
|
|
now := time.Date(2026, time.August, 18, 9, 0, 0, 0, time.UTC)
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusForbidden,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"error":{"type":"PERMISSION_DENIED"}}`),
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP,
|
|
Hint: execution.FailureHintCandidateUnavailable,
|
|
StatusCode: http.StatusForbidden,
|
|
ReplaySafety: execution.ReplaySafetyRejectedBeforeProcessing,
|
|
Summary: "candidate is unavailable",
|
|
},
|
|
},
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
|
}}
|
|
engine, handler, registry, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
recording := &recordingRuntimeRegistry{CredentialRegistry: registry}
|
|
handler.registry = recording
|
|
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"ok":true}` || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
if recording.cooldownCalls != 0 || recording.incrFailureCalls != 0 || recording.blacklistCalls != 0 {
|
|
t.Fatalf("credential mutations = cooldown:%d failure:%d blacklist:%d, want none",
|
|
recording.cooldownCalls, recording.incrFailureCalls, recording.blacklistCalls)
|
|
}
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("first credential stats = %#v, want empty", got)
|
|
}
|
|
if got := stats.Snapshot(2, now); got != (health.CredentialStats{Success: 1}) {
|
|
t.Fatalf("second credential stats = %#v, want success", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRetriesSafeBootstrapCapacityErrorWithoutCredentialPenalty(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
typeValue string
|
|
code string
|
|
scope execution.ErrorScope
|
|
}{
|
|
{name: "server overload", typeValue: "service_unavailable_error", code: "server_is_overloaded", scope: execution.ErrorScopeGroup},
|
|
{name: "transient rate limit", typeValue: "rate_limit_error", code: "rate_limit_exceeded"},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusServiceUnavailable,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP, OriginHint: execution.ErrorOriginUpstream,
|
|
ScopeHint: test.scope, StatusCode: http.StatusServiceUnavailable,
|
|
Type: test.typeValue, Code: test.code, Summary: "rejected before generation",
|
|
ReplaySafety: execution.ReplaySafetyRejectedBeforeProcessing,
|
|
},
|
|
},
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusOK, Committed: true,
|
|
Stream: StreamObservation{EndReason: StreamEndCleanEOF},
|
|
},
|
|
}}
|
|
engine, handler, registry, _ := newStatsHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
recording := &recordingRuntimeRegistry{CredentialRegistry: registry}
|
|
handler.registry = recording
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.streamInputs) != 2 ||
|
|
forwarder.streamInputs[0].APIKey == forwarder.streamInputs[1].APIKey {
|
|
t.Fatalf("response/attempts = %d/%d inputs=%#v", recorder.Code, len(forwarder.streamInputs), forwarder.streamInputs)
|
|
}
|
|
if recording.cooldownCalls != 0 || recording.incrFailureCalls != 0 || recording.blacklistCalls != 0 {
|
|
t.Fatalf("credential mutations = cooldown:%d failure:%d blacklist:%d, want none",
|
|
recording.cooldownCalls, recording.incrFailureCalls, recording.blacklistCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRecordsStreamSuccessOnlyAfterCleanTerminal(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
wantSuccess bool
|
|
}{
|
|
{
|
|
name: "clean terminal records after forward returns",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusOK, Committed: true, RequestWritten: true,
|
|
Stream: StreamObservation{EndReason: StreamEndCleanEOF},
|
|
},
|
|
wantSuccess: true,
|
|
},
|
|
{
|
|
name: "pump failure after ready does not record success",
|
|
result: UpstreamResult{
|
|
Err: errors.New("stream pump failed"), Committed: true, RequestWritten: true,
|
|
Stream: StreamObservation{EndReason: StreamEndUpstreamTerminated},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &streamReadyBlockingForwarder{
|
|
result: test.result, ready: make(chan struct{}), release: make(chan struct{}),
|
|
}
|
|
t.Cleanup(forwarder.Release)
|
|
engine, handler, _, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-one")
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
done := make(chan struct{})
|
|
go func() {
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
close(done)
|
|
}()
|
|
|
|
receiveTestSignal(t, forwarder.ready, "stream-ready callback")
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("stats before forward returns = %#v, want zero", got)
|
|
}
|
|
forwarder.Release()
|
|
receiveTestSignal(t, done, "stream request completion")
|
|
want := health.CredentialStats{}
|
|
if test.wantSuccess {
|
|
want.Success = 1
|
|
}
|
|
if got := stats.Snapshot(1, now); got != want {
|
|
t.Fatalf("stats after forward returns = %#v, want %#v", got, want)
|
|
}
|
|
})
|
|
}
|
|
|
|
preCommitForwarder := &scriptedForwarder{streamResults: []UpstreamResult{{
|
|
Err: errors.New("first stream event failed"), RequestWritten: true,
|
|
}}}
|
|
engine, handler, _, stats := newStatsHandlerTestRuntime(t, preCommitForwarder, "sk-one")
|
|
handler.now = func() time.Time { return now }
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("pre-commit stream stats = %#v, want zero", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerFinalizesCommittedStreamThroughJudge(t *testing.T) {
|
|
now := time.Date(2026, time.August, 24, 12, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
wantCooldown int
|
|
}{
|
|
{
|
|
name: "trusted credential rate limit applies effect without retry",
|
|
result: UpstreamResult{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
ResponseStarted: true,
|
|
StatusCode: http.StatusOK,
|
|
RequestWritten: true,
|
|
Committed: true,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindProvider,
|
|
Hint: execution.FailureHintRateLimited,
|
|
ScopeHint: execution.ErrorScopeCredential,
|
|
StatusCode: http.StatusOK,
|
|
Summary: "credential rate limited",
|
|
},
|
|
Stream: StreamObservation{EndReason: StreamEndSSEError},
|
|
},
|
|
wantCooldown: 1,
|
|
},
|
|
{
|
|
name: "unscoped rate limit applies model effect",
|
|
result: UpstreamResult{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
ResponseStarted: true,
|
|
StatusCode: http.StatusOK,
|
|
RequestWritten: true,
|
|
Committed: true,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindProvider,
|
|
Hint: execution.FailureHintRateLimited,
|
|
StatusCode: http.StatusOK,
|
|
Summary: "overloaded",
|
|
},
|
|
Stream: StreamObservation{EndReason: StreamEndSSEError},
|
|
},
|
|
},
|
|
{
|
|
name: "downstream failure suppresses provider effect",
|
|
result: UpstreamResult{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
ResponseStarted: true,
|
|
StatusCode: http.StatusOK,
|
|
RequestWritten: true,
|
|
Committed: true,
|
|
Err: &streamFailure{kind: streamFailureDownstreamWrite, err: errors.New("downstream write failed")},
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindProvider,
|
|
Hint: execution.FailureHintRateLimited,
|
|
ScopeHint: execution.ErrorScopeCredential,
|
|
StatusCode: http.StatusOK,
|
|
Summary: "credential rate limited",
|
|
},
|
|
Stream: StreamObservation{EndReason: StreamEndDownstreamWriteFailure},
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{test.result}}
|
|
engine, handler, registry, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
recording := &recordingRuntimeRegistry{CredentialRegistry: registry}
|
|
handler.registry = recording
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("stream attempts = %d, want 1", len(forwarder.streamInputs))
|
|
}
|
|
if recording.cooldownCalls != test.wantCooldown {
|
|
t.Fatalf("cooldown calls = %d, want %d", recording.cooldownCalls, test.wantCooldown)
|
|
}
|
|
credentialID := forwarder.streamInputs[0].Credential.ID
|
|
if test.wantCooldown == 1 {
|
|
if !recording.cooldownUntil.Equal(now.Add(time.Minute)) {
|
|
t.Fatalf("cooldown until = %s, want %s", recording.cooldownUntil, now.Add(time.Minute))
|
|
}
|
|
if got := stats.Snapshot(credentialID, now); got.Problem != 1 {
|
|
t.Fatalf("credential stats = %#v, want one problem", got)
|
|
}
|
|
} else if test.result.ExecutionError.ScopeHint == "" {
|
|
if got := registry.ModelCooldowns(credentialID, now); !got["gpt-4o"].Equal(now.Add(time.Minute)) {
|
|
t.Fatalf("model cooldown = %v", got)
|
|
}
|
|
} else if got := stats.Snapshot(credentialID, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("credential stats = %#v, want empty", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotMutateCredentialForCanceledAttempt(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
requestContext, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
forwarder := &scriptedForwarder{
|
|
results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}},
|
|
onCall: func(int) { cancel() },
|
|
}
|
|
engine, handler, _, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-one")
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
).WithContext(requestContext)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("canceled attempt stats = %#v, want zero", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotMutateCredentialForCommittedNonStreamingAttempt(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`),
|
|
Committed: true, RequestWritten: true,
|
|
}}}
|
|
engine, handler, _, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-one")
|
|
handler.now = func() time.Time { return now }
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("committed attempt stats = %#v, want zero", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerInitializesDebugHeadersBeforeValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
accessKey string
|
|
body string
|
|
}{
|
|
{name: "invalid auth", path: "/v1/chat/completions", accessKey: "wrong", body: `{"model":"gpt-4o"}`},
|
|
{name: "invalid model", path: "/v1/chat/completions", accessKey: "gl-client", body: `{}`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
request := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewBufferString(tt.body))
|
|
request.Header.Set("Authorization", "Bearer "+tt.accessKey)
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
assertDebugHeaders(t, recorder.Header(), "", "0")
|
|
if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatal("validation failure reached upstream forwarder")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRegistersEveryEnabledDataPlaneRoute(t *testing.T) {
|
|
engine, _, _ := newHandlerTestRuntime(t, &scriptedForwarder{}, "sk-one")
|
|
|
|
for _, testCase := range []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
}{
|
|
{name: "OpenAI chat", method: http.MethodPost, target: "/v1/chat/completions"},
|
|
{name: "Anthropic messages", method: http.MethodPost, target: "/v1/messages"},
|
|
{
|
|
name: "Gemini generate",
|
|
method: http.MethodPost,
|
|
target: "/v1beta/models/gemini-2.5-pro:generateContent",
|
|
},
|
|
{
|
|
name: "Gemini stream",
|
|
method: http.MethodPost,
|
|
target: "/v1beta/models/gemini-2.5-pro:streamGenerateContent",
|
|
},
|
|
{name: "Gemini models", method: http.MethodGet, target: "/v1beta/models"},
|
|
{name: "OpenAI or Anthropic models", method: http.MethodGet, target: "/v1/models"},
|
|
} {
|
|
t.Run(testCase.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(testCase.method, testCase.target, nil)
|
|
request.Header.Set("Authorization", "Bearer wrong")
|
|
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf(
|
|
"%s %s = %d %s, want 401",
|
|
testCase.method,
|
|
testCase.target,
|
|
recorder.Code,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRejectsCaseCollidingModelBeforeAttempt(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"forbidden","Model":"allowed"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest ||
|
|
!strings.Contains(recorder.Body.String(), `"code":"invalid_protocol_request"`) {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatal("case-colliding request reached upstream")
|
|
}
|
|
}
|
|
|
|
func TestHandlerRejectsNonCanonicalServiceTierFieldsBeforeAttempt(t *testing.T) {
|
|
for _, test := range []struct {
|
|
name string
|
|
path string
|
|
body string
|
|
}{
|
|
{name: "chat completions uppercase", path: "/v1/chat/completions", body: `{"model":"gpt-4o","SERVICE_TIER":"ultrafast"}`},
|
|
{name: "chat completions collision", path: "/v1/chat/completions", body: `{"model":"gpt-4o","service_tier":"default","SERVICE_TIER":"ultrafast"}`},
|
|
{name: "responses uppercase", path: "/v1/responses", body: `{"model":"gpt-4o","SERVICE_TIER":"ultrafast"}`},
|
|
{name: "responses collision", path: "/v1/responses", body: `{"model":"gpt-4o","service_tier":"default","SERVICE_TIER":"ultrafast"}`},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
handler, _, _ := newHandlerForTest(t, forwarder, "sk-one")
|
|
handler.dialects = dialect.NewSet(
|
|
dialect.NewOpenAI(),
|
|
dialect.NewOpenAIResponses(),
|
|
)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
test.path,
|
|
bytes.NewBufferString(test.body),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest ||
|
|
!strings.Contains(recorder.Body.String(), `"code":"invalid_protocol_request"`) {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatal("ultrafast request reached upstream")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerEnforcesModelUTF8ByteLimitBeforeAttempt(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
model string
|
|
wantStatus int
|
|
wantCode string
|
|
wantCalls int
|
|
}{
|
|
{
|
|
name: "ASCII 255 bytes accepted",
|
|
model: strings.Repeat("a", 255),
|
|
wantStatus: http.StatusOK,
|
|
wantCalls: 1,
|
|
},
|
|
{
|
|
name: "ASCII 256 bytes rejected",
|
|
model: strings.Repeat("a", 256),
|
|
wantStatus: http.StatusBadRequest,
|
|
wantCode: reasonInvalidProtocolRequest.Code,
|
|
},
|
|
{
|
|
name: "multibyte UTF-8 255 bytes accepted",
|
|
model: strings.Repeat("界", 85),
|
|
wantStatus: http.StatusOK,
|
|
wantCalls: 1,
|
|
},
|
|
{
|
|
name: "multibyte UTF-8 256 bytes rejected",
|
|
model: strings.Repeat("界", 84) + strings.Repeat("é", 2),
|
|
wantStatus: http.StatusBadRequest,
|
|
wantCode: reasonInvalidProtocolRequest.Code,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`),
|
|
RequestWritten: true,
|
|
}}}
|
|
sink := &recordingRequestLogSink{}
|
|
engine, handler, manager, _ := newRequestLogHandlerTestRuntime(
|
|
t,
|
|
forwarder,
|
|
&recordingAccessKeyRPMLimiter{},
|
|
sink,
|
|
"sk-one",
|
|
)
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1, Name: "openai", ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{
|
|
ID: "gpt-4o",
|
|
Alias: test.model,
|
|
}},
|
|
Enabled: true,
|
|
}},
|
|
Credentials: []state.CredentialConfig{testCredentialConfig(1, 1)},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1,
|
|
Name: "client",
|
|
KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]string{"model": test.model})
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewReader(payload),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != test.wantStatus {
|
|
t.Fatalf(
|
|
"status = %d, want %d; body=%s",
|
|
recorder.Code,
|
|
test.wantStatus,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
if len(forwarder.inputs) != test.wantCalls {
|
|
t.Fatalf("upstream attempts = %d, want %d", len(forwarder.inputs), test.wantCalls)
|
|
}
|
|
|
|
events := sink.snapshot()
|
|
if len(events) != 1 {
|
|
t.Fatalf("RequestLog events = %d, want 1", len(events))
|
|
}
|
|
event := events[0]
|
|
if test.wantCode == "" {
|
|
if event.ClientModel != test.model {
|
|
t.Fatalf("client model = %q, want accepted model", event.ClientModel)
|
|
}
|
|
return
|
|
}
|
|
|
|
var response struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if response.Code != test.wantCode {
|
|
t.Fatalf("response code = %q, want %q", response.Code, test.wantCode)
|
|
}
|
|
assertDebugHeaders(t, recorder.Header(), "", "0")
|
|
if event.ClientModel != "" || len(event.Attempts) != 0 ||
|
|
strings.Contains(event.ErrorSummary, test.model) {
|
|
t.Fatalf("rejected model leaked into RequestLog event: %#v", event)
|
|
}
|
|
if strings.Contains(recorder.Body.String(), test.model) ||
|
|
strings.Contains(fmt.Sprint(recorder.Header()), test.model) {
|
|
t.Fatalf(
|
|
"rejected model leaked into response: headers=%v body=%s",
|
|
recorder.Header(),
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerServesLocalModelEndpoints(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
headers http.Header
|
|
expected string
|
|
}{
|
|
{
|
|
name: "OpenAI with Bearer", method: http.MethodGet, target: "/v1/models",
|
|
headers: http.Header{"Authorization": {"Bearer gl-client"}},
|
|
expected: `{"object":"list","data":[{"id":"alpha","object":"model","created":1735689600,"owned_by":"gpt-load"},{"id":"beta","object":"model","created":1735689600,"owned_by":"gpt-load"},{"id":"zeta","object":"model","created":1735689600,"owned_by":"gpt-load"}]}`,
|
|
},
|
|
{
|
|
name: "Anthropic with Bearer", method: http.MethodGet, target: "/v1/models",
|
|
headers: http.Header{"Authorization": {"Bearer gl-client"}, "Anthropic-Version": {"2023-06-01"}},
|
|
expected: `{"data":[{"type":"model","id":"alpha","display_name":"alpha","created_at":"2025-01-01T00:00:00Z"},{"type":"model","id":"beta","display_name":"beta","created_at":"2025-01-01T00:00:00Z"},{"type":"model","id":"zeta","display_name":"zeta","created_at":"2025-01-01T00:00:00Z"}],"has_more":false,"first_id":"alpha","last_id":"zeta"}`,
|
|
},
|
|
{
|
|
name: "x-api-key alone stays OpenAI", method: http.MethodGet, target: "/v1/models",
|
|
headers: http.Header{"X-Api-Key": {"gl-client"}},
|
|
expected: `{"object":"list","data":[{"id":"alpha","object":"model","created":1735689600,"owned_by":"gpt-load"},{"id":"beta","object":"model","created":1735689600,"owned_by":"gpt-load"},{"id":"zeta","object":"model","created":1735689600,"owned_by":"gpt-load"}]}`,
|
|
},
|
|
{
|
|
name: "Anthropic with x-api-key", method: http.MethodGet, target: "/v1/models",
|
|
headers: http.Header{"X-Api-Key": {"gl-client"}, "Anthropic-Version": {"2023-06-01"}},
|
|
expected: `{"data":[{"type":"model","id":"alpha","display_name":"alpha","created_at":"2025-01-01T00:00:00Z"},{"type":"model","id":"beta","display_name":"beta","created_at":"2025-01-01T00:00:00Z"},{"type":"model","id":"zeta","display_name":"zeta","created_at":"2025-01-01T00:00:00Z"}],"has_more":false,"first_id":"alpha","last_id":"zeta"}`,
|
|
},
|
|
{
|
|
name: "Gemini with query key", method: http.MethodGet, target: "/v1beta/models?key=gl-client",
|
|
expected: `{"models":[{"name":"models/alpha"},{"name":"models/beta"},{"name":"models/zeta"}]}`,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
engine := newModelListHandlerEngine(t, state.FilterSet{})
|
|
request := httptest.NewRequest(test.method, test.target, nil)
|
|
request.Header = test.headers.Clone()
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
assertJSONEqual(t, recorder.Body.String(), test.expected)
|
|
assertDebugHeaders(t, recorder.Header(), "", "0")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelEndpointsApplyFiltersAndKeepEmptyShape(t *testing.T) {
|
|
t.Run("joint filters", func(t *testing.T) {
|
|
engine := newModelListHandlerEngine(t, state.FilterSet{
|
|
Protocols: map[protocol.Protocol]struct{}{protocol.OpenAICompletions: {}},
|
|
Models: map[string]struct{}{"alpha": {}},
|
|
Groups: map[uint]struct{}{1: {}},
|
|
})
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertJSONEqual(t, recorder.Body.String(), `{"object":"list","data":[{"id":"alpha","object":"model","created":1735689600,"owned_by":"gpt-load"}]}`)
|
|
assertDebugHeaders(t, recorder.Header(), "", "0")
|
|
})
|
|
|
|
t.Run("protocol denied keeps official empty envelope", func(t *testing.T) {
|
|
engine := newModelListHandlerEngine(t, state.FilterSet{
|
|
Protocols: map[protocol.Protocol]struct{}{protocol.Gemini: {}},
|
|
})
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
request.Header.Set("X-Api-Key", "gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertJSONEqual(t, recorder.Body.String(), `{"object":"list","data":[]}`)
|
|
assertDebugHeaders(t, recorder.Header(), "", "0")
|
|
})
|
|
}
|
|
|
|
func TestHandlerModelEndpointsRequireValidAccessKey(t *testing.T) {
|
|
engine := newModelListHandlerEngine(t, state.FilterSet{})
|
|
for _, header := range []string{"", "Bearer wrong"} {
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
if header != "" {
|
|
request.Header.Set("Authorization", header)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusUnauthorized || !strings.Contains(recorder.Body.String(), reasonInvalidAccessKey.Code) {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
assertDebugHeaders(t, recorder.Header(), "", "0")
|
|
}
|
|
}
|
|
|
|
func TestHandlerModelEndpointHasNoDataPlaneSideEffects(t *testing.T) {
|
|
keyService := encryptiontest.Service(t, "model-handler-test-master-key")
|
|
manager := state.NewManager()
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1, Name: "openai", ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "alpha"}}, Enabled: true,
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"), Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
spyEncryption := &decryptPanicEncryption{Service: keyService}
|
|
handler := NewHandler(
|
|
manager, state.NewCredentialRegistry(), spyEncryption, panicForwarder{}, dialect.NewSet(), health.NewStatsStore(),
|
|
health.NewMutationCoordinator(),
|
|
nil, nil, nil,
|
|
)
|
|
handler.registry = panicRuntimeRegistry{}
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Body = panicReadCloser{}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
assertJSONEqual(t, recorder.Body.String(), `{"object":"list","data":[{"id":"alpha","object":"model","created":1735689600,"owned_by":"gpt-load"}]}`)
|
|
}
|
|
|
|
func TestHandlerModelListOverflowReturnsSmallStableErrorWithoutPartialJSON(t *testing.T) {
|
|
engine := newModelListHandlerEngineWithLimit(t, state.FilterSet{}, 64)
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Anthropic-Version", "2023-06-01")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusInternalServerError || recorder.Body.Len() > 256 ||
|
|
!strings.Contains(recorder.Body.String(), `"code":"model_list_too_large"`) {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
for _, fragment := range []string{`"type":"model"`, `"id":"alpha"`, `"id":"beta"`, `"id":"zeta"`} {
|
|
if strings.Contains(recorder.Body.String(), fragment) {
|
|
t.Fatalf("overflow response contains partial model fragment %q: %s", fragment, recorder.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func newModelListHandlerEngine(t *testing.T, filters state.FilterSet) *gin.Engine {
|
|
return newModelListHandlerEngineWithLimit(t, filters, maxNonStreamingResponseBodyBytes)
|
|
}
|
|
|
|
func newModelListHandlerEngineWithLimit(
|
|
t *testing.T,
|
|
filters state.FilterSet,
|
|
limit int64,
|
|
) *gin.Engine {
|
|
t.Helper()
|
|
keyService := encryptiontest.Service(t, "model-handler-test-master-key")
|
|
manager := state.NewManager()
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{
|
|
{ConnectionType: "api_key", ID: 1, Name: "multi", ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "zeta"}, {ID: "alpha"}}, Enabled: true,
|
|
},
|
|
{ConnectionType: "api_key", ID: 2, Name: "openai", ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "beta"}}, Enabled: true,
|
|
},
|
|
},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive, Filters: filters,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
handler := NewHandler(
|
|
manager, state.NewCredentialRegistry(), keyService, &scriptedForwarder{}, dialect.NewSet(), health.NewStatsStore(),
|
|
health.NewMutationCoordinator(),
|
|
nil, nil, nil,
|
|
)
|
|
handler.modelListLimit = limit
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
return engine
|
|
}
|
|
|
|
type panicRuntimeRegistry struct{}
|
|
|
|
func (panicRuntimeRegistry) SetModelCooldown(state.CredentialRef, string, time.Time, time.Time) (bool, bool) {
|
|
panic("model endpoint mutated cooldown")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SchedulingState() *state.SchedulingState {
|
|
panic("unexpected registry access")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) CollectCredentialCandidates([]uint, func(uint) bool, time.Time) []state.CredentialMeta {
|
|
panic("model endpoint collected upstream candidates")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) ActiveEncryptedCredentialData(uint, uint) (string, bool) {
|
|
panic("model endpoint read an upstream key")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) CaptureActiveCredentialRefs([]uint) []state.CredentialRef {
|
|
panic("model endpoint captured upstream keys")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) CredentialRef(uint) (state.CredentialRef, bool) {
|
|
panic("model endpoint read a credential reference")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) ActiveEncryptedCredentialDataIfMatch(state.CredentialRef) (string, bool) {
|
|
panic("model endpoint matched an upstream key")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SetCooldown(uint, time.Time) bool {
|
|
panic("model endpoint set cooldown")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SetCooldownWithChange(uint, time.Time) (bool, bool) {
|
|
panic("model endpoint set cooldown")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SetCooldownWithChangeIfVersion(uint, uint64, time.Time) (bool, bool) {
|
|
panic("model endpoint set cooldown")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) IncrFailure(uint) (int, bool) {
|
|
panic("model endpoint incremented failure")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SetBlacklisted(uint) bool {
|
|
panic("model endpoint set blacklist")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SetBlacklistedWithChange(uint) (bool, bool) {
|
|
panic("model endpoint set blacklist")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) ClearFailure(uint) bool {
|
|
panic("model endpoint cleared failure")
|
|
}
|
|
|
|
type panicForwarder struct{}
|
|
|
|
func (panicForwarder) Forward(context.Context, ForwardInput) UpstreamResult {
|
|
panic("model endpoint called Forward")
|
|
}
|
|
|
|
func (panicForwarder) ForwardStream(context.Context, ForwardInput, http.ResponseWriter) UpstreamResult {
|
|
panic("model endpoint called ForwardStream")
|
|
}
|
|
|
|
type decryptPanicEncryption struct {
|
|
encryption.Service
|
|
}
|
|
|
|
func (*decryptPanicEncryption) Decrypt(string) (string, error) {
|
|
panic("model endpoint decrypted an upstream key")
|
|
}
|
|
|
|
type panicReadCloser struct{}
|
|
|
|
func (panicReadCloser) Read([]byte) (int, error) {
|
|
panic("model endpoint read request body")
|
|
}
|
|
|
|
func (panicReadCloser) Close() error {
|
|
return nil
|
|
}
|
|
|
|
func TestHandlerNeverExposesProviderKeyFragmentsInResponseHeaders(t *testing.T) {
|
|
const providerKey = "prov-fragment-test-abcdefghwxyz"
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`),
|
|
}}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, providerKey)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if values := recorder.Header().Values(debugHeaderKey); len(values) != 0 {
|
|
t.Fatalf("%s = %#v, want absent", debugHeaderKey, values)
|
|
}
|
|
if recorder.Header().Get(debugHeaderGroup) != "openai" ||
|
|
recorder.Header().Get(debugHeaderAttempts) != "1" {
|
|
t.Fatalf("debug headers = %#v", recorder.Header())
|
|
}
|
|
serialized := fmt.Sprint(recorder.Header())
|
|
for _, forbidden := range []string{
|
|
providerKey,
|
|
providerKey[:4],
|
|
providerKey[len(providerKey)-4:],
|
|
utils.MaskAPIKey(providerKey),
|
|
} {
|
|
if strings.Contains(serialized, forbidden) {
|
|
t.Fatalf("response headers expose provider key fragment %q: %s", forbidden, serialized)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerReportsFinalAttemptInDebugHeaders(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
results []UpstreamResult
|
|
upstreamKeys []string
|
|
wantAttempts string
|
|
}{
|
|
{
|
|
name: "first attempt success",
|
|
results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`),
|
|
}},
|
|
upstreamKeys: []string{"sk-first-success"}, wantAttempts: "1",
|
|
},
|
|
{
|
|
name: "retry success",
|
|
results: []UpstreamResult{
|
|
{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: []byte(`{"error":"invalid_api_key"}`), ClassificationBody: []byte(`{"error":"invalid_api_key"}`)},
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`)},
|
|
},
|
|
upstreamKeys: []string{"sk-retry-one", "sk-retry-two"}, wantAttempts: "2",
|
|
},
|
|
{
|
|
name: "transport skips only group",
|
|
results: []UpstreamResult{{Err: errors.New("dial failed")}},
|
|
upstreamKeys: []string{"sk-dial-one", "sk-dial-two"},
|
|
wantAttempts: "1",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: tt.results}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, tt.upstreamKeys...)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
assertDebugHeaders(t, recorder.Header(), "openai", tt.wantAttempts)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerWriteUpstreamResponseChecksWriteResultAndClearsDeadline(t *testing.T) {
|
|
writeFailure := errors.New("write failed")
|
|
flushFailure := errors.New("flush failed")
|
|
tests := []struct {
|
|
name string
|
|
write func([]byte) (int, error)
|
|
flushErr error
|
|
wantErr error
|
|
}{
|
|
{
|
|
name: "short write",
|
|
write: func(body []byte) (int, error) {
|
|
return len(body) - 1, nil
|
|
},
|
|
wantErr: io.ErrShortWrite,
|
|
},
|
|
{
|
|
name: "write error",
|
|
write: func([]byte) (int, error) {
|
|
return 0, writeFailure
|
|
},
|
|
wantErr: writeFailure,
|
|
},
|
|
{
|
|
name: "flush error",
|
|
write: func(body []byte) (int, error) {
|
|
return len(body), nil
|
|
},
|
|
flushErr: flushFailure,
|
|
wantErr: flushFailure,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
ginContext, _ := gin.CreateTestContext(recorder)
|
|
writer := &deadlineGinWriter{
|
|
ResponseWriter: ginContext.Writer,
|
|
write: test.write,
|
|
flushErr: test.flushErr,
|
|
}
|
|
ginContext.Writer = writer
|
|
handler := &Handler{writeTimeout: time.Second}
|
|
|
|
err := handler.writeUpstreamResponse(ginContext, UpstreamResult{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"ok":true}`),
|
|
})
|
|
if !errors.Is(err, test.wantErr) {
|
|
t.Fatalf("writeUpstreamResponse() error = %v, want %v", err, test.wantErr)
|
|
}
|
|
if len(writer.deadlines) < 2 || writer.deadlines[0].IsZero() ||
|
|
!writer.deadlines[len(writer.deadlines)-1].IsZero() {
|
|
t.Fatalf("deadlines = %#v, want armed operations followed by clear", writer.deadlines)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerWriteEmptyResponseCommitsStatusBeforeFlush(t *testing.T) {
|
|
recorder := httptest.NewRecorder()
|
|
ginContext, _ := gin.CreateTestContext(recorder)
|
|
writer := &deadlineGinWriter{
|
|
ResponseWriter: ginContext.Writer,
|
|
write: func(body []byte) (int, error) {
|
|
return len(body), nil
|
|
},
|
|
}
|
|
ginContext.Writer = writer
|
|
handler := &Handler{writeTimeout: time.Second}
|
|
|
|
err := handler.writeUpstreamResponse(ginContext, UpstreamResult{
|
|
StatusCode: http.StatusNoContent,
|
|
Header: make(http.Header),
|
|
})
|
|
if err != nil || recorder.Code != http.StatusNoContent || writer.flushes != 1 {
|
|
t.Fatalf(
|
|
"writeUpstreamResponse() error/status/flushes = %v/%d/%d",
|
|
err, recorder.Code, writer.flushes,
|
|
)
|
|
}
|
|
}
|
|
|
|
type deadlineGinWriter struct {
|
|
gin.ResponseWriter
|
|
write func([]byte) (int, error)
|
|
flushErr error
|
|
flushes int
|
|
deadlines []time.Time
|
|
}
|
|
|
|
func (writer *deadlineGinWriter) Write(body []byte) (int, error) {
|
|
return writer.write(body)
|
|
}
|
|
|
|
func (writer *deadlineGinWriter) SetWriteDeadline(deadline time.Time) error {
|
|
writer.deadlines = append(writer.deadlines, deadline)
|
|
return nil
|
|
}
|
|
|
|
func (writer *deadlineGinWriter) FlushError() error {
|
|
writer.flushes++
|
|
return writer.flushErr
|
|
}
|
|
|
|
func TestHandlerRejectsSpoofedDebugHeaders(t *testing.T) {
|
|
spoofed := http.Header{
|
|
"X-GPTLoad-Group": {"spoofed-group"},
|
|
"X-GPTLoad-Key": {"spoofed-key"},
|
|
"X-GPTLoad-Attempts": {"999"},
|
|
}
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: spoofed, Body: []byte(`{"ok":true}`),
|
|
}}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-real-key")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
assertDebugHeaders(t, recorder.Header(), "openai", "1")
|
|
}
|
|
|
|
func assertDebugHeaders(t *testing.T, headers http.Header, group, attempts string) {
|
|
t.Helper()
|
|
want := map[string]string{
|
|
debugHeaderGroup: group, debugHeaderAttempts: attempts,
|
|
}
|
|
for name, value := range want {
|
|
values, exists := headers[http.CanonicalHeaderKey(name)]
|
|
if !exists || len(values) != 1 || values[0] != value {
|
|
t.Errorf("%s = %#v (exists=%t), want exactly [%q]", name, values, exists, value)
|
|
}
|
|
}
|
|
if values := headers.Values(debugHeaderKey); len(values) != 0 {
|
|
t.Errorf("%s = %#v, want absent", debugHeaderKey, values)
|
|
}
|
|
}
|
|
|
|
func TestReadDecodedRequestBodyHonorsIndependentLimits(t *testing.T) {
|
|
t.Run("exact limit is accepted", func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("1234"))
|
|
body, err := readDecodedRequestBody(request, contentcoding.Identity, 4, 4)
|
|
if err != nil || string(body) != "1234" {
|
|
t.Fatalf("readDecodedRequestBody() = %q, %v", body, err)
|
|
}
|
|
})
|
|
|
|
t.Run("limit plus one is rejected without draining", func(t *testing.T) {
|
|
reader := &boundedCountingReader{remaining: 100}
|
|
request := httptest.NewRequest(http.MethodPost, "/", reader)
|
|
request.ContentLength = -1
|
|
body, err := readDecodedRequestBody(request, contentcoding.Identity, 4, 4)
|
|
if !errors.Is(err, errRequestTooLarge) || body != nil {
|
|
t.Fatalf("readDecodedRequestBody() = %q, %v, want request too large", body, err)
|
|
}
|
|
if reader.read != 5 {
|
|
t.Fatalf("reader consumed %d bytes, want limit+1 (5)", reader.read)
|
|
}
|
|
})
|
|
|
|
t.Run("positive ContentLength rejects before reading", func(t *testing.T) {
|
|
reader := &boundedCountingReader{remaining: 100}
|
|
request := httptest.NewRequest(http.MethodPost, "/", reader)
|
|
request.ContentLength = 5
|
|
body, err := readDecodedRequestBody(request, contentcoding.Identity, 4, 4)
|
|
if !errors.Is(err, errRequestTooLarge) || body != nil || reader.read != 0 {
|
|
t.Fatalf("readDecodedRequestBody() = %q, %v; read=%d, want early rejection", body, err, reader.read)
|
|
}
|
|
})
|
|
|
|
t.Run("decoded overflow maps to request too large", func(t *testing.T) {
|
|
encoded := encodeContentCodingForGatewayTest(t, contentcoding.Gzip, []byte("12345"))
|
|
request := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(encoded))
|
|
body, err := readDecodedRequestBody(request, contentcoding.Gzip, int64(len(encoded)), 4)
|
|
if !errors.Is(err, errRequestTooLarge) || body != nil {
|
|
t.Fatalf("readDecodedRequestBody() = %q, %v, want decoded overflow", body, err)
|
|
}
|
|
})
|
|
|
|
t.Run("MaxInt64 encoded limit does not overflow limit plus one", func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("1234"))
|
|
body, err := readDecodedRequestBody(
|
|
request,
|
|
contentcoding.Identity,
|
|
math.MaxInt64,
|
|
4,
|
|
)
|
|
if err != nil || string(body) != "1234" {
|
|
t.Fatalf("readDecodedRequestBody() = %q, %v, want 1234", body, err)
|
|
}
|
|
})
|
|
|
|
t.Run("negative decoded limit is explicitly rejected", func(t *testing.T) {
|
|
request := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("1234"))
|
|
body, err := readDecodedRequestBody(
|
|
request,
|
|
contentcoding.Identity,
|
|
4,
|
|
-1,
|
|
)
|
|
if body != nil || err == nil || !strings.Contains(err.Error(), "decoded request body limit") {
|
|
t.Fatalf("readDecodedRequestBody() = %q, %v, want explicit decoded-limit error", body, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerCapturesKeyIdentityOnlyAfterDecodedInspection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body []byte
|
|
encoding string
|
|
contentLength int64
|
|
wantStatus int
|
|
wantCapture int
|
|
}{
|
|
{
|
|
name: "malformed coding", body: []byte("not-gzip"), encoding: "gzip",
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "encoded limit", body: []byte(`{"model":"gpt-4o"}`),
|
|
contentLength: maxRequestBodyBytes + 1, wantStatus: http.StatusRequestEntityTooLarge,
|
|
},
|
|
{
|
|
name: "content length within expanded limit", body: []byte(`{"model":"gpt-4o"}`),
|
|
contentLength: 64 << 20, wantStatus: http.StatusOK, wantCapture: 1,
|
|
},
|
|
{
|
|
name: "invalid JSON", body: []byte(`{"model":`),
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "valid decoded request", body: []byte(`{"model":"gpt-4o"}`),
|
|
wantStatus: http.StatusOK, wantCapture: 1,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
handler, _, registry := newHandlerForTest(t, forwarder, "sk-upstream")
|
|
counting := &captureCountingRuntimeRegistry{runtimeCredentialRegistry: registry}
|
|
handler.registry = counting
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewReader(test.body),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
if test.encoding != "" {
|
|
request.Header.Set("Content-Encoding", test.encoding)
|
|
}
|
|
if test.contentLength > 0 {
|
|
request.ContentLength = test.contentLength
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != test.wantStatus || counting.captureCalls != test.wantCapture {
|
|
t.Fatalf(
|
|
"response/capture calls = %d/%d, want %d/%d; body=%s",
|
|
recorder.Code,
|
|
counting.captureCalls,
|
|
test.wantStatus,
|
|
test.wantCapture,
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("canceled body read", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
handler, _, registry := newHandlerForTest(t, forwarder, "sk-upstream")
|
|
counting := &captureCountingRuntimeRegistry{runtimeCredentialRegistry: registry}
|
|
handler.registry = counting
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
requestContext, cancel := context.WithCancel(context.Background())
|
|
body := newBlockingRequestBody(`{"model":"gpt-4o"}`, func() error {
|
|
cancel()
|
|
return context.Canceled
|
|
})
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
body,
|
|
).WithContext(requestContext)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
engine.ServeHTTP(recorder, request)
|
|
close(done)
|
|
}()
|
|
receiveTestSignal(t, body.started, "post-inspection capture canceled read")
|
|
close(body.release)
|
|
receiveTestSignal(t, done, "post-inspection capture canceled completion")
|
|
if counting.captureCalls != 0 || len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatalf(
|
|
"capture/forward calls = %d/%d, want 0/0",
|
|
counting.captureCalls,
|
|
len(forwarder.inputs)+len(forwarder.streamInputs),
|
|
)
|
|
}
|
|
})
|
|
|
|
t.Run("canceled after successful inspection", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
handler, _, registry := newHandlerForTest(t, forwarder, "sk-upstream")
|
|
counting := &captureCountingRuntimeRegistry{runtimeCredentialRegistry: registry}
|
|
handler.registry = counting
|
|
requestContext, cancel := context.WithCancel(context.Background())
|
|
baseDialect := handler.dialects[protocol.OpenAICompletions]
|
|
handler.dialects = dialect.NewSet(&cancelingSuccessfulInspectDialect{
|
|
Dialect: baseDialect,
|
|
cancel: cancel,
|
|
})
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
strings.NewReader(`{"model":"gpt-4o"}`),
|
|
).WithContext(requestContext)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if counting.captureCalls != 0 || len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatalf(
|
|
"capture/forward calls = %d/%d, want 0/0 after inspection cancellation",
|
|
counting.captureCalls,
|
|
len(forwarder.inputs)+len(forwarder.streamInputs),
|
|
)
|
|
}
|
|
if recorder.Body.Len() != 0 {
|
|
t.Fatalf("canceled response body = %q, want empty", recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerContentCodingErrorContract(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
contentEncodings []string
|
|
collidingEncoding string
|
|
body []byte
|
|
contentLength int64
|
|
wantStatus int
|
|
wantCode string
|
|
wantAcceptEncoding string
|
|
}{
|
|
{
|
|
name: "unknown coding", contentEncodings: []string{"snappy"},
|
|
body: []byte(`{"model":"gpt-4o"}`),
|
|
wantStatus: http.StatusUnsupportedMediaType, wantCode: "unsupported_content_encoding",
|
|
wantAcceptEncoding: contentcoding.SupportedRequestEncodings,
|
|
},
|
|
{
|
|
name: "multiple case-colliding fields", contentEncodings: []string{"identity"},
|
|
collidingEncoding: "gzip", body: []byte(`{"model":"gpt-4o"}`),
|
|
wantStatus: http.StatusUnsupportedMediaType, wantCode: "unsupported_content_encoding",
|
|
wantAcceptEncoding: contentcoding.SupportedRequestEncodings,
|
|
},
|
|
{
|
|
name: "stacked coding", contentEncodings: []string{"gzip, br"},
|
|
body: []byte(`{"model":"gpt-4o"}`),
|
|
wantStatus: http.StatusUnsupportedMediaType, wantCode: "unsupported_content_encoding",
|
|
wantAcceptEncoding: contentcoding.SupportedRequestEncodings,
|
|
},
|
|
{
|
|
name: "malformed known coding", contentEncodings: []string{"gzip"},
|
|
body: []byte("not-gzip"),
|
|
wantStatus: http.StatusBadRequest, wantCode: "invalid_content_encoding",
|
|
},
|
|
{
|
|
name: "encoded length fast rejection", contentEncodings: []string{"identity"},
|
|
body: []byte(`{"model":"gpt-4o"}`), contentLength: maxRequestBodyBytes + 1,
|
|
wantStatus: http.StatusRequestEntityTooLarge, wantCode: "request_too_large",
|
|
},
|
|
{
|
|
name: "decoded length rejection", contentEncodings: []string{"gzip"},
|
|
body: encodeContentCodingForGatewayTest(
|
|
t,
|
|
contentcoding.Gzip,
|
|
bytes.Repeat([]byte("x"), int(maxRequestBodyBytes+1)),
|
|
),
|
|
wantStatus: http.StatusRequestEntityTooLarge, wantCode: "request_too_large",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-upstream")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewReader(test.body),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Del("Content-Encoding")
|
|
for _, value := range test.contentEncodings {
|
|
request.Header.Add("Content-Encoding", value)
|
|
}
|
|
if test.collidingEncoding != "" {
|
|
request.Header["content-encoding"] = []string{test.collidingEncoding}
|
|
}
|
|
if test.contentLength > 0 {
|
|
request.ContentLength = test.contentLength
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
assertGatewayReasonTest(t, recorder, test.wantStatus, test.wantCode)
|
|
if got := recorder.Header().Get("Accept-Encoding"); got != test.wantAcceptEncoding {
|
|
t.Fatalf("Accept-Encoding = %q, want %q", got, test.wantAcceptEncoding)
|
|
}
|
|
if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatal("invalid content coding reached upstream")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerContentCodingInspectionUsesDecodedBody(t *testing.T) {
|
|
plaintext := []byte(`{"model":"gpt-4o","stream":true}`)
|
|
for _, encoding := range []contentcoding.Encoding{
|
|
contentcoding.Identity,
|
|
contentcoding.Gzip,
|
|
contentcoding.Brotli,
|
|
contentcoding.Deflate,
|
|
contentcoding.Zstd,
|
|
} {
|
|
t.Run(string(encoding), func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": {"text/event-stream"}},
|
|
Committed: true,
|
|
RequestWritten: true,
|
|
Stream: StreamObservation{EndReason: StreamEndCleanEOF},
|
|
}}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-upstream")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewReader(encodeContentCodingForGatewayTest(t, encoding, plaintext)),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
if encoding != contentcoding.Identity {
|
|
request.Header.Set("Content-Encoding", string(encoding))
|
|
}
|
|
request.Header["content-length"] = []string{"stale-client-length"}
|
|
for name, value := range map[string]string{
|
|
"ETag": `"stale"`,
|
|
"Digest": "sha-256=stale",
|
|
"Content-MD5": "stale-md5",
|
|
"Content-Range": "bytes 0-1/2",
|
|
"Content-Digest": "sha-256=:c3RhbGU=:",
|
|
"Repr-Digest": "sha-256=:c3RhbGU=:",
|
|
"Signature": "stale-signature",
|
|
"Signature-Input": "stale-signature-input",
|
|
} {
|
|
request.Header.Set(name, value)
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf(
|
|
"response/stream calls = %d/%d body=%s, want decoded stream request",
|
|
recorder.Code,
|
|
len(forwarder.streamInputs),
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
parsed := forwarder.streamInputs[0].Request
|
|
if !bytes.Equal(parsed.Body, plaintext) || forwarder.streamInputs[0].ExternalModel != "gpt-4o" {
|
|
t.Fatalf("parsed body/model = %s / %q", parsed.Body, forwarder.streamInputs[0].ExternalModel)
|
|
}
|
|
for _, name := range []string{
|
|
"Content-Encoding",
|
|
"Content-Length",
|
|
"ETag",
|
|
"Digest",
|
|
"Content-MD5",
|
|
"Content-Range",
|
|
"Content-Digest",
|
|
"Repr-Digest",
|
|
"Signature",
|
|
"Signature-Input",
|
|
} {
|
|
if values := headerFieldValues(parsed.Header, name); len(values) != 0 {
|
|
t.Fatalf("parsed %s = %#v, want absent", name, values)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerContentCodingErrorPrecedence(t *testing.T) {
|
|
t.Run("invalid access key wins", func(t *testing.T) {
|
|
engine, _, _ := newHandlerTestRuntime(t, &scriptedForwarder{}, "sk-upstream")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("invalid"))
|
|
request.Header.Set("Authorization", "Bearer wrong")
|
|
request.Header.Set("Content-Encoding", "unknown")
|
|
request.Header.Set("Accept-Encoding", "identity;q=0")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, http.StatusUnauthorized, reasonInvalidAccessKey.Code)
|
|
})
|
|
|
|
for _, test := range []struct {
|
|
name string
|
|
method string
|
|
target string
|
|
wantStatus int
|
|
wantCode string
|
|
}{
|
|
{name: "route wins", method: http.MethodPost, target: "/v1/unknown", wantStatus: http.StatusNotFound, wantCode: reasonEndpointNotFound.Code},
|
|
{name: "method wins", method: http.MethodGet, target: "/v1/chat/completions", wantStatus: http.StatusMethodNotAllowed, wantCode: reasonMethodNotAllowed.Code},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
engine, _, _ := newHandlerTestRuntime(t, &scriptedForwarder{}, "sk-upstream")
|
|
request := httptest.NewRequest(test.method, test.target, strings.NewReader("invalid"))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Content-Encoding", "unknown")
|
|
request.Header.Set("Accept-Encoding", "identity;q=0")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, test.wantStatus, test.wantCode)
|
|
})
|
|
}
|
|
|
|
t.Run("RPM wins", func(t *testing.T) {
|
|
limiter := &recordingAccessKeyRPMLimiter{decisions: []ratelimit.LimitDecision{{
|
|
Allowed: false, RetryAfter: time.Second,
|
|
}}}
|
|
engine, _, _, _ := newRequestLogHandlerTestRuntime(
|
|
t,
|
|
&scriptedForwarder{},
|
|
limiter,
|
|
&recordingRequestLogSink{},
|
|
"sk-upstream",
|
|
)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("invalid"))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Content-Encoding", "unknown")
|
|
request.Header.Set("Accept-Encoding", "identity;q=0")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, http.StatusTooManyRequests, reasonAccessKeyRateLimited.Code)
|
|
})
|
|
|
|
t.Run("415 wins over 406", func(t *testing.T) {
|
|
engine, _, _ := newHandlerTestRuntime(t, &scriptedForwarder{}, "sk-upstream")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("invalid"))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Content-Encoding", "unknown")
|
|
request.Header.Set("Accept-Encoding", "identity;q=0")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, http.StatusUnsupportedMediaType, "unsupported_content_encoding")
|
|
})
|
|
|
|
t.Run("406 wins over body and JSON", func(t *testing.T) {
|
|
engine, _, _ := newHandlerTestRuntime(t, &scriptedForwarder{}, "sk-upstream")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("not-gzip"))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Content-Encoding", "gzip")
|
|
request.Header.Set("Accept-Encoding", "identity;q=0")
|
|
request.ContentLength = maxRequestBodyBytes + 1
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, http.StatusNotAcceptable, "not_acceptable")
|
|
})
|
|
}
|
|
|
|
func TestHandlerIdentityNegotiation(t *testing.T) {
|
|
t.Run("case-colliding explicit identity exclusion wins over wildcard", func(t *testing.T) {
|
|
engine, _, _ := newHandlerTestRuntime(t, &scriptedForwarder{}, "sk-upstream")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
strings.NewReader(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header["Accept-Encoding"] = []string{"*;q=1"}
|
|
request.Header["accept-encoding"] = []string{"identity;q=0"}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, http.StatusNotAcceptable, "not_acceptable")
|
|
})
|
|
|
|
t.Run("model list negotiates without reading body", func(t *testing.T) {
|
|
engine := newModelListHandlerEngine(t, state.FilterSet{})
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Accept-Encoding", "identity;q=0")
|
|
request.Header.Set("Content-Encoding", "unknown")
|
|
request.Body = panicReadCloser{}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
assertGatewayReasonTest(t, recorder, http.StatusNotAcceptable, "not_acceptable")
|
|
})
|
|
|
|
t.Run("model list accepts implicit identity without reading body", func(t *testing.T) {
|
|
engine := newModelListHandlerEngine(t, state.FilterSet{})
|
|
request := httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Accept-Encoding", "gzip")
|
|
request.Header.Set("Content-Encoding", "unknown")
|
|
request.Body = panicReadCloser{}
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s, want 200", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerCancellationWinsOverContentCoding(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-upstream")
|
|
requestContext, cancel := context.WithCancel(context.Background())
|
|
body := newBlockingRequestBody("not-gzip", func() error {
|
|
cancel()
|
|
return context.Canceled
|
|
})
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", body).WithContext(requestContext)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
request.Header.Set("Content-Encoding", "gzip")
|
|
recorder := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
engine.ServeHTTP(recorder, request)
|
|
close(done)
|
|
}()
|
|
receiveTestSignal(t, body.started, "canceled body read")
|
|
close(body.release)
|
|
receiveTestSignal(t, done, "canceled request completion")
|
|
|
|
if recorder.Body.Len() != 0 || recorder.Code == http.StatusBadRequest || recorder.Code == http.StatusRequestEntityTooLarge {
|
|
t.Fatalf("canceled response = %d %q, want no coding reason", recorder.Code, recorder.Body.String())
|
|
}
|
|
if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatal("canceled request reached upstream")
|
|
}
|
|
}
|
|
|
|
func assertGatewayReasonTest(
|
|
t *testing.T,
|
|
recorder *httptest.ResponseRecorder,
|
|
wantStatus int,
|
|
wantCode string,
|
|
) {
|
|
t.Helper()
|
|
if recorder.Code != wantStatus {
|
|
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, wantStatus, recorder.Body.String())
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode reason body: %v; body=%q", err, recorder.Body.String())
|
|
}
|
|
if body.Code != wantCode {
|
|
t.Fatalf("code = %q, want %q; body=%s", body.Code, wantCode, recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerRejectsOversizedRequestBody(t *testing.T) {
|
|
if maxRequestBodyBytes != 128<<20 {
|
|
t.Fatalf("maxRequestBodyBytes = %d, want %d", maxRequestBodyBytes, 128<<20)
|
|
}
|
|
forwarder := &scriptedForwarder{}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-unused")
|
|
reader := &boundedCountingReader{remaining: maxRequestBodyBytes + 100}
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", reader)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if recorder.Code != http.StatusRequestEntityTooLarge || body.Code != reasonRequestTooLarge.Code {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
if reader.read != maxRequestBodyBytes+1 {
|
|
t.Fatalf("reader consumed %d bytes, want %d", reader.read, maxRequestBodyBytes+1)
|
|
}
|
|
if len(forwarder.inputs)+len(forwarder.streamInputs) != 0 {
|
|
t.Fatal("oversized request reached upstream forwarder")
|
|
}
|
|
}
|
|
|
|
type boundedCountingReader struct {
|
|
remaining int64
|
|
read int64
|
|
}
|
|
|
|
func (reader *boundedCountingReader) Read(destination []byte) (int, error) {
|
|
if reader.remaining == 0 {
|
|
return 0, io.EOF
|
|
}
|
|
read := int64(len(destination))
|
|
if read > reader.remaining {
|
|
read = reader.remaining
|
|
}
|
|
for index := int64(0); index < read; index++ {
|
|
destination[index] = 'x'
|
|
}
|
|
reader.remaining -= read
|
|
reader.read += read
|
|
return int(read), nil
|
|
}
|
|
|
|
func TestHandlerUsesStreamingForwarder(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
wantNormal int
|
|
wantStreams int
|
|
}{
|
|
{name: "stream absent", body: `{"model":"gpt-4o"}`, wantNormal: 1},
|
|
{name: "stream false", body: `{"model":"gpt-4o","stream":false}`, wantNormal: 1},
|
|
{name: "stream true", body: `{"model":"gpt-4o","stream":true}`, wantStreams: 1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{
|
|
results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}},
|
|
streamResults: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Committed: true, RequestWritten: true,
|
|
}},
|
|
}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(tt.body))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if len(forwarder.inputs) != tt.wantNormal || len(forwarder.streamInputs) != tt.wantStreams {
|
|
t.Fatalf("normal/stream calls = %d/%d, want %d/%d", len(forwarder.inputs), len(forwarder.streamInputs), tt.wantNormal, tt.wantStreams)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerSuccessfulStreamClearsFailureAfterCommittedReturn(t *testing.T) {
|
|
forwarder := &scriptedForwarder{
|
|
invokeStreamReady: true,
|
|
streamResults: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, RequestWritten: true, Committed: true,
|
|
Stream: StreamObservation{EndReason: StreamEndCleanEOF},
|
|
}},
|
|
}
|
|
engine, _, registry := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
_, _ = registry.IncrFailure(1)
|
|
_, _ = registry.IncrFailure(1)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
count, ok := registry.IncrFailure(1)
|
|
if !ok || count != 1 {
|
|
t.Fatalf("failure count = %d, %t, want 1, true", count, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotRetryOversizedResponse(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{Err: fmt.Errorf("%w: response too large", ErrUpstreamProtocol), RequestWritten: true},
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadGateway || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("response/attempts = %d/%d, body=%s", recorder.Code, len(forwarder.inputs), recorder.Body.String())
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil || body.Code != reasonUpstreamProtocol.Code {
|
|
t.Fatalf("response = %s, error=%v", recorder.Body.String(), err)
|
|
}
|
|
}
|
|
|
|
func TestHandlerTerminatesRequestWrittenStreamFailuresBeforeCommit(t *testing.T) {
|
|
protocolFailure := fmt.Errorf("%w: gzip", ErrUpstreamProtocol)
|
|
tests := []struct {
|
|
name string
|
|
results []UpstreamResult
|
|
wantStatus int
|
|
wantCode string
|
|
wantAttempts int
|
|
}{
|
|
{
|
|
name: "protocol error does not reach committed success",
|
|
results: []UpstreamResult{
|
|
{Err: protocolFailure, RequestWritten: true},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
},
|
|
wantStatus: http.StatusBadGateway, wantCode: reasonUpstreamProtocol.Code, wantAttempts: 1,
|
|
},
|
|
{
|
|
name: "first-event timeout does not reach committed success",
|
|
results: []UpstreamResult{
|
|
{Err: context.DeadlineExceeded, RequestWritten: true},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
},
|
|
wantStatus: http.StatusGatewayTimeout, wantCode: reasonUpstreamTimeout.Code, wantAttempts: 1,
|
|
},
|
|
{
|
|
name: "protocol errors exhausted",
|
|
results: []UpstreamResult{
|
|
{Err: protocolFailure, RequestWritten: true},
|
|
{Err: protocolFailure, RequestWritten: true},
|
|
},
|
|
wantStatus: http.StatusBadGateway, wantCode: reasonUpstreamProtocol.Code, wantAttempts: 1,
|
|
},
|
|
{
|
|
name: "first-event timeouts exhausted",
|
|
results: []UpstreamResult{
|
|
{Err: context.DeadlineExceeded, RequestWritten: true},
|
|
{Err: context.DeadlineExceeded, RequestWritten: true},
|
|
},
|
|
wantStatus: http.StatusGatewayTimeout, wantCode: reasonUpstreamTimeout.Code, wantAttempts: 1,
|
|
},
|
|
{
|
|
name: "transport failures exhausted",
|
|
results: []UpstreamResult{
|
|
{Err: errors.New("stream disconnected"), RequestWritten: true},
|
|
{Err: errors.New("stream disconnected"), RequestWritten: true},
|
|
},
|
|
wantStatus: http.StatusBadGateway, wantCode: reasonUpstreamConnect.Code, wantAttempts: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: tt.results}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != tt.wantStatus || len(forwarder.streamInputs) != tt.wantAttempts {
|
|
t.Fatalf("status/attempts = %d/%d, want %d/%d; body=%s", recorder.Code, len(forwarder.streamInputs), tt.wantStatus, tt.wantAttempts, recorder.Body.String())
|
|
}
|
|
if tt.wantCode != "" {
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil || body.Code != tt.wantCode {
|
|
t.Fatalf("response = %s, error=%v, want code %q", recorder.Body.String(), err, tt.wantCode)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRetriesClassifiedFirstProviderErrorAndReturns429OnExhaustion(t *testing.T) {
|
|
const marker = "rate_limit_error"
|
|
providerError := UpstreamResult{
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Retry-After": []string{"1"}},
|
|
ClassificationBody: []byte(`{"error":{"type":"` + marker + `"}}`),
|
|
ProviderErrorBeforeCommit: true,
|
|
RequestWritten: true,
|
|
}
|
|
|
|
t.Run("retryable category changes key", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
providerError,
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.streamInputs) != 2 {
|
|
t.Fatalf("status/attempts = %d/%d; body=%s",
|
|
recorder.Code, len(forwarder.streamInputs), recorder.Body.String())
|
|
}
|
|
if forwarder.streamInputs[0].APIKey == forwarder.streamInputs[1].APIKey {
|
|
t.Fatalf("retry reused key %q", forwarder.streamInputs[0].APIKey)
|
|
}
|
|
})
|
|
|
|
t.Run("candidate exhaustion returns protocol 502", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{providerError}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-only")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v; body=%s", err, recorder.Body.String())
|
|
}
|
|
if recorder.Code != http.StatusTooManyRequests ||
|
|
body.Code != reasonUpstreamRateLimited.Code ||
|
|
strings.Contains(recorder.Body.String(), "rate_limit_error") {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerFirstProviderErrorDoesNotCommitOrRecordSuccess(t *testing.T) {
|
|
now := time.Date(2026, time.July, 27, 12, 0, 0, 0, time.UTC)
|
|
forwarder := &scriptedForwarder{
|
|
invokeStreamReady: true,
|
|
streamResults: []UpstreamResult{withProviderErrorBeforeCommit(UpstreamResult{
|
|
StatusCode: http.StatusOK,
|
|
ClassificationBody: []byte(`{"error":{"type":"unexpected_provider_error"}}`),
|
|
ErrorSummary: fixedErrorSummary("upstream_sse_error"),
|
|
RequestWritten: true,
|
|
Usage: usage.Result{State: usage.StateMissing},
|
|
})},
|
|
}
|
|
engine, handler, registry, stats := newStatsHandlerTestRuntime(t, forwarder, "sk-only")
|
|
handler.now = func() time.Time { return now }
|
|
_, _ = registry.IncrFailure(1)
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadGateway || len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("status/attempts = %d/%d", recorder.Code, len(forwarder.streamInputs))
|
|
}
|
|
if got := stats.Snapshot(1, now); got != (health.CredentialStats{}) {
|
|
t.Fatalf("stats = %#v, want no success/failure record", got)
|
|
}
|
|
count, ok := registry.IncrFailure(1)
|
|
if !ok || count != 2 {
|
|
t.Fatalf("failure count after provider error = %d/%t, want unchanged then increment to 2", count, ok)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotRetryRequestWrittenAmbiguousPreCommitFailures(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
}{
|
|
{name: "timeout", result: UpstreamResult{Err: context.DeadlineExceeded, RequestWritten: true}},
|
|
{name: "read error", result: UpstreamResult{Err: errors.New("read failed"), RequestWritten: true}},
|
|
{name: "clean EOF", result: UpstreamResult{Err: errIncompleteSSEEvent, RequestWritten: true}},
|
|
{name: "framing", result: UpstreamResult{Err: fmt.Errorf("%w: framing", ErrUpstreamProtocol), RequestWritten: true}},
|
|
{name: "encoding", result: UpstreamResult{Err: fmt.Errorf("%w: encoding", ErrUpstreamProtocol), RequestWritten: true}},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
test.result,
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
if len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("stream attempts = %d, want 1", len(forwarder.streamInputs))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerSkipsGroupAfterRequestNotWrittenTransportFailure(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
{Err: errors.New("dial failed"), RequestWritten: false},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-first", "sk-second")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadGateway || len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("status/attempts = %d/%d, body=%s",
|
|
recorder.Code, len(forwarder.streamInputs), recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerStopsAtStreamingTerminalBoundaries(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
writeBody bool
|
|
}{
|
|
{
|
|
name: "committed disconnect",
|
|
result: UpstreamResult{
|
|
Err: errors.New("upstream disconnected"), RequestWritten: true, Committed: true,
|
|
},
|
|
writeBody: true,
|
|
},
|
|
{
|
|
name: "downstream cancellation",
|
|
result: UpstreamResult{
|
|
Err: context.Canceled, RequestWritten: true,
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
tt.result,
|
|
{StatusCode: http.StatusOK, Committed: true},
|
|
}}
|
|
if tt.writeBody {
|
|
forwarder.onStreamCall = func(index int, writer http.ResponseWriter) {
|
|
if index == 0 {
|
|
writer.WriteHeader(http.StatusOK)
|
|
_, _ = writer.Write([]byte("data: first\n\n"))
|
|
}
|
|
}
|
|
}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("stream attempts = %d, want 1", len(forwarder.streamInputs))
|
|
}
|
|
if tt.writeBody && recorder.Body.String() != "data: first\n\n" {
|
|
t.Fatalf("committed body = %q, want only first event", recorder.Body.String())
|
|
}
|
|
if strings.Contains(recorder.Body.String(), `"code"`) {
|
|
t.Fatalf("terminal stream appended reason: %s", recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotRetryDownstreamWriteDeadline(t *testing.T) {
|
|
deadlineErr := errors.New("downstream stream write deadline exceeded")
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
{
|
|
Err: deadlineErr, RequestWritten: true,
|
|
Committed: true,
|
|
},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
}}
|
|
forwarder.onStreamCall = func(index int, writer http.ResponseWriter) {
|
|
if index == 0 {
|
|
writer.WriteHeader(http.StatusOK)
|
|
_, _ = writer.Write([]byte("data: first\n\n"))
|
|
}
|
|
}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("stream attempts = %d, want 1 after downstream write deadline", len(forwarder.streamInputs))
|
|
}
|
|
if recorder.Body.String() != "data: first\n\n" {
|
|
t.Fatalf("committed body = %q, want only first event", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotAdvanceCandidatesAfterRequestDeadline(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
{Err: context.DeadlineExceeded, RequestWritten: true},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
|
|
defer cancel()
|
|
request = request.WithContext(ctx)
|
|
forwarder.onStreamCall = func(_ int, _ http.ResponseWriter) {
|
|
<-ctx.Done()
|
|
}
|
|
recorder := httptest.NewRecorder()
|
|
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if len(forwarder.streamInputs) != 1 {
|
|
t.Fatalf("stream attempts = %d, want 1 after downstream deadline", len(forwarder.streamInputs))
|
|
}
|
|
if recorder.Body.Len() != 0 {
|
|
t.Fatalf("deadline path appended a response: %s", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerUsesClassifierForStreamingNonSuccess(t *testing.T) {
|
|
t.Run("retry then committed success", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: []byte(`{"error":"invalid_api_key"}`), ClassificationBody: []byte(`{"error":"invalid_api_key"}`), RequestWritten: true},
|
|
{StatusCode: http.StatusOK, Committed: true, RequestWritten: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.streamInputs) != 2 {
|
|
t.Fatalf("status/attempts = %d/%d, want 200/2", recorder.Code, len(forwarder.streamInputs))
|
|
}
|
|
})
|
|
|
|
t.Run("last key-level retryable response is passed through", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{streamResults: []UpstreamResult{
|
|
{StatusCode: http.StatusUnauthorized, Header: make(http.Header),
|
|
Body: []byte(`{"error":"first"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid_api_key"}`), RequestWritten: true},
|
|
{StatusCode: http.StatusUnauthorized, Header: make(http.Header),
|
|
Body: []byte(`{"error":"last"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid_api_key"}`), RequestWritten: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o","stream":true}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusUnauthorized || recorder.Body.String() != `{"error":"last"}` {
|
|
t.Fatalf("final response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerUsesClassifierForNonStreamingNonSuccess(t *testing.T) {
|
|
t.Run("client error terminates after one attempt", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{StatusCode: http.StatusBadRequest, Header: make(http.Header),
|
|
Body: []byte(`{"error":"invalid input"}`),
|
|
ExecutionError: &execution.ErrorEvidence{Kind: execution.ErrorKindHTTP, Code: "invalid_parameter"},
|
|
RequestWritten: true},
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`)},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusBadRequest || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("status/attempts = %d/%d, want 400/1", recorder.Code, len(forwarder.inputs))
|
|
}
|
|
})
|
|
|
|
t.Run("rate limit advances to a second key", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{StatusCode: http.StatusTooManyRequests, Header: http.Header{"Retry-After": {"30"}},
|
|
Body: []byte(`{"error":"rate_limit"}`),
|
|
ClassificationBody: []byte(`{"error":"rate_limit"}`), RequestWritten: true},
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
|
}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("status/attempts = %d/%d, want 200/2", recorder.Code, len(forwarder.inputs))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerRetriesAnotherGroupAfterLocalConversionFailure(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
conversionFailure := UpstreamResult{
|
|
Err: fmt.Errorf("%w: target conversion failed", ErrUpstreamProtocol),
|
|
DispatchState: execution.DispatchNotSent,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKind("conversion_unsupported"),
|
|
Code: "target_conversion_not_supported",
|
|
Summary: "target conversion is not supported",
|
|
},
|
|
ErrorSummary: "target conversion is not supported",
|
|
}
|
|
|
|
t.Run("falls back to another group without mutating credential health", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
conversionFailure,
|
|
{StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true},
|
|
}}
|
|
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
|
before := registry.Snapshot()
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
|
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
|
|
))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || recorder.Body.String() != `{"ok":true}` || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("response/attempts = %d %s / %d, want 200 and two groups", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
if forwarder.inputs[0].Group.ID == forwarder.inputs[1].Group.ID {
|
|
t.Fatalf("attempts stayed in group %d", forwarder.inputs[0].Group.ID)
|
|
}
|
|
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
|
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
|
}
|
|
})
|
|
|
|
t.Run("returns stable 422 after every target rejects conversion", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{conversionFailure, conversionFailure}}
|
|
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
|
before := registry.Snapshot()
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
|
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
|
|
))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if recorder.Code != http.StatusUnprocessableEntity || body.Code != "protocol_conversion_unsupported" || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
|
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
|
}
|
|
})
|
|
|
|
t.Run("malformed client request remains 400 and is not retried", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
Err: fmt.Errorf("%w: invalid request", ErrUpstreamProtocol),
|
|
DispatchState: execution.DispatchNotSent,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindInvalidRequest,
|
|
Summary: "invalid Anthropic request body",
|
|
},
|
|
}}}
|
|
engine, _ := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
|
`{"model":"claude-client","messages":[{"role":"user","content":"hello"}]}`,
|
|
))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusBadRequest ||
|
|
!strings.Contains(recorder.Body.String(), `"code":"invalid_protocol_request"`) ||
|
|
len(forwarder.inputs) != 1 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
})
|
|
|
|
t.Run("upstream unsupported model retries other candidates without penalty", func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusBadRequest,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"error":{"code":"unsupported_model"}}`),
|
|
RequestWritten: true,
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
ResponseStarted: true,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP,
|
|
Hint: execution.FailureHintModelUnavailable,
|
|
StatusCode: http.StatusBadRequest,
|
|
Code: "unsupported_model",
|
|
Summary: "request capability is unavailable",
|
|
},
|
|
}}}
|
|
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder)
|
|
before := registry.Snapshot()
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
|
`{"model":"claude-client","max_tokens":64,"messages":[{"role":"user","content":"hello"}]}`,
|
|
))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusBadRequest || recorder.Body.String() != `{"error":{"code":"unsupported_model"}}` || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
|
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerKeepsOriginalRouteAfterParameterOverride(t *testing.T) {
|
|
t.Parallel()
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
settings := config.Settings{state.SettingParameterOverrides: []any{
|
|
map[string]any{
|
|
"match": map[string]any{
|
|
"protocol": string(protocol.Anthropic),
|
|
"model": "claude-*",
|
|
},
|
|
"set": map[string]any{"container": "container_123"},
|
|
},
|
|
}}
|
|
engine, registry := newConvertedFallbackHandlerTestRuntime(t, forwarder, settings)
|
|
before := registry.Snapshot()
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
|
`{"model":"claude-client","max_tokens":64,"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) != 1 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
input := forwarder.inputs[0]
|
|
if input.RouteMode != execution.RouteConverted ||
|
|
input.RouteRequirement.Normalize() != execution.RouteRequirementAny {
|
|
t.Fatalf("route = %q/%q, want converted/any", input.RouteMode, input.RouteRequirement)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(input.Request.Body, &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body["container"] != "container_123" {
|
|
t.Fatalf("overridden request = %s, want container", input.Request.Body)
|
|
}
|
|
if after := registry.Snapshot(); !reflect.DeepEqual(after, before) {
|
|
t.Fatalf("credential health changed: before=%#v after=%#v", before, after)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotExpandCandidatesAfterParameterOverride(t *testing.T) {
|
|
t.Parallel()
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
settings := config.Settings{state.SettingParameterOverrides: []any{
|
|
map[string]any{
|
|
"match": map[string]any{
|
|
"protocol": string(protocol.Anthropic),
|
|
"model": "claude-*",
|
|
},
|
|
"remove": []any{"/container"},
|
|
},
|
|
}}
|
|
engine, _ := newConvertedFallbackHandlerTestRuntime(t, forwarder, settings)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewBufferString(
|
|
`{"model":"claude-client","max_tokens":64,"container":{"id":"container_123"},"messages":[{"role":"user","content":"hello"}]}`,
|
|
))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if recorder.Code != http.StatusServiceUnavailable ||
|
|
body.Code != reasonNoCandidate.Code || len(forwarder.inputs) != 0 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
}
|
|
|
|
func TestHandlerForwardsOverrideWithoutProviderSemanticValidation(t *testing.T) {
|
|
t.Parallel()
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
handler, manager, _ := newHandlerForTest(t, forwarder, "sk-one")
|
|
publishHandlerPolicySettings(
|
|
t,
|
|
handler,
|
|
manager,
|
|
1,
|
|
nil,
|
|
config.Settings{state.SettingParameterOverrides: []any{
|
|
map[string]any{"set": map[string]any{"service_tier": "ultrafast"}},
|
|
}},
|
|
)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("response/attempts = %d %s / %d", recorder.Code, recorder.Body.String(), len(forwarder.inputs))
|
|
}
|
|
if !bytes.Contains(forwarder.inputs[0].Request.Body, []byte(`"service_tier":"ultrafast"`)) {
|
|
t.Fatalf("effective request = %s, want overridden service_tier", forwarder.inputs[0].Request.Body)
|
|
}
|
|
}
|
|
|
|
func TestHandlerAppliesExactCooldownDeadline(t *testing.T) {
|
|
attemptNow := time.Date(2026, time.July, 21, 12, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
want time.Time
|
|
}{
|
|
{
|
|
name: "rate limit reset",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Header: http.Header{"Retry-After": {"30"}},
|
|
Body: []byte(`{"error":"rate_limit"}`),
|
|
ClassificationBody: []byte(`{"error":"rate_limit"}`),
|
|
RequestWritten: true,
|
|
},
|
|
want: attemptNow.Add(30 * time.Second),
|
|
},
|
|
{
|
|
name: "fixed fallback",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusTooManyRequests, Header: make(http.Header),
|
|
Body: []byte(`{"error":"rate_limit"}`),
|
|
ClassificationBody: []byte(`{"error":"rate_limit"}`),
|
|
RequestWritten: true,
|
|
},
|
|
want: attemptNow.Add(time.Minute),
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{test.result}}
|
|
handler, _, registry := newHandlerForTest(t, forwarder, "sk-one")
|
|
recording := &recordingRuntimeRegistry{CredentialRegistry: registry}
|
|
handler.registry = recording
|
|
handler.now = func() time.Time { return attemptNow }
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if until := registry.ModelCooldowns(1, attemptNow)["gpt-4o"]; !until.Equal(test.want) {
|
|
t.Fatalf("model deadline = %v, want %v", until, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerRoutesStoredResponsesToExplicitNativeFallback(t *testing.T) {
|
|
for _, body := range []string{
|
|
`{"model":"gpt","input":"hello","store":true}`,
|
|
`{"model":"gpt","input":"hello"}`,
|
|
} {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"resp_1","object":"response","store":false,"output":[]}`),
|
|
}}}
|
|
engine, _, _ := newResponsesStoreHandlerRuntime(t, forwarder, false, false)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(body))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("status/inputs = %d/%d; body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
input := forwarder.inputs[0]
|
|
if input.ChannelID != string(channel.Codex) || input.RouteMode != execution.RouteNative ||
|
|
!input.ResponsesStoreDowngraded || string(input.Request.Body) != body {
|
|
t.Fatalf("fallback input = %#v; body=%s", input, input.Request.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerKeepsStoredResponsesOnExactTarget(t *testing.T) {
|
|
for _, body := range []string{
|
|
`{"model":"gpt","input":"hello","store":true}`,
|
|
`{"model":"gpt","input":"hello"}`,
|
|
} {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"resp_1","object":"response","store":true,"output":[]}`),
|
|
}}}
|
|
engine, _, _ := newResponsesStoreHandlerRuntime(t, forwarder, true, true)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewBufferString(body))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("status/inputs = %d/%d; body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
input := forwarder.inputs[0]
|
|
if input.ChannelID != string(channel.OpenAI) || input.ResponsesStoreDowngraded ||
|
|
string(input.Request.Body) != body {
|
|
t.Fatalf("exact input = %#v; body=%s", input, input.Request.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerFallsBackWhenUpstreamManagedCredentialIsUnavailable(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
DispatchState: execution.DispatchMaybeSent,
|
|
StatusCode: http.StatusOK,
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"resp_1","object":"response","store":false,"output":[]}`),
|
|
}}}
|
|
engine, _, _ := newResponsesStoreHandlerRuntime(t, forwarder, true, false)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/responses",
|
|
bytes.NewBufferString(`{"model":"gpt","input":"hello","store":true}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 1 ||
|
|
forwarder.inputs[0].ChannelID != string(channel.Codex) ||
|
|
!forwarder.inputs[0].ResponsesStoreDowngraded {
|
|
t.Fatalf("status/inputs/body = %d/%d/%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionRateLimitWithoutResetUsesTenMinuteCooldown(t *testing.T) {
|
|
attemptNow := time.Date(2026, time.August, 21, 10, 0, 0, 0, time.UTC)
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusTooManyRequests,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP, StatusCode: http.StatusTooManyRequests,
|
|
Hint: execution.FailureHintRateLimited, Summary: "subscription quota exceeded",
|
|
},
|
|
}}}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, "placeholder")
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "subscription", ChannelID: channel.Codex,
|
|
ConnectionType: "subscription", Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: []state.CredentialConfig{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "subscription-account",
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical := `{"type":"codex","access_token":"access","refresh_token":"refresh","account_id":"account-1"}`
|
|
encrypted, err := handler.encryption.Encrypt(canonical)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "subscription-account", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler.now = func() time.Time { return attemptNow }
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
views := registry.Snapshot()
|
|
if len(views) != 1 || !views[0].ModelCooldowns["gpt-4o"].Equal(attemptNow.Add(10*time.Minute)) {
|
|
t.Fatalf("subscription cooldown = %#v, want %v", views, attemptNow.Add(10*time.Minute))
|
|
}
|
|
}
|
|
|
|
func TestHandlerCooldownExcludesKeyAcrossRequests(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Header: http.Header{"Retry-After": {"3600"}},
|
|
Body: []byte(`{"error":"rate_limit"}`),
|
|
ClassificationBody: []byte(`{"error":"rate_limit"}`),
|
|
RequestWritten: true,
|
|
},
|
|
{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, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
|
|
for range 2 {
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
}
|
|
if len(forwarder.inputs) != 3 ||
|
|
forwarder.inputs[0].APIKey == forwarder.inputs[1].APIKey ||
|
|
forwarder.inputs[1].APIKey != forwarder.inputs[2].APIKey {
|
|
t.Fatalf("attempt keys = %#v, want cooled key then stable backup", forwarder.inputs)
|
|
}
|
|
}
|
|
|
|
func TestHandlerBlacklistsKeyOnThirdInvalidFailure(t *testing.T) {
|
|
invalid := UpstreamResult{
|
|
StatusCode: http.StatusUnauthorized, Header: make(http.Header),
|
|
Body: []byte(`{"error":"invalid_api_key"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid_api_key"}`),
|
|
RequestWritten: true,
|
|
}
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{invalid, invalid, invalid}}
|
|
engine, _, registry := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
for attempt := 1; attempt <= 3; attempt++ {
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusUnauthorized {
|
|
t.Fatalf("attempt %d response = %d %s", attempt, recorder.Code, recorder.Body.String())
|
|
}
|
|
blacklisted := registry.BlacklistedCredentials()
|
|
if (attempt < 3 && len(blacklisted) != 0) ||
|
|
(attempt == 3 && (len(blacklisted) != 1 || blacklisted[0].ID != 1)) {
|
|
t.Fatalf("attempt %d blacklisted = %#v", attempt, blacklisted)
|
|
}
|
|
}
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusServiceUnavailable || len(forwarder.inputs) != 3 {
|
|
t.Fatalf("post-blacklist response/attempts = %d/%d, want 503/3",
|
|
recorder.Code, len(forwarder.inputs))
|
|
}
|
|
}
|
|
|
|
func TestHandlerAppliesSystemAndGroupBlacklistSettings(t *testing.T) {
|
|
invalid := UpstreamResult{
|
|
StatusCode: http.StatusUnauthorized, Header: make(http.Header),
|
|
Body: []byte(`{"error":"invalid_api_key"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid_api_key"}`),
|
|
RequestWritten: true,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
systemSettings config.Settings
|
|
groupSettings config.Settings
|
|
requests int
|
|
wantBlacklisted bool
|
|
}{
|
|
{
|
|
name: "system threshold",
|
|
systemSettings: config.Settings{
|
|
state.SettingBlacklistThreshold: 2,
|
|
},
|
|
requests: 2,
|
|
wantBlacklisted: true,
|
|
},
|
|
{
|
|
name: "group threshold enables blacklisting over disabled system policy",
|
|
systemSettings: config.Settings{
|
|
state.SettingBlacklistThreshold: 0,
|
|
},
|
|
groupSettings: config.Settings{
|
|
state.SettingBlacklistThreshold: 2,
|
|
},
|
|
requests: 2,
|
|
wantBlacklisted: true,
|
|
},
|
|
{
|
|
name: "zero group threshold disables blacklisting",
|
|
systemSettings: config.Settings{
|
|
state.SettingBlacklistThreshold: 1,
|
|
},
|
|
groupSettings: config.Settings{
|
|
state.SettingBlacklistThreshold: 0,
|
|
},
|
|
requests: 3,
|
|
wantBlacklisted: false,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
results := make([]UpstreamResult, test.requests)
|
|
for index := range results {
|
|
results[index] = invalid
|
|
}
|
|
forwarder := &scriptedForwarder{results: results}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, "sk-one")
|
|
publishHandlerPolicySettings(
|
|
t,
|
|
handler,
|
|
manager,
|
|
1,
|
|
test.systemSettings,
|
|
test.groupSettings,
|
|
)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
|
|
for attempt := 0; attempt < test.requests; attempt++ {
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
}
|
|
|
|
blacklisted := registry.BlacklistedCredentials()
|
|
if got := len(blacklisted) != 0; got != test.wantBlacklisted {
|
|
t.Fatalf(
|
|
"blacklisted = %#v after %d requests, want blacklisted=%t",
|
|
blacklisted,
|
|
test.requests,
|
|
test.wantBlacklisted,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerClearsFailureOnlyForNonStreamingSuccess(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
wantCount int
|
|
}{
|
|
{
|
|
name: "success clears",
|
|
result: UpstreamResult{StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`), RequestWritten: true},
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "client error does not clear",
|
|
result: UpstreamResult{StatusCode: http.StatusBadRequest, Header: make(http.Header),
|
|
Body: []byte(`{"error":"invalid input"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid input"}`), RequestWritten: true},
|
|
wantCount: 3,
|
|
},
|
|
{
|
|
name: "two hundred with error does not clear",
|
|
result: UpstreamResult{StatusCode: http.StatusOK,
|
|
Err: errors.New("response failed"), RequestWritten: true},
|
|
wantCount: 3,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{test.result}}
|
|
engine, _, registry := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
_, _ = registry.IncrFailure(1)
|
|
_, _ = registry.IncrFailure(1)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
count, ok := registry.IncrFailure(1)
|
|
if !ok || count != test.wantCount {
|
|
t.Fatalf("failure count = %d, %t, want %d, true", count, ok, test.wantCount)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotClearFailureAfterDownstreamCancellation(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
forwarder := &scriptedForwarder{
|
|
results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}},
|
|
onCall: func(int) { cancel() },
|
|
}
|
|
engine, _, registry := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
_, _ = registry.IncrFailure(1)
|
|
_, _ = registry.IncrFailure(1)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`)).WithContext(ctx)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
engine.ServeHTTP(httptest.NewRecorder(), request)
|
|
|
|
count, ok := registry.IncrFailure(1)
|
|
if !ok || count != 3 {
|
|
t.Fatalf("failure count = %d, %t, want 3, true", count, ok)
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionExplicit401RetriesSameCredentialWithForcedRefresh(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusUnauthorized,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP, StatusCode: http.StatusUnauthorized,
|
|
Hint: execution.FailureHintRefreshRequired,
|
|
ReplaySafety: execution.ReplaySafetyRejectedBeforeProcessing,
|
|
Summary: "access token expired",
|
|
},
|
|
},
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"ok","model":"gpt-4o"}`),
|
|
},
|
|
}}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, "placeholder")
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
SystemSettings: config.Settings{state.SettingRetryCount: 1},
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "subscription", ChannelID: channel.Codex,
|
|
Settings: config.Settings{state.SettingRetryCount: 0},
|
|
ConnectionType: "subscription", Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: []state.CredentialConfig{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "subscription-account",
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical := `{"type":"codex","access_token":"access","refresh_token":"refresh","account_id":"account-1"}`
|
|
encrypted, err := handler.encryption.Encrypt(canonical)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "subscription-account", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("status=%d inputs=%d body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
if forwarder.inputs[0].Credential.ID != forwarder.inputs[1].Credential.ID ||
|
|
forwarder.inputs[0].ForceCredentialRefresh || !forwarder.inputs[1].ForceCredentialRefresh {
|
|
t.Fatalf("inputs = %#v", forwarder.inputs)
|
|
}
|
|
registry.SchedulingState().WithLock(func(ledger *state.SchedulingLedger) {
|
|
if ledger.Sequence != 2 {
|
|
t.Fatalf("allocation sequence = %d, want both explicit attempts charged", ledger.Sequence)
|
|
}
|
|
})
|
|
|
|
}
|
|
|
|
func TestSubscriptionExplicit401ForcesRefreshAtMostOncePerRequest(t *testing.T) {
|
|
expired := UpstreamResult{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusUnauthorized,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP, StatusCode: http.StatusUnauthorized,
|
|
Hint: execution.FailureHintRefreshRequired,
|
|
ReplaySafety: execution.ReplaySafetyRejectedBeforeProcessing,
|
|
Summary: "access token expired",
|
|
},
|
|
}
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
expired,
|
|
expired,
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"unexpected-third-attempt","model":"gpt-4o"}`),
|
|
},
|
|
}}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, "placeholder")
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "subscription", ChannelID: channel.Codex,
|
|
ConnectionType: "subscription", Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: []state.CredentialConfig{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "subscription-account",
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical := `{"type":"codex","access_token":"access","refresh_token":"refresh","account_id":"account-1"}`
|
|
encrypted, err := handler.encryption.Encrypt(canonical)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "subscription-account", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusUnauthorized || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("status=%d inputs=%d body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
if forwarder.inputs[0].ForceCredentialRefresh || !forwarder.inputs[1].ForceCredentialRefresh {
|
|
t.Fatalf("refresh attempts = %#v", forwarder.inputs)
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionExplicit401UsesNewerCredentialVersionFromConcurrentRefresh(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusUnauthorized,
|
|
ExecutionError: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP, StatusCode: http.StatusUnauthorized,
|
|
Hint: execution.FailureHintRefreshRequired,
|
|
ReplaySafety: execution.ReplaySafetyRejectedBeforeProcessing,
|
|
Summary: "access token expired",
|
|
},
|
|
},
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"ok","model":"gpt-4o"}`),
|
|
},
|
|
}}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, "placeholder")
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "subscription", ChannelID: channel.Codex,
|
|
ConnectionType: "subscription", Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: []state.CredentialConfig{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "subscription-account",
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldCredential := `{"type":"codex","access_token":"old-access","refresh_token":"refresh","account_id":"account-1"}`
|
|
oldEncrypted, err := handler.encryption.Encrypt(oldCredential)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1,
|
|
Fingerprint: "subscription-account", Status: state.CredentialStatusActive,
|
|
EncryptedValue: oldEncrypted,
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newCredential := `{"type":"codex","access_token":"new-access","refresh_token":"new-refresh","account_id":"account-1"}`
|
|
newEncrypted, err := handler.encryption.Encrypt(newCredential)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
forwarder.onCall = func(index int) {
|
|
if index == 0 && !registry.ReplaceCredentialSecretIfMatch(1, 1, 2, "subscription-secret-v2", newEncrypted) {
|
|
t.Fatal("publish concurrent credential refresh")
|
|
}
|
|
}
|
|
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("status=%d inputs=%d body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
if forwarder.inputs[1].Credential.Version != 2 || forwarder.inputs[1].ForceCredentialRefresh ||
|
|
string(forwarder.inputs[1].Credential.Data()) != newCredential {
|
|
t.Fatalf("retry input = %#v credential=%s", forwarder.inputs[1], forwarder.inputs[1].Credential.Data())
|
|
}
|
|
}
|
|
|
|
func TestSubscriptionRefreshFailureBeforeDispatchRetriesAnotherCredential(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
evidence *execution.ErrorEvidence
|
|
wantCooldown bool
|
|
}{
|
|
{
|
|
name: "reauthorization required",
|
|
evidence: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindProvider, Code: "refresh_rejected",
|
|
Hint: execution.FailureHintReauthorizationRequired,
|
|
},
|
|
},
|
|
{
|
|
name: "temporarily unavailable",
|
|
evidence: &execution.ErrorEvidence{
|
|
Kind: execution.ErrorKindHTTP, Code: "refresh_temporarily_unavailable",
|
|
Hint: execution.FailureHintRefreshUnavailable,
|
|
},
|
|
wantCooldown: true,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
assertSubscriptionRefreshFailureRetriesAnotherCredential(
|
|
t,
|
|
test.evidence,
|
|
test.wantCooldown,
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func assertSubscriptionRefreshFailureRetriesAnotherCredential(
|
|
t *testing.T,
|
|
evidence *execution.ErrorEvidence,
|
|
wantCooldown bool,
|
|
) {
|
|
t.Helper()
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{
|
|
DispatchState: execution.DispatchNotSent,
|
|
ExecutionError: evidence,
|
|
},
|
|
{
|
|
DispatchState: execution.DispatchMaybeSent, ResponseStarted: true,
|
|
StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"id":"ok","model":"gpt-4o"}`),
|
|
},
|
|
}}
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, "placeholder-1", "placeholder-2")
|
|
credentials := []state.CredentialConfig{
|
|
{ID: 1, GroupID: 1, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 1, Fingerprint: "subscription-account-1"},
|
|
{ID: 2, GroupID: 1, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 2, Fingerprint: "subscription-account-2"},
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "subscription", ChannelID: channel.Codex,
|
|
ConnectionType: "subscription", Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: credentials,
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries := make([]state.CredentialEntry, 0, 2)
|
|
for index := 1; index <= 2; index++ {
|
|
canonical := fmt.Sprintf(`{"type":"codex","access_token":"access-%d","refresh_token":"refresh-%d","account_id":"account-%d"}`, index, index, index)
|
|
encrypted, err := handler.encryption.Encrypt(canonical)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries = append(entries, state.CredentialEntry{
|
|
ID: uint(index), GroupID: 1, Version: 1, IdentityGeneration: uint64(index),
|
|
Fingerprint: fmt.Sprintf("subscription-account-%d", index),
|
|
Status: state.CredentialStatusActive, EncryptedValue: encrypted,
|
|
})
|
|
}
|
|
if err := registry.ReplaceCredentials(entries); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if response.Code != http.StatusOK || len(forwarder.inputs) != 2 {
|
|
t.Fatalf("status=%d inputs=%d body=%s", response.Code, len(forwarder.inputs), response.Body.String())
|
|
}
|
|
if forwarder.inputs[0].Credential.ID == forwarder.inputs[1].Credential.ID {
|
|
t.Fatalf("credential ids = %d, %d", forwarder.inputs[0].Credential.ID, forwarder.inputs[1].Credential.ID)
|
|
}
|
|
failedCredentialID := forwarder.inputs[0].Credential.ID
|
|
for _, view := range registry.Snapshot() {
|
|
if view.ID == failedCredentialID && view.CooldownUntil.IsZero() == wantCooldown {
|
|
t.Fatalf("failed credential runtime view = %#v, want cooldown=%t", view, wantCooldown)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerLeavesCredentialRegistryUnchangedForNonCredentialEffects(t *testing.T) {
|
|
for _, effect := range []health.Effect{
|
|
health.EffectNone,
|
|
health.EffectSkipGroup,
|
|
health.Effect("unknown"),
|
|
} {
|
|
t.Run(fmt.Sprintf("effect_%s", effect), func(t *testing.T) {
|
|
recording := &recordingRuntimeRegistry{CredentialRegistry: state.NewCredentialRegistry()}
|
|
handler := &Handler{registry: recording}
|
|
handler.applyDecisionEffect(state.CredentialRef{ID: 1, GroupID: 1, IdentityGeneration: 1}, health.Decision{Effect: effect}, 0, time.Time{})
|
|
if recording.cooldownCalls != 0 || recording.incrFailureCalls != 0 ||
|
|
recording.blacklistCalls != 0 || recording.clearCalls != 0 {
|
|
t.Fatalf("mutation calls = cooldown:%d failure:%d blacklist:%d clear:%d",
|
|
recording.cooldownCalls, recording.incrFailureCalls,
|
|
recording.blacklistCalls, recording.clearCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerReturnsStableTerminalReasons(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
accessKey string
|
|
body string
|
|
upstreamKeys []string
|
|
results []UpstreamResult
|
|
wantStatus int
|
|
wantCode string
|
|
wantAttempts int
|
|
}{
|
|
{name: "invalid access key", path: "/v1/chat/completions", accessKey: "wrong", body: `{"model":"gpt-4o"}`, wantStatus: http.StatusUnauthorized, wantCode: "invalid_access_key"},
|
|
{name: "malformed registered endpoint", path: "/v1beta/models/missing-action", accessKey: "gl-client", body: `{}`, wantStatus: http.StatusNotFound, wantCode: "protocol_endpoint_not_found"},
|
|
{name: "invalid protocol request", path: "/v1/chat/completions", accessKey: "gl-client", body: `{}`, wantStatus: http.StatusBadRequest, wantCode: "invalid_protocol_request"},
|
|
{name: "no candidate", path: "/v1/chat/completions", accessKey: "gl-client", body: `{"model":"gpt-4o"}`, wantStatus: http.StatusServiceUnavailable, wantCode: "no_available_candidate"},
|
|
{
|
|
name: "post-write timeout",
|
|
path: "/v1/chat/completions", accessKey: "gl-client", body: `{"model":"gpt-4o"}`,
|
|
upstreamKeys: []string{"sk-one"},
|
|
results: []UpstreamResult{{Err: context.DeadlineExceeded, RequestWritten: true}},
|
|
wantStatus: http.StatusGatewayTimeout, wantCode: "upstream_timeout",
|
|
},
|
|
{
|
|
name: "connection failure skips only group",
|
|
path: "/v1/chat/completions", accessKey: "gl-client", body: `{"model":"gpt-4o"}`,
|
|
upstreamKeys: []string{"sk-one", "sk-two"},
|
|
results: []UpstreamResult{{Err: errors.New("dial failed")}},
|
|
wantStatus: http.StatusBadGateway, wantCode: "upstream_connect_failed", wantAttempts: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: tt.results}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, tt.upstreamKeys...)
|
|
request := httptest.NewRequest(http.MethodPost, tt.path, bytes.NewBufferString(tt.body))
|
|
request.Header.Set("Authorization", "Bearer "+tt.accessKey)
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != tt.wantStatus {
|
|
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, tt.wantStatus, recorder.Body.String())
|
|
}
|
|
if tt.wantAttempts > 0 && len(forwarder.inputs) != tt.wantAttempts {
|
|
t.Fatalf("attempts = %d, want %d", len(forwarder.inputs), tt.wantAttempts)
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode reason body: %v", err)
|
|
}
|
|
if body.Code != tt.wantCode {
|
|
t.Fatalf("code = %q, want %q", body.Code, tt.wantCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerReturnsModelRequiredByFilterForProtocolOnlyRequest(t *testing.T) {
|
|
forwarder := &scriptedForwarder{}
|
|
handler, manager, _ := newHandlerForTest(t, forwarder, "sk-responses")
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1,
|
|
Name: "responses",
|
|
ChannelID: channel.OpenAI,
|
|
Params: json.RawMessage(`{}`),
|
|
Enabled: true,
|
|
}},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1,
|
|
Name: "client",
|
|
KeyHash: keyService.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
Filters: state.FilterSet{
|
|
Models: map[string]struct{}{"public-model": {}},
|
|
},
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
handler.dialects = dialect.NewSet(
|
|
dialect.NewOpenAIResponses(),
|
|
)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodGet,
|
|
"/v1/responses/resp_123",
|
|
nil,
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusBadRequest ||
|
|
!strings.Contains(
|
|
recorder.Body.String(),
|
|
`"code":"model_required_by_filter"`,
|
|
) ||
|
|
len(forwarder.inputs) != 0 {
|
|
t.Fatalf(
|
|
"response/attempts = %d %s / %d, want 400 reason and no forward",
|
|
recorder.Code,
|
|
recorder.Body.String(),
|
|
len(forwarder.inputs),
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestHandlerStripsDownstreamQueryCredentialBeforeForwarding(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions?key=gl-client&trace=true", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("response/input count = %d/%d", recorder.Code, len(forwarder.inputs))
|
|
}
|
|
if got := forwarder.inputs[0].Request.RawQuery; got != "trace=true" {
|
|
t.Fatalf("forward RawQuery = %q, want trace=true", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerPreservesRawQueryBytesAfterStrippingCredential(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
engine, _, _ := newHandlerTestRuntime(t, forwarder, "sk-one")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.URL.RawQuery = "trace=first&key=gl-client&filter=%ZZ&sig=a%2Fb&z=last"
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf("response/input count = %d/%d", recorder.Code, len(forwarder.inputs))
|
|
}
|
|
const want = "trace=first&filter=%ZZ&sig=a%2Fb&z=last"
|
|
if got := forwarder.inputs[0].Request.RawQuery; got != want {
|
|
t.Fatalf("forward RawQuery = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRetries401WithAnotherKeyThenReturnsSuccess(t *testing.T) {
|
|
upstream := fakeupstream.New(
|
|
fakeupstream.Step{Status: http.StatusUnauthorized, Fixture: "openai/401.json"},
|
|
fakeupstream.Step{Status: http.StatusOK, Fixture: "openai/success.json"},
|
|
)
|
|
defer upstream.Close()
|
|
|
|
engine := newRealGatewayEngine(t, upstream.URL, "sk-first", "sk-second")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || !bytes.Contains(recorder.Body.Bytes(), []byte("chatcmpl-test")) {
|
|
t.Fatalf("response = %d %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
requests := upstream.Requests()
|
|
if len(requests) != 2 {
|
|
t.Fatalf("upstream requests = %d, want 2", len(requests))
|
|
}
|
|
first := requests[0].Headers.Get("Authorization")
|
|
second := requests[1].Headers.Get("Authorization")
|
|
if first == second || first == "" || second == "" {
|
|
t.Fatalf("upstream credentials = %q then %q, want two distinct keys", first, second)
|
|
}
|
|
for _, credential := range []string{first, second} {
|
|
if strings.Contains(credential, "gl-client") {
|
|
t.Fatalf("downstream access key reached upstream: %q", credential)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandlerAppliesSystemRetrySettingsAndIgnoresLegacyGroupOverrides(t *testing.T) {
|
|
invalid := UpstreamResult{
|
|
StatusCode: http.StatusUnauthorized, Header: make(http.Header),
|
|
Body: []byte(`{"error":"invalid_api_key"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid_api_key"}`),
|
|
RequestWritten: true,
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
systemSettings config.Settings
|
|
groupSettings config.Settings
|
|
wantAttempts int
|
|
}{
|
|
{
|
|
name: "zero system count disables retries",
|
|
systemSettings: config.Settings{
|
|
state.SettingRetryCount: 0,
|
|
},
|
|
wantAttempts: 1,
|
|
},
|
|
{
|
|
name: "legacy group count cannot enable retries over disabled system policy",
|
|
systemSettings: config.Settings{
|
|
state.SettingRetryCount: 0,
|
|
},
|
|
groupSettings: config.Settings{
|
|
state.SettingRetryCount: 4,
|
|
},
|
|
wantAttempts: 1,
|
|
},
|
|
{
|
|
name: "legacy group retry count cannot reduce system count",
|
|
systemSettings: config.Settings{
|
|
state.SettingRetryCount: 4,
|
|
},
|
|
groupSettings: config.Settings{
|
|
state.SettingRetryCount: 1,
|
|
},
|
|
wantAttempts: 5,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
keys := []string{"sk-one", "sk-two", "sk-three", "sk-four", "sk-five"}
|
|
results := make([]UpstreamResult, len(keys))
|
|
for index := range results {
|
|
results[index] = invalid
|
|
}
|
|
forwarder := &scriptedForwarder{results: results}
|
|
handler, manager, _ := newHandlerForTest(t, forwarder, keys...)
|
|
publishHandlerPolicySettings(
|
|
t,
|
|
handler,
|
|
manager,
|
|
len(keys),
|
|
test.systemSettings,
|
|
test.groupSettings,
|
|
)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
response := httptest.NewRecorder()
|
|
|
|
engine.ServeHTTP(response, request)
|
|
|
|
if len(forwarder.inputs) != test.wantAttempts {
|
|
t.Fatalf(
|
|
"upstream attempts = %d, want %d; response=%d %s",
|
|
len(forwarder.inputs),
|
|
test.wantAttempts,
|
|
response.Code,
|
|
response.Body.String(),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerReturnsLastUpstreamResponseWhenBudgetIsExhausted(t *testing.T) {
|
|
upstream := fakeupstream.New(
|
|
fakeupstream.Step{Status: http.StatusUnauthorized, Fixture: "openai/401.json"},
|
|
fakeupstream.Step{Status: http.StatusTooManyRequests, Fixture: "openai/429.json"},
|
|
fakeupstream.Step{Status: http.StatusInternalServerError, Fixture: "openai/500.json"},
|
|
)
|
|
defer upstream.Close()
|
|
|
|
engine := newRealGatewayEngine(t, upstream.URL, "sk-one", "sk-two", "sk-three", "sk-unused")
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("X-Api-Key", "gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
wantAttempts := state.DefaultRuntimeSettings().RetryCount + 1
|
|
if recorder.Code != http.StatusInternalServerError || len(upstream.Requests()) != wantAttempts {
|
|
t.Fatalf("response/attempts = %d/%d, want 500/%d", recorder.Code, len(upstream.Requests()), wantAttempts)
|
|
}
|
|
if !bytes.Contains(recorder.Body.Bytes(), []byte("internal_error")) {
|
|
t.Fatalf("body = %s, want final upstream fixture", recorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotExposeAliasedUpstreamModelWhenRetryBudgetIsExhausted(t *testing.T) {
|
|
const (
|
|
externalModel = "public-model"
|
|
upstreamModel = "provider-model"
|
|
)
|
|
var attempts atomic.Int64
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
attempts.Add(1)
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.Header().Set("X-Upstream-Model", upstreamModel)
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = writer.Write([]byte(`{"error":"provider-model internal error"}`))
|
|
}))
|
|
defer upstream.Close()
|
|
|
|
engine, _ := newDialectGatewayEngine(t, protocol.OpenAICompletions, externalModel,
|
|
dialect.NewSet(dialect.NewOpenAI()),
|
|
dialectGatewayGroup{id: 1, name: "openai-1", upstreamURL: upstream.URL,
|
|
apiKeys: []string{"sk-one"},
|
|
models: []state.ModelConfig{{ID: upstreamModel, Alias: externalModel}}},
|
|
dialectGatewayGroup{id: 2, name: "openai-2", upstreamURL: upstream.URL,
|
|
apiKeys: []string{"sk-two"},
|
|
models: []state.ModelConfig{{ID: upstreamModel, Alias: externalModel}}},
|
|
dialectGatewayGroup{id: 3, name: "openai-3", upstreamURL: upstream.URL,
|
|
apiKeys: []string{"sk-three"},
|
|
models: []state.ModelConfig{{ID: upstreamModel, Alias: externalModel}}},
|
|
)
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"public-model"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusInternalServerError || attempts.Load() != 3 {
|
|
t.Fatalf("response/attempts = %d/%d, want 500/3", recorder.Code, attempts.Load())
|
|
}
|
|
if strings.Contains(recorder.Body.String(), upstreamModel) ||
|
|
!strings.Contains(recorder.Body.String(), externalModel) {
|
|
t.Fatalf("final response body = %s", recorder.Body.String())
|
|
}
|
|
assertHeadersDoNotContain(t, recorder.Header(), upstreamModel)
|
|
}
|
|
|
|
func TestHandlerKeepsFrozenSnapshotAcrossRetry(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{
|
|
{StatusCode: http.StatusUnauthorized, Header: make(http.Header), Body: []byte(`{"error":"invalid_api_key"}`), ClassificationBody: []byte(`{"error":"invalid_api_key"}`), RequestWritten: true},
|
|
{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, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
forwarder.onCall = func(index int) {
|
|
if index != 0 {
|
|
return
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1, Name: "openai", ChannelID: channel.OpenAI, Params: json.RawMessage(`{}`), Enabled: true,
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}},
|
|
Settings: config.Settings{state.SettingBlacklistThreshold: 7},
|
|
}},
|
|
Credentials: []state.CredentialConfig{
|
|
testCredentialConfig(1, 1),
|
|
testCredentialConfig(2, 1),
|
|
},
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"), Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() during request error = %v", err)
|
|
}
|
|
}
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.inputs) != 2 ||
|
|
forwarder.inputs[0].Group.BlacklistThreshold != 3 ||
|
|
forwarder.inputs[1].Group.BlacklistThreshold != 3 {
|
|
t.Fatalf("response/attempts/frozen thresholds = %d/%d/%d/%d, want 200/2/3/3", recorder.Code, len(forwarder.inputs), forwarder.inputs[0].Group.BlacklistThreshold, forwarder.inputs[1].Group.BlacklistThreshold)
|
|
}
|
|
if current := manager.Current(); current == nil || current.Groups[1].BlacklistThreshold != 7 {
|
|
t.Fatalf("current snapshot = %#v, want newly published blacklist_threshold=7", current)
|
|
}
|
|
|
|
secondRequest := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
secondRequest.Header.Set("Authorization", "Bearer gl-client")
|
|
secondRecorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(secondRecorder, secondRequest)
|
|
if secondRecorder.Code != http.StatusOK || len(forwarder.inputs) != 3 ||
|
|
forwarder.inputs[2].Group.BlacklistThreshold != 7 {
|
|
t.Fatalf("new request/attempts = %d/%d, body=%s, want 200/3 and threshold=7", secondRecorder.Code, len(forwarder.inputs), secondRecorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestHandlerSkipsCandidateChangedAfterCollection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*testing.T, *state.CredentialRegistry, encryption.Service)
|
|
}{
|
|
{
|
|
name: "key moved to another group",
|
|
mutate: func(t *testing.T, registry *state.CredentialRegistry, keyService encryption.Service) {
|
|
t.Helper()
|
|
encrypted, err := keyService.Encrypt("sk-group-two")
|
|
if err != nil {
|
|
t.Fatalf("Encrypt(group two key) error = %v", err)
|
|
}
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 2, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive, EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatalf("Replace(moved key) error = %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "key disabled",
|
|
mutate: func(t *testing.T, registry *state.CredentialRegistry, _ encryption.Service) {
|
|
t.Helper()
|
|
if err := registry.SetCredentialStatus(1, state.CredentialStatusDisabled); err != nil {
|
|
t.Fatalf("SetCredentialStatus(disabled) error = %v", err)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
forwarder := &scriptedForwarder{results: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
}}}
|
|
_, manager, registry := newHandlerTestRuntime(t, forwarder, "sk-group-one")
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
runtimeRegistry := &mutatingRuntimeRegistry{
|
|
CredentialRegistry: registry,
|
|
mutate: func() { tt.mutate(t, registry, keyService) },
|
|
}
|
|
openAI := dialect.NewOpenAI()
|
|
handler := NewHandler(
|
|
manager, registry, keyService, forwarder, dialect.NewSet(openAI), health.NewStatsStore(),
|
|
health.NewMutationCoordinator(),
|
|
nil, nil, nil,
|
|
)
|
|
handler.registry = runtimeRegistry
|
|
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewBufferString(`{"model":"gpt-4o"}`))
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusServiceUnavailable || len(forwarder.inputs) != 0 {
|
|
t.Fatalf("response/attempts = %d/%d, want 503/0; body=%s", recorder.Code, len(forwarder.inputs), recorder.Body.String())
|
|
}
|
|
var body struct {
|
|
Code string `json:"code"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
if body.Code != reasonNoCandidate.Code {
|
|
t.Fatalf("response code = %q, want %q", body.Code, reasonNoCandidate.Code)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerFreezesKeyIdentityAfterInspectingBody(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
seedPlain string
|
|
seedStatus state.CredentialStatus
|
|
currentPlain string
|
|
mutate func(*state.CredentialRegistry, string) error
|
|
}{
|
|
{
|
|
name: "new import", currentPlain: "sk-imported",
|
|
mutate: func(registry *state.CredentialRegistry, encrypted string) error {
|
|
return registry.ApplyCredentialImport(1, []state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypted,
|
|
}})
|
|
},
|
|
},
|
|
{
|
|
name: "disabled becomes active", seedPlain: "sk-enabled",
|
|
seedStatus: state.CredentialStatusDisabled, currentPlain: "sk-enabled",
|
|
mutate: func(registry *state.CredentialRegistry, _ string) error {
|
|
return registry.SetCredentialStatus(1, state.CredentialStatusActive)
|
|
},
|
|
},
|
|
{
|
|
name: "same ID gets new ciphertext", seedPlain: "sk-old",
|
|
seedStatus: state.CredentialStatusActive, currentPlain: "sk-replaced",
|
|
mutate: func(registry *state.CredentialRegistry, encrypted string) error {
|
|
return registry.ApplyCredentialImport(1, []state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypted,
|
|
}})
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(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,
|
|
},
|
|
}}
|
|
handler, _, registry := newHandlerForTest(t, forwarder)
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
recordingEncryption := &recordingDecryptEncryption{Service: keyService}
|
|
handler.encryption = recordingEncryption
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
currentCiphertext := encryptTestCredentialValue(t, keyService, test.currentPlain)
|
|
if test.seedStatus != "" {
|
|
seedCiphertext := currentCiphertext
|
|
if test.seedPlain != test.currentPlain {
|
|
seedCiphertext = encryptTestCredentialValue(t, keyService, test.seedPlain)
|
|
}
|
|
if replaceErr := registry.ReplaceCredentials([]state.CredentialEntry{{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: test.seedStatus,
|
|
EncryptedValue: seedCiphertext,
|
|
}}); replaceErr != nil {
|
|
t.Fatalf("Replace(seed key) error = %v", replaceErr)
|
|
}
|
|
}
|
|
|
|
body := newBlockingRequestBody(
|
|
`{"model":"gpt-4o"}`,
|
|
func() error { return test.mutate(registry, currentCiphertext) },
|
|
)
|
|
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", body)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
oldRecorder := httptest.NewRecorder()
|
|
oldDone := make(chan struct{})
|
|
go func() {
|
|
engine.ServeHTTP(oldRecorder, request)
|
|
close(oldDone)
|
|
}()
|
|
|
|
receiveTestSignal(t, body.started, "blocked request body read")
|
|
close(body.release)
|
|
receiveTestSignal(t, oldDone, "blocked request completion")
|
|
if body.firstReadErr != nil {
|
|
t.Fatalf("first Read identity mutation error = %v", body.firstReadErr)
|
|
}
|
|
|
|
if oldRecorder.Code != http.StatusOK || len(forwarder.inputs) != 1 {
|
|
t.Fatalf(
|
|
"decoded request response/attempts = %d/%d, want 200/1; body=%s",
|
|
oldRecorder.Code,
|
|
len(forwarder.inputs),
|
|
oldRecorder.Body.String(),
|
|
)
|
|
}
|
|
if got := forwarder.inputs[0].APIKey; got != test.currentPlain {
|
|
t.Fatalf("decoded request API key = %q, want %q", got, test.currentPlain)
|
|
}
|
|
if len(recordingEncryption.ciphertexts) != 1 {
|
|
t.Fatalf("decoded request decrypt calls = %d, want 1", len(recordingEncryption.ciphertexts))
|
|
}
|
|
|
|
newRequest := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
newRequest.Header.Set("Authorization", "Bearer gl-client")
|
|
newRecorder := httptest.NewRecorder()
|
|
engine.ServeHTTP(newRecorder, newRequest)
|
|
|
|
if newRecorder.Code != http.StatusOK || len(forwarder.inputs) != 2 {
|
|
t.Fatalf(
|
|
"new request response/attempts = %d/%d, want 200/2; body=%s",
|
|
newRecorder.Code,
|
|
len(forwarder.inputs),
|
|
newRecorder.Body.String(),
|
|
)
|
|
}
|
|
if got := forwarder.inputs[1].APIKey; got != test.currentPlain {
|
|
t.Fatalf("new request API key = %q, want %q", got, test.currentPlain)
|
|
}
|
|
if len(recordingEncryption.ciphertexts) != 2 {
|
|
t.Fatalf(
|
|
"new request decrypt calls = %d, want 2",
|
|
len(recordingEncryption.ciphertexts),
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHandlerAllowsCapturedUnavailableIdentityAfterRecovery(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
makeUnavailable func(*testing.T, *state.CredentialRegistry)
|
|
recover func(*testing.T, *state.CredentialRegistry, string)
|
|
}{
|
|
{
|
|
name: "blacklisted",
|
|
makeUnavailable: func(t *testing.T, registry *state.CredentialRegistry) {
|
|
t.Helper()
|
|
if ok := registry.SetBlacklisted(3); !ok {
|
|
t.Fatal("SetBlacklisted(3) = false")
|
|
}
|
|
},
|
|
recover: func(t *testing.T, registry *state.CredentialRegistry, _ string) {
|
|
t.Helper()
|
|
if ok := registry.Recover(3); !ok {
|
|
t.Fatal("Recover(3) = false")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "cooldown",
|
|
makeUnavailable: func(t *testing.T, registry *state.CredentialRegistry) {
|
|
t.Helper()
|
|
if ok := registry.SetCooldown(3, time.Now().Add(time.Hour)); !ok {
|
|
t.Fatal("SetCooldown(3) = false")
|
|
}
|
|
},
|
|
recover: func(t *testing.T, registry *state.CredentialRegistry, encrypted string) {
|
|
t.Helper()
|
|
if err := registry.ApplyCredentialImport(1, []state.CredentialEntry{{
|
|
ID: 3, GroupID: 1, Version: 1, IdentityGeneration: 3, Fingerprint: "test-3", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatalf("ApplyImport(recovered cooldown key) error = %v", err)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
firstForward := make(chan struct{})
|
|
releaseForward := make(chan struct{})
|
|
forwarder := &scriptedForwarder{
|
|
results: []UpstreamResult{
|
|
{
|
|
StatusCode: http.StatusTooManyRequests, Header: make(http.Header),
|
|
Body: []byte(`{"error":"rate limit"}`),
|
|
ClassificationBody: []byte(`{"error":"rate limit"}`),
|
|
RequestWritten: true,
|
|
},
|
|
{
|
|
StatusCode: http.StatusOK, Header: make(http.Header),
|
|
Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
},
|
|
},
|
|
onCall: func(index int) {
|
|
if index != 0 {
|
|
return
|
|
}
|
|
close(firstForward)
|
|
<-releaseForward
|
|
},
|
|
}
|
|
handler, _, registry := newHandlerForTest(t, forwarder)
|
|
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
encrypt := func(plaintext string) string {
|
|
t.Helper()
|
|
return encryptTestCredentialValue(t, keyService, plaintext)
|
|
}
|
|
recoverableCiphertext := encrypt("sk-recoverable")
|
|
if err := registry.ReplaceCredentials([]state.CredentialEntry{
|
|
{
|
|
ID: 1, GroupID: 1, Version: 1, IdentityGeneration: 1, Fingerprint: "test-1", Status: state.CredentialStatusActive,
|
|
EncryptedValue: encrypt("sk-first"),
|
|
},
|
|
{
|
|
ID: 2, GroupID: 1, Version: 1, IdentityGeneration: 2, Fingerprint: "test-2", Status: state.CredentialStatusDisabled,
|
|
EncryptedValue: encrypt("sk-newly-enabled"),
|
|
},
|
|
{
|
|
ID: 3, GroupID: 1, Version: 1, IdentityGeneration: 3, Fingerprint: "test-3", Status: state.CredentialStatusActive,
|
|
EncryptedValue: recoverableCiphertext,
|
|
},
|
|
}); err != nil {
|
|
t.Fatalf("Replace(keys) error = %v", err)
|
|
}
|
|
test.makeUnavailable(t, registry)
|
|
|
|
request := httptest.NewRequest(
|
|
http.MethodPost,
|
|
"/v1/chat/completions",
|
|
bytes.NewBufferString(`{"model":"gpt-4o"}`),
|
|
)
|
|
request.Header.Set("Authorization", "Bearer gl-client")
|
|
recorder := httptest.NewRecorder()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
engine.ServeHTTP(recorder, request)
|
|
close(done)
|
|
}()
|
|
|
|
receiveTestSignal(t, firstForward, "first forward")
|
|
if err := registry.SetCredentialStatus(2, state.CredentialStatusActive); err != nil {
|
|
t.Fatalf("SetCredentialStatus(2, active) error = %v", err)
|
|
}
|
|
test.recover(t, registry, recoverableCiphertext)
|
|
close(releaseForward)
|
|
receiveTestSignal(t, done, "recovery request completion")
|
|
|
|
if recorder.Code != http.StatusOK || len(forwarder.inputs) != 2 {
|
|
t.Fatalf(
|
|
"response/attempts = %d/%d, want 200/2; body=%s",
|
|
recorder.Code,
|
|
len(forwarder.inputs),
|
|
recorder.Body.String(),
|
|
)
|
|
}
|
|
if got := forwarder.inputs[1].APIKey; got != "sk-recoverable" {
|
|
t.Fatalf(
|
|
"second attempt API key = %q, want captured recovered identity",
|
|
got,
|
|
)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newRealGatewayEngine(t *testing.T, upstreamURL string, upstreamKeys ...string) *gin.Engine {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
manager := state.NewManager()
|
|
baseURL := testUpstreamBaseURL(upstreamURL, protocol.OpenAICompletions)
|
|
channelID, params := testChannelConfig(t, protocol.OpenAICompletions, baseURL)
|
|
credentialConfigs := make([]state.CredentialConfig, 0, len(upstreamKeys))
|
|
for index := range upstreamKeys {
|
|
credentialConfigs = append(credentialConfigs, testCredentialConfig(uint(index+1), 1))
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1, Name: "openai", ChannelID: channelID, Params: params,
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: credentialConfigs,
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
registry := state.NewCredentialRegistry()
|
|
entries := make([]state.CredentialEntry, 0, len(upstreamKeys))
|
|
for index, plaintext := range upstreamKeys {
|
|
entries = append(entries, testCredentialEntry(t, keyService, uint(index+1), 1, plaintext))
|
|
}
|
|
if err := registry.ReplaceCredentials(entries); err != nil {
|
|
t.Fatalf("ReplaceCredentials() error = %v", err)
|
|
}
|
|
|
|
openAI := dialect.NewOpenAI()
|
|
handler := NewHandler(
|
|
manager,
|
|
registry,
|
|
keyService,
|
|
newTestExecutionForwarder(t),
|
|
dialect.NewSet(openAI),
|
|
health.NewStatsStore(),
|
|
health.NewMutationCoordinator(),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
return engine
|
|
}
|
|
|
|
func testDialectClientConfig() *platformhttp.Config {
|
|
return &platformhttp.Config{
|
|
ConnectTimeout: time.Second, ResponseHeaderTimeout: time.Second,
|
|
IdleConnTimeout: time.Second, MaxIdleConns: 10, MaxIdleConnsPerHost: 10,
|
|
DisableCompression: true, ForceAttemptHTTP2: true,
|
|
TLSHandshakeTimeout: time.Second, ExpectContinueTimeout: time.Second,
|
|
}
|
|
}
|
|
|
|
func newHandlerTestRuntime(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
upstreamKeys ...string,
|
|
) (*gin.Engine, *state.Manager, *state.CredentialRegistry) {
|
|
t.Helper()
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, upstreamKeys...)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
return engine, manager, registry
|
|
}
|
|
|
|
func newResponsesStoreHandlerRuntime(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
includeExact bool,
|
|
includeExactCredential bool,
|
|
) (*gin.Engine, *state.Manager, *state.CredentialRegistry) {
|
|
t.Helper()
|
|
handler, manager, registry := newHandlerForTest(t, forwarder)
|
|
groups := []state.GroupConfig{{
|
|
ConnectionType: "subscription",
|
|
ID: 1,
|
|
Name: "codex",
|
|
ChannelID: channel.Codex,
|
|
Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-codex", Alias: "gpt"}},
|
|
Enabled: true,
|
|
}}
|
|
credentials := []state.CredentialConfig{{
|
|
ID: 1, GroupID: 1, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 1, Fingerprint: "codex-account",
|
|
}}
|
|
if includeExact {
|
|
groups = append(groups, state.GroupConfig{
|
|
ConnectionType: "api_key",
|
|
ID: 2,
|
|
Name: "openai",
|
|
ChannelID: channel.OpenAI,
|
|
Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-openai", Alias: "gpt"}},
|
|
Enabled: true,
|
|
})
|
|
credentials = append(credentials, state.CredentialConfig{
|
|
ID: 2, GroupID: 2, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: 2, Fingerprint: "openai-key",
|
|
})
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: groups,
|
|
Credentials: credentials,
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
|
|
entries := make([]state.CredentialEntry, 0, 2)
|
|
for _, value := range []struct {
|
|
id uint
|
|
groupID uint
|
|
identity uint64
|
|
fingerprint string
|
|
plaintext string
|
|
include bool
|
|
}{
|
|
{
|
|
id: 1, groupID: 1, identity: 1, fingerprint: "codex-account", include: true,
|
|
plaintext: `{"type":"codex","access_token":"access","refresh_token":"refresh","account_id":"account-1"}`,
|
|
},
|
|
{
|
|
id: 2, groupID: 2, identity: 2, fingerprint: "openai-key", include: includeExactCredential,
|
|
plaintext: `{"api_key":"sk-openai"}`,
|
|
},
|
|
} {
|
|
if !value.include {
|
|
continue
|
|
}
|
|
encrypted, err := handler.encryption.Encrypt(value.plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt() error = %v", err)
|
|
}
|
|
entries = append(entries, state.CredentialEntry{
|
|
ID: value.id, GroupID: value.groupID, Status: state.CredentialStatusActive,
|
|
Version: 1, IdentityGeneration: value.identity, Fingerprint: value.fingerprint,
|
|
EncryptedValue: encrypted,
|
|
})
|
|
}
|
|
if err := registry.ReplaceCredentials(entries); err != nil {
|
|
t.Fatalf("ReplaceCredentials() error = %v", err)
|
|
}
|
|
handler.dialects = dialect.NewSet(dialect.NewOpenAIResponses())
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
return engine, manager, registry
|
|
}
|
|
|
|
func publishHandlerPolicySettings(
|
|
t *testing.T,
|
|
handler *Handler,
|
|
manager *state.Manager,
|
|
credentialCount int,
|
|
systemSettings config.Settings,
|
|
groupSettings config.Settings,
|
|
) {
|
|
t.Helper()
|
|
credentials := make([]state.CredentialConfig, 0, credentialCount)
|
|
for index := 0; index < credentialCount; index++ {
|
|
credentials = append(credentials, state.CredentialConfig{
|
|
ID: uint(index + 1),
|
|
GroupID: 1,
|
|
Status: state.CredentialStatusActive,
|
|
Version: 1,
|
|
IdentityGeneration: uint64(index + 1),
|
|
Fingerprint: fmt.Sprintf("credential-%d", index+1),
|
|
})
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
SystemSettings: systemSettings,
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{
|
|
ConnectionType: "api_key",
|
|
ID: 1,
|
|
Name: "openai",
|
|
ChannelID: channel.OpenAI,
|
|
Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}},
|
|
Settings: groupSettings,
|
|
Enabled: true,
|
|
}},
|
|
Credentials: credentials,
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1,
|
|
Name: "client",
|
|
KeyHash: handler.encryption.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() policy settings error = %v", err)
|
|
}
|
|
}
|
|
|
|
func newStatsHandlerTestRuntime(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
upstreamKeys ...string,
|
|
) (*gin.Engine, *Handler, *state.CredentialRegistry, *health.StatsStore) {
|
|
t.Helper()
|
|
stats := health.NewStatsStore()
|
|
handler, _, registry := newHandlerForTestWithStats(t, forwarder, stats, upstreamKeys...)
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
return engine, handler, registry, stats
|
|
}
|
|
|
|
func newHandlerForTest(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
upstreamKeys ...string,
|
|
) (*Handler, *state.Manager, *state.CredentialRegistry) {
|
|
return newHandlerForTestWithStats(t, forwarder, health.NewStatsStore(), upstreamKeys...)
|
|
}
|
|
|
|
func newHandlerForTestWithStats(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
stats *health.StatsStore,
|
|
upstreamKeys ...string,
|
|
) (*Handler, *state.Manager, *state.CredentialRegistry) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
keyService := encryptiontest.Service(t, "handler-test-master-key")
|
|
manager := state.NewManager()
|
|
credentialConfigs := make([]state.CredentialConfig, 0, len(upstreamKeys))
|
|
for index := range upstreamKeys {
|
|
credentialConfigs = append(credentialConfigs, state.CredentialConfig{
|
|
ID: uint(index + 1),
|
|
GroupID: 1,
|
|
Status: state.CredentialStatusActive,
|
|
Version: 1,
|
|
IdentityGeneration: uint64(index + 1),
|
|
Fingerprint: fmt.Sprintf("credential-%d", index+1),
|
|
})
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channel.NewRegistry(),
|
|
Groups: []state.GroupConfig{{ConnectionType: "api_key", ID: 1, Name: "openai", ChannelID: channel.OpenAI,
|
|
Params: json.RawMessage(`{}`),
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, Enabled: true,
|
|
}},
|
|
Credentials: credentialConfigs,
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"),
|
|
Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
registry := state.NewCredentialRegistry()
|
|
entries := make([]state.CredentialEntry, 0, len(upstreamKeys))
|
|
for index, plaintext := range upstreamKeys {
|
|
credential, err := json.Marshal(map[string]string{"api_key": plaintext})
|
|
if err != nil {
|
|
t.Fatalf("Marshal() error = %v", err)
|
|
}
|
|
encrypted, err := keyService.Encrypt(string(credential))
|
|
if err != nil {
|
|
t.Fatalf("Encrypt() error = %v", err)
|
|
}
|
|
entries = append(entries, state.CredentialEntry{
|
|
ID: uint(index + 1), GroupID: 1,
|
|
Version: 1, IdentityGeneration: uint64(index + 1),
|
|
Fingerprint: fmt.Sprintf("credential-%d", index+1),
|
|
Status: state.CredentialStatusActive, EncryptedValue: encrypted,
|
|
})
|
|
}
|
|
if err := registry.ReplaceCredentials(entries); err != nil {
|
|
t.Fatalf("ReplaceCredentials() error = %v", err)
|
|
}
|
|
|
|
openAI := dialect.NewOpenAI()
|
|
handler := NewHandler(
|
|
manager, registry, keyService, forwarder, dialect.NewSet(openAI), stats,
|
|
health.NewMutationCoordinator(),
|
|
nil, nil, nil,
|
|
)
|
|
|
|
return handler, manager, registry
|
|
}
|
|
|
|
func newConvertedFallbackHandlerTestRuntime(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
groupSettings ...config.Settings,
|
|
) (*gin.Engine, *state.CredentialRegistry) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
keyService := encryptiontest.Service(t, "handler-conversion-fallback-test-key")
|
|
channelRegistry := channel.NewRegistry()
|
|
manager := state.NewManager()
|
|
groups := []state.GroupConfig{
|
|
{ConnectionType: "api_key", ID: 1, Name: "compatible-one", ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://one.example/v1"}`), Enabled: true,
|
|
Models: []state.ModelConfig{{ID: "upstream-one", Alias: "claude-client"}},
|
|
},
|
|
{ConnectionType: "api_key", ID: 2, Name: "compatible-two", ChannelID: channel.OpenAICompatible,
|
|
Params: json.RawMessage(`{"base_url":"https://two.example/v1"}`), Enabled: true,
|
|
Models: []state.ModelConfig{{ID: "upstream-two", Alias: "claude-client"}},
|
|
},
|
|
}
|
|
if len(groupSettings) > 0 {
|
|
for index := range groups {
|
|
groups[index].Settings = groupSettings[0]
|
|
}
|
|
}
|
|
credentials := []state.CredentialConfig{
|
|
{ID: 1, GroupID: 1, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 1, Fingerprint: "credential-one"},
|
|
{ID: 2, GroupID: 2, Status: state.CredentialStatusActive, Version: 1, IdentityGeneration: 2, Fingerprint: "credential-two"},
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
ChannelRegistry: channelRegistry,
|
|
Groups: groups,
|
|
Credentials: credentials,
|
|
AccessKeys: []state.AccessKeyConfig{{
|
|
ID: 1, Name: "client", KeyHash: keyService.Hash("gl-client"), Status: state.AccessKeyStatusActive,
|
|
}},
|
|
}); err != nil {
|
|
t.Fatalf("Publish() error = %v", err)
|
|
}
|
|
|
|
registry := state.NewCredentialRegistry()
|
|
entries := make([]state.CredentialEntry, 0, len(credentials))
|
|
for _, credential := range credentials {
|
|
plaintext := fmt.Sprintf(`{"api_key":"sk-%d"}`, credential.ID)
|
|
encrypted, err := keyService.Encrypt(plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt() error = %v", err)
|
|
}
|
|
entries = append(entries, state.CredentialEntry{
|
|
ID: credential.ID, GroupID: credential.GroupID, Status: credential.Status,
|
|
Version: credential.Version, IdentityGeneration: credential.IdentityGeneration,
|
|
Fingerprint: credential.Fingerprint, EncryptedValue: encrypted,
|
|
})
|
|
}
|
|
if err := registry.ReplaceCredentials(entries); err != nil {
|
|
t.Fatalf("ReplaceCredentials() error = %v", err)
|
|
}
|
|
handler := NewHandler(
|
|
manager, registry, keyService, forwarder, dialect.NewSet(dialect.NewAnthropic()),
|
|
health.NewStatsStore(), health.NewMutationCoordinator(),
|
|
nil, nil, nil,
|
|
)
|
|
handler.channels = channelRegistry
|
|
|
|
engine := gin.New()
|
|
bindGatewayRoutesForTest(t, engine, handler)
|
|
return engine, registry
|
|
}
|