- agent-readable-repository-refactor 완료: 기존 테스트 파일 아카이브 이동 - 새 테스트 파일 추가 (edge, node, client, config, readability) - readability_audit 스크립트 및 baseline 추가 - roadmap/SDD 문서 갱신 - agent-client/pi/extensions/openai-sampling-parameters 추가
879 lines
35 KiB
Go
879 lines
35 KiB
Go
package openai
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
edgeservice "iop/apps/edge/internal/service"
|
|
"iop/packages/go/config"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// chatSidebandServer returns a direct OpenAI-compatible provider route backed by
|
|
// the given tunnel frames. It routes via the legacy openai_compat model route
|
|
// (not provider-pool) so the raw provider tunnel passthrough path is exercised.
|
|
func chatSidebandServer(frames chan *iop.ProviderTunnelFrame) (*Server, *providerFakeRunService) {
|
|
fake := &providerFakeRunService{
|
|
tunnelFrames: frames,
|
|
}
|
|
srv := chatProviderRouteServer(fake, nil)
|
|
return srv, fake
|
|
}
|
|
|
|
func chatProviderRouteServer(fake *providerFakeRunService, logger *zap.Logger) *Server {
|
|
return NewServer(config.EdgeOpenAIConf{
|
|
ModelRoutes: []config.OpenAIRouteEntry{
|
|
{Model: "pool-model", Adapter: "openai_compat", Target: "served-model"},
|
|
},
|
|
}, fake, logger)
|
|
}
|
|
|
|
func TestChatCompletionsPassthroughDoesNotExposeSideband(t *testing.T) {
|
|
providerBody := "data: {\"choices\":[{\"delta\":{\"content\":\"pure\"}}]}\n\ndata: [DONE]\n\n"
|
|
frames := make(chan *iop.ProviderTunnelFrame, 4)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: 200,
|
|
Headers: map[string]string{"Content-Type": "text/event-stream"},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)}
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_USAGE,
|
|
Usage: &iop.Usage{InputTokens: 3, OutputTokens: 5},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
srv, _ := chatSidebandServer(frames)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
// Byte-identity with the provider body proves no IOP extension markers or
|
|
// response-mode labels are injected into the pure passthrough response.
|
|
if got := w.Body.String(); got != providerBody {
|
|
t.Fatalf("pure passthrough body not byte-identical:\n got: %q\nwant: %q", got, providerBody)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassthroughDoesNotLabelTransformed(t *testing.T) {
|
|
providerBody := `{"ok":true}`
|
|
frames := make(chan *iop.ProviderTunnelFrame, 3)
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerBody)}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
srv, _ := chatSidebandServer(frames)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
// Byte-identity with the provider body proves the pure passthrough response
|
|
// carries no IOP response-mode label.
|
|
if got := w.Body.String(); got != providerBody {
|
|
t.Fatalf("pure passthrough body not byte-identical: %q", got)
|
|
}
|
|
}
|
|
|
|
// chatPassthroughServer returns an openai.Server whose fake service relays the
|
|
// tunnel to the given provider fixture, emulating the Node relay.
|
|
func chatPassthroughServer(providerURL string) (*Server, *providerFakeRunService) {
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
tunnelProviderURL: providerURL,
|
|
tunnelServedTarget: "served-model",
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
|
})
|
|
return srv, fake
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughNonStreamingByteIdentity verifies SDD S04 for
|
|
// the non-streaming path: when the provider omits a top-level model echo, the
|
|
// caller receives the provider JSON body byte-identically, along with provider
|
|
// status and headers.
|
|
func TestChatCompletionsPassthroughNonStreamingByteIdentity(t *testing.T) {
|
|
// Provider-original body with fields the normalized path would rewrite or
|
|
// drop (reasoning_content, provider-specific key order and spacing).
|
|
providerBody := "{\"id\":\"cmpl-provider-1\",\"object\":\"chat.completion\", \"choices\":[{\"index\":0,\"message\":{\"role\":\"assistant\",\"content\":\"hi\",\"reasoning_content\":\"because\"},\"finish_reason\":\"stop\"}],\"provider_extra\":{\"a\":1}}"
|
|
var gotProviderReq []byte
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotProviderReq, _ = io.ReadAll(r.Body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("X-Provider-Marker", "prov-42")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(providerBody))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
srv, fake := chatPassthroughServer(provider.URL)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != providerBody {
|
|
t.Fatalf("body not byte-identical to provider:\n got: %q\nwant: %q", got, providerBody)
|
|
}
|
|
if got := w.Header().Get("X-Provider-Marker"); got != "prov-42" {
|
|
t.Errorf("provider header not relayed: %q", got)
|
|
}
|
|
if got := w.Header().Get("Content-Type"); got != "application/json" {
|
|
t.Errorf("content type not relayed: %q", got)
|
|
}
|
|
// The provider request body carries the served model (model rewrite only).
|
|
if !strings.Contains(string(gotProviderReq), `"model":"served-model"`) {
|
|
t.Errorf("provider request body missing served model: %s", gotProviderReq)
|
|
}
|
|
if !strings.Contains(string(gotProviderReq), `"content":"hello"`) {
|
|
t.Errorf("provider request body lost caller messages: %s", gotProviderReq)
|
|
}
|
|
if len(fake.reqsSnapshot()) != 0 {
|
|
t.Error("normalized SubmitRun must not be called on passthrough")
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassthroughNonStreamingRewritesModelEcho(t *testing.T) {
|
|
providerBody := `{"id":"cmpl-provider-1","object":"chat.completion","created":123,"model":"served-model","choices":[{"index":0,"message":{"role":"assistant","content":"hi","reasoning_content":"because"},"finish_reason":"stop"}],"provider_extra":{"a":1}}`
|
|
var gotProviderReq []byte
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotProviderReq, _ = io.ReadAll(r.Body)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(providerBody))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
srv, _ := chatPassthroughServer(provider.URL)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Model string `json:"model"`
|
|
Choices []struct {
|
|
Message struct {
|
|
Content string `json:"content"`
|
|
ReasoningContent string `json:"reasoning_content"`
|
|
} `json:"message"`
|
|
} `json:"choices"`
|
|
ProviderExtra map[string]int `json:"provider_extra"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode response: %v body=%s", err, w.Body.String())
|
|
}
|
|
if resp.Model != "pool-model" {
|
|
t.Fatalf("response model: got %q, want pool-model; body=%s", resp.Model, w.Body.String())
|
|
}
|
|
if resp.Choices[0].Message.Content != "hi" || resp.Choices[0].Message.ReasoningContent != "because" {
|
|
t.Fatalf("provider content/reasoning not preserved: %+v", resp.Choices[0].Message)
|
|
}
|
|
if resp.ProviderExtra["a"] != 1 {
|
|
t.Fatalf("provider_extra not preserved: %+v", resp.ProviderExtra)
|
|
}
|
|
if !strings.Contains(string(gotProviderReq), `"model":"served-model"`) {
|
|
t.Fatalf("provider request body missing served model: %s", gotProviderReq)
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughStreamingByteIdentity verifies SDD S04 for the
|
|
// streaming path: provider SSE bytes without top-level model echoes, including
|
|
// provider-specific fields like reasoning_content and native tool_calls chunks,
|
|
// reach the caller byte-identically.
|
|
func TestChatCompletionsPassthroughStreamingByteIdentity(t *testing.T) {
|
|
providerBody := "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" +
|
|
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"thinking...\"}}]}\n\n" +
|
|
"data: {\"choices\":[{\"delta\":{\"content\":\"<think>not rewritten</think>\"}}]}\n\n" +
|
|
"data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"name\":\"f\",\"arguments\":\"{}\"}}]}}]}\n\n" +
|
|
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":3}}\n\n" +
|
|
"data: [DONE]\n\n"
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(providerBody))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
srv, _ := chatPassthroughServer(provider.URL)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if got := w.Body.String(); got != providerBody {
|
|
t.Fatalf("SSE stream not byte-identical to provider:\n got: %q\nwant: %q", got, providerBody)
|
|
}
|
|
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
|
|
t.Errorf("content type not relayed: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassthroughStreamingRewritesModelEcho(t *testing.T) {
|
|
providerBody := "data: {\"id\":\"chunk-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"served-model\",\"choices\":[{\"delta\":{\"role\":\"assistant\"}}]}\n\n" +
|
|
"data: {\"id\":\"chunk-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"served-model\",\"choices\":[{\"delta\":{\"reasoning\":\"thinking...\"}}]}\n\n" +
|
|
"data: {\"id\":\"chunk-1\",\"object\":\"chat.completion.chunk\",\"created\":123,\"model\":\"served-model\",\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n" +
|
|
"data: [DONE]\n\n"
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(providerBody))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
srv, _ := chatPassthroughServer(provider.URL)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
body := w.Body.String()
|
|
if strings.Contains(body, `"model":"served-model"`) {
|
|
t.Fatalf("response leaked provider-served model: %s", body)
|
|
}
|
|
if strings.Count(body, `"model":"pool-model"`) != 3 {
|
|
t.Fatalf("response model aliases not rewritten in every chunk: %s", body)
|
|
}
|
|
if !strings.Contains(body, `"reasoning":"thinking..."`) || !strings.Contains(body, `"content":"hi"`) {
|
|
t.Fatalf("provider reasoning/content not preserved: %s", body)
|
|
}
|
|
if !strings.Contains(body, "data: [DONE]") {
|
|
t.Fatalf("done marker not preserved: %s", body)
|
|
}
|
|
if got := w.Header().Get("Content-Type"); got != "text/event-stream" {
|
|
t.Errorf("content type not relayed: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughProviderErrorStatusRelayed verifies a provider
|
|
// error status/body is passed to the caller unmodified instead of being
|
|
// converted into an IOP error envelope.
|
|
func TestChatCompletionsPassthroughProviderErrorStatusRelayed(t *testing.T) {
|
|
providerBody := `{"error":{"message":"rate limited by provider","type":"rate_limit"}}`
|
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
_, _ = w.Write([]byte(providerBody))
|
|
}))
|
|
defer provider.Close()
|
|
|
|
srv, _ := chatPassthroughServer(provider.URL)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("provider status not relayed: got %d", w.Code)
|
|
}
|
|
if got := w.Body.String(); got != providerBody {
|
|
t.Fatalf("provider error body not byte-identical: %q", got)
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughLegacyProviderRouteUsesTunnel verifies that a
|
|
// legacy model_routes entry with an openai_compat adapter also defaults to the
|
|
// tunnel passthrough path (direct route, no provider pool).
|
|
func TestChatCompletionsPassthroughLegacyProviderRouteUsesTunnel(t *testing.T) {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 3)
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START, StatusCode: 200}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(`{"ok":true}`)}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
fake := &providerFakeRunService{
|
|
tunnelFrames: frames,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{
|
|
ModelRoutes: []config.OpenAIRouteEntry{
|
|
{Model: "compat-model", Adapter: "openai_compat", Target: "backend-model"},
|
|
},
|
|
}, fake, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"compat-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
tunnelReqs := fake.tunnelReqsSnapshot()
|
|
if len(tunnelReqs) != 1 {
|
|
t.Fatalf("expected tunnel dispatch for openai_compat route, got %d", len(tunnelReqs))
|
|
}
|
|
if tunnelReqs[0].Adapter != "openai_compat" || tunnelReqs[0].Target != "backend-model" {
|
|
t.Errorf("tunnel adapter/target: got %q/%q", tunnelReqs[0].Adapter, tunnelReqs[0].Target)
|
|
}
|
|
if tunnelReqs[0].ProviderPool {
|
|
t.Error("legacy route must not set ProviderPool")
|
|
}
|
|
bodies := fake.tunnelBodiesSnapshot()
|
|
if len(bodies) != 1 || !strings.Contains(string(bodies[0]), `"model":"backend-model"`) {
|
|
t.Errorf("legacy route body must carry route target: %s", bodies)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsPassthroughNonProviderRouteKeepsNormalizedPath(t *testing.T) {
|
|
fake := &providerFakeRunService{
|
|
fakeRunService: fakeRunService{events: bufferedRunEvents(
|
|
&iop.RunEvent{Type: "delta", Delta: "ok"},
|
|
&iop.RunEvent{Type: "complete"},
|
|
)},
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{Adapter: "ollama", Target: "llama-fixed"}, fake, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"any-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if len(fake.tunnelReqsSnapshot()) != 0 {
|
|
t.Error("ollama route must not use the tunnel")
|
|
}
|
|
if len(fake.reqsSnapshot()) != 1 {
|
|
t.Fatalf("expected normalized SubmitRun dispatch, got %d", len(fake.reqsSnapshot()))
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughTunnelErrorBeforeStartReturns502 verifies an
|
|
// ERROR frame before response-start becomes a JSON error response.
|
|
func TestChatCompletionsPassthroughTunnelErrorBeforeStartReturns502(t *testing.T) {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 1)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR,
|
|
Error: "provider connect refused",
|
|
}
|
|
close(frames)
|
|
fake := &providerFakeRunService{
|
|
tunnelFrames: frames,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "provider connect refused") {
|
|
t.Fatalf("error message not surfaced: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughContextCancelSendsCancelRun verifies caller
|
|
// disconnect propagates to the Node cancel path for tunnel dispatches.
|
|
func TestChatCompletionsPassthroughContextCancelSendsCancelRun(t *testing.T) {
|
|
// Frames channel stays open and empty: the tunnel is in-flight when the
|
|
// caller context is cancelled.
|
|
fake := &providerFakeRunService{
|
|
tunnelFrames: make(chan *iop.ProviderTunnelFrame),
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
|
})
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`)).WithContext(ctx)
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
calls := fake.cancelCallsSnapshot()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected 1 CancelRun call, got %d: %+v", len(calls), calls)
|
|
}
|
|
if calls[0].RunID != "run-tunnel" || calls[0].NodeRef != "node-1" {
|
|
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
|
}
|
|
}
|
|
|
|
// flakyResponseWriter is a tunnel-path ResponseWriter double: it accepts
|
|
// failAfterWrites successful Write calls, then fails every subsequent Write,
|
|
// optionally sleeping writeDelay per call to emulate a slow client.
|
|
type flakyResponseWriter struct {
|
|
mu sync.Mutex
|
|
header http.Header
|
|
status int
|
|
body bytes.Buffer
|
|
failAfterWrites int // -1: never fail
|
|
writeDelay time.Duration
|
|
writes int
|
|
failedWrites int
|
|
}
|
|
|
|
func newFlakyResponseWriter(failAfterWrites int) *flakyResponseWriter {
|
|
return &flakyResponseWriter{header: make(http.Header), failAfterWrites: failAfterWrites}
|
|
}
|
|
|
|
func (w *flakyResponseWriter) Header() http.Header { return w.header }
|
|
|
|
func (w *flakyResponseWriter) WriteHeader(code int) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.status == 0 {
|
|
w.status = code
|
|
}
|
|
}
|
|
|
|
func (w *flakyResponseWriter) Write(p []byte) (int, error) {
|
|
if w.writeDelay > 0 {
|
|
time.Sleep(w.writeDelay)
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
if w.status == 0 {
|
|
w.status = http.StatusOK
|
|
}
|
|
if w.failAfterWrites >= 0 && w.writes >= w.failAfterWrites {
|
|
w.failedWrites++
|
|
return 0, errors.New("client connection write failed")
|
|
}
|
|
w.writes++
|
|
w.body.Write(p)
|
|
return len(p), nil
|
|
}
|
|
|
|
func (w *flakyResponseWriter) Flush() {}
|
|
|
|
func (w *flakyResponseWriter) snapshot() (status int, body string, failedWrites int) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return w.status, w.body.String(), w.failedWrites
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughWriteFailureSendsCancelRunOnce verifies S09
|
|
// for a caller write failure after response start: exactly one CancelRun is
|
|
// propagated to the Node, the ordered prefix written before the failure is
|
|
// preserved, and no tunnel bytes are written after the failed write even
|
|
// though more frames (including END) are available.
|
|
func TestChatCompletionsPassthroughWriteFailureSendsCancelRunOnce(t *testing.T) {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 5)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: 200,
|
|
Headers: map[string]string{"Content-Type": "text/event-stream"},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte("data: chunk-1\n\n")}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte("data: chunk-2\n\n")}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte("data: chunk-3\n\n")}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
srv, fake := chatSidebandServer(frames)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := newFlakyResponseWriter(1) // first body write succeeds, second fails
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
status, body, failedWrites := w.snapshot()
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", status, body)
|
|
}
|
|
if body != "data: chunk-1\n\n" {
|
|
t.Fatalf("body after write failure must stay the ordered prefix:\n got: %q\nwant: %q", body, "data: chunk-1\n\n")
|
|
}
|
|
if failedWrites == 0 {
|
|
t.Fatal("fixture did not exercise the write failure path")
|
|
}
|
|
calls := fake.cancelCallsSnapshot()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 CancelRun call on write failure, got %d: %+v", len(calls), calls)
|
|
}
|
|
if calls[0].RunID != "run-tunnel" || calls[0].NodeRef != "node-1" {
|
|
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughWaitTimeoutSendsCancelRun verifies the tunnel
|
|
// wait timeout before response start returns a 502 and propagates exactly one
|
|
// CancelRun to the Node.
|
|
func TestChatCompletionsPassthroughWaitTimeoutSendsCancelRun(t *testing.T) {
|
|
fake := &providerFakeRunService{
|
|
tunnelFrames: make(chan *iop.ProviderTunnelFrame),
|
|
tunnelWaitTimeout: 50 * time.Millisecond,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusBadGateway {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if !strings.Contains(w.Body.String(), "run timed out") {
|
|
t.Fatalf("timeout error not surfaced: %s", w.Body.String())
|
|
}
|
|
calls := fake.cancelCallsSnapshot()
|
|
if len(calls) != 1 {
|
|
t.Fatalf("expected exactly 1 CancelRun call on wait timeout, got %d: %+v", len(calls), calls)
|
|
}
|
|
if calls[0].RunID != "run-tunnel" {
|
|
t.Fatalf("unexpected cancel request: %+v", calls[0])
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughProviderErrorAfterStartStopsBodyWrites
|
|
// verifies an ERROR frame after response start truncates the stream: bytes
|
|
// written before the error are preserved in order, later frames are never
|
|
// written, and no spurious CancelRun is sent for a provider-terminal failure.
|
|
func TestChatCompletionsPassthroughProviderErrorAfterStartStopsBodyWrites(t *testing.T) {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 4)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: 200,
|
|
Headers: map[string]string{"Content-Type": "text/event-stream"},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte("data: before-error\n\n")}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_ERROR, Error: "provider stream failed"}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte("data: after-error-must-not-write\n\n")}
|
|
close(frames)
|
|
srv, fake := chatSidebandServer(frames)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status already committed before the error must stay 200, got %d", w.Code)
|
|
}
|
|
if got := w.Body.String(); got != "data: before-error\n\n" {
|
|
t.Fatalf("provider error must truncate after the ordered prefix:\n got: %q\nwant: %q", got, "data: before-error\n\n")
|
|
}
|
|
if calls := fake.cancelCallsSnapshot(); len(calls) != 0 {
|
|
t.Fatalf("provider-terminal error must not trigger CancelRun, got %+v", calls)
|
|
}
|
|
}
|
|
|
|
// TestChatCompletionsPassthroughSlowClientByteIdentity verifies a slow caller
|
|
// applies backpressure without corrupting opaque relay data: every body frame
|
|
// reaches the caller exactly once, in order, and a slow-but-alive client never
|
|
// triggers CancelRun.
|
|
func TestChatCompletionsPassthroughSlowClientByteIdentity(t *testing.T) {
|
|
const chunkCount = 40
|
|
var want strings.Builder
|
|
frames := make(chan *iop.ProviderTunnelFrame, chunkCount+2)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: 200,
|
|
Headers: map[string]string{"Content-Type": "text/event-stream"},
|
|
}
|
|
for i := 0; i < chunkCount; i++ {
|
|
chunk := fmt.Sprintf("data: chunk-%02d\n\n", i)
|
|
want.WriteString(chunk)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
Sequence: int64(i + 1),
|
|
Body: []byte(chunk),
|
|
}
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
srv, fake := chatSidebandServer(frames)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"stream":true,
|
|
"messages":[{"role":"user","content":"hello"}]
|
|
}`))
|
|
w := newFlakyResponseWriter(-1)
|
|
w.writeDelay = 2 * time.Millisecond
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
status, body, _ := w.snapshot()
|
|
if status != http.StatusOK {
|
|
t.Fatalf("status: got %d", status)
|
|
}
|
|
if body != want.String() {
|
|
t.Fatalf("slow client stream not byte-identical/ordered:\n got: %q\nwant: %q", body, want.String())
|
|
}
|
|
if calls := fake.cancelCallsSnapshot(); len(calls) != 0 {
|
|
t.Fatalf("slow but alive client must not trigger CancelRun, got %+v", calls)
|
|
}
|
|
}
|
|
|
|
// responsesProviderTunnelServer builds a server whose catalog routes
|
|
// "pool-model" through the OpenAI-compatible provider tunnel, wired to the given
|
|
// provider frames and served target.
|
|
func responsesProviderTunnelServer(frames chan *iop.ProviderTunnelFrame, servedTarget string) (*Server, *providerFakeRunService) {
|
|
fake := &providerFakeRunService{
|
|
poolDispatchPath: string(edgeservice.ProviderPoolPathTunnel),
|
|
tunnelFrames: frames,
|
|
tunnelServedTarget: servedTarget,
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-1": "served-model"}},
|
|
})
|
|
return srv, fake
|
|
}
|
|
|
|
// TestResponsesProviderTunnelAllowsUnknownFields verifies that Codex/Responses
|
|
// unknown fields (tools, parallel_tool_calls, store) are accepted on the
|
|
// provider passthrough route and preserved in the forwarded tunnel body.
|
|
func TestResponsesProviderTunnelAllowsUnknownFields(t *testing.T) {
|
|
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"input":"hi",
|
|
"tools":[{"type":"web_search"}],
|
|
"parallel_tool_calls":true,
|
|
"store":false
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("provider passthrough must accept unknown fields: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
bodies := fake.tunnelBodiesSnapshot()
|
|
if len(bodies) != 1 {
|
|
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
|
|
}
|
|
var providerReq map[string]any
|
|
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
|
|
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
|
|
}
|
|
if _, ok := providerReq["tools"]; !ok {
|
|
t.Fatalf("tools must be preserved on passthrough: %+v", providerReq)
|
|
}
|
|
if providerReq["parallel_tool_calls"] != true {
|
|
t.Fatalf("parallel_tool_calls must be preserved: %+v", providerReq)
|
|
}
|
|
if providerReq["store"] != false {
|
|
t.Fatalf("store must be preserved: %+v", providerReq)
|
|
}
|
|
}
|
|
|
|
// TestResponsesProviderTunnelSubmitsResponsesPath verifies the passthrough
|
|
// dispatches a provider tunnel POST to /v1/responses and never calls SubmitRun.
|
|
func TestResponsesProviderTunnelSubmitsResponsesPath(t *testing.T) {
|
|
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi"}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if len(fake.reqsSnapshot()) != 0 {
|
|
t.Fatalf("responses passthrough must not call SubmitRun, got %d", len(fake.reqsSnapshot()))
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
|
|
}
|
|
if reqs[0].Path != "/v1/responses" {
|
|
t.Fatalf("tunnel path: got %q want /v1/responses", reqs[0].Path)
|
|
}
|
|
if reqs[0].Method != http.MethodPost {
|
|
t.Fatalf("tunnel method: got %q want POST", reqs[0].Method)
|
|
}
|
|
}
|
|
|
|
// TestResponsesProviderTunnelRewritesOnlyModel verifies the caller model alias
|
|
// is rewritten to the provider-served model while max_output_tokens, tools, and
|
|
// arbitrary fields are preserved verbatim.
|
|
func TestResponsesProviderTunnelRewritesOnlyModel(t *testing.T) {
|
|
srv, fake := responsesProviderTunnelServer(staticProviderTunnelFrames(`{"ok":true}`), "served-model")
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"input":"hi",
|
|
"max_output_tokens":123,
|
|
"tools":[{"type":"web_search"}],
|
|
"custom_field":"keep-me"
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
bodies := fake.tunnelBodiesSnapshot()
|
|
if len(bodies) != 1 {
|
|
t.Fatalf("expected 1 tunnel body, got %d", len(bodies))
|
|
}
|
|
var providerReq map[string]any
|
|
if err := json.Unmarshal(bodies[0], &providerReq); err != nil {
|
|
t.Fatalf("provider body JSON: %v body=%s", err, bodies[0])
|
|
}
|
|
if providerReq["model"] != "served-model" {
|
|
t.Fatalf("model must be rewritten to served target: %+v", providerReq["model"])
|
|
}
|
|
if providerReq["max_output_tokens"].(float64) != 123 {
|
|
t.Fatalf("max_output_tokens must be preserved: %+v", providerReq)
|
|
}
|
|
if _, ok := providerReq["tools"]; !ok {
|
|
t.Fatalf("tools must be preserved: %+v", providerReq)
|
|
}
|
|
if providerReq["custom_field"] != "keep-me" {
|
|
t.Fatalf("arbitrary fields must be preserved: %+v", providerReq)
|
|
}
|
|
}
|
|
|
|
// TestResponsesProviderTunnelStreaming verifies a stream:true Responses request
|
|
// sets Stream=true on the tunnel dispatch and relays raw SSE bytes verbatim.
|
|
func TestResponsesProviderTunnelStreaming(t *testing.T) {
|
|
frames := make(chan *iop.ProviderTunnelFrame, 4)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: 200,
|
|
Headers: map[string]string{"Content-Type": "text/event-stream"},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
Body: []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n"),
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY,
|
|
Body: []byte("data: [DONE]\n\n"),
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
|
|
srv, fake := responsesProviderTunnelServer(frames, "served-model")
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"pool-model","input":"hi","stream":true}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleResponses(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
reqs := fake.tunnelReqsSnapshot()
|
|
if len(reqs) != 1 {
|
|
t.Fatalf("expected 1 tunnel dispatch, got %d", len(reqs))
|
|
}
|
|
if !reqs[0].Stream {
|
|
t.Fatalf("tunnel request Stream must be true for a stream:true responses request")
|
|
}
|
|
body := w.Body.String()
|
|
if !strings.Contains(body, "response.output_text.delta") || !strings.Contains(body, "[DONE]") {
|
|
t.Fatalf("SSE body must be relayed verbatim, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionsProviderPoolTunnelRelaysProviderErrorBody(t *testing.T) {
|
|
providerError := `{"error":{"message":"unknown field custom_provider_options","type":"invalid_request_error","param":"custom_provider_options"}}`
|
|
frames := make(chan *iop.ProviderTunnelFrame, 3)
|
|
frames <- &iop.ProviderTunnelFrame{
|
|
Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_RESPONSE_START,
|
|
StatusCode: http.StatusUnprocessableEntity,
|
|
Headers: map[string]string{"Content-Type": "application/json"},
|
|
}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_BODY, Body: []byte(providerError)}
|
|
frames <- &iop.ProviderTunnelFrame{Kind: iop.ProviderTunnelFrameKind_PROVIDER_TUNNEL_FRAME_KIND_END, End: true}
|
|
close(frames)
|
|
|
|
fake := &providerFakeRunService{
|
|
tunnelFrames: frames,
|
|
tunnelServedTarget: "served-model",
|
|
}
|
|
srv := NewServer(config.EdgeOpenAIConf{}, fake, nil)
|
|
srv.SetModelCatalog([]config.ModelCatalogEntry{
|
|
{ID: "pool-model", Providers: map[string]string{"prov-vllm": "served-model"}},
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
|
|
"model":"pool-model",
|
|
"messages":[{"role":"user","content":"hello"}],
|
|
"custom_provider_options":{"reject":true}
|
|
}`))
|
|
w := httptest.NewRecorder()
|
|
srv.handleChatCompletions(w, req)
|
|
|
|
if w.Code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("status: got %d body=%s", w.Code, w.Body.String())
|
|
}
|
|
if w.Body.String() != providerError {
|
|
t.Fatalf("provider error body not relayed byte-identically:\n got: %q\nwant: %q", w.Body.String(), providerError)
|
|
}
|
|
if got := w.Header().Get("Content-Type"); !strings.Contains(got, "application/json") {
|
|
t.Fatalf("Content-Type header not relayed: %q", got)
|
|
}
|
|
if len(fake.reqsSnapshot()) != 0 {
|
|
t.Fatalf("provider error must not fall back to normalized SubmitRun, got %d calls", len(fake.reqsSnapshot()))
|
|
}
|
|
}
|