openai_gpt56_max_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net/http"
  8. "net/http/httptest"
  9. "strings"
  10. "testing"
  11. "github.com/Wei-Shaw/sub2api/internal/config"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. func TestAstraForwardPreservesMaxInPayloadAndUsage(t *testing.T) {
  17. for _, requestedModel := range []string{"gpt-6-astra", "astra-public"} {
  18. for _, stream := range []bool{false, true} {
  19. t.Run(fmt.Sprintf("%s/stream=%t", requestedModel, stream), func(t *testing.T) {
  20. 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}}`
  21. contentType := "application/json"
  22. if stream {
  23. response = "data: {\"type\":\"response.completed\",\"response\":" + response + "}\n\ndata: [DONE]\n\n"
  24. contentType = "text/event-stream"
  25. }
  26. upstream := &httpUpstreamRecorder{resp: &http.Response{StatusCode: http.StatusOK,
  27. Header: http.Header{"Content-Type": []string{contentType}}, Body: io.NopCloser(strings.NewReader(response))}}
  28. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  29. account := rawGPT56ResponsesAPIKeyAccount(requestedModel, "gpt-6-astra")
  30. body, err := json.Marshal(map[string]any{"model": requestedModel, "stream": stream, "input": "hello", "reasoning": map[string]string{"effort": "max"}})
  31. require.NoError(t, err)
  32. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  33. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(string(body)))
  34. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  35. result, err := svc.Forward(context.Background(), c, account, body)
  36. require.NoError(t, err)
  37. require.NotNil(t, result)
  38. require.Equal(t, "gpt-6-astra", gjson.GetBytes(upstream.lastBody, "model").String())
  39. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  40. require.NotNil(t, result.ReasoningEffort)
  41. require.Equal(t, "max", *result.ReasoningEffort)
  42. usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
  43. billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
  44. usageService := newOpenAIRecordUsageServiceWithBillingRepoForTest(usageRepo, billingRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
  45. err = usageService.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
  46. Result: result, Account: account, User: &User{ID: 21}, APIKey: &APIKey{ID: 22, Group: &Group{RateMultiplier: 1}},
  47. })
  48. require.NoError(t, err)
  49. require.NotNil(t, usageRepo.lastLog)
  50. require.Equal(t, "max", *usageRepo.lastLog.ReasoningEffort)
  51. require.Equal(t, "max", *usageRepo.lastLog.RequestedReasoningEffort)
  52. })
  53. }
  54. }
  55. }
  56. func TestNormalizeOpenAIReasoningEffortForMaxCapableModels(t *testing.T) {
  57. tests := []struct {
  58. name string
  59. raw string
  60. model string
  61. want string
  62. }{
  63. {name: "Astra 保留 max", raw: "max", model: "gpt-6-astra", want: "max"},
  64. {name: "Sol 保留 max", raw: "max", model: "gpt-5.6-sol", want: "max"},
  65. {name: "Terra 保留 max", raw: "max", model: "openai/gpt-5.6-terra", want: "max"},
  66. {name: "Luna 后缀保留 max", raw: "max", model: "gpt-5.6-luna-2026-07-09", want: "max"},
  67. {name: "DeepSeek V4 保留 max", raw: "max", model: "deepseek-v4-pro", want: "max"},
  68. {name: "旧 GPT 模型沿用 xhigh", raw: "max", model: "gpt-5.5", want: "xhigh"},
  69. }
  70. for _, tt := range tests {
  71. t.Run(tt.name, func(t *testing.T) {
  72. require.Equal(t, tt.want, normalizeOpenAIReasoningEffortForModel(tt.raw, tt.model))
  73. })
  74. }
  75. }
  76. func TestNormalizeOpenAICodexCompactReasoningEffortDowngradesMax(t *testing.T) {
  77. body := []byte(`{"model":"gpt-5.6-sol","input":"compact me","reasoning":{"effort":"max","summary":"auto"}}`)
  78. normalized, changed, err := normalizeOpenAICodexCompactReasoningEffort(body, "gpt-5.6-sol")
  79. require.NoError(t, err)
  80. require.True(t, changed)
  81. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(normalized, "model").String())
  82. require.Equal(t, "xhigh", gjson.GetBytes(normalized, "reasoning.effort").String())
  83. require.Equal(t, "auto", gjson.GetBytes(normalized, "reasoning.summary").String())
  84. }
  85. func TestNormalizeOpenAICodexCompactReasoningEffortForAccountScopesCompatibility(t *testing.T) {
  86. gin.SetMode(gin.TestMode)
  87. body := []byte(`{"model":"gpt-5.6-sol","input":"compact me","reasoning":{"effort":"max"}}`)
  88. tests := []struct {
  89. name string
  90. path string
  91. account *Account
  92. changed bool
  93. want string
  94. }{
  95. {
  96. name: "OpenAI OAuth compact 降级",
  97. path: "/openai/v1/responses/compact",
  98. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  99. changed: true,
  100. want: "xhigh",
  101. },
  102. {
  103. name: "OpenAI OAuth 普通请求保留",
  104. path: "/openai/v1/responses",
  105. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  106. want: "max",
  107. },
  108. {
  109. name: "OpenAI API Key compact 保留",
  110. path: "/openai/v1/responses/compact",
  111. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  112. want: "max",
  113. },
  114. {
  115. name: "Grok OAuth compact 保留",
  116. path: "/openai/v1/responses/compact",
  117. account: &Account{Platform: PlatformGrok, Type: AccountTypeOAuth},
  118. want: "max",
  119. },
  120. }
  121. for _, tt := range tests {
  122. t.Run(tt.name, func(t *testing.T) {
  123. rec := httptest.NewRecorder()
  124. c, _ := gin.CreateTestContext(rec)
  125. c.Request = httptest.NewRequest(http.MethodPost, tt.path, nil)
  126. normalized, changed, err := normalizeOpenAICodexCompactReasoningEffortForAccount(c, tt.account, body)
  127. require.NoError(t, err)
  128. require.Equal(t, tt.changed, changed)
  129. require.Equal(t, tt.want, gjson.GetBytes(normalized, "reasoning.effort").String())
  130. })
  131. }
  132. }
  133. func TestOpenAIGatewayServiceForwardPreservesGPT56MaxEffort(t *testing.T) {
  134. gin.SetMode(gin.TestMode)
  135. upstream := &httpUpstreamRecorder{
  136. resp: &http.Response{
  137. StatusCode: http.StatusOK,
  138. Header: http.Header{"Content-Type": []string{"application/json"}},
  139. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  140. },
  141. }
  142. cfg := &config.Config{}
  143. cfg.Security.URLAllowlist.Enabled = false
  144. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  145. account := &Account{
  146. ID: 7,
  147. Name: "openai-apikey",
  148. Platform: PlatformOpenAI,
  149. Type: AccountTypeAPIKey,
  150. Concurrency: 1,
  151. Credentials: map[string]any{
  152. "api_key": "sk-test",
  153. "base_url": "https://example.com",
  154. },
  155. Extra: map[string]any{"use_responses_api": true},
  156. }
  157. rec := httptest.NewRecorder()
  158. c, _ := gin.CreateTestContext(rec)
  159. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  160. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  161. body := []byte(`{"model":"gpt-5.6-sol","stream":false,"reasoning":{"effort":"max"},"input":"hello"}`)
  162. result, err := svc.Forward(context.Background(), c, account, body)
  163. require.NoError(t, err)
  164. require.NotNil(t, result)
  165. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  166. require.NotNil(t, result.ReasoningEffort)
  167. require.Equal(t, "max", *result.ReasoningEffort)
  168. }
  169. func TestOpenAIGatewayServiceForwardPreservesMappedGPT56MaxEffort(t *testing.T) {
  170. gin.SetMode(gin.TestMode)
  171. upstream := &httpUpstreamRecorder{
  172. resp: &http.Response{
  173. StatusCode: http.StatusOK,
  174. Header: http.Header{"Content-Type": []string{"application/json"}},
  175. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  176. },
  177. }
  178. cfg := &config.Config{}
  179. cfg.Security.URLAllowlist.Enabled = false
  180. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  181. account := &Account{
  182. ID: 9,
  183. Name: "openai-apikey-mapped",
  184. Platform: PlatformOpenAI,
  185. Type: AccountTypeAPIKey,
  186. Concurrency: 1,
  187. Credentials: map[string]any{
  188. "api_key": "sk-test",
  189. "base_url": "https://example.com",
  190. "model_mapping": map[string]any{
  191. "sol": "gpt-5.6-sol",
  192. },
  193. },
  194. Extra: map[string]any{"use_responses_api": true},
  195. }
  196. rec := httptest.NewRecorder()
  197. c, _ := gin.CreateTestContext(rec)
  198. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  199. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  200. body := []byte(`{"model":"sol","stream":false,"reasoning":{"effort":"max"},"input":"hello"}`)
  201. result, err := svc.Forward(context.Background(), c, account, body)
  202. require.NoError(t, err)
  203. require.NotNil(t, result)
  204. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
  205. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  206. require.NotNil(t, result.ReasoningEffort)
  207. require.Equal(t, "max", *result.ReasoningEffort)
  208. }
  209. func TestOpenAIGatewayServiceForwardOAuthCompactDowngradesMaxEffort(t *testing.T) {
  210. gin.SetMode(gin.TestMode)
  211. upstream := &httpUpstreamRecorder{
  212. resp: &http.Response{
  213. StatusCode: http.StatusOK,
  214. Header: http.Header{"Content-Type": []string{"application/json"}},
  215. Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
  216. },
  217. }
  218. cfg := &config.Config{}
  219. cfg.Security.URLAllowlist.Enabled = false
  220. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  221. account := &Account{
  222. ID: 8,
  223. Name: "openai-oauth",
  224. Platform: PlatformOpenAI,
  225. Type: AccountTypeOAuth,
  226. Concurrency: 1,
  227. Credentials: map[string]any{
  228. "access_token": "oauth-token",
  229. "chatgpt_account_id": "chatgpt-acc",
  230. },
  231. Status: StatusActive,
  232. Schedulable: true,
  233. }
  234. rec := httptest.NewRecorder()
  235. c, _ := gin.CreateTestContext(rec)
  236. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil)
  237. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  238. body := []byte(`{"model":"gpt-5.6-sol","instructions":"compact-test","input":"hello","reasoning":{"effort":"max"}}`)
  239. result, err := svc.Forward(context.Background(), c, account, body)
  240. require.NoError(t, err)
  241. require.NotNil(t, result)
  242. require.NotNil(t, upstream.lastReq)
  243. require.Equal(t, chatgptCodexURL+"/compact", upstream.lastReq.URL.String())
  244. require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  245. require.NotNil(t, result.ReasoningEffort)
  246. require.Equal(t, "xhigh", *result.ReasoningEffort)
  247. }
  248. func TestOpenAIGatewayServiceForwardOAuthRemoteCompactV2PreservesResponsesWire(t *testing.T) {
  249. gin.SetMode(gin.TestMode)
  250. upstream := &httpUpstreamRecorder{
  251. resp: &http.Response{
  252. StatusCode: http.StatusOK,
  253. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  254. Body: io.NopCloser(strings.NewReader(
  255. "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"summary\"}}\n\n" +
  256. "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n" +
  257. "data: [DONE]\n\n",
  258. )),
  259. },
  260. }
  261. cfg := &config.Config{}
  262. cfg.Security.URLAllowlist.Enabled = false
  263. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  264. account := &Account{
  265. ID: 10,
  266. Name: "openai-oauth-responses",
  267. Platform: PlatformOpenAI,
  268. Type: AccountTypeOAuth,
  269. Concurrency: 1,
  270. Credentials: map[string]any{
  271. "access_token": "oauth-token",
  272. "chatgpt_account_id": "chatgpt-acc",
  273. "compact_model_mapping": map[string]any{
  274. "gpt-5.6-sol": "gpt-5.6-sol-openai-compact",
  275. },
  276. },
  277. Status: StatusActive,
  278. Schedulable: true,
  279. }
  280. rec := httptest.NewRecorder()
  281. c, _ := gin.CreateTestContext(rec)
  282. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  283. c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2")
  284. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  285. 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"}}`)
  286. result, err := svc.Forward(context.Background(), c, account, body)
  287. require.NoError(t, err)
  288. require.NotNil(t, result)
  289. require.NotNil(t, upstream.lastReq)
  290. require.Equal(t, chatgptCodexURL, upstream.lastReq.URL.String())
  291. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
  292. require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
  293. require.Equal(t, "compaction_trigger", gjson.GetBytes(upstream.lastBody, "input.#(type==\"compaction_trigger\").type").String())
  294. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  295. require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String())
  296. require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features"))
  297. require.Contains(t, rec.Body.String(), `"type":"compaction"`)
  298. require.Contains(t, rec.Body.String(), `"encrypted_content":"summary"`)
  299. require.NotNil(t, result.ReasoningEffort)
  300. require.Equal(t, "max", *result.ReasoningEffort)
  301. }
  302. func TestOpenAIGatewayServiceForwardAPIKeyRemoteCompactV2PreservesResponsesWire(t *testing.T) {
  303. gin.SetMode(gin.TestMode)
  304. upstream := &httpUpstreamRecorder{
  305. resp: &http.Response{
  306. StatusCode: http.StatusOK,
  307. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  308. Body: io.NopCloser(strings.NewReader(
  309. "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"summary\"}}\n\n" +
  310. "data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n" +
  311. "data: [DONE]\n\n",
  312. )),
  313. },
  314. }
  315. cfg := &config.Config{}
  316. cfg.Security.URLAllowlist.Enabled = false
  317. svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
  318. account := &Account{
  319. ID: 11,
  320. Name: "openai-apikey-responses",
  321. Platform: PlatformOpenAI,
  322. Type: AccountTypeAPIKey,
  323. Concurrency: 1,
  324. Credentials: map[string]any{
  325. "api_key": "sk-test",
  326. "base_url": "https://example.com/v1",
  327. "compact_model_mapping": map[string]any{
  328. "gpt-5.6-sol": "gpt-5.6-sol-openai-compact",
  329. },
  330. },
  331. Extra: map[string]any{"use_responses_api": true},
  332. Status: StatusActive,
  333. Schedulable: true,
  334. }
  335. rec := httptest.NewRecorder()
  336. c, _ := gin.CreateTestContext(rec)
  337. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
  338. c.Request.Header.Set("x-codex-beta-features", "remote_compaction_v2")
  339. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  340. 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"}}`)
  341. result, err := svc.Forward(context.Background(), c, account, body)
  342. require.NoError(t, err)
  343. require.NotNil(t, result)
  344. require.NotNil(t, upstream.lastReq)
  345. require.Equal(t, "https://example.com/v1/responses", upstream.lastReq.URL.String())
  346. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
  347. require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
  348. require.Equal(t, "compaction_trigger", gjson.GetBytes(upstream.lastBody, "input.#(type==\"compaction_trigger\").type").String())
  349. require.Equal(t, "max", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  350. require.Equal(t, "all_turns", gjson.GetBytes(upstream.lastBody, "reasoning.context").String())
  351. require.Equal(t, "remote_compaction_v2", upstream.lastReq.Header.Get("x-codex-beta-features"))
  352. require.Contains(t, rec.Body.String(), `"type":"compaction"`)
  353. require.Contains(t, rec.Body.String(), `"encrypted_content":"summary"`)
  354. require.NotNil(t, result.ReasoningEffort)
  355. require.Equal(t, "max", *result.ReasoningEffort)
  356. }