openai_agent_identity_compat_test.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/base64"
  6. "encoding/json"
  7. "io"
  8. "net/http"
  9. "net/http/httptest"
  10. "strings"
  11. "testing"
  12. "github.com/Wei-Shaw/sub2api/internal/config"
  13. "github.com/gin-gonic/gin"
  14. "github.com/stretchr/testify/require"
  15. "github.com/tidwall/gjson"
  16. )
  17. func TestAccountTestServiceOpenAICompactAgentIdentityUsesFreshAssertion(t *testing.T) {
  18. gin.SetMode(gin.TestMode)
  19. key, privateKey := newTestAgentIdentityKey(t)
  20. account := Account{
  21. ID: 21,
  22. Name: "agent-identity",
  23. Platform: PlatformOpenAI,
  24. Type: AccountTypeOAuth,
  25. Status: StatusActive,
  26. Schedulable: true,
  27. Concurrency: 1,
  28. Credentials: map[string]any{
  29. "auth_mode": OpenAIAuthModeAgentIdentity,
  30. "agent_runtime_id": key.runtimeID,
  31. "agent_private_key": privateKey,
  32. "task_id": key.taskID,
  33. "chatgpt_account_id": "account-agent-test",
  34. "chatgpt_account_is_fedramp": true,
  35. },
  36. }
  37. repo := &snapshotUpdateAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}}
  38. upstream := &httpUpstreamRecorder{resp: &http.Response{
  39. StatusCode: http.StatusOK,
  40. Header: http.Header{"Content-Type": []string{"application/json"}},
  41. Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed","output":[{"type":"compaction","id":"cmp_agent_fresh","encrypted_content":"blob"}]}`)),
  42. }}
  43. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream}
  44. rec := httptest.NewRecorder()
  45. c, _ := gin.CreateTestContext(rec)
  46. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/21/test", bytes.NewReader(nil))
  47. require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact))
  48. require.Equal(t, "AgentAssertion", strings.SplitN(upstream.lastReq.Header.Get("Authorization"), " ", 2)[0])
  49. require.Equal(t, "account-agent-test", upstream.lastReq.Header.Get("chatgpt-account-id"))
  50. require.Equal(t, "true", upstream.lastReq.Header.Get("x-openai-fedramp"))
  51. require.NotContains(t, upstream.lastReq.Header.Get("Authorization"), privateKey)
  52. }
  53. func TestAccountTestServiceOpenAICompactAgentIdentityRecoversInvalidTaskOnce(t *testing.T) {
  54. gin.SetMode(gin.TestMode)
  55. key, privateKey := newTestAgentIdentityKey(t)
  56. account := &Account{
  57. ID: 22,
  58. Name: "agent-identity-recovery",
  59. Platform: PlatformOpenAI,
  60. Type: AccountTypeOAuth,
  61. Status: StatusActive,
  62. Schedulable: true,
  63. Concurrency: 1,
  64. Credentials: map[string]any{
  65. "auth_mode": OpenAIAuthModeAgentIdentity,
  66. "agent_runtime_id": key.runtimeID,
  67. "agent_private_key": privateKey,
  68. "task_id": "task-compact-old",
  69. "chatgpt_account_id": "account-agent-compact-recovery",
  70. },
  71. }
  72. repo := &accountTestAgentIdentityRepo{account: account}
  73. registerCalls := 0
  74. registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  75. registerCalls++
  76. _, _ = io.WriteString(w, `{"task_id":"task-compact-new"}`)
  77. }))
  78. defer registerServer.Close()
  79. oldBase := openAIAgentIdentityAuthAPIBaseURL
  80. openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
  81. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  82. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  83. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  84. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"id":"compact-agent","status":"completed","output":[{"type":"compaction","id":"cmp_agent","encrypted_content":"blob"}]}`))},
  85. }}
  86. invalidator := &agentIdentityWSInvalidationRecorder{}
  87. svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, agentIdentityWS: invalidator}
  88. rec := httptest.NewRecorder()
  89. c, _ := gin.CreateTestContext(rec)
  90. c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/22/test", bytes.NewReader(nil))
  91. require.NoError(t, svc.TestAccountConnection(c, account.ID, "gpt-5.4", "", AccountTestModeCompact))
  92. require.Equal(t, 1, registerCalls)
  93. require.Len(t, upstream.requests, 2)
  94. require.Equal(t, "task-compact-new", account.GetCredential("task_id"))
  95. require.Equal(t, 0, repo.setErrorCalls)
  96. require.Equal(t, []int64{account.ID}, invalidator.accountIDs)
  97. }
  98. func TestOpenAIAgentIdentityPassthroughKeepsSessionAndPromptCacheHeaders(t *testing.T) {
  99. gin.SetMode(gin.TestMode)
  100. key, privateKey := newTestAgentIdentityKey(t)
  101. account := &Account{
  102. ID: 24,
  103. Platform: PlatformOpenAI,
  104. Type: AccountTypeOAuth,
  105. Credentials: map[string]any{
  106. "auth_mode": OpenAIAuthModeAgentIdentity,
  107. "agent_runtime_id": key.runtimeID,
  108. "agent_private_key": privateKey,
  109. "task_id": key.taskID,
  110. "chatgpt_account_id": "account-agent-passthrough",
  111. },
  112. }
  113. rec := httptest.NewRecorder()
  114. c, _ := gin.CreateTestContext(rec)
  115. body := []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":true,"prompt_cache_key":"cache-agent"}`)
  116. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  117. c.Request.Header.Set("session_id", "client-session")
  118. c.Request.Header.Set("conversation_id", "client-conversation")
  119. c.Request.Header.Set("Authorization", "Bearer inbound-must-not-forward")
  120. svc := &OpenAIGatewayService{}
  121. req, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "")
  122. require.NoError(t, err)
  123. require.Equal(t, "AgentAssertion", strings.SplitN(req.Header.Get("Authorization"), " ", 2)[0])
  124. require.Equal(t, "account-agent-passthrough", req.Header.Get("chatgpt-account-id"))
  125. require.NotEqual(t, "client-session", req.Header.Get("session_id"))
  126. require.NotEqual(t, "client-conversation", req.Header.Get("conversation_id"))
  127. require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-session"), req.Header.Get("session_id"))
  128. require.Equal(t, isolateOpenAIUpstreamSessionID(0, account, "client-conversation"), req.Header.Get("conversation_id"))
  129. requestBody, err := io.ReadAll(req.Body)
  130. require.NoError(t, err)
  131. require.Contains(t, string(requestBody), `"prompt_cache_key":"cache-agent"`)
  132. // Authentication mode must not affect session isolation or prompt-cache
  133. // behavior. Compare the same request with the existing OAuth path instead
  134. // of pinning this test to an implementation-specific hash.
  135. oauthAccount := &Account{
  136. ID: 26,
  137. Platform: PlatformOpenAI,
  138. Type: AccountTypeOAuth,
  139. Credentials: map[string]any{
  140. "chatgpt_account_id": "account-agent-passthrough",
  141. },
  142. }
  143. oauthRecorder := httptest.NewRecorder()
  144. oauthContext, _ := gin.CreateTestContext(oauthRecorder)
  145. oauthContext.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
  146. oauthContext.Request.Header.Set("session_id", "client-session")
  147. oauthContext.Request.Header.Set("conversation_id", "client-conversation")
  148. oauthReq, err := svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), oauthContext, oauthAccount, body, "oauth-token")
  149. require.NoError(t, err)
  150. require.Equal(t, oauthReq.Header.Get("session_id"), req.Header.Get("session_id"))
  151. require.Equal(t, oauthReq.Header.Get("conversation_id"), req.Header.Get("conversation_id"))
  152. }
  153. func TestOpenAIAgentIdentityErrorRedactionDoesNotLeakCredentialValues(t *testing.T) {
  154. key, privateKey := newTestAgentIdentityKey(t)
  155. account := &Account{
  156. ID: 25,
  157. Platform: PlatformOpenAI,
  158. Type: AccountTypeOAuth,
  159. Credentials: map[string]any{
  160. "auth_mode": OpenAIAuthModeAgentIdentity,
  161. "agent_runtime_id": key.runtimeID,
  162. "agent_private_key": privateKey,
  163. "task_id": key.taskID,
  164. "access_token": key.runtimeID + "-oauth-value",
  165. },
  166. }
  167. svc := &OpenAIGatewayService{}
  168. oauthValue := account.GetCredential("access_token")
  169. redacted := svc.redactAgentIdentitySensitiveBody(context.Background(), account, []byte(`{"message":"runtime-test task-test `+oauthValue+` AgentAssertion abc123"}`))
  170. require.NotContains(t, string(redacted), key.runtimeID)
  171. require.NotContains(t, string(redacted), key.taskID)
  172. require.NotContains(t, string(redacted), oauthValue)
  173. require.NotContains(t, string(redacted), "AgentAssertion abc123")
  174. require.Contains(t, string(redacted), "[redacted]")
  175. }
  176. func TestOpenAIAuthenticationHeadersPreserveOAuthPATAndAPIKeyBearerModes(t *testing.T) {
  177. svc := &OpenAIGatewayService{}
  178. tests := []struct {
  179. name string
  180. account *Account
  181. token string
  182. }{
  183. {name: "oauth", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}, token: "oauth-runtime-token"},
  184. {name: "personal access token", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"auth_mode": OpenAIAuthModePersonalAccessToken}}, token: "pat-runtime-token"},
  185. {name: "api key", account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, token: "api-key-runtime-token"},
  186. }
  187. for _, tt := range tests {
  188. t.Run(tt.name, func(t *testing.T) {
  189. headers, err := svc.buildOpenAIAuthenticationHeaders(context.Background(), tt.account, tt.token)
  190. require.NoError(t, err)
  191. require.Equal(t, "Bearer "+tt.token, headers.Get("Authorization"))
  192. })
  193. }
  194. }
  195. func TestOpenAIWSAgentIdentityRecoveryRequiresTaskInvalidBody(t *testing.T) {
  196. require.False(t, isAgentIdentityTaskInvalidWSDialError(&openAIWSDialError{
  197. StatusCode: http.StatusUnauthorized,
  198. ResponseBody: []byte(`{"error":{"code":"invalid_signature"}}`),
  199. }))
  200. require.True(t, isAgentIdentityTaskInvalidWSDialError(&openAIWSDialError{
  201. StatusCode: http.StatusUnauthorized,
  202. ResponseBody: []byte(`{"error":{"code":"invalid_task_id"}}`),
  203. }))
  204. }
  205. func TestValidateOpenAIWSBearerTokenAllowsAgentIdentityWithoutStoredToken(t *testing.T) {
  206. t.Run("Given Agent Identity When a WS path receives no bearer token Then dial-time assertion auth is allowed", func(t *testing.T) {
  207. account := &Account{
  208. Platform: PlatformOpenAI,
  209. Type: AccountTypeOAuth,
  210. Credentials: map[string]any{
  211. "auth_mode": OpenAIAuthModeAgentIdentity,
  212. },
  213. }
  214. require.NoError(t, validateOpenAIWSBearerToken(account, ""))
  215. })
  216. t.Run("Given bearer credentials When a WS path receives no token Then the request is rejected", func(t *testing.T) {
  217. accounts := []*Account{
  218. {Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  219. {Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"auth_mode": OpenAIAuthModePersonalAccessToken}},
  220. {Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
  221. }
  222. for _, account := range accounts {
  223. require.EqualError(t, validateOpenAIWSBearerToken(account, ""), "token is empty")
  224. }
  225. })
  226. }
  227. func TestOpenAIWSConnPoolHeadersFactoryRunsAtDialAndStalePrewarmIsDiscarded(t *testing.T) {
  228. cfg := &config.Config{}
  229. cfg.Gateway.OpenAIWS.MaxConnsPerAccount = 1
  230. pool := newOpenAIWSConnPool(cfg)
  231. defer pool.Close()
  232. pool.setClientDialerForTest(&openAIWSFakeDialer{})
  233. accountID := int64(22)
  234. ap := pool.getOrCreateAccountPool(accountID)
  235. factoryCalls := 0
  236. latestHeader := ""
  237. req := openAIWSAcquireRequest{
  238. Account: &Account{ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  239. WSURL: "wss://example.com/v1/responses",
  240. HeadersFactory: func(_ context.Context, headers http.Header) (http.Header, error) {
  241. factoryCalls++
  242. latestHeader = "AgentAssertion dial-" + string(rune('0'+factoryCalls))
  243. if headers == nil {
  244. headers = make(http.Header)
  245. }
  246. headers.Set("Authorization", latestHeader)
  247. return headers, nil
  248. },
  249. }
  250. ap.mu.Lock()
  251. ap.lastAcquire = &req
  252. generation := ap.generation
  253. ap.mu.Unlock()
  254. pool.prewarmConns(accountID, req, 1, generation)
  255. require.Equal(t, 1, factoryCalls, "prewarm must generate authorization inside the actual dial")
  256. require.Equal(t, "AgentAssertion dial-1", latestHeader)
  257. pool.ClearAccount(accountID)
  258. ap.mu.Lock()
  259. require.Empty(t, ap.conns, "credential recovery must remove pooled connections")
  260. require.Nil(t, ap.lastAcquire, "credential recovery must discard delayed acquire state")
  261. require.Equal(t, generation+1, ap.generation)
  262. ap.mu.Unlock()
  263. // A prewarm captured before ClearAccount must not be admitted after recovery.
  264. pool.prewarmConns(accountID, req, 1, generation)
  265. ap.mu.Lock()
  266. require.Empty(t, ap.conns)
  267. ap.mu.Unlock()
  268. }
  269. func TestOpenAIAgentIdentityTaskInvalidRetriesExactlyOnce(t *testing.T) {
  270. gin.SetMode(gin.TestMode)
  271. key, privateKey := newTestAgentIdentityKey(t)
  272. account := &Account{
  273. ID: 23,
  274. Name: "agent-identity",
  275. Platform: PlatformOpenAI,
  276. Type: AccountTypeOAuth,
  277. Status: StatusActive,
  278. Schedulable: true,
  279. Concurrency: 1,
  280. Credentials: map[string]any{
  281. "auth_mode": OpenAIAuthModeAgentIdentity,
  282. "agent_runtime_id": key.runtimeID,
  283. "agent_private_key": privateKey,
  284. "task_id": "task-old",
  285. "chatgpt_account_id": "account-agent-retry",
  286. },
  287. }
  288. repo := &agentIdentityForwardRepo{account: account}
  289. registerCalls := 0
  290. registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  291. registerCalls++
  292. _, _ = io.WriteString(w, `{"task_id":"task-new"}`)
  293. }))
  294. defer registerServer.Close()
  295. oldBase := openAIAgentIdentityAuthAPIBaseURL
  296. openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
  297. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  298. successBody := `{"id":"resp-agent-retry","object":"response","model":"gpt-5.4","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`
  299. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  300. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  301. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(successBody))},
  302. }}
  303. require.True(t, isAgentIdentityTaskInvalidHTTPResponse(http.StatusUnauthorized, []byte(`{"error":{"code":"invalid_task_id"}}`)))
  304. svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: repo, httpUpstream: upstream}
  305. rec := httptest.NewRecorder()
  306. c, _ := gin.CreateTestContext(rec)
  307. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
  308. _, err := svc.Forward(context.Background(), c, account, []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
  309. require.NoError(t, err)
  310. require.Equal(t, 1, registerCalls)
  311. require.Len(t, upstream.requests, 2)
  312. require.NotEqual(t, upstream.requests[0].Header.Get("Authorization"), upstream.requests[1].Header.Get("Authorization"))
  313. require.Equal(t, "task-new", decodeAgentAssertionTask(t, upstream.requests[1].Header.Get("Authorization")))
  314. // Two consecutive invalid responses still produce only one retry for this
  315. // request; the recovery path must not loop indefinitely.
  316. upstream.responses = []*http.Response{
  317. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  318. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  319. }
  320. rec2 := httptest.NewRecorder()
  321. c2, _ := gin.CreateTestContext(rec2)
  322. c2.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
  323. _, err = svc.Forward(context.Background(), c2, account, []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
  324. require.Error(t, err)
  325. require.Equal(t, 2, registerCalls)
  326. require.Len(t, upstream.requests, 4)
  327. // Passthrough uses the same one-shot task recovery contract.
  328. account.Extra = map[string]any{"openai_passthrough": true}
  329. account.Credentials["task_id"] = "task-old-passthrough"
  330. upstream.responses = []*http.Response{
  331. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  332. {StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":1,\"total_tokens\":2}}}\n\ndata: [DONE]\n\n"))},
  333. }
  334. rec3 := httptest.NewRecorder()
  335. c3, _ := gin.CreateTestContext(rec3)
  336. c3.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
  337. _, err = svc.Forward(context.Background(), c3, account, []byte(`{"model":"gpt-5.4","instructions":"Reply OK","input":[],"stream":false}`))
  338. require.NoError(t, err)
  339. require.Equal(t, 3, registerCalls)
  340. require.Len(t, upstream.requests, 6)
  341. }
  342. func TestOpenAIAgentIdentityCompatRoutesRecoverInvalidTaskOnce(t *testing.T) {
  343. gin.SetMode(gin.TestMode)
  344. tests := []struct {
  345. name string
  346. path string
  347. body []byte
  348. call func(*OpenAIGatewayService, context.Context, *gin.Context, *Account, []byte) (*OpenAIForwardResult, error)
  349. }{
  350. {
  351. name: "chat completions",
  352. path: "/v1/chat/completions",
  353. body: []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"user","content":"hi"}]}`),
  354. call: func(s *OpenAIGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
  355. return s.ForwardAsChatCompletions(ctx, c, account, body, "", "gpt-5.4")
  356. },
  357. },
  358. {
  359. name: "anthropic messages",
  360. path: "/v1/messages",
  361. body: []byte(`{"model":"gpt-5.4","stream":false,"max_tokens":32,"messages":[{"role":"user","content":"hi"}]}`),
  362. call: func(s *OpenAIGatewayService, ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
  363. return s.ForwardAsAnthropic(ctx, c, account, body, "", "gpt-5.4")
  364. },
  365. },
  366. }
  367. for index, tt := range tests {
  368. t.Run(tt.name, func(t *testing.T) {
  369. key, privateKey := newTestAgentIdentityKey(t)
  370. account := &Account{
  371. ID: int64(40 + index),
  372. Name: "agent-identity-compat",
  373. Platform: PlatformOpenAI,
  374. Type: AccountTypeOAuth,
  375. Status: StatusActive,
  376. Schedulable: true,
  377. Concurrency: 1,
  378. Credentials: map[string]any{
  379. "auth_mode": OpenAIAuthModeAgentIdentity,
  380. "agent_runtime_id": key.runtimeID,
  381. "agent_private_key": privateKey,
  382. "task_id": "task-compat-old",
  383. "chatgpt_account_id": "account-compat-recovery",
  384. },
  385. }
  386. repo := &agentIdentityForwardRepo{account: account}
  387. registerCalls := 0
  388. registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  389. registerCalls++
  390. _, _ = io.WriteString(w, `{"task_id":"task-compat-new"}`)
  391. }))
  392. defer registerServer.Close()
  393. oldBase := openAIAgentIdentityAuthAPIBaseURL
  394. openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
  395. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  396. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  397. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  398. {StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))},
  399. }}
  400. svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: repo, httpUpstream: upstream}
  401. rec := httptest.NewRecorder()
  402. c, _ := gin.CreateTestContext(rec)
  403. c.Request = httptest.NewRequest(http.MethodPost, tt.path, bytes.NewReader(tt.body))
  404. _, err := tt.call(svc, context.Background(), c, account, tt.body)
  405. require.Error(t, err)
  406. require.Equal(t, 1, registerCalls)
  407. require.Len(t, upstream.requests, 2)
  408. require.Equal(t, "task-compat-new", account.GetCredential("task_id"))
  409. })
  410. }
  411. }
  412. func TestOpenAIAgentIdentityChatRecoveryKeepsAutoDerivedSessionIsolationStable(t *testing.T) {
  413. gin.SetMode(gin.TestMode)
  414. key, privateKey := newTestAgentIdentityKey(t)
  415. account := &Account{
  416. ID: 52, Name: "agent-identity", Platform: PlatformOpenAI, Type: AccountTypeOAuth,
  417. Status: StatusActive, Schedulable: true, Concurrency: 1,
  418. Credentials: map[string]any{
  419. "auth_mode": OpenAIAuthModeAgentIdentity,
  420. "agent_runtime_id": key.runtimeID,
  421. "agent_private_key": privateKey,
  422. "task_id": "task-cache-old",
  423. },
  424. Extra: map[string]any{"openai_responses_supported": true},
  425. }
  426. repo := &agentIdentityForwardRepo{account: account}
  427. registerServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  428. _, _ = io.WriteString(w, `{"task_id":"task-cache-new"}`)
  429. }))
  430. defer registerServer.Close()
  431. oldBase := openAIAgentIdentityAuthAPIBaseURL
  432. openAIAgentIdentityAuthAPIBaseURL = registerServer.URL
  433. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = oldBase })
  434. invalidTask := func() *http.Response {
  435. return &http.Response{StatusCode: http.StatusUnauthorized, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_task_id"}}`))}
  436. }
  437. upstream := &httpUpstreamRecorder{responses: []*http.Response{invalidTask(), invalidTask()}}
  438. svc := &OpenAIGatewayService{cfg: &config.Config{}, accountRepo: repo, httpUpstream: upstream}
  439. body := []byte(`{"model":"gpt-5.4","stream":false,"messages":[{"role":"user","content":"hi"}]}`)
  440. rec := httptest.NewRecorder()
  441. c, _ := gin.CreateTestContext(rec)
  442. c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
  443. c.Set("api_key", &APIKey{ID: 99})
  444. _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "gpt-5.4")
  445. require.Error(t, err)
  446. require.Len(t, upstream.requests, 2)
  447. firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
  448. secondKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
  449. require.NotEmpty(t, firstKey)
  450. require.Equal(t, firstKey, secondKey)
  451. require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(99, codexAccountIdentitySource(c, account), firstKey)), upstream.requests[0].Header.Get("session_id"))
  452. require.Equal(t, upstream.requests[0].Header.Get("session_id"), upstream.requests[1].Header.Get("session_id"))
  453. }
  454. func decodeAgentAssertionTask(t *testing.T, header string) string {
  455. t.Helper()
  456. encoded := strings.TrimPrefix(header, "AgentAssertion ")
  457. decoded, err := base64.RawURLEncoding.DecodeString(encoded)
  458. require.NoError(t, err)
  459. var envelope struct {
  460. TaskID string `json:"task_id"`
  461. }
  462. require.NoError(t, json.Unmarshal(decoded, &envelope))
  463. return envelope.TaskID
  464. }
  465. type agentIdentityForwardRepo struct {
  466. AccountRepository
  467. account *Account
  468. }
  469. type agentIdentityWSInvalidationRecorder struct {
  470. accountIDs []int64
  471. }
  472. func (r *agentIdentityWSInvalidationRecorder) InvalidateAgentIdentityWSConnections(accountID int64) {
  473. r.accountIDs = append(r.accountIDs, accountID)
  474. }
  475. type accountTestAgentIdentityRepo struct {
  476. AccountRepository
  477. account *Account
  478. setErrorCalls int
  479. }
  480. func (r *accountTestAgentIdentityRepo) GetByID(_ context.Context, _ int64) (*Account, error) {
  481. return r.account, nil
  482. }
  483. func (r *accountTestAgentIdentityRepo) UpdateCredentials(_ context.Context, _ int64, credentials map[string]any) error {
  484. r.account.Credentials = credentials
  485. return nil
  486. }
  487. func (r *accountTestAgentIdentityRepo) UpdateExtra(_ context.Context, _ int64, _ map[string]any) error {
  488. return nil
  489. }
  490. func (r *accountTestAgentIdentityRepo) SetError(_ context.Context, _ int64, _ string) error {
  491. r.setErrorCalls++
  492. return nil
  493. }
  494. func (r *agentIdentityForwardRepo) GetByID(_ context.Context, _ int64) (*Account, error) {
  495. return r.account, nil
  496. }
  497. func (r *agentIdentityForwardRepo) UpdateCredentials(_ context.Context, _ int64, credentials map[string]any) error {
  498. r.account.Credentials = credentials
  499. return nil
  500. }