1988 lines
71 KiB
Go
1988 lines
71 KiB
Go
package gateway
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/rand"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"gpt-load/internal/dialect"
|
|
"gpt-load/internal/health"
|
|
"gpt-load/internal/platform/encryption"
|
|
platformhttp "gpt-load/internal/platform/httpclient"
|
|
"gpt-load/internal/platform/redact"
|
|
"gpt-load/internal/platform/utils"
|
|
"gpt-load/internal/protocol"
|
|
"gpt-load/internal/state"
|
|
"gpt-load/internal/testutil/fakeupstream"
|
|
)
|
|
|
|
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 mutatingRuntimeRegistry struct {
|
|
*state.KeyRegistry
|
|
mutate func()
|
|
mutated bool
|
|
}
|
|
|
|
type recordingRuntimeRegistry struct {
|
|
*state.KeyRegistry
|
|
cooldownKeyID uint
|
|
cooldownUntil time.Time
|
|
cooldownCalls int
|
|
incrFailureCalls int
|
|
blacklistCalls int
|
|
clearCalls int
|
|
}
|
|
|
|
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(keyID uint, until time.Time) bool {
|
|
registry.cooldownKeyID = keyID
|
|
registry.cooldownUntil = until
|
|
registry.cooldownCalls++
|
|
return registry.KeyRegistry.SetCooldown(keyID, until)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) IncrFailure(keyID uint) (int, bool) {
|
|
registry.incrFailureCalls++
|
|
return registry.KeyRegistry.IncrFailure(keyID)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) SetBlacklisted(keyID uint) bool {
|
|
registry.blacklistCalls++
|
|
return registry.KeyRegistry.SetBlacklisted(keyID)
|
|
}
|
|
|
|
func (registry *recordingRuntimeRegistry) ClearFailure(keyID uint) bool {
|
|
registry.clearCalls++
|
|
return registry.KeyRegistry.ClearFailure(keyID)
|
|
}
|
|
|
|
func (registry *mutatingRuntimeRegistry) CollectCandidates(
|
|
groupIDs []uint,
|
|
excluded func(uint) bool,
|
|
now time.Time,
|
|
) []state.KeyMeta {
|
|
candidates := registry.KeyRegistry.CollectCandidates(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 UpstreamResult{Err: errors.New("script exhausted")}
|
|
}
|
|
return 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 UpstreamResult{Err: errors.New("stream script exhausted")}
|
|
}
|
|
result := forwarder.streamResults[index]
|
|
if forwarder.invokeStreamReady && result.Committed && input.OnStreamReady != nil {
|
|
input.OnStreamReady()
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (forwarder *streamReadyBlockingForwarder) Forward(
|
|
context.Context,
|
|
ForwardInput,
|
|
) UpstreamResult {
|
|
return UpstreamResult{Err: errors.New("unexpected non-streaming forward")}
|
|
}
|
|
|
|
func (forwarder *streamReadyBlockingForwarder) ForwardStream(
|
|
_ context.Context,
|
|
input ForwardInput,
|
|
_ http.ResponseWriter,
|
|
) UpstreamResult {
|
|
input.OnStreamReady()
|
|
close(forwarder.ready)
|
|
<-forwarder.release
|
|
return forwarder.result
|
|
}
|
|
|
|
func (forwarder *streamReadyBlockingForwarder) Release() {
|
|
forwarder.releaseOnce.Do(func() { close(forwarder.release) })
|
|
}
|
|
|
|
func TestHandlerRecordsOnlyAttributableNonStreamingResults(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
want health.KeyStats
|
|
}{
|
|
{
|
|
name: "2xx success",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusOK, Header: make(http.Header), Body: []byte(`{"ok":true}`), RequestWritten: true,
|
|
},
|
|
want: health.KeyStats{Success: 1},
|
|
},
|
|
{
|
|
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.KeyStats{Failure: 1, ConsecutiveFailure: 1},
|
|
},
|
|
{
|
|
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.KeyStats{},
|
|
},
|
|
{
|
|
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.KeyStats{},
|
|
},
|
|
{
|
|
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.KeyStats{},
|
|
},
|
|
{
|
|
name: "host error",
|
|
result: UpstreamResult{
|
|
StatusCode: http.StatusInternalServerError, Header: make(http.Header), Body: []byte(`{"error":"overloaded"}`),
|
|
ClassificationBody: []byte(`{"error":"overloaded"}`), RequestWritten: true,
|
|
},
|
|
want: health.KeyStats{},
|
|
},
|
|
{
|
|
name: "pre-write transport",
|
|
result: UpstreamResult{
|
|
Err: errors.New("dial upstream"),
|
|
},
|
|
want: health.KeyStats{},
|
|
},
|
|
{
|
|
name: "context canceled",
|
|
result: UpstreamResult{
|
|
Err: context.Canceled,
|
|
},
|
|
want: health.KeyStats{},
|
|
},
|
|
}
|
|
|
|
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.newRandom = func() *rand.Rand { return rand.New(zeroSource{}) }
|
|
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.KeyStats{Failure: 1, ConsecutiveFailure: 1}) {
|
|
t.Fatalf("first key stats = %#v, want one failure", got)
|
|
}
|
|
if got := stats.Snapshot(2, now); got != (health.KeyStats{Success: 1}) {
|
|
t.Fatalf("second key stats = %#v, want one success", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerRecordsStreamSuccessAtReadyTime(t *testing.T) {
|
|
now := time.Date(2026, time.July, 22, 10, 0, 0, 0, time.UTC)
|
|
tests := []struct {
|
|
name string
|
|
result UpstreamResult
|
|
}{
|
|
{
|
|
name: "committed success records before forward returns",
|
|
result: UpstreamResult{StatusCode: http.StatusOK, Committed: true, RequestWritten: true},
|
|
},
|
|
{
|
|
name: "pump failure after ready keeps success",
|
|
result: UpstreamResult{Err: errors.New("stream pump failed"), Committed: true, RequestWritten: true},
|
|
},
|
|
}
|
|
|
|
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.KeyStats{Success: 1}) {
|
|
t.Fatalf("stats before forward returns = %#v, want one success", got)
|
|
}
|
|
forwarder.Release()
|
|
receiveTestSignal(t, done, "stream request completion")
|
|
if got := stats.Snapshot(1, now); got != (health.KeyStats{Success: 1}) {
|
|
t.Fatalf("stats after forward returns = %#v, want one success", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
preCommitForwarder := &scriptedForwarder{streamResults: []UpstreamResult{{
|
|
Err: errors.New("first stream event failed"), RequestWritten: true, RetryableBeforeCommit: 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.KeyStats{}) {
|
|
t.Fatalf("pre-commit stream stats = %#v, want zero", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotRecordCanceledAttempt(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 {
|
|
t.Fatal("canceled attempt read the clock")
|
|
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.KeyStats{}) {
|
|
t.Fatalf("canceled attempt stats = %#v, want zero", got)
|
|
}
|
|
}
|
|
|
|
func TestHandlerDoesNotRecordCommittedNonStreamingAttempt(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 {
|
|
t.Fatal("committed attempt read the clock")
|
|
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.KeyStats{}) {
|
|
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: "unknown endpoint", path: "/unknown", accessKey: "gl-client", body: `{}`},
|
|
{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 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":"cannot_extract_model"`) {
|
|
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 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/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.OpenAI: {}},
|
|
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, err := encryption.NewService("model-handler-test-master-key")
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
manager := state.NewManager()
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "openai", UpstreamURL: "https://unused.example.com",
|
|
Protocols: []protocol.Protocol{protocol.OpenAI},
|
|
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.NewKeyRegistry(), spyEncryption, panicForwarder{}, dialect.NewSet(), health.NewStatsStore(),
|
|
nil, nil,
|
|
)
|
|
handler.registry = panicRuntimeRegistry{}
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
|
|
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, err := encryption.NewService("model-handler-test-master-key")
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
manager := state.NewManager()
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
Groups: []state.GroupConfig{
|
|
{
|
|
ID: 1, Name: "multi", UpstreamURL: "https://multi.example.com",
|
|
Protocols: []protocol.Protocol{protocol.OpenAI, protocol.Anthropic, protocol.Gemini},
|
|
Models: []state.ModelConfig{{ID: "zeta"}, {ID: "alpha"}}, Enabled: true,
|
|
},
|
|
{
|
|
ID: 2, Name: "openai", UpstreamURL: "https://openai.example.com",
|
|
Protocols: []protocol.Protocol{protocol.OpenAI, protocol.Anthropic},
|
|
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.NewKeyRegistry(), keyService, &scriptedForwarder{}, dialect.NewSet(), health.NewStatsStore(),
|
|
nil, nil,
|
|
)
|
|
handler.modelListLimit = limit
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
return engine
|
|
}
|
|
|
|
type panicRuntimeRegistry struct{}
|
|
|
|
func (panicRuntimeRegistry) CollectCandidates([]uint, func(uint) bool, time.Time) []state.KeyMeta {
|
|
panic("model endpoint collected upstream candidates")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) ActiveEncryptedValue(uint, uint) (string, bool) {
|
|
panic("model endpoint read an upstream key")
|
|
}
|
|
|
|
func (panicRuntimeRegistry) SetCooldown(uint, time.Time) 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) 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 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)
|
|
|
|
last := forwarder.inputs[len(forwarder.inputs)-1]
|
|
assertDebugHeaders(t, recorder.Header(), "openai", utils.MaskAPIKey(last.APIKey), tt.wantAttempts)
|
|
if strings.Contains(strings.Join(recorder.Header().Values("X-GPTLoad-Key"), ","), last.APIKey) {
|
|
t.Fatal("debug header exposed plaintext key")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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", utils.MaskAPIKey("sk-real-key"), "1")
|
|
}
|
|
|
|
func assertDebugHeaders(t *testing.T, headers http.Header, group, key, attempts string) {
|
|
t.Helper()
|
|
want := map[string]string{
|
|
"X-GPTLoad-Group": group, "X-GPTLoad-Key": key, "X-GPTLoad-Attempts": 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReadRequestBodyHonorsLimit(t *testing.T) {
|
|
t.Run("exact limit is accepted", func(t *testing.T) {
|
|
body, err := readRequestBody(strings.NewReader("1234"), 4)
|
|
if err != nil || string(body) != "1234" {
|
|
t.Fatalf("readRequestBody() = %q, %v", body, err)
|
|
}
|
|
})
|
|
|
|
t.Run("limit plus one is rejected without draining", func(t *testing.T) {
|
|
reader := &boundedCountingReader{remaining: 100}
|
|
body, err := readRequestBody(reader, 4)
|
|
if !errors.Is(err, errRequestTooLarge) || body != nil {
|
|
t.Fatalf("readRequestBody() = %q, %v, want request too large", body, err)
|
|
}
|
|
if reader.read != 5 {
|
|
t.Fatalf("reader consumed %d bytes, want limit+1 (5)", reader.read)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestHandlerRejectsOversizedRequestBody(t *testing.T) {
|
|
if maxRequestBodyBytes != 32<<20 {
|
|
t.Fatalf("maxRequestBodyBytes = %d, want %d", maxRequestBodyBytes, 32<<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 TestHandlerStreamReadyClearsFailureBeforeCommittedReturn(t *testing.T) {
|
|
forwarder := &scriptedForwarder{
|
|
invokeStreamReady: true,
|
|
streamResults: []UpstreamResult{{
|
|
StatusCode: http.StatusOK, RequestWritten: true, Committed: true,
|
|
}},
|
|
}
|
|
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 TestHandlerRetriesStreamBeforeCommit(t *testing.T) {
|
|
protocolFailure := fmt.Errorf("%w: gzip", ErrUpstreamProtocol)
|
|
tests := []struct {
|
|
name string
|
|
results []UpstreamResult
|
|
wantStatus int
|
|
wantCode string
|
|
wantAttempts int
|
|
}{
|
|
{
|
|
name: "protocol error then committed success",
|
|
results: []UpstreamResult{
|
|
{Err: protocolFailure, RequestWritten: true, RetryableBeforeCommit: true},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
},
|
|
wantStatus: http.StatusOK, wantAttempts: 2,
|
|
},
|
|
{
|
|
name: "first-event timeout then committed success",
|
|
results: []UpstreamResult{
|
|
{Err: context.DeadlineExceeded, RequestWritten: true, RetryableBeforeCommit: true},
|
|
{StatusCode: http.StatusOK, RequestWritten: true, Committed: true},
|
|
},
|
|
wantStatus: http.StatusOK, wantAttempts: 2,
|
|
},
|
|
{
|
|
name: "protocol errors exhausted",
|
|
results: []UpstreamResult{
|
|
{Err: protocolFailure, RequestWritten: true, RetryableBeforeCommit: true},
|
|
{Err: protocolFailure, RequestWritten: true, RetryableBeforeCommit: true},
|
|
},
|
|
wantStatus: http.StatusBadGateway, wantCode: reasonUpstreamProtocol.Code, wantAttempts: 2,
|
|
},
|
|
{
|
|
name: "first-event timeouts exhausted",
|
|
results: []UpstreamResult{
|
|
{Err: context.DeadlineExceeded, RequestWritten: true, RetryableBeforeCommit: true},
|
|
{Err: context.DeadlineExceeded, RequestWritten: true, RetryableBeforeCommit: true},
|
|
},
|
|
wantStatus: http.StatusGatewayTimeout, wantCode: reasonUpstreamTimeout.Code, wantAttempts: 2,
|
|
},
|
|
{
|
|
name: "transport failures exhausted",
|
|
results: []UpstreamResult{
|
|
{Err: errors.New("stream disconnected"), RequestWritten: true, RetryableBeforeCommit: true},
|
|
{Err: errors.New("stream disconnected"), RequestWritten: true, RetryableBeforeCommit: true},
|
|
},
|
|
wantStatus: http.StatusBadGateway, wantCode: reasonUpstreamConnect.Code, wantAttempts: 2,
|
|
},
|
|
}
|
|
|
|
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 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, RetryableBeforeCommit: 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, RetryableBeforeCommit: 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, RetryableBeforeCommit: 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"}`),
|
|
ClassificationBody: []byte(`{"error":"invalid input"}`), 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 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),
|
|
},
|
|
{
|
|
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: attemptNow.Add(time.Hour),
|
|
},
|
|
}
|
|
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{KeyRegistry: registry}
|
|
handler.registry = recording
|
|
handler.now = func() time.Time { return attemptNow }
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
|
|
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 recording.cooldownCalls != 1 || recording.cooldownKeyID != 1 ||
|
|
!recording.cooldownUntil.Equal(test.want) {
|
|
t.Fatalf("cooldown = calls:%d key:%d until:%v, want 1/1/%v",
|
|
recording.cooldownCalls, recording.cooldownKeyID,
|
|
recording.cooldownUntil, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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.BlacklistedKeys()
|
|
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 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 TestHandlerLeavesKeyRegistryUnchangedForNonKeyActions(t *testing.T) {
|
|
for _, action := range []health.Action{
|
|
health.ActionRetry,
|
|
health.ActionSkipGroup,
|
|
health.ActionTerminate,
|
|
health.Action(255),
|
|
} {
|
|
t.Run(fmt.Sprintf("action_%d", action), func(t *testing.T) {
|
|
recording := &recordingRuntimeRegistry{KeyRegistry: state.NewKeyRegistry()}
|
|
handler := &Handler{registry: recording}
|
|
handler.applyKeyAction(1, health.Result{Action: action}, 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: "unknown endpoint after auth", path: "/unknown", accessKey: "gl-client", body: `{}`, wantStatus: http.StatusNotFound, wantCode: "protocol_endpoint_not_found"},
|
|
{name: "cannot extract model", path: "/v1/chat/completions", accessKey: "gl-client", body: `{}`, wantStatus: http.StatusBadRequest, wantCode: "cannot_extract_model"},
|
|
{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 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 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)
|
|
|
|
if recorder.Code != http.StatusInternalServerError || len(upstream.Requests()) != maxAttempts {
|
|
t.Fatalf("response/attempts = %d/%d, want 500/%d", recorder.Code, len(upstream.Requests()), maxAttempts)
|
|
}
|
|
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.OpenAI, externalModel,
|
|
dialect.NewSet(dialect.NewOpenAI(http.DefaultClient)),
|
|
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() != maxAttempts {
|
|
t.Fatalf("response/attempts = %d/%d, want 500/%d", recorder.Code, attempts.Load(), maxAttempts)
|
|
}
|
|
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},
|
|
}}
|
|
engine, manager, _ := newHandlerTestRuntime(t, forwarder, "sk-one", "sk-two")
|
|
keyService, err := encryption.NewService("handler-test-master-key")
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
forwarder.onCall = func(index int) {
|
|
if index != 0 {
|
|
return
|
|
}
|
|
if _, err := manager.Publish(state.CompileInput{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 {
|
|
t.Fatalf("response/attempts = %d/%d, want 200/2", recorder.Code, len(forwarder.inputs))
|
|
}
|
|
if current := manager.Current(); current == nil || len(current.Groups) != 0 {
|
|
t.Fatalf("current snapshot = %#v, want newly published empty groups", current)
|
|
}
|
|
}
|
|
|
|
func TestHandlerSkipsCandidateChangedAfterCollection(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*testing.T, *state.KeyRegistry, encryption.Service)
|
|
}{
|
|
{
|
|
name: "key moved to another group",
|
|
mutate: func(t *testing.T, registry *state.KeyRegistry, 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.Replace([]state.KeyEntry{{
|
|
ID: 1, GroupID: 2, Status: state.KeyStatusActive, EncryptedValue: encrypted,
|
|
}}); err != nil {
|
|
t.Fatalf("Replace(moved key) error = %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "key disabled",
|
|
mutate: func(t *testing.T, registry *state.KeyRegistry, _ encryption.Service) {
|
|
t.Helper()
|
|
if err := registry.SetKeyStatus(1, state.KeyStatusDisabled); err != nil {
|
|
t.Fatalf("SetKeyStatus(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, err := encryption.NewService("handler-test-master-key")
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
runtimeRegistry := &mutatingRuntimeRegistry{
|
|
KeyRegistry: registry,
|
|
mutate: func() { tt.mutate(t, registry, keyService) },
|
|
}
|
|
openAI := dialect.NewOpenAI(http.DefaultClient)
|
|
handler := NewHandler(
|
|
manager, registry, keyService, forwarder, dialect.NewSet(openAI), health.NewStatsStore(),
|
|
nil, nil,
|
|
)
|
|
handler.registry = runtimeRegistry
|
|
handler.newRandom = func() *rand.Rand { return rand.New(rand.NewSource(1)) }
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
|
|
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 newRealGatewayEngine(t *testing.T, upstreamURL string, upstreamKeys ...string) *gin.Engine {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
keyService, err := encryption.NewService("handler-test-master-key")
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
manager := state.NewManager()
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "openai", UpstreamURL: upstreamURL,
|
|
Protocols: []protocol.Protocol{protocol.OpenAI},
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, 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)
|
|
}
|
|
registry := state.NewKeyRegistry()
|
|
entries := make([]state.KeyEntry, 0, len(upstreamKeys))
|
|
for index, plaintext := range upstreamKeys {
|
|
encrypted, err := keyService.Encrypt(plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt() error = %v", err)
|
|
}
|
|
entries = append(entries, state.KeyEntry{
|
|
ID: uint(index + 1), GroupID: 1, Status: state.KeyStatusActive, EncryptedValue: encrypted,
|
|
})
|
|
}
|
|
if err := registry.Replace(entries); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
|
|
clients := platformhttp.NewHTTPClientManager()
|
|
openAI := dialect.NewOpenAI(clients.GetClient(testDialectClientConfig()))
|
|
handler := NewHandler(
|
|
manager,
|
|
registry,
|
|
keyService,
|
|
NewForwarder(clients, redact.New()),
|
|
dialect.NewSet(openAI),
|
|
health.NewStatsStore(),
|
|
nil,
|
|
nil,
|
|
)
|
|
handler.newRandom = func() *rand.Rand { return rand.New(rand.NewSource(1)) }
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
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.KeyRegistry) {
|
|
t.Helper()
|
|
handler, manager, registry := newHandlerForTest(t, forwarder, upstreamKeys...)
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
return engine, manager, registry
|
|
}
|
|
|
|
func newStatsHandlerTestRuntime(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
upstreamKeys ...string,
|
|
) (*gin.Engine, *Handler, *state.KeyRegistry, *health.StatsStore) {
|
|
t.Helper()
|
|
stats := health.NewStatsStore()
|
|
handler, _, registry := newHandlerForTestWithStats(t, forwarder, stats, upstreamKeys...)
|
|
engine := gin.New()
|
|
handler.RegisterRoutes(engine)
|
|
return engine, handler, registry, stats
|
|
}
|
|
|
|
func newHandlerForTest(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
upstreamKeys ...string,
|
|
) (*Handler, *state.Manager, *state.KeyRegistry) {
|
|
return newHandlerForTestWithStats(t, forwarder, health.NewStatsStore(), upstreamKeys...)
|
|
}
|
|
|
|
func newHandlerForTestWithStats(
|
|
t *testing.T,
|
|
forwarder AttemptForwarder,
|
|
stats *health.StatsStore,
|
|
upstreamKeys ...string,
|
|
) (*Handler, *state.Manager, *state.KeyRegistry) {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
keyService, err := encryption.NewService("handler-test-master-key")
|
|
if err != nil {
|
|
t.Fatalf("NewService() error = %v", err)
|
|
}
|
|
manager := state.NewManager()
|
|
if _, err := manager.Publish(state.CompileInput{
|
|
Groups: []state.GroupConfig{{
|
|
ID: 1, Name: "openai", UpstreamURL: "http://upstream.invalid",
|
|
Protocols: []protocol.Protocol{protocol.OpenAI},
|
|
Models: []state.ModelConfig{{ID: "gpt-4o"}}, 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)
|
|
}
|
|
registry := state.NewKeyRegistry()
|
|
entries := make([]state.KeyEntry, 0, len(upstreamKeys))
|
|
for index, plaintext := range upstreamKeys {
|
|
encrypted, err := keyService.Encrypt(plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt() error = %v", err)
|
|
}
|
|
entries = append(entries, state.KeyEntry{
|
|
ID: uint(index + 1), GroupID: 1, Status: state.KeyStatusActive, EncryptedValue: encrypted,
|
|
})
|
|
}
|
|
if err := registry.Replace(entries); err != nil {
|
|
t.Fatalf("Replace() error = %v", err)
|
|
}
|
|
|
|
openAI := dialect.NewOpenAI(http.DefaultClient)
|
|
handler := NewHandler(
|
|
manager, registry, keyService, forwarder, dialect.NewSet(openAI), stats,
|
|
nil, nil,
|
|
)
|
|
handler.newRandom = func() *rand.Rand { return rand.New(rand.NewSource(1)) }
|
|
return handler, manager, registry
|
|
}
|