package service import ( "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) func TestAstraForwardPreservesMaxInPayloadAndUsage(t *testing.T) { for _, requestedModel := range []string{"gpt-6-astra", "astra-public"} { for _, stream := range []bool{false, true} { t.Run(fmt.Sprintf("%s/stream=%t", requestedModel, stream), func(t *testing.T) { response := `{"id":"resp_astra","model":"gpt-6-astra","status":"completed","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":1,"output_tokens":2}}` contentType := "application/json" if stream { response = "data: {\"type\":\"response.completed\",\"response\":" + response + "}\n\ndata: [DONE]\n\n" contentType = "text/event-stream" } upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(response))}} svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := rawGPT56ResponsesAPIKeyAccount(requestedModel, "gpt-6-astra") body, err := json.Marshal(map[string]any{"model": requestedModel, "stream": stream, "input": "hello", "reasoning": map[string]string{"effort": "max"}}) require.NoError(t, err) c, _ := gin.CreateTestContext(httptest.NewRecorder()) c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(body))) SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "gpt-6-astra", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "max", *result.ReasoningEffort) usageRepo := &openAIRecordUsageLogRepoStub{inserted: true} billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}} usageService := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil) err = usageService.RecordUsage(context.Background(), &OpenAIRecordUsageInput{ Result: result, Account: account, User: &User{ID: 21}, APIKey: &APIKey{ID: 22, Group: &Group{RateMultiplier: 1}}, }) require.NoError(t, err) require.NotNil(t, usageRepo.lastLog) require.Equal(t, "max", *usageRepo.lastLog.ReasoningEffort) require.Equal(t, "max", *usageRepo.lastLog.RequestedReasoningEffort) }) } } } func TestNormalizeOpenAIReasoningEffortForMaxCapableModels(t *testing.T) { tests := []struct { name string raw string model string want string }{ {name: "Astra 保留 max", raw: "max", model: "gpt-6-astra", want: "max"}, {name: "Sol 保留 max", raw: "max", model: "gpt-5.6-sol", want: "max"}, {name: "Terra 保留 max", raw: "max", model: "openai/gpt-5.6-terra", want: "max"}, {name: "Luna 后缀保留 max", raw: "max", model: "gpt-5.6-luna-2026-07-09", want: "max"}, {name: "DeepSeek V4 保留 max", raw: "max", model: "deepseek-v4-pro", want: "max"}, {name: "旧 GPT 模型沿用 xhigh", raw: "max", model: "gpt-5.5", want: "xhigh"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, normalizeOpenAIReasoningEffortForModel(tt.raw, tt.model)) }) } } func TestNormalizeOpenAICodexCompactReasoningEffortDowngradesMax(t *testing.T) { body := []byte(`{"model":"gpt-5.6-sol","input":"compact me","reasoning":{"effort":"max","summary":"auto"}}`) normalized, changed, err := normalizeOpenAICodexCompactReasoningEffort(body, "gpt-5.6-sol") require.NoError(t, err) require.True(t, changed) require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(normalized, "model").String()) require.Equal(t, "xhigh", gjson.GetBytes(normalized, "reasoning.effort").String()) require.Equal(t, "auto", gjson.GetBytes(normalized, "reasoning.summary").String()) } func TestNormalizeOpenAICodexCompactReasoningEffortForAccountScopesCompatibility(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-5.6-sol","input":"compact me","reasoning":{"effort":"max"}}`) tests := []struct { name string path string account *Account changed bool want string }{ { name: "OpenAI OAuth compact 降级", path: "/openai/v1/responses/compact", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, changed: true, want: "xhigh", }, { name: "OpenAI OAuth 普通请求保留", path: "/openai/v1/responses", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, want: "max", }, { name: "OpenAI API Key compact 保留", path: "/openai/v1/responses/compact", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, want: "max", }, { name: "Grok OAuth compact 保留", path: "/openai/v1/responses/compact", account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}, want: "max", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, tt.path, nil) normalized, changed, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, tt.account, body) require.NoError(t, err) require.Equal(t, tt.changed, changed) require.Equal(t, tt.want, gjson.GetBytes(normalized, "reasoning.effort").String()) }) } } func TestOpenAIGatewayServiceForwardPreservesGPT56MaxEffort(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(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), }, } cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} account := &Account{ ID: 7, Name: "openai-apikey", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://example.com", }, Extra: map[string]any{"use_responses_api": true}, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) body := []byte(`{"model":"gpt-5.6-sol","stream":false,"reasoning":{"effort":"max"},"input":"hello"}`) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "max", *result.ReasoningEffort) } func TestOpenAIGatewayServiceForwardPreservesMappedGPT56MaxEffort(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(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), }, } cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} account := &Account{ ID: 9, Name: "openai-apikey-mapped", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://example.com", "model_mapping": map[string]any{ "sol": "gpt-5.6-sol", }, }, Extra: map[string]any{"use_responses_api": true}, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) body := []byte(`{"model":"sol","stream":false,"reasoning":{"effort":"max"},"input":"hello"}`) result, err := svc.Forward(context.Background(), c, account, body) require.NoError(t, err) require.NotNil(t, result) require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String()) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "max", *result.ReasoningEffort) } func TestOpenAIGatewayServiceForwardOAuthCompactDowngradesMaxEffort(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(`{"usage":{"input_tokens":1,"output_tokens":2}}`)), }, } cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} account := &Account{ ID: 8, Name: "openai-oauth", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{ "access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc", }, Status: StatusActive, Schedulable: true, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil) SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) body := []byte(`{"model":"gpt-5.6-sol","instructions":"compact-test","input":"hello","reasoning":{"effort":"max"}}`) 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, chatgptCodexURL+"/compact", upstream.lastReq.URL.String()) require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "xhigh", *result.ReasoningEffort) } func TestOpenAIGatewayServiceForwardOAuthRemoteCompactV2PreservesResponsesWire(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader( "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"summary\"}}\n\n" + "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n" + "data: [DONE]\n\n", )), }, } cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} account := &Account{ ID: 10, Name: "openai-oauth-responses", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1, Credentials: map[string]any{ "access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc", "compact_model_mapping": map[string]any{ "gpt-5.6-sol": "gpt-5.6-sol-openai-compact", }, }, Status: StatusActive, Schedulable: true, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) body := []byte(`{"model":"gpt-5.6-sol","stream":true,"instructions":"response-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}],"reasoning":{"effort":"max","context":"all_turns"}}`) 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, chatgptCodexURL, upstream.lastReq.URL.String()) require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.Equal(t, "compaction_trigger", gjson.GetBytes(upstream.lastBody, "input.#(type==\"compaction_trigger\").type").String()) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String()) require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) require.Contains(t, rec.Body.String(), `"type":"compaction"`) require.Contains(t, rec.Body.String(), `"encrypted_content":"summary"`) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "max", *result.ReasoningEffort) } func TestOpenAIGatewayServiceForwardAPIKeyRemoteCompactV2PreservesResponsesWire(t *testing.T) { gin.SetMode(gin.TestMode) upstream := &httpUpstreamRecorder{ resp: &http.Response{ StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader( "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"summary\"}}\n\n" + "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n" + "data: [DONE]\n\n", )), }, } cfg := &config.Config{} cfg.Security.URLAllowlist.Enabled = false svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} account := &Account{ ID: 11, Name: "openai-apikey-responses", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://example.com/v1", "compact_model_mapping": map[string]any{ "gpt-5.6-sol": "gpt-5.6-sol-openai-compact", }, }, Extra: map[string]any{"use_responses_api": true}, Status: StatusActive, Schedulable: true, } rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil) c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2") SetOpenAIClientTransport(c, OpenAIClientTransportHTTP) body := []byte(`{"model":"gpt-5.6-sol","stream":true,"instructions":"response-test","input":[{"type":"message","role":"user","content":"hello"},{"type":"compaction_trigger"}],"reasoning":{"effort":"max","context":"all_turns"}}`) 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://example.com/v1/responses", upstream.lastReq.URL.String()) require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String()) require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool()) require.Equal(t, "compaction_trigger", gjson.GetBytes(upstream.lastBody, "input.#(type==\"compaction_trigger\").type").String()) require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String()) require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String()) require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features")) require.Contains(t, rec.Body.String(), `"type":"compaction"`) require.Contains(t, rec.Body.String(), `"encrypted_content":"summary"`) require.NotNil(t, result.ReasoningEffort) require.Equal(t, "max", *result.ReasoningEffort) }