| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138 |
- package service
- import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- type openAIChatFailingWriter struct {
- gin.ResponseWriter
- failAfter int
- writes int
- }
- func (w *openAIChatFailingWriter) Write(p []byte) (int, error) {
- if w.writes >= w.failAfter {
- return 0, errors.New("write failed: client disconnected")
- }
- w.writes++
- return w.ResponseWriter.Write(p)
- }
- type openAIChatStreamReadErrorCloser struct {
- payload []byte
- err error
- sent bool
- }
- func (r *openAIChatStreamReadErrorCloser) Read(p []byte) (int, error) {
- if !r.sent {
- r.sent = true
- return copy(p, r.payload), nil
- }
- return 0, r.err
- }
- func (r *openAIChatStreamReadErrorCloser) Close() error { return nil }
- func TestHandleChatStreamingResponse_ClassifiesHTTP2ReadError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
- resp := &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{
- "Content-Type": []string{"text/event-stream"},
- "x-request-id": []string{"upstream-rid"},
- },
- Body: &openAIChatStreamReadErrorCloser{
- payload: []byte("data: {\"type\":\"response.output_text.delta\",\"delta\":\"partial\"}\n\n"),
- err: errors.New("stream error: stream ID 5; INTERNAL_ERROR; received from peer"),
- },
- }
- svc := &OpenAIGatewayService{cfg: &config.Config{}}
- result, err := svc.handleChatStreamingResponse(
- resp,
- c,
- &Account{ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI},
- "gpt-5.6-sol",
- "gpt-5.6-sol",
- "gpt-5.6-sol",
- time.Now(),
- 0,
- )
- require.Error(t, err)
- require.NotNil(t, result)
- require.True(t, c.Writer.Written(), "partial output must make replay unsafe")
- code, message, ok := OpenAIUpstreamStreamReadErrorDetails(err)
- require.True(t, ok)
- require.Equal(t, OpenAIUpstreamHTTP2StreamErrorCode, code)
- require.Equal(t, "Upstream HTTP/2 stream failed", message)
- require.NotContains(t, message, "stream ID")
- require.NotContains(t, message, "INTERNAL_ERROR")
- }
- func TestNormalizeResponsesRequestServiceTier(t *testing.T) {
- t.Parallel()
- req := &apicompat.ResponsesRequest{ServiceTier: " fast "}
- normalizeResponsesRequestServiceTier(req)
- require.Equal(t, "priority", req.ServiceTier)
- req.ServiceTier = "flex"
- normalizeResponsesRequestServiceTier(req)
- require.Equal(t, "flex", req.ServiceTier)
- // OpenAI 官方合法 tier 应被透传保留。
- req.ServiceTier = "auto"
- normalizeResponsesRequestServiceTier(req)
- require.Equal(t, "auto", req.ServiceTier)
- req.ServiceTier = "default"
- normalizeResponsesRequestServiceTier(req)
- require.Equal(t, "default", req.ServiceTier)
- req.ServiceTier = "scale"
- normalizeResponsesRequestServiceTier(req)
- require.Equal(t, "scale", req.ServiceTier)
- // 真未知值仍被剥离。
- req.ServiceTier = "turbo"
- normalizeResponsesRequestServiceTier(req)
- require.Empty(t, req.ServiceTier)
- }
- func TestNormalizeResponsesBodyServiceTier(t *testing.T) {
- t.Parallel()
- body, tier, err := normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"fast"}`))
- require.NoError(t, err)
- require.Equal(t, "priority", tier)
- require.Equal(t, "priority", gjson.GetBytes(body, "service_tier").String())
- body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"flex"}`))
- require.NoError(t, err)
- require.Equal(t, "flex", tier)
- require.Equal(t, "flex", gjson.GetBytes(body, "service_tier").String())
- // OpenAI 官方 tier 直接保留在 body 中(透传上游)。
- body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"auto"}`))
- require.NoError(t, err)
- require.Equal(t, "auto", tier)
- require.Equal(t, "auto", gjson.GetBytes(body, "service_tier").String())
- body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"default"}`))
- require.NoError(t, err)
- require.Equal(t, "default", tier)
- require.Equal(t, "default", gjson.GetBytes(body, "service_tier").String())
- body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"scale"}`))
- require.NoError(t, err)
- require.Equal(t, "scale", tier)
- require.Equal(t, "scale", gjson.GetBytes(body, "service_tier").String())
- body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.6-sol","service_tier":"ultrafast"}`))
- require.NoError(t, err)
- require.Equal(t, "ultrafast", tier)
- require.Equal(t, "ultrafast", gjson.GetBytes(body, "service_tier").String())
- // 真未知值才会被删除。
- body, tier, err = normalizeResponsesBodyServiceTier([]byte(`{"model":"gpt-5.1","service_tier":"turbo"}`))
- require.NoError(t, err)
- require.Empty(t, tier)
- require.False(t, gjson.GetBytes(body, "service_tier").Exists())
- }
- func TestForwardAsChatCompletions_UnknownModelWithoutMessagesDispatchKeepsRequestedModel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt6","messages":[{"role":"user","content":"hello"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_unknown_model"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"model not found"}}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
- require.Error(t, err)
- require.Nil(t, result)
- require.Equal(t, "gpt6", gjson.GetBytes(upstream.lastBody, "model").String())
- require.NotEqual(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
- require.Equal(t, http.StatusBadRequest, rec.Code)
- }
- func TestForwardAsChatCompletions_APIKeyPropagatesPromptCacheKeyInResponsesBody(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- c.Set("api_key", &APIKey{ID: 99})
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_prompt_cache"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 2,
- Name: "openai-compatible",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-compatible",
- },
- Extra: map[string]any{
- "openai_responses_supported": true,
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "cache-key-123", "gpt-5.4")
- require.Error(t, err)
- require.Nil(t, result)
- require.Equal(t, "cache-key-123", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
- require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
- require.Equal(t, "https://api.openai.com/v1/responses", upstream.lastReq.URL.String())
- require.Equal(t, "Bearer sk-compatible", upstream.lastReq.Header.Get("Authorization"))
- require.Equal(t, generateSessionUUID(isolateOpenAISessionID(99, "cache-key-123")), upstream.lastReq.Header.Get("session_id"))
- }
- func TestForwardAsChatCompletions_APIKeyAutoDerivesStableIsolatedPromptCacheKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
- response := func() *http.Response {
- return &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
- }
- }
- upstream := &httpUpstreamRecorder{responses: []*http.Response{response(), response(), response()}}
- svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
- account := &Account{
- ID: 2, Name: "openai-compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-compatible"},
- Extra: map[string]any{"openai_responses_supported": true},
- }
- firstBody := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hello"}],"stream":false}`)
- appendedBody := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"be concise"},{"role":"user","content":"hello"},{"role":"assistant","content":"hi"},{"role":"user","content":"continue"}],"stream":false}`)
- forward := func(apiKeyID int64, body []byte) {
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- c.Set("api_key", &APIKey{ID: apiKeyID})
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
- require.Error(t, err)
- require.Nil(t, result)
- }
- forward(99, firstBody)
- forward(99, appendedBody)
- forward(100, appendedBody)
- firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
- appendedKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
- otherTenantKey := gjson.GetBytes(upstream.bodies[2], "prompt_cache_key").String()
- require.NotEmpty(t, firstKey)
- require.Equal(t, firstKey, appendedKey)
- require.NotEqual(t, firstKey, otherTenantKey)
- require.Equal(t, generateSessionUUID(firstKey), upstream.requests[0].Header.Get("session_id"))
- require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id"))
- require.Equal(t, generateSessionUUID(otherTenantKey), upstream.requests[2].Header.Get("session_id"))
- require.NotEqual(t, upstream.requests[1].Header.Get("session_id"), upstream.requests[2].Header.Get("session_id"))
- }
- func TestForwardAsChatCompletions_ResponsesShapeDoesNotAutoDerivePromptCacheKey(t *testing.T) {
- gin.SetMode(gin.TestMode)
- response := func() *http.Response {
- return &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
- }
- }
- upstream := &httpUpstreamRecorder{responses: []*http.Response{response(), response(), response()}}
- svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
- account := &Account{
- ID: 2, Name: "openai-compatible", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1,
- Credentials: map[string]any{"api_key": "sk-compatible"},
- Extra: map[string]any{"openai_responses_supported": true},
- }
- firstBody := []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":[{"type":"input_text","text":"first unrelated input"}]}],"stream":false}`)
- secondBody := []byte(`{"model":"gpt-5.4","input":[{"role":"user","content":[{"type":"input_text","text":"second unrelated input"}]}],"stream":false}`)
- forward := func(body []byte, promptCacheKey string) {
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Set("api_key", &APIKey{ID: 99})
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, promptCacheKey, "gpt-5.4")
- require.Error(t, err)
- require.Nil(t, result)
- }
- forward(firstBody, "")
- forward(secondBody, "")
- forward(secondBody, "explicit-responses-key")
- require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
- require.Empty(t, upstream.requests[0].Header.Get("session_id"))
- require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
- require.Empty(t, upstream.requests[1].Header.Get("session_id"))
- require.Equal(t, "explicit-responses-key", gjson.GetBytes(upstream.bodies[2], "prompt_cache_key").String())
- require.Equal(t, generateSessionUUID(isolateOpenAISessionID(99, "explicit-responses-key")), upstream.requests[2].Header.Get("session_id"))
- }
- func TestForwardAsChatCompletions_OAuthDoesNotInjectDefaultInstructions(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_no_default_instructions"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
- }}
- svc := &OpenAIGatewayService{
- cfg: &config.Config{},
- httpUpstream: upstream,
- }
- account := &Account{
- ID: 3,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
- require.Error(t, err)
- require.Nil(t, result)
- require.NotNil(t, upstream.lastReq)
- require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
- require.True(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists())
- require.Equal(t, "", gjson.GetBytes(upstream.lastBody, "instructions").String())
- require.NotContains(t, string(upstream.lastBody), "Communicate with the user by streaming thinking")
- }
- func forwardOAuthChatCompletionsForUpstreamBody(t *testing.T, body []byte) []byte {
- t.Helper()
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusBadRequest,
- Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_chat_system_promotion"}},
- Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop before response parsing"}}`)),
- }}
- svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
- account := &Account{
- ID: 4,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
- require.Error(t, err)
- require.Nil(t, result)
- require.NotEmpty(t, upstream.lastBody)
- return upstream.lastBody
- }
- func TestForwardAsChatCompletions_OAuthPromotesSystemMessageWithoutDuplication(t *testing.T) {
- const systemPrompt = "Unique system prefix for token accounting."
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"` + systemPrompt + `"},{"role":"user","content":"hello"}],"stream":false}`)
- upstreamBody := forwardOAuthChatCompletionsForUpstreamBody(t, body)
- require.Equal(t, systemPrompt, gjson.GetBytes(upstreamBody, "instructions").String())
- require.Equal(t, int64(1), gjson.GetBytes(upstreamBody, "input.#").Int())
- require.Equal(t, "user", gjson.GetBytes(upstreamBody, "input.0.role").String())
- require.Equal(t, 1, strings.Count(string(upstreamBody), systemPrompt))
- }
- func TestForwardAsChatCompletions_OAuthJsonObjectKeepsSystemMessageInInput(t *testing.T) {
- const systemPrompt = "Return JSON only."
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":"` + systemPrompt + `"},{"role":"user","content":"symbol data"}],"response_format":{"type":" JSON_OBJECT "},"stream":false}`)
- upstreamBody := forwardOAuthChatCompletionsForUpstreamBody(t, body)
- require.Equal(t, systemPrompt, gjson.GetBytes(upstreamBody, "instructions").String())
- require.Equal(t, int64(2), gjson.GetBytes(upstreamBody, "input.#").Int())
- require.Equal(t, "developer", gjson.GetBytes(upstreamBody, "input.0.role").String())
- require.Equal(t, systemPrompt, gjson.GetBytes(upstreamBody, "input.0.content").String())
- require.Equal(t, 2, strings.Count(string(upstreamBody), systemPrompt))
- }
- func TestForwardAsChatCompletions_OAuthKeepsMixedSystemContentInInput(t *testing.T) {
- const systemPrompt = "Inspect this reference image."
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"system","content":[{"type":"text","text":"` + systemPrompt + `"},{"type":"image_url","image_url":{"url":"https://example.com/reference.png"}}]},{"role":"user","content":"hello"}],"stream":false}`)
- upstreamBody := forwardOAuthChatCompletionsForUpstreamBody(t, body)
- require.Equal(t, systemPrompt, gjson.GetBytes(upstreamBody, "instructions").String())
- require.Equal(t, int64(2), gjson.GetBytes(upstreamBody, "input.#").Int())
- require.Equal(t, "developer", gjson.GetBytes(upstreamBody, "input.0.role").String())
- require.Equal(t, int64(2), gjson.GetBytes(upstreamBody, "input.0.content.#").Int())
- require.Equal(t, "input_image", gjson.GetBytes(upstreamBody, "input.0.content.1.type").String())
- require.Equal(t, "https://example.com/reference.png", gjson.GetBytes(upstreamBody, "input.0.content.1.image_url").String())
- }
- func TestForwardAsChatCompletions_ClientDisconnectDrainsUpstreamUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Writer = &openAIChatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
- "",
- `data: {"type":"response.output_text.delta","delta":"ok"}`,
- "",
- `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":11,"output_tokens":5,"total_tokens":16,"input_tokens_details":{"cached_tokens":4}}}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_disconnect"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 11, result.Usage.InputTokens)
- require.Equal(t, 5, result.Usage.OutputTokens)
- require.Equal(t, 4, result.Usage.CacheReadInputTokens)
- }
- func TestForwardAsChatCompletions_BufferedContextWindowResponseFailedReturnsErrorWithoutFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"large prompt"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `event: response.failed`,
- `data: {"type":"response.failed","response":{"id":"resp_failed","object":"response","model":"gpt-5.5","status":"failed","output":[],"error":{"code":"upstream_error","message":"input exceeds the context window"}}}`,
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_failed_buffered"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- require.Error(t, err)
- require.Nil(t, result)
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr))
- require.True(t, c.Writer.Written())
- require.Equal(t, http.StatusBadGateway, rec.Code)
- require.Contains(t, rec.Body.String(), "input exceeds the context window")
- }
- func TestForwardAsChatCompletions_StreamContextWindowResponseFailedReturnsErrorWithoutFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"` + strings.Repeat("large prompt ", 6000) + `"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_failed","model":"gpt-5.5","status":"in_progress","output":[]}}`,
- "",
- `event: response.failed`,
- `data: {"type":"response.failed","response":{"id":"resp_failed","object":"response","model":"gpt-5.5","status":"failed","output":[],"error":{"code":"upstream_error","message":"input exceeds the context window"}}}`,
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_failed_stream"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- require.Error(t, err)
- require.NotNil(t, result)
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr))
- require.True(t, c.Writer.Written())
- require.Equal(t, http.StatusBadGateway, rec.Code)
- require.Contains(t, rec.Header().Get("Content-Type"), "application/json")
- require.Contains(t, rec.Body.String(), "input exceeds the context window")
- require.NotContains(t, rec.Body.String(), "[DONE]")
- }
- func TestForwardAsChatCompletions_StreamBareErrorAfterOutputDoesNotFailOver(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.output_text.delta","output_index":0,"content_index":0,"delta":"partial"}`,
- "",
- `event: error`,
- `data: {"type":"error","error":{"type":"server_error","code":"server_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(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
- Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- require.Error(t, err)
- require.NotNil(t, result)
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr))
- require.Contains(t, rec.Body.String(), "partial")
- require.Contains(t, rec.Body.String(), "temporary upstream failure")
- require.NotContains(t, rec.Body.String(), "[DONE]")
- }
- func TestForwardAsChatCompletions_StreamCyberPolicyNoFailover(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.5","messages":[{"role":"user","content":"` + strings.Repeat("large prompt ", 6000) + `"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_cyber","model":"gpt-5.5","status":"in_progress","output":[]}}`,
- "",
- `event: response.failed`,
- `data: {"type":"response.failed","response":{"id":"resp_cyber","object":"response","model":"gpt-5.5","status":"failed","output":[],"error":{"code":"cyber_policy","message":"flagged for cyber policy"}}}`,
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_cyber"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.5")
- var failoverErr *UpstreamFailoverError
- require.False(t, errors.As(err, &failoverErr), "cyber must NOT trigger failover")
- require.NotNil(t, GetOpsCyberPolicy(c), "cyber mark must be set")
- respBody := rec.Body.String()
- require.Contains(t, respBody, `"error"`)
- require.Contains(t, respBody, `"cyber_policy"`)
- require.Contains(t, respBody, "data: [DONE]")
- }
- func TestForwardAsChatCompletions_StreamsUsageWithoutClientStreamOptions(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
- "",
- `data: {"type":"response.output_text.delta","delta":"ok"}`,
- "",
- `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":13,"output_tokens":7,"total_tokens":20,"input_tokens_details":{"cached_tokens":5}}}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_usage_no_stream_options"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 13, result.Usage.InputTokens)
- require.Equal(t, 7, result.Usage.OutputTokens)
- require.Equal(t, 5, result.Usage.CacheReadInputTokens)
- responseBody := rec.Body.String()
- require.Contains(t, responseBody, `"usage"`)
- require.Contains(t, responseBody, `"prompt_tokens":13`)
- require.Contains(t, responseBody, `"completion_tokens":7`)
- require.Contains(t, responseBody, `"cached_tokens":5`)
- }
- func TestForwardAsChatCompletions_StreamsTopLevelTerminalUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.created","response":{"id":"resp_top","model":"gpt-5.4","status":"in_progress","output":[]}}`,
- "",
- `data: {"type":"response.output_text.delta","delta":"ok"}`,
- "",
- `data: {"type":"response.completed","response":{"id":"resp_top","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]},"usage":{"input_tokens":21,"output_tokens":9,"total_tokens":30,"input_tokens_details":{"cached_tokens":4}}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_top_level_usage"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 21, result.Usage.InputTokens)
- require.Equal(t, 9, result.Usage.OutputTokens)
- require.Equal(t, 4, result.Usage.CacheReadInputTokens)
- responseBody := rec.Body.String()
- require.Contains(t, responseBody, `"usage"`)
- require.Contains(t, responseBody, `"prompt_tokens":21`)
- require.Contains(t, responseBody, `"completion_tokens":9`)
- require.Contains(t, responseBody, `"cached_tokens":4`)
- }
- func TestForwardAsChatCompletions_BufferedTopLevelTerminalUsage(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.completed","response":{"id":"resp_top_buffered","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]},"usage":{"input_tokens":18,"output_tokens":6,"total_tokens":24,"input_tokens_details":{"cached_tokens":3}}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_buffered_top_level_usage"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Equal(t, 18, result.Usage.InputTokens)
- require.Equal(t, 6, result.Usage.OutputTokens)
- require.Equal(t, 3, result.Usage.CacheReadInputTokens)
- responseBody := rec.Body.String()
- require.Contains(t, responseBody, `"usage"`)
- require.Contains(t, responseBody, `"prompt_tokens":18`)
- require.Contains(t, responseBody, `"completion_tokens":6`)
- require.Contains(t, responseBody, `"cached_tokens":3`)
- }
- func TestForwardAsChatCompletions_TerminalUsageWithoutUpstreamCloseReturns(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- c.Writer = &openAIChatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":17,"output_tokens":8,"total_tokens":25,"input_tokens_details":{"cached_tokens":6}}}}` + "\n\n")
- upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
- defer func() {
- require.NoError(t, upstreamStream.Close())
- }()
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_terminal_no_close"}},
- Body: upstreamStream,
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- type forwardResult struct {
- result *OpenAIForwardResult
- err error
- }
- resultCh := make(chan forwardResult, 1)
- go func() {
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- resultCh <- forwardResult{result: result, err: err}
- }()
- select {
- case got := <-resultCh:
- require.NoError(t, got.err)
- require.NotNil(t, got.result)
- require.Equal(t, 17, got.result.Usage.InputTokens)
- require.Equal(t, 8, got.result.Usage.OutputTokens)
- require.Equal(t, 6, got.result.Usage.CacheReadInputTokens)
- case <-time.After(time.Second):
- require.Fail(t, "ForwardAsChatCompletions should return after terminal usage event even if upstream keeps the connection open")
- }
- }
- func TestForwardAsChatCompletions_EventNamedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := []byte(strings.Join([]string{
- `event: response.created`,
- `data: {"response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
- ``,
- `event: response.output_text.delta`,
- `data: {"delta":"ok"}`,
- ``,
- `event: response.completed`,
- `data: {"response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":17,"output_tokens":8,"total_tokens":25,"input_tokens_details":{"cached_tokens":6}}}}`,
- ``,
- ``,
- }, "\n"))
- upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
- defer func() {
- require.NoError(t, upstreamStream.Close())
- }()
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_event_named_terminal"}},
- Body: upstreamStream,
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- type forwardResult struct {
- result *OpenAIForwardResult
- err error
- }
- resultCh := make(chan forwardResult, 1)
- go func() {
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- resultCh <- forwardResult{result: result, err: err}
- }()
- select {
- case got := <-resultCh:
- require.NoError(t, got.err)
- require.NotNil(t, got.result)
- require.Equal(t, 17, got.result.Usage.InputTokens)
- require.Equal(t, 8, got.result.Usage.OutputTokens)
- require.Equal(t, 6, got.result.Usage.CacheReadInputTokens)
- require.Contains(t, rec.Body.String(), `"content":"ok"`)
- case <-time.After(time.Second):
- require.Fail(t, "ForwardAsChatCompletions should use SSE event names when data payloads omit type")
- }
- }
- func TestForwardAsChatCompletions_EventTypeDoesNotLeakAcrossFrames(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := strings.Join([]string{
- `event: response.created`,
- `data: {"response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
- ``,
- `data: {"type":"response.output_text.delta","delta":"ok"}`,
- ``,
- `event: response.completed`,
- `data: {"response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":17,"output_tokens":8,"total_tokens":25,"input_tokens_details":{"cached_tokens":6}}}}`,
- ``,
- `data: [DONE]`,
- ``,
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_event_boundary"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Contains(t, rec.Body.String(), `"content":"ok"`)
- require.Contains(t, rec.Body.String(), `data: [DONE]`)
- }
- func TestForwardAsChatCompletions_BufferedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":17,"output_tokens":8,"total_tokens":25,"input_tokens_details":{"cached_tokens":6}}}}` + "\n\n")
- upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
- defer func() {
- require.NoError(t, upstreamStream.Close())
- }()
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_buffered_terminal_no_close"}},
- Body: upstreamStream,
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- type forwardResult struct {
- result *OpenAIForwardResult
- err error
- }
- resultCh := make(chan forwardResult, 1)
- go func() {
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- resultCh <- forwardResult{result: result, err: err}
- }()
- select {
- case got := <-resultCh:
- require.NoError(t, got.err)
- require.NotNil(t, got.result)
- require.Equal(t, 17, got.result.Usage.InputTokens)
- require.Equal(t, 8, got.result.Usage.OutputTokens)
- require.Equal(t, 6, got.result.Usage.CacheReadInputTokens)
- require.Contains(t, rec.Body.String(), `"finish_reason":"stop"`)
- case <-time.After(time.Second):
- require.Fail(t, "ForwardAsChatCompletions buffered response should return after terminal usage event even if upstream keeps the connection open")
- }
- }
- func TestForwardAsChatCompletions_DoneSentinelWithoutTerminalReturnsError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":true}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- upstreamBody := "data: [DONE]\n\n"
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_missing_terminal"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.1")
- require.Error(t, err)
- require.Contains(t, err.Error(), "missing terminal event")
- require.NotNil(t, result)
- require.Zero(t, result.Usage.InputTokens)
- require.Zero(t, result.Usage.OutputTokens)
- }
- func TestForwardAsChatCompletions_UpstreamRequestIgnoresClientCancel(t *testing.T) {
- gin.SetMode(gin.TestMode)
- rec := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(rec)
- reqCtx, cancel := context.WithCancel(context.Background())
- body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body)).WithContext(reqCtx)
- c.Request.Header.Set("Content-Type", "application/json")
- cancel()
- upstreamBody := strings.Join([]string{
- `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
- "",
- "data: [DONE]",
- "",
- }, "\n")
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_chat_ctx"}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }}
- svc := &OpenAIGatewayService{httpUpstream: upstream}
- account := &Account{
- ID: 1,
- Name: "openai-oauth",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-acc",
- },
- }
- result, err := svc.ForwardAsChatCompletions(reqCtx, c, account, body, "", "gpt-5.1")
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastReq)
- require.NoError(t, upstream.lastReq.Context().Err())
- }
- // TestBuildChatStreamErrorSSE verifies F4: the error chunk payload follows the
- // OpenAI chat streaming error convention so third-party clients stop retrying.
- func TestBuildChatStreamErrorSSE(t *testing.T) {
- got := buildChatStreamErrorSSE("cyber_policy", "blocked by policy")
- require.True(t, strings.HasPrefix(got, "data: "), "must be an SSE data frame")
- payload := strings.TrimSuffix(strings.TrimPrefix(got, "data: "), "\n\n")
- require.Equal(t, "invalid_request_error", gjson.Get(payload, "error.type").String())
- require.Equal(t, "cyber_policy", gjson.Get(payload, "error.code").String())
- require.Equal(t, "blocked by policy", gjson.Get(payload, "error.message").String())
- }
|