openai_fast_policy_ws_test.go 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "sync/atomic"
  10. "testing"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  14. "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
  15. "github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
  16. coderws "github.com/coder/websocket"
  17. "github.com/gin-gonic/gin"
  18. "github.com/stretchr/testify/require"
  19. "github.com/tidwall/gjson"
  20. )
  21. // --- Helper-level (unit) tests for applyOpenAIFastPolicyToWSResponseCreate ---
  22. func TestWSResponseCreate_DefaultPassesPriorityAndNormalizesFast(t *testing.T) {
  23. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  24. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  25. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority","input":[{"type":"input_text","text":"hi"}]}`)
  26. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  27. require.NoError(t, err)
  28. require.Nil(t, blocked)
  29. require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String(), "default policy should preserve priority tier")
  30. // Other fields preserved.
  31. require.Equal(t, "response.create", gjson.GetBytes(updated, "type").String())
  32. require.Equal(t, "gpt-5.5", gjson.GetBytes(updated, "model").String())
  33. require.Equal(t, "hi", gjson.GetBytes(updated, "input.0.text").String())
  34. frame = []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"fast"}`)
  35. updated, blocked, err = svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  36. require.NoError(t, err)
  37. require.Nil(t, blocked)
  38. require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String(), "fast alias should normalize before reaching upstream")
  39. // Mixed-case + whitespace variant should also normalize.
  40. frame = []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":" Fast "}`)
  41. updated, blocked, err = svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  42. require.NoError(t, err)
  43. require.Nil(t, blocked)
  44. require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String())
  45. }
  46. func TestWSResponseCreate_ExplicitFilterStripsServiceTier(t *testing.T) {
  47. svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy())
  48. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  49. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority","input":[{"type":"input_text","text":"hi"}]}`)
  50. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  51. require.NoError(t, err)
  52. require.Nil(t, blocked)
  53. require.NotContains(t, string(updated), `"service_tier"`, "filter action should strip service_tier")
  54. frame = []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"fast"}`)
  55. updated, blocked, err = svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  56. require.NoError(t, err)
  57. require.Nil(t, blocked)
  58. require.NotContains(t, string(updated), `"service_tier"`)
  59. }
  60. func TestWSResponseCreate_UserScopedRuleOverridesGlobalRule(t *testing.T) {
  61. settings := &OpenAIFastPolicySettings{
  62. Rules: []OpenAIFastPolicyRule{
  63. {
  64. ServiceTier: OpenAIFastTierPriority,
  65. Action: BetaPolicyActionFilter,
  66. Scope: BetaPolicyScopeAll,
  67. },
  68. {
  69. ServiceTier: OpenAIFastTierPriority,
  70. Action: BetaPolicyActionPass,
  71. Scope: BetaPolicyScopeAll,
  72. UserIDs: []int64{42},
  73. },
  74. },
  75. }
  76. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  77. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  78. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`)
  79. allowedUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(42))
  80. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(allowedUserCtx, account, "gpt-5.5", frame)
  81. require.NoError(t, err)
  82. require.Nil(t, blocked)
  83. require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String())
  84. otherUserCtx := context.WithValue(context.Background(), ctxkey.UserID, int64(43))
  85. updated, blocked, err = svc.applyOpenAIFastPolicyToWSResponseCreate(otherUserCtx, account, "gpt-5.5", frame)
  86. require.NoError(t, err)
  87. require.Nil(t, blocked)
  88. require.NotContains(t, string(updated), `"service_tier"`)
  89. }
  90. func TestWSResponseCreate_ForcePriorityRewritesKnownTier(t *testing.T) {
  91. settings := &OpenAIFastPolicySettings{
  92. Rules: []OpenAIFastPolicyRule{{
  93. ServiceTier: OpenAIFastTierAny,
  94. Action: OpenAIFastPolicyActionForcePriority,
  95. Scope: BetaPolicyScopeAll,
  96. }},
  97. }
  98. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  99. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  100. for _, tier := range []string{"flex", "auto", "default", "scale", "fast", "priority", "ultrafast"} {
  101. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"` + tier + `"}`)
  102. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  103. require.NoError(t, err)
  104. require.Nil(t, blocked)
  105. require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String(),
  106. "tier %q should be forced to priority", tier)
  107. }
  108. }
  109. func TestWSResponseCreate_GroupForceInjectsMissingTier(t *testing.T) {
  110. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  111. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  112. ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
  113. ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
  114. })
  115. frame := []byte(`{"type":"response.create","model":"gpt-5.6-sol","input":[]}`)
  116. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame)
  117. require.NoError(t, err)
  118. require.Nil(t, blocked)
  119. require.Equal(t, OpenAIFastTierPriority, gjson.GetBytes(updated, "service_tier").String())
  120. }
  121. func TestWSResponseCreate_GroupForceDoesNotTouchOtherFrames(t *testing.T) {
  122. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  123. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
  124. ctx := context.WithValue(context.Background(), ctxkey.Group, &Group{
  125. ID: 7, Platform: PlatformOpenAI, Status: StatusActive, Hydrated: true, ForceOpenAIFast: true,
  126. })
  127. frame := []byte(`{"type":"response.cancel","model":"gpt-5.6-sol"}`)
  128. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(ctx, account, "gpt-5.6-sol", frame)
  129. require.NoError(t, err)
  130. require.Nil(t, blocked)
  131. require.Equal(t, string(frame), string(updated))
  132. }
  133. func TestWSResponseCreate_FlexPassThrough(t *testing.T) {
  134. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  135. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  136. // Default policy has no rules; flex is left untouched.
  137. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"flex"}`)
  138. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  139. require.NoError(t, err)
  140. require.Nil(t, blocked)
  141. require.Equal(t, "flex", gjson.GetBytes(updated, "service_tier").String(), "flex frames must reach upstream untouched under default policy")
  142. }
  143. func TestWSResponseCreate_BlockReturnsTypedError(t *testing.T) {
  144. settings := &OpenAIFastPolicySettings{
  145. Rules: []OpenAIFastPolicyRule{{
  146. ServiceTier: OpenAIFastTierPriority,
  147. Action: BetaPolicyActionBlock,
  148. Scope: BetaPolicyScopeAll,
  149. ErrorMessage: "ws fast blocked",
  150. ModelWhitelist: []string{"gpt-5.5"},
  151. FallbackAction: BetaPolicyActionPass,
  152. }},
  153. }
  154. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  155. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  156. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`)
  157. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  158. require.NoError(t, err)
  159. require.NotNil(t, blocked)
  160. require.Equal(t, "ws fast blocked", blocked.Message)
  161. // On block, payload returned unchanged so caller can inspect / log it.
  162. require.Equal(t, string(frame), string(updated))
  163. }
  164. func TestWSResponseCreate_NoServiceTierUntouched(t *testing.T) {
  165. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  166. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  167. frame := []byte(`{"type":"response.create","model":"gpt-5.5","input":[]}`)
  168. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  169. require.NoError(t, err)
  170. require.Nil(t, blocked)
  171. require.Equal(t, string(frame), string(updated), "no service_tier present must result in zero mutation")
  172. }
  173. func TestWSResponseCreate_NonResponseCreateFrameUntouched(t *testing.T) {
  174. settings := &OpenAIFastPolicySettings{
  175. Rules: []OpenAIFastPolicyRule{{
  176. ServiceTier: OpenAIFastTierPriority,
  177. Action: BetaPolicyActionFilter,
  178. Scope: BetaPolicyScopeAll,
  179. ModelWhitelist: []string{"*"},
  180. FallbackAction: BetaPolicyActionFilter,
  181. }},
  182. }
  183. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  184. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  185. // response.cancel happens to carry a service_tier-shaped field — must not be touched.
  186. frame := []byte(`{"type":"response.cancel","service_tier":"priority"}`)
  187. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  188. require.NoError(t, err)
  189. require.Nil(t, blocked)
  190. require.Equal(t, string(frame), string(updated))
  191. }
  192. // TestWSResponseCreate_EmptyTypeFrameUntouched is the A1 regression: the
  193. // helper used to treat empty type as response.create, which risked stripping
  194. // fields from malformed / unknown client events. After the A1 fix only a
  195. // strict "response.create" match triggers policy.
  196. func TestWSResponseCreate_EmptyTypeFrameUntouched(t *testing.T) {
  197. settings := &OpenAIFastPolicySettings{
  198. Rules: []OpenAIFastPolicyRule{{
  199. ServiceTier: OpenAIFastTierPriority,
  200. Action: BetaPolicyActionFilter,
  201. Scope: BetaPolicyScopeAll,
  202. ModelWhitelist: []string{"*"},
  203. FallbackAction: BetaPolicyActionFilter,
  204. }},
  205. }
  206. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  207. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  208. // Frame with no "type" field: must pass through completely unchanged
  209. // even with a service_tier-shaped field present.
  210. frame := []byte(`{"service_tier":"priority","model":"gpt-5.5"}`)
  211. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  212. require.NoError(t, err)
  213. require.Nil(t, blocked)
  214. require.Equal(t, string(frame), string(updated), "empty type must NOT be policy-checked — Realtime spec requires type, malformed frames are passed through")
  215. // Explicit empty string also passes through.
  216. frame = []byte(`{"type":"","service_tier":"priority","model":"gpt-5.5"}`)
  217. updated, blocked, err = svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  218. require.NoError(t, err)
  219. require.Nil(t, blocked)
  220. require.Equal(t, string(frame), string(updated))
  221. }
  222. // TestBuildOpenAIFastPolicyBlockedWSEvent_HasEventIDAndCode is the B1
  223. // regression: the rendered Realtime error event must carry a non-empty
  224. // event_id (so clients can correlate the rejection) and a stable error.code
  225. // ("policy_violation"). The HTTP-side equivalent is the 403 permission_error
  226. // JSON body emitted by writeOpenAIFastPolicyBlockedResponse.
  227. func TestBuildOpenAIFastPolicyBlockedWSEvent_HasEventIDAndCode(t *testing.T) {
  228. bytes := buildOpenAIFastPolicyBlockedWSEvent(&OpenAIFastBlockedError{Message: "blocked because reasons"})
  229. require.NotNil(t, bytes)
  230. require.Equal(t, "error", gjson.GetBytes(bytes, "type").String())
  231. require.Equal(t, "invalid_request_error", gjson.GetBytes(bytes, "error.type").String())
  232. require.Equal(t, "policy_violation", gjson.GetBytes(bytes, "error.code").String())
  233. require.Equal(t, "blocked because reasons", gjson.GetBytes(bytes, "error.message").String())
  234. eventID := gjson.GetBytes(bytes, "event_id").String()
  235. require.NotEmpty(t, eventID, "event_id must be present so clients can correlate the rejection in their logs")
  236. require.True(t, strings.HasPrefix(eventID, "evt_"), "event_id should follow the evt_<rand> Realtime convention; got %q", eventID)
  237. // Sanity check: two consecutive events get distinct IDs.
  238. other := buildOpenAIFastPolicyBlockedWSEvent(&OpenAIFastBlockedError{Message: "second"})
  239. otherID := gjson.GetBytes(other, "event_id").String()
  240. require.NotEqual(t, eventID, otherID, "event_id must be random per-event")
  241. }
  242. // TestBuildOpenAIFastPolicyBlockedWSEvent_NilSafe ensures the helper returns
  243. // nil for a nil error (defensive guard for callers that always invoke it).
  244. func TestBuildOpenAIFastPolicyBlockedWSEvent_NilSafe(t *testing.T) {
  245. require.Nil(t, buildOpenAIFastPolicyBlockedWSEvent(nil))
  246. }
  247. // --- D5: passthrough wrapper FrameConn — capturedSessionModel fallback ---
  248. // fakePassthroughFrameConn replays a fixed sequence of client frames into the
  249. // policy-enforcing wrapper, then returns io.EOF. Captures all Write attempts
  250. // for write-side assertions (none expected in the D5 test, since the wrapper
  251. // only filters reads).
  252. type fakePassthroughFrameConn struct {
  253. reads [][]byte
  254. idx int
  255. writes [][]byte
  256. closeOnce bool
  257. }
  258. func (f *fakePassthroughFrameConn) ReadFrame(ctx context.Context) (coderws.MessageType, []byte, error) {
  259. if f.idx >= len(f.reads) {
  260. return coderws.MessageText, nil, errOpenAIWSConnClosed
  261. }
  262. payload := f.reads[f.idx]
  263. f.idx++
  264. return coderws.MessageText, payload, nil
  265. }
  266. func (f *fakePassthroughFrameConn) WriteFrame(ctx context.Context, msgType coderws.MessageType, payload []byte) error {
  267. cp := append([]byte(nil), payload...)
  268. f.writes = append(f.writes, cp)
  269. return nil
  270. }
  271. func (f *fakePassthroughFrameConn) Close() error {
  272. f.closeOnce = true
  273. return nil
  274. }
  275. // gpt55WhitelistFastPolicy 返回一份强制带 model whitelist 的策略,用于
  276. // 验证 capturedSessionModel fallback 的语义(默认配置没有规则,fallback
  277. // 路径无法被观察到)。
  278. func gpt55WhitelistFastPolicy() *OpenAIFastPolicySettings {
  279. return &OpenAIFastPolicySettings{
  280. Rules: []OpenAIFastPolicyRule{{
  281. ServiceTier: OpenAIFastTierPriority,
  282. Action: BetaPolicyActionFilter,
  283. Scope: BetaPolicyScopeAll,
  284. ModelWhitelist: []string{"gpt-5.5", "gpt-5.5*"},
  285. FallbackAction: BetaPolicyActionPass,
  286. }},
  287. }
  288. }
  289. // TestPolicyEnforcingFrameConn_FollowupFrameWithoutModelUsesCapturedModel is
  290. // the D5 regression: in passthrough mode a follow-up response.create frame
  291. // without a "model" field must still hit the policy via the session-level
  292. // model captured from the first frame. Without the fallback an empty model
  293. // would miss a model whitelist and silently leak service_tier=priority
  294. // through to the upstream.
  295. func TestPolicyEnforcingFrameConn_FollowupFrameWithoutModelUsesCapturedModel(t *testing.T) {
  296. // 此处特意使用带 whitelist 的策略,以便观察 capturedSessionModel
  297. // fallback 是否生效(默认配置没有规则,fallback 与否结果一致,
  298. // 不能用来覆盖此回归)。
  299. svc := newOpenAIGatewayServiceWithSettings(t, gpt55WhitelistFastPolicy())
  300. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  301. // Simulate the passthrough adapter capturing model from the first frame.
  302. firstFrame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`)
  303. capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstFrame)
  304. require.Equal(t, "gpt-5.5", capturedSessionModel)
  305. // Follow-up frame deliberately omits "model" — Realtime allows this.
  306. followupFrame := []byte(`{"type":"response.create","service_tier":"priority"}`)
  307. inner := &fakePassthroughFrameConn{
  308. reads: [][]byte{followupFrame},
  309. }
  310. wrapper := &openAIWSPolicyEnforcingFrameConn{
  311. inner: inner,
  312. filter: func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
  313. if msgType != coderws.MessageText {
  314. return payload, nil, nil
  315. }
  316. model := openAIWSPassthroughPolicyModelForFrame(account, payload)
  317. if model == "" {
  318. model = capturedSessionModel
  319. }
  320. return svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, model, payload)
  321. },
  322. }
  323. // Read the follow-up frame through the wrapper. The policy MUST still
  324. // trigger filter (gpt-5.5 + priority → filter), so the service_tier
  325. // field is gone by the time the relay sees it.
  326. _, payload, err := wrapper.ReadFrame(context.Background())
  327. require.NoError(t, err)
  328. require.NotContains(t, string(payload), `"service_tier"`,
  329. "D5 regression: empty model on follow-up frame must fall back to capturedSessionModel; whitelist policy filters service_tier=priority for gpt-5.5")
  330. require.Equal(t, "response.create", gjson.GetBytes(payload, "type").String())
  331. }
  332. func TestOpenAIWSPassthroughPolicyModelDoesNotApplyAccountMapping(t *testing.T) {
  333. account := &Account{
  334. Platform: PlatformOpenAI,
  335. Type: AccountTypeOAuth,
  336. Credentials: map[string]any{
  337. "model_mapping": map[string]any{"public-model": "private-model"},
  338. },
  339. Extra: map[string]any{"openai_passthrough": true},
  340. }
  341. responseCreate := []byte(`{"type":"response.create","model":"public-model"}`)
  342. require.Equal(t, "public-model", openAIWSPassthroughPolicyModelForFrame(account, responseCreate))
  343. sessionUpdate := []byte(`{"type":"session.update","session":{"model":"public-model"}}`)
  344. require.Equal(t, "public-model", openAIWSPassthroughPolicyModelFromSessionFrame(account, sessionUpdate))
  345. }
  346. // TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses pins the
  347. // inverse: when the wrapper has NO capturedSessionModel fallback (model is
  348. // empty per-frame and no fallback is wired up), the policy fails to match
  349. // the model whitelist and the frame leaks through unchanged. This documents
  350. // exactly the leak the D5 fix prevents.
  351. func TestPolicyEnforcingFrameConn_WithoutCapturedFallbackPolicyMisses(t *testing.T) {
  352. // 同样使用带 whitelist 的策略以观察 leak。
  353. svc := newOpenAIGatewayServiceWithSettings(t, gpt55WhitelistFastPolicy())
  354. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  355. followupFrame := []byte(`{"type":"response.create","service_tier":"priority"}`)
  356. inner := &fakePassthroughFrameConn{reads: [][]byte{followupFrame}}
  357. wrapper := &openAIWSPolicyEnforcingFrameConn{
  358. inner: inner,
  359. filter: func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
  360. // NO fallback — emulate the pre-fix behavior.
  361. model := openAIWSPassthroughPolicyModelForFrame(account, payload)
  362. return svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, model, payload)
  363. },
  364. }
  365. _, payload, err := wrapper.ReadFrame(context.Background())
  366. require.NoError(t, err)
  367. // Pre-fix: empty model misses ["gpt-5.5","gpt-5.5*"] whitelist → fallback=pass → service_tier kept.
  368. require.Contains(t, string(payload), `"service_tier"`,
  369. "sanity: without capturedSessionModel fallback the leak (D5) reproduces — confirms the fix is load-bearing")
  370. }
  371. // --- Ingress end-to-end test (explicit filter path) ---
  372. // TestWSResponseCreate_IngressFiltersServiceTierBeforeUpstream wires up the
  373. // real ProxyResponsesWebSocketFromClient ingress session pipeline against a
  374. // captureConn upstream and asserts that a client frame with service_tier=fast
  375. // is normalized + filtered out by an explicit admin policy before being
  376. // written upstream.
  377. func TestWSResponseCreate_IngressFiltersServiceTierBeforeUpstream(t *testing.T) {
  378. gin.SetMode(gin.TestMode)
  379. cfg := &config.Config{}
  380. cfg.Security.URLAllowlist.Enabled = false
  381. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  382. cfg.Gateway.OpenAIWS.Enabled = true
  383. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  384. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  385. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  386. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  387. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  388. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  389. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  390. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  391. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
  392. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  393. captureConn := &openAIWSCaptureConn{
  394. events: [][]byte{
  395. []byte(`{"type":"response.completed","response":{"id":"resp_ws_filter_1","model":"gpt-5.5","usage":{"input_tokens":1,"output_tokens":1}}}`),
  396. },
  397. }
  398. captureDialer := &openAIWSCaptureDialer{conn: captureConn}
  399. pool := newOpenAIWSConnPool(cfg)
  400. pool.setClientDialerForTest(captureDialer)
  401. repo := &openAIFastPolicyRepoStub{values: map[string]string{}}
  402. filterPolicyJSON, err := json.Marshal(openAIFastFilterPriorityPolicy())
  403. require.NoError(t, err)
  404. repo.values[SettingKeyOpenAIFastPolicySettings] = string(filterPolicyJSON)
  405. svc := &OpenAIGatewayService{
  406. cfg: cfg,
  407. httpUpstream: &httpUpstreamRecorder{},
  408. cache: &stubGatewayCache{},
  409. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  410. toolCorrector: NewCodexToolCorrector(),
  411. openaiWSPool: pool,
  412. settingService: NewSettingService(repo, cfg),
  413. }
  414. account := &Account{
  415. ID: 901,
  416. Name: "openai-ws-filter",
  417. Platform: PlatformOpenAI,
  418. Type: AccountTypeAPIKey,
  419. Status: StatusActive,
  420. Schedulable: true,
  421. Concurrency: 1,
  422. Credentials: map[string]any{"api_key": "sk-test"},
  423. Extra: map[string]any{
  424. "responses_websockets_v2_enabled": true,
  425. },
  426. }
  427. serverErrCh := make(chan error, 1)
  428. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  429. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
  430. CompressionMode: coderws.CompressionContextTakeover,
  431. })
  432. if err != nil {
  433. serverErrCh <- err
  434. return
  435. }
  436. defer func() { _ = conn.CloseNow() }()
  437. rec := httptest.NewRecorder()
  438. ginCtx, _ := gin.CreateTestContext(rec)
  439. req := r.Clone(r.Context())
  440. req.Header = req.Header.Clone()
  441. req.Header.Set("User-Agent", "unit-test-agent/1.0")
  442. ginCtx.Request = req
  443. readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
  444. _, firstMessage, readErr := conn.Read(readCtx)
  445. cancel()
  446. if readErr != nil {
  447. serverErrCh <- readErr
  448. return
  449. }
  450. serverErrCh <- svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil)
  451. }))
  452. defer wsServer.Close()
  453. dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
  454. clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
  455. cancelDial()
  456. require.NoError(t, err)
  457. defer func() { _ = clientConn.CloseNow() }()
  458. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  459. require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"service_tier":"fast"}`)))
  460. cancelWrite()
  461. readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
  462. _, event, readErr := clientConn.Read(readCtx)
  463. cancelRead()
  464. require.NoError(t, readErr)
  465. require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
  466. require.NoError(t, clientConn.Close(coderws.StatusNormalClosure, "done"))
  467. select {
  468. case serverErr := <-serverErrCh:
  469. require.NoError(t, serverErr)
  470. case <-time.After(5 * time.Second):
  471. t.Fatal("等待 ingress websocket 结束超时")
  472. }
  473. require.Len(t, captureConn.writes, 1, "上游应只收到一条 response.create")
  474. upstream := captureConn.writes[0]
  475. _, hasServiceTier := upstream["service_tier"]
  476. require.False(t, hasServiceTier, "上游收到的 response.create 不应包含 service_tier 字段(已被 fast policy filter 删除)")
  477. require.Equal(t, "response.create", upstream["type"])
  478. require.Equal(t, "gpt-5.5", upstream["model"])
  479. }
  480. // TestWSResponseCreate_IngressBlockSendsErrorEventAndSkipsUpstream is the
  481. // integration flavour of TestWSResponseCreate_BlockReturnsTypedError. It
  482. // asserts that with a custom block rule, the client receives a Realtime-style
  483. // error event AND the upstream FrameConn never receives the offending frame.
  484. func TestWSResponseCreate_IngressBlockSendsErrorEventAndSkipsUpstream(t *testing.T) {
  485. gin.SetMode(gin.TestMode)
  486. cfg := &config.Config{}
  487. cfg.Security.URLAllowlist.Enabled = false
  488. cfg.Security.URLAllowlist.AllowInsecureHTTP = true
  489. cfg.Gateway.OpenAIWS.Enabled = true
  490. cfg.Gateway.OpenAIWS.OAuthEnabled = true
  491. cfg.Gateway.OpenAIWS.APIKeyEnabled = true
  492. cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
  493. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  494. cfg.Gateway.OpenAIWS.MinIdlePerAccount = 0
  495. cfg.Gateway.OpenAIWS.MaxIdlePerAccount = 1
  496. cfg.Gateway.OpenAIWS.QueueLimitPerConn = 8
  497. cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
  498. cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
  499. cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
  500. captureConn := &openAIWSCaptureConn{
  501. // No events queued; the upstream should never get written to anyway.
  502. }
  503. captureDialer := &openAIWSCaptureDialer{conn: captureConn}
  504. pool := newOpenAIWSConnPool(cfg)
  505. pool.setClientDialerForTest(captureDialer)
  506. blockSettings := &OpenAIFastPolicySettings{
  507. Rules: []OpenAIFastPolicyRule{{
  508. ServiceTier: OpenAIFastTierPriority,
  509. Action: BetaPolicyActionBlock,
  510. Scope: BetaPolicyScopeAll,
  511. ErrorMessage: "ws priority blocked for testing",
  512. ModelWhitelist: []string{"gpt-5.5"},
  513. FallbackAction: BetaPolicyActionPass,
  514. }},
  515. }
  516. repo := &openAIFastPolicyRepoStub{values: map[string]string{}}
  517. raw, err := json.Marshal(blockSettings)
  518. require.NoError(t, err)
  519. repo.values[SettingKeyOpenAIFastPolicySettings] = string(raw)
  520. svc := &OpenAIGatewayService{
  521. cfg: cfg,
  522. httpUpstream: &httpUpstreamRecorder{},
  523. cache: &stubGatewayCache{},
  524. openaiWSResolver: NewOpenAIWSProtocolResolver(cfg),
  525. toolCorrector: NewCodexToolCorrector(),
  526. openaiWSPool: pool,
  527. settingService: NewSettingService(repo, cfg),
  528. }
  529. account := &Account{
  530. ID: 902,
  531. Name: "openai-ws-block",
  532. Platform: PlatformOpenAI,
  533. Type: AccountTypeAPIKey,
  534. Status: StatusActive,
  535. Schedulable: true,
  536. Concurrency: 1,
  537. Credentials: map[string]any{"api_key": "sk-test"},
  538. Extra: map[string]any{
  539. "responses_websockets_v2_enabled": true,
  540. },
  541. }
  542. serverErrCh := make(chan error, 1)
  543. wsServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  544. conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
  545. CompressionMode: coderws.CompressionContextTakeover,
  546. })
  547. if err != nil {
  548. serverErrCh <- err
  549. return
  550. }
  551. defer func() { _ = conn.CloseNow() }()
  552. rec := httptest.NewRecorder()
  553. ginCtx, _ := gin.CreateTestContext(rec)
  554. req := r.Clone(r.Context())
  555. req.Header = req.Header.Clone()
  556. req.Header.Set("User-Agent", "unit-test-agent/1.0")
  557. ginCtx.Request = req
  558. readCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
  559. _, firstMessage, readErr := conn.Read(readCtx)
  560. cancel()
  561. if readErr != nil {
  562. serverErrCh <- readErr
  563. return
  564. }
  565. proxyErr := svc.ProxyResponsesWebSocketFromClient(r.Context(), ginCtx, conn, account, "sk-test", firstMessage, nil)
  566. // Mirror the production handler (openai_gateway_handler.go:1325-1328):
  567. // when the proxy returns an OpenAIWSClientCloseError, surface its
  568. // status code to the client via a graceful close handshake. Without
  569. // this the deferred CloseNow() above would tear down the TCP
  570. // connection without sending a close frame, and the C3 timing
  571. // assertion (next read returns CloseStatus=1008) would see EOF
  572. // instead.
  573. var closeErr *OpenAIWSClientCloseError
  574. if errors.As(proxyErr, &closeErr) {
  575. _ = conn.Close(closeErr.StatusCode(), closeErr.Reason())
  576. }
  577. serverErrCh <- proxyErr
  578. }))
  579. defer wsServer.Close()
  580. dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
  581. clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(wsServer.URL, "http"), nil)
  582. cancelDial()
  583. require.NoError(t, err)
  584. defer func() { _ = clientConn.CloseNow() }()
  585. writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
  586. require.NoError(t, clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.5","stream":false,"service_tier":"priority"}`)))
  587. cancelWrite()
  588. // C3 timing assertion: the FIRST frame the client reads must be the
  589. // error event — not a close frame. coder/websocket@v1.8.14 Conn.Write is
  590. // synchronous (writeFrame Flushes the bufio writer at write.go:307-311
  591. // before returning) and the close handshake re-acquires the same
  592. // writeFrameMu, so this ordering is enforced by the library itself; this
  593. // assertion guards against future refactors that might break it.
  594. readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
  595. _, event, readErr := clientConn.Read(readCtx)
  596. cancelRead()
  597. require.NoError(t, readErr, "first read must succeed and return the error event before any close frame")
  598. require.Equal(t, "error", gjson.GetBytes(event, "type").String())
  599. require.Equal(t, "invalid_request_error", gjson.GetBytes(event, "error.type").String())
  600. // B1 regression: event_id + error.code must be populated.
  601. require.Equal(t, "policy_violation", gjson.GetBytes(event, "error.code").String())
  602. require.NotEmpty(t, gjson.GetBytes(event, "event_id").String(), "event_id must be present so clients can correlate")
  603. require.Contains(t, gjson.GetBytes(event, "error.message").String(), "ws priority blocked for testing")
  604. // Next read must surface the close frame (as a CloseError). This
  605. // asserts the [error event, close] ordering — i.e. the close did NOT
  606. // race ahead of the data frame.
  607. readCtx2, cancelRead2 := context.WithTimeout(context.Background(), 3*time.Second)
  608. _, _, secondReadErr := clientConn.Read(readCtx2)
  609. cancelRead2()
  610. require.Error(t, secondReadErr, "after the error event the connection must surface a close")
  611. require.Equal(t, coderws.StatusPolicyViolation, coderws.CloseStatus(secondReadErr),
  612. "close status must be PolicyViolation; got %v", secondReadErr)
  613. select {
  614. case serverErr := <-serverErrCh:
  615. // Server returns an OpenAIWSClientCloseError — handler closes the WS;
  616. // here we just assert it surfaced as the typed close error.
  617. require.Error(t, serverErr)
  618. var closeErr *OpenAIWSClientCloseError
  619. require.True(t, errors.As(serverErr, &closeErr), "block 应返回 OpenAIWSClientCloseError,得到 %T: %v", serverErr, serverErr)
  620. require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode())
  621. case <-time.After(5 * time.Second):
  622. t.Fatal("等待 ingress 关闭超时")
  623. }
  624. // Critical: the offending frame must NEVER reach the upstream.
  625. // captureDialer.DialCount may legitimately be 0 or 1 depending on whether
  626. // the lease was acquired before policy fired; either way, no writes.
  627. require.Empty(t, captureConn.writes, "block 命中后上游不应收到 response.create")
  628. }
  629. // --- HTTP-side gap-filling tests (already covered by existing tests but
  630. // requested to be split out explicitly) ---
  631. // TestApplyOpenAIFastPolicyToBody_BlockShortCircuitsUpstream confirms that
  632. // applyOpenAIFastPolicyToBody surfaces a *OpenAIFastBlockedError when the rule
  633. // action is "block", and that the body is left untouched. The caller (chat
  634. // completions / messages handlers) inspects this typed error and skips the
  635. // upstream HTTP call entirely — see openai_gateway_chat_completions.go:175 and
  636. // openai_gateway_messages.go:149.
  637. func TestApplyOpenAIFastPolicyToBody_BlockShortCircuitsUpstream(t *testing.T) {
  638. settings := &OpenAIFastPolicySettings{
  639. Rules: []OpenAIFastPolicyRule{{
  640. ServiceTier: OpenAIFastTierPriority,
  641. Action: BetaPolicyActionBlock,
  642. Scope: BetaPolicyScopeAll,
  643. ErrorMessage: "priority blocked",
  644. ModelWhitelist: []string{"gpt-5.5"},
  645. FallbackAction: BetaPolicyActionPass,
  646. }},
  647. }
  648. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  649. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  650. body := []byte(`{"model":"gpt-5.5","service_tier":"priority","input":[]}`)
  651. updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body)
  652. require.Error(t, err)
  653. var blocked *OpenAIFastBlockedError
  654. require.True(t, errors.As(err, &blocked), "block must surface as typed error so caller can skip upstream HTTP request")
  655. require.Equal(t, "priority blocked", blocked.Message)
  656. require.Equal(t, string(body), string(updated), "block must not mutate body")
  657. }
  658. // TestForwardAsAnthropicMessages_BetaFastModePassesOpenAIFastPolicyByDefault
  659. // verifies the Anthropic-compat entrypoint chain: anthropic-beta: fast-mode →
  660. // BetaFastMode detection → ServiceTier="priority" injection
  661. // (openai_gateway_messages.go:60) → default OpenAI fast policy pass. We
  662. // exercise the same internal pipeline (Anthropic→Responses + BetaFastMode +
  663. // policy) without spinning up a real upstream HTTP server.
  664. func TestForwardAsAnthropicMessages_BetaFastModePassesOpenAIFastPolicyByDefault(t *testing.T) {
  665. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  666. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  667. // Step 1: parse Anthropic request (mirrors openai_gateway_messages.go:38-50).
  668. anthropicBody := []byte(`{"model":"gpt-5.5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
  669. var anthropicReq apicompat.AnthropicRequest
  670. require.NoError(t, json.Unmarshal(anthropicBody, &anthropicReq))
  671. responsesReq, err := apicompat.AnthropicToResponses(&anthropicReq)
  672. require.NoError(t, err)
  673. // Step 2: BetaFastMode header → service_tier="priority" (mirrors line 58-61).
  674. headers := http.Header{}
  675. headers.Set("anthropic-beta", claude.BetaFastMode)
  676. require.True(t, containsBetaToken(headers.Get("anthropic-beta"), claude.BetaFastMode))
  677. responsesReq.ServiceTier = "priority"
  678. responsesReq.Model = "gpt-5.5"
  679. // Step 3: marshal & apply fast policy (mirrors line 78 + 149).
  680. responsesBody, err := json.Marshal(responsesReq)
  681. require.NoError(t, err)
  682. require.Equal(t, "priority", gjson.GetBytes(responsesBody, "service_tier").String(), "前置:beta 翻译应当注入 priority")
  683. upstreamBody, policyErr := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", responsesBody)
  684. require.NoError(t, policyErr)
  685. // Step 4: default policy must preserve the explicit fast/priority request.
  686. require.Equal(t, "priority", gjson.GetBytes(upstreamBody, "service_tier").String(),
  687. "default policy should pass service_tier=priority through to upstream")
  688. }
  689. // --- Fix1: passthrough capturedSessionModel must follow session.update ---
  690. // TestPolicyEnforcingFrameConn_SessionUpdateRotatesCapturedModel covers the
  691. // fix1 bypass: client opens with a whitelist-miss model (gpt-4o → pass under
  692. // gpt-5.5 whitelist), rotates to gpt-5.5 via session.update, then sends
  693. // response.create without "model". Without the session.update sniffing the
  694. // follow-up frame would fall back to the stale gpt-4o capture and pass — the
  695. // fix updates capturedSessionModel from session.* events so the fallback now
  696. // resolves to gpt-5.5 and the policy filters service_tier.
  697. func TestPolicyEnforcingFrameConn_SessionUpdateRotatesCapturedModel(t *testing.T) {
  698. svc := newOpenAIGatewayServiceWithSettings(t, gpt55WhitelistFastPolicy())
  699. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  700. // Frame 1: response.create with whitelist-miss model — under default
  701. // rule fallback=pass, service_tier stays.
  702. first := []byte(`{"type":"response.create","model":"gpt-4o","service_tier":"priority"}`)
  703. // Frame 2: session.update rotates the session model to gpt-5.5.
  704. rotate := []byte(`{"type":"session.update","session":{"model":"gpt-5.5"}}`)
  705. // Frame 3: response.create WITHOUT model — must inherit gpt-5.5.
  706. followup := []byte(`{"type":"response.create","service_tier":"priority"}`)
  707. inner := &fakePassthroughFrameConn{reads: [][]byte{first, rotate, followup}}
  708. // Replicate the production wiring in openai_ws_v2_passthrough_adapter.go
  709. // so capturedSessionModel state is shared across frames.
  710. capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, first)
  711. require.Equal(t, "gpt-4o", capturedSessionModel)
  712. wrapper := &openAIWSPolicyEnforcingFrameConn{
  713. inner: inner,
  714. filter: func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
  715. if msgType != coderws.MessageText {
  716. return payload, nil, nil
  717. }
  718. if updated := openAIWSPassthroughPolicyModelFromSessionFrame(account, payload); updated != "" {
  719. capturedSessionModel = updated
  720. }
  721. model := openAIWSPassthroughPolicyModelForFrame(account, payload)
  722. if model == "" {
  723. model = capturedSessionModel
  724. }
  725. return svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, model, payload)
  726. },
  727. }
  728. // Frame 1: gpt-4o miss whitelist → pass (service_tier preserved).
  729. _, payload1, err := wrapper.ReadFrame(context.Background())
  730. require.NoError(t, err)
  731. require.Contains(t, string(payload1), `"service_tier"`, "frame1: gpt-4o miss whitelist → pass keeps service_tier")
  732. // Frame 2: session.update — not response.create, untouched, but its
  733. // side effect updates capturedSessionModel to gpt-5.5.
  734. _, payload2, err := wrapper.ReadFrame(context.Background())
  735. require.NoError(t, err)
  736. require.Equal(t, string(rotate), string(payload2), "session.update frame is forwarded verbatim")
  737. require.Equal(t, "gpt-5.5", capturedSessionModel, "fix1: session.update must rotate capturedSessionModel")
  738. // Frame 3: empty model + new captured gpt-5.5 → matches whitelist → filter.
  739. _, payload3, err := wrapper.ReadFrame(context.Background())
  740. require.NoError(t, err)
  741. require.NotContains(t, string(payload3), `"service_tier"`,
  742. "fix1: post-rotate response.create without model must use refreshed capturedSessionModel and trigger filter")
  743. }
  744. // TestPolicyModelFromSessionFrame_OnlySessionUpdate covers the negative
  745. // branches of openAIWSPassthroughPolicyModelFromSessionFrame: only
  746. // client→upstream session.update frames rotate the captured model;
  747. // server→client events (session.created) and unrelated frames must not.
  748. func TestPolicyModelFromSessionFrame_OnlySessionUpdate(t *testing.T) {
  749. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  750. // session.created is a server→client event in the OpenAI Realtime
  751. // protocol — clients never send it, so this filter (which only runs on
  752. // the client→upstream direction) must ignore it even if it appears.
  753. created := []byte(`{"type":"session.created","session":{"model":"gpt-5.5"}}`)
  754. require.Empty(t, openAIWSPassthroughPolicyModelFromSessionFrame(account, created))
  755. // Non-session.* frames must NOT trigger rotation.
  756. notSession := []byte(`{"type":"response.create","session":{"model":"gpt-9"}}`)
  757. require.Empty(t, openAIWSPassthroughPolicyModelFromSessionFrame(account, notSession))
  758. // Missing session.model returns empty — caller keeps the old captured value.
  759. noModel := []byte(`{"type":"session.update","session":{"voice":"alloy"}}`)
  760. require.Empty(t, openAIWSPassthroughPolicyModelFromSessionFrame(account, noModel))
  761. }
  762. // --- Fix2: native /responses normalize "fast" → "priority" on pass ---
  763. // TestApplyOpenAIFastPolicyToBody_PassNormalizesFastAlias is the fix2
  764. // regression. Before the fix, when action=pass, applyOpenAIFastPolicyToBody
  765. // returned the body unchanged so a raw "fast" alias would leak to the
  766. // upstream OpenAI API (which does not accept "fast"). The fix normalizes
  767. // "fast" → "priority" on pass too.
  768. func TestApplyOpenAIFastPolicyToBody_PassNormalizesFastAlias(t *testing.T) {
  769. // Use a policy that deliberately misses gpt-4 so the action is pass.
  770. settings := &OpenAIFastPolicySettings{
  771. Rules: []OpenAIFastPolicyRule{{
  772. ServiceTier: OpenAIFastTierPriority,
  773. Action: BetaPolicyActionFilter,
  774. Scope: BetaPolicyScopeAll,
  775. ModelWhitelist: []string{"gpt-5.5"},
  776. FallbackAction: BetaPolicyActionPass,
  777. }},
  778. }
  779. svc := newOpenAIGatewayServiceWithSettings(t, settings)
  780. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  781. // gpt-4 + "fast" → fallback pass. Body must be rewritten to "priority".
  782. body := []byte(`{"model":"gpt-4","service_tier":"fast"}`)
  783. updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-4", body)
  784. require.NoError(t, err)
  785. require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String(),
  786. "fix2: pass action must still normalize 'fast' → 'priority' so upstream OpenAI accepts the slug")
  787. // Already-canonical "priority" on pass: zero mutation (byte-equal).
  788. body = []byte(`{"model":"gpt-4","service_tier":"priority"}`)
  789. updated, err = svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-4", body)
  790. require.NoError(t, err)
  791. require.Equal(t, string(body), string(updated))
  792. // Mixed-case alias → normalized.
  793. body = []byte(`{"model":"gpt-4","service_tier":" Fast "}`)
  794. updated, err = svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-4", body)
  795. require.NoError(t, err)
  796. require.Equal(t, "priority", gjson.GetBytes(updated, "service_tier").String())
  797. // Unrecognized tier → still no-op (not normalized, since normTier == "").
  798. body = []byte(`{"model":"gpt-4","service_tier":"turbo"}`)
  799. updated, err = svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-4", body)
  800. require.NoError(t, err)
  801. require.Equal(t, string(body), string(updated))
  802. }
  803. // --- Fix3: passthrough billing must reflect post-filter service_tier ---
  804. // TestPassthroughBilling_PostFilterServiceTier is the fix3 regression. The
  805. // passthrough adapter (openai_ws_v2_passthrough_adapter.go) now extracts
  806. // requestServiceTier from firstClientMessage AFTER applyOpenAIFastPolicy
  807. // has rewritten it, so a filter hit preserves a nil outbound tier instead of
  808. // the user-requested "priority". This test pins the
  809. // contract those two helpers must uphold for the adapter's billing path.
  810. func TestPassthroughBilling_PostFilterServiceTier(t *testing.T) {
  811. svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy())
  812. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  813. raw := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`)
  814. // Pre-filter sanity: extracting from the raw frame would (incorrectly,
  815. // pre-fix) report "priority" — this is the very thing the adapter
  816. // must NOT do anymore.
  817. pre := extractOpenAIServiceTierFromBody(raw)
  818. require.NotNil(t, pre)
  819. require.Equal(t, "priority", *pre,
  820. "sanity: raw first frame carries priority that pre-fix billing would have reported")
  821. // Apply explicit policy filter (gpt-5.5 + priority → filter).
  822. filtered, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", raw)
  823. require.NoError(t, err)
  824. require.Nil(t, blocked)
  825. require.NotContains(t, string(filtered), `"service_tier"`)
  826. // Post-filter: extracting from the rewritten frame returns nil. This
  827. // is the value the adapter now passes to OpenAIForwardResult.ServiceTier;
  828. // any observed response tier remains separate for usage-time resolution.
  829. post := extractOpenAIServiceTierFromBody(filtered)
  830. require.Nil(t, post, "fix3: post-filter extraction must preserve a nil outbound tier")
  831. // And the byte-level invariant the adapter relies on: filtering an
  832. // already-filtered frame is a no-op (idempotent), so re-running the
  833. // policy doesn't accidentally re-introduce the field.
  834. again, blocked2, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", filtered)
  835. require.NoError(t, err)
  836. require.Nil(t, blocked2)
  837. require.Equal(t, string(filtered), string(again),
  838. "policy is idempotent: filtering an already-filtered frame leaves bytes unchanged")
  839. }
  840. // TestApplyOpenAIFastPolicyToBody_NonStringServiceTier covers the test gap
  841. // flagged in the review: when a client sends service_tier as a non-string
  842. // (number, null, object, etc.) the policy must NOT panic and must NOT
  843. // pretend the field was filtered. Behavior: skip policy entirely (treat as
  844. // "no usable tier"), forward body unchanged. This mirrors the HTTP entry's
  845. // type-assertion `reqBody["service_tier"].(string); ok` guard.
  846. func TestApplyOpenAIFastPolicyToBody_NonStringServiceTier(t *testing.T) {
  847. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  848. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  849. // Number — gjson .String() coerces to "1" which is not a recognized
  850. // tier alias; normalize returns "" → policy no-ops.
  851. cases := [][]byte{
  852. []byte(`{"model":"gpt-5.5","service_tier":1}`),
  853. []byte(`{"model":"gpt-5.5","service_tier":null}`),
  854. []byte(`{"model":"gpt-5.5","service_tier":{"nested":"priority"}}`),
  855. []byte(`{"model":"gpt-5.5","service_tier":["priority"]}`),
  856. []byte(`{"model":"gpt-5.5","service_tier":true}`),
  857. }
  858. for _, body := range cases {
  859. updated, err := svc.applyOpenAIFastPolicyToBody(context.Background(), account, "gpt-5.5", body)
  860. require.NoError(t, err, "non-string service_tier must not error: %s", string(body))
  861. require.Equal(t, string(body), string(updated),
  862. "non-string service_tier must pass through unchanged: %s", string(body))
  863. }
  864. // Same guard for the WS response.create entry.
  865. for _, body := range cases {
  866. frame := body
  867. updated, blocked, err := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", frame)
  868. require.NoError(t, err, "non-string service_tier ws frame must not error: %s", string(frame))
  869. require.Nil(t, blocked, "non-string service_tier must not trigger block: %s", string(frame))
  870. require.Equal(t, string(frame), string(updated),
  871. "non-string service_tier ws frame must pass through unchanged: %s", string(frame))
  872. }
  873. }
  874. // TestPassthroughBilling_MultiTurnServiceTierFollowsFilteredFrames covers the
  875. // multi-turn passthrough billing regression: OpenAI Realtime / Responses WS
  876. // allows the client to ship a different service_tier on each response.create
  877. // frame (per-response field, see codex-rs/core/src/client.rs
  878. // build_responses_request which re-fills the field on every request). Before
  879. // the fix the adapter only captured service_tier from firstClientMessage so
  880. // turn 2/3 billing was wrong. After the fix the filter closure refreshes an
  881. // atomic.Pointer[string] on every successful response.create frame.
  882. //
  883. // This test pins the four legs of the semantic contract:
  884. // - turn 1: service_tier=priority hits the explicit filter rule, so
  885. // after filter the upstream sees no tier → billing is nil.
  886. // - turn 2: service_tier=flex passes (the filter rule targets priority only),
  887. // billing should now reflect "flex".
  888. // - turn 3: response.create without any service_tier — the upstream will
  889. // treat it as default; we choose to mirror that and overwrite billing
  890. // to nil rather than carry over "flex" from turn 2.
  891. // - non-response.create frame (response.cancel here) carrying a stray
  892. // service_tier-shaped field must NOT clobber the billing pointer.
  893. func TestPassthroughBilling_MultiTurnServiceTierFollowsFilteredFrames(t *testing.T) {
  894. svc := newOpenAIGatewayServiceWithSettings(t, openAIFastFilterPriorityPolicy())
  895. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  896. // Mirror the production filter closure (openai_ws_v2_passthrough_adapter.go
  897. // proxyResponsesWebSocketV2Passthrough) so this test fails if the
  898. // production code drops the per-frame Store.
  899. var requestServiceTierPtr atomic.Pointer[string]
  900. capturedSessionModel := ""
  901. filter := func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
  902. if msgType != coderws.MessageText {
  903. return payload, nil, nil
  904. }
  905. if updated := openAIWSPassthroughPolicyModelFromSessionFrame(account, payload); updated != "" {
  906. capturedSessionModel = updated
  907. }
  908. model := openAIWSPassthroughPolicyModelForFrame(account, payload)
  909. if model == "" {
  910. model = capturedSessionModel
  911. }
  912. out, blocked, policyErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, model, payload)
  913. if policyErr == nil && blocked == nil &&
  914. strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" {
  915. requestServiceTierPtr.Store(extractOpenAIServiceTierFromBody(out))
  916. }
  917. return out, blocked, policyErr
  918. }
  919. // First-frame initialization mirrors the adapter: extract from the
  920. // post-filter payload so a filter-on-first-frame clears the outbound tier.
  921. firstFrame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`)
  922. firstOut, firstBlocked, firstErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", firstFrame)
  923. require.NoError(t, firstErr)
  924. require.Nil(t, firstBlocked)
  925. requestServiceTierPtr.Store(extractOpenAIServiceTierFromBody(firstOut))
  926. capturedSessionModel = openAIWSPassthroughPolicyModelForFrame(account, firstFrame)
  927. require.Nil(t, requestServiceTierPtr.Load(),
  928. "turn 1: filter strips service_tier=priority, leaving a nil outbound tier")
  929. // Turn 2: client switches to flex, should pass and update billing.
  930. turn2 := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"flex"}`)
  931. out2, blocked2, err2 := filter(coderws.MessageText, turn2)
  932. require.NoError(t, err2)
  933. require.Nil(t, blocked2)
  934. require.Equal(t, "flex", gjson.GetBytes(out2, "service_tier").String(), "turn 2: flex must pass to upstream untouched")
  935. tier2 := requestServiceTierPtr.Load()
  936. require.NotNil(t, tier2, "turn 2: billing must update to reflect flex")
  937. require.Equal(t, "flex", *tier2)
  938. // A non-response.create frame with a stray service_tier-shaped field
  939. // must NOT overwrite the billing pointer (those frames don't carry
  940. // per-response service_tier in the Realtime spec).
  941. cancelFrame := []byte(`{"type":"response.cancel","service_tier":"priority"}`)
  942. _, blockedCancel, errCancel := filter(coderws.MessageText, cancelFrame)
  943. require.NoError(t, errCancel)
  944. require.Nil(t, blockedCancel)
  945. tierAfterCancel := requestServiceTierPtr.Load()
  946. require.NotNil(t, tierAfterCancel, "response.cancel must not clobber billing tier to nil")
  947. require.Equal(t, "flex", *tierAfterCancel,
  948. "non-response.create frames must not update billing tier even if they carry a service_tier-shaped field")
  949. // Turn 3: response.create without any service_tier. We deliberately
  950. // overwrite billing back to nil so it tracks what the upstream actually
  951. // sees on this turn (default tier).
  952. turn3 := []byte(`{"type":"response.create","model":"gpt-5.5"}`)
  953. out3, blocked3, err3 := filter(coderws.MessageText, turn3)
  954. require.NoError(t, err3)
  955. require.Nil(t, blocked3)
  956. require.Equal(t, string(turn3), string(out3), "turn 3 has no service_tier — filter must not mutate")
  957. require.Nil(t, requestServiceTierPtr.Load(),
  958. "turn 3: response.create without service_tier overwrites billing to nil to match upstream default")
  959. }
  960. func TestPassthroughUsageMeta_TracksReasoningEffortAcrossTurns(t *testing.T) {
  961. svc := newOpenAIGatewayServiceWithSettings(t, DefaultOpenAIFastPolicySettings())
  962. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  963. firstFrame := []byte(`{"type":"response.create","model":"gpt-5.5","reasoning":{"effort":"medium"},"service_tier":"priority"}`)
  964. meta := newOpenAIWSPassthroughUsageMeta("", firstFrame)
  965. capturedSessionModel := openAIWSPassthroughPolicyModelForFrame(account, firstFrame)
  966. firstOut, firstBlocked, firstErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, capturedSessionModel, firstFrame)
  967. require.NoError(t, firstErr)
  968. require.Nil(t, firstBlocked)
  969. meta.initFromFirstFrame(firstOut, capturedSessionModel)
  970. require.NotNil(t, meta.reasoningEffort.Load())
  971. require.Equal(t, "medium", *meta.reasoningEffort.Load())
  972. process := func(payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
  973. if updated := openAIWSPassthroughPolicyModelFromSessionFrame(account, payload); updated != "" {
  974. capturedSessionModel = updated
  975. }
  976. meta.updateSessionRequestModel(payload)
  977. requestModelForThisFrame := meta.requestModelForFrame(payload)
  978. model := openAIWSPassthroughPolicyModelForFrame(account, payload)
  979. if model == "" {
  980. model = capturedSessionModel
  981. }
  982. out, blocked, policyErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, model, payload)
  983. if policyErr == nil && blocked == nil &&
  984. strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" {
  985. meta.updateFromResponseCreate(out, model, requestModelForThisFrame)
  986. }
  987. return out, blocked, policyErr
  988. }
  989. _, blockedSession, errSession := process([]byte(`{"type":"session.update","session":{"model":"gpt-5-high"}}`))
  990. require.NoError(t, errSession)
  991. require.Nil(t, blockedSession)
  992. require.NotNil(t, meta.reasoningEffort.Load())
  993. require.Equal(t, "medium", *meta.reasoningEffort.Load(), "session.update 只刷新后续 fallback model,不覆盖当前 turn metadata")
  994. _, blockedCancel, errCancel := process([]byte(`{"type":"response.cancel","reasoning_effort":"x-high"}`))
  995. require.NoError(t, errCancel)
  996. require.Nil(t, blockedCancel)
  997. require.NotNil(t, meta.reasoningEffort.Load())
  998. require.Equal(t, "medium", *meta.reasoningEffort.Load(), "非 response.create 帧不能污染当前 turn metadata")
  999. _, blockedFlat, errFlat := process([]byte(`{"type":"response.create","reasoning_effort":"x-high"}`))
  1000. require.NoError(t, errFlat)
  1001. require.Nil(t, blockedFlat)
  1002. require.NotNil(t, meta.reasoningEffort.Load())
  1003. require.Equal(t, "xhigh", *meta.reasoningEffort.Load(), "flat reasoning_effort 必须进入 passthrough usage metadata")
  1004. _, blockedClear, errClear := process([]byte(`{"type":"response.create","model":"gpt-4o"}`))
  1005. require.NoError(t, errClear)
  1006. require.Nil(t, blockedClear)
  1007. require.Nil(t, meta.reasoningEffort.Load(), "新的 response.create 无 effort 且无可推导后缀时必须清空旧值")
  1008. }
  1009. // TestPassthroughBilling_BlockedFrameDoesNotMutateServiceTier locks in the
  1010. // "block keeps previous" semantic: when policy returns block on a
  1011. // response.create frame, that frame is never sent upstream, so billing tier
  1012. // must keep the previous turn's value rather than getting silently zeroed.
  1013. func TestPassthroughBilling_BlockedFrameDoesNotMutateServiceTier(t *testing.T) {
  1014. blockSettings := &OpenAIFastPolicySettings{
  1015. Rules: []OpenAIFastPolicyRule{{
  1016. ServiceTier: OpenAIFastTierPriority,
  1017. Action: BetaPolicyActionBlock,
  1018. Scope: BetaPolicyScopeAll,
  1019. ErrorMessage: "blocked",
  1020. ModelWhitelist: []string{"gpt-5.5"},
  1021. FallbackAction: BetaPolicyActionPass,
  1022. }},
  1023. }
  1024. svc := newOpenAIGatewayServiceWithSettings(t, blockSettings)
  1025. account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
  1026. var requestServiceTierPtr atomic.Pointer[string]
  1027. flexValue := "flex"
  1028. requestServiceTierPtr.Store(&flexValue) // simulate prior turn billed as flex
  1029. filter := func(msgType coderws.MessageType, payload []byte) ([]byte, *OpenAIFastBlockedError, error) {
  1030. if msgType != coderws.MessageText {
  1031. return payload, nil, nil
  1032. }
  1033. out, blocked, policyErr := svc.applyOpenAIFastPolicyToWSResponseCreate(context.Background(), account, "gpt-5.5", payload)
  1034. if policyErr == nil && blocked == nil &&
  1035. strings.TrimSpace(gjson.GetBytes(payload, "type").String()) == "response.create" {
  1036. requestServiceTierPtr.Store(extractOpenAIServiceTierFromBody(out))
  1037. }
  1038. return out, blocked, policyErr
  1039. }
  1040. frame := []byte(`{"type":"response.create","model":"gpt-5.5","service_tier":"priority"}`)
  1041. _, blocked, err := filter(coderws.MessageText, frame)
  1042. require.NoError(t, err)
  1043. require.NotNil(t, blocked, "policy must block this frame")
  1044. tier := requestServiceTierPtr.Load()
  1045. require.NotNil(t, tier, "blocked frame must not clobber prior billing tier to nil")
  1046. require.Equal(t, "flex", *tier,
  1047. "blocked frame is never sent upstream; billing must retain the previous turn's tier")
  1048. }