| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- //go:build unit
- package service
- import (
- "context"
- "errors"
- "io"
- "net/http"
- "strings"
- "testing"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- // TestOpenAIResponsesEmptyCompletedFailsOver verifies that a Responses stream
- // ending with an empty response.completed (no output, no usage, no error) is
- // turned into a failover error instead of a successful empty reply (issue
- // #5009).
- func TestOpenAIResponsesEmptyCompletedFailsOver(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(
- "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" +
- "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_empty\",\"object\":\"response\",\"status\":\"completed\"}}\n\n",
- )),
- }}
- svc := newOpenAIImageGenerationControlTestService(upstream)
- c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
- account := newOpenAIImageGenerationControlTestAccount()
- account.Extra = map[string]any{"openai_passthrough": true}
- body := []byte(`{
- "model":"gpt-5.6-sol",
- "stream":true,
- "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]
- }`)
- _, err := svc.Forward(context.Background(), c, account, body)
- require.Error(t, err)
- var failoverErr *UpstreamFailoverError
- require.True(t, errors.As(err, &failoverErr), "empty completed must produce UpstreamFailoverError, got: %v", err)
- require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
- require.Empty(t, recorder.Body.String(), "no empty success stream may reach the client")
- }
- // TestOpenAIResponsesEmptyCompletedWithOutputSucceeds ensures streams with real
- // semantic output are untouched.
- func TestOpenAIResponsesEmptyCompletedWithOutputSucceeds(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(
- "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_ok\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" +
- "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hello\"}\n\n" +
- "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":10,\"output_tokens\":5,\"total_tokens\":15}}}\n\n",
- )),
- }}
- svc := newOpenAIImageGenerationControlTestService(upstream)
- c, recorder := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
- account := newOpenAIImageGenerationControlTestAccount()
- account.Extra = map[string]any{"openai_passthrough": true}
- body := []byte(`{
- "model":"gpt-5.6-sol",
- "stream":true,
- "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]
- }`)
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Contains(t, recorder.Body.String(), "hello")
- require.NotNil(t, result.Usage)
- require.Equal(t, 10, result.Usage.InputTokens)
- require.Equal(t, 5, result.Usage.OutputTokens)
- }
- // TestOpenAIResponsesEmptyCompletedWithUsageSucceeds ensures a completed event
- // carrying usage is not mistaken for a silent refusal even without output.
- func TestOpenAIResponsesEmptyCompletedWithUsageSucceeds(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}},
- Body: io.NopCloser(strings.NewReader(
- "data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_usage\",\"object\":\"response\",\"status\":\"in_progress\"}}\n\n" +
- "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_usage\",\"object\":\"response\",\"status\":\"completed\",\"usage\":{\"input_tokens\":3,\"output_tokens\":0,\"total_tokens\":3}}}\n\n",
- )),
- }}
- svc := newOpenAIImageGenerationControlTestService(upstream)
- c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
- account := newOpenAIImageGenerationControlTestAccount()
- account.Extra = map[string]any{"openai_passthrough": true}
- body := []byte(`{
- "model":"gpt-5.6-sol",
- "stream":true,
- "input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}]
- }`)
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, result.Usage)
- require.Equal(t, 3, result.Usage.InputTokens)
- }
- func TestOpenAIResponsesCompletedEventIsEmpty(t *testing.T) {
- cases := []struct {
- name string
- data string
- usage *OpenAIUsage
- want bool
- }{
- {
- name: "bare completed",
- data: `{"type":"response.completed"}`,
- want: true,
- },
- {
- name: "completed with empty output array",
- data: `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[]}}`,
- want: true,
- },
- {
- name: "completed with usage",
- data: `{"type":"response.completed","response":{"id":"r1","status":"completed","usage":{"input_tokens":1,"output_tokens":1}}}`,
- want: false,
- },
- {
- name: "completed with error",
- data: `{"type":"response.completed","response":{"id":"r1","status":"completed","error":{"code":"x"}}}`,
- want: false,
- },
- {
- name: "completed with output item",
- data: `{"type":"response.completed","response":{"id":"r1","status":"completed","output":[{"type":"message","id":"msg_1"}]}}`,
- want: false,
- },
- {
- name: "accumulated usage",
- data: `{"type":"response.completed"}`,
- usage: &OpenAIUsage{
- InputTokens: 7,
- OutputTokens: 2,
- },
- want: false,
- },
- {
- name: "invalid json",
- data: `{"type":`,
- want: false,
- },
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- require.Equal(t, tc.want, openAIResponsesCompletedEventIsEmpty([]byte(tc.data), tc.usage))
- })
- }
- }
|