openai_image_intent_hint_test.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. package service
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "sync"
  9. "sync/atomic"
  10. "testing"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. "github.com/tidwall/gjson"
  14. )
  15. func newOpenAIImageIntentHintTestContext(transport OpenAIClientTransport) *gin.Context {
  16. c := &gin.Context{}
  17. SetOpenAIClientTransport(c, transport)
  18. return c
  19. }
  20. func countingOpenAIImageIntentClassifier(calls *atomic.Int64) openAIImageIntentClassifier {
  21. return func(endpoint string, requestedModel string, body []byte) bool {
  22. calls.Add(1)
  23. return IsImageGenerationIntent(endpoint, requestedModel, body)
  24. }
  25. }
  26. func TestResolveOpenAIImageIntentHintCachesTrueAndFalse(t *testing.T) {
  27. gin.SetMode(gin.TestMode)
  28. tests := []struct {
  29. name string
  30. body []byte
  31. want bool
  32. }{
  33. {name: "true", body: []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`), want: true},
  34. {name: "false is known", body: []byte(`{"model":"gpt-5.4","input":"write code"}`), want: false},
  35. }
  36. for _, tt := range tests {
  37. t.Run(tt.name, func(t *testing.T) {
  38. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  39. var calls atomic.Int64
  40. classify := countingOpenAIImageIntentClassifier(&calls)
  41. require.Equal(t, tt.want, resolveOpenAIImageIntentHint(c, "gpt-5.4", tt.body, classify))
  42. require.Equal(t, tt.want, resolveOpenAIImageIntentHint(c, "gpt-5.4", tt.body, classify))
  43. require.Equal(t, int64(1), calls.Load())
  44. cached, known := getOpenAIImageIntentHint(c)
  45. require.True(t, known)
  46. require.Equal(t, tt.want, cached)
  47. })
  48. }
  49. }
  50. func TestResolveOpenAIImageIntentHintUsesHandlerSeed(t *testing.T) {
  51. gin.SetMode(gin.TestMode)
  52. for _, seeded := range []bool{false, true} {
  53. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  54. SetOpenAIImageIntentHint(c, seeded)
  55. var calls atomic.Int64
  56. got := resolveOpenAIImageIntentHint(c, "gpt-5.4", []byte(`{"model":"gpt-5.4"}`), countingOpenAIImageIntentClassifier(&calls))
  57. require.Equal(t, seeded, got)
  58. require.Zero(t, calls.Load())
  59. }
  60. }
  61. func TestResolveOpenAIPassthroughImageIntentReusesCanonicalAcrossFailover(t *testing.T) {
  62. gin.SetMode(gin.TestMode)
  63. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  64. body := []byte(`{"model":"gpt-5.4","input":"write code"}`)
  65. var calls atomic.Int64
  66. classify := countingOpenAIImageIntentClassifier(&calls)
  67. for range 3 {
  68. require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", body, "gpt-5.4", body, false, classify))
  69. }
  70. require.Equal(t, int64(1), calls.Load())
  71. }
  72. func TestResolveOpenAIPassthroughImageIntentKeepsCompactMappingAttemptLocal(t *testing.T) {
  73. gin.SetMode(gin.TestMode)
  74. t.Run("text to image", func(t *testing.T) {
  75. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  76. body := []byte(`{"model":"draw-alias","input":"draw"}`)
  77. compactBody := []byte(`{"model":"gpt-image-2","input":"draw"}`)
  78. var calls atomic.Int64
  79. classify := countingOpenAIImageIntentClassifier(&calls)
  80. require.True(t, resolveOpenAIPassthroughImageIntent(c, "draw-alias", body, "gpt-image-2", compactBody, true, classify))
  81. cached, known := getOpenAIImageIntentHint(c)
  82. require.True(t, known)
  83. require.False(t, cached)
  84. require.False(t, resolveOpenAIPassthroughImageIntent(c, "draw-alias", body, "draw-alias", body, false, classify))
  85. require.Equal(t, int64(2), calls.Load())
  86. cached, known = getOpenAIImageIntentHint(c)
  87. require.True(t, known)
  88. require.False(t, cached)
  89. })
  90. t.Run("image to text", func(t *testing.T) {
  91. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  92. body := []byte(`{"model":"gpt-image-2","input":"draw"}`)
  93. compactBody := []byte(`{"model":"gpt-5.4","input":"draw"}`)
  94. var calls atomic.Int64
  95. classify := countingOpenAIImageIntentClassifier(&calls)
  96. require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", body, "gpt-5.4", compactBody, true, classify))
  97. cached, known := getOpenAIImageIntentHint(c)
  98. require.True(t, known)
  99. require.True(t, cached)
  100. require.True(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", body, "gpt-image-2", body, false, classify))
  101. require.Equal(t, int64(2), calls.Load())
  102. })
  103. }
  104. func TestResolveOpenAIPassthroughImageIntentInvalidationDoesNotPolluteCanonical(t *testing.T) {
  105. gin.SetMode(gin.TestMode)
  106. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  107. canonicalBody := []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`)
  108. strippedBody := []byte(`{"model":"gpt-5.4","tools":[]}`)
  109. var calls atomic.Int64
  110. classify := countingOpenAIImageIntentClassifier(&calls)
  111. require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", canonicalBody, "gpt-5.4", strippedBody, true, classify))
  112. require.Equal(t, int64(2), calls.Load(), "unknown canonical and invalidated attempt are classified independently")
  113. cached, known := getOpenAIImageIntentHint(c)
  114. require.True(t, known)
  115. require.True(t, cached)
  116. require.True(t, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", canonicalBody, "gpt-5.4", canonicalBody, false, classify))
  117. require.Equal(t, int64(2), calls.Load())
  118. }
  119. func TestResolveOpenAIPassthroughImageIntentMappedBodyStartsUnknownThenSeedsCanonical(t *testing.T) {
  120. gin.SetMode(gin.TestMode)
  121. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  122. canonicalBody := []byte(`{"model":"gpt-image-2","input":"draw"}`)
  123. strippedAttemptBody := []byte(`{"model":"gpt-5.4","input":"draw"}`)
  124. _, known := getOpenAIImageIntentHint(c)
  125. require.False(t, known)
  126. var calls atomic.Int64
  127. classify := countingOpenAIImageIntentClassifier(&calls)
  128. require.False(t, resolveOpenAIPassthroughImageIntent(c, "gpt-image-2", canonicalBody, "gpt-5.4", strippedAttemptBody, true, classify))
  129. require.Equal(t, int64(2), calls.Load())
  130. cached, known := getOpenAIImageIntentHint(c)
  131. require.True(t, known)
  132. require.True(t, cached)
  133. }
  134. func TestResolveOpenAIPassthroughImageIntentReusesAcrossInvariantMutations(t *testing.T) {
  135. gin.SetMode(gin.TestMode)
  136. tests := []struct {
  137. name string
  138. canonicalBody []byte
  139. attemptBody []byte
  140. want bool
  141. }{
  142. {
  143. name: "oauth sanitize fast policy and reasoning",
  144. canonicalBody: []byte(`{"model":"gpt-5.4","input":[{"type":"input_image","image_url":"data:image/png;base64,"}],"service_tier":"fast","reasoning":{"effort":"minimal"}}`),
  145. attemptBody: []byte(`{"model":"gpt-5.4","input":[],"service_tier":"priority","reasoning":{"effort":"none"},"store":false,"stream":true}`),
  146. want: false,
  147. },
  148. {
  149. name: "namespace flatten",
  150. canonicalBody: []byte(`{"model":"gpt-5.4","tools":[{"type":"namespace","name":"code_tools"}]}`),
  151. attemptBody: []byte(`{"model":"gpt-5.4","tools":[{"type":"function","name":"code_tools.run"}]}`),
  152. want: false,
  153. },
  154. }
  155. for _, tt := range tests {
  156. t.Run(tt.name, func(t *testing.T) {
  157. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  158. var calls atomic.Int64
  159. classify := countingOpenAIImageIntentClassifier(&calls)
  160. require.Equal(t, tt.want, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", tt.canonicalBody, "gpt-5.4", tt.attemptBody, false, classify))
  161. require.Equal(t, tt.want, resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", tt.canonicalBody, "gpt-5.4", tt.attemptBody, false, classify))
  162. require.Equal(t, int64(1), calls.Load())
  163. })
  164. }
  165. }
  166. func TestOpenAIGatewayServicePassthroughCompactImageIntentIsAttemptLocal(t *testing.T) {
  167. gin.SetMode(gin.TestMode)
  168. tests := []struct {
  169. name string
  170. canonicalModel string
  171. compactModel string
  172. wantRejected bool
  173. wantCanonical bool
  174. }{
  175. {
  176. name: "text to image rejects",
  177. canonicalModel: "gpt-5.4",
  178. compactModel: "gpt-image-2",
  179. wantRejected: true,
  180. },
  181. {
  182. name: "image to text reaches upstream",
  183. canonicalModel: "gpt-image-2",
  184. compactModel: "gpt-5.4",
  185. wantCanonical: true,
  186. },
  187. }
  188. for _, tt := range tests {
  189. t.Run(tt.name, func(t *testing.T) {
  190. upstream := &httpUpstreamRecorder{resp: &http.Response{
  191. StatusCode: http.StatusOK,
  192. Header: http.Header{"Content-Type": []string{"application/json"}},
  193. Body: io.NopCloser(strings.NewReader(`{"id":"resp_compact","model":"` + tt.compactModel + `","usage":{"input_tokens":1,"output_tokens":1}}`)),
  194. }}
  195. svc := newOpenAIImageGenerationControlTestService(upstream)
  196. c, recorder := newOpenAIImageGenerationControlTestContext(false, "unit-test-agent/1.0")
  197. c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses/compact", nil)
  198. SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
  199. account := newOpenAIImageGenerationControlTestAccount()
  200. account.Extra = map[string]any{"openai_passthrough": true}
  201. account.Credentials = map[string]any{
  202. "api_key": "sk-test",
  203. "compact_model_mapping": map[string]any{
  204. tt.canonicalModel: tt.compactModel,
  205. },
  206. }
  207. body := []byte(`{"model":"` + tt.canonicalModel + `","stream":false,"input":"draw"}`)
  208. result, err := svc.Forward(context.Background(), c, account, body)
  209. cached, known := getOpenAIImageIntentHint(c)
  210. require.True(t, known)
  211. require.Equal(t, tt.wantCanonical, cached)
  212. if tt.wantRejected {
  213. require.Error(t, err)
  214. require.Nil(t, result)
  215. require.Equal(t, http.StatusForbidden, recorder.Code)
  216. require.Nil(t, upstream.lastReq)
  217. return
  218. }
  219. require.NoError(t, err)
  220. require.NotNil(t, result)
  221. require.NotNil(t, upstream.lastReq)
  222. require.Equal(t, tt.compactModel, gjson.GetBytes(upstream.lastBody, "model").String())
  223. })
  224. }
  225. }
  226. func TestResolveOpenAIImageIntentHintExcludesWebSocketAndUnknownTransport(t *testing.T) {
  227. gin.SetMode(gin.TestMode)
  228. for _, transport := range []OpenAIClientTransport{OpenAIClientTransportWS, OpenAIClientTransportUnknown} {
  229. c := newOpenAIImageIntentHintTestContext(transport)
  230. var calls atomic.Int64
  231. classify := countingOpenAIImageIntentClassifier(&calls)
  232. body := []byte(`{"model":"gpt-5.4","input":"write code"}`)
  233. require.False(t, resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify))
  234. require.False(t, resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify))
  235. require.Equal(t, int64(2), calls.Load())
  236. _, known := getOpenAIImageIntentHint(c)
  237. require.False(t, known)
  238. }
  239. }
  240. func TestResolveOpenAIImageIntentHintConcurrentRequestsAreIsolated(t *testing.T) {
  241. gin.SetMode(gin.TestMode)
  242. const requests = 32
  243. var calls atomic.Int64
  244. classify := countingOpenAIImageIntentClassifier(&calls)
  245. var wg sync.WaitGroup
  246. results := make([][2]bool, requests)
  247. for i := range requests {
  248. wg.Add(1)
  249. go func(index int, image bool) {
  250. defer wg.Done()
  251. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  252. body := []byte(`{"model":"gpt-5.4","input":"write code"}`)
  253. if image {
  254. body = []byte(`{"model":"gpt-5.4","tools":[{"type":"image_generation"}]}`)
  255. }
  256. results[index][0] = resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify)
  257. results[index][1] = resolveOpenAIImageIntentHint(c, "gpt-5.4", body, classify)
  258. }(i, i%2 == 0)
  259. }
  260. wg.Wait()
  261. for i, result := range results {
  262. require.Equal(t, i%2 == 0, result[0])
  263. require.Equal(t, result[0], result[1])
  264. }
  265. require.Equal(t, int64(requests), calls.Load())
  266. }
  267. var openAIImageIntentHintBenchmarkSink bool
  268. func BenchmarkOpenAIPassthroughImageIntentHintLargeBody(b *testing.B) {
  269. body := []byte(`{"model":"gpt-5.4","input":"` + strings.Repeat("x", 4<<20) + `"}`)
  270. const attempts = 4
  271. b.Run("scan_each_attempt", func(b *testing.B) {
  272. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  273. b.ReportAllocs()
  274. calls := 0
  275. for range b.N {
  276. c.Set(openAIImageIntentHintContextKey, struct{}{})
  277. for range attempts {
  278. calls++
  279. openAIImageIntentHintBenchmarkSink = IsImageGenerationIntent(openAIResponsesEndpoint, "gpt-5.4", body)
  280. }
  281. }
  282. b.ReportMetric(float64(calls)/float64(b.N), "classifier_calls/op")
  283. })
  284. b.Run("request_scoped_hint", func(b *testing.B) {
  285. c := newOpenAIImageIntentHintTestContext(OpenAIClientTransportHTTP)
  286. b.ReportAllocs()
  287. calls := 0
  288. classify := func(endpoint string, requestedModel string, candidate []byte) bool {
  289. calls++
  290. return IsImageGenerationIntent(endpoint, requestedModel, candidate)
  291. }
  292. for range b.N {
  293. c.Set(openAIImageIntentHintContextKey, struct{}{})
  294. for range attempts {
  295. openAIImageIntentHintBenchmarkSink = resolveOpenAIPassthroughImageIntent(c, "gpt-5.4", body, "gpt-5.4", body, false, classify)
  296. }
  297. }
  298. b.ReportMetric(float64(calls)/float64(b.N), "classifier_calls/op")
  299. })
  300. }