| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- package service
- import (
- "testing"
- "github.com/stretchr/testify/require"
- "github.com/tidwall/gjson"
- )
- func TestNormalizeOpenAIPassthroughOAuthBody_RemovesUnsupportedUser(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4","input":"hello","user":"user_123","metadata":{"user_id":"user_123"},"prompt_cache_retention":"24h","safety_identifier":"sid","stream_options":{"include_usage":true}}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.True(t, changed)
- for _, field := range openAIChatGPTInternalUnsupportedFields {
- require.False(t, gjson.GetBytes(normalized, field).Exists(), "%s should be stripped", field)
- }
- require.True(t, gjson.GetBytes(normalized, "stream").Bool())
- require.False(t, gjson.GetBytes(normalized, "store").Bool())
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesCompatibilityFields(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","prompt":"hello","commands":["unsupported"],"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "hello", gjson.GetBytes(normalized, "input.0.content").String())
- for _, field := range []string{"prompt", "commands", "truncation", "stop_sequences", "chat_template_kwargs"} {
- require.False(t, gjson.GetBytes(normalized, field).Exists(), field)
- }
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_NormalizesReasoningMode(t *testing.T) {
- body := []byte(`{"model":"gpt-5.6-sol","input":"hello","reasoning":{"mode":"pro"}}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
- require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
- }
- func TestNormalizeOpenAIOAuthResponsesCompatibilityBody_PreservesExplicitInput(t *testing.T) {
- body := []byte(`{"model":"gpt-5.5","input":"explicit","prompt":"legacy"}`)
- normalized, changed, err := normalizeOpenAIOAuthResponsesCompatibilityBody(body)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "explicit", gjson.GetBytes(normalized, "input").String())
- require.False(t, gjson.GetBytes(normalized, "prompt").Exists())
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_OnlyStripsOAuthFields(t *testing.T) {
- body := []byte(`{"type":"response.create","prompt":"hello","commands":{},"truncation":"auto","stop_sequences":["END"],"chat_template_kwargs":{"enable_thinking":true}}`)
- oauthBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "hello", gjson.GetBytes(oauthBody, "input").String())
- for _, field := range []string{"prompt", "commands", "truncation", "stop_sequences", "chat_template_kwargs"} {
- require.False(t, gjson.GetBytes(oauthBody, field).Exists(), field)
- }
- apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
- require.NoError(t, err)
- require.False(t, changed)
- require.JSONEq(t, string(body), string(apiKeyBody))
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesNativeItemIDs(t *testing.T) {
- body := []byte(`{"type":"response.create","model":"gpt-5.6-sol","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"}}]}`)
- for _, oauth := range []bool{false, true} {
- accountType := AccountTypeAPIKey
- if oauth {
- accountType = AccountTypeOAuth
- }
- normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "response.create", gjson.GetBytes(normalized, "type").String())
- require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
- require.Equal(t, "ctc_valid", gjson.GetBytes(normalized, "input.1.id").String())
- require.False(t, gjson.GetBytes(normalized, "input.2.id").Exists())
- require.Equal(t, "tsc_valid", gjson.GetBytes(normalized, "input.3.id").String())
- // Native Responses call_id values are correlation keys, not item IDs.
- require.Equal(t, "call_custom_1", gjson.GetBytes(normalized, "input.0.call_id").String())
- require.Equal(t, "call_search_1", gjson.GetBytes(normalized, "input.2.call_id").String())
- }
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_APIKeyStoreFalseReplay(t *testing.T) {
- body := []byte(`{"type":"response.create","store":false,"parallel_tool_calls":true,"input":[` +
- `{"type":"reasoning","id":"rs_drop","summary":[]},` +
- `{"type":"reasoning","id":"rs_keep","call_id":"remove","encrypted_content":"cipher"},` +
- `{"type":"message","content":"continue"}` +
- `]}`)
- normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- }, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
- require.Equal(t, int64(2), gjson.GetBytes(normalized, "input.#").Int())
- require.False(t, gjson.GetBytes(normalized, "input.0.id").Exists())
- require.False(t, gjson.GetBytes(normalized, "input.0.call_id").Exists())
- require.True(t, gjson.GetBytes(normalized, "input.0.summary").IsArray())
- require.Equal(t, "message", gjson.GetBytes(normalized, "input.1.type").String())
- }
- func TestNormalizeOpenAIResponsesReasoningMode(t *testing.T) {
- tests := []struct {
- name string
- body string
- wantEffort string
- }{
- {name: "pro maps to max", body: `{"reasoning":{"mode":"pro"}}`, wantEffort: "max"},
- {name: "explicit effort wins", body: `{"reasoning":{"mode":"pro","effort":"high"}}`, wantEffort: "high"},
- {name: "other mode only removed", body: `{"reasoning":{"mode":"standard"}}`},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body))
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
- require.Equal(t, tt.wantEffort, gjson.GetBytes(normalized, "reasoning.effort").String())
- })
- }
- }
- func TestNormalizeOpenAIResponsesReasoningMode_AstraPreservesBody(t *testing.T) {
- // GPT-6 Astra 保留官方 reasoning.mode 与 reasoning.effort 各自原样:
- // 不删 mode、缺失 effort 也不补 max。非 Astra 的旧兼容行为不受影响。
- tests := []struct {
- name string
- body string
- }{
- {name: "pro + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`},
- {name: "standard + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"standard","effort":"max"}}`},
- {name: "missing mode + max preserved", body: `{"model":"gpt-6-astra","reasoning":{"effort":"max"}}`},
- {name: "pro + explicit high preserved", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"high"}}`},
- {name: "pro without effort does not inject max", body: `{"model":"gpt-6-astra","reasoning":{"mode":"pro"}}`},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body))
- require.NoError(t, err)
- require.False(t, changed)
- require.JSONEq(t, tt.body, string(normalized))
- })
- }
- }
- func TestNormalizeOpenAIResponsesReasoningMode_NonAstraKeepsLegacyBehavior(t *testing.T) {
- // 确保 guard 只放过 Astra,非 Astra model 字段不影响既有 strip/pro->max 语义。
- tests := []struct {
- name string
- body string
- wantEffort string
- }{
- {name: "gpt-5.6-sol pro maps to max", body: `{"model":"gpt-5.6-sol","reasoning":{"mode":"pro"}}`, wantEffort: "max"},
- {name: "gpt-5.4 explicit effort wins", body: `{"model":"gpt-5.4","reasoning":{"mode":"pro","effort":"high"}}`, wantEffort: "high"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- normalized, changed, err := normalizeOpenAIResponsesReasoningMode([]byte(tt.body))
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
- require.Equal(t, tt.wantEffort, gjson.GetBytes(normalized, "reasoning.effort").String())
- })
- }
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_AstraPreservesReasoningMode(t *testing.T) {
- body := []byte(`{"model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`)
- normalized, _, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String())
- require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_AstraPreservesReasoningMode(t *testing.T) {
- body := []byte(`{"type":"response.create","model":"gpt-6-astra","reasoning":{"mode":"pro","effort":"max"}}`)
- for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
- normalized, _, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
- require.NoError(t, err)
- require.Equal(t, "pro", gjson.GetBytes(normalized, "reasoning.mode").String())
- require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
- }
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_ReasoningModeAccountScope(t *testing.T) {
- body := []byte(`{"type":"response.create","reasoning":{"mode":"pro"}}`)
- for _, accountType := range []string{AccountTypeOAuth, AccountTypeSetupToken} {
- normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "max", gjson.GetBytes(normalized, "reasoning.effort").String())
- require.False(t, gjson.GetBytes(normalized, "reasoning.mode").Exists())
- }
- apiKeyBody, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, false)
- require.NoError(t, err)
- require.False(t, changed)
- require.JSONEq(t, string(body), string(apiKeyBody))
- }
- func TestNormalizeOpenAIResponsesWebSocketCompatibilityBody_SanitizesToolSchemas(t *testing.T) {
- body := []byte(`{"type":"response.create","tools":[{"type":"function","name":"search","parameters":{"type":null,"properties":{"q":{"type":"string","pattern":"^(?=.*foo).+$"}}}}]}`)
- for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth} {
- normalized, changed, err := normalizeOpenAIResponsesWebSocketCompatibilityBody(body, &Account{Platform: PlatformOpenAI, Type: accountType}, false)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "object", gjson.GetBytes(normalized, "tools.0.parameters.type").String())
- require.False(t, gjson.GetBytes(normalized, "tools.0.parameters.properties.q.pattern").Exists())
- }
- }
- func TestNormalizeOpenAIResponseFormatSchemasBody_PreservesNonStrictOptionalFields(t *testing.T) {
- body := []byte(`{"text":{"format":{"type":"json_schema","strict":false,"schema":{"properties":{"tags":{"items":{"type":"string"},"uniqueItems":true}},"minProperties":1,"maxProperties":4}}}}`)
- normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.type").String())
- require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.properties.tags.type").String())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.minProperties").Exists())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.properties.tags.uniqueItems").Exists())
- require.Equal(t, int64(4), gjson.GetBytes(normalized, "text.format.schema.maxProperties").Int())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.required").Exists())
- }
- func TestNormalizeOpenAIResponseFormatSchemasBody_DoesNotExpandStrictSchema(t *testing.T) {
- body := []byte(`{"response_format":{"type":"json_schema","json_schema":{"strict":true,"schema":{"properties":{"name":{"type":"string"}}}}}}`)
- normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
- require.NoError(t, err)
- require.True(t, changed) // Safe type inference still applies.
- require.Equal(t, "object", gjson.GetBytes(normalized, "response_format.json_schema.schema.type").String())
- require.False(t, gjson.GetBytes(normalized, "response_format.json_schema.schema.required").Exists())
- require.False(t, gjson.GetBytes(normalized, "response_format.json_schema.schema.additionalProperties").Exists())
- }
- func TestNormalizeOpenAIResponseFormatSchemasBody_TraversesNestedSchemaContainers(t *testing.T) {
- body := []byte(`{
- "text":{"format":{"type":"json_schema","schema":{
- "$defs":{"entry":{"properties":{"name":{"type":"string"}},"minProperties":1,"maxProperties":3}},
- "additionalProperties":{"items":{"type":"string"},"uniqueItems":true},
- "prefixItems":[{"properties":{"id":{"type":"string"}},"minProperties":1}],
- "dependentSchemas":{"kind":{"properties":{"value":{"type":"string"}},"uniqueItems":true}},
- "not":{"items":{"type":"string"},"minProperties":1}
- }}}
- }`)
- normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.$defs.entry.type").String())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.$defs.entry.minProperties").Exists())
- require.Equal(t, int64(3), gjson.GetBytes(normalized, "text.format.schema.$defs.entry.maxProperties").Int())
- require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.additionalProperties.type").String())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.additionalProperties.uniqueItems").Exists())
- require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.prefixItems.0.type").String())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.prefixItems.0.minProperties").Exists())
- require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.dependentSchemas.kind.type").String())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.dependentSchemas.kind.uniqueItems").Exists())
- require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.not.type").String())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.not.minProperties").Exists())
- require.False(t, gjson.GetBytes(normalized, "text.format.schema.required").Exists())
- }
- func TestNormalizeOpenAIResponseFormatSchemasBody_PreservesExistingTypeValues(t *testing.T) {
- body := []byte(`{"text":{"format":{"type":"json_schema","schema":{"properties":{"union":{"type":["object","null"],"properties":{"name":{"type":"string"}}},"custom":{"type":{"vendor":"shape"},"properties":{"id":{"type":"string"}}},"inferred":{"type":null,"items":{"type":"string"}}}}}}}`)
- normalized, changed, err := normalizeOpenAIResponseFormatSchemasBody(body)
- require.NoError(t, err)
- require.True(t, changed)
- require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.type").String())
- require.Equal(t, "object", gjson.GetBytes(normalized, "text.format.schema.properties.union.type.0").String())
- require.Equal(t, "null", gjson.GetBytes(normalized, "text.format.schema.properties.union.type.1").String())
- require.True(t, gjson.GetBytes(normalized, "text.format.schema.properties.custom.type").IsObject())
- require.Equal(t, "array", gjson.GetBytes(normalized, "text.format.schema.properties.inferred.type").String())
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_CompactRemovesUnsupportedUser(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4","input":"hello","user":"user_123","metadata":{"user_id":"user_123"},"stream":true,"store":true}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, true)
- require.NoError(t, err)
- require.True(t, changed)
- require.False(t, gjson.GetBytes(normalized, "user").Exists())
- require.False(t, gjson.GetBytes(normalized, "metadata").Exists())
- require.False(t, gjson.GetBytes(normalized, "stream").Exists())
- require.False(t, gjson.GetBytes(normalized, "store").Exists())
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_StringInputWrappedAsArray(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4","input":"hello world"}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.True(t, changed)
- input := gjson.GetBytes(normalized, "input")
- require.True(t, input.IsArray(), "string input should be converted to array")
- items := input.Array()
- require.Len(t, items, 1)
- require.Equal(t, "message", items[0].Get("type").String())
- require.Equal(t, "user", items[0].Get("role").String())
- require.Equal(t, "hello world", items[0].Get("content").String())
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_EmptyStringInputWrappedAsEmptyArray(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4","input":" "}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.True(t, changed)
- input := gjson.GetBytes(normalized, "input")
- require.True(t, input.IsArray())
- require.Len(t, input.Array(), 0, "whitespace-only input should become empty array")
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_ObjectInputWrappedAsArray(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4","input":{"type":"message","role":"user","content":"hi"}}`)
- normalized, changed, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- require.True(t, changed)
- input := gjson.GetBytes(normalized, "input")
- require.True(t, input.IsArray(), "object input should be wrapped in array")
- items := input.Array()
- require.Len(t, items, 1)
- require.Equal(t, "message", items[0].Get("type").String())
- }
- func TestNormalizeOpenAIPassthroughOAuthBody_ArrayInputUnchanged(t *testing.T) {
- body := []byte(`{"model":"gpt-5.4","input":[{"type":"message","role":"user","content":"hi"}]}`)
- normalized, _, err := normalizeOpenAIPassthroughOAuthBody(body, false)
- require.NoError(t, err)
- input := gjson.GetBytes(normalized, "input")
- require.True(t, input.IsArray())
- require.Len(t, input.Array(), 1)
- require.Equal(t, "message", input.Array()[0].Get("type").String())
- }
- func TestDetectOpenAIPassthroughInstructionsRejectReason(t *testing.T) {
- for _, tt := range []struct {
- name string
- body string
- want string
- }{
- {name: "missing is optional", body: `{"model":"gpt-5.1-codex"}`, want: ""},
- {name: "non string remains rejected", body: `{"instructions":{"text":"invalid"}}`, want: "instructions_not_string"},
- {name: "empty remains rejected", body: `{"instructions":" "}`, want: "instructions_empty"},
- {name: "non empty remains accepted", body: `{"instructions":"client guidance"}`, want: ""},
- } {
- t.Run(tt.name, func(t *testing.T) {
- require.Equal(t, tt.want, detectOpenAIPassthroughInstructionsRejectReason("gpt-5.1-codex", []byte(tt.body)))
- })
- }
- }
|