openai_routing_hint_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/gin-gonic/gin"
  11. "github.com/stretchr/testify/require"
  12. )
  13. func TestSetOpenAICodexRoutingHintCanonicalizesOfficialServiceTiers(t *testing.T) {
  14. oauthAccount := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  15. tests := []struct {
  16. name string
  17. model string
  18. serviceTier string
  19. want string
  20. }{
  21. {name: "fast alias", model: "gpt-5.6", serviceTier: " fast ", want: "model=gpt-5.6;tier=priority"},
  22. {name: "priority", model: "gpt-5.6", serviceTier: "priority", want: "model=gpt-5.6;tier=priority"},
  23. {name: "flex", model: "gpt-5.6", serviceTier: "flex", want: "model=gpt-5.6;tier=flex"},
  24. {name: "ultrafast", model: "gpt-5.6", serviceTier: "ultrafast", want: "model=gpt-5.6;tier=ultrafast"},
  25. {name: "explicit default sentinel", model: "gpt-5.6", serviceTier: "default", want: "model=gpt-5.6"},
  26. {name: "omitted tier", model: "gpt-5.6", want: "model=gpt-5.6"},
  27. {name: "auto is not expanded without catalog support", model: "gpt-5.6", serviceTier: "auto", want: "model=gpt-5.6"},
  28. {name: "scale is not expanded without catalog support", model: "gpt-5.6", serviceTier: "scale", want: "model=gpt-5.6"},
  29. {name: "unknown tier does not expand protocol", model: "gpt-5.6", serviceTier: "turbo", want: "model=gpt-5.6"},
  30. }
  31. for _, tt := range tests {
  32. t.Run(tt.name, func(t *testing.T) {
  33. headers := make(http.Header)
  34. setOpenAICodexRoutingHint(headers, oauthAccount, tt.model, tt.serviceTier)
  35. require.Equal(t, tt.want, headers.Get(openAICodexRoutingHintHeader))
  36. })
  37. }
  38. t.Run("invalid header value is omitted", func(t *testing.T) {
  39. headers := make(http.Header)
  40. setOpenAICodexRoutingHint(headers, oauthAccount, "gpt-5.6\ninvalid", "priority")
  41. require.Empty(t, headers.Get(openAICodexRoutingHintHeader))
  42. })
  43. for _, model := range []string{"gpt-5.6;evil", "gpt=5.6"} {
  44. t.Run("delimiter in model is omitted: "+model, func(t *testing.T) {
  45. headers := make(http.Header)
  46. setOpenAICodexRoutingHint(headers, oauthAccount, model, "priority")
  47. require.Empty(t, headers.Get(openAICodexRoutingHintHeader))
  48. })
  49. }
  50. t.Run("api key strips gateway-owned hint in every key casing", func(t *testing.T) {
  51. headers := make(http.Header)
  52. headers[openAICodexRoutingHintHeader] = []string{"lowercase-spoof"}
  53. headers["X-Codex-Routing-Hint"] = []string{"canonical-spoof"}
  54. setOpenAICodexRoutingHint(headers, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "gpt-5.6", "priority")
  55. for key := range headers {
  56. require.False(t, strings.EqualFold(key, openAICodexRoutingHintHeader))
  57. }
  58. })
  59. t.Run("oauth replaces spoofed lowercase hint", func(t *testing.T) {
  60. headers := make(http.Header)
  61. headers[openAICodexRoutingHintHeader] = []string{"model=spoof;tier=flex"}
  62. setOpenAICodexRoutingHint(headers, oauthAccount, "gpt-5.6", "priority")
  63. require.Equal(t, "model=gpt-5.6;tier=priority", headers.Get(openAICodexRoutingHintHeader))
  64. require.Len(t, headers, 1)
  65. })
  66. }
  67. func TestOpenAIOAuthHTTPBuildersSendRoutingHintFromFinalBody(t *testing.T) {
  68. gin.SetMode(gin.TestMode)
  69. oauthAccount := &Account{
  70. Platform: PlatformOpenAI,
  71. Type: AccountTypeOAuth,
  72. Credentials: map[string]any{
  73. "chatgpt_account_id": "test-account",
  74. },
  75. }
  76. svc := &OpenAIGatewayService{}
  77. tests := []struct {
  78. name string
  79. body []byte
  80. want string
  81. }{
  82. {name: "fast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`), want: "model=gpt-5.6-codex;tier=priority"},
  83. {name: "flex", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"flex"}`), want: "model=gpt-5.6-codex;tier=flex"},
  84. {name: "ultrafast", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"ultrafast"}`), want: "model=gpt-5.6-codex;tier=ultrafast"},
  85. {name: "default", body: []byte(`{"model":"gpt-5.6-codex","service_tier":"default"}`), want: "model=gpt-5.6-codex"},
  86. {name: "omitted", body: []byte(`{"model":"gpt-5.6-codex"}`), want: "model=gpt-5.6-codex"},
  87. }
  88. for _, tt := range tests {
  89. t.Run(tt.name, func(t *testing.T) {
  90. for _, passthrough := range []bool{false, true} {
  91. mode := "ordinary"
  92. if passthrough {
  93. mode = "passthrough"
  94. }
  95. t.Run(mode, func(t *testing.T) {
  96. recorder := httptest.NewRecorder()
  97. c, _ := gin.CreateTestContext(recorder)
  98. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(tt.body))
  99. var req *http.Request
  100. var err error
  101. if passthrough {
  102. req, err = svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, oauthAccount, tt.body, "test-token")
  103. } else {
  104. req, err = svc.buildUpstreamRequest(context.Background(), c, oauthAccount, tt.body, "test-token", false, "", true)
  105. }
  106. require.NoError(t, err)
  107. require.Equal(t, tt.want, req.Header.Get(openAICodexRoutingHintHeader))
  108. })
  109. }
  110. })
  111. }
  112. }
  113. func TestOpenAIHTTPPassthroughStripsOnlyOAuthLegacyResponsesBeta(t *testing.T) {
  114. gin.SetMode(gin.TestMode)
  115. svc := &OpenAIGatewayService{cfg: &config.Config{
  116. Security: config.SecurityConfig{
  117. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  118. },
  119. }}
  120. body := []byte(`{"model":"gpt-5.6-codex","service_tier":"priority"}`)
  121. build := func(t *testing.T, account *Account, betaValues []string, rawLowercaseKey bool) http.Header {
  122. t.Helper()
  123. recorder := httptest.NewRecorder()
  124. c, _ := gin.CreateTestContext(recorder)
  125. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  126. if rawLowercaseKey {
  127. c.Request.Header["openai-beta"] = append([]string(nil), betaValues...)
  128. } else {
  129. for _, value := range betaValues {
  130. c.Request.Header.Add("OpenAI-Beta", value)
  131. }
  132. }
  133. req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "test-token")
  134. require.NoError(t, err)
  135. return req.Header
  136. }
  137. oauth := &Account{
  138. Platform: PlatformOpenAI,
  139. Type: AccountTypeOAuth,
  140. Credentials: map[string]any{
  141. "chatgpt_account_id": "test-account",
  142. },
  143. }
  144. apiKey := &Account{
  145. Platform: PlatformOpenAI,
  146. Type: AccountTypeAPIKey,
  147. Credentials: map[string]any{
  148. "api_key": "test-api-key",
  149. },
  150. }
  151. t.Run("oauth legacy only is removed including raw lowercase key", func(t *testing.T) {
  152. headers := build(t, oauth, []string{"responses=experimental"}, true)
  153. require.Empty(t, headers.Values("OpenAI-Beta"))
  154. })
  155. t.Run("oauth mixed beta preserves independent tokens", func(t *testing.T) {
  156. headers := build(t, oauth, []string{
  157. "responses=experimental, future_feature=v1",
  158. "another_feature=v2, RESPONSES=EXPERIMENTAL",
  159. }, false)
  160. require.Equal(t, []string{"future_feature=v1", "another_feature=v2"}, headers.Values("OpenAI-Beta"))
  161. })
  162. t.Run("api key explicit beta remains caller controlled", func(t *testing.T) {
  163. headers := build(t, apiKey, []string{"responses=experimental, future_feature=v1"}, false)
  164. require.Equal(t, []string{"responses=experimental, future_feature=v1"}, headers.Values("OpenAI-Beta"))
  165. })
  166. }
  167. func TestBuildOpenAIWSHeadersSendsOAuthRoutingHintOnly(t *testing.T) {
  168. gin.SetMode(gin.TestMode)
  169. recorder := httptest.NewRecorder()
  170. c, _ := gin.CreateTestContext(recorder)
  171. c.Request = httptest.NewRequest(http.MethodGet, "/v1/responses", nil)
  172. svc := &OpenAIGatewayService{}
  173. decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}
  174. build := func(t *testing.T, account *Account, tier string) http.Header {
  175. headers, _, err := svc.buildOpenAIWSHeaders(
  176. context.Background(),
  177. c,
  178. account,
  179. "test-token",
  180. decision,
  181. true,
  182. "",
  183. "",
  184. "",
  185. "gpt-5.6-codex",
  186. tier,
  187. )
  188. require.NoError(t, err)
  189. return headers
  190. }
  191. oauthAccount := &Account{
  192. Platform: PlatformOpenAI,
  193. Type: AccountTypeOAuth,
  194. Credentials: map[string]any{
  195. "chatgpt_account_id": "test-account",
  196. },
  197. }
  198. require.Equal(t, "model=gpt-5.6-codex;tier=priority", build(t, oauthAccount, "fast").Get(openAICodexRoutingHintHeader))
  199. require.Equal(t, "model=gpt-5.6-codex;tier=ultrafast", build(t, oauthAccount, "ultrafast").Get(openAICodexRoutingHintHeader))
  200. require.Equal(t, "model=gpt-5.6-codex", build(t, oauthAccount, "default").Get(openAICodexRoutingHintHeader))
  201. require.Empty(t, build(t, &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, "priority").Get(openAICodexRoutingHintHeader))
  202. }
  203. func TestOpenAIRoutingDiagnosticsUseFinalDerivedValuesOnly(t *testing.T) {
  204. gin.SetMode(gin.TestMode)
  205. logSink, restore := captureStructuredLog(t)
  206. defer restore()
  207. account := &Account{
  208. ID: 917,
  209. Platform: PlatformOpenAI,
  210. Type: AccountTypeOAuth,
  211. Credentials: map[string]any{
  212. "chatgpt_account_id": "chatgpt-account",
  213. },
  214. }
  215. body := []byte(`{"model":"gpt-5.6-codex","service_tier":"fast"}`)
  216. svc := &OpenAIGatewayService{}
  217. recorder := httptest.NewRecorder()
  218. c, _ := gin.CreateTestContext(recorder)
  219. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  220. c.Request.Header.Set("Authorization", "Bearer caller-secret")
  221. c.Request.Header.Set(openAICodexRoutingHintHeader, "model=caller-secret")
  222. _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "oauth-secret", false, "", true)
  223. require.NoError(t, err)
  224. decision := OpenAIWSProtocolDecision{Transport: OpenAIUpstreamTransportResponsesWebsocketV2}
  225. _, _, err = svc.buildOpenAIWSHeaders(
  226. context.Background(), c, account, "oauth-secret", decision, true,
  227. "", "", "", "gpt-5.6-codex", "fast",
  228. )
  229. require.NoError(t, err)
  230. require.True(t, logSink.ContainsMessageAtLevel("openai routing decision", "debug"))
  231. require.True(t, logSink.ContainsFieldValue("account_id", "917"))
  232. require.True(t, logSink.ContainsFieldValue("final_model", "gpt-5.6-codex"))
  233. require.True(t, logSink.ContainsFieldValue("final_service_tier", "priority"))
  234. require.True(t, logSink.ContainsFieldValue("routing_hint_generated", "true"))
  235. require.True(t, logSink.ContainsFieldValue("transport", "http"))
  236. require.True(t, logSink.ContainsFieldValue("transport", string(OpenAIUpstreamTransportResponsesWebsocketV2)))
  237. require.True(t, logSink.ContainsFieldValue("ws_affinity_decision", "not_applicable"))
  238. require.True(t, logSink.ContainsFieldValue("ws_affinity_decision", "soft_routing_hint"))
  239. require.False(t, logSink.ContainsFieldValue("authorization", "caller-secret"))
  240. require.False(t, logSink.ContainsFieldValue("credentials", "oauth-secret"))
  241. require.False(t, logSink.ContainsFieldValue("routing_hint", "caller-secret"))
  242. }
  243. func TestOpenAIWSConnPoolPreferredContinuationIgnoresRoutingHintChanges(t *testing.T) {
  244. cfg := &config.Config{}
  245. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 2
  246. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  247. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 2
  248. pool := newOpenAIWSConnPool(cfg)
  249. dialer := &openAIWSCountingDialer{}
  250. pool.setClientDialerForTest(dialer)
  251. account := &Account{ID: 913, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  252. acquire := func(t *testing.T, hint, preferred string, forcePreferred bool) *openAIWSConnLease {
  253. t.Helper()
  254. headers := make(http.Header)
  255. if hint != "" {
  256. headers.Set(openAICodexRoutingHintHeader, hint)
  257. }
  258. lease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{
  259. Account: account,
  260. WSURL: "wss://example.com/v1/responses",
  261. Headers: headers,
  262. PreferredConnID: preferred,
  263. ForcePreferredConn: forcePreferred,
  264. })
  265. require.NoError(t, err)
  266. require.NotNil(t, lease)
  267. return lease
  268. }
  269. standard := acquire(t, "model=gpt-5.6-codex", "", false)
  270. connID := standard.ConnID()
  271. standard.Release()
  272. priority := acquire(t, "model=gpt-5.6-codex;tier=priority", connID, true)
  273. require.True(t, priority.Reused())
  274. require.Equal(t, connID, priority.ConnID())
  275. priority.Release()
  276. standardAgain := acquire(t, "model=gpt-5.6-codex", connID, true)
  277. require.True(t, standardAgain.Reused())
  278. require.Equal(t, connID, standardAgain.ConnID())
  279. standardAgain.Release()
  280. require.Equal(t, 1, dialer.DialCount(), "routing hint is dial-time advisory, not continuation compatibility")
  281. }
  282. func TestOpenAIWSConnPoolUsesRoutingHintAsSoftDialAffinity(t *testing.T) {
  283. cfg := &config.Config{}
  284. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 4
  285. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  286. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 4
  287. pool := newOpenAIWSConnPool(cfg)
  288. dialer := &openAIWSCountingDialer{}
  289. pool.setClientDialerForTest(dialer)
  290. account := &Account{ID: 913, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  291. acquire := func(t *testing.T, hint string) *openAIWSConnLease {
  292. headers := make(http.Header)
  293. headers.Set(openAICodexRoutingHintHeader, hint)
  294. lease, err := pool.Acquire(context.Background(), openAIWSAcquireRequest{
  295. Account: account,
  296. WSURL: "wss://example.com/v1/responses",
  297. Headers: headers,
  298. })
  299. require.NoError(t, err)
  300. require.NotNil(t, lease)
  301. return lease
  302. }
  303. priority := acquire(t, "model=gpt-5.6-codex;tier=priority")
  304. priorityConnID := priority.ConnID()
  305. priority.Release()
  306. priorityAgain := acquire(t, "model=gpt-5.6-codex;tier=priority")
  307. require.True(t, priorityAgain.Reused())
  308. require.Equal(t, priorityConnID, priorityAgain.ConnID())
  309. priorityAgain.Release()
  310. flex := acquire(t, "model=gpt-5.6-codex;tier=flex")
  311. require.False(t, flex.Reused())
  312. require.NotEqual(t, priorityConnID, flex.ConnID())
  313. flex.Release()
  314. otherModel := acquire(t, "model=gpt-5.5-codex;tier=priority")
  315. require.False(t, otherModel.Reused())
  316. require.NotEqual(t, priorityConnID, otherModel.ConnID())
  317. otherModel.Release()
  318. defaultTier := acquire(t, "model=gpt-5.6-codex")
  319. require.False(t, defaultTier.Reused())
  320. require.NotEqual(t, priorityConnID, defaultTier.ConnID())
  321. defaultConnID := defaultTier.ConnID()
  322. defaultTier.Release()
  323. defaultAgain := acquire(t, "model=gpt-5.6-codex")
  324. require.True(t, defaultAgain.Reused())
  325. require.Equal(t, defaultConnID, defaultAgain.ConnID())
  326. defaultAgain.Release()
  327. require.Equal(t, 4, dialer.DialCount())
  328. }