* fix(groups): add missing gateway protocol probes * fix(groups): avoid typed gateway probe cancellation races * fix(probes): validate gateway response array elements * fix(probes): validate required native response payloads * fix(probes): enforce exclusive Gemini part payloads
680 lines
27 KiB
Go
680 lines
27 KiB
Go
package bifrost
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"reflect"
|
|
"testing"
|
|
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
|
|
"gpt-load/internal/channel"
|
|
"gpt-load/internal/execution"
|
|
"gpt-load/internal/outboundproxy"
|
|
"gpt-load/internal/protocol"
|
|
"gpt-load/internal/provideradapter"
|
|
)
|
|
|
|
func TestNewAPINativeUnaryProtocolsUseCanonicalRootPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
clientProtocol protocol.Protocol
|
|
operation execution.Operation
|
|
path string
|
|
body string
|
|
wantPath string
|
|
credentialHeader string
|
|
response string
|
|
}{
|
|
{
|
|
name: "completions", clientProtocol: protocol.OpenAICompletions,
|
|
operation: execution.OperationChatCompletion, path: "/v1/chat/completions",
|
|
body: `{"model":"client-model","messages":[{"role":"user","content":"hello"}],"provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1/chat/completions", credentialHeader: "Authorization",
|
|
response: `{"id":"chat_1","object":"chat.completion","created":1,"model":"upstream-model","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
|
|
},
|
|
{
|
|
name: "responses", clientProtocol: protocol.OpenAIResponses,
|
|
operation: execution.OperationResponsesCreate, path: "/v1/responses",
|
|
body: `{"model":"client-model","input":"hello","store":false,"provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1/responses", credentialHeader: "Authorization",
|
|
response: `{"id":"resp_1","object":"response","status":"completed","model":"upstream-model","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`,
|
|
},
|
|
{
|
|
name: "responses compact", clientProtocol: protocol.OpenAIResponses,
|
|
operation: execution.OperationResponsesCompact, path: "/v1/responses/compact",
|
|
body: `{"model":"client-model","input":[{"role":"user","content":"hello"}],"provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1/responses/compact", credentialHeader: "Authorization",
|
|
response: `{"id":"cmp_1","object":"response.compaction","model":"upstream-model","output":[]}`,
|
|
},
|
|
{
|
|
name: "images", clientProtocol: protocol.OpenAIImages,
|
|
operation: execution.OperationImagesGenerate, path: "/v1/images/generations",
|
|
body: `{"model":"client-model","prompt":"hello","provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1/images/generations", credentialHeader: "Authorization",
|
|
response: `{"created":1,"data":[{"url":"https://images.example/result.png"}]}`,
|
|
},
|
|
{
|
|
name: "embeddings", clientProtocol: protocol.OpenAIEmbeddings,
|
|
operation: execution.OperationEmbeddingsCreate, path: "/v1/embeddings",
|
|
body: `{"model":"client-model","input":"hello","provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1/embeddings", credentialHeader: "Authorization",
|
|
response: `{"object":"list","model":"upstream-model","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2]}],"usage":{"prompt_tokens":1,"total_tokens":1}}`,
|
|
},
|
|
{
|
|
name: "anthropic", clientProtocol: protocol.Anthropic,
|
|
operation: execution.OperationChatCompletion, path: "/v1/messages",
|
|
body: `{"model":"client-model","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1/messages", credentialHeader: "X-Api-Key",
|
|
response: `{"id":"msg_1","type":"message","role":"assistant","model":"upstream-model","content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}`,
|
|
},
|
|
{
|
|
name: "gemini", clientProtocol: protocol.Gemini,
|
|
operation: execution.OperationChatCompletion, path: "/v1beta/models/client-model:generateContent",
|
|
body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}],"provider":"client","api_key":"client-body"}`,
|
|
wantPath: "/team-a/v1beta/models/upstream-model:generateContent", credentialHeader: "X-Goog-Api-Key",
|
|
response: `{"candidates":[{"content":{"role":"model","parts":[{"text":"pong"}]} ,"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"upstream-model"}`,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodPost || request.URL.Path != test.wantPath ||
|
|
request.URL.Query().Get("trace") != "kept" || request.URL.Query().Get("key") != "" ||
|
|
request.URL.Query().Get("api_key") != "" {
|
|
t.Errorf("upstream target = %s %s?%s", request.Method, request.URL.Path, request.URL.RawQuery)
|
|
}
|
|
wantCredential := testAPIKey
|
|
if test.credentialHeader == "Authorization" {
|
|
wantCredential = "Bearer " + testAPIKey
|
|
}
|
|
if request.Header.Get(test.credentialHeader) != wantCredential {
|
|
t.Errorf("credential %s = %q", test.credentialHeader, request.Header.Get(test.credentialHeader))
|
|
}
|
|
for _, name := range []string{"Authorization", "X-Api-Key", "X-Goog-Api-Key"} {
|
|
if name != test.credentialHeader && request.Header.Get(name) != "" {
|
|
t.Errorf("unexpected credential header %s = %q", name, request.Header.Get(name))
|
|
}
|
|
}
|
|
if test.clientProtocol == protocol.Anthropic && request.Header.Get("Anthropic-Version") == "" {
|
|
t.Error("Anthropic-Version is missing")
|
|
}
|
|
body, _ := io.ReadAll(request.Body)
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
t.Fatalf("decode upstream request: %v", err)
|
|
}
|
|
if payload["provider"] != nil || payload["api_key"] != nil {
|
|
t.Errorf("control fields reached upstream: %#v", payload)
|
|
}
|
|
if test.clientProtocol != protocol.Gemini && payload["model"] != "upstream-model" {
|
|
t.Errorf("upstream model = %#v", payload["model"])
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.Header().Set("X-Request-Id", "newapi-"+test.name)
|
|
_, _ = io.WriteString(writer, test.response)
|
|
}))
|
|
defer server.Close()
|
|
|
|
registry := channel.NewRegistry()
|
|
resolved, err := registry.Resolve(
|
|
channel.NewAPI,
|
|
json.RawMessage(`{"base_url":"`+server.URL+`/team-a"}`),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager, err := newRuntimeManager(runtimeOptions{allowPrivateNetwork: true}, registry)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer manager.Shutdown()
|
|
if err := manager.Reconcile([]provideradapter.RuntimeTarget{{Target: resolved}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
spec := freezeTestAttempt(execution.AttemptSpec{
|
|
RequestID: "newapi-request", AttemptID: "newapi-attempt", Sequence: 1,
|
|
ChannelID: string(channel.NewAPI), ClientProtocol: test.clientProtocol,
|
|
Operation: test.operation, ClientModel: "client-model", UpstreamModel: "upstream-model",
|
|
Method: http.MethodPost, Path: test.path,
|
|
RawQuery: "trace=kept&key=client-query&api_key=client-query",
|
|
Header: http.Header{
|
|
"Authorization": {"Bearer client-auth"},
|
|
"X-Api-Key": {"client-auth"},
|
|
"X-Goog-Api-Key": {"client-auth"},
|
|
"Content-Type": {"application/json"},
|
|
},
|
|
Body: []byte(test.body), TargetConfig: resolved.TargetConfig,
|
|
Credential: execution.NewCredentialSnapshot(
|
|
17, 1, 1, []byte(`{"api_key":"`+testAPIKey+`"}`),
|
|
),
|
|
})
|
|
if err := spec.Validate(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result := manager.Execute(context.Background(), spec)
|
|
if err := result.Validate(); err != nil {
|
|
t.Fatalf("result validation: %v; result=%+v", err, result)
|
|
}
|
|
if result.Error != nil || result.StatusCode != http.StatusOK ||
|
|
result.UpstreamProtocol != test.clientProtocol || result.UpstreamRequestID != "newapi-"+test.name {
|
|
t.Fatalf("result = %+v", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewAPIImageEditUsesGatewayRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
imageBytes := []byte{0x00, 0xff, 0x10, 0x20, '\r', '\n'}
|
|
body, contentType := imagesMultipartBody(t, imageBytes)
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/team-a/v1/images/edits" {
|
|
t.Errorf("path = %q", request.URL.Path)
|
|
}
|
|
if request.Header.Get("Authorization") != "Bearer "+testAPIKey {
|
|
t.Errorf("Authorization = %q", request.Header.Get("Authorization"))
|
|
}
|
|
mediaType, parameters, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
|
if err != nil || mediaType != "multipart/form-data" || parameters["boundary"] == "" {
|
|
t.Errorf("Content-Type = %q: %v", request.Header.Get("Content-Type"), err)
|
|
return
|
|
}
|
|
reader := multipart.NewReader(request.Body, parameters["boundary"])
|
|
seen := make(map[string]bool)
|
|
for {
|
|
part, nextErr := reader.NextRawPart()
|
|
if nextErr == io.EOF {
|
|
break
|
|
}
|
|
if nextErr != nil {
|
|
t.Errorf("NextRawPart() error = %v", nextErr)
|
|
return
|
|
}
|
|
_, disposition, parseErr := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
|
|
if parseErr != nil {
|
|
t.Errorf("Content-Disposition = %q: %v", part.Header.Get("Content-Disposition"), parseErr)
|
|
return
|
|
}
|
|
name := disposition["name"]
|
|
seen[name] = true
|
|
data, readErr := io.ReadAll(part)
|
|
if readErr != nil {
|
|
t.Errorf("ReadAll(%q) error = %v", name, readErr)
|
|
return
|
|
}
|
|
switch name {
|
|
case "model":
|
|
if string(data) != "provider-image" {
|
|
t.Errorf("model = %q", data)
|
|
}
|
|
case "image[]":
|
|
if !bytes.Equal(data, imageBytes) {
|
|
t.Errorf("image data = %x", data)
|
|
}
|
|
case "api_key", "provider", "fallbacks":
|
|
t.Errorf("control part %q reached upstream", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"prompt", "model", "stream", "image[]", "future"} {
|
|
if !seen[name] {
|
|
t.Errorf("part %q is missing", name)
|
|
}
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = io.WriteString(writer, `{"created":1,"data":[{"b64_json":"b2s="}]}`)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager, _ := newNewAPIManagerForTest(t, server.URL+"/team-a")
|
|
spec := openAIImagesSpec(
|
|
channel.NewAPI,
|
|
server.URL+"/team-a",
|
|
execution.OperationImagesEdit,
|
|
"/v1/images/edits",
|
|
contentType,
|
|
body,
|
|
)
|
|
result := manager.Execute(context.Background(), spec)
|
|
if err := result.Validate(); err != nil || result.Error != nil || result.StatusCode != http.StatusOK {
|
|
t.Fatalf("result = %+v, validation=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestNewAPIGeminiAcceptsOpenAIStyleErrorEnvelope(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const responseBody = `{"error":{"type":"rate_limit_error","message":"retry later"}}`
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != "/team-a/v1beta/models/upstream-model:generateContent" {
|
|
t.Errorf("path = %q", request.URL.Path)
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = io.WriteString(writer, responseBody)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager, resolved := newNewAPIManagerForTest(t, server.URL+"/team-a")
|
|
spec := freezeTestAttempt(execution.AttemptSpec{
|
|
RequestID: "newapi-gemini-error", AttemptID: "newapi-gemini-error-attempt", Sequence: 1,
|
|
ChannelID: string(channel.NewAPI), ClientProtocol: protocol.Gemini,
|
|
Operation: execution.OperationChatCompletion,
|
|
ClientModel: "client-model", UpstreamModel: "upstream-model",
|
|
Method: http.MethodPost, Path: "/v1beta/models/client-model:generateContent",
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`),
|
|
TargetConfig: resolved.TargetConfig,
|
|
Credential: execution.NewCredentialSnapshot(
|
|
17, 1, 1, []byte(`{"api_key":"`+testAPIKey+`"}`),
|
|
),
|
|
})
|
|
result := manager.Execute(context.Background(), spec)
|
|
if err := result.Validate(); err != nil || result.Error == nil ||
|
|
result.Error.Kind != execution.ErrorKindHTTP || result.StatusCode != http.StatusTooManyRequests ||
|
|
result.UpstreamProtocol != protocol.Gemini || !bytes.Equal(result.Body, []byte(responseBody)) {
|
|
t.Fatalf("result = %+v, validation=%v, body=%s", result, err, result.Body)
|
|
}
|
|
}
|
|
|
|
func TestResolveMultiProtocolGatewayTargetURLPreservesEncodedDeploymentPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
got, err := resolveMultiProtocolGatewayTargetURL(
|
|
"https://relay.example/team%2Fone/",
|
|
"/v1/models",
|
|
"cursor=next%2Fpage",
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const want = "https://relay.example/team%2Fone/v1/models?cursor=next%2Fpage"
|
|
if got != want {
|
|
t.Fatalf("target URL = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNewAPIUtilityOperationsUseGatewayRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var paths []string
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
paths = append(paths, request.URL.Path)
|
|
if request.Header.Get("Authorization") != "Bearer "+testAPIKey {
|
|
t.Errorf("Authorization = %q", request.Header.Get("Authorization"))
|
|
}
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
switch request.URL.Path {
|
|
case "/team-a/v1/models":
|
|
_, _ = io.WriteString(writer, `{"success":true,"object":"list","data":[{"id":"model-one"}]}`)
|
|
case "/team-a/v1/chat/completions":
|
|
_, _ = io.WriteString(writer, `{"id":"chat_1","object":"chat.completion","created":1,"model":"probe-upstream","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}]}`)
|
|
case "/team-a/v1/embeddings":
|
|
_, _ = io.WriteString(writer, `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1]}],"model":"probe-upstream","usage":{"prompt_tokens":1,"total_tokens":1}}`)
|
|
default:
|
|
writer.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager, resolved := newNewAPIManagerForTest(t, server.URL+"/team-a")
|
|
models := utilitySpec(channel.NewAPI, protocol.OpenAICompletions, execution.OperationListModels, http.MethodGet, "/v1/models", nil)
|
|
models.TargetConfig = resolved.TargetConfig
|
|
models = freezeTestAttempt(models)
|
|
modelsResult := manager.Execute(context.Background(), models)
|
|
if err := modelsResult.Validate(); err != nil || modelsResult.Error != nil ||
|
|
!bytes.Contains(modelsResult.Body, []byte(`"id":"model-one"`)) {
|
|
t.Fatalf("models result = %+v err=%v body=%s", modelsResult, err, modelsResult.Body)
|
|
}
|
|
|
|
chatProbe := utilitySpec(channel.NewAPI, protocol.OpenAICompletions, execution.OperationProbe, "", "", nil)
|
|
chatProbe.TargetConfig = resolved.TargetConfig
|
|
chatProbe.ClientModel, chatProbe.UpstreamModel = "probe-client", "probe-upstream"
|
|
chatProbe = freezeTestAttempt(chatProbe)
|
|
chatResult := manager.Execute(context.Background(), chatProbe)
|
|
if err := chatResult.Validate(); err != nil || chatResult.Error != nil {
|
|
t.Fatalf("chat probe = %+v err=%v", chatResult, err)
|
|
}
|
|
|
|
embeddingProbe := utilitySpec(channel.NewAPI, protocol.OpenAIEmbeddings, execution.OperationProbe, "", "", nil)
|
|
embeddingProbe.TargetConfig = resolved.TargetConfig
|
|
embeddingProbe.ClientModel, embeddingProbe.UpstreamModel = "probe-client", "probe-upstream"
|
|
embeddingProbe = freezeTestAttempt(embeddingProbe)
|
|
embeddingResult := manager.Execute(context.Background(), embeddingProbe)
|
|
if err := embeddingResult.Validate(); err != nil || embeddingResult.Error != nil {
|
|
t.Fatalf("embedding probe = %+v err=%v", embeddingResult, err)
|
|
}
|
|
|
|
wantPaths := []string{
|
|
"/team-a/v1/models",
|
|
"/team-a/v1/chat/completions",
|
|
"/team-a/v1/embeddings",
|
|
}
|
|
if !reflect.DeepEqual(paths, wantPaths) {
|
|
t.Fatalf("utility paths = %#v, want %#v", paths, wantPaths)
|
|
}
|
|
}
|
|
|
|
func TestNewAPIResponsesResourceSemanticsFailBeforeDispatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var calls int
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
calls++
|
|
writer.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager, resolved := newNewAPIManagerForTest(t, server.URL+"/team-a")
|
|
spec := freezeTestAttempt(execution.AttemptSpec{
|
|
RequestID: "responses-resource", AttemptID: "responses-resource-attempt", Sequence: 1,
|
|
ChannelID: string(channel.NewAPI), ClientProtocol: protocol.OpenAIResponses,
|
|
Operation: execution.OperationResponsesCreate, RouteRequirement: execution.RouteRequirementNative,
|
|
ClientModel: "client-model", UpstreamModel: "upstream-model",
|
|
Method: http.MethodPost, Path: "/v1/responses",
|
|
Header: http.Header{"Content-Type": {"application/json"}},
|
|
Body: []byte(`{"model":"client-model","input":"hello"}`),
|
|
TargetConfig: resolved.TargetConfig,
|
|
Credential: execution.NewCredentialSnapshot(
|
|
17, 1, 1, []byte(`{"api_key":"`+testAPIKey+`"}`),
|
|
),
|
|
})
|
|
result := manager.Execute(context.Background(), spec)
|
|
if err := result.Validate(); err != nil || result.DispatchState != execution.DispatchNotSent || result.Error == nil {
|
|
t.Fatalf("resource result = %+v err=%v", result, err)
|
|
}
|
|
if calls != 0 {
|
|
t.Fatalf("upstream calls = %d, want 0", calls)
|
|
}
|
|
}
|
|
|
|
func TestNewAPINativeStreamsKeepProtocolWireAndUsage(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const openAIStream = "data: {\"id\":\"chat_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"stream-model\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n" +
|
|
"data: {\"id\":\"chat_1\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"stream-model\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":2,\"total_tokens\":7}}\n\n" +
|
|
"data: [DONE]\n\n"
|
|
tests := []struct {
|
|
name string
|
|
clientProtocol protocol.Protocol
|
|
path string
|
|
body string
|
|
wantPath string
|
|
credentialHeader string
|
|
stream string
|
|
}{
|
|
{
|
|
name: "completions", clientProtocol: protocol.OpenAICompletions,
|
|
path: "/v1/chat/completions", wantPath: "/team-a/v1/chat/completions",
|
|
body: `{"model":"stream-model","stream":true,"messages":[{"role":"user","content":"hello"}]}`,
|
|
credentialHeader: "Authorization", stream: openAIStream,
|
|
},
|
|
{
|
|
name: "anthropic", clientProtocol: protocol.Anthropic,
|
|
path: "/v1/messages", wantPath: "/team-a/v1/messages",
|
|
body: `{"model":"stream-model","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`,
|
|
credentialHeader: "X-Api-Key", stream: anthropicResponsesStreamFixture,
|
|
},
|
|
{
|
|
name: "gemini", clientProtocol: protocol.Gemini,
|
|
path: "/v1beta/models/stream-model:streamGenerateContent",
|
|
wantPath: "/team-a/v1beta/models/stream-model:streamGenerateContent",
|
|
body: `{"contents":[{"role":"user","parts":[{"text":"hello"}]}]}`,
|
|
credentialHeader: "X-Goog-Api-Key", stream: geminiResponsesStreamFixture,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
if request.URL.Path != test.wantPath || request.URL.Query().Get("trace") != "kept" ||
|
|
request.URL.Query().Get("key") != "" {
|
|
t.Errorf("stream target = %s?%s", request.URL.Path, request.URL.RawQuery)
|
|
}
|
|
if test.clientProtocol == protocol.Gemini && request.URL.Query().Get("alt") != "sse" {
|
|
t.Errorf("Gemini alt = %q", request.URL.Query().Get("alt"))
|
|
}
|
|
wantCredential := testAPIKey
|
|
if test.credentialHeader == "Authorization" {
|
|
wantCredential = "Bearer " + testAPIKey
|
|
}
|
|
if request.Header.Get(test.credentialHeader) != wantCredential {
|
|
t.Errorf("credential %s = %q", test.credentialHeader, request.Header.Get(test.credentialHeader))
|
|
}
|
|
writer.Header().Set("Content-Type", "text/event-stream")
|
|
writer.WriteHeader(http.StatusOK)
|
|
_, _ = io.WriteString(writer, test.stream)
|
|
writer.(http.Flusher).Flush()
|
|
}))
|
|
defer server.Close()
|
|
|
|
manager, resolved := newNewAPIManagerForTest(t, server.URL+"/team-a")
|
|
spec := freezeTestAttempt(execution.AttemptSpec{
|
|
RequestID: "newapi-stream", AttemptID: "newapi-stream-attempt", Sequence: 1,
|
|
ChannelID: string(channel.NewAPI), ClientProtocol: test.clientProtocol,
|
|
Operation: execution.OperationChatCompletion,
|
|
ClientModel: "stream-model", UpstreamModel: "stream-model",
|
|
Method: http.MethodPost, Path: test.path,
|
|
RawQuery: "trace=kept&key=client-query&alt=client",
|
|
Header: http.Header{
|
|
"Authorization": {"Bearer client-auth"},
|
|
"X-Api-Key": {"client-auth"},
|
|
"X-Goog-Api-Key": {"client-auth"},
|
|
"Content-Type": {"application/json"},
|
|
},
|
|
Body: []byte(test.body), TargetConfig: resolved.TargetConfig,
|
|
Credential: execution.NewCredentialSnapshot(
|
|
17, 1, 1, []byte(`{"api_key":"`+testAPIKey+`"}`),
|
|
),
|
|
})
|
|
var data bytes.Buffer
|
|
result := manager.ExecuteStream(context.Background(), spec, func(event execution.StreamEvent) error {
|
|
if event.Kind == execution.StreamEventData {
|
|
data.Write(event.Data)
|
|
}
|
|
return nil
|
|
})
|
|
if err := result.Validate(); err != nil || result.Error != nil || result.Usage == nil ||
|
|
result.UpstreamProtocol != test.clientProtocol || data.String() != test.stream {
|
|
t.Fatalf("stream result = %+v err=%v data=%q", result, err, data.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func newNewAPIManagerForTest(t *testing.T, baseURL string) (*RuntimeManager, channel.ResolvedTarget) {
|
|
t.Helper()
|
|
|
|
registry := channel.NewRegistry()
|
|
resolved, err := registry.Resolve(
|
|
channel.NewAPI,
|
|
json.RawMessage(`{"base_url":"`+baseURL+`"}`),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager, err := newRuntimeManager(runtimeOptions{allowPrivateNetwork: true}, registry)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Start(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(manager.Shutdown)
|
|
if err := manager.Reconcile([]provideradapter.RuntimeTarget{{Target: resolved}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return manager, resolved
|
|
}
|
|
|
|
func TestNewAPIRuntimeProfilesUseOneGatewayRoot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := channel.NewRegistry()
|
|
resolved, err := registry.Resolve(
|
|
channel.NewAPI,
|
|
json.RawMessage(`{"base_url":"https://relay.example/team-a"}`),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
protocol protocol.Protocol
|
|
operation execution.Operation
|
|
wantProvider schemas.ModelProvider
|
|
}{
|
|
{name: "completions", protocol: protocol.OpenAICompletions, operation: execution.OperationChatCompletion, wantProvider: schemas.OpenAI},
|
|
{name: "responses", protocol: protocol.OpenAIResponses, operation: execution.OperationResponsesCreate, wantProvider: schemas.OpenAI},
|
|
{name: "images", protocol: protocol.OpenAIImages, operation: execution.OperationImagesGenerate, wantProvider: schemas.OpenAI},
|
|
{name: "embeddings", protocol: protocol.OpenAIEmbeddings, operation: execution.OperationEmbeddingsCreate, wantProvider: schemas.OpenAI},
|
|
{name: "anthropic", protocol: protocol.Anthropic, operation: execution.OperationChatCompletion, wantProvider: schemas.Anthropic},
|
|
{name: "gemini", protocol: protocol.Gemini, operation: execution.OperationChatCompletion, wantProvider: schemas.Gemini},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
config, err := buildEffectiveProviderConfigForAttempt(
|
|
resolved,
|
|
execution.AttemptSpec{
|
|
ClientProtocol: test.protocol,
|
|
Operation: test.operation,
|
|
RouteMode: execution.RouteNative,
|
|
},
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("buildEffectiveProviderConfigForAttempt() error = %v", err)
|
|
}
|
|
if config.provider != test.wantProvider || config.custom ||
|
|
config.targetBaseURL != "https://relay.example/team-a" ||
|
|
config.providerConfig.NetworkConfig.BaseURL != "https://relay.example/team-a" {
|
|
t.Fatalf("New API config = provider %q custom %t target %q network %q", config.provider, config.custom, config.targetBaseURL, config.providerConfig.NetworkConfig.BaseURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewAPIReconcileKeepsEveryRuntimeProfileActive(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := channel.NewRegistry()
|
|
resolved, err := registry.Resolve(
|
|
channel.NewAPI,
|
|
json.RawMessage(`{"base_url":"https://relay.example/team-a"}`),
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
manager, err := newRuntimeManager(runtimeOptions{allowPrivateNetwork: true}, registry)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Reconcile([]provideradapter.RuntimeTarget{{Target: resolved}}); err != nil {
|
|
t.Fatalf("Reconcile() error = %v", err)
|
|
}
|
|
|
|
expected := make([]effectiveProviderConfig, 0, 3)
|
|
for _, clientProtocol := range []protocol.Protocol{
|
|
protocol.OpenAICompletions,
|
|
protocol.Anthropic,
|
|
protocol.Gemini,
|
|
} {
|
|
config, err := buildEffectiveProviderConfigForAttempt(
|
|
resolved,
|
|
execution.AttemptSpec{ClientProtocol: clientProtocol, RouteMode: execution.RouteNative},
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expected = append(expected, config)
|
|
}
|
|
|
|
assertActive := func(want []effectiveProviderConfig) {
|
|
t.Helper()
|
|
manager.pool.mu.Lock()
|
|
defer manager.pool.mu.Unlock()
|
|
if len(manager.pool.active) != len(want) {
|
|
t.Fatalf("active runtime configs = %d, want %d", len(manager.pool.active), len(want))
|
|
}
|
|
for _, config := range want {
|
|
active, ok := manager.pool.active[config.fingerprint]
|
|
if !ok || !bytes.Equal(active, config.canonical) {
|
|
t.Errorf("profile %q is not active", config.provider)
|
|
}
|
|
}
|
|
}
|
|
assertActive(expected)
|
|
|
|
effectiveProxy := outboundproxy.Effective{
|
|
Config: outboundproxy.Config{Mode: outboundproxy.ModeCustom, URL: "http://proxy.example.com:8080"},
|
|
Source: outboundproxy.SourceGroup,
|
|
}
|
|
if err := manager.Reconcile([]provideradapter.RuntimeTarget{{
|
|
Target: resolved,
|
|
Proxy: effectiveProxy,
|
|
}}); err != nil {
|
|
t.Fatalf("Reconcile(proxy) error = %v", err)
|
|
}
|
|
expectedWithProxy := append([]effectiveProviderConfig(nil), expected...)
|
|
for _, clientProtocol := range []protocol.Protocol{
|
|
protocol.OpenAICompletions,
|
|
protocol.Anthropic,
|
|
protocol.Gemini,
|
|
} {
|
|
config, err := buildEffectiveProviderConfigForAttempt(
|
|
resolved,
|
|
execution.AttemptSpec{
|
|
ClientProtocol: clientProtocol,
|
|
RouteMode: execution.RouteNative,
|
|
Proxy: effectiveProxy,
|
|
},
|
|
true,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
expectedWithProxy = append(expectedWithProxy, config)
|
|
}
|
|
assertActive(expectedWithProxy)
|
|
}
|
|
|
|
func TestNewAPIRouteCapabilityMatchesDeclaredOperations(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
registry := channel.NewRegistry()
|
|
manager, err := newRuntimeManager(runtimeOptions{}, registry)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
descriptor, ok := registry.Get(channel.NewAPI)
|
|
if !ok {
|
|
t.Fatal("New API channel is missing")
|
|
}
|
|
for _, route := range descriptor.Routes {
|
|
if err := manager.ValidateRouteCapability(channel.ProviderMultiProtocolGateway, route); err != nil {
|
|
t.Errorf("route %s/%s/%s rejected: %v", route.ClientProtocol, route.Operation, route.RouteMode, err)
|
|
}
|
|
}
|
|
for _, unsupported := range []channel.RouteDescriptor{
|
|
{ClientProtocol: protocol.OpenAIImages, Operation: execution.OperationProbe, RouteMode: execution.RouteNative},
|
|
} {
|
|
if err := manager.ValidateRouteCapability(channel.ProviderMultiProtocolGateway, unsupported); err == nil {
|
|
t.Errorf("unsupported route accepted: %#v", unsupported)
|
|
}
|
|
}
|
|
}
|