| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294 |
- //go:build unit
- package service
- import (
- "context"
- "fmt"
- "io"
- "net/http"
- "runtime"
- "strings"
- "testing"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`,
- )),
- }}
- 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":false,
- "input":[
- {"type":"message","id":"item_bad_message","role":"assistant","content":[{"type":"output_text","text":"hello"}]},
- {"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"},
- {"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]},
- {"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"},
- {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
- {"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},
- {"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
- {"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}},
- {"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"},
- {"type":"web_search_call","id":"item_wrong_web"}
- ]
- }`)
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastReq)
- forwarded := upstream.lastBody
- require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists())
- require.Equal(t, "hello", gjson.GetBytes(forwarded, "input.0.content.0.text").String())
- require.False(t, gjson.GetBytes(forwarded, "input.1.id").Exists())
- require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.1.call_id").String())
- require.Equal(t, "exec_command", gjson.GetBytes(forwarded, "input.1.name").String())
- require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String())
- require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String())
- require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String())
- require.False(t, gjson.GetBytes(forwarded, "input.4.id").Exists())
- require.Equal(t, "ctc_valid", gjson.GetBytes(forwarded, "input.5.id").String())
- require.False(t, gjson.GetBytes(forwarded, "input.6.id").Exists())
- require.Equal(t, "tsc_valid", gjson.GetBytes(forwarded, "input.7.id").String())
- require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.8.id").String())
- require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.8.call_id").String())
- require.False(t, gjson.GetBytes(forwarded, "input.9.id").Exists())
- }
- func TestOpenAIGatewayService_OAuthPassthrough_SanitizesNativeToolItemIDs(t *testing.T) {
- gin.SetMode(gin.TestMode)
- for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
- t.Run(accountType, func(t *testing.T) {
- upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [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(upstreamSSE)),
- }}
- svc := newOpenAIImageGenerationControlTestService(upstream)
- c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
- account := newOpenAIImageGenerationControlTestAccount()
- account.Type = accountType
- account.Credentials = map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-account",
- }
- account.Extra = map[string]any{"openai_passthrough": true}
- body := []byte(`{
- "model":"gpt-5.6-sol",
- "stream":true,
- "instructions":"test",
- "input":[
- {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
- {"type":"custom_tool_call","id":"ctc_valid","call_id":"call_custom_2","name":"apply_patch","input":"patch"},
- {"type":"tool_search_call","id":"fc_wrong_search","call_id":"call_search_1","arguments":{"query":"docs"}},
- {"type":"tool_search_call","id":"tsc_valid","call_id":"call_search_2","arguments":{"query":"docs"}}
- ]
- }`)
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastReq)
- require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
- require.Equal(t, "ctc_valid", gjson.GetBytes(upstream.lastBody, "input.1.id").String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "input.2.id").Exists())
- require.Equal(t, "tsc_valid", gjson.GetBytes(upstream.lastBody, "input.3.id").String())
- })
- }
- }
- func TestOpenAIGatewayService_SetupTokenLegacy_SanitizesAndTransforms(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstreamSSE := "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_test\",\"model\":\"gpt-5.6-sol\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [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(upstreamSSE)),
- }}
- svc := newOpenAIImageGenerationControlTestService(upstream)
- c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
- account := newOpenAIImageGenerationControlTestAccount()
- account.Type = AccountTypeSetupToken
- account.Credentials = map[string]any{
- "access_token": "setup-token",
- "chatgpt_account_id": "chatgpt-account",
- }
- account.Extra = map[string]any{"openai_passthrough": false}
- body := []byte(`{
- "model":"gpt-5.6-sol",
- "stream":true,
- "store":true,
- "reasoning":{"mode":"pro"},
- "instructions":"test",
- "input":[
- {"type":"custom_tool_call","id":"fc_wrong_custom","call_id":"call_custom_1","name":"apply_patch","input":"patch"},
- {"type":"function_call_output","call_id":"call_orphan","output":"orphan"}
- ]
- }`)
- result, err := svc.Forward(context.Background(), c, account, body)
- require.NoError(t, err)
- require.NotNil(t, result)
- require.NotNil(t, upstream.lastReq)
- require.Equal(t, "https://chatgpt.com/backend-api/codex/responses", upstream.lastReq.URL.String())
- require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool())
- require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
- require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning.mode").Exists())
- require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.id").Exists())
- require.Len(t, gjson.GetBytes(upstream.lastBody, "input").Array(), 1)
- }
- // TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs
- // verifies that reasoning items with a non-rs id (e.g. item_*) are stripped
- // before forwarding. OpenAI upstream requires reasoning ids to begin with
- // "rs" and rejects item_* with 400:
- // "Expected an ID that begins with 'rs'." (#5410)
- func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidReasoningItemIDs(t *testing.T) {
- gin.SetMode(gin.TestMode)
- upstream := &httpUpstreamRecorder{resp: &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(
- `{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`,
- )),
- }}
- 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":false,
- "input":[
- {"type":"reasoning","id":"item_bad_reasoning","summary":[]},
- {"type":"reasoning","id":"rs_valid","summary":[]},
- {"type":"message","id":"msg_valid","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, upstream.lastReq)
- forwarded := upstream.lastBody
- require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists(),
- "item_* id should be stripped from reasoning")
- require.Equal(t, "rs_valid", gjson.GetBytes(forwarded, "input.1.id").String(),
- "valid rs* id must be preserved")
- require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String())
- }
- func TestShouldStripOpenAIResponsesInputItemID_Reasoning(t *testing.T) {
- cases := []struct {
- name string
- itemType string
- id string
- want bool
- }{
- {"reasoning item_* id", "reasoning", "item_bad_reasoning", true},
- {"reasoning rs id", "reasoning", "rs_abc123", false},
- {"reasoning empty id", "reasoning", "", true},
- {"message msg id", "message", "msg_abc", false},
- {"message item id", "message", "item_x", true},
- {"function_call fc id", "function_call", "fc_abc", false},
- {"function_call ctc id", "function_call", "ctc_abc", true},
- {"function_call item id", "function_call", "item_x", true},
- {"custom tool ctc id", "custom_tool_call", "ctc_abc", false},
- {"custom tool fc id", "custom_tool_call", "fc_abc", true},
- {"tool search tsc id", "tool_search_call", "tsc_abc", false},
- {"tool search fc id", "tool_search_call", "fc_abc", true},
- {"web search ws id", "web_search_call", "ws_001", false},
- {"web search item id", "web_search_call", "item_001", true},
- {"custom output fc id", "custom_tool_call_output", "fc_001", false},
- {"custom output ctco id", "custom_tool_call_output", "ctco_001", true},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- require.Equal(t, tc.want, shouldStripOpenAIResponsesInputItemID(tc.itemType, tc.id))
- })
- }
- }
- func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing.T) {
- makeBody := func(itemCount int) []byte {
- items := make([]string, itemCount)
- for i := range items {
- items[i] = fmt.Sprintf(`{"type":"message","id":"item_%d","role":"user","content":[{"type":"input_text","text":"hello"}]}`, i)
- }
- return []byte(`{"model":"gpt-5.6-sol","input":[` + strings.Join(items, ",") + `]}`)
- }
- allocatedBytes := func(body []byte) uint64 {
- runtime.GC()
- var before, after runtime.MemStats
- runtime.ReadMemStats(&before)
- sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
- runtime.ReadMemStats(&after)
- require.NoError(t, err)
- require.True(t, changed)
- require.NotEmpty(t, sanitized)
- return after.TotalAlloc - before.TotalAlloc
- }
- smallAllocated := allocatedBytes(makeBody(20))
- largeAllocated := allocatedBytes(makeBody(200))
- require.Less(t, largeAllocated, smallAllocated*30,
- "10x more input items must not cause quadratic whole-body allocation growth")
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBodyPreservesOpaqueReferences(t *testing.T) {
- body := []byte(`{"type":"response.create","input":[
- {"type":"custom_tool_call","id":"ctc_call","call_id":"call_custom","name":"apply_patch","input":"patch"},
- {"type":"custom_tool_call_output","id":"ctco_bad","call_id":"call_custom","output":"done"},
- {"type":"item_reference","id":"ctco_bad"},
- {"type":"future_item","id":"item_future","payload":"keep"}
- ]}`)
- for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
- t.Run(accountType, func(t *testing.T) {
- normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
- Platform: PlatformOpenAI,
- Type: accountType,
- }, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Len(t, gjson.GetBytes(normalized, "input").Array(), 4)
- require.Equal(t, "ctc_call", gjson.GetBytes(normalized, "input.0.id").String())
- require.Equal(t, "call_custom", gjson.GetBytes(normalized, "input.1.call_id").String())
- require.False(t, gjson.GetBytes(normalized, "input.1.id").Exists())
- require.Equal(t, "ctco_bad", gjson.GetBytes(normalized, "input.2.id").String())
- require.Equal(t, "item_future", gjson.GetBytes(normalized, "input.3.id").String())
- second, changedAgain, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(normalized, &Account{
- Platform: PlatformOpenAI,
- Type: accountType,
- }, false)
- require.NoError(t, err)
- require.False(t, changedAgain)
- require.JSONEq(t, string(normalized), string(second))
- })
- }
- }
|