| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- //go:build unit
- package service
- import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- func buildResponsesFailedSSEStream(errType, errorMessage string) string {
- failed := fmt.Sprintf(`{"type":"response.failed","response":{"id":"resp_err","object":"response","status":"failed","error":{"type":"%s","message":"%s"},"output":[],"usage":{"input_tokens":10,"output_tokens":0,"total_tokens":10}}}`, errType, errorMessage)
- return fmt.Sprintf("data: %s\n\n", failed)
- }
- func TestForwardAsAnthropic_BufferedResponseFailed_ReturnsError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(ssePayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
- require.Error(t, err, "non-cyber response.failed must return an error, not swallow as 200")
- require.Contains(t, err.Error(), "upstream response failed")
- require.Equal(t, http.StatusBadGateway, rec.Code, "should write 502 for non-failover failed response")
- }
- func TestForwardAsAnthropic_StreamingResponseFailed_ReturnsError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- ssePayload := buildResponsesFailedSSEStream("invalid_request_error", "Content policy violation")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(ssePayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
- require.Error(t, err, "streaming response.failed must return an error")
- require.Contains(t, err.Error(), "upstream response failed")
- }
- func TestForwardAsAnthropic_StreamingBareErrorAfterOutputIsVisible(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- ssePayload := strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_bare_error","object":"response","model":"gpt-5.4","status":"in_progress","output":[]}}`,
- "",
- `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"partial"}`,
- "",
- `event: error`,
- `data: {"type":"error","error":{"type":"server_error","code":"upstream_error","message":"mixed tools failed"}}`,
- "",
- `data: [DONE]`,
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(ssePayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
- require.Error(t, err)
- require.Contains(t, err.Error(), "upstream response failed: mixed tools failed")
- clientStream := rec.Body.String()
- require.Contains(t, clientStream, `"text":"partial"`)
- require.Contains(t, clientStream, "event: error")
- require.Contains(t, clientStream, "mixed tools failed")
- require.NotContains(t, clientStream, "event: message_stop")
- require.NotContains(t, err.Error(), "missing terminal event")
- }
- func TestForwardAsAnthropic_StreamingBareErrorBeforeOutputFailsOver(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- ssePayload := strings.Join([]string{
- `event: error`,
- `data: {"type":"error","error":{"type":"server_error","code":"upstream_error","message":"temporary upstream failure"}}`,
- "",
- `data: [DONE]`,
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(ssePayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr), "pre-output retryable error must remain failover-safe: %T: %v", err, err)
- require.Empty(t, rec.Body.String(), "failover path must not commit downstream output")
- }
- func TestForwardAsAnthropic_StreamingGenericBareErrorBeforeOutputIsNotHiddenByFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- ssePayload := "event: error\n" +
- `data: {"type":"error","error":{"type":"server_error","code":"upstream_error","message":"mixed tools failed"}}` + "\n\n" +
- "data: [DONE]\n\n"
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(ssePayload)),
- }}
- svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
- _, err := svc.ForwardAsAnthropic(context.Background(), c, rawChatCompletionsTestAccount(), body, "", "")
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr))
- require.Contains(t, rec.Body.String(), "mixed tools failed")
- }
- func TestForwardAsAnthropic_BufferedResponseFailed_Failover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- ssePayload := buildResponsesFailedSSEStream("rate_limit_error", "Rate limit reached")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(ssePayload)),
- }}
- svc := &OpenAIGatewayService{
- cfg: rawChatCompletionsTestConfig(),
- httpUpstream: upstream,
- }
- account := rawChatCompletionsTestAccount()
- _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr), "rate_limit_error should trigger UpstreamFailoverError for failover, got: %T: %v", err, err)
- }
|