openai_compat_model_test.go 101 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net/http"
  10. "net/http/httptest"
  11. "os"
  12. "path/filepath"
  13. "strings"
  14. "sync"
  15. "testing"
  16. "time"
  17. "github.com/Wei-Shaw/sub2api/internal/config"
  18. "github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
  19. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  20. "github.com/gin-gonic/gin"
  21. "github.com/stretchr/testify/require"
  22. "github.com/tidwall/gjson"
  23. )
  24. type openAICompatFailingWriter struct {
  25. gin.ResponseWriter
  26. failAfter int
  27. writes int
  28. }
  29. func (w *openAICompatFailingWriter) Write(p []byte) (int, error) {
  30. if w.writes >= w.failAfter {
  31. return 0, errors.New("write failed: client disconnected")
  32. }
  33. w.writes++
  34. return w.ResponseWriter.Write(p)
  35. }
  36. type openAICompatBlockingReadCloser struct {
  37. data []byte
  38. offset int
  39. closed chan struct{}
  40. closeOnce sync.Once
  41. }
  42. func newOpenAICompatBlockingReadCloser(data []byte) *openAICompatBlockingReadCloser {
  43. return &openAICompatBlockingReadCloser{
  44. data: data,
  45. closed: make(chan struct{}),
  46. }
  47. }
  48. func (r *openAICompatBlockingReadCloser) Read(p []byte) (int, error) {
  49. if r.offset < len(r.data) {
  50. n := copy(p, r.data[r.offset:])
  51. r.offset += n
  52. return n, nil
  53. }
  54. <-r.closed
  55. return 0, io.EOF
  56. }
  57. func (r *openAICompatBlockingReadCloser) Close() error {
  58. r.closeOnce.Do(func() {
  59. close(r.closed)
  60. })
  61. return nil
  62. }
  63. func TestNormalizeOpenAICompatRequestedModel(t *testing.T) {
  64. t.Parallel()
  65. tests := []struct {
  66. name string
  67. input string
  68. want string
  69. }{
  70. {name: "gpt reasoning alias strips xhigh", input: "gpt-5.4-xhigh", want: "gpt-5.4"},
  71. {name: "gpt reasoning alias strips none", input: "gpt-5.4-none", want: "gpt-5.4"},
  72. {name: "codex max model stays intact", input: "gpt-5.1-codex-max", want: "gpt-5.1-codex-max"},
  73. {name: "non openai model unchanged", input: "claude-opus-4-6", want: "claude-opus-4-6"},
  74. }
  75. for _, tt := range tests {
  76. t.Run(tt.name, func(t *testing.T) {
  77. require.Equal(t, tt.want, NormalizeOpenAICompatRequestedModel(tt.input))
  78. })
  79. }
  80. }
  81. func TestApplyOpenAICompatModelNormalization(t *testing.T) {
  82. t.Parallel()
  83. t.Run("derives xhigh from model suffix when output config missing", func(t *testing.T) {
  84. req := &apicompat.AnthropicRequest{Model: "gpt-5.4-xhigh"}
  85. applyOpenAICompatModelNormalization(req)
  86. require.Equal(t, "gpt-5.4", req.Model)
  87. require.NotNil(t, req.OutputConfig)
  88. require.Equal(t, "max", req.OutputConfig.Effort)
  89. })
  90. t.Run("explicit output config wins over model suffix", func(t *testing.T) {
  91. req := &apicompat.AnthropicRequest{
  92. Model: "gpt-5.4-xhigh",
  93. OutputConfig: &apicompat.AnthropicOutputConfig{Effort: "low"},
  94. }
  95. applyOpenAICompatModelNormalization(req)
  96. require.Equal(t, "gpt-5.4", req.Model)
  97. require.NotNil(t, req.OutputConfig)
  98. require.Equal(t, "low", req.OutputConfig.Effort)
  99. })
  100. t.Run("non openai model is untouched", func(t *testing.T) {
  101. req := &apicompat.AnthropicRequest{Model: "claude-opus-4-6"}
  102. applyOpenAICompatModelNormalization(req)
  103. require.Equal(t, "claude-opus-4-6", req.Model)
  104. require.Nil(t, req.OutputConfig)
  105. })
  106. }
  107. func TestForwardAsAnthropic_UsesExactFableMessagesDispatchModel(t *testing.T) {
  108. t.Parallel()
  109. gin.SetMode(gin.TestMode)
  110. rec := httptest.NewRecorder()
  111. c, _ := gin.CreateTestContext(rec)
  112. body := []byte(`{"model":"claude-fable-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  113. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  114. c.Request.Header.Set("Content-Type", "application/json")
  115. upstreamBody := strings.Join([]string{
  116. `data: {"type":"response.completed","response":{"id":"resp_fable","object":"response","model":"gpt-5.6-sol","status":"completed","output":[{"type":"message","id":"msg_fable","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  117. "",
  118. "data: [DONE]",
  119. "",
  120. }, "\n")
  121. upstream := &httpUpstreamRecorder{resp: &http.Response{
  122. StatusCode: http.StatusOK,
  123. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_fable"}},
  124. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  125. }}
  126. svc := &OpenAIGatewayService{
  127. httpUpstream: upstream,
  128. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  129. }
  130. account := &Account{
  131. ID: 1,
  132. Name: "openai-oauth",
  133. Platform: PlatformOpenAI,
  134. Type: AccountTypeOAuth,
  135. Concurrency: 1,
  136. Credentials: map[string]any{
  137. "access_token": "oauth-token",
  138. "chatgpt_account_id": "chatgpt-acc",
  139. },
  140. }
  141. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.6-sol")
  142. require.NoError(t, err)
  143. require.NotNil(t, result)
  144. require.Equal(t, "claude-fable-5", result.Model)
  145. require.Equal(t, "gpt-5.6-sol", result.BillingModel)
  146. require.Equal(t, "gpt-5.6-sol", result.UpstreamModel)
  147. require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(upstream.lastBody, "model").String())
  148. require.NotContains(t, string(upstream.lastBody), "claude-fable-5")
  149. require.Equal(t, "claude-fable-5", gjson.GetBytes(rec.Body.Bytes(), "model").String())
  150. }
  151. func TestForwardAsAnthropic_NormalizesRoutingAndEffortForGpt54XHigh(t *testing.T) {
  152. t.Parallel()
  153. gin.SetMode(gin.TestMode)
  154. rec := httptest.NewRecorder()
  155. c, _ := gin.CreateTestContext(rec)
  156. body := []byte(`{"model":"gpt-5.4-xhigh","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  157. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  158. c.Request.Header.Set("Content-Type", "application/json")
  159. upstreamBody := strings.Join([]string{
  160. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  161. "",
  162. "data: [DONE]",
  163. "",
  164. }, "\n")
  165. upstream := &httpUpstreamRecorder{resp: &http.Response{
  166. StatusCode: http.StatusOK,
  167. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_compat"}},
  168. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  169. }}
  170. svc := &OpenAIGatewayService{
  171. httpUpstream: upstream,
  172. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  173. }
  174. account := &Account{
  175. ID: 1,
  176. Name: "openai-oauth",
  177. Platform: PlatformOpenAI,
  178. Type: AccountTypeOAuth,
  179. Concurrency: 1,
  180. Credentials: map[string]any{
  181. "access_token": "oauth-token",
  182. "chatgpt_account_id": "chatgpt-acc",
  183. "model_mapping": map[string]any{
  184. "gpt-5.4": "gpt-5.4",
  185. },
  186. },
  187. }
  188. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  189. require.NoError(t, err)
  190. require.NotNil(t, result)
  191. require.Equal(t, "gpt-5.4-xhigh", result.Model)
  192. require.Equal(t, "gpt-5.4", result.UpstreamModel)
  193. require.Equal(t, "gpt-5.4", result.BillingModel)
  194. require.NotNil(t, result.ReasoningEffort)
  195. require.Equal(t, "xhigh", *result.ReasoningEffort)
  196. require.Equal(t, "gpt-5.4", gjson.GetBytes(upstream.lastBody, "model").String())
  197. require.Equal(t, "xhigh", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  198. require.Equal(t, http.StatusOK, rec.Code)
  199. require.Equal(t, "gpt-5.4-xhigh", gjson.GetBytes(rec.Body.Bytes(), "model").String())
  200. require.Equal(t, "ok", gjson.GetBytes(rec.Body.Bytes(), "content.0.text").String())
  201. t.Logf("upstream body: %s", string(upstream.lastBody))
  202. t.Logf("response body: %s", rec.Body.String())
  203. }
  204. func TestForwardAsAnthropic_PreservesMaxForFinalGPT56ResponsesModel(t *testing.T) {
  205. t.Parallel()
  206. gin.SetMode(gin.TestMode)
  207. tests := []struct {
  208. name string
  209. account *Account
  210. model string
  211. defaultMapped string
  212. effort string
  213. wantModel string
  214. wantEffort string
  215. }{
  216. {
  217. name: "API Key mapping wins and keeps Luna max",
  218. account: rawGPT56ResponsesAPIKeyAccount("luna", "gpt-5.6-luna"),
  219. model: "luna",
  220. defaultMapped: "gpt-5.6-sol",
  221. effort: "max",
  222. wantModel: "gpt-5.6-luna",
  223. wantEffort: "max",
  224. },
  225. {
  226. name: "OAuth mapping wins and keeps Sol max",
  227. account: rawGPT56ResponsesOAuthAccount("sol", "gpt-5.6-sol"),
  228. model: "sol",
  229. defaultMapped: "gpt-5.6-terra",
  230. effort: "max",
  231. wantModel: "gpt-5.6-sol",
  232. wantEffort: "max",
  233. },
  234. {
  235. name: "old model still maps max to xhigh",
  236. account: rawGPT56ResponsesAPIKeyAccount("gpt-5.5", "gpt-5.5"),
  237. model: "gpt-5.5",
  238. effort: "max",
  239. wantModel: "gpt-5.5",
  240. wantEffort: "xhigh",
  241. },
  242. {
  243. name: "GPT56 default remains medium",
  244. account: rawGPT56ResponsesAPIKeyAccount("gpt-5.6-sol", "gpt-5.6-sol"),
  245. model: "gpt-5.6-sol",
  246. wantModel: "gpt-5.6-sol",
  247. wantEffort: "medium",
  248. },
  249. }
  250. for _, tt := range tests {
  251. t.Run(tt.name, func(t *testing.T) {
  252. rec := httptest.NewRecorder()
  253. c, _ := gin.CreateTestContext(rec)
  254. body := `{"model":"` + tt.model + `","max_tokens":16,"messages":[{"role":"user","content":"hello"}`
  255. if tt.effort != "" {
  256. body += `],"output_config":{"effort":"` + tt.effort + `"},"stream":false}`
  257. } else {
  258. body += `],"stream":false}`
  259. }
  260. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(body))
  261. c.Request.Header.Set("Content-Type", "application/json")
  262. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_gpt56_"+tt.name, tt.wantModel)}
  263. svc := &OpenAIGatewayService{
  264. httpUpstream: upstream,
  265. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  266. }
  267. result, err := svc.ForwardAsAnthropic(context.Background(), c, tt.account, []byte(body), "", tt.defaultMapped)
  268. require.NoError(t, err)
  269. require.NotNil(t, result)
  270. require.Equal(t, tt.wantModel, result.UpstreamModel)
  271. require.Equal(t, tt.wantModel, gjson.GetBytes(upstream.lastBody, "model").String())
  272. require.Equal(t, tt.wantEffort, gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
  273. require.NotNil(t, result.ReasoningEffort)
  274. require.Equal(t, tt.wantEffort, *result.ReasoningEffort)
  275. })
  276. }
  277. }
  278. func rawGPT56ResponsesAPIKeyAccount(requestedModel, mappedModel string) *Account {
  279. return &Account{
  280. ID: 501,
  281. Name: "gpt56-apikey",
  282. Platform: PlatformOpenAI,
  283. Type: AccountTypeAPIKey,
  284. Concurrency: 1,
  285. Credentials: map[string]any{
  286. "api_key": "sk-test",
  287. "base_url": "https://api.example.com/v1",
  288. "model_mapping": map[string]any{requestedModel: mappedModel},
  289. },
  290. Extra: map[string]any{"use_responses_api": true},
  291. }
  292. }
  293. func rawGPT56ResponsesOAuthAccount(requestedModel, mappedModel string) *Account {
  294. return &Account{
  295. ID: 502,
  296. Name: "gpt56-oauth",
  297. Platform: PlatformOpenAI,
  298. Type: AccountTypeOAuth,
  299. Concurrency: 1,
  300. Credentials: map[string]any{
  301. "access_token": "oauth-token",
  302. "chatgpt_account_id": "chatgpt-acc",
  303. "model_mapping": map[string]any{requestedModel: mappedModel},
  304. },
  305. }
  306. }
  307. func TestForwardAsAnthropic_MappedClaudeModelAcceptsChatUsageShape(t *testing.T) {
  308. t.Parallel()
  309. gin.SetMode(gin.TestMode)
  310. rec := httptest.NewRecorder()
  311. c, _ := gin.CreateTestContext(rec)
  312. body := []byte(`{"model":"claude-opus-4-7","max_tokens":16,"messages":[{"role":"user","content":"compact this"}],"stream":true}`)
  313. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  314. c.Request.Header.Set("Content-Type", "application/json")
  315. upstreamBody := strings.Join([]string{
  316. `data: {"type":"response.created","response":{"id":"resp_compact","model":"gpt-5.5","status":"in_progress","output":[]}}`,
  317. "",
  318. `data: {"type":"response.output_text.delta","delta":"ok"}`,
  319. "",
  320. `data: {"type":"response.completed","response":{"id":"resp_compact","object":"response","model":"gpt-5.5","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"prompt_tokens":31,"completion_tokens":9,"total_tokens":40,"prompt_tokens_details":{"cached_tokens":11}}}}`,
  321. "",
  322. "data: [DONE]",
  323. "",
  324. }, "\n")
  325. upstream := &httpUpstreamRecorder{resp: &http.Response{
  326. StatusCode: http.StatusOK,
  327. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_compact_usage"}},
  328. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  329. }}
  330. svc := &OpenAIGatewayService{
  331. httpUpstream: upstream,
  332. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  333. }
  334. account := &Account{
  335. ID: 1,
  336. Name: "openai-apikey",
  337. Platform: PlatformOpenAI,
  338. Type: AccountTypeAPIKey,
  339. Concurrency: 1,
  340. Credentials: map[string]any{
  341. "api_key": "sk-test",
  342. "base_url": "https://api.openai.com/v1",
  343. "model_mapping": map[string]any{
  344. "gpt-5.5": "gpt-5.5",
  345. },
  346. },
  347. }
  348. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.5")
  349. require.NoError(t, err)
  350. require.NotNil(t, result)
  351. require.Equal(t, "claude-opus-4-7", result.Model)
  352. require.Equal(t, "gpt-5.5", result.BillingModel)
  353. require.Equal(t, "gpt-5.5", result.UpstreamModel)
  354. require.Equal(t, 31, result.Usage.InputTokens)
  355. require.Equal(t, 9, result.Usage.OutputTokens)
  356. require.Equal(t, 11, result.Usage.CacheReadInputTokens)
  357. require.Equal(t, "gpt-5.5", gjson.GetBytes(upstream.lastBody, "model").String())
  358. }
  359. func TestForwardAsAnthropic_InjectsPromptCacheKeyForAPIKeyMessagesDispatch(t *testing.T) {
  360. t.Parallel()
  361. gin.SetMode(gin.TestMode)
  362. rec := httptest.NewRecorder()
  363. c, _ := gin.CreateTestContext(rec)
  364. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":{"user_id":"claude-session-1"},"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  365. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  366. c.Request.Header.Set("Content-Type", "application/json")
  367. upstreamBody := strings.Join([]string{
  368. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":3}}}}`,
  369. "",
  370. "data: [DONE]",
  371. "",
  372. }, "\n")
  373. upstream := &httpUpstreamRecorder{resp: &http.Response{
  374. StatusCode: http.StatusOK,
  375. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_cache_key"}},
  376. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  377. }}
  378. svc := &OpenAIGatewayService{
  379. httpUpstream: upstream,
  380. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  381. }
  382. account := &Account{
  383. ID: 1,
  384. Name: "openai-apikey",
  385. Platform: PlatformOpenAI,
  386. Type: AccountTypeAPIKey,
  387. Concurrency: 1,
  388. Credentials: map[string]any{
  389. "api_key": "sk-test",
  390. "base_url": "https://api.openai.com/v1",
  391. },
  392. }
  393. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex")
  394. require.NoError(t, err)
  395. require.NotNil(t, result)
  396. require.Equal(t, "stable-cache-key", gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String())
  397. require.Equal(t, "gpt-5.3-codex", gjson.GetBytes(upstream.lastBody, "model").String())
  398. require.Equal(t, 3, result.Usage.CacheReadInputTokens)
  399. }
  400. func TestForwardAsAnthropic_AutoDerivesPromptCacheKeyWhenMessagesDispatchHasNoSessionID(t *testing.T) {
  401. t.Parallel()
  402. gin.SetMode(gin.TestMode)
  403. rec := httptest.NewRecorder()
  404. c, _ := gin.CreateTestContext(rec)
  405. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"open repo"}],"stream":false}`)
  406. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  407. c.Request.Header.Set("Content-Type", "application/json")
  408. upstreamBody := strings.Join([]string{
  409. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.3-codex","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7,"input_tokens_details":{"cached_tokens":3}}}}`,
  410. "",
  411. "data: [DONE]",
  412. "",
  413. }, "\n")
  414. upstream := &httpUpstreamRecorder{resp: &http.Response{
  415. StatusCode: http.StatusOK,
  416. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_auto_cache_key"}},
  417. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  418. }}
  419. svc := &OpenAIGatewayService{
  420. httpUpstream: upstream,
  421. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  422. }
  423. account := &Account{
  424. ID: 1,
  425. Name: "openai-apikey",
  426. Platform: PlatformOpenAI,
  427. Type: AccountTypeAPIKey,
  428. Concurrency: 1,
  429. Credentials: map[string]any{
  430. "api_key": "sk-test",
  431. "base_url": "https://api.openai.com/v1",
  432. },
  433. }
  434. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.3-codex")
  435. require.NoError(t, err)
  436. require.NotNil(t, result)
  437. cacheKey := gjson.GetBytes(upstream.lastBody, "prompt_cache_key").String()
  438. require.NotEmpty(t, cacheKey)
  439. require.True(t, strings.HasPrefix(cacheKey, "anthropic-digest-"))
  440. require.Equal(t, generateSessionUUID(isolateOpenAISessionID(0, cacheKey)), upstream.lastReq.Header.Get("session_id"))
  441. }
  442. func TestForwardAsAnthropic_GPT6AstraPromptCacheIdentityStableAcrossAppendedTurns(t *testing.T) {
  443. t.Parallel()
  444. gin.SetMode(gin.TestMode)
  445. for _, mappedModel := range []string{"gpt-6-astra", "gpt-6"} {
  446. mappedModel := mappedModel
  447. t.Run(mappedModel, func(t *testing.T) {
  448. t.Parallel()
  449. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  450. openAICompatSSECompletedResponse("resp_gpt6_first", mappedModel),
  451. openAICompatSSECompletedResponse("resp_gpt6_second", mappedModel),
  452. }}
  453. svc := &OpenAIGatewayService{
  454. httpUpstream: upstream,
  455. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  456. }
  457. account := &Account{
  458. ID: 6615,
  459. Name: "openai-apikey",
  460. Platform: PlatformOpenAI,
  461. Type: AccountTypeAPIKey,
  462. Concurrency: 1,
  463. Credentials: map[string]any{
  464. "api_key": "sk-test",
  465. "base_url": "https://api.openai.com/v1",
  466. },
  467. }
  468. bodies := [][]byte{
  469. []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"Inspect the repository"}],"stream":false}`),
  470. []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"You are helpful.","messages":[{"role":"user","content":"Inspect the repository"},{"role":"assistant","content":"Done."},{"role":"user","content":"Run the tests"}],"stream":false}`),
  471. }
  472. for _, body := range bodies {
  473. rec := httptest.NewRecorder()
  474. c, _ := gin.CreateTestContext(rec)
  475. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  476. c.Request.Header.Set("Content-Type", "application/json")
  477. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", mappedModel)
  478. require.NoError(t, err)
  479. require.NotNil(t, result)
  480. }
  481. require.Len(t, upstream.bodies, 2)
  482. require.Len(t, upstream.requests, 2)
  483. require.Equal(t, mappedModel, gjson.GetBytes(upstream.bodies[0], "model").String())
  484. require.Equal(t, mappedModel, gjson.GetBytes(upstream.bodies[1], "model").String())
  485. firstCacheKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
  486. secondCacheKey := gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String()
  487. require.NotEmpty(t, firstCacheKey)
  488. require.Equal(t, firstCacheKey, secondCacheKey)
  489. firstSessionID := upstream.requests[0].Header.Get("session_id")
  490. secondSessionID := upstream.requests[1].Header.Get("session_id")
  491. require.NotEmpty(t, firstSessionID)
  492. require.Equal(t, firstSessionID, secondSessionID)
  493. })
  494. }
  495. }
  496. func TestForwardAsAnthropic_DoesNotAutoDerivePromptCacheKeyForNonCodexModel(t *testing.T) {
  497. t.Parallel()
  498. gin.SetMode(gin.TestMode)
  499. rec := httptest.NewRecorder()
  500. c, _ := gin.CreateTestContext(rec)
  501. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  502. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  503. c.Request.Header.Set("Content-Type", "application/json")
  504. upstreamBody := strings.Join([]string{
  505. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-4o","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  506. "",
  507. "data: [DONE]",
  508. "",
  509. }, "\n")
  510. upstream := &httpUpstreamRecorder{resp: &http.Response{
  511. StatusCode: http.StatusOK,
  512. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_no_cache_key"}},
  513. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  514. }}
  515. svc := &OpenAIGatewayService{
  516. httpUpstream: upstream,
  517. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  518. }
  519. account := &Account{
  520. ID: 1,
  521. Name: "openai-apikey",
  522. Platform: PlatformOpenAI,
  523. Type: AccountTypeAPIKey,
  524. Concurrency: 1,
  525. Credentials: map[string]any{
  526. "api_key": "sk-test",
  527. "base_url": "https://api.openai.com/v1",
  528. },
  529. }
  530. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-4o")
  531. require.NoError(t, err)
  532. require.NotNil(t, result)
  533. require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists())
  534. require.Empty(t, upstream.lastReq.Header.Get("session_id"))
  535. }
  536. func TestForwardAsAnthropic_TrimsFullReplayOnlyForCodexCompatModels(t *testing.T) {
  537. t.Parallel()
  538. gin.SetMode(gin.TestMode)
  539. messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
  540. for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
  541. messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
  542. }
  543. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
  544. run := func(t *testing.T, mappedModel string) []byte {
  545. t.Helper()
  546. rec := httptest.NewRecorder()
  547. c, _ := gin.CreateTestContext(rec)
  548. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  549. c.Request.Header.Set("Content-Type", "application/json")
  550. upstreamBody := strings.Join([]string{
  551. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"` + mappedModel + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  552. "",
  553. "data: [DONE]",
  554. "",
  555. }, "\n")
  556. upstream := &httpUpstreamRecorder{resp: &http.Response{
  557. StatusCode: http.StatusOK,
  558. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_trim"}},
  559. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  560. }}
  561. svc := &OpenAIGatewayService{
  562. httpUpstream: upstream,
  563. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  564. }
  565. account := &Account{
  566. ID: 1,
  567. Name: "openai-apikey",
  568. Platform: PlatformOpenAI,
  569. Type: AccountTypeAPIKey,
  570. Concurrency: 1,
  571. Credentials: map[string]any{
  572. "api_key": "sk-test",
  573. "base_url": "https://api.openai.com/v1",
  574. },
  575. }
  576. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", mappedModel)
  577. require.NoError(t, err)
  578. require.NotNil(t, result)
  579. return upstream.lastBody
  580. }
  581. codexBody := run(t, "gpt-5.3-codex")
  582. require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+1), gjson.GetBytes(codexBody, "input.#").Int())
  583. require.Equal(t, "developer", gjson.GetBytes(codexBody, "input.0.role").String())
  584. require.Contains(t, gjson.GetBytes(codexBody, "input.0.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  585. require.Equal(t, "message-03", gjson.GetBytes(codexBody, "input.1.content.0.text").String())
  586. require.Equal(t, "message-14", gjson.GetBytes(codexBody, "input.12.content.0.text").String())
  587. nonCompatBody := run(t, "gpt-4o")
  588. require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+3), gjson.GetBytes(nonCompatBody, "input.#").Int())
  589. require.Equal(t, "message-00", gjson.GetBytes(nonCompatBody, "input.0.content.0.text").String())
  590. }
  591. func TestForwardAsAnthropic_OAuthCompatKeepsFullReplayForCacheGrowth(t *testing.T) {
  592. t.Parallel()
  593. gin.SetMode(gin.TestMode)
  594. messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+3)
  595. for i := 0; i < openAICompatAnthropicReplayMaxTailMessages+3; i++ {
  596. messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
  597. }
  598. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
  599. rec := httptest.NewRecorder()
  600. c, _ := gin.CreateTestContext(rec)
  601. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  602. c.Request.Header.Set("Content-Type", "application/json")
  603. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_trim", "gpt-5.4")}
  604. svc := &OpenAIGatewayService{
  605. httpUpstream: upstream,
  606. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  607. }
  608. account := &Account{
  609. ID: 1,
  610. Name: "openai-oauth",
  611. Platform: PlatformOpenAI,
  612. Type: AccountTypeOAuth,
  613. Concurrency: 1,
  614. Credentials: map[string]any{
  615. "access_token": "oauth-token",
  616. "chatgpt_account_id": "chatgpt-acc",
  617. },
  618. }
  619. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
  620. require.NoError(t, err)
  621. require.NotNil(t, result)
  622. require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+4), gjson.GetBytes(upstream.lastBody, "input.#").Int())
  623. require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
  624. require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  625. require.Equal(t, "message-00", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String())
  626. require.Equal(t, "message-14", gjson.GetBytes(upstream.lastBody, "input.15.content.0.text").String())
  627. require.False(t, gjson.GetBytes(upstream.lastBody, "prompt_cache_key").Exists())
  628. }
  629. func TestForwardAsAnthropic_AttachesPreviousResponseIDForCompatContinuation(t *testing.T) {
  630. t.Parallel()
  631. gin.SetMode(gin.TestMode)
  632. upstream := &httpUpstreamRecorder{}
  633. svc := &OpenAIGatewayService{
  634. httpUpstream: upstream,
  635. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  636. }
  637. account := &Account{
  638. ID: 1,
  639. Name: "openai-apikey",
  640. Platform: PlatformOpenAI,
  641. Type: AccountTypeAPIKey,
  642. Concurrency: 1,
  643. Credentials: map[string]any{
  644. "api_key": "sk-test",
  645. "base_url": "https://api.openai.com/v1",
  646. },
  647. }
  648. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`)
  649. upstream.resp = openAICompatSSECompletedResponse("resp_first", "gpt-5.3-codex")
  650. firstRec := httptest.NewRecorder()
  651. firstCtx, _ := gin.CreateTestContext(firstRec)
  652. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  653. firstCtx.Request.Header.Set("Content-Type", "application/json")
  654. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex")
  655. require.NoError(t, err)
  656. require.NotNil(t, firstResult)
  657. require.Equal(t, "resp_first", firstResult.ResponseID)
  658. require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
  659. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  660. upstream.resp = openAICompatSSECompletedResponse("resp_second", "gpt-5.3-codex")
  661. secondRec := httptest.NewRecorder()
  662. secondCtx, _ := gin.CreateTestContext(secondRec)
  663. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  664. secondCtx.Request.Header.Set("Content-Type", "application/json")
  665. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
  666. require.NoError(t, err)
  667. require.NotNil(t, secondResult)
  668. require.Equal(t, "resp_second", secondResult.ResponseID)
  669. require.Equal(t, "resp_first", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
  670. require.Equal(t, int64(2), gjson.GetBytes(upstream.lastBody, "input.#").Int())
  671. require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
  672. require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  673. require.Equal(t, "second", gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String())
  674. }
  675. func TestForwardAsAnthropic_PreviousResponseIDKeepsMultiToolCallContext(t *testing.T) {
  676. t.Parallel()
  677. gin.SetMode(gin.TestMode)
  678. upstream := &httpUpstreamRecorder{}
  679. svc := &OpenAIGatewayService{
  680. httpUpstream: upstream,
  681. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  682. }
  683. account := &Account{
  684. ID: 1,
  685. Name: "openai-apikey",
  686. Platform: PlatformOpenAI,
  687. Type: AccountTypeAPIKey,
  688. Concurrency: 1,
  689. Credentials: map[string]any{
  690. "api_key": "sk-test",
  691. "base_url": "https://api.openai.com/v1",
  692. },
  693. }
  694. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"inspect files"}],"stream":false}`)
  695. upstream.resp = openAICompatSSECompletedResponse("resp_first_tools", "gpt-5.3-codex")
  696. firstRec := httptest.NewRecorder()
  697. firstCtx, _ := gin.CreateTestContext(firstRec)
  698. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  699. firstCtx.Request.Header.Set("Content-Type", "application/json")
  700. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex")
  701. require.NoError(t, err)
  702. require.NotNil(t, firstResult)
  703. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"inspect files"},{"role":"assistant","content":[{"type":"tool_use","id":"call_one","name":"Read","input":{"file_path":"a.go"}},{"type":"tool_use","id":"call_two","name":"Read","input":{"file_path":"b.go"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_one","content":"package a"},{"type":"tool_result","tool_use_id":"call_two","content":"package b"},{"type":"text","text":"continue"}]}],"tools":[{"name":"Read","description":"read a file","input_schema":{"type":"object","properties":{"file_path":{"type":"string"}}}}],"stream":false}`)
  704. upstream.resp = openAICompatSSECompletedResponse("resp_second_tools", "gpt-5.3-codex")
  705. secondRec := httptest.NewRecorder()
  706. secondCtx, _ := gin.CreateTestContext(secondRec)
  707. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  708. secondCtx.Request.Header.Set("Content-Type", "application/json")
  709. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
  710. require.NoError(t, err)
  711. require.NotNil(t, secondResult)
  712. require.Equal(t, "resp_first_tools", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
  713. require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.1.type").String())
  714. require.Equal(t, "call_one", gjson.GetBytes(upstream.lastBody, "input.1.call_id").String())
  715. require.Equal(t, "function_call", gjson.GetBytes(upstream.lastBody, "input.2.type").String())
  716. require.Equal(t, "call_two", gjson.GetBytes(upstream.lastBody, "input.2.call_id").String())
  717. require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.3.type").String())
  718. require.Equal(t, "call_one", gjson.GetBytes(upstream.lastBody, "input.3.call_id").String())
  719. require.Equal(t, "function_call_output", gjson.GetBytes(upstream.lastBody, "input.4.type").String())
  720. require.Equal(t, "call_two", gjson.GetBytes(upstream.lastBody, "input.4.call_id").String())
  721. require.Equal(t, "continue", gjson.GetBytes(upstream.lastBody, "input.5.content.0.text").String())
  722. }
  723. func TestForwardAsAnthropic_ReplaysFullToolHistoryWhenPreviousResponseUnavailable(t *testing.T) {
  724. t.Parallel()
  725. gin.SetMode(gin.TestMode)
  726. upstream := &httpUpstreamRecorder{}
  727. svc := &OpenAIGatewayService{
  728. httpUpstream: upstream,
  729. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  730. }
  731. account := &Account{
  732. ID: 1,
  733. Name: "openai-apikey",
  734. Platform: PlatformOpenAI,
  735. Type: AccountTypeAPIKey,
  736. Concurrency: 1,
  737. Credentials: map[string]any{
  738. "api_key": "sk-test",
  739. "base_url": "https://api.openai.com/v1",
  740. },
  741. }
  742. svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing")
  743. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":[{"type":"tool_use","id":"call_1","name":"lookup","input":{"q":"first"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call_1","content":"found"},{"type":"text","text":"second"}]}],"tools":[{"name":"lookup","input_schema":{"type":"object"}}],"stream":false}`)
  744. upstream.responses = []*http.Response{
  745. {
  746. StatusCode: http.StatusBadRequest,
  747. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_missing"}},
  748. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)),
  749. },
  750. openAICompatSSECompletedResponse("resp_replayed", "gpt-5.3-codex"),
  751. }
  752. rec := httptest.NewRecorder()
  753. c, _ := gin.CreateTestContext(rec)
  754. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  755. c.Request.Header.Set("Content-Type", "application/json")
  756. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
  757. require.NoError(t, err)
  758. require.NotNil(t, result)
  759. require.Equal(t, "resp_replayed", result.ResponseID)
  760. require.Len(t, upstream.requests, 2)
  761. require.Equal(t, "resp_missing", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
  762. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  763. require.Equal(t, int64(5), gjson.GetBytes(upstream.bodies[1], "input.#").Int())
  764. require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String())
  765. require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  766. require.Equal(t, "first", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String())
  767. require.Equal(t, "function_call", gjson.GetBytes(upstream.bodies[1], "input.2.type").String())
  768. require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.2.call_id").String())
  769. require.Equal(t, "function_call_output", gjson.GetBytes(upstream.bodies[1], "input.3.type").String())
  770. require.Equal(t, "call_1", gjson.GetBytes(upstream.bodies[1], "input.3.call_id").String())
  771. require.Equal(t, "found", gjson.GetBytes(upstream.bodies[1], "input.3.output").String())
  772. require.Equal(t, "second", gjson.GetBytes(upstream.bodies[1], "input.4.content.0.text").String())
  773. }
  774. func TestOpenAICompatPreviousResponseUnavailableRecognitionIsStrict(t *testing.T) {
  775. t.Parallel()
  776. body := []byte(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)
  777. require.True(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "", body))
  778. require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusForbidden, "", body))
  779. require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "permission is not available for this user", nil))
  780. require.False(t, isOpenAICompatPreviousResponseNotFound(http.StatusBadRequest, "previous_response_id is not available for this project", nil))
  781. }
  782. func TestForwardAsAnthropic_PreviousResponseUnavailableRetryFailureDoesNotLoop(t *testing.T) {
  783. t.Parallel()
  784. gin.SetMode(gin.TestMode)
  785. unavailable := func() *http.Response {
  786. return &http.Response{
  787. StatusCode: http.StatusBadRequest,
  788. Header: http.Header{"Content-Type": []string{"application/json"}},
  789. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is not available for this user","type":"invalid_request_error"}}`)),
  790. }
  791. }
  792. upstream := &httpUpstreamRecorder{responses: []*http.Response{unavailable(), unavailable()}}
  793. svc := &OpenAIGatewayService{
  794. httpUpstream: upstream,
  795. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  796. }
  797. account := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Concurrency: 1, Credentials: map[string]any{"api_key": "sk-test", "base_url": "https://api.openai.com/v1"}}
  798. svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_missing")
  799. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  800. rec := httptest.NewRecorder()
  801. c, _ := gin.CreateTestContext(rec)
  802. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  803. _, _ = svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.3-codex")
  804. require.Len(t, upstream.requests, 2)
  805. require.True(t, gjson.GetBytes(upstream.bodies[0], "previous_response_id").Exists())
  806. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  807. }
  808. func TestForwardAsAnthropic_DisablesAPIKeyContinuationWhenUpstreamRequiresWebSocketV2(t *testing.T) {
  809. t.Parallel()
  810. gin.SetMode(gin.TestMode)
  811. upstream := &httpUpstreamRecorder{}
  812. svc := &OpenAIGatewayService{
  813. httpUpstream: upstream,
  814. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  815. }
  816. account := &Account{
  817. ID: 1,
  818. Name: "openai-apikey",
  819. Platform: PlatformOpenAI,
  820. Type: AccountTypeAPIKey,
  821. Concurrency: 1,
  822. Credentials: map[string]any{
  823. "api_key": "sk-test",
  824. "base_url": "https://api.openai.com/v1",
  825. },
  826. }
  827. svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_http_unsupported")
  828. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  829. upstream.responses = []*http.Response{
  830. {
  831. StatusCode: http.StatusBadRequest,
  832. Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_prev_http_unsupported"}},
  833. Body: io.NopCloser(strings.NewReader(`{"error":{"message":"previous_response_id is only supported on Responses WebSocket v2","type":"invalid_request_error"}}`)),
  834. },
  835. openAICompatSSECompletedResponse("resp_replayed", "gpt-5.5"),
  836. openAICompatSSECompletedResponse("resp_later", "gpt-5.5"),
  837. }
  838. rec := httptest.NewRecorder()
  839. c, _ := gin.CreateTestContext(rec)
  840. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  841. c.Request.Header.Set("Content-Type", "application/json")
  842. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.5")
  843. require.NoError(t, err)
  844. require.NotNil(t, result)
  845. require.Equal(t, "resp_replayed", result.ResponseID)
  846. require.Len(t, upstream.requests, 2)
  847. require.Equal(t, "resp_http_unsupported", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
  848. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  849. laterRec := httptest.NewRecorder()
  850. laterCtx, _ := gin.CreateTestContext(laterRec)
  851. laterCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  852. laterCtx.Request.Header.Set("Content-Type", "application/json")
  853. laterResult, err := svc.ForwardAsAnthropic(context.Background(), laterCtx, account, body, "stable-cache-key", "gpt-5.5")
  854. require.NoError(t, err)
  855. require.NotNil(t, laterResult)
  856. require.Equal(t, "resp_later", laterResult.ResponseID)
  857. require.Len(t, upstream.requests, 3)
  858. require.False(t, gjson.GetBytes(upstream.bodies[2], "previous_response_id").Exists())
  859. }
  860. func TestForwardAsAnthropic_APIKeyMetadataSessionSurvivesChangingCacheControlAnchorAfterContinuationDisabled(t *testing.T) {
  861. t.Parallel()
  862. gin.SetMode(gin.TestMode)
  863. metadata := `{"user_id":"{\"device_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"account_uuid\":\"\",\"session_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}"}`
  864. firstBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"project docs","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`)
  865. messages := make([]string, 0, openAICompatAnthropicReplayMaxTailMessages+4)
  866. messages = append(messages, `{"role":"user","content":[{"type":"text","text":"rewritten context","cache_control":{"type":"ephemeral"}}]}`)
  867. for i := 1; i < openAICompatAnthropicReplayMaxTailMessages+4; i++ {
  868. messages = append(messages, `{"role":"user","content":"message-`+fmt.Sprintf("%02d", i)+`"}`)
  869. }
  870. secondBody := []byte(`{"model":"claude-haiku-4-5-20251001","max_tokens":16,"metadata":` + metadata + `,"messages":[` + strings.Join(messages, ",") + `],"stream":false}`)
  871. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  872. openAICompatSSECompletedResponse("resp_first", "gpt-5.4-mini"),
  873. openAICompatSSECompletedResponse("resp_second", "gpt-5.4-mini"),
  874. }}
  875. svc := &OpenAIGatewayService{
  876. httpUpstream: upstream,
  877. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  878. }
  879. account := &Account{
  880. ID: 1,
  881. Name: "openai-apikey",
  882. Platform: PlatformOpenAI,
  883. Type: AccountTypeAPIKey,
  884. Concurrency: 1,
  885. Credentials: map[string]any{
  886. "api_key": "sk-test",
  887. "base_url": "https://api.openai.com/v1",
  888. },
  889. }
  890. firstRec := httptest.NewRecorder()
  891. firstCtx, _ := gin.CreateTestContext(firstRec)
  892. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  893. firstCtx.Request.Header.Set("Content-Type", "application/json")
  894. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4-mini")
  895. require.NoError(t, err)
  896. require.NotNil(t, firstResult)
  897. firstKey := gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").String()
  898. require.NotEmpty(t, firstKey)
  899. require.True(t, strings.HasPrefix(firstKey, "anthropic-metadata-"))
  900. svc.disableOpenAICompatSessionContinuation(context.Background(), nil, account, firstKey)
  901. secondRec := httptest.NewRecorder()
  902. secondCtx, _ := gin.CreateTestContext(secondRec)
  903. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  904. secondCtx.Request.Header.Set("Content-Type", "application/json")
  905. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4-mini")
  906. require.NoError(t, err)
  907. require.NotNil(t, secondResult)
  908. require.Len(t, upstream.requests, 2)
  909. require.Equal(t, firstKey, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").String())
  910. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  911. require.Equal(t, int64(openAICompatAnthropicReplayMaxTailMessages+5), gjson.GetBytes(upstream.bodies[1], "input.#").Int())
  912. require.Equal(t, "developer", gjson.GetBytes(upstream.bodies[1], "input.0.role").String())
  913. require.Contains(t, gjson.GetBytes(upstream.bodies[1], "input.0.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  914. require.Equal(t, "rewritten context", gjson.GetBytes(upstream.bodies[1], "input.1.content.0.text").String())
  915. require.Equal(t, "message-15", gjson.GetBytes(upstream.bodies[1], "input.16.content.0.text").String())
  916. }
  917. func TestForwardAsAnthropic_DoesNotAttachPreviousResponseIDForOAuthCompat(t *testing.T) {
  918. t.Parallel()
  919. gin.SetMode(gin.TestMode)
  920. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_next", "gpt-5.4")}
  921. svc := &OpenAIGatewayService{
  922. httpUpstream: upstream,
  923. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  924. }
  925. account := &Account{
  926. ID: 1,
  927. Name: "openai-oauth",
  928. Platform: PlatformOpenAI,
  929. Type: AccountTypeOAuth,
  930. Concurrency: 1,
  931. Credentials: map[string]any{
  932. "access_token": "oauth-token",
  933. "chatgpt_account_id": "chatgpt-acc",
  934. },
  935. }
  936. svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "stable-cache-key", "resp_oauth_prev")
  937. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  938. rec := httptest.NewRecorder()
  939. c, _ := gin.CreateTestContext(rec)
  940. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  941. c.Request.Header.Set("Content-Type", "application/json")
  942. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4")
  943. require.NoError(t, err)
  944. require.NotNil(t, result)
  945. require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
  946. }
  947. func TestForwardAsAnthropic_ReusesOAuthCodexTurnState(t *testing.T) {
  948. t.Parallel()
  949. gin.SetMode(gin.TestMode)
  950. firstResp := openAICompatSSECompletedResponse("resp_oauth_first", "gpt-5.4")
  951. firstResp.Header.Set("x-codex-turn-state", "turn_state_first")
  952. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  953. firstResp,
  954. openAICompatSSECompletedResponse("resp_oauth_second", "gpt-5.4"),
  955. }}
  956. svc := &OpenAIGatewayService{
  957. httpUpstream: upstream,
  958. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  959. }
  960. account := &Account{
  961. ID: 1,
  962. Name: "openai-oauth",
  963. Platform: PlatformOpenAI,
  964. Type: AccountTypeOAuth,
  965. Concurrency: 1,
  966. Credentials: map[string]any{
  967. "access_token": "oauth-token",
  968. "chatgpt_account_id": "chatgpt-acc",
  969. },
  970. }
  971. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`)
  972. firstRec := httptest.NewRecorder()
  973. firstCtx, _ := gin.CreateTestContext(firstRec)
  974. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  975. firstCtx.Request.Header.Set("Content-Type", "application/json")
  976. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.4")
  977. require.NoError(t, err)
  978. require.NotNil(t, firstResult)
  979. require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
  980. requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
  981. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  982. secondRec := httptest.NewRecorder()
  983. secondCtx, _ := gin.CreateTestContext(secondRec)
  984. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  985. secondCtx.Request.Header.Set("Content-Type", "application/json")
  986. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.4")
  987. require.NoError(t, err)
  988. require.NotNil(t, secondResult)
  989. require.Equal(t, "turn_state_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
  990. require.Equal(t, generateSessionUUID(isolateOpenAIUpstreamSessionID(0, account, "stable-cache-key")), upstream.requests[1].Header.Get("session_id"))
  991. require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
  992. requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
  993. require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
  994. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  995. }
  996. func TestForwardAsAnthropic_OAuthRestoresCodexIdentityHeaders(t *testing.T) {
  997. gin.SetMode(gin.TestMode)
  998. const tuiUA = "codex-tui/9.9.9 (Mac OS X 14.0; arm64) iTerm (codex-tui; 9.9.9)"
  999. const vscodeUA = "codex_vscode/9.9.9 (Mac OS X 14.0; arm64) vscode (codex_vscode; 9.9.9)"
  1000. // messages 桥接路径同样强制统一出站身份:三类客户端身份都收敛到规范身份。
  1001. tests := []struct {
  1002. name string
  1003. userAgent string
  1004. originator string
  1005. }{
  1006. {name: "官方vscode身份", userAgent: vscodeUA, originator: "opencode"},
  1007. {name: "TUI身份", userAgent: tuiUA, originator: "opencode"},
  1008. {name: "第三方UA", userAgent: "third-party-client/1.0.0", originator: "opencode"},
  1009. }
  1010. for _, tt := range tests {
  1011. t.Run(tt.name, func(t *testing.T) {
  1012. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  1013. rec := httptest.NewRecorder()
  1014. c, _ := gin.CreateTestContext(rec)
  1015. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1016. c.Request.Header.Set("Content-Type", "application/json")
  1017. c.Request.Header.Set("User-Agent", tt.userAgent)
  1018. c.Request.Header.Set("originator", tt.originator)
  1019. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_identity", "gpt-5.4")}
  1020. svc := &OpenAIGatewayService{
  1021. httpUpstream: upstream,
  1022. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1023. }
  1024. account := &Account{
  1025. ID: 1,
  1026. Name: "openai-oauth",
  1027. Platform: PlatformOpenAI,
  1028. Type: AccountTypeOAuth,
  1029. Concurrency: 1,
  1030. Credentials: map[string]any{
  1031. "access_token": "oauth-token",
  1032. "chatgpt_account_id": "chatgpt-acc",
  1033. },
  1034. }
  1035. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
  1036. require.NoError(t, err)
  1037. require.NotNil(t, result)
  1038. requireOpenAIMessagesCodexIdentity(t, upstream.lastReq, codexCLIUserAgent, openai.CodexDefaultOriginator)
  1039. })
  1040. }
  1041. }
  1042. func TestForwardAsAnthropic_OAuthDigestFallbackReusesTurnStateWithoutExplicitKey(t *testing.T) {
  1043. t.Parallel()
  1044. gin.SetMode(gin.TestMode)
  1045. firstResp := openAICompatSSECompletedResponse("resp_oauth_digest_first", "gpt-5.4")
  1046. firstResp.Header.Set("x-codex-turn-state", "turn_state_digest_first")
  1047. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  1048. firstResp,
  1049. openAICompatSSECompletedResponse("resp_oauth_digest_second", "gpt-5.4"),
  1050. }}
  1051. svc := &OpenAIGatewayService{
  1052. httpUpstream: upstream,
  1053. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1054. }
  1055. account := &Account{
  1056. ID: 1,
  1057. Name: "openai-oauth",
  1058. Platform: PlatformOpenAI,
  1059. Type: AccountTypeOAuth,
  1060. Concurrency: 1,
  1061. Credentials: map[string]any{
  1062. "access_token": "oauth-token",
  1063. "chatgpt_account_id": "chatgpt-acc",
  1064. },
  1065. }
  1066. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":false}`)
  1067. firstRec := httptest.NewRecorder()
  1068. firstCtx, _ := gin.CreateTestContext(firstRec)
  1069. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  1070. firstCtx.Request.Header.Set("Content-Type", "application/json")
  1071. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.4")
  1072. require.NoError(t, err)
  1073. require.NotNil(t, firstResult)
  1074. firstSessionID := upstream.requests[0].Header.Get("session_id")
  1075. require.NotEmpty(t, firstSessionID)
  1076. require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
  1077. requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
  1078. require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
  1079. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  1080. secondRec := httptest.NewRecorder()
  1081. secondCtx, _ := gin.CreateTestContext(secondRec)
  1082. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  1083. secondCtx.Request.Header.Set("Content-Type", "application/json")
  1084. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.4")
  1085. require.NoError(t, err)
  1086. require.NotNil(t, secondResult)
  1087. require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
  1088. require.Equal(t, "turn_state_digest_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
  1089. require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
  1090. requireOpenAIMessagesCodexIdentity(t, upstream.requests[1], codexCLIUserAgent, openai.CodexDefaultOriginator)
  1091. require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
  1092. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  1093. }
  1094. func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesDigestPrefixRewrite(t *testing.T) {
  1095. t.Parallel()
  1096. gin.SetMode(gin.TestMode)
  1097. firstResp := openAICompatSSECompletedResponse("resp_oauth_metadata_first", "gpt-5.5")
  1098. firstResp.Header.Set("x-codex-turn-state", "turn_state_metadata_first")
  1099. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  1100. firstResp,
  1101. openAICompatSSECompletedResponse("resp_oauth_metadata_second", "gpt-5.5"),
  1102. }}
  1103. svc := &OpenAIGatewayService{
  1104. httpUpstream: upstream,
  1105. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1106. }
  1107. account := &Account{
  1108. ID: 1,
  1109. Name: "openai-oauth",
  1110. Platform: PlatformOpenAI,
  1111. Type: AccountTypeOAuth,
  1112. Concurrency: 1,
  1113. Credentials: map[string]any{
  1114. "access_token": "oauth-token",
  1115. "chatgpt_account_id": "chatgpt-acc",
  1116. },
  1117. }
  1118. metadata := `{"user_id":"{\"device_id\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"account_uuid\":\"\",\"session_id\":\"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\"}"}`
  1119. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"messages":[{"role":"user","content":"first plan"}],"stream":false}`)
  1120. firstRec := httptest.NewRecorder()
  1121. firstCtx, _ := gin.CreateTestContext(firstRec)
  1122. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  1123. firstCtx.Request.Header.Set("Content-Type", "application/json")
  1124. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5")
  1125. require.NoError(t, err)
  1126. require.NotNil(t, firstResult)
  1127. firstSessionID := upstream.requests[0].Header.Get("session_id")
  1128. require.NotEmpty(t, firstSessionID)
  1129. require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
  1130. require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
  1131. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"messages":[{"role":"user","content":"rewritten plan"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  1132. secondRec := httptest.NewRecorder()
  1133. secondCtx, _ := gin.CreateTestContext(secondRec)
  1134. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  1135. secondCtx.Request.Header.Set("Content-Type", "application/json")
  1136. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5")
  1137. require.NoError(t, err)
  1138. require.NotNil(t, secondResult)
  1139. require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
  1140. require.Equal(t, "turn_state_metadata_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
  1141. require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
  1142. require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
  1143. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  1144. }
  1145. func TestForwardAsAnthropic_OAuthMetadataSessionSurvivesChangingCacheControlAnchor(t *testing.T) {
  1146. t.Parallel()
  1147. gin.SetMode(gin.TestMode)
  1148. firstResp := openAICompatSSECompletedResponse("resp_oauth_cache_anchor_first", "gpt-5.5")
  1149. firstResp.Header.Set("x-codex-turn-state", "turn_state_cache_anchor_first")
  1150. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  1151. firstResp,
  1152. openAICompatSSECompletedResponse("resp_oauth_cache_anchor_second", "gpt-5.5"),
  1153. }}
  1154. svc := &OpenAIGatewayService{
  1155. httpUpstream: upstream,
  1156. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1157. }
  1158. account := &Account{
  1159. ID: 1,
  1160. Name: "openai-oauth",
  1161. Platform: PlatformOpenAI,
  1162. Type: AccountTypeOAuth,
  1163. Concurrency: 1,
  1164. Credentials: map[string]any{
  1165. "access_token": "oauth-token",
  1166. "chatgpt_account_id": "chatgpt-acc",
  1167. },
  1168. }
  1169. metadata := `{"user_id":"{\"device_id\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\",\"account_uuid\":\"\",\"session_id\":\"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\"}"}`
  1170. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"anchor one","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`)
  1171. firstRec := httptest.NewRecorder()
  1172. firstCtx, _ := gin.CreateTestContext(firstRec)
  1173. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  1174. firstCtx.Request.Header.Set("Content-Type", "application/json")
  1175. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "", "gpt-5.5")
  1176. require.NoError(t, err)
  1177. require.NotNil(t, firstResult)
  1178. firstSessionID := upstream.requests[0].Header.Get("session_id")
  1179. require.NotEmpty(t, firstSessionID)
  1180. require.Empty(t, upstream.requests[0].Header.Get("x-codex-turn-state"))
  1181. require.False(t, gjson.GetBytes(upstream.bodies[0], "prompt_cache_key").Exists())
  1182. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"metadata":` + metadata + `,"system":[{"type":"text","text":"anchor two","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  1183. secondRec := httptest.NewRecorder()
  1184. secondCtx, _ := gin.CreateTestContext(secondRec)
  1185. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  1186. secondCtx.Request.Header.Set("Content-Type", "application/json")
  1187. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "", "gpt-5.5")
  1188. require.NoError(t, err)
  1189. require.NotNil(t, secondResult)
  1190. require.Equal(t, firstSessionID, upstream.requests[1].Header.Get("session_id"))
  1191. require.Equal(t, "turn_state_cache_anchor_first", upstream.requests[1].Header.Get("x-codex-turn-state"))
  1192. require.Empty(t, upstream.requests[1].Header.Get("conversation_id"))
  1193. require.False(t, gjson.GetBytes(upstream.bodies[1], "prompt_cache_key").Exists())
  1194. require.False(t, gjson.GetBytes(upstream.bodies[1], "previous_response_id").Exists())
  1195. }
  1196. func TestForwardAsAnthropic_OAuthKeepsSystemAsDeveloperInput(t *testing.T) {
  1197. t.Parallel()
  1198. gin.SetMode(gin.TestMode)
  1199. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_system", "gpt-5.4")}
  1200. svc := &OpenAIGatewayService{
  1201. httpUpstream: upstream,
  1202. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1203. }
  1204. account := &Account{
  1205. ID: 1,
  1206. Name: "openai-oauth",
  1207. Platform: PlatformOpenAI,
  1208. Type: AccountTypeOAuth,
  1209. Concurrency: 1,
  1210. Credentials: map[string]any{
  1211. "access_token": "oauth-token",
  1212. "chatgpt_account_id": "chatgpt-acc",
  1213. },
  1214. }
  1215. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":[{"type":"text","text":"project instructions","cache_control":{"type":"ephemeral"}}],"messages":[{"role":"user","content":"first"}],"stream":false}`)
  1216. rec := httptest.NewRecorder()
  1217. c, _ := gin.CreateTestContext(rec)
  1218. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1219. c.Request.Header.Set("Content-Type", "application/json")
  1220. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.4")
  1221. require.NoError(t, err)
  1222. require.NotNil(t, result)
  1223. require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
  1224. require.Equal(t, "input_text", gjson.GetBytes(upstream.lastBody, "input.0.content.0.type").String())
  1225. require.Equal(t, "project instructions", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
  1226. instructions := gjson.GetBytes(upstream.lastBody, "instructions")
  1227. require.True(t, instructions.Exists())
  1228. require.Empty(t, instructions.String())
  1229. requireOpenAIMessagesCodexIdentity(t, upstream.requests[0], codexCLIUserAgent, openai.CodexDefaultOriginator)
  1230. }
  1231. func TestForwardAsAnthropic_OAuthAddsClaudeCodeTodoGuardForCompatModel(t *testing.T) {
  1232. t.Parallel()
  1233. gin.SetMode(gin.TestMode)
  1234. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_todo_guard", "gpt-5.5")}
  1235. svc := &OpenAIGatewayService{
  1236. httpUpstream: upstream,
  1237. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1238. }
  1239. account := &Account{
  1240. ID: 1,
  1241. Name: "openai-oauth",
  1242. Platform: PlatformOpenAI,
  1243. Type: AccountTypeOAuth,
  1244. Concurrency: 1,
  1245. Credentials: map[string]any{
  1246. "access_token": "oauth-token",
  1247. "chatgpt_account_id": "chatgpt-acc",
  1248. },
  1249. }
  1250. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"system":"project instructions","messages":[{"role":"user","content":"review files"}],"stream":false}`)
  1251. rec := httptest.NewRecorder()
  1252. c, _ := gin.CreateTestContext(rec)
  1253. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1254. c.Request.Header.Set("Content-Type", "application/json")
  1255. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.5")
  1256. require.NoError(t, err)
  1257. require.NotNil(t, result)
  1258. require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.0.role").String())
  1259. require.Equal(t, "project instructions", gjson.GetBytes(upstream.lastBody, "input.0.content.0.text").String())
  1260. require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "input.1.role").String())
  1261. require.Contains(t, gjson.GetBytes(upstream.lastBody, "input.1.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  1262. require.Equal(t, "user", gjson.GetBytes(upstream.lastBody, "input.2.role").String())
  1263. }
  1264. func TestForwardAsAnthropic_OAuthPreservesClaudeCodeToolCallID(t *testing.T) {
  1265. t.Parallel()
  1266. gin.SetMode(gin.TestMode)
  1267. upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_oauth_tool", "gpt-5.4")}
  1268. svc := &OpenAIGatewayService{
  1269. httpUpstream: upstream,
  1270. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1271. }
  1272. account := &Account{
  1273. ID: 1,
  1274. Name: "openai-oauth",
  1275. Platform: PlatformOpenAI,
  1276. Type: AccountTypeOAuth,
  1277. Concurrency: 1,
  1278. Credentials: map[string]any{
  1279. "access_token": "oauth-token",
  1280. "chatgpt_account_id": "chatgpt-acc",
  1281. },
  1282. }
  1283. body := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"list files"},{"role":"assistant","content":[{"type":"tool_use","id":"toolu_123","name":"Bash","input":{"command":"ls"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_123","content":"ok"}]}],"tools":[{"name":"Bash","description":"run shell","input_schema":{"type":"object","properties":{"command":{"type":"string"}}}}],"stream":false}`)
  1284. rec := httptest.NewRecorder()
  1285. c, _ := gin.CreateTestContext(rec)
  1286. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1287. c.Request.Header.Set("Content-Type", "application/json")
  1288. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "stable-cache-key", "gpt-5.4")
  1289. require.NoError(t, err)
  1290. require.NotNil(t, result)
  1291. require.Equal(t, "toolu_123", gjson.GetBytes(upstream.lastBody, `input.#(type=="function_call").call_id`).String())
  1292. require.Equal(t, "toolu_123", gjson.GetBytes(upstream.lastBody, `input.#(type=="function_call_output").call_id`).String())
  1293. require.True(t, gjson.GetBytes(upstream.lastBody, "parallel_tool_calls").Bool())
  1294. require.Equal(t, "medium", gjson.GetBytes(upstream.lastBody, "text.verbosity").String())
  1295. require.False(t, gjson.GetBytes(upstream.lastBody, "tools.0.strict").Bool())
  1296. }
  1297. func TestForwardAsAnthropic_StoresStreamingResponseIDWithoutUsage(t *testing.T) {
  1298. t.Parallel()
  1299. gin.SetMode(gin.TestMode)
  1300. upstream := &httpUpstreamRecorder{}
  1301. svc := &OpenAIGatewayService{
  1302. httpUpstream: upstream,
  1303. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
  1304. }
  1305. account := &Account{
  1306. ID: 1,
  1307. Name: "openai-apikey",
  1308. Platform: PlatformOpenAI,
  1309. Type: AccountTypeAPIKey,
  1310. Concurrency: 1,
  1311. Credentials: map[string]any{
  1312. "api_key": "sk-test",
  1313. "base_url": "https://api.openai.com/v1",
  1314. },
  1315. }
  1316. firstBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"}],"stream":true}`)
  1317. upstream.resp = openAICompatSSEResponseWithoutUsage("resp_stream_first", "gpt-5.3-codex")
  1318. firstRec := httptest.NewRecorder()
  1319. firstCtx, _ := gin.CreateTestContext(firstRec)
  1320. firstCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(firstBody))
  1321. firstCtx.Request.Header.Set("Content-Type", "application/json")
  1322. firstResult, err := svc.ForwardAsAnthropic(context.Background(), firstCtx, account, firstBody, "stable-cache-key", "gpt-5.3-codex")
  1323. require.NoError(t, err)
  1324. require.NotNil(t, firstResult)
  1325. require.Equal(t, "resp_stream_first", firstResult.ResponseID)
  1326. secondBody := []byte(`{"model":"claude-sonnet-4-5","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}],"stream":false}`)
  1327. upstream.resp = openAICompatSSECompletedResponse("resp_stream_second", "gpt-5.3-codex")
  1328. secondRec := httptest.NewRecorder()
  1329. secondCtx, _ := gin.CreateTestContext(secondRec)
  1330. secondCtx.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(secondBody))
  1331. secondCtx.Request.Header.Set("Content-Type", "application/json")
  1332. secondResult, err := svc.ForwardAsAnthropic(context.Background(), secondCtx, account, secondBody, "stable-cache-key", "gpt-5.3-codex")
  1333. require.NoError(t, err)
  1334. require.NotNil(t, secondResult)
  1335. require.Equal(t, "resp_stream_first", gjson.GetBytes(upstream.lastBody, "previous_response_id").String())
  1336. }
  1337. func openAICompatSSECompletedResponse(responseID, model string) *http.Response {
  1338. body := strings.Join([]string{
  1339. `data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  1340. "",
  1341. "data: [DONE]",
  1342. "",
  1343. }, "\n")
  1344. return &http.Response{
  1345. StatusCode: http.StatusOK,
  1346. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_continuation"}},
  1347. Body: io.NopCloser(strings.NewReader(body)),
  1348. }
  1349. }
  1350. func requireOpenAIMessagesCodexIdentity(t *testing.T, req *http.Request, wantUserAgent, wantOriginator string) {
  1351. t.Helper()
  1352. require.NotNil(t, req)
  1353. require.Equal(t, wantUserAgent, req.Header.Get("User-Agent"))
  1354. require.Equal(t, wantOriginator, req.Header.Get("originator"))
  1355. require.Equal(t, codexCLIVersion, req.Header.Get("version"))
  1356. require.Equal(t, "responses=experimental", req.Header.Get("OpenAI-Beta"))
  1357. }
  1358. func openAICompatSSEResponseWithoutUsage(responseID, model string) *http.Response {
  1359. body := strings.Join([]string{
  1360. `data: {"type":"response.completed","response":{"id":"` + responseID + `","object":"response","model":"` + model + `","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}]}}`,
  1361. "",
  1362. "data: [DONE]",
  1363. "",
  1364. }, "\n")
  1365. return &http.Response{
  1366. StatusCode: http.StatusOK,
  1367. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_" + responseID}},
  1368. Body: io.NopCloser(strings.NewReader(body)),
  1369. }
  1370. }
  1371. func TestForwardAsAnthropic_ForcedCodexInstructionsTemplatePrependsRenderedInstructions(t *testing.T) {
  1372. t.Parallel()
  1373. gin.SetMode(gin.TestMode)
  1374. templateDir := t.TempDir()
  1375. templatePath := filepath.Join(templateDir, "codex-instructions.md.tmpl")
  1376. require.NoError(t, os.WriteFile(templatePath, []byte("server-prefix\n\n{{ .ExistingInstructions }}"), 0o644))
  1377. rec := httptest.NewRecorder()
  1378. c, _ := gin.CreateTestContext(rec)
  1379. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  1380. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1381. c.Request.Header.Set("Content-Type", "application/json")
  1382. upstreamBody := strings.Join([]string{
  1383. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  1384. "",
  1385. "data: [DONE]",
  1386. "",
  1387. }, "\n")
  1388. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1389. StatusCode: http.StatusOK,
  1390. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced"}},
  1391. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1392. }}
  1393. svc := &OpenAIGatewayService{
  1394. cfg: &config.Config{Gateway: config.GatewayConfig{
  1395. ForcedCodexInstructionsTemplateFile: templatePath,
  1396. ForcedCodexInstructionsTemplate: "server-prefix\n\n{{ .ExistingInstructions }}",
  1397. }},
  1398. httpUpstream: upstream,
  1399. }
  1400. account := &Account{
  1401. ID: 1,
  1402. Name: "openai-oauth",
  1403. Platform: PlatformOpenAI,
  1404. Type: AccountTypeOAuth,
  1405. Concurrency: 1,
  1406. Credentials: map[string]any{
  1407. "access_token": "oauth-token",
  1408. "chatgpt_account_id": "chatgpt-acc",
  1409. },
  1410. }
  1411. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1412. require.NoError(t, err)
  1413. require.NotNil(t, result)
  1414. require.Equal(t, "server-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
  1415. }
  1416. func TestForwardAsAnthropic_ForcedCodexInstructionsTemplateUsesCachedTemplateContent(t *testing.T) {
  1417. t.Parallel()
  1418. gin.SetMode(gin.TestMode)
  1419. rec := httptest.NewRecorder()
  1420. c, _ := gin.CreateTestContext(rec)
  1421. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"system":"client-system","messages":[{"role":"user","content":"hello"}],"stream":false}`)
  1422. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1423. c.Request.Header.Set("Content-Type", "application/json")
  1424. upstreamBody := strings.Join([]string{
  1425. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  1426. "",
  1427. "data: [DONE]",
  1428. "",
  1429. }, "\n")
  1430. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1431. StatusCode: http.StatusOK,
  1432. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_forced_cached"}},
  1433. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1434. }}
  1435. svc := &OpenAIGatewayService{
  1436. cfg: &config.Config{Gateway: config.GatewayConfig{
  1437. ForcedCodexInstructionsTemplateFile: "/path/that/should/not/be/read.tmpl",
  1438. ForcedCodexInstructionsTemplate: "cached-prefix\n\n{{ .ExistingInstructions }}",
  1439. }},
  1440. httpUpstream: upstream,
  1441. }
  1442. account := &Account{
  1443. ID: 1,
  1444. Name: "openai-oauth",
  1445. Platform: PlatformOpenAI,
  1446. Type: AccountTypeOAuth,
  1447. Concurrency: 1,
  1448. Credentials: map[string]any{
  1449. "access_token": "oauth-token",
  1450. "chatgpt_account_id": "chatgpt-acc",
  1451. },
  1452. }
  1453. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1454. require.NoError(t, err)
  1455. require.NotNil(t, result)
  1456. require.Equal(t, "cached-prefix\n\nclient-system", gjson.GetBytes(upstream.lastBody, "instructions").String())
  1457. }
  1458. func TestForwardAsAnthropic_ClientDisconnectDrainsUpstreamUsage(t *testing.T) {
  1459. gin.SetMode(gin.TestMode)
  1460. rec := httptest.NewRecorder()
  1461. c, _ := gin.CreateTestContext(rec)
  1462. c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1463. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1464. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1465. c.Request.Header.Set("Content-Type", "application/json")
  1466. upstreamBody := strings.Join([]string{
  1467. `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
  1468. "",
  1469. `data: {"type":"response.output_text.delta","delta":"ok"}`,
  1470. "",
  1471. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13,"input_tokens_details":{"cached_tokens":3}}}}`,
  1472. "",
  1473. "data: [DONE]",
  1474. "",
  1475. }, "\n")
  1476. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1477. StatusCode: http.StatusOK,
  1478. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_disconnect"}},
  1479. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1480. }}
  1481. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1482. account := &Account{
  1483. ID: 1,
  1484. Name: "openai-oauth",
  1485. Platform: PlatformOpenAI,
  1486. Type: AccountTypeOAuth,
  1487. Concurrency: 1,
  1488. Credentials: map[string]any{
  1489. "access_token": "oauth-token",
  1490. "chatgpt_account_id": "chatgpt-acc",
  1491. },
  1492. }
  1493. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1494. require.NoError(t, err)
  1495. require.NotNil(t, result)
  1496. require.Equal(t, 9, result.Usage.InputTokens)
  1497. require.Equal(t, 4, result.Usage.OutputTokens)
  1498. require.Equal(t, 3, result.Usage.CacheReadInputTokens)
  1499. }
  1500. func TestForwardAsAnthropic_TerminalUsageWithoutUpstreamCloseReturns(t *testing.T) {
  1501. gin.SetMode(gin.TestMode)
  1502. rec := httptest.NewRecorder()
  1503. c, _ := gin.CreateTestContext(rec)
  1504. c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1505. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1506. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1507. c.Request.Header.Set("Content-Type", "application/json")
  1508. upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}` + "\n\n")
  1509. upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
  1510. defer func() {
  1511. require.NoError(t, upstreamStream.Close())
  1512. }()
  1513. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1514. StatusCode: http.StatusOK,
  1515. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_terminal_no_close"}},
  1516. Body: upstreamStream,
  1517. }}
  1518. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1519. account := &Account{
  1520. ID: 1,
  1521. Name: "openai-oauth",
  1522. Platform: PlatformOpenAI,
  1523. Type: AccountTypeOAuth,
  1524. Concurrency: 1,
  1525. Credentials: map[string]any{
  1526. "access_token": "oauth-token",
  1527. "chatgpt_account_id": "chatgpt-acc",
  1528. },
  1529. }
  1530. type forwardResult struct {
  1531. result *OpenAIForwardResult
  1532. err error
  1533. }
  1534. resultCh := make(chan forwardResult, 1)
  1535. go func() {
  1536. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1537. resultCh <- forwardResult{result: result, err: err}
  1538. }()
  1539. select {
  1540. case got := <-resultCh:
  1541. require.NoError(t, got.err)
  1542. require.NotNil(t, got.result)
  1543. require.Equal(t, 15, got.result.Usage.InputTokens)
  1544. require.Equal(t, 6, got.result.Usage.OutputTokens)
  1545. require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
  1546. case <-time.After(time.Second):
  1547. require.Fail(t, "ForwardAsAnthropic should return after terminal usage event even if upstream keeps the connection open")
  1548. }
  1549. }
  1550. func TestForwardAsAnthropic_EventNamedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
  1551. gin.SetMode(gin.TestMode)
  1552. rec := httptest.NewRecorder()
  1553. c, _ := gin.CreateTestContext(rec)
  1554. c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1555. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1556. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1557. c.Request.Header.Set("Content-Type", "application/json")
  1558. upstreamBody := []byte(strings.Join([]string{
  1559. `event: response.completed`,
  1560. `data: {"response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}`,
  1561. ``,
  1562. ``,
  1563. }, "\n"))
  1564. upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
  1565. defer func() {
  1566. require.NoError(t, upstreamStream.Close())
  1567. }()
  1568. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1569. StatusCode: http.StatusOK,
  1570. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_messages_event_named_terminal"}},
  1571. Body: upstreamStream,
  1572. }}
  1573. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1574. account := &Account{
  1575. ID: 1,
  1576. Name: "openai-oauth",
  1577. Platform: PlatformOpenAI,
  1578. Type: AccountTypeOAuth,
  1579. Concurrency: 1,
  1580. Credentials: map[string]any{
  1581. "access_token": "oauth-token",
  1582. "chatgpt_account_id": "chatgpt-acc",
  1583. },
  1584. }
  1585. type forwardResult struct {
  1586. result *OpenAIForwardResult
  1587. err error
  1588. }
  1589. resultCh := make(chan forwardResult, 1)
  1590. go func() {
  1591. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1592. resultCh <- forwardResult{result: result, err: err}
  1593. }()
  1594. select {
  1595. case got := <-resultCh:
  1596. require.NoError(t, got.err)
  1597. require.NotNil(t, got.result)
  1598. require.Equal(t, 15, got.result.Usage.InputTokens)
  1599. require.Equal(t, 6, got.result.Usage.OutputTokens)
  1600. require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
  1601. case <-time.After(time.Second):
  1602. require.Fail(t, "ForwardAsAnthropic should use SSE event names when data payloads omit type")
  1603. }
  1604. }
  1605. func TestForwardAsAnthropic_EventNamedTerminalWithKeepaliveReturns(t *testing.T) {
  1606. gin.SetMode(gin.TestMode)
  1607. rec := httptest.NewRecorder()
  1608. c, _ := gin.CreateTestContext(rec)
  1609. c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1610. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1611. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1612. c.Request.Header.Set("Content-Type", "application/json")
  1613. upstreamBody := []byte(strings.Join([]string{
  1614. `: upstream ping`,
  1615. ``,
  1616. `event: response.completed`,
  1617. `data: {"response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}`,
  1618. ``,
  1619. ``,
  1620. }, "\n"))
  1621. upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
  1622. defer func() {
  1623. require.NoError(t, upstreamStream.Close())
  1624. }()
  1625. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1626. StatusCode: http.StatusOK,
  1627. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_messages_event_named_keepalive"}},
  1628. Body: upstreamStream,
  1629. }}
  1630. svc := &OpenAIGatewayService{
  1631. cfg: &config.Config{Gateway: config.GatewayConfig{
  1632. StreamKeepaliveInterval: 5,
  1633. }},
  1634. httpUpstream: upstream,
  1635. }
  1636. account := &Account{
  1637. ID: 1,
  1638. Name: "openai-oauth",
  1639. Platform: PlatformOpenAI,
  1640. Type: AccountTypeOAuth,
  1641. Concurrency: 1,
  1642. Credentials: map[string]any{
  1643. "access_token": "oauth-token",
  1644. "chatgpt_account_id": "chatgpt-acc",
  1645. },
  1646. }
  1647. type forwardResult struct {
  1648. result *OpenAIForwardResult
  1649. err error
  1650. }
  1651. resultCh := make(chan forwardResult, 1)
  1652. go func() {
  1653. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1654. resultCh <- forwardResult{result: result, err: err}
  1655. }()
  1656. select {
  1657. case got := <-resultCh:
  1658. require.NoError(t, got.err)
  1659. require.NotNil(t, got.result)
  1660. require.Equal(t, 15, got.result.Usage.InputTokens)
  1661. require.Equal(t, 6, got.result.Usage.OutputTokens)
  1662. require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
  1663. case <-time.After(time.Second):
  1664. require.Fail(t, "ForwardAsAnthropic keepalive path should use SSE event names when data payloads omit type")
  1665. }
  1666. }
  1667. func TestForwardAsAnthropic_BufferedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
  1668. gin.SetMode(gin.TestMode)
  1669. rec := httptest.NewRecorder()
  1670. c, _ := gin.CreateTestContext(rec)
  1671. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  1672. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1673. c.Request.Header.Set("Content-Type", "application/json")
  1674. upstreamBody := []byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}` + "\n\n")
  1675. upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
  1676. defer func() {
  1677. require.NoError(t, upstreamStream.Close())
  1678. }()
  1679. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1680. StatusCode: http.StatusOK,
  1681. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_buffered_terminal_no_close"}},
  1682. Body: upstreamStream,
  1683. }}
  1684. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1685. account := &Account{
  1686. ID: 1,
  1687. Name: "openai-oauth",
  1688. Platform: PlatformOpenAI,
  1689. Type: AccountTypeOAuth,
  1690. Concurrency: 1,
  1691. Credentials: map[string]any{
  1692. "access_token": "oauth-token",
  1693. "chatgpt_account_id": "chatgpt-acc",
  1694. },
  1695. }
  1696. type forwardResult struct {
  1697. result *OpenAIForwardResult
  1698. err error
  1699. }
  1700. resultCh := make(chan forwardResult, 1)
  1701. go func() {
  1702. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1703. resultCh <- forwardResult{result: result, err: err}
  1704. }()
  1705. select {
  1706. case got := <-resultCh:
  1707. require.NoError(t, got.err)
  1708. require.NotNil(t, got.result)
  1709. require.Equal(t, 15, got.result.Usage.InputTokens)
  1710. require.Equal(t, 6, got.result.Usage.OutputTokens)
  1711. require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
  1712. require.Contains(t, rec.Body.String(), `"stop_reason":"end_turn"`)
  1713. case <-time.After(time.Second):
  1714. require.Fail(t, "ForwardAsAnthropic buffered response should return after terminal usage event even if upstream keeps the connection open")
  1715. }
  1716. }
  1717. func TestHandleAnthropicBufferedStreamingResponse_OverridesUpstreamContentType(t *testing.T) {
  1718. gin.SetMode(gin.TestMode)
  1719. rec := httptest.NewRecorder()
  1720. c, _ := gin.CreateTestContext(rec)
  1721. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  1722. resp := &http.Response{
  1723. StatusCode: http.StatusOK,
  1724. Header: http.Header{"Content-Type": []string{"text/event-stream"}},
  1725. Body: io.NopCloser(strings.NewReader(
  1726. `data: {"type":"response.completed","response":{"id":"resp_buffered_json","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}` + "\n\n",
  1727. )),
  1728. }
  1729. cfg := &config.Config{}
  1730. svc := &OpenAIGatewayService{
  1731. cfg: cfg,
  1732. responseHeaderFilter: compileResponseHeaderFilter(cfg),
  1733. }
  1734. result, err := svc.handleAnthropicBufferedStreamingResponse(
  1735. resp, c, &Account{}, "claude-sonnet-4-5", "gpt-5.4", "gpt-5.4", time.Now(),
  1736. )
  1737. require.NoError(t, err)
  1738. require.NotNil(t, result)
  1739. require.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type"))
  1740. require.NotContains(t, rec.Header().Get("Content-Type"), "text/event-stream")
  1741. var message apicompat.AnthropicResponse
  1742. require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &message))
  1743. require.Equal(t, "message", message.Type)
  1744. require.Equal(t, "resp_buffered_json", message.ID)
  1745. require.Equal(t, 10, message.Usage.InputTokens)
  1746. require.Equal(t, 6, message.Usage.OutputTokens)
  1747. require.Equal(t, 5, message.Usage.CacheReadInputTokens)
  1748. }
  1749. func TestForwardAsAnthropic_BufferedEventNamedTerminalWithoutUpstreamCloseReturns(t *testing.T) {
  1750. gin.SetMode(gin.TestMode)
  1751. rec := httptest.NewRecorder()
  1752. c, _ := gin.CreateTestContext(rec)
  1753. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  1754. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1755. c.Request.Header.Set("Content-Type", "application/json")
  1756. upstreamBody := []byte(strings.Join([]string{
  1757. `event: response.completed`,
  1758. `data: {"response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":15,"output_tokens":6,"total_tokens":21,"input_tokens_details":{"cached_tokens":5}}}}`,
  1759. ``,
  1760. ``,
  1761. }, "\n"))
  1762. upstreamStream := newOpenAICompatBlockingReadCloser(upstreamBody)
  1763. defer func() {
  1764. require.NoError(t, upstreamStream.Close())
  1765. }()
  1766. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1767. StatusCode: http.StatusOK,
  1768. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_messages_buffered_event_named"}},
  1769. Body: upstreamStream,
  1770. }}
  1771. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1772. account := &Account{
  1773. ID: 1,
  1774. Name: "openai-oauth",
  1775. Platform: PlatformOpenAI,
  1776. Type: AccountTypeOAuth,
  1777. Concurrency: 1,
  1778. Credentials: map[string]any{
  1779. "access_token": "oauth-token",
  1780. "chatgpt_account_id": "chatgpt-acc",
  1781. },
  1782. }
  1783. type forwardResult struct {
  1784. result *OpenAIForwardResult
  1785. err error
  1786. }
  1787. resultCh := make(chan forwardResult, 1)
  1788. go func() {
  1789. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1790. resultCh <- forwardResult{result: result, err: err}
  1791. }()
  1792. select {
  1793. case got := <-resultCh:
  1794. require.NoError(t, got.err)
  1795. require.NotNil(t, got.result)
  1796. require.Equal(t, 15, got.result.Usage.InputTokens)
  1797. require.Equal(t, 6, got.result.Usage.OutputTokens)
  1798. require.Equal(t, 5, got.result.Usage.CacheReadInputTokens)
  1799. require.Contains(t, rec.Body.String(), `"stop_reason":"end_turn"`)
  1800. case <-time.After(time.Second):
  1801. require.Fail(t, "ForwardAsAnthropic buffered response should use SSE event names when data payloads omit type")
  1802. }
  1803. }
  1804. func TestForwardAsAnthropic_MissingTerminalBeforeOutputReturnsFailoverAndOps(t *testing.T) {
  1805. gin.SetMode(gin.TestMode)
  1806. rec := httptest.NewRecorder()
  1807. c, _ := gin.CreateTestContext(rec)
  1808. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1809. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1810. c.Request.Header.Set("Content-Type", "application/json")
  1811. upstreamBody := "data: [DONE]\n\n"
  1812. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1813. StatusCode: http.StatusOK,
  1814. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_missing_terminal"}},
  1815. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1816. }}
  1817. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1818. account := &Account{
  1819. ID: 1,
  1820. Name: "openai-oauth",
  1821. Platform: PlatformOpenAI,
  1822. Type: AccountTypeOAuth,
  1823. Concurrency: 1,
  1824. Credentials: map[string]any{
  1825. "access_token": "oauth-token",
  1826. "chatgpt_account_id": "chatgpt-acc",
  1827. },
  1828. }
  1829. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1830. require.Error(t, err)
  1831. var failoverErr *UpstreamFailoverError
  1832. require.True(t, errors.As(err, &failoverErr), "missing terminal before output must use failover path")
  1833. require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
  1834. require.Contains(t, string(failoverErr.ResponseBody), "OpenAI messages stream ended before a terminal event")
  1835. require.NotNil(t, result)
  1836. require.Zero(t, result.Usage.InputTokens)
  1837. require.Zero(t, result.Usage.OutputTokens)
  1838. require.False(t, c.Writer.Written(), "no client body/header should be committed before safe failover")
  1839. require.Empty(t, rec.Body.String())
  1840. events := openAICompatOpsEvents(t, c)
  1841. require.Len(t, events, 1)
  1842. require.Equal(t, "failover", events[0].Kind)
  1843. require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode)
  1844. require.Equal(t, int64(1), events[0].AccountID)
  1845. require.Equal(t, "rid_missing_terminal", events[0].UpstreamRequestID)
  1846. require.Contains(t, events[0].Message, "terminal event")
  1847. }
  1848. func TestForwardAsAnthropic_MissingTerminalAfterOutputRecordsOpsWithoutFailover(t *testing.T) {
  1849. gin.SetMode(gin.TestMode)
  1850. rec := httptest.NewRecorder()
  1851. c, _ := gin.CreateTestContext(rec)
  1852. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1853. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1854. c.Request.Header.Set("Content-Type", "application/json")
  1855. upstreamBody := strings.Join([]string{
  1856. `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
  1857. "",
  1858. `data: {"type":"response.output_text.delta","delta":"partial"}`,
  1859. "",
  1860. }, "\n")
  1861. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1862. StatusCode: http.StatusOK,
  1863. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_partial_missing_terminal"}},
  1864. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1865. }}
  1866. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1867. account := &Account{
  1868. ID: 1,
  1869. Name: "openai-oauth",
  1870. Platform: PlatformOpenAI,
  1871. Type: AccountTypeOAuth,
  1872. Concurrency: 1,
  1873. Credentials: map[string]any{
  1874. "access_token": "oauth-token",
  1875. "chatgpt_account_id": "chatgpt-acc",
  1876. },
  1877. }
  1878. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1879. require.Error(t, err)
  1880. require.Contains(t, err.Error(), "missing terminal event")
  1881. var failoverErr *UpstreamFailoverError
  1882. require.False(t, errors.As(err, &failoverErr), "partial output must not be replayed through failover")
  1883. require.NotNil(t, result)
  1884. require.False(t, result.ClientDisconnect)
  1885. require.True(t, c.Writer.Written())
  1886. require.Contains(t, rec.Body.String(), "event: message_start")
  1887. require.Contains(t, rec.Body.String(), "partial")
  1888. events := openAICompatOpsEvents(t, c)
  1889. require.Len(t, events, 1)
  1890. require.Equal(t, "stream_missing_terminal", events[0].Kind)
  1891. require.Equal(t, http.StatusBadGateway, events[0].UpstreamStatusCode)
  1892. require.Equal(t, int64(1), events[0].AccountID)
  1893. require.Equal(t, "rid_partial_missing_terminal", events[0].UpstreamRequestID)
  1894. require.Contains(t, events[0].Message, "terminal event")
  1895. }
  1896. func TestForwardAsAnthropic_MissingTerminalAfterClientDisconnectSkipsOpsAndFailover(t *testing.T) {
  1897. gin.SetMode(gin.TestMode)
  1898. rec := httptest.NewRecorder()
  1899. c, _ := gin.CreateTestContext(rec)
  1900. c.Writer = &openAICompatFailingWriter{ResponseWriter: c.Writer, failAfter: 0}
  1901. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1902. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1903. c.Request.Header.Set("Content-Type", "application/json")
  1904. upstreamBody := strings.Join([]string{
  1905. `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
  1906. "",
  1907. }, "\n")
  1908. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1909. StatusCode: http.StatusOK,
  1910. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_client_disconnect_missing_terminal"}},
  1911. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1912. }}
  1913. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1914. account := &Account{
  1915. ID: 1,
  1916. Name: "openai-oauth",
  1917. Platform: PlatformOpenAI,
  1918. Type: AccountTypeOAuth,
  1919. Concurrency: 1,
  1920. Credentials: map[string]any{
  1921. "access_token": "oauth-token",
  1922. "chatgpt_account_id": "chatgpt-acc",
  1923. },
  1924. }
  1925. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1926. require.Error(t, err)
  1927. require.Contains(t, err.Error(), "missing terminal event")
  1928. var failoverErr *UpstreamFailoverError
  1929. require.False(t, errors.As(err, &failoverErr))
  1930. require.NotNil(t, result)
  1931. require.True(t, result.ClientDisconnect)
  1932. require.Empty(t, rec.Body.String())
  1933. _, ok := c.Get(OpsUpstreamErrorsKey)
  1934. require.False(t, ok, "client disconnect must not be attributed as an upstream error")
  1935. }
  1936. func TestForwardAsAnthropic_CompleteStreamDoesNotRecordMissingTerminalOps(t *testing.T) {
  1937. gin.SetMode(gin.TestMode)
  1938. rec := httptest.NewRecorder()
  1939. c, _ := gin.CreateTestContext(rec)
  1940. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":true}`)
  1941. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  1942. c.Request.Header.Set("Content-Type", "application/json")
  1943. upstreamBody := strings.Join([]string{
  1944. `data: {"type":"response.created","response":{"id":"resp_1","model":"gpt-5.4","status":"in_progress","output":[]}}`,
  1945. "",
  1946. `data: {"type":"response.output_text.delta","delta":"ok"}`,
  1947. "",
  1948. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":9,"output_tokens":4,"total_tokens":13}}}`,
  1949. "",
  1950. "data: [DONE]",
  1951. "",
  1952. }, "\n")
  1953. upstream := &httpUpstreamRecorder{resp: &http.Response{
  1954. StatusCode: http.StatusOK,
  1955. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "X-Request-Id": []string{"rid_complete_terminal"}},
  1956. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1957. }}
  1958. svc := &OpenAIGatewayService{httpUpstream: upstream}
  1959. account := &Account{
  1960. ID: 1,
  1961. Name: "openai-oauth",
  1962. Platform: PlatformOpenAI,
  1963. Type: AccountTypeOAuth,
  1964. Concurrency: 1,
  1965. Credentials: map[string]any{
  1966. "access_token": "oauth-token",
  1967. "chatgpt_account_id": "chatgpt-acc",
  1968. },
  1969. }
  1970. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "gpt-5.1")
  1971. require.NoError(t, err)
  1972. require.NotNil(t, result)
  1973. require.Equal(t, 9, result.Usage.InputTokens)
  1974. require.Equal(t, 4, result.Usage.OutputTokens)
  1975. require.Contains(t, rec.Body.String(), "event: message_stop")
  1976. _, ok := c.Get(OpsUpstreamErrorsKey)
  1977. require.False(t, ok)
  1978. }
  1979. func openAICompatOpsEvents(t *testing.T, c *gin.Context) []*OpsUpstreamErrorEvent {
  1980. t.Helper()
  1981. v, ok := c.Get(OpsUpstreamErrorsKey)
  1982. require.True(t, ok)
  1983. events, ok := v.([]*OpsUpstreamErrorEvent)
  1984. require.True(t, ok)
  1985. return events
  1986. }
  1987. func TestForwardAsAnthropic_UpstreamRequestIgnoresClientCancel(t *testing.T) {
  1988. gin.SetMode(gin.TestMode)
  1989. rec := httptest.NewRecorder()
  1990. c, _ := gin.CreateTestContext(rec)
  1991. reqCtx, cancel := context.WithCancel(context.Background())
  1992. body := []byte(`{"model":"gpt-5.4","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
  1993. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body)).WithContext(reqCtx)
  1994. c.Request.Header.Set("Content-Type", "application/json")
  1995. cancel()
  1996. upstreamBody := strings.Join([]string{
  1997. `data: {"type":"response.completed","response":{"id":"resp_1","object":"response","model":"gpt-5.4","status":"completed","output":[{"type":"message","id":"msg_1","role":"assistant","status":"completed","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":5,"output_tokens":2,"total_tokens":7}}}`,
  1998. "",
  1999. "data: [DONE]",
  2000. "",
  2001. }, "\n")
  2002. upstream := &httpUpstreamRecorder{resp: &http.Response{
  2003. StatusCode: http.StatusOK,
  2004. Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_ctx"}},
  2005. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  2006. }}
  2007. svc := &OpenAIGatewayService{httpUpstream: upstream}
  2008. account := &Account{
  2009. ID: 1,
  2010. Name: "openai-oauth",
  2011. Platform: PlatformOpenAI,
  2012. Type: AccountTypeOAuth,
  2013. Concurrency: 1,
  2014. Credentials: map[string]any{
  2015. "access_token": "oauth-token",
  2016. "chatgpt_account_id": "chatgpt-acc",
  2017. },
  2018. }
  2019. result, err := svc.ForwardAsAnthropic(reqCtx, c, account, body, "", "gpt-5.1")
  2020. require.NoError(t, err)
  2021. require.NotNil(t, result)
  2022. require.NotNil(t, upstream.lastReq)
  2023. require.NoError(t, upstream.lastReq.Context().Err())
  2024. }
  2025. func TestForwardAsAnthropic_AstraContinuationRestoresHistoryAndDisablesUnsupportedSession(t *testing.T) {
  2026. for _, message := range []string{
  2027. "previous_response_id is not available for this user",
  2028. "previous_response_id requires an OpenAI API-key account for HTTP requests",
  2029. } {
  2030. t.Run(message, func(t *testing.T) {
  2031. encodedError, err := json.Marshal(map[string]any{"error": map[string]string{"message": message}})
  2032. require.NoError(t, err)
  2033. upstream := &httpUpstreamRecorder{responses: []*http.Response{
  2034. {StatusCode: http.StatusBadRequest, Body: io.NopCloser(bytes.NewReader(encodedError))},
  2035. openAICompatSSECompletedResponse("resp_replayed", "gpt-6-astra"),
  2036. openAICompatSSECompletedResponse("resp_later", "gpt-6-astra"),
  2037. }}
  2038. svc := &OpenAIGatewayService{httpUpstream: upstream, cfg: &config.Config{}}
  2039. account := rawGPT56ResponsesAPIKeyAccount("gpt-6-astra", "gpt-6-astra")
  2040. svc.bindOpenAICompatSessionResponseID(context.Background(), nil, account, "astra-session", "resp_old")
  2041. body := []byte(`{"model":"gpt-6-astra","max_tokens":16,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":"ok"},{"role":"user","content":"second"}]}`)
  2042. for i := 0; i < 2; i++ {
  2043. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  2044. c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
  2045. result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "astra-session", "gpt-6-astra")
  2046. require.NoError(t, err)
  2047. require.NotNil(t, result)
  2048. }
  2049. require.Len(t, upstream.bodies, 3)
  2050. require.Equal(t, "resp_old", gjson.GetBytes(upstream.bodies[0], "previous_response_id").String())
  2051. for _, sent := range upstream.bodies[1:] {
  2052. require.False(t, gjson.GetBytes(sent, "previous_response_id").Exists())
  2053. require.Equal(t, "astra-session", gjson.GetBytes(sent, "prompt_cache_key").String())
  2054. require.Equal(t, int64(4), gjson.GetBytes(sent, "input.#").Int())
  2055. require.Contains(t, gjson.GetBytes(sent, "input.0.content.0.text").String(), "<sub2api-claude-code-todo-guard>")
  2056. require.Equal(t, "first", gjson.GetBytes(sent, "input.1.content.0.text").String())
  2057. require.Equal(t, "second", gjson.GetBytes(sent, "input.3.content.0.text").String())
  2058. }
  2059. })
  2060. }
  2061. require.False(t, isOpenAICompatPreviousResponseUnsupported(http.StatusUnauthorized, "previous_response_id is not available for this user", nil))
  2062. require.False(t, isOpenAICompatPreviousResponseUnsupported(http.StatusBadRequest, "The model is not available for this user", nil))
  2063. }