| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751 |
- package service
- import (
- "bytes"
- "context"
- "fmt"
- "io"
- "net/http"
- "net/http/httptest"
- "strconv"
- "strings"
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestOpenAIResponsesRejectedFieldRetryStateRejectsDuplicateBodyAndCap(t *testing.T) {
- initialBody := []byte(`{"model":"gpt-5.5"}`)
- state := newOpenAIResponsesRejectedFieldRetryState(initialBody)
- require.False(t, state.Allow(initialBody))
- for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
- nextBody := []byte(fmt.Sprintf(`{"model":"gpt-5.5","variant":%d}`, attempt))
- require.True(t, state.Allow(nextBody))
- require.False(t, state.Allow(nextBody))
- }
- require.False(t, state.Allow([]byte(`{"model":"gpt-5.5","variant":"overflow"}`)))
- }
- func TestOpenAIResponsesRejectedFieldRetryStateForRequestAllowsSameTransformAcrossAccounts(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- initialBody := []byte(`{"model":"gpt-5.5","truncation":"auto"}`)
- retryBody := []byte(`{"model":"gpt-5.5"}`)
- accountA := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
- require.True(t, accountA.Allow(retryBody))
- require.False(t, accountA.Allow(retryBody), "one account must not repeat the same transform")
- accountB := openAIResponsesRejectedFieldRetryStateForRequest(c, initialBody)
- require.NotSame(t, accountA, accountB)
- require.Same(t, accountA.budget, accountB.budget)
- require.True(t, accountB.Allow(retryBody), "a failover account must be allowed to apply the same transform")
- }
- func TestOpenAIResponsesRejectedFieldRetryStateForRequestSharesBoundedBudgetAcrossAccounts(t *testing.T) {
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- for attempt := 0; attempt < maxOpenAIResponsesRejectedFieldRetries; attempt++ {
- state := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(fmt.Sprintf(`{"account":%d}`, attempt)))
- require.True(t, state.Allow([]byte(`{"same":"retry"}`)))
- }
- overflow := openAIResponsesRejectedFieldRetryStateForRequest(c, []byte(`{"account":"overflow"}`))
- require.False(t, overflow.Allow([]byte(`{"new":"retry"}`)))
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousErrors(t *testing.T) {
- tests := []struct {
- name string
- body []byte
- responseBody []byte
- }{
- {
- name: "namespace belongs to message",
- body: []byte(`{"input":[{"type":"message","namespace":"keep"}]}`),
- responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"input[0].namespace"}}`),
- },
- {
- name: "max output tokens only mentioned",
- body: []byte(`{"max_output_tokens":4096}`),
- responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"max_output_tokens must be positive","param":"max_output_tokens"}}`),
- },
- {
- name: "structured param overrides namespace mention",
- body: []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"}]}`),
- responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].namespace'.","param":"tools"}}`),
- },
- {
- name: "nested max output tokens param is not top level",
- body: []byte(`{"max_output_tokens":4096,"input":[{"type":"message","content":{"max_output_tokens":"keep"}}]}`),
- responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.max_output_tokens","param":"input[0].content.max_output_tokens"}}`),
- },
- {
- name: "structured target conflicts with message target",
- body: []byte(`{"max_output_tokens":4096,"truncation":"auto"}`),
- responseBody: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: truncation.","param":"max_output_tokens"}}`),
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
- require.NoError(t, err)
- require.False(t, changed)
- require.Nil(t, retryBody)
- })
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRepairsAutomationMissingRootType(t *testing.T) {
- body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"},{"type":"object","properties":{}}]}}]}`)
- responseBody := []byte(`{"error":{"code":"invalid_function_parameters","message":"Invalid schema for function 'automation_update': got 'type: \"None\"'.","param":"tools[0].parameters"}}`)
- retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "tool parameter root type rejection", reason)
- require.Equal(t, "object", gjson.GetBytes(retryBody, "tools.0.parameters.type").String())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotGuessAutomationRootType(t *testing.T) {
- body := []byte(`{"tools":[{"type":"function","name":"automation_update","parameters":{"oneOf":[{"type":"object"}]}}]}`)
- tests := []string{
- `{"error":{"code":"invalid_function_parameters","message":"got type: \"None\"","param":"metadata.parameters"}}`,
- `{"error":{"code":"invalid_request_error","message":"got type: \"None\"","param":"tools[0].parameters"}}`,
- `{"error":{"code":"invalid_function_parameters","message":"expected an object","param":"tools[0].parameters"}}`,
- }
- for _, response := range tests {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, []byte(response))
- require.NoError(t, err)
- require.False(t, changed)
- require.Nil(t, retryBody)
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyFindsNamespacePathInMessage(t *testing.T) {
- body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
- responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0] was accepted; Unknown parameter: 'input[1].namespace'."}}`)
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String())
- require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsNamespacePathToRejectionPhrase(t *testing.T) {
- body := []byte(`{"input":[{"type":"function_call","namespace":"keep","arguments":"{}"},{"type":"function_call","namespace":"remove","arguments":"{}"}]}`)
- responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"input[0].namespace is supported; Unknown parameter: input[1].namespace."}}`)
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.namespace").String())
- require.False(t, gjson.GetBytes(retryBody, "input.1.namespace").Exists())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyDoesNotTreatMaxOutputTokensSuggestionAsRejection(t *testing.T) {
- body := []byte(`{"max_tokens":4096,"max_output_tokens":2048}`)
- responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: max_tokens. Use max_output_tokens instead."}}`)
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.False(t, changed)
- require.Nil(t, retryBody)
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRejectionPhrase(t *testing.T) {
- body := []byte(`{"max_output_tokens":2048}`)
- responseBody := []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens."}}`)
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactIndexedStatus(t *testing.T) {
- body := []byte(`{"input":[{"type":"message","status":"keep","content":"one"},{"type":"reasoning","status":"remove","summary":[]}]}`)
- responses := []struct {
- name string
- body []byte
- }{
- {
- name: "structured param",
- body: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`),
- },
- {
- name: "message param",
- body: []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: input[1].status."}}`),
- },
- }
- for _, tt := range responses {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.body)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.0.status").String())
- require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
- })
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyNormalizesExactNullContent(t *testing.T) {
- tests := []struct {
- name string
- body []byte
- wantChange bool
- wantValue string
- wantExists bool
- }{
- {
- name: "message becomes empty string",
- body: []byte(`{"input":[{"type":"message","role":"assistant","content":null}]}`),
- wantChange: true,
- wantValue: "",
- wantExists: true,
- },
- {
- name: "reasoning content is removed",
- body: []byte(`{"input":[{"type":"reasoning","content":null,"summary":[]}]}`),
- wantChange: true,
- wantExists: false,
- },
- {
- name: "unknown item is unchanged",
- body: []byte(`{"input":[{"type":"future_item","content":null}]}`),
- wantChange: false,
- },
- {
- name: "non null content is unchanged",
- body: []byte(`{"input":[{"type":"message","content":"keep"}]}`),
- wantChange: false,
- },
- }
- responseBody := []byte(`{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`)
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, responseBody)
- require.NoError(t, err)
- require.Equal(t, tt.wantChange, changed)
- if !tt.wantChange {
- require.Nil(t, retryBody)
- return
- }
- content := gjson.GetBytes(retryBody, "input.0.content")
- require.Equal(t, tt.wantExists, content.Exists())
- if tt.wantExists {
- require.Equal(t, tt.wantValue, content.String())
- require.Equal(t, gjson.String, content.Type)
- }
- })
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExactReasoningContentAboveMaximumZero(t *testing.T) {
- body := []byte(`{"input":[{"type":"reasoning","content":[{"type":"reasoning_text","text":"remove"}],"summary":[]},{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`)
- responseBody := []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content","type":"invalid_request_error"}}`)
- retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "indexed reasoning content maximum-length rejection", reason)
- require.False(t, gjson.GetBytes(retryBody, "input.0.content").Exists())
- require.Equal(t, "keep", gjson.GetBytes(retryBody, "input.1.content.0.text").String())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeReasoningMaximumZeroMutations(t *testing.T) {
- tests := []struct {
- name string
- body []byte
- responseBody []byte
- }{
- {
- name: "message content is not reasoning",
- body: []byte(`{"input":[{"type":"message","content":[{"type":"input_text","text":"keep"}]}]}`),
- responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
- },
- {
- name: "structured param and message disagree",
- body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]},{"type":"reasoning","content":[{"text":"keep too"}]}]}`),
- responseBody: []byte(`{"error":{"code":"array_above_max_length","message":"Invalid 'input[1].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
- },
- {
- name: "different error code",
- body: []byte(`{"input":[{"type":"reasoning","content":[{"text":"keep"}]}]}`),
- responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"Invalid 'input[0].content': array too long. Expected an array with maximum length 0, but got an array with length 1 instead.","param":"input[0].content"}}`),
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
- require.NoError(t, err)
- require.False(t, changed)
- require.Nil(t, retryBody)
- })
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","truncation":"auto","input":"keep"}`)
- responses := [][]byte{
- []byte(`{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
- []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: truncation."}}`),
- }
- for _, responseBody := range responses {
- retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "truncation parameter rejection", reason)
- require.False(t, gjson.GetBytes(retryBody, "truncation").Exists())
- require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
- }
- }
- func TestOpenAIGatewayService_APIKeyRetriesExplicitlyRejectedTopLevelTruncation(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"truncation":"auto","input":"keep"}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: 'truncation'.","param":"truncation"}}`),
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, "auto", gjson.GetBytes(upstream.bodies[0], "truncation").String())
- require.False(t, gjson.GetBytes(upstream.bodies[1], "truncation").Exists())
- require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input").String())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsUnsafeIndexedMutations(t *testing.T) {
- tests := []struct {
- name string
- body []byte
- responseBody []byte
- }{
- {
- name: "nested status path",
- body: []byte(`{"input":[{"type":"message","content":{"status":"keep"}}]}`),
- responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[0].content.status.","param":"input[0].content.status"}}`),
- },
- {
- name: "status index out of bounds",
- body: []byte(`{"input":[{"type":"message","status":"keep"}]}`),
- responseBody: []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: input[4].status.","param":"input[4].status"}}`),
- },
- {
- name: "status path only mentioned",
- body: []byte(`{"input":[{"type":"message","status":"keep"}]}`),
- responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"input[0].status must be completed","param":"input[0].status"}}`),
- },
- {
- name: "content param and message disagree",
- body: []byte(`{"input":[{"type":"message","content":null},{"type":"message","content":null}]}`),
- responseBody: []byte(`{"error":{"code":"invalid_type","message":"Invalid type for input[1].content: got null instead.","param":"input[0].content"}}`),
- },
- {
- name: "content error only mentions null",
- body: []byte(`{"input":[{"type":"message","content":null}]}`),
- responseBody: []byte(`{"error":{"code":"invalid_type","message":"content cannot be null","param":"input[0].content"}}`),
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
- require.NoError(t, err)
- require.False(t, changed)
- require.Nil(t, retryBody)
- })
- }
- }
- func TestOpenAIGatewayService_OAuthRetriesExactRejectedStatus(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":true,"instructions":"test","input":[{"type":"message","role":"user","status":"completed","content":"hello"}]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[0].status'.","param":"input[0].status"}}`),
- newOpenAIRejectedFieldTestResponse(http.StatusOK, "data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_ok\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"),
- }}
- upstream.responses[1].Header.Set("Content-Type", "text/event-stream")
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIOAuthNamespaceTestAccount(), body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, "completed", gjson.GetBytes(upstream.bodies[0], "input.0.status").String())
- require.False(t, gjson.GetBytes(upstream.bodies[1], "input.0.status").Exists())
- }
- func TestOpenAIGatewayService_APIKeyRetriesExactRejectedNullMessageContent(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"message","role":"assistant","content":null},{"type":"message","role":"user","content":"continue"}]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"invalid_type","message":"Invalid type for 'input[0].content': expected one of a string or a list of input items, but got null instead.","param":"input[0].content"}}`),
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(), newOpenAIRejectedFieldTestContext(body), newOpenAIRejectedFieldTestAccount(), body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, gjson.Null, gjson.GetBytes(upstream.bodies[0], "input.0.content").Type)
- require.Equal(t, gjson.String, gjson.GetBytes(upstream.bodies[1], "input.0.content").Type)
- require.Equal(t, "continue", gjson.GetBytes(upstream.bodies[1], "input.1.content").String())
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRemovesModelRejectedPromptCacheBreakpoint(t *testing.T) {
- tests := []struct {
- name string
- body []byte
- responseBody []byte
- removedPath string
- preserved string
- reason string
- }{
- {
- name: "top level",
- body: []byte(`{"model":"gpt-5.6-sol","prompt_cache_breakpoint":{"type":"message_start"},"input":"hello"}`),
- responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
- removedPath: "prompt_cache_breakpoint",
- preserved: "input",
- reason: "prompt_cache_breakpoint parameter rejection",
- },
- {
- name: "indexed path from message",
- body: []byte(`{"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_start"}},{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`),
- responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[1].prompt_cache_breakpoint is not supported on this model"}}`),
- removedPath: "input.1.prompt_cache_breakpoint",
- preserved: "input.0.prompt_cache_breakpoint",
- reason: "indexed prompt_cache_breakpoint parameter rejection",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, tt.body, tt.responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, tt.reason, reason)
- require.False(t, gjson.GetBytes(retryBody, tt.removedPath).Exists())
- require.True(t, gjson.GetBytes(retryBody, tt.preserved).Exists())
- })
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyRejectsAmbiguousPromptCacheBreakpointErrors(t *testing.T) {
- body := []byte(`{"prompt_cache_breakpoint":{"type":"message_start"},"input":[{"type":"message","prompt_cache_breakpoint":{"type":"message_end"}}]}`)
- tests := []struct {
- name string
- responseBody []byte
- }{
- {
- name: "structured param disagrees",
- responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
- },
- {
- name: "index out of bounds",
- responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"input[4].prompt_cache_breakpoint is not supported on this model","param":"input[4].prompt_cache_breakpoint"}}`),
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, tt.responseBody)
- require.NoError(t, err)
- require.False(t, changed)
- require.Nil(t, retryBody)
- })
- }
- }
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyAcceptsEitherCacheModelRejectionSignal(t *testing.T) {
- tests := []struct {
- name string
- responseBody []byte
- }{
- {
- name: "invalid parameter code",
- responseBody: []byte(`{"error":{"code":"invalid_parameter","message":"This optional cache hint cannot be used here","param":"prompt_cache_breakpoint"}}`),
- },
- {
- name: "model rejection message",
- responseBody: []byte(`{"error":{"code":"invalid_request_error","message":"prompt_cache_breakpoint is not supported on this model","param":"prompt_cache_breakpoint"}}`),
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, []byte(`{"prompt_cache_breakpoint":true,"input":"keep"}`), tt.responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(retryBody, "prompt_cache_breakpoint").Exists())
- require.Equal(t, "keep", gjson.GetBytes(retryBody, "input").String())
- })
- }
- }
- func TestOpenAIResponsesRejectedFieldRetryStateAllowsPromptCacheBreakpointVariantOnce(t *testing.T) {
- body := []byte(`{"input":[{"prompt_cache_breakpoint":{"type":"message_start"}}]}`)
- responseBody := []byte(`{"error":{"code":"invalid_parameter","message":"input[0].prompt_cache_breakpoint is not supported on this model","param":"input[0].prompt_cache_breakpoint"}}`)
- state := newOpenAIResponsesRejectedFieldRetryState(body)
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.True(t, state.Allow(retryBody))
- require.False(t, state.Allow(retryBody))
- }
- func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(),
- newOpenAIRejectedFieldTestContext(body),
- newOpenAIRejectedFieldTestAccount(),
- body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 1)
- require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
- require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
- }
- func TestOpenAIGatewayServiceProactivelyStripsCrossProviderReasoningContent(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"store":true,"input":[` +
- `{"type":"message","role":"user","content":"one"},` +
- `{"type":"message","role":"assistant","content":"two"},` +
- `{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{}"},` +
- `{"type":"function_call_output","call_id":"call_1","output":"ok"},` +
- `{"type":"message","role":"user","content":"five"},` +
- `{"type":"reasoning","summary":[{"type":"summary_text","text":"keep"}],"content":[{"type":"reasoning_text","text":"remove"}]}` +
- `]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(),
- newOpenAIRejectedFieldTestContext(body),
- newOpenAIRejectedFieldTestAccount(),
- body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 1, "reasoning content should be normalized before the first upstream request")
- require.Equal(t, "reasoning", gjson.GetBytes(upstream.bodies[0], "input.5.type").String())
- require.False(t, gjson.GetBytes(upstream.bodies[0], "input.5.content").Exists())
- require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[0], "input.5.summary.0.text").String())
- }
- func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
- accounts := []struct {
- name string
- account *Account
- }{
- {name: "oauth", account: newOpenAIOAuthNamespaceTestAccount()},
- {name: "apikey", account: newOpenAIRejectedFieldTestAccount()},
- }
- for _, tt := range accounts {
- for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
- t.Run(tt.name+path, func(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"test","input":[{"type":"message","role":"user","namespace":"remove","content":[{"type":"input_text","text":"hello","namespace":"nested-keep"}]}]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_namespace_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- c := newOpenAIRejectedFieldTestContext(body)
- c.Request.URL.Path = path
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(),
- c,
- tt.account,
- body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 1, "namespace must be removed before the first upstream request")
- require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
- require.Equal(t, "nested-keep", gjson.GetBytes(upstream.bodies[0], "input.0.content.0.namespace").String())
- })
- }
- }
- }
- func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":4096,"input":[{"type":"message","role":"user","content":{"max_output_tokens":"keep"}}]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens","type":"invalid_request_error"}}`),
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(),
- newOpenAIRejectedFieldTestContext(body),
- newOpenAIRejectedFieldTestAccount(),
- body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- require.Equal(t, int64(4096), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
- require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
- require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String())
- }
- func TestOpenAIGatewayService_ComposesProactiveNamespaceStripWithRejectedFieldRetry(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
- upstream := &httpUpstreamRecorder{responses: []*http.Response{
- newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`),
- newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
- }}
- result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
- context.Background(),
- newOpenAIRejectedFieldTestContext(body),
- newOpenAIRejectedFieldTestAccount(),
- body,
- )
- require.NoError(t, err)
- require.NotNil(t, result)
- require.Len(t, upstream.bodies, 2)
- for _, forwardedBody := range upstream.bodies {
- require.False(t, gjson.GetBytes(forwardedBody, "input.0.namespace").Exists())
- require.False(t, gjson.GetBytes(forwardedBody, "input.1.namespace").Exists())
- }
- require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
- require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
- }
- func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
- return &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{
- URLAllowlist: config.URLAllowlistConfig{Enabled: false},
- }},
- httpUpstream: upstream,
- }
- }
- func newOpenAIRejectedFieldTestContext(body []byte) *gin.Context {
- gin.SetMode(gin.TestMode)
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
- c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
- c.Request.Header.Set("Content-Type", "application/json")
- c.Request.Header.Set("User-Agent", "curl/8.0")
- return c
- }
- func newOpenAIRejectedFieldTestAccount() *Account {
- return &Account{
- ID: 5107,
- Name: "responses-compatible",
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Concurrency: 1,
- Credentials: map[string]any{
- "api_key": "sk-test",
- "base_url": "https://compat.example",
- },
- Extra: map[string]any{
- openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
- openai_compat.ExtraKeyResponsesSupported: true,
- },
- Status: StatusActive,
- Schedulable: true,
- }
- }
- func newOpenAIOAuthNamespaceTestAccount() *Account {
- return &Account{
- ID: 5108,
- Name: "openai-oauth-namespace",
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Concurrency: 1,
- Credentials: map[string]any{
- "access_token": "oauth-token",
- "chatgpt_account_id": "chatgpt-account",
- },
- Status: StatusActive,
- Schedulable: true,
- }
- }
- func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response {
- return &http.Response{
- StatusCode: status,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(body)),
- }
- }
- // A replayed conversation carries many items of the same type, each with a
- // status the upstream schema rejects. One rejection must clear all of them:
- // clearing one index per round trip exhausts the bounded retry budget.
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsStatusForWholeType(t *testing.T) {
- input := make([]string, 0, 12)
- for i := 0; i < 10; i++ {
- input = append(input, `{"type":"tool_search_output","status":"completed","call_id":"call_`+strconv.Itoa(i)+`","tools":[]}`)
- }
- input = append(input, `{"type":"message","role":"user","status":"completed","content":"hi"}`)
- body := []byte(`{"input":[` + strings.Join(input, ",") + `]}`)
- responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[7].status'.","param":"input[7].status"}}`)
- retryBody, reason, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.NotEmpty(t, reason)
- for i := 0; i < 10; i++ {
- require.False(t, gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".status").Exists(),
- "every tool_search_output must lose its status in a single retry, index %d did not", i)
- require.Equal(t, "call_"+strconv.Itoa(i), gjson.GetBytes(retryBody, "input."+strconv.Itoa(i)+".call_id").String(),
- "unrelated fields must survive")
- }
- require.Equal(t, "completed", gjson.GetBytes(retryBody, "input.10.status").String(),
- "a different item type keeps its status: the rejection only proves this type has none")
- }
- // The rejected item may carry no type to match on.
- func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyClearsUntypedStatusAtIndexOnly(t *testing.T) {
- body := []byte(`{"input":[{"status":"keep_a"},{"status":"remove"}]}`)
- responseBody := []byte(`{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].status'.","param":"input[1].status"}}`)
- retryBody, _, changed, err := normalizeOpenAIResponsesRejectedFieldRetryBody(http.StatusBadRequest, body, responseBody)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "keep_a", gjson.GetBytes(retryBody, "input.0.status").String())
- require.False(t, gjson.GetBytes(retryBody, "input.1.status").Exists())
- }
|