openai_codex_models_service_test.go 127 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "io"
  9. "net"
  10. "net/http"
  11. "net/http/httptest"
  12. "net/url"
  13. "strings"
  14. "sync"
  15. "sync/atomic"
  16. "testing"
  17. "time"
  18. "github.com/Wei-Shaw/sub2api/internal/config"
  19. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  20. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  21. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  22. "github.com/stretchr/testify/require"
  23. "golang.org/x/net/http2"
  24. )
  25. type codexModelsHTTPUpstreamStub struct {
  26. do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
  27. }
  28. type codexModelsVisibilityAccountRepo struct {
  29. AccountRepository
  30. byGroup map[int64][]Account
  31. }
  32. func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
  33. accounts := r.byGroup[groupID]
  34. return append([]Account(nil), accounts...), nil
  35. }
  36. func (r codexModelsVisibilityAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
  37. if groupID == nil {
  38. return nil, nil
  39. }
  40. accounts := r.byGroup[*groupID]
  41. return append([]Account(nil), accounts...), nil
  42. }
  43. type countingCodexModelsAccountRepo struct {
  44. AccountRepository
  45. accounts []Account
  46. err error
  47. availabilityErr error
  48. groupID *int64
  49. platforms []string
  50. includeGrouped bool
  51. calls atomic.Int32
  52. }
  53. func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) {
  54. r.calls.Add(1)
  55. if r.err != nil {
  56. return nil, r.err
  57. }
  58. return append([]Account(nil), r.accounts...), nil
  59. }
  60. func (r *countingCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error) {
  61. if groupID != nil {
  62. value := *groupID
  63. r.groupID = &value
  64. }
  65. r.platforms = append([]string(nil), platforms...)
  66. r.includeGrouped = includeGrouped
  67. if r.availabilityErr != nil {
  68. return nil, r.availabilityErr
  69. }
  70. return append([]Account(nil), r.accounts...), nil
  71. }
  72. type splitCodexModelsAccountRepo struct {
  73. AccountRepository
  74. schedulable map[int64][]Account
  75. catalog map[int64][]Account
  76. all map[int64][]Account
  77. }
  78. func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
  79. return append([]Account(nil), r.schedulable[groupID]...), nil
  80. }
  81. func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
  82. accounts := r.all[groupID]
  83. if accounts == nil {
  84. accounts = r.catalog[groupID]
  85. }
  86. return append([]Account(nil), accounts...), nil
  87. }
  88. func (r splitCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
  89. if groupID == nil {
  90. return nil, nil
  91. }
  92. return append([]Account(nil), r.catalog[*groupID]...), nil
  93. }
  94. func newCodexCatalogMappedAccount(
  95. id int64,
  96. target string,
  97. displayName string,
  98. levels []string,
  99. modalities []string,
  100. contextWindow int64,
  101. schedulable bool,
  102. extraMapping map[string]any,
  103. ) Account {
  104. reasoning := true
  105. mapping := map[string]any{"my-coder": target}
  106. for key, value := range extraMapping {
  107. mapping[key] = value
  108. }
  109. account := Account{
  110. ID: id,
  111. Platform: PlatformOpenAI,
  112. Type: AccountTypeAPIKey,
  113. Status: StatusActive,
  114. Schedulable: schedulable,
  115. Credentials: map[string]any{
  116. "base_url": fmt.Sprintf("https://provider-%d.example/v1", id),
  117. "model_mapping": mapping,
  118. },
  119. }
  120. models := map[string]UpstreamModelMetadata{
  121. target: {
  122. ID: target,
  123. DisplayName: displayName,
  124. Description: displayName + " upstream",
  125. Reasoning: &reasoning,
  126. SupportedReasoningLevels: levels,
  127. InputModalities: modalities,
  128. ContextWindow: contextWindow,
  129. },
  130. }
  131. for _, value := range extraMapping {
  132. exclusive, _ := value.(string)
  133. if exclusive == "" || exclusive == target {
  134. continue
  135. }
  136. models[exclusive] = UpstreamModelMetadata{
  137. ID: exclusive,
  138. DisplayName: "Exclusive Model",
  139. Description: "Only mapped on the unschedulable account",
  140. Reasoning: &reasoning,
  141. SupportedReasoningLevels: []string{"high"},
  142. InputModalities: []string{"text", "image"},
  143. ContextWindow: 1_000_000,
  144. }
  145. }
  146. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: models})
  147. return account
  148. }
  149. func TestFilterCodexModelIDsForGroupOmitsWildcardKeys(t *testing.T) {
  150. t.Parallel()
  151. got := FilterCodexModelIDsForGroup(
  152. []string{"deepseek-v4-pro", "foo-*", " bar-* ", "gpt-5.5"},
  153. &Group{Platform: PlatformDeepseek},
  154. )
  155. require.Equal(t, []string{"deepseek-v4-pro", "gpt-5.5"}, got)
  156. }
  157. func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any {
  158. t.Helper()
  159. var envelope struct {
  160. Models []map[string]any `json:"models"`
  161. }
  162. require.NoError(t, json.Unmarshal(body, &envelope))
  163. return envelope.Models
  164. }
  165. func codexManifestModelSlugs(t *testing.T, body []byte) []string {
  166. t.Helper()
  167. models := decodeCodexManifestModels(t, body)
  168. slugs := make([]string, 0, len(models))
  169. for _, model := range models {
  170. slug, ok := model["slug"].(string)
  171. require.True(t, ok)
  172. slugs = append(slugs, slug)
  173. }
  174. return slugs
  175. }
  176. func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) {
  177. t.Helper()
  178. require.Equal(t, slug, model["slug"])
  179. require.NotEmpty(t, model["display_name"])
  180. require.NotEmpty(t, model["description"])
  181. require.Equal(t, "unified_exec", model["shell_type"])
  182. require.Equal(t, "list", model["visibility"])
  183. require.Equal(t, true, model["supported_in_api"])
  184. require.NotNil(t, model["priority"])
  185. require.Equal(t, []any{}, model["additional_speed_tiers"])
  186. require.IsType(t, []any{}, model["service_tiers"])
  187. require.Contains(t, model, "default_service_tier")
  188. require.Contains(t, model, "availability_nux")
  189. require.Contains(t, model, "upgrade")
  190. require.Contains(t, model, "default_verbosity")
  191. require.Contains(t, model, "apply_patch_tool_type")
  192. require.Contains(t, model, "auto_compact_token_limit")
  193. require.Contains(t, model, "comp_hash")
  194. require.Contains(t, model, "auto_review_model_override")
  195. require.Contains(t, model, "model_specialty")
  196. require.Contains(t, model, "tool_mode")
  197. require.Contains(t, model, "multi_agent_version")
  198. require.Equal(t, true, model["supports_reasoning_summary_parameter"])
  199. require.Contains(t, model, "include_skills_usage_instructions")
  200. require.Contains(t, model, "include_plugin_usage_instructions")
  201. require.Contains(t, model, "include_apps_usage_instructions")
  202. require.Contains(t, model, "supports_image_detail_original")
  203. require.Contains(t, model, "node_repl_auto_review_required")
  204. require.Contains(t, model, "node_repl_disabled")
  205. require.Contains(t, model, "truncation_policy")
  206. require.Contains(t, model, "supports_parallel_tool_calls")
  207. require.Contains(t, model, "experimental_supported_tools")
  208. modelMessages, ok := model["model_messages"].(map[string]any)
  209. require.True(t, ok)
  210. require.NotEmpty(t, modelMessages["instructions_template"])
  211. for _, key := range []string{
  212. "instructions_variables",
  213. "approvals",
  214. "collaboration_modes",
  215. "auto_review",
  216. "permissions",
  217. "multi_agent",
  218. "token_budget",
  219. "guardian_v2",
  220. } {
  221. require.Contains(t, modelMessages, key)
  222. }
  223. }
  224. func effortsFromManifestModel(t *testing.T, model map[string]any) []string {
  225. t.Helper()
  226. levels, ok := model["supported_reasoning_levels"].([]any)
  227. require.True(t, ok)
  228. efforts := make([]string, 0, len(levels))
  229. for _, rawLevel := range levels {
  230. level, ok := rawLevel.(map[string]any)
  231. require.True(t, ok)
  232. effort, ok := level["effort"].(string)
  233. require.True(t, ok)
  234. efforts = append(efforts, effort)
  235. }
  236. return efforts
  237. }
  238. func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) {
  239. t.Parallel()
  240. deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro")
  241. require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName)
  242. require.Equal(t, int64(1_000_000), deepSeek.ContextWindow)
  243. require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow)
  244. require.NotNil(t, deepSeek.DefaultReasoningLevel)
  245. require.Equal(t, "high", *deepSeek.DefaultReasoningLevel)
  246. require.Equal(t, []configuredCodexReasoningLevel{
  247. {Effort: "low", Description: "Fast responses with lighter reasoning"},
  248. {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"},
  249. {Effort: "max", Description: "Maximum reasoning depth for complex tasks"},
  250. }, deepSeek.SupportedReasoningLevels)
  251. require.True(t, deepSeek.SupportsParallelToolCalls)
  252. require.Equal(t, []string{"text"}, deepSeek.InputModalities)
  253. grok := newConfiguredCodexModelDescriptor("grok-4.6")
  254. require.Equal(t, "Grok 4.6", grok.DisplayName)
  255. require.Equal(t, int64(500_000), grok.ContextWindow)
  256. require.Equal(t, int64(500_000), grok.MaxContextWindow)
  257. require.NotNil(t, grok.DefaultReasoningLevel)
  258. require.Equal(t, "high", *grok.DefaultReasoningLevel)
  259. require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels))
  260. require.True(t, grok.SupportsParallelToolCalls)
  261. require.Equal(t, []string{"text"}, grok.InputModalities)
  262. require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
  263. require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
  264. grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest")
  265. require.Equal(t, "Grok 4.6", grokAlias.DisplayName)
  266. require.NotNil(t, grokAlias.DefaultReasoningLevel)
  267. require.Equal(t, "high", *grokAlias.DefaultReasoningLevel)
  268. require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels))
  269. grok45 := newConfiguredCodexModelDescriptor("grok-4.5")
  270. require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels))
  271. grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning")
  272. require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName)
  273. require.NotNil(t, grokNonReasoning.DefaultReasoningLevel)
  274. require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel)
  275. require.Equal(t, []configuredCodexReasoningLevel{
  276. {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
  277. }, grokNonReasoning.SupportedReasoningLevels)
  278. claude := newConfiguredCodexModelDescriptor("claude-opus-4-6")
  279. require.Equal(t, "Claude Opus 4.6", claude.DisplayName)
  280. require.NotNil(t, claude.DefaultReasoningLevel)
  281. require.Equal(t, "medium", *claude.DefaultReasoningLevel)
  282. require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels))
  283. require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"})
  284. require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
  285. require.True(t, claude.SupportsParallelToolCalls)
  286. claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5")
  287. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels))
  288. providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6")
  289. require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName)
  290. require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels))
  291. claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001")
  292. require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName)
  293. require.NotNil(t, claudeHaiku.DefaultReasoningLevel)
  294. require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel)
  295. require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels))
  296. gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol")
  297. require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName)
  298. require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description)
  299. require.NotNil(t, gpt56.DefaultReasoningLevel)
  300. require.Equal(t, "low", *gpt56.DefaultReasoningLevel)
  301. require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels)
  302. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels))
  303. require.True(t, gpt56.SupportsParallelToolCalls)
  304. require.True(t, gpt56.SupportVerbosity)
  305. require.Equal(t, []string{"text"}, gpt56.InputModalities)
  306. require.Equal(t, int64(872_000), gpt56.MaxContextWindow)
  307. require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy)
  308. require.NotNil(t, gpt56.DefaultVerbosity)
  309. require.Equal(t, "low", *gpt56.DefaultVerbosity)
  310. require.True(t, gpt56.SupportsReasoningSummaryParameter)
  311. require.Equal(t, "none", gpt56.DefaultReasoningSummary)
  312. gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna")
  313. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels))
  314. require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel)
  315. gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra")
  316. require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName)
  317. require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6Astra.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6."))
  318. require.NotNil(t, gpt6Astra.DefaultReasoningLevel)
  319. require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel)
  320. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels))
  321. require.NotNil(t, gpt6Astra.MultiAgentReasoningEffort)
  322. require.Equal(t, "xhigh", *gpt6Astra.MultiAgentReasoningEffort)
  323. require.Equal(t, "v2", gpt6Astra.MultiAgentVersion)
  324. require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
  325. require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra"))
  326. require.Equal(t, []configuredCodexServiceTier{{
  327. ID: "priority",
  328. Name: "Fast",
  329. Description: "Priority processing for lower latency.",
  330. }}, gpt6Astra.ServiceTiers)
  331. require.True(t, isOpenAICodexImageInputModel("gpt-6-astra"))
  332. require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra"))
  333. require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01"))
  334. require.False(t, isOpenAIGPT6AstraModel("gpt-6-other"))
  335. require.Equal(t, int64(1_050_000), gpt6Astra.ContextWindow)
  336. require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow)
  337. gpt6 := newConfiguredCodexModelDescriptor("gpt-6")
  338. require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName)
  339. require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6."))
  340. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels))
  341. require.NotNil(t, gpt6.MultiAgentReasoningEffort)
  342. require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort)
  343. require.Equal(t, int64(1_050_000), gpt6.ContextWindow)
  344. gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5")
  345. require.Equal(t, "GPT-5.5", gpt55.DisplayName)
  346. require.NotNil(t, gpt55.DefaultReasoningLevel)
  347. require.Equal(t, "medium", *gpt55.DefaultReasoningLevel)
  348. require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels))
  349. require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
  350. require.NotNil(t, gpt55.DefaultVerbosity)
  351. require.Equal(t, "low", *gpt55.DefaultVerbosity)
  352. gpt4o := newConfiguredCodexModelDescriptor("gpt-4o")
  353. require.Equal(t, "gpt-4o", gpt4o.DisplayName)
  354. require.NotNil(t, gpt4o.DefaultReasoningLevel)
  355. require.Equal(t, "none", *gpt4o.DefaultReasoningLevel)
  356. require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels))
  357. require.True(t, gpt4o.SupportsParallelToolCalls)
  358. image := newConfiguredCodexModelDescriptor("gpt-image-2")
  359. require.Equal(t, "gpt-image-2", image.DisplayName)
  360. require.NotNil(t, image.DefaultReasoningLevel)
  361. require.Equal(t, "none", *image.DefaultReasoningLevel)
  362. require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels))
  363. custom := newConfiguredCodexModelDescriptor("company-coding-model")
  364. require.Equal(t, "company-coding-model", custom.DisplayName)
  365. require.Equal(t, int64(272_000), custom.ContextWindow)
  366. require.NotNil(t, custom.DefaultReasoningLevel)
  367. require.Equal(t, "none", *custom.DefaultReasoningLevel)
  368. require.Equal(t, []configuredCodexReasoningLevel{
  369. {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
  370. }, custom.SupportedReasoningLevels)
  371. require.False(t, custom.SupportsParallelToolCalls)
  372. require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate)
  373. require.Equal(t, "auto", custom.DefaultReasoningSummary)
  374. require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy)
  375. }
  376. func TestBuildCodexModelsManifestUsesGPT6AstraInstructions(t *testing.T) {
  377. body, err := BuildCodexModelsManifest([]string{"gpt-6-astra"})
  378. require.NoError(t, err)
  379. var manifest struct {
  380. Models []struct {
  381. ModelMessages struct {
  382. InstructionsTemplate string `json:"instructions_template"`
  383. } `json:"model_messages"`
  384. } `json:"models"`
  385. }
  386. require.NoError(t, json.Unmarshal(body, &manifest))
  387. require.Len(t, manifest.Models, 1)
  388. require.True(t, strings.HasPrefix(
  389. strings.TrimSpace(manifest.Models[0].ModelMessages.InstructionsTemplate),
  390. "You are Codex, an agent based on GPT-6.",
  391. ))
  392. }
  393. func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string {
  394. efforts := make([]string, 0, len(levels))
  395. for _, level := range levels {
  396. efforts = append(efforts, level.Effort)
  397. }
  398. return efforts
  399. }
  400. // Scenario: 无推理模型可直接选中。
  401. func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) {
  402. t.Parallel()
  403. body, err := BuildCodexModelsManifest([]string{"company-coding-model"})
  404. require.NoError(t, err)
  405. models := decodeCodexManifestModels(t, body)
  406. require.Len(t, models, 1)
  407. require.Equal(t, "none", models[0]["default_reasoning_level"])
  408. levels, ok := models[0]["supported_reasoning_levels"].([]any)
  409. require.True(t, ok)
  410. require.Len(t, levels, 1)
  411. firstLevel, ok := levels[0].(map[string]any)
  412. require.True(t, ok)
  413. require.Equal(t, "none", firstLevel["effort"])
  414. }
  415. // Scenario: 已知推理模型保留真实档位。
  416. func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) {
  417. t.Parallel()
  418. body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"})
  419. require.NoError(t, err)
  420. models := decodeCodexManifestModels(t, body)
  421. require.Len(t, models, 1)
  422. require.Equal(t, "low", models[0]["default_reasoning_level"])
  423. levels, ok := models[0]["supported_reasoning_levels"].([]any)
  424. require.True(t, ok)
  425. require.Len(t, levels, 6)
  426. firstLevel, ok := levels[0].(map[string]any)
  427. require.True(t, ok)
  428. require.NotEqual(t, "none", firstLevel["effort"])
  429. }
  430. // Scenario: 支持 Fast 的 GPT 型号在目录中声明 priority service tier。
  431. func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t *testing.T) {
  432. t.Parallel()
  433. body, err := BuildCodexModelsManifest([]string{
  434. "gpt-5.4-mini",
  435. "gpt-5.5",
  436. "gpt-5.6-terra",
  437. })
  438. require.NoError(t, err)
  439. models := decodeCodexManifestModels(t, body)
  440. require.Len(t, models, 3)
  441. for _, model := range models {
  442. require.Equal(t, []any{
  443. map[string]any{
  444. "id": "priority",
  445. "name": "Fast",
  446. "description": "Priority processing for lower latency.",
  447. },
  448. }, model["service_tiers"])
  449. require.Nil(t, model["default_service_tier"])
  450. }
  451. }
  452. // Scenario: GPT-5.6 Sol 在 Fast 之外额外声明 ultrafast service tier。
  453. func TestBuildCodexModelsManifestAdvertisesUltrafastServiceTierForSol(t *testing.T) {
  454. t.Parallel()
  455. body, err := BuildCodexModelsManifest([]string{
  456. "gpt-5.6-sol",
  457. "gpt-5.6",
  458. })
  459. require.NoError(t, err)
  460. models := decodeCodexManifestModels(t, body)
  461. require.Len(t, models, 2)
  462. wantTiers := []any{
  463. map[string]any{
  464. "id": "priority",
  465. "name": "Fast",
  466. "description": "Priority processing for lower latency.",
  467. },
  468. map[string]any{
  469. "id": "ultrafast",
  470. "name": "Ultrafast",
  471. "description": "Ultra-low latency processing.",
  472. },
  473. }
  474. for _, model := range models {
  475. require.Equal(t, wantTiers, model["service_tiers"])
  476. require.Nil(t, model["default_service_tier"])
  477. }
  478. }
  479. // Scenario: 未明确支持 Fast 的型号不推测 service tier。
  480. func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) {
  481. t.Parallel()
  482. body, err := BuildCodexModelsManifest([]string{
  483. "gpt-4o",
  484. "claude-opus-4-6",
  485. "deepseek-v4-pro",
  486. "company-coding-model",
  487. })
  488. require.NoError(t, err)
  489. models := decodeCodexManifestModels(t, body)
  490. require.Len(t, models, 4)
  491. for _, model := range models {
  492. require.Equal(t, []any{}, model["service_tiers"])
  493. require.Nil(t, model["default_service_tier"])
  494. }
  495. }
  496. // Scenario: 专用图片生成模型不进入 Codex 主模型目录。
  497. func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) {
  498. t.Parallel()
  499. body, err := BuildCodexModelsManifest([]string{
  500. "grok-4.6",
  501. "gpt-image-1",
  502. "gpt-image-1.5",
  503. "gpt-image-2",
  504. "openai/gpt-image-2",
  505. "gemini-2.5-flash-image",
  506. "gemini-3.1-flash-image-preview",
  507. "gemini-3-pro-image",
  508. "google/gemini-3-pro-image",
  509. "google/models/gemini-2.5-flash-image-preview",
  510. "grok-imagine-image",
  511. "grok-imagine-video",
  512. "xai/grok-imagine-image-quality",
  513. "grok-4.5",
  514. })
  515. require.NoError(t, err)
  516. models := decodeCodexManifestModels(t, body)
  517. slugs := make([]string, 0, len(models))
  518. for _, model := range models {
  519. slug, _ := model["slug"].(string)
  520. slugs = append(slugs, slug)
  521. }
  522. require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs)
  523. }
  524. func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) {
  525. t.Parallel()
  526. const groupID int64 = 701
  527. svc := &GatewayService{
  528. accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  529. groupID: {{
  530. ID: 1,
  531. Platform: PlatformGrok,
  532. Type: AccountTypeOAuth,
  533. Credentials: map[string]any{
  534. "access_token": "token",
  535. },
  536. }},
  537. }},
  538. }
  539. body, err := svc.BuildCodexModelsManifestForGroup(
  540. context.Background(),
  541. &Group{ID: groupID, Platform: PlatformComposite},
  542. "",
  543. []string{"grok-4.5"},
  544. )
  545. require.NoError(t, err)
  546. models := decodeCodexManifestModels(t, body)
  547. require.Len(t, models, 1)
  548. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  549. require.Equal(t, "Grok 4.5", models[0]["display_name"])
  550. require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
  551. }
  552. func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) {
  553. t.Parallel()
  554. const groupID int64 = 702
  555. svc := &GatewayService{
  556. accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  557. groupID: {{
  558. ID: 2,
  559. Platform: PlatformOpenAI,
  560. Type: AccountTypeOAuth,
  561. }},
  562. }},
  563. }
  564. body, err := svc.BuildCodexModelsManifestForGroup(
  565. context.Background(),
  566. &Group{ID: groupID, Platform: PlatformComposite},
  567. "",
  568. []string{"gpt-5.6-sol"},
  569. )
  570. require.NoError(t, err)
  571. models := decodeCodexManifestModels(t, body)
  572. require.Len(t, models, 1)
  573. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  574. }
  575. func TestBuildCodexModelsManifestForGroupUsesProviderImageCapabilities(t *testing.T) {
  576. t.Parallel()
  577. tests := []struct {
  578. name string
  579. model string
  580. accounts []Account
  581. modalities []any
  582. }{
  583. {
  584. name: "official Grok 4.6",
  585. model: "grok-4.6",
  586. accounts: []Account{{
  587. ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth,
  588. }},
  589. modalities: []any{"text", "image"},
  590. },
  591. {
  592. name: "official Grok Build vision host",
  593. model: "grok-build-0.1",
  594. accounts: []Account{{
  595. ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth,
  596. }},
  597. modalities: []any{"text", "image"},
  598. },
  599. {
  600. name: "official Grok 4.20 vision model",
  601. model: "grok-4.20-0309-reasoning",
  602. accounts: []Account{{
  603. ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth,
  604. }},
  605. modalities: []any{"text", "image"},
  606. },
  607. {
  608. name: "Grok 3 Mini is text only",
  609. model: "grok-3-mini",
  610. accounts: []Account{{
  611. ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth,
  612. }},
  613. modalities: []any{"text"},
  614. },
  615. {
  616. name: "Grok Composer has only Chat image bridge",
  617. model: "grok-composer-2.5-fast",
  618. accounts: []Account{{
  619. ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth,
  620. }},
  621. modalities: []any{"text"},
  622. },
  623. {
  624. name: "custom Grok host",
  625. model: "grok-4.5",
  626. accounts: []Account{{
  627. ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey,
  628. Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
  629. }},
  630. modalities: []any{"text"},
  631. },
  632. {
  633. name: "malformed Grok host",
  634. model: "grok-4.5",
  635. accounts: []Account{{
  636. ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey,
  637. Credentials: map[string]any{"base_url": "::invalid::url"},
  638. }},
  639. modalities: []any{"text"},
  640. },
  641. {
  642. name: "mixed official and custom Grok candidates",
  643. model: "grok-4.5",
  644. accounts: []Account{
  645. {ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth},
  646. {
  647. ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey,
  648. Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
  649. },
  650. },
  651. modalities: []any{"text"},
  652. },
  653. {
  654. name: "DeepSeek V4",
  655. model: "deepseek-v4-pro",
  656. accounts: []Account{{
  657. ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
  658. }},
  659. modalities: []any{"text"},
  660. },
  661. {
  662. name: "official OpenAI API key",
  663. model: "gpt-5.6-sol",
  664. accounts: []Account{{
  665. ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  666. }},
  667. modalities: []any{"text", "image"},
  668. },
  669. {
  670. name: "official OpenAI legacy text model",
  671. model: "gpt-3.5-turbo",
  672. accounts: []Account{{
  673. ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  674. }},
  675. modalities: []any{"text"},
  676. },
  677. {
  678. name: "custom OpenAI-compatible host",
  679. model: "gpt-5.6-sol",
  680. accounts: []Account{{
  681. ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  682. Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
  683. }},
  684. modalities: []any{"text", "image"},
  685. },
  686. {
  687. name: "custom OpenAI-compatible host with unknown model",
  688. model: "company-coding-model",
  689. accounts: []Account{{
  690. ID: 29, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  691. Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
  692. }},
  693. modalities: []any{"text"},
  694. },
  695. }
  696. for i, tt := range tests {
  697. tt := tt
  698. t.Run(tt.name, func(t *testing.T) {
  699. t.Parallel()
  700. groupID := int64(710 + i)
  701. svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  702. groupID: tt.accounts,
  703. }}}
  704. body, err := svc.BuildCodexModelsManifestForGroup(
  705. context.Background(),
  706. &Group{ID: groupID, Platform: PlatformComposite},
  707. "",
  708. []string{tt.model},
  709. )
  710. require.NoError(t, err)
  711. models := decodeCodexManifestModels(t, body)
  712. require.Len(t, models, 1)
  713. require.Equal(t, tt.modalities, models[0]["input_modalities"])
  714. })
  715. }
  716. }
  717. func TestBuildCodexModelsManifestForGroupPrefersSyncedOpenAIImageCapabilities(t *testing.T) {
  718. t.Parallel()
  719. newAccount := func(id int64, modalities []string) Account {
  720. account := Account{
  721. ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
  722. Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
  723. }
  724. if modalities != nil {
  725. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  726. "gpt-5.6-sol": {ID: "gpt-5.6-sol", InputModalities: modalities},
  727. }})
  728. }
  729. return account
  730. }
  731. tests := []struct {
  732. name string
  733. accounts []Account
  734. modalities []any
  735. }{
  736. {
  737. name: "explicit text-only snapshot narrows local fallback",
  738. accounts: []Account{
  739. newAccount(30, nil),
  740. newAccount(31, []string{"text"}),
  741. },
  742. modalities: []any{"text"},
  743. },
  744. {
  745. name: "explicit multimodal snapshot preserves local fallback",
  746. accounts: []Account{
  747. newAccount(32, nil),
  748. newAccount(33, []string{"text", "image"}),
  749. },
  750. modalities: []any{"text", "image"},
  751. },
  752. }
  753. for i, tt := range tests {
  754. tt := tt
  755. t.Run(tt.name, func(t *testing.T) {
  756. t.Parallel()
  757. groupID := int64(760 + i)
  758. svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  759. groupID: tt.accounts,
  760. }}}
  761. body, err := svc.BuildCodexModelsManifestForGroup(
  762. context.Background(),
  763. &Group{ID: groupID, Platform: PlatformOpenAI},
  764. "",
  765. []string{"gpt-5.6-sol"},
  766. )
  767. require.NoError(t, err)
  768. models := decodeCodexManifestModels(t, body)
  769. require.Len(t, models, 1)
  770. require.Equal(t, tt.modalities, models[0]["input_modalities"])
  771. })
  772. }
  773. }
  774. func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) {
  775. t.Parallel()
  776. const groupID int64 = 730
  777. routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{
  778. ID: 1,
  779. GroupID: groupID,
  780. PublicModel: "vision-alias",
  781. MatchType: CompositeRouteMatchExact,
  782. TargetPlatform: PlatformGrok,
  783. UpstreamModel: "grok-4.5",
  784. Endpoint: CompositeRouteEndpointResponses,
  785. Enabled: true,
  786. }}}
  787. svc := &GatewayService{
  788. accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  789. groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}},
  790. }},
  791. compositeResolver: NewCompositeRouteResolver(routeRepo),
  792. }
  793. body, err := svc.BuildCodexModelsManifestForGroup(
  794. context.Background(),
  795. &Group{ID: groupID, Platform: PlatformComposite},
  796. "",
  797. []string{"vision-alias"},
  798. )
  799. require.NoError(t, err)
  800. models := decodeCodexManifestModels(t, body)
  801. require.Len(t, models, 1)
  802. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  803. }
  804. func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) {
  805. t.Parallel()
  806. const groupID int64 = 731
  807. svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  808. groupID: {{
  809. ID: 21,
  810. Platform: PlatformGrok,
  811. Type: AccountTypeOAuth,
  812. Credentials: map[string]any{
  813. "model_mapping": map[string]any{"vision-alias": "grok-4.5"},
  814. },
  815. }},
  816. }}}
  817. body, err := svc.BuildCodexModelsManifestForGroup(
  818. context.Background(),
  819. &Group{ID: groupID, Platform: PlatformComposite},
  820. "",
  821. []string{"vision-alias"},
  822. )
  823. require.NoError(t, err)
  824. models := decodeCodexManifestModels(t, body)
  825. require.Len(t, models, 1)
  826. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  827. }
  828. // Scenario: a Composite exact alias inherits metadata from its unique mapped target model.
  829. func TestBuildCodexModelsManifestForGroupUsesMappedTargetMetadataForCompositeAlias(t *testing.T) {
  830. t.Parallel()
  831. const groupID int64 = 733
  832. svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  833. groupID: {{
  834. ID: 23,
  835. Platform: PlatformAnthropic,
  836. Type: AccountTypeAPIKey,
  837. Credentials: map[string]any{
  838. "model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
  839. },
  840. }},
  841. }}}
  842. body, err := svc.BuildCodexModelsManifestForGroup(
  843. context.Background(),
  844. &Group{ID: groupID, Platform: PlatformComposite},
  845. "",
  846. []string{"reasoning-alias"},
  847. )
  848. require.NoError(t, err)
  849. models := decodeCodexManifestModels(t, body)
  850. require.Len(t, models, 1)
  851. require.Equal(t, "reasoning-alias", models[0]["slug"])
  852. require.Equal(t, "reasoning-alias", models[0]["display_name"])
  853. require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
  854. require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromManifestModel(t, models[0]))
  855. }
  856. // Scenario: conflicting targets on the same platform keep the public alias but do not guess capabilities.
  857. func TestBuildCodexModelsManifestForGroupUsesSafeFallbackForConflictingAliasTargets(t *testing.T) {
  858. t.Parallel()
  859. const groupID int64 = 734
  860. svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  861. groupID: {
  862. {
  863. ID: 24,
  864. Platform: PlatformAnthropic,
  865. Type: AccountTypeAPIKey,
  866. Credentials: map[string]any{
  867. "model_mapping": map[string]any{"shared-alias": "claude-opus-4-8"},
  868. },
  869. },
  870. {
  871. ID: 25,
  872. Platform: PlatformAnthropic,
  873. Type: AccountTypeAPIKey,
  874. Credentials: map[string]any{
  875. "model_mapping": map[string]any{"shared-alias": "claude-haiku-4-5-20251001"},
  876. },
  877. },
  878. },
  879. }}}
  880. body, err := svc.BuildCodexModelsManifestForGroup(
  881. context.Background(),
  882. &Group{ID: groupID, Platform: PlatformComposite},
  883. "",
  884. []string{"shared-alias"},
  885. )
  886. require.NoError(t, err)
  887. models := decodeCodexManifestModels(t, body)
  888. require.Len(t, models, 1)
  889. require.Equal(t, "shared-alias", models[0]["slug"])
  890. require.Equal(t, "shared-alias", models[0]["display_name"])
  891. require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
  892. require.Empty(t, effortsFromManifestModel(t, models[0]))
  893. }
  894. // Scenario: a media-only target remains hidden even when exposed through an ordinary alias.
  895. func TestBuildCodexModelsManifestForGroupOmitsDedicatedMediaTargetAlias(t *testing.T) {
  896. t.Parallel()
  897. const groupID int64 = 735
  898. svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
  899. groupID: {{
  900. ID: 26,
  901. Platform: PlatformOpenAI,
  902. Type: AccountTypeAPIKey,
  903. Credentials: map[string]any{
  904. "model_mapping": map[string]any{"creative-alias": "gpt-image-2"},
  905. },
  906. }},
  907. }}}
  908. body, err := svc.BuildCodexModelsManifestForGroup(
  909. context.Background(),
  910. &Group{ID: groupID, Platform: PlatformComposite},
  911. "",
  912. []string{"creative-alias"},
  913. )
  914. require.NoError(t, err)
  915. require.Empty(t, decodeCodexManifestModels(t, body))
  916. }
  917. func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
  918. t.Parallel()
  919. const groupID int64 = 732
  920. repo := &countingCodexModelsAccountRepo{accounts: []Account{{
  921. ID: 22,
  922. Platform: PlatformGrok,
  923. Type: AccountTypeOAuth,
  924. Credentials: map[string]any{
  925. "model_mapping": map[string]any{
  926. "vision-alias-a": "grok-4.5",
  927. "vision-alias-b": "grok-4.6",
  928. },
  929. },
  930. }}}
  931. svc := &GatewayService{accountRepo: repo}
  932. _, err := svc.BuildCodexModelsManifestForGroup(
  933. context.Background(),
  934. &Group{ID: groupID, Platform: PlatformComposite},
  935. "",
  936. []string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"},
  937. )
  938. require.NoError(t, err)
  939. require.Equal(t, int32(1), repo.calls.Load())
  940. require.NotNil(t, repo.groupID)
  941. require.Equal(t, groupID, *repo.groupID)
  942. require.False(t, repo.includeGrouped)
  943. require.Contains(t, repo.platforms, PlatformOpenAI)
  944. require.Contains(t, repo.platforms, PlatformGrok)
  945. require.Contains(t, repo.platforms, PlatformDeepseek)
  946. require.Contains(t, repo.platforms, PlatformMiniMax)
  947. require.NotContains(t, repo.platforms, PlatformComposite)
  948. }
  949. func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) {
  950. t.Parallel()
  951. repo := &countingCodexModelsAccountRepo{}
  952. svc := &GatewayService{accountRepo: repo}
  953. body, err := svc.BuildCodexModelsManifestForGroup(
  954. context.Background(),
  955. &Group{ID: 733, Platform: PlatformDeepseek},
  956. "",
  957. []string{"deepseek-v4-pro"},
  958. )
  959. require.NoError(t, err)
  960. require.Equal(t, int32(1), repo.calls.Load())
  961. models := decodeCodexManifestModels(t, body)
  962. require.Len(t, models, 1)
  963. require.Equal(t, []any{"text"}, models[0]["input_modalities"])
  964. }
  965. func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) {
  966. t.Parallel()
  967. repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")}
  968. svc := &GatewayService{accountRepo: repo}
  969. body, err := svc.BuildCodexModelsManifestForGroup(
  970. context.Background(),
  971. &Group{ID: 734, Platform: PlatformComposite},
  972. "",
  973. []string{"gpt-5.6-sol", "grok-4.5"},
  974. )
  975. require.NoError(t, err)
  976. require.Equal(t, int32(1), repo.calls.Load())
  977. models := decodeCodexManifestModels(t, body)
  978. require.Len(t, models, 2)
  979. require.Equal(t, []any{"text"}, models[0]["input_modalities"])
  980. require.Equal(t, []any{"text"}, models[1]["input_modalities"])
  981. }
  982. func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) {
  983. t.Parallel()
  984. const groupID int64 = 71
  985. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
  986. byGroup: map[int64][]Account{
  987. groupID: {
  988. {
  989. Platform: PlatformOpenAI,
  990. Credentials: map[string]any{
  991. "model_mapping": map[string]any{
  992. "deepseek-4-pro": "deepseek-v4-pro",
  993. },
  994. },
  995. },
  996. },
  997. 72: {
  998. {
  999. Platform: PlatformOpenAI,
  1000. Credentials: map[string]any{
  1001. "model_mapping": map[string]any{"other-group-model": "upstream-model"},
  1002. },
  1003. },
  1004. },
  1005. },
  1006. }}
  1007. manifest := &OpenAIModelsResponse{
  1008. Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`),
  1009. }
  1010. err := svc.MergeGroupConfiguredCodexModels(
  1011. context.Background(),
  1012. &Group{ID: groupID, Platform: PlatformOpenAI},
  1013. manifest,
  1014. "",
  1015. )
  1016. require.NoError(t, err)
  1017. models := decodeCodexManifestModels(t, manifest.Body)
  1018. require.Len(t, models, 2)
  1019. require.Equal(t, "gpt-5.6", models[0]["slug"])
  1020. require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
  1021. requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro")
  1022. require.EqualValues(t, 1_000_000, models[1]["context_window"])
  1023. require.EqualValues(t, 1_000_000, models[1]["max_context_window"])
  1024. require.Equal(t, "high", models[1]["default_reasoning_level"])
  1025. require.Len(t, models[1]["supported_reasoning_levels"], 3)
  1026. require.NotContains(t, string(manifest.Body), "other-group-model")
  1027. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  1028. }
  1029. // Mixed groups retain configured metadata alongside defaults for unmapped accounts.
  1030. func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) {
  1031. t.Parallel()
  1032. const groupID int64 = 77
  1033. reasoning := true
  1034. arkAccount := Account{
  1035. Platform: PlatformOpenAI,
  1036. Type: AccountTypeAPIKey,
  1037. Credentials: map[string]any{
  1038. "model_mapping": map[string]any{
  1039. "glm-5.3": "glm-5.3",
  1040. "gpt-image-2": "gpt-image-2",
  1041. },
  1042. },
  1043. }
  1044. arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  1045. "glm-5.3": {
  1046. ID: "glm-5.3",
  1047. DisplayName: "GLM 5.3",
  1048. Description: "Ark coding model",
  1049. Reasoning: &reasoning,
  1050. DefaultReasoningLevel: "medium",
  1051. SupportedReasoningLevels: []string{"low", "medium", "high"},
  1052. InputModalities: []string{"text"},
  1053. ContextWindow: 1_000_000,
  1054. },
  1055. }})
  1056. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
  1057. byGroup: map[int64][]Account{
  1058. groupID: {
  1059. {
  1060. Platform: PlatformOpenAI,
  1061. Type: AccountTypeOAuth,
  1062. },
  1063. arkAccount,
  1064. },
  1065. },
  1066. }}
  1067. group := &Group{ID: groupID, Platform: PlatformOpenAI}
  1068. manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
  1069. require.NoError(t, err)
  1070. require.True(t, configured)
  1071. models := decodeCodexManifestModels(t, manifest.Body)
  1072. require.Equal(t, "glm-5.3", models[0]["slug"])
  1073. require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol")
  1074. require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2")
  1075. require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review")
  1076. require.Equal(t, "GLM 5.3", models[0]["display_name"])
  1077. require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
  1078. require.Equal(t, "medium", models[0]["default_reasoning_level"])
  1079. require.Equal(t, []any{"text"}, models[0]["input_modalities"])
  1080. require.EqualValues(t, 1_000_000, models[0]["context_window"])
  1081. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  1082. notModified, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
  1083. context.Background(),
  1084. group,
  1085. "W/"+manifest.ETag,
  1086. )
  1087. require.NoError(t, err)
  1088. require.True(t, configured)
  1089. require.True(t, notModified.NotModified)
  1090. require.Empty(t, notModified.Body)
  1091. require.Equal(t, manifest.ETag, notModified.ETag)
  1092. }
  1093. // Scenario: OpenAI 通配映射展开组内精确选择,但不发布通配符 slug。
  1094. func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWildcardMapping(t *testing.T) {
  1095. t.Parallel()
  1096. const groupID int64 = 80
  1097. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
  1098. byGroup: map[int64][]Account{
  1099. groupID: {{
  1100. Platform: PlatformOpenAI,
  1101. Type: AccountTypeAPIKey,
  1102. Credentials: map[string]any{
  1103. "model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"},
  1104. },
  1105. }},
  1106. },
  1107. }}
  1108. group := &Group{
  1109. ID: groupID,
  1110. Platform: PlatformOpenAI,
  1111. ModelAllowlist: GroupModelAllowlist{
  1112. Enabled: true,
  1113. Models: []string{"gpt-5.6"},
  1114. },
  1115. }
  1116. manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
  1117. require.NoError(t, err)
  1118. require.True(t, configured)
  1119. require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
  1120. require.NotContains(t, string(manifest.Body), "gpt-*")
  1121. }
  1122. // Scenario: OpenAI 配置目录对仅因瞬态状态退出当前调度池的账号取能力交集,
  1123. // 且不发布其独有模型。持久 schedulable 仍为 true。
  1124. func TestBuildGroupConfiguredCodexModelsManifestIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) {
  1125. t.Parallel()
  1126. const groupID int64 = 79
  1127. schedulable := newCodexCatalogMappedAccount(
  1128. 41,
  1129. "gpt-5.6-sol",
  1130. "GPT-5.6 Sol",
  1131. []string{"low", "medium", "high", "xhigh"},
  1132. []string{"text", "image"},
  1133. 1_000_000,
  1134. true,
  1135. nil,
  1136. )
  1137. transientlyUnschedulable := newCodexCatalogMappedAccount(
  1138. 42,
  1139. "glm-5.3",
  1140. "GLM 5.3",
  1141. []string{"low", "medium", "high"},
  1142. []string{"text"},
  1143. 272_000,
  1144. true,
  1145. map[string]any{"exclusive-model": "exclusive-upstream"},
  1146. )
  1147. svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
  1148. schedulable: map[int64][]Account{groupID: {schedulable}},
  1149. catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}},
  1150. }}
  1151. manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
  1152. context.Background(),
  1153. &Group{ID: groupID, Platform: PlatformOpenAI},
  1154. "",
  1155. )
  1156. require.NoError(t, err)
  1157. require.True(t, configured)
  1158. models := decodeCodexManifestModels(t, manifest.Body)
  1159. require.Len(t, models, 1)
  1160. require.Equal(t, "my-coder", models[0]["slug"])
  1161. require.Equal(t, "my-coder", models[0]["display_name"])
  1162. require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
  1163. require.Equal(t, []any{"text"}, models[0]["input_modalities"])
  1164. require.EqualValues(t, 272_000, models[0]["context_window"])
  1165. }
  1166. // Scenario: 管理员持久禁用的账号不能继续收窄 Codex 能力目录。
  1167. func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) {
  1168. t.Parallel()
  1169. const groupID int64 = 80
  1170. enabled := newCodexCatalogMappedAccount(
  1171. 51,
  1172. "gpt-5.6-sol",
  1173. "GPT-5.6 Sol",
  1174. []string{"low", "medium", "high", "xhigh"},
  1175. []string{"text", "image"},
  1176. 1_000_000,
  1177. true,
  1178. nil,
  1179. )
  1180. disabled := newCodexCatalogMappedAccount(
  1181. 52,
  1182. "glm-5.3",
  1183. "GLM 5.3",
  1184. []string{"low", "medium", "high"},
  1185. []string{"text"},
  1186. 272_000,
  1187. false,
  1188. nil,
  1189. )
  1190. svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
  1191. schedulable: map[int64][]Account{groupID: {enabled}},
  1192. catalog: map[int64][]Account{groupID: {enabled}},
  1193. all: map[int64][]Account{groupID: {enabled, disabled}},
  1194. }}
  1195. manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
  1196. context.Background(),
  1197. &Group{ID: groupID, Platform: PlatformOpenAI},
  1198. "",
  1199. )
  1200. require.NoError(t, err)
  1201. require.True(t, configured)
  1202. models := decodeCodexManifestModels(t, manifest.Body)
  1203. require.Len(t, models, 1)
  1204. require.Equal(t, "my-coder", models[0]["slug"])
  1205. require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
  1206. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  1207. require.EqualValues(t, 1_000_000, models[0]["context_window"])
  1208. }
  1209. // Scenario: 没有管理员模型配置时保留现有上游发现路径。
  1210. func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) {
  1211. t.Parallel()
  1212. const groupID int64 = 78
  1213. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
  1214. byGroup: map[int64][]Account{
  1215. groupID: {{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}},
  1216. },
  1217. }}
  1218. manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
  1219. context.Background(),
  1220. &Group{ID: groupID, Platform: PlatformOpenAI},
  1221. "",
  1222. )
  1223. require.NoError(t, err)
  1224. require.False(t, configured)
  1225. require.Nil(t, manifest)
  1226. }
  1227. func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) {
  1228. t.Parallel()
  1229. const groupID int64 = 74
  1230. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
  1231. manifest := &OpenAIModelsResponse{
  1232. Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"codex-auto-future","visibility":"list"},{"slug":"gpt-image-2","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
  1233. }
  1234. require.NoError(t, svc.MergeGroupConfiguredCodexModels(
  1235. context.Background(),
  1236. &Group{ID: groupID, Platform: PlatformOpenAI},
  1237. manifest,
  1238. "",
  1239. ))
  1240. models := decodeCodexManifestModels(t, manifest.Body)
  1241. require.Len(t, models, 1)
  1242. require.Equal(t, "gpt-5.6", models[0]["slug"])
  1243. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  1244. }
  1245. // Scenario: OpenAI 账号映射不启用 Auto Review。
  1246. func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) {
  1247. t.Parallel()
  1248. const groupID int64 = 75
  1249. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
  1250. byGroup: map[int64][]Account{
  1251. groupID: {
  1252. {
  1253. Platform: PlatformOpenAI,
  1254. Credentials: map[string]any{
  1255. "model_mapping": map[string]any{
  1256. openai.CodexUsageProbeModel: openai.CodexUsageProbeModel,
  1257. },
  1258. },
  1259. },
  1260. },
  1261. },
  1262. }}
  1263. manifest := &OpenAIModelsResponse{
  1264. Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`),
  1265. }
  1266. require.NoError(t, svc.MergeGroupConfiguredCodexModels(
  1267. context.Background(),
  1268. &Group{ID: groupID, Platform: PlatformOpenAI},
  1269. manifest,
  1270. "",
  1271. ))
  1272. require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
  1273. }
  1274. // Scenario: 启用的分组自定义列表允许 Auto Review。
  1275. func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) {
  1276. t.Parallel()
  1277. const groupID int64 = 76
  1278. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
  1279. manifest := &OpenAIModelsResponse{
  1280. Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
  1281. }
  1282. group := &Group{
  1283. ID: groupID,
  1284. Platform: PlatformOpenAI,
  1285. ModelAllowlist: GroupModelAllowlist{
  1286. Enabled: true,
  1287. Models: []string{openai.CodexUsageProbeModel},
  1288. },
  1289. }
  1290. require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
  1291. require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body))
  1292. }
  1293. func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) {
  1294. t.Parallel()
  1295. const groupID int64 = 73
  1296. svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
  1297. byGroup: map[int64][]Account{
  1298. groupID: {
  1299. {
  1300. Platform: PlatformOpenAI,
  1301. Credentials: map[string]any{
  1302. "model_mapping": map[string]any{
  1303. "deepseek-4-pro": "deepseek-v4-pro",
  1304. "hidden-alias": "hidden-upstream",
  1305. },
  1306. },
  1307. },
  1308. },
  1309. },
  1310. }}
  1311. group := &Group{
  1312. ID: groupID,
  1313. Platform: PlatformOpenAI,
  1314. ModelAllowlist: GroupModelAllowlist{
  1315. Enabled: true,
  1316. Models: []string{"deepseek-4-pro"},
  1317. },
  1318. }
  1319. upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`)
  1320. manifest := &OpenAIModelsResponse{Body: upstreamBody}
  1321. require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
  1322. models := decodeCodexManifestModels(t, manifest.Body)
  1323. require.Len(t, models, 1)
  1324. requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro")
  1325. finalETag := manifest.ETag
  1326. second := &OpenAIModelsResponse{Body: upstreamBody}
  1327. require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag))
  1328. require.True(t, second.NotModified)
  1329. require.Empty(t, second.Body)
  1330. require.Equal(t, finalETag, second.ETag)
  1331. }
  1332. type codexModelsBlockingBody struct {
  1333. ctx context.Context
  1334. readStarted chan struct{}
  1335. startedOnce *sync.Once
  1336. release <-chan struct{}
  1337. body *strings.Reader
  1338. }
  1339. func (b *codexModelsBlockingBody) Read(p []byte) (int, error) {
  1340. b.startedOnce.Do(func() { close(b.readStarted) })
  1341. select {
  1342. case <-b.release:
  1343. return b.body.Read(p)
  1344. case <-b.ctx.Done():
  1345. return 0, b.ctx.Err()
  1346. }
  1347. }
  1348. func (b *codexModelsBlockingBody) Close() error { return nil }
  1349. func (s *codexModelsHTTPUpstreamStub) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  1350. return s.do(req, proxyURL, accountID, accountConcurrency)
  1351. }
  1352. func (s *codexModelsHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  1353. return s.Do(req, proxyURL, accountID, accountConcurrency)
  1354. }
  1355. func TestIsRetryableCodexModelsManifestTransportError(t *testing.T) {
  1356. tests := []struct {
  1357. name string
  1358. err error
  1359. retryable bool
  1360. }{
  1361. {name: "nil", err: nil},
  1362. {name: "configuration error", err: errors.New("invalid proxy URL")},
  1363. {name: "upstream configuration error", err: errors.New("upstream error: invalid proxy")},
  1364. {name: "proxy connection configuration error", err: errors.New("proxy connection error: invalid configuration")},
  1365. {name: "canceled request", err: context.Canceled},
  1366. {
  1367. name: "redirect policy error",
  1368. err: &url.Error{
  1369. Op: "Get",
  1370. URL: "https://upstream.example/v1/models",
  1371. Err: errors.New("stopped after 10 redirects"),
  1372. },
  1373. },
  1374. {name: "deadline exceeded", err: context.DeadlineExceeded, retryable: true},
  1375. {name: "unexpected EOF", err: io.ErrUnexpectedEOF, retryable: true},
  1376. {name: "closed connection", err: net.ErrClosed, retryable: true},
  1377. {
  1378. name: "network operation",
  1379. err: &net.OpError{
  1380. Op: "read",
  1381. Net: "tcp",
  1382. Err: errors.New("connection reset"),
  1383. },
  1384. retryable: true,
  1385. },
  1386. {
  1387. name: "DNS error",
  1388. err: &net.DNSError{Err: "temporary failure", Name: "upstream.example"},
  1389. retryable: true,
  1390. },
  1391. {
  1392. name: "typed HTTP2 GOAWAY",
  1393. err: http2.GoAwayError{ErrCode: http2.ErrCodeNo},
  1394. retryable: true,
  1395. },
  1396. {
  1397. name: "stdlib HTTP2 GOAWAY",
  1398. err: errors.New("http2: server sent GOAWAY and closed the connection; LastStreamID=1, ErrCode=NO_ERROR"),
  1399. retryable: true,
  1400. },
  1401. {
  1402. name: "stdlib HTTP2 refused stream",
  1403. err: errors.New("stream error: stream ID 3; REFUSED_STREAM"),
  1404. retryable: true,
  1405. },
  1406. {
  1407. name: "stdlib HTTP2 connection error",
  1408. err: errors.New(`Get "https://upstream.example/v1/models": connection error: PROTOCOL_ERROR`),
  1409. retryable: true,
  1410. },
  1411. }
  1412. for _, tt := range tests {
  1413. t.Run(tt.name, func(t *testing.T) {
  1414. if got := isRetryableCodexModelsManifestTransportError(tt.err); got != tt.retryable {
  1415. t.Fatalf("retryable = %v, want %v", got, tt.retryable)
  1416. }
  1417. })
  1418. }
  1419. }
  1420. func TestIsRetryableCodexModelsManifestStatus(t *testing.T) {
  1421. t.Parallel()
  1422. tests := []struct {
  1423. name string
  1424. statusCode int
  1425. useAPIKeyUpstream bool
  1426. retryable bool
  1427. }{
  1428. {name: "api key 404", statusCode: http.StatusNotFound, useAPIKeyUpstream: true, retryable: true},
  1429. {name: "api key 405", statusCode: http.StatusMethodNotAllowed, useAPIKeyUpstream: true, retryable: true},
  1430. {name: "oauth 404", statusCode: http.StatusNotFound},
  1431. {name: "oauth 405", statusCode: http.StatusMethodNotAllowed},
  1432. {name: "api key 401", statusCode: http.StatusUnauthorized, useAPIKeyUpstream: true},
  1433. {name: "oauth 401", statusCode: http.StatusUnauthorized, retryable: true},
  1434. {name: "api key 400", statusCode: http.StatusBadRequest, useAPIKeyUpstream: true},
  1435. {name: "api key 403", statusCode: http.StatusForbidden, useAPIKeyUpstream: true},
  1436. {name: "rate limited", statusCode: http.StatusTooManyRequests, retryable: true},
  1437. {name: "server error", statusCode: http.StatusServiceUnavailable, retryable: true},
  1438. }
  1439. for _, tt := range tests {
  1440. t.Run(tt.name, func(t *testing.T) {
  1441. t.Parallel()
  1442. require.Equal(t, tt.retryable, isRetryableCodexModelsManifestStatus(tt.statusCode, tt.useAPIKeyUpstream))
  1443. })
  1444. }
  1445. }
  1446. func newCodexModelsAPIKeyTestService(upstream HTTPUpstream) *OpenAIGatewayService {
  1447. return &OpenAIGatewayService{
  1448. cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
  1449. Enabled: false,
  1450. }}},
  1451. httpUpstream: upstream,
  1452. }
  1453. }
  1454. func newCodexModelsAPIKeyTestAccount(baseURL string) *Account {
  1455. credentials := map[string]any{"api_key": "sk-upstream"}
  1456. if baseURL != "" {
  1457. credentials["base_url"] = baseURL
  1458. }
  1459. return &Account{
  1460. ID: 2,
  1461. Platform: PlatformOpenAI,
  1462. Type: AccountTypeAPIKey,
  1463. Credentials: credentials,
  1464. Concurrency: 3,
  1465. }
  1466. }
  1467. func newCodexModelsTestAccount() *Account {
  1468. return &Account{
  1469. ID: 1,
  1470. Platform: PlatformOpenAI,
  1471. Type: AccountTypeOAuth,
  1472. Credentials: map[string]any{
  1473. "access_token": "test-access-token",
  1474. "chatgpt_account_id": "acc-123",
  1475. },
  1476. }
  1477. }
  1478. func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
  1479. manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}`
  1480. var gotAuth, gotAccountID, gotOriginator, gotClientVersion string
  1481. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1482. gotAuth = r.Header.Get("Authorization")
  1483. gotAccountID = r.Header.Get("chatgpt-account-id")
  1484. gotOriginator = r.Header.Get("Originator")
  1485. gotClientVersion = r.URL.Query().Get("client_version")
  1486. w.Header().Set("ETag", `W/"abc123"`)
  1487. w.Header().Set("Content-Type", "application/json")
  1488. _, _ = w.Write([]byte(manifestBody))
  1489. }))
  1490. defer server.Close()
  1491. original := chatgptCodexModelsURL
  1492. chatgptCodexModelsURL = server.URL
  1493. defer func() { chatgptCodexModelsURL = original }()
  1494. s := &OpenAIGatewayService{}
  1495. manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", "")
  1496. if err != nil {
  1497. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  1498. }
  1499. if string(manifest.Body) != manifestBody {
  1500. t.Errorf("body not passed through verbatim: got %q", manifest.Body)
  1501. }
  1502. if manifest.ETag != `W/"abc123"` {
  1503. t.Errorf("etag not passed through: got %q", manifest.ETag)
  1504. }
  1505. if gotAuth != "Bearer test-access-token" {
  1506. t.Errorf("authorization header: got %q", gotAuth)
  1507. }
  1508. if gotAccountID != "acc-123" {
  1509. t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
  1510. }
  1511. if gotOriginator != openai.CodexDefaultOriginator {
  1512. t.Errorf("originator header: got %q", gotOriginator)
  1513. }
  1514. if gotClientVersion != "0.137.0" {
  1515. t.Errorf("client_version query: got %q", gotClientVersion)
  1516. }
  1517. }
  1518. func TestFetchCodexModelsManifestAgentIdentityUsesAssertionWithoutOAuthToken(t *testing.T) {
  1519. key, privateKey := newTestAgentIdentityKey(t)
  1520. account := &Account{
  1521. ID: 3,
  1522. Platform: PlatformOpenAI,
  1523. Type: AccountTypeOAuth,
  1524. Credentials: map[string]any{
  1525. "auth_mode": OpenAIAuthModeAgentIdentity,
  1526. "agent_runtime_id": key.runtimeID,
  1527. "agent_private_key": privateKey,
  1528. "task_id": key.taskID,
  1529. "chatgpt_account_id": "acc-agent",
  1530. },
  1531. }
  1532. var gotAuth, gotAccountID string
  1533. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1534. gotAuth = r.Header.Get("Authorization")
  1535. gotAccountID = r.Header.Get("chatgpt-account-id")
  1536. _, _ = w.Write([]byte(`{"models":[]}`))
  1537. }))
  1538. defer server.Close()
  1539. original := chatgptCodexModelsURL
  1540. chatgptCodexModelsURL = server.URL
  1541. defer func() { chatgptCodexModelsURL = original }()
  1542. s := &OpenAIGatewayService{}
  1543. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  1544. if err != nil {
  1545. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  1546. }
  1547. if string(manifest.Body) != `{"models":[]}` {
  1548. t.Fatalf("unexpected manifest body: %q", manifest.Body)
  1549. }
  1550. if !strings.HasPrefix(gotAuth, "AgentAssertion ") {
  1551. t.Fatalf("authorization scheme: got %q", strings.SplitN(gotAuth, " ", 2)[0])
  1552. }
  1553. if gotAccountID != "acc-agent" {
  1554. t.Fatalf("chatgpt-account-id header: got %q", gotAccountID)
  1555. }
  1556. }
  1557. func TestFetchCodexModelsManifestAgentIdentityRecoversInvalidTaskOnce(t *testing.T) {
  1558. key, privateKey := newTestAgentIdentityKey(t)
  1559. account := &Account{
  1560. ID: 4,
  1561. Platform: PlatformOpenAI,
  1562. Type: AccountTypeOAuth,
  1563. Credentials: map[string]any{
  1564. "auth_mode": OpenAIAuthModeAgentIdentity,
  1565. "agent_runtime_id": key.runtimeID,
  1566. "agent_private_key": privateKey,
  1567. "task_id": "task-models-old",
  1568. "chatgpt_account_id": "acc-agent-recovery",
  1569. },
  1570. }
  1571. repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}}
  1572. modelsCalls := 0
  1573. registerCalls := 0
  1574. var assertions []string
  1575. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1576. w.Header().Set("content-type", "application/json")
  1577. if strings.Contains(r.URL.Path, "/task/register") {
  1578. registerCalls++
  1579. _, _ = w.Write([]byte(`{"task_id":"task-models-new"}`))
  1580. return
  1581. }
  1582. modelsCalls++
  1583. assertions = append(assertions, r.Header.Get("Authorization"))
  1584. if modelsCalls == 1 {
  1585. w.WriteHeader(http.StatusUnauthorized)
  1586. _, _ = w.Write([]byte(`{"error":{"code":"invalid_task_id"}}`))
  1587. return
  1588. }
  1589. _, _ = w.Write([]byte(`{"models":[]}`))
  1590. }))
  1591. defer server.Close()
  1592. originalModelsURL := chatgptCodexModelsURL
  1593. chatgptCodexModelsURL = server.URL
  1594. t.Cleanup(func() { chatgptCodexModelsURL = originalModelsURL })
  1595. originalAuthBase := openAIAgentIdentityAuthAPIBaseURL
  1596. openAIAgentIdentityAuthAPIBaseURL = server.URL
  1597. t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = originalAuthBase })
  1598. s := &OpenAIGatewayService{accountRepo: repo}
  1599. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  1600. require.NoError(t, err)
  1601. require.Equal(t, `{"models":[]}`, string(manifest.Body))
  1602. require.Equal(t, 2, modelsCalls)
  1603. require.Equal(t, 1, registerCalls)
  1604. require.Len(t, assertions, 2)
  1605. require.Equal(t, "task-models-old", decodeAgentAssertionTask(t, assertions[0]))
  1606. require.Equal(t, "task-models-new", decodeAgentAssertionTask(t, assertions[1]))
  1607. }
  1608. func TestFetchCodexModelsManifestAgentIdentityRedactsUpstreamErrors(t *testing.T) {
  1609. key, privateKey := newTestAgentIdentityKey(t)
  1610. account := &Account{
  1611. ID: 5,
  1612. Platform: PlatformOpenAI,
  1613. Type: AccountTypeOAuth,
  1614. Credentials: map[string]any{
  1615. "auth_mode": OpenAIAuthModeAgentIdentity,
  1616. "agent_runtime_id": key.runtimeID,
  1617. "agent_private_key": privateKey,
  1618. "task_id": key.taskID,
  1619. "chatgpt_account_id": "acc-agent-redaction",
  1620. },
  1621. }
  1622. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  1623. w.WriteHeader(http.StatusBadRequest)
  1624. _, _ = fmt.Fprintf(w, `{"error":"%s %s %s AgentAssertion leaked"}`, key.runtimeID, key.taskID, privateKey)
  1625. }))
  1626. defer server.Close()
  1627. original := chatgptCodexModelsURL
  1628. chatgptCodexModelsURL = server.URL
  1629. t.Cleanup(func() { chatgptCodexModelsURL = original })
  1630. s := &OpenAIGatewayService{}
  1631. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  1632. require.Error(t, err)
  1633. require.NotContains(t, err.Error(), key.runtimeID)
  1634. require.NotContains(t, err.Error(), key.taskID)
  1635. require.NotContains(t, err.Error(), privateKey)
  1636. require.NotContains(t, err.Error(), "AgentAssertion leaked")
  1637. require.Contains(t, err.Error(), "[redacted]")
  1638. }
  1639. func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) {
  1640. var gotClientVersion string
  1641. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1642. gotClientVersion = r.URL.Query().Get("client_version")
  1643. _, _ = w.Write([]byte(`{"models":[]}`))
  1644. }))
  1645. defer server.Close()
  1646. original := chatgptCodexModelsURL
  1647. chatgptCodexModelsURL = server.URL
  1648. defer func() { chatgptCodexModelsURL = original }()
  1649. s := &OpenAIGatewayService{}
  1650. if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil {
  1651. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  1652. }
  1653. if gotClientVersion != CodexCanonicalClientVersion() {
  1654. t.Errorf("default client_version: got %q, want %q", gotClientVersion, CodexCanonicalClientVersion())
  1655. }
  1656. }
  1657. func TestFetchCodexModelsManifestNotModified(t *testing.T) {
  1658. manifestBody := `{"models":[{"slug":"gpt-5.5"}]}`
  1659. var calls atomic.Int32
  1660. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  1661. calls.Add(1)
  1662. w.Header().Set("ETag", `W/"abc123"`)
  1663. _, _ = w.Write([]byte(manifestBody))
  1664. }))
  1665. defer server.Close()
  1666. original := chatgptCodexModelsURL
  1667. chatgptCodexModelsURL = server.URL
  1668. defer func() { chatgptCodexModelsURL = original }()
  1669. s := &OpenAIGatewayService{}
  1670. account := newCodexModelsTestAccount()
  1671. first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  1672. if err != nil {
  1673. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  1674. }
  1675. if first.NotModified {
  1676. t.Fatal("cold fetch must return the manifest body")
  1677. }
  1678. if first.ETag != `W/"abc123"` {
  1679. t.Errorf("etag not passed through: got %q", first.ETag)
  1680. }
  1681. // OAuth 路径接入缓存后,客户端 If-None-Match 与缓存内容 ETag 比较,不再透传上游:
  1682. // 新鲜期内命中缓存且匹配直接 304,零上游请求。
  1683. second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", `W/"abc123"`)
  1684. if err != nil {
  1685. t.Fatalf("cached fetch returned error: %v", err)
  1686. }
  1687. if !second.NotModified {
  1688. t.Fatal("matching cached ETag must return NotModified")
  1689. }
  1690. if second.ETag != `W/"abc123"` {
  1691. t.Errorf("NotModified etag: got %q", second.ETag)
  1692. }
  1693. if got := calls.Load(); got != 1 {
  1694. t.Errorf("upstream calls: got %d, want 1", got)
  1695. }
  1696. }
  1697. func TestFetchCodexModelsManifestUpstreamError(t *testing.T) {
  1698. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  1699. http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError)
  1700. }))
  1701. defer server.Close()
  1702. original := chatgptCodexModelsURL
  1703. chatgptCodexModelsURL = server.URL
  1704. defer func() { chatgptCodexModelsURL = original }()
  1705. s := &OpenAIGatewayService{}
  1706. if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil {
  1707. t.Fatal("expected error for upstream 500, got nil")
  1708. }
  1709. }
  1710. func TestFetchCodexModelsManifestMissingToken(t *testing.T) {
  1711. account := newCodexModelsTestAccount()
  1712. delete(account.Credentials, "access_token")
  1713. s := &OpenAIGatewayService{}
  1714. if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil {
  1715. t.Fatal("expected error for missing access token, got nil")
  1716. }
  1717. }
  1718. func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
  1719. manifestBody := `{"models":[{"slug":"deepseek-v4-pro"}]}`
  1720. var gotRequest *http.Request
  1721. var gotProxyURL string
  1722. var gotAccountID int64
  1723. var gotConcurrency int
  1724. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
  1725. gotRequest = req
  1726. gotProxyURL = proxyURL
  1727. gotAccountID = accountID
  1728. gotConcurrency = accountConcurrency
  1729. header := make(http.Header)
  1730. header.Set("ETag", `W/"api-key-manifest"`)
  1731. return &http.Response{
  1732. StatusCode: http.StatusOK,
  1733. Header: header,
  1734. Body: io.NopCloser(strings.NewReader(manifestBody)),
  1735. }, nil
  1736. }}
  1737. s := newCodexModelsAPIKeyTestService(upstream)
  1738. manifest, err := s.FetchCodexModelsManifest(
  1739. context.Background(),
  1740. newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"),
  1741. "0.144.0",
  1742. "",
  1743. )
  1744. if err != nil {
  1745. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  1746. }
  1747. if gotRequest == nil {
  1748. t.Fatal("expected request to custom API key upstream")
  1749. }
  1750. if gotRequest.Method != http.MethodGet {
  1751. t.Errorf("method: got %q", gotRequest.Method)
  1752. }
  1753. if gotRequest.URL.String() != "https://upstream.example/v1/models?client_version=0.144.0" {
  1754. t.Errorf("request URL: got %q", gotRequest.URL.String())
  1755. }
  1756. if gotRequest.Header.Get("Authorization") != "Bearer sk-upstream" {
  1757. t.Errorf("authorization header: got %q", gotRequest.Header.Get("Authorization"))
  1758. }
  1759. if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator {
  1760. t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator"))
  1761. }
  1762. if gotRequest.Header.Get("Version") != "0.144.0" {
  1763. t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version"))
  1764. }
  1765. if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() {
  1766. t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent"))
  1767. }
  1768. if gotRequest.Header.Get("chatgpt-account-id") != "" {
  1769. t.Errorf("chatgpt-account-id must not be sent to API key upstream: got %q", gotRequest.Header.Get("chatgpt-account-id"))
  1770. }
  1771. if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 {
  1772. t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency)
  1773. }
  1774. models := decodeCodexManifestModels(t, manifest.Body)
  1775. require.Len(t, models, 1)
  1776. requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro")
  1777. require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"])
  1778. require.EqualValues(t, 1_000_000, models[0]["context_window"])
  1779. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  1780. require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag)
  1781. }
  1782. // Scenario: 完整上游清单没有 ETag 时,最终正文仍生成强 ETag 并支持 304。
  1783. func TestFetchCodexModelsManifestAPIKeyCompleteBodyWithoutUpstreamETagUsesFinalBodyETag(t *testing.T) {
  1784. completeBody, err := BuildCodexModelsManifest([]string{"custom-complete-model"})
  1785. require.NoError(t, err)
  1786. var calls atomic.Int32
  1787. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  1788. calls.Add(1)
  1789. return &http.Response{
  1790. StatusCode: http.StatusOK,
  1791. Header: make(http.Header),
  1792. Body: io.NopCloser(bytes.NewReader(completeBody)),
  1793. }, nil
  1794. }}
  1795. svc := newCodexModelsAPIKeyTestService(upstream)
  1796. svc.accountRepo = codexModelsVisibilityAccountRepo{}
  1797. account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
  1798. group := &Group{ID: 82, Platform: PlatformOpenAI}
  1799. first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
  1800. require.NoError(t, err)
  1801. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
  1802. require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, first, ""))
  1803. require.Equal(t, codexModelsManifestBodyETag(first.Body), first.ETag)
  1804. require.NotEmpty(t, first.ETag)
  1805. second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
  1806. require.NoError(t, err)
  1807. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
  1808. require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, first.ETag))
  1809. require.True(t, second.NotModified)
  1810. require.Empty(t, second.Body)
  1811. require.Equal(t, int32(1), calls.Load())
  1812. }
  1813. func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) {
  1814. upstreamBody := `{"object":"list","data":[{"id":"gpt-5.6","object":"model"},{"id":" ","object":"model"},{"id":"gpt-5.6-codex","object":"model"}]}`
  1815. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  1816. header := make(http.Header)
  1817. header.Set("ETag", `W/"openai-list"`)
  1818. return &http.Response{
  1819. StatusCode: http.StatusOK,
  1820. Header: header,
  1821. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  1822. }, nil
  1823. }}
  1824. s := newCodexModelsAPIKeyTestService(upstream)
  1825. manifest, err := s.FetchCodexModelsManifest(
  1826. context.Background(),
  1827. newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"),
  1828. "0.144.0",
  1829. "",
  1830. )
  1831. if err != nil {
  1832. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  1833. }
  1834. models := decodeCodexManifestModels(t, manifest.Body)
  1835. require.Len(t, models, 2)
  1836. requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6")
  1837. requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex")
  1838. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  1839. require.Equal(t, []any{"text", "image"}, models[1]["input_modalities"])
  1840. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  1841. require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
  1842. }
  1843. func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) {
  1844. upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}`
  1845. converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody))
  1846. models := decodeCodexManifestModels(t, converted)
  1847. require.Len(t, models, 1)
  1848. requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5")
  1849. require.Equal(t, "GPT-5.5", models[0]["display_name"])
  1850. require.Equal(t, "medium", models[0]["default_reasoning_level"])
  1851. require.Len(t, models[0]["supported_reasoning_levels"], 4)
  1852. }
  1853. func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) {
  1854. t.Parallel()
  1855. svc := &OpenAIGatewayService{}
  1856. manifest := &OpenAIModelsResponse{
  1857. Body: []byte(`{"models":[{"slug":"grok-4.6","description":"Provider supplied","service_tiers":[{"id":"provider-priority","name":"Provider Fast","description":"Provider supplied tier."}],"model_messages":{"auto_review":{"enabled":true}},"truncation_policy":{"mode":"tokens"},"unknown":{"kept":true}}],"metadata":{"source":"upstream"}}`),
  1858. }
  1859. account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
  1860. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
  1861. models := decodeCodexManifestModels(t, manifest.Body)
  1862. require.Len(t, models, 1)
  1863. requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6")
  1864. require.Equal(t, "Provider supplied", models[0]["description"])
  1865. require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
  1866. require.Equal(t, []any{
  1867. map[string]any{
  1868. "id": "provider-priority",
  1869. "name": "Provider Fast",
  1870. "description": "Provider supplied tier.",
  1871. },
  1872. }, models[0]["service_tiers"])
  1873. require.Equal(t, []any{"text"}, models[0]["input_modalities"])
  1874. require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
  1875. modelMessages, ok := models[0]["model_messages"].(map[string]any)
  1876. require.True(t, ok)
  1877. require.NotEmpty(t, modelMessages["instructions_template"])
  1878. require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"])
  1879. require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"])
  1880. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  1881. var envelope map[string]any
  1882. require.NoError(t, json.Unmarshal(manifest.Body, &envelope))
  1883. require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
  1884. }
  1885. func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t *testing.T) {
  1886. t.Parallel()
  1887. svc := &OpenAIGatewayService{}
  1888. manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[
  1889. {"slug":"gpt-5.6-sol"},
  1890. {"slug":"company-coding-model"},
  1891. {"slug":"gpt-4o","input_modalities":["text"]}
  1892. ]}`)}
  1893. account := newCodexModelsAPIKeyTestAccount("https://openai-compatible.example.test/v1")
  1894. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
  1895. models := decodeCodexManifestModels(t, manifest.Body)
  1896. require.Len(t, models, 3)
  1897. bySlug := make(map[string]map[string]any, len(models))
  1898. for _, model := range models {
  1899. slug, ok := model["slug"].(string)
  1900. require.True(t, ok)
  1901. bySlug[slug] = model
  1902. }
  1903. require.Equal(t, []any{"text", "image"}, bySlug["gpt-5.6-sol"]["input_modalities"])
  1904. require.Equal(t, []any{"text"}, bySlug["company-coding-model"]["input_modalities"])
  1905. require.Equal(t, []any{"text"}, bySlug["gpt-4o"]["input_modalities"])
  1906. }
  1907. // Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。
  1908. func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) {
  1909. t.Parallel()
  1910. reasoning := true
  1911. account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
  1912. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  1913. "future-reasoner": {
  1914. ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability",
  1915. Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
  1916. SupportedReasoningLevels: []string{"low", "high", "ultra"},
  1917. InputModalities: []string{"text", "image"},
  1918. ContextWindow: 999_000,
  1919. },
  1920. }})
  1921. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  1922. return &http.Response{
  1923. StatusCode: http.StatusOK,
  1924. Header: http.Header{"Content-Type": []string{"application/json"}},
  1925. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"future-reasoner","object":"model"}]}`)),
  1926. }, nil
  1927. }}
  1928. svc := newCodexModelsAPIKeyTestService(upstream)
  1929. manifest, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
  1930. require.NoError(t, err)
  1931. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
  1932. models := decodeCodexManifestModels(t, manifest.Body)
  1933. require.Len(t, models, 1)
  1934. require.Equal(t, "Future Reasoner", models[0]["display_name"])
  1935. require.Equal(t, "Synced upstream capability", models[0]["description"])
  1936. require.Equal(t, "ultra", models[0]["default_reasoning_level"])
  1937. require.Equal(t, []string{"low", "high", "ultra"}, effortsFromManifestModel(t, models[0]))
  1938. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  1939. require.EqualValues(t, 999_000, models[0]["context_window"])
  1940. require.EqualValues(t, 999_000, models[0]["max_context_window"])
  1941. }
  1942. func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWithoutOverwritingExplicitMetadata(t *testing.T) {
  1943. t.Parallel()
  1944. reasoning := true
  1945. account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
  1946. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  1947. "provider-model": {
  1948. ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description",
  1949. Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
  1950. SupportedReasoningLevels: []string{"high", "ultra"},
  1951. InputModalities: []string{"text", "image"},
  1952. ContextWindow: 999_000,
  1953. },
  1954. }})
  1955. manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{
  1956. "slug":"provider-model",
  1957. "description":"Provider supplied",
  1958. "context_window":64000,
  1959. "max_context_window":64000
  1960. }]}`)}
  1961. svc := &OpenAIGatewayService{}
  1962. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
  1963. models := decodeCodexManifestModels(t, manifest.Body)
  1964. require.Len(t, models, 1)
  1965. require.Equal(t, "Synced Display", models[0]["display_name"])
  1966. require.Equal(t, "Provider supplied", models[0]["description"])
  1967. require.Equal(t, "ultra", models[0]["default_reasoning_level"])
  1968. require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, models[0]))
  1969. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  1970. require.EqualValues(t, 64_000, models[0]["context_window"])
  1971. require.EqualValues(t, 64_000, models[0]["max_context_window"])
  1972. }
  1973. // Scenario: 原生 manifest 的缺失字段在命中缓存后仍使用账号当前同步快照,而不是缓存中的本地默认值。
  1974. func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedNativeManifest(t *testing.T) {
  1975. t.Parallel()
  1976. reasoning := true
  1977. account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
  1978. setSnapshot := func(displayName string, contextWindow int64) {
  1979. account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
  1980. "deepseek-v4-pro": {
  1981. ID: "deepseek-v4-pro", DisplayName: displayName,
  1982. Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
  1983. SupportedReasoningLevels: []string{"high", "ultra"},
  1984. InputModalities: []string{"text", "image"},
  1985. ContextWindow: contextWindow,
  1986. },
  1987. }})
  1988. }
  1989. setSnapshot("Synced DeepSeek", 256_000)
  1990. var calls atomic.Int32
  1991. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  1992. calls.Add(1)
  1993. return &http.Response{
  1994. StatusCode: http.StatusOK,
  1995. Header: http.Header{"Content-Type": []string{"application/json"}},
  1996. Body: io.NopCloser(strings.NewReader(`{"models":[{
  1997. "slug":"deepseek-v4-pro",
  1998. "description":"Provider supplied"
  1999. }]}`)),
  2000. }, nil
  2001. }}
  2002. svc := newCodexModelsAPIKeyTestService(upstream)
  2003. first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
  2004. require.NoError(t, err)
  2005. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
  2006. firstModel := decodeCodexManifestModels(t, first.Body)[0]
  2007. require.Equal(t, "Synced DeepSeek", firstModel["display_name"])
  2008. require.Equal(t, "Provider supplied", firstModel["description"])
  2009. require.Equal(t, "ultra", firstModel["default_reasoning_level"])
  2010. require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, firstModel))
  2011. require.Equal(t, []any{"text", "image"}, firstModel["input_modalities"])
  2012. require.EqualValues(t, 256_000, firstModel["context_window"])
  2013. setSnapshot("Refreshed DeepSeek", 512_000)
  2014. second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
  2015. require.NoError(t, err)
  2016. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
  2017. secondModel := decodeCodexManifestModels(t, second.Body)[0]
  2018. require.Equal(t, "Refreshed DeepSeek", secondModel["display_name"])
  2019. require.Equal(t, "Provider supplied", secondModel["description"])
  2020. require.EqualValues(t, 512_000, secondModel["context_window"])
  2021. require.Equal(t, int32(1), calls.Load(), "second response should use the cached upstream source body")
  2022. }
  2023. func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) {
  2024. t.Parallel()
  2025. svc := &OpenAIGatewayService{}
  2026. manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-6-astra"},{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o"},{"slug":"gpt-3.5-turbo"},{"slug":"gpt-4"}]}`)}
  2027. account := newCodexModelsAPIKeyTestAccount("")
  2028. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
  2029. models := decodeCodexManifestModels(t, manifest.Body)
  2030. require.Len(t, models, 5)
  2031. bySlug := make(map[string]map[string]any, len(models))
  2032. for _, model := range models {
  2033. slug, ok := model["slug"].(string)
  2034. require.True(t, ok)
  2035. bySlug[slug] = model
  2036. }
  2037. for _, slug := range []string{"gpt-6-astra", "gpt-5.6-sol", "gpt-4o"} {
  2038. require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"])
  2039. require.Equal(t, true, bySlug[slug]["supports_image_detail_original"])
  2040. }
  2041. for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} {
  2042. require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"])
  2043. require.Equal(t, false, bySlug[slug]["supports_image_detail_original"])
  2044. }
  2045. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  2046. }
  2047. func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) {
  2048. t.Parallel()
  2049. svc := &OpenAIGatewayService{}
  2050. manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{"slug":"gpt-5.6-sol"},{"slug":"gpt-4o-realtime-preview"},{"slug":"gpt-4o-mini-tts"},{"slug":"text-embedding-3-large"},{"slug":"omni-moderation-latest"},{"slug":"o4-mini"},{"slug":"codex-mini-latest"}]}`)}
  2051. account := newCodexModelsAPIKeyTestAccount("")
  2052. require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
  2053. require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body))
  2054. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  2055. }
  2056. func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) {
  2057. tests := []struct {
  2058. name string
  2059. body string
  2060. want string
  2061. }{
  2062. {
  2063. name: "affected models disable responses lite and preserve unknown fields",
  2064. body: `{"models":[{"slug":"gpt-6-astra","use_responses_lite":true},{"slug":"gpt-5.6-sol","use_responses_lite":true,"unknown_model":{"enabled":true}},{"slug":"gpt-5.6-terra","use_responses_lite":true},{"slug":"gpt-5.6-luna","use_responses_lite":true}],"unknown_top":{"version":1}}`,
  2065. want: `{"models":[{"slug":"gpt-6-astra","use_responses_lite":false},{"slug":"gpt-5.6-sol","unknown_model":{"enabled":true},"use_responses_lite":false},{"slug":"gpt-5.6-terra","use_responses_lite":false},{"slug":"gpt-5.6-luna","use_responses_lite":false}],"unknown_top":{"version":1}}`,
  2066. },
  2067. {
  2068. name: "unaffected model unchanged",
  2069. body: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
  2070. want: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
  2071. },
  2072. {
  2073. name: "false missing and alternate entries unchanged",
  2074. body: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-terra"},null,"gpt-5.6-luna",{"slug":17,"use_responses_lite":true}]}`,
  2075. want: `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-terra"},null,"gpt-5.6-luna",{"slug":17,"use_responses_lite":true}]}`,
  2076. },
  2077. }
  2078. for _, tt := range tests {
  2079. t.Run(tt.name, func(t *testing.T) {
  2080. got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body), nil)
  2081. require.NoError(t, err)
  2082. require.Equal(t, tt.want, string(got))
  2083. })
  2084. }
  2085. }
  2086. func TestFetchCodexModelsManifestAPIKeyDisablesResponsesLiteForAffectedModels(t *testing.T) {
  2087. const upstreamBody = `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`
  2088. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2089. return &http.Response{
  2090. StatusCode: http.StatusOK,
  2091. Header: http.Header{"Etag": []string{`"upstream-strong"`}},
  2092. Body: io.NopCloser(strings.NewReader(upstreamBody)),
  2093. }, nil
  2094. }}
  2095. s := newCodexModelsAPIKeyTestService(upstream)
  2096. manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", "")
  2097. require.NoError(t, err)
  2098. require.JSONEq(t, `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`, string(manifest.Body))
  2099. require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
  2100. require.Equal(t, `"upstream-strong"`, manifest.upstreamETag)
  2101. notModified, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", manifest.ETag)
  2102. require.NoError(t, err)
  2103. require.True(t, notModified.NotModified)
  2104. require.Equal(t, manifest.ETag, notModified.ETag)
  2105. }
  2106. func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) {
  2107. const manifestBody = ` {"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]} `
  2108. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  2109. _, _ = w.Write([]byte(manifestBody))
  2110. }))
  2111. defer server.Close()
  2112. original := chatgptCodexModelsURL
  2113. chatgptCodexModelsURL = server.URL
  2114. defer func() { chatgptCodexModelsURL = original }()
  2115. s := &OpenAIGatewayService{}
  2116. manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.145.0", "")
  2117. require.NoError(t, err)
  2118. require.Equal(t, manifestBody, string(manifest.Body))
  2119. }
  2120. func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) {
  2121. t.Parallel()
  2122. body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`)
  2123. models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body))
  2124. require.Len(t, models, 2)
  2125. requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash")
  2126. requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro")
  2127. require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"])
  2128. require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"])
  2129. require.EqualValues(t, 1_000_000, models[0]["context_window"])
  2130. require.EqualValues(t, 1_000_000, models[1]["context_window"])
  2131. }
  2132. func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) {
  2133. tests := []struct {
  2134. name string
  2135. body string
  2136. want string
  2137. }{
  2138. {
  2139. name: "codex manifest unchanged",
  2140. body: `{"models":[{"slug":"m-1"}]}`,
  2141. want: `{"models":[{"slug":"m-1"}]}`,
  2142. },
  2143. {
  2144. name: "empty data unchanged",
  2145. body: `{"object":"list","data":[]}`,
  2146. want: `{"object":"list","data":[]}`,
  2147. },
  2148. {
  2149. name: "data not an array unchanged",
  2150. body: `{"object":"list","data":{"id":"m-1"}}`,
  2151. want: `{"object":"list","data":{"id":"m-1"}}`,
  2152. },
  2153. {
  2154. name: "entries without usable IDs unchanged",
  2155. body: `{"object":"list","data":[{"id":""},{"object":"model"}]}`,
  2156. want: `{"object":"list","data":[{"id":""},{"object":"model"}]}`,
  2157. },
  2158. {
  2159. name: "invalid JSON unchanged",
  2160. body: `{"data":`,
  2161. want: `{"data":`,
  2162. },
  2163. {
  2164. name: "non-object unchanged",
  2165. body: `[]`,
  2166. want: `[]`,
  2167. },
  2168. }
  2169. for _, tt := range tests {
  2170. t.Run(tt.name, func(t *testing.T) {
  2171. if got := string(convertOpenAIModelListToCodexManifest([]byte(tt.body))); got != tt.want {
  2172. t.Errorf("got %q, want %q", got, tt.want)
  2173. }
  2174. })
  2175. }
  2176. }
  2177. func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) {
  2178. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2179. return &http.Response{
  2180. StatusCode: http.StatusOK,
  2181. Header: make(http.Header),
  2182. Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)),
  2183. }, nil
  2184. }}
  2185. s := newCodexModelsAPIKeyTestService(upstream)
  2186. s.cfg.Gateway.ModelsListReadMaxBytes = 8
  2187. _, err := s.FetchCodexModelsManifest(
  2188. context.Background(),
  2189. newCodexModelsAPIKeyTestAccount("https://upstream.example"),
  2190. "0.144.0",
  2191. "",
  2192. )
  2193. require.Error(t, err)
  2194. require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
  2195. require.Contains(t, err.Error(), "response exceeds 8 bytes")
  2196. require.True(t, IsRetryableCodexModelsManifestError(err))
  2197. }
  2198. func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) {
  2199. manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}`
  2200. require.Greater(t, len(manifestBody), 8<<20)
  2201. require.Less(t, len(manifestBody), 16<<20)
  2202. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  2203. w.Header().Set("Content-Type", "application/json")
  2204. _, _ = io.WriteString(w, manifestBody)
  2205. }))
  2206. defer server.Close()
  2207. original := chatgptCodexModelsURL
  2208. chatgptCodexModelsURL = server.URL
  2209. defer func() { chatgptCodexModelsURL = original }()
  2210. s := &OpenAIGatewayService{cfg: &config.Config{}}
  2211. s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20
  2212. manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "")
  2213. require.NoError(t, err)
  2214. require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact")
  2215. }
  2216. func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
  2217. tests := []struct {
  2218. name string
  2219. body string
  2220. }{
  2221. {name: "OpenAI models list", body: `{"object":"list","data":[]}`},
  2222. {name: "invalid JSON", body: `{"models":`},
  2223. {name: "non-object", body: `[]`},
  2224. {name: "null object", body: `null`},
  2225. {name: "missing models", body: `{}`},
  2226. {name: "models object", body: `{"models":{}}`},
  2227. {name: "models null", body: `{"models":null}`},
  2228. }
  2229. for _, tt := range tests {
  2230. t.Run(tt.name, func(t *testing.T) {
  2231. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2232. return &http.Response{
  2233. StatusCode: http.StatusOK,
  2234. Header: make(http.Header),
  2235. Body: io.NopCloser(strings.NewReader(tt.body)),
  2236. }, nil
  2237. }}
  2238. s := newCodexModelsAPIKeyTestService(upstream)
  2239. _, err := s.FetchCodexModelsManifest(
  2240. context.Background(),
  2241. newCodexModelsAPIKeyTestAccount("https://upstream.example"),
  2242. "0.144.0",
  2243. "",
  2244. )
  2245. if err == nil {
  2246. t.Fatal("expected invalid manifest error, got nil")
  2247. }
  2248. if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST" {
  2249. t.Errorf("error reason: got %q", infraerrors.Reason(err))
  2250. }
  2251. if !IsRetryableCodexModelsManifestError(err) {
  2252. t.Error("invalid upstream manifest must be retryable")
  2253. }
  2254. })
  2255. }
  2256. }
  2257. func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
  2258. var calls atomic.Int32
  2259. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2260. body := `{"object":"list","data":[]}`
  2261. if calls.Add(1) > 1 {
  2262. body = `{"models":[{"slug":"gpt-5.6"}]}`
  2263. }
  2264. return &http.Response{
  2265. StatusCode: http.StatusOK,
  2266. Header: make(http.Header),
  2267. Body: io.NopCloser(strings.NewReader(body)),
  2268. }, nil
  2269. }}
  2270. s := newCodexModelsAPIKeyTestService(upstream)
  2271. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2272. if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err == nil {
  2273. t.Fatal("expected invalid manifest error on first fetch")
  2274. }
  2275. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2276. if err != nil {
  2277. t.Fatalf("second fetch returned error: %v", err)
  2278. }
  2279. if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"}]}`; got != want {
  2280. t.Errorf("body: got %q, want %q", got, want)
  2281. }
  2282. if got := calls.Load(); got != 2 {
  2283. t.Errorf("upstream calls: got %d, want 2", got)
  2284. }
  2285. }
  2286. func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t *testing.T) {
  2287. const manifestBody = `{"models":[{"slug":"gpt-5.6"}]}`
  2288. var calls atomic.Int32
  2289. var readStartedOnce sync.Once
  2290. readStarted := make(chan struct{})
  2291. deadlineRemaining := make(chan time.Duration, 1)
  2292. release := make(chan struct{})
  2293. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2294. calls.Add(1)
  2295. deadline, ok := req.Context().Deadline()
  2296. if !ok {
  2297. deadlineRemaining <- 0
  2298. } else {
  2299. deadlineRemaining <- time.Until(deadline)
  2300. }
  2301. return &http.Response{
  2302. StatusCode: http.StatusOK,
  2303. Header: http.Header{"Etag": []string{`W/"shared"`}},
  2304. Body: &codexModelsBlockingBody{
  2305. ctx: req.Context(),
  2306. readStarted: readStarted,
  2307. startedOnce: &readStartedOnce,
  2308. release: release,
  2309. body: strings.NewReader(manifestBody),
  2310. },
  2311. }, nil
  2312. }}
  2313. s := newCodexModelsAPIKeyTestService(upstream)
  2314. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2315. firstCtx, cancelFirst := context.WithCancel(context.Background())
  2316. firstErr := make(chan error, 1)
  2317. go func() {
  2318. _, err := s.FetchCodexModelsManifest(firstCtx, account, "0.144.0", "")
  2319. firstErr <- err
  2320. }()
  2321. select {
  2322. case <-readStarted:
  2323. case <-time.After(time.Second):
  2324. t.Fatal("upstream body read did not start")
  2325. }
  2326. remaining := <-deadlineRemaining
  2327. if remaining < 14*time.Second || remaining > codexModelsManifestRequestTimeout {
  2328. t.Errorf("detached refresh deadline: got %s, want approximately %s", remaining, codexModelsManifestRequestTimeout)
  2329. }
  2330. cancelFirst()
  2331. select {
  2332. case err := <-firstErr:
  2333. if !errors.Is(err, context.Canceled) {
  2334. t.Fatalf("first caller error: got %v, want context.Canceled", err)
  2335. }
  2336. case <-time.After(time.Second):
  2337. t.Fatal("canceled caller did not return promptly")
  2338. }
  2339. secondResult := make(chan struct {
  2340. manifest *OpenAIModelsResponse
  2341. err error
  2342. }, 1)
  2343. go func() {
  2344. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2345. secondResult <- struct {
  2346. manifest *OpenAIModelsResponse
  2347. err error
  2348. }{manifest: manifest, err: err}
  2349. }()
  2350. time.Sleep(50 * time.Millisecond)
  2351. if got := calls.Load(); got != 1 {
  2352. t.Errorf("upstream calls before shared refresh completed: got %d, want 1", got)
  2353. }
  2354. close(release)
  2355. select {
  2356. case result := <-secondResult:
  2357. if result.err != nil {
  2358. t.Fatalf("second caller returned error: %v", result.err)
  2359. }
  2360. if string(result.manifest.Body) != manifestBody {
  2361. t.Errorf("second caller body: got %q", result.manifest.Body)
  2362. }
  2363. case <-time.After(time.Second):
  2364. t.Fatal("second caller did not receive shared refresh result")
  2365. }
  2366. if got := calls.Load(); got != 1 {
  2367. t.Errorf("total upstream calls: got %d, want 1", got)
  2368. }
  2369. }
  2370. func TestFetchCodexModelsManifestAPIKeyConcurrentRequestsShareRefresh(t *testing.T) {
  2371. const callers = 8
  2372. var calls atomic.Int32
  2373. started := make(chan struct{})
  2374. var startedOnce sync.Once
  2375. release := make(chan struct{})
  2376. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2377. calls.Add(1)
  2378. startedOnce.Do(func() { close(started) })
  2379. <-release
  2380. return &http.Response{
  2381. StatusCode: http.StatusOK,
  2382. Header: make(http.Header),
  2383. Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
  2384. }, nil
  2385. }}
  2386. s := newCodexModelsAPIKeyTestService(upstream)
  2387. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2388. begin := make(chan struct{})
  2389. errs := make(chan error, callers)
  2390. for i := 0; i < callers; i++ {
  2391. go func() {
  2392. <-begin
  2393. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2394. errs <- err
  2395. }()
  2396. }
  2397. close(begin)
  2398. select {
  2399. case <-started:
  2400. case <-time.After(time.Second):
  2401. t.Fatal("upstream request did not start")
  2402. }
  2403. time.Sleep(50 * time.Millisecond)
  2404. if got := calls.Load(); got != 1 {
  2405. t.Errorf("concurrent upstream calls: got %d, want 1", got)
  2406. }
  2407. close(release)
  2408. for i := 0; i < callers; i++ {
  2409. if err := <-errs; err != nil {
  2410. t.Errorf("caller %d returned error: %v", i, err)
  2411. }
  2412. }
  2413. }
  2414. func TestFetchCodexModelsManifestAPIKeyFreshCacheHandlesETagLocally(t *testing.T) {
  2415. var calls atomic.Int32
  2416. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2417. calls.Add(1)
  2418. if got := req.Header.Get("If-None-Match"); got != "" {
  2419. t.Errorf("cache refresh must not inherit a caller's If-None-Match: got %q", got)
  2420. }
  2421. return &http.Response{
  2422. StatusCode: http.StatusOK,
  2423. Header: http.Header{"Etag": []string{`W/"cached"`}},
  2424. Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
  2425. }, nil
  2426. }}
  2427. s := newCodexModelsAPIKeyTestService(upstream)
  2428. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2429. if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
  2430. t.Fatalf("initial fetch returned error: %v", err)
  2431. }
  2432. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", `W/"cached"`)
  2433. if err != nil {
  2434. t.Fatalf("cached fetch returned error: %v", err)
  2435. }
  2436. if !manifest.NotModified {
  2437. t.Fatal("matching cached ETag must return NotModified")
  2438. }
  2439. if got := calls.Load(); got != 1 {
  2440. t.Errorf("upstream calls: got %d, want 1", got)
  2441. }
  2442. }
  2443. func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) {
  2444. var calls atomic.Int32
  2445. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2446. calls.Add(1)
  2447. return &http.Response{
  2448. StatusCode: http.StatusOK,
  2449. Header: make(http.Header),
  2450. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`)),
  2451. }, nil
  2452. }}
  2453. s := newCodexModelsAPIKeyTestService(upstream)
  2454. s.accountRepo = codexModelsVisibilityAccountRepo{}
  2455. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2456. first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2457. require.NoError(t, err)
  2458. require.Contains(t, string(first.Body), "model-a")
  2459. require.Contains(t, string(first.Body), "model-b")
  2460. require.NoError(t, s.CompleteAPIKeyCodexModelsManifestForClient(first, account))
  2461. require.NoError(t, s.MergeGroupConfiguredCodexModels(
  2462. context.Background(),
  2463. &Group{
  2464. ID: 81,
  2465. Platform: PlatformOpenAI,
  2466. ModelAllowlist: GroupModelAllowlist{
  2467. Enabled: true,
  2468. Models: []string{"model-a"},
  2469. },
  2470. },
  2471. first,
  2472. "",
  2473. ))
  2474. require.Equal(t, []string{"model-a"}, codexManifestModelSlugs(t, first.Body))
  2475. second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2476. require.NoError(t, err)
  2477. require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, second.Body))
  2478. require.Equal(t, int32(1), calls.Load())
  2479. var wg sync.WaitGroup
  2480. for i := 0; i < 8; i++ {
  2481. wg.Add(1)
  2482. go func() {
  2483. defer wg.Done()
  2484. manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2485. require.NoError(t, fetchErr)
  2486. require.NoError(t, s.MergeGroupConfiguredCodexModels(
  2487. context.Background(),
  2488. &Group{
  2489. ID: 82,
  2490. Platform: PlatformOpenAI,
  2491. ModelAllowlist: GroupModelAllowlist{
  2492. Enabled: true,
  2493. Models: []string{"model-b"},
  2494. },
  2495. },
  2496. manifest,
  2497. "",
  2498. ))
  2499. require.Equal(t, []string{"model-b"}, codexManifestModelSlugs(t, manifest.Body))
  2500. }()
  2501. }
  2502. wg.Wait()
  2503. third, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2504. require.NoError(t, err)
  2505. require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, third.Body))
  2506. require.Equal(t, int32(1), calls.Load())
  2507. }
  2508. func TestFetchCodexModelsManifestAPIKeyCacheKeyIsolatesRequestIdentity(t *testing.T) {
  2509. var calls atomic.Int32
  2510. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2511. calls.Add(1)
  2512. return &http.Response{
  2513. StatusCode: http.StatusOK,
  2514. Header: make(http.Header),
  2515. Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
  2516. }, nil
  2517. }}
  2518. s := newCodexModelsAPIKeyTestService(upstream)
  2519. base := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2520. fetch := func(account *Account, version string) {
  2521. t.Helper()
  2522. if _, err := s.FetchCodexModelsManifest(context.Background(), account, version, ""); err != nil {
  2523. t.Fatalf("fetch returned error: %v", err)
  2524. }
  2525. }
  2526. fetch(base, "0.144.0")
  2527. fetch(base, "0.144.0")
  2528. differentAccount := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2529. differentAccount.ID = 3
  2530. fetch(differentAccount, "0.144.0")
  2531. differentToken := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2532. differentToken.Credentials["api_key"] = "sk-other"
  2533. fetch(differentToken, "0.144.0")
  2534. differentUpstream := newCodexModelsAPIKeyTestAccount("https://other-upstream.example")
  2535. fetch(differentUpstream, "0.144.0")
  2536. fetch(base, "0.145.0")
  2537. differentHeaders := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2538. differentHeaders.Credentials[credKeyHeaderOverrideEnabled] = true
  2539. differentHeaders.Credentials[credKeyHeaderOverrides] = map[string]any{"x-tenant": "other"}
  2540. fetch(differentHeaders, "0.144.0")
  2541. proxyID := int64(9)
  2542. differentProxy := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2543. differentProxy.ProxyID = &proxyID
  2544. differentProxy.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 8080}
  2545. fetch(differentProxy, "0.144.0")
  2546. fetch(differentProxy, "0.144.0")
  2547. if got := calls.Load(); got != 7 {
  2548. t.Errorf("isolated upstream calls: got %d, want 7", got)
  2549. }
  2550. }
  2551. func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.T) {
  2552. var calls atomic.Int32
  2553. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2554. calls.Add(1)
  2555. body := `{"models":[]}`
  2556. switch {
  2557. case strings.Contains(req.URL.Host, "large-source"):
  2558. body = `{"object":"list","data":[{"id":"model-a","padding":"` + strings.Repeat("x", 1<<20) + `"}]}`
  2559. case strings.Contains(req.URL.Host, "large"):
  2560. body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
  2561. }
  2562. return &http.Response{
  2563. StatusCode: http.StatusOK,
  2564. Header: make(http.Header),
  2565. Body: io.NopCloser(strings.NewReader(body)),
  2566. }, nil
  2567. }}
  2568. s := newCodexModelsAPIKeyTestService(upstream)
  2569. fetch := func(account *Account) {
  2570. t.Helper()
  2571. if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
  2572. t.Fatalf("fetch returned error: %v", err)
  2573. }
  2574. }
  2575. small := newCodexModelsAPIKeyTestAccount("https://small.example")
  2576. fetch(small)
  2577. fetch(small)
  2578. large := newCodexModelsAPIKeyTestAccount("https://large.example")
  2579. large.ID = 3
  2580. fetch(large)
  2581. fetch(large)
  2582. largeSource := newCodexModelsAPIKeyTestAccount("https://large-source.example")
  2583. largeSource.ID = 4
  2584. fetch(largeSource)
  2585. fetch(largeSource)
  2586. if got := calls.Load(); got != 5 {
  2587. t.Fatalf("body-size bounded cache calls: got %d, want 5", got)
  2588. }
  2589. for i := int64(10); i < 523; i++ {
  2590. account := newCodexModelsAPIKeyTestAccount("https://bounded.example")
  2591. account.ID = i
  2592. fetch(account)
  2593. }
  2594. last := newCodexModelsAPIKeyTestAccount("https://bounded.example")
  2595. last.ID = 522
  2596. fetch(last)
  2597. if got := calls.Load(); got != 518 {
  2598. t.Fatalf("most recent cache entry was not retained: calls=%d, want 518", got)
  2599. }
  2600. first := newCodexModelsAPIKeyTestAccount("https://bounded.example")
  2601. first.ID = 10
  2602. fetch(first)
  2603. if got := calls.Load(); got != 519 {
  2604. t.Errorf("oldest cache entry was not evicted: calls=%d, want 519", got)
  2605. }
  2606. }
  2607. func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) {
  2608. var calls atomic.Int32
  2609. refreshStarted := make(chan struct{})
  2610. releaseRefresh := make(chan struct{})
  2611. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2612. call := calls.Add(1)
  2613. body := `{"models":[{"slug":"old"}]}`
  2614. if call > 1 {
  2615. if call == 2 {
  2616. close(refreshStarted)
  2617. }
  2618. <-releaseRefresh
  2619. body = `{"models":[{"slug":"new"}]}`
  2620. }
  2621. return &http.Response{
  2622. StatusCode: http.StatusOK,
  2623. Header: make(http.Header),
  2624. Body: io.NopCloser(strings.NewReader(body)),
  2625. }, nil
  2626. }}
  2627. s := newCodexModelsAPIKeyTestService(upstream)
  2628. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2629. if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
  2630. t.Fatalf("initial fetch returned error: %v", err)
  2631. }
  2632. s.openAIModelsCache.mu.Lock()
  2633. for key, entry := range s.openAIModelsCache.entries {
  2634. entry.expiresAt = time.Now().Add(-time.Second)
  2635. s.openAIModelsCache.entries[key] = entry
  2636. }
  2637. s.openAIModelsCache.mu.Unlock()
  2638. resultCh := make(chan struct {
  2639. manifest *OpenAIModelsResponse
  2640. err error
  2641. }, 1)
  2642. go func() {
  2643. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2644. resultCh <- struct {
  2645. manifest *OpenAIModelsResponse
  2646. err error
  2647. }{manifest: manifest, err: err}
  2648. }()
  2649. select {
  2650. case <-refreshStarted:
  2651. case <-time.After(time.Second):
  2652. t.Fatal("background refresh did not start")
  2653. }
  2654. var staleResult struct {
  2655. manifest *OpenAIModelsResponse
  2656. err error
  2657. }
  2658. select {
  2659. case staleResult = <-resultCh:
  2660. case <-time.After(100 * time.Millisecond):
  2661. t.Error("stale manifest was not returned while refresh was blocked")
  2662. close(releaseRefresh)
  2663. staleResult = <-resultCh
  2664. }
  2665. if staleResult.err != nil {
  2666. t.Fatalf("stale fetch returned error: %v", staleResult.err)
  2667. }
  2668. if got := string(staleResult.manifest.Body); got != `{"models":[{"slug":"old"}]}` {
  2669. t.Errorf("stale body: got %q", got)
  2670. }
  2671. if got := calls.Load(); got != 2 {
  2672. t.Errorf("upstream calls during stale refresh: got %d, want 2", got)
  2673. }
  2674. select {
  2675. case <-releaseRefresh:
  2676. default:
  2677. close(releaseRefresh)
  2678. }
  2679. deadline := time.Now().Add(time.Second)
  2680. for {
  2681. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2682. if err == nil && string(manifest.Body) == `{"models":[{"slug":"new"}]}` {
  2683. break
  2684. }
  2685. if time.Now().After(deadline) {
  2686. t.Fatalf("refreshed manifest was not cached: manifest=%v err=%v", manifest, err)
  2687. }
  2688. time.Sleep(10 * time.Millisecond)
  2689. }
  2690. if got := calls.Load(); got != 2 {
  2691. t.Errorf("stale refresh was not deduplicated: calls=%d, want 2", got)
  2692. }
  2693. }
  2694. func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) {
  2695. var calls atomic.Int32
  2696. refreshDone := make(chan struct{})
  2697. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2698. call := calls.Add(1)
  2699. if call == 1 {
  2700. header := make(http.Header)
  2701. header.Set("ETag", `"upstream-cached"`)
  2702. return &http.Response{
  2703. StatusCode: http.StatusOK,
  2704. Header: header,
  2705. Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]}`)),
  2706. }, nil
  2707. }
  2708. if got := req.Header.Get("If-None-Match"); got != `"upstream-cached"` {
  2709. t.Errorf("background revalidation If-None-Match: got %q", got)
  2710. }
  2711. close(refreshDone)
  2712. header := make(http.Header)
  2713. header.Set("ETag", `"upstream-cached"`)
  2714. return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
  2715. }}
  2716. s := newCodexModelsAPIKeyTestService(upstream)
  2717. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2718. if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
  2719. t.Fatalf("initial fetch returned error: %v", err)
  2720. }
  2721. s.openAIModelsCache.mu.Lock()
  2722. for key, entry := range s.openAIModelsCache.entries {
  2723. entry.expiresAt = time.Now().Add(-time.Second)
  2724. s.openAIModelsCache.entries[key] = entry
  2725. }
  2726. s.openAIModelsCache.mu.Unlock()
  2727. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2728. if err != nil {
  2729. t.Fatalf("stale fetch returned error: %v", err)
  2730. }
  2731. if got := string(manifest.Body); got != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
  2732. t.Fatalf("stale body: got %q", got)
  2733. }
  2734. select {
  2735. case <-refreshDone:
  2736. case <-time.After(time.Second):
  2737. t.Fatal("ETag revalidation did not complete")
  2738. }
  2739. deadline := time.Now().Add(time.Second)
  2740. for {
  2741. s.openAIModelsCache.mu.Lock()
  2742. fresh := false
  2743. for _, entry := range s.openAIModelsCache.entries {
  2744. fresh = time.Now().Before(entry.expiresAt)
  2745. }
  2746. s.openAIModelsCache.mu.Unlock()
  2747. if fresh {
  2748. break
  2749. }
  2750. if time.Now().After(deadline) {
  2751. t.Fatal("304 revalidation did not renew the cached manifest")
  2752. }
  2753. time.Sleep(10 * time.Millisecond)
  2754. }
  2755. manifest, err = s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2756. if err != nil || string(manifest.Body) != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
  2757. t.Fatalf("renewed cached manifest: body=%q err=%v", manifest.Body, err)
  2758. }
  2759. if got := calls.Load(); got != 2 {
  2760. t.Errorf("upstream calls: got %d, want 2", got)
  2761. }
  2762. }
  2763. func TestFetchCodexModelsManifestAPIKeyColdCacheHandlesNotModifiedLocally(t *testing.T) {
  2764. var gotIfNoneMatch string
  2765. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2766. gotIfNoneMatch = req.Header.Get("If-None-Match")
  2767. header := make(http.Header)
  2768. header.Set("ETag", `W/"api-key-manifest"`)
  2769. return &http.Response{
  2770. StatusCode: http.StatusOK,
  2771. Header: header,
  2772. Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
  2773. }, nil
  2774. }}
  2775. s := newCodexModelsAPIKeyTestService(upstream)
  2776. manifest, err := s.FetchCodexModelsManifest(
  2777. context.Background(),
  2778. newCodexModelsAPIKeyTestAccount("https://upstream.example"),
  2779. "0.144.0",
  2780. `W/"api-key-manifest"`,
  2781. )
  2782. if err != nil {
  2783. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  2784. }
  2785. if !manifest.NotModified {
  2786. t.Error("expected NotModified to be true")
  2787. }
  2788. if manifest.ETag != `W/"api-key-manifest"` {
  2789. t.Errorf("etag not passed through: got %q", manifest.ETag)
  2790. }
  2791. if gotIfNoneMatch != "" {
  2792. t.Errorf("cold shared refresh must not inherit caller if-none-match: got %q", gotIfNoneMatch)
  2793. }
  2794. }
  2795. func TestFetchCodexModelsManifestAPIKeyDoesNotCacheUnexpectedColdNotModified(t *testing.T) {
  2796. var calls atomic.Int32
  2797. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2798. calls.Add(1)
  2799. if got := req.Header.Get("If-None-Match"); got != "" {
  2800. t.Errorf("cold shared refresh If-None-Match: got %q", got)
  2801. }
  2802. header := make(http.Header)
  2803. header.Set("ETag", `W/"unexpected"`)
  2804. return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
  2805. }}
  2806. s := newCodexModelsAPIKeyTestService(upstream)
  2807. account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
  2808. for i := 0; i < 2; i++ {
  2809. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
  2810. if err != nil {
  2811. t.Fatalf("fetch %d returned error: %v", i, err)
  2812. }
  2813. if !manifest.NotModified {
  2814. t.Fatalf("fetch %d: expected upstream NotModified response", i)
  2815. }
  2816. }
  2817. if got := calls.Load(); got != 2 {
  2818. t.Errorf("unexpected cold 304 was cached: upstream calls=%d, want 2", got)
  2819. }
  2820. }
  2821. func TestFetchCodexModelsManifestAPIKeyPreservesBaseURLQuery(t *testing.T) {
  2822. var gotURL string
  2823. upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2824. gotURL = req.URL.String()
  2825. return &http.Response{
  2826. StatusCode: http.StatusOK,
  2827. Header: make(http.Header),
  2828. Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
  2829. }, nil
  2830. }}
  2831. s := newCodexModelsAPIKeyTestService(upstream)
  2832. _, err := s.FetchCodexModelsManifest(
  2833. context.Background(),
  2834. newCodexModelsAPIKeyTestAccount("https://upstream.example/v1?tenant=acme"),
  2835. "0.144.0",
  2836. "",
  2837. )
  2838. if err != nil {
  2839. t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
  2840. }
  2841. if gotURL != "https://upstream.example/v1/models?client_version=0.144.0&tenant=acme" {
  2842. t.Errorf("request URL: got %q", gotURL)
  2843. }
  2844. }
  2845. func TestFetchCodexModelsManifestAPIKeyRejectsBaseURLFragment(t *testing.T) {
  2846. called := false
  2847. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2848. called = true
  2849. return &http.Response{
  2850. StatusCode: http.StatusOK,
  2851. Header: make(http.Header),
  2852. Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
  2853. }, nil
  2854. }}
  2855. s := newCodexModelsAPIKeyTestService(upstream)
  2856. _, err := s.FetchCodexModelsManifest(
  2857. context.Background(),
  2858. newCodexModelsAPIKeyTestAccount("https://upstream.example/v1#models"),
  2859. "0.144.0",
  2860. "",
  2861. )
  2862. if err == nil {
  2863. t.Fatal("expected invalid upstream base URL error, got nil")
  2864. }
  2865. if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID" {
  2866. t.Errorf("error reason: got %q", infraerrors.Reason(err))
  2867. }
  2868. if called {
  2869. t.Fatal("fragment-bearing base URL must be rejected before the upstream request")
  2870. }
  2871. }
  2872. // codexModelsAccountStateRepo records account state transitions triggered by
  2873. // manifest upstream errors (#4544).
  2874. type codexModelsAccountStateRepo struct {
  2875. AccountRepository
  2876. mu sync.Mutex
  2877. setErrorCalls int
  2878. lastErrorMsg string
  2879. setTempUnschedCalls int
  2880. lastTempReason string
  2881. }
  2882. func (r *codexModelsAccountStateRepo) SetError(_ context.Context, _ int64, errorMsg string) error {
  2883. r.mu.Lock()
  2884. defer r.mu.Unlock()
  2885. r.setErrorCalls++
  2886. r.lastErrorMsg = errorMsg
  2887. return nil
  2888. }
  2889. func (r *codexModelsAccountStateRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
  2890. r.mu.Lock()
  2891. defer r.mu.Unlock()
  2892. r.setTempUnschedCalls++
  2893. r.lastTempReason = reason
  2894. return nil
  2895. }
  2896. func newCodexModels401TestService(repo AccountRepository) *OpenAIGatewayService {
  2897. rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  2898. s := &OpenAIGatewayService{rateLimitService: rateLimitService}
  2899. rateLimitService.SetAccountRuntimeBlocker(s)
  2900. return s
  2901. }
  2902. func TestFetchCodexModelsManifestOAuth401MarksAccountUnschedulable(t *testing.T) {
  2903. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  2904. w.WriteHeader(http.StatusUnauthorized)
  2905. _, _ = w.Write([]byte(`{"detail":{"message":"invalid token"}}`))
  2906. }))
  2907. defer server.Close()
  2908. original := chatgptCodexModelsURL
  2909. chatgptCodexModelsURL = server.URL
  2910. defer func() { chatgptCodexModelsURL = original }()
  2911. repo := &codexModelsAccountStateRepo{}
  2912. s := newCodexModels401TestService(repo)
  2913. account := newCodexModelsTestAccount()
  2914. account.Credentials["refresh_token"] = "test-refresh-token"
  2915. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  2916. require.Error(t, err)
  2917. require.True(t, IsRetryableCodexModelsManifestError(err), "manifest 401 should allow account failover")
  2918. require.Equal(t, 1, repo.setTempUnschedCalls, "OAuth 401 should temp-unschedule the account")
  2919. require.Equal(t, 0, repo.setErrorCalls)
  2920. require.True(t, s.isOpenAIAccountRuntimeBlocked(account), "account should be runtime-blocked after manifest 401")
  2921. }
  2922. func TestFetchCodexModelsManifestOAuth401TokenRevokedDisablesAccount(t *testing.T) {
  2923. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  2924. w.WriteHeader(http.StatusUnauthorized)
  2925. _, _ = w.Write([]byte(`{"error":{"code":"token_revoked","message":"token has been revoked"}}`))
  2926. }))
  2927. defer server.Close()
  2928. original := chatgptCodexModelsURL
  2929. chatgptCodexModelsURL = server.URL
  2930. defer func() { chatgptCodexModelsURL = original }()
  2931. repo := &codexModelsAccountStateRepo{}
  2932. s := newCodexModels401TestService(repo)
  2933. account := newCodexModelsTestAccount()
  2934. account.Credentials["refresh_token"] = "test-refresh-token"
  2935. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  2936. require.Error(t, err)
  2937. require.True(t, IsRetryableCodexModelsManifestError(err))
  2938. require.Equal(t, 1, repo.setErrorCalls, "revoked token should permanently disable the account")
  2939. require.Contains(t, repo.lastErrorMsg, "Token revoked")
  2940. require.Equal(t, 0, repo.setTempUnschedCalls)
  2941. }
  2942. func TestFetchCodexModelsManifestAgentIdentity401DoesNotDisableAccount(t *testing.T) {
  2943. key, privateKey := newTestAgentIdentityKey(t)
  2944. account := &Account{
  2945. ID: 6,
  2946. Platform: PlatformOpenAI,
  2947. Type: AccountTypeOAuth,
  2948. Credentials: map[string]any{
  2949. "auth_mode": OpenAIAuthModeAgentIdentity,
  2950. "agent_runtime_id": key.runtimeID,
  2951. "agent_private_key": privateKey,
  2952. "task_id": key.taskID,
  2953. "chatgpt_account_id": "acc-agent-401",
  2954. },
  2955. }
  2956. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  2957. w.WriteHeader(http.StatusUnauthorized)
  2958. _, _ = w.Write([]byte(`{"detail":"some non-task 401"}`))
  2959. }))
  2960. defer server.Close()
  2961. original := chatgptCodexModelsURL
  2962. chatgptCodexModelsURL = server.URL
  2963. defer func() { chatgptCodexModelsURL = original }()
  2964. repo := &codexModelsAccountStateRepo{}
  2965. s := newCodexModels401TestService(repo)
  2966. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  2967. require.Error(t, err)
  2968. require.Equal(t, 0, repo.setErrorCalls, "agent identity 401s must not disable the account")
  2969. require.Equal(t, 0, repo.setTempUnschedCalls)
  2970. }
  2971. func TestFetchCodexModelsManifestAPIKey401KeepsNoFailoverAndNoDisable(t *testing.T) {
  2972. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2973. return &http.Response{
  2974. StatusCode: http.StatusUnauthorized,
  2975. Status: "401 Unauthorized",
  2976. Header: make(http.Header),
  2977. Body: io.NopCloser(strings.NewReader(`{"error":"invalid api key"}`)),
  2978. }, nil
  2979. }}
  2980. repo := &codexModelsAccountStateRepo{}
  2981. s := newCodexModelsAPIKeyTestService(upstream)
  2982. s.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
  2983. _, err := s.FetchCodexModelsManifest(
  2984. context.Background(),
  2985. newCodexModelsAPIKeyTestAccount("https://upstream.example"),
  2986. "0.144.0",
  2987. "",
  2988. )
  2989. require.Error(t, err)
  2990. require.False(t, IsRetryableCodexModelsManifestError(err), "custom upstream manifest 401 keeps the no-failover behavior")
  2991. require.Equal(t, 0, repo.setErrorCalls, "custom upstream manifest 401 must not disable the account")
  2992. require.Equal(t, 0, repo.setTempUnschedCalls)
  2993. }
  2994. func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) {
  2995. upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  2996. return &http.Response{
  2997. StatusCode: http.StatusTooManyRequests,
  2998. Status: "429 Too Many Requests",
  2999. Header: make(http.Header),
  3000. Body: io.NopCloser(strings.NewReader(`{"error":"rate limited"}`)),
  3001. }, nil
  3002. }}
  3003. s := newCodexModelsAPIKeyTestService(upstream)
  3004. _, err := s.FetchCodexModelsManifest(
  3005. context.Background(),
  3006. newCodexModelsAPIKeyTestAccount("https://upstream.example"),
  3007. "0.144.0",
  3008. "",
  3009. )
  3010. if err == nil {
  3011. t.Fatal("expected error for upstream 429, got nil")
  3012. }
  3013. if infraerrors.Code(err) != http.StatusBadGateway {
  3014. t.Errorf("error status: got %d, want %d", infraerrors.Code(err), http.StatusBadGateway)
  3015. }
  3016. if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_FAILED" {
  3017. t.Errorf("error reason: got %q", infraerrors.Reason(err))
  3018. }
  3019. }
  3020. func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testing.T) {
  3021. tests := []struct {
  3022. name string
  3023. baseURL string
  3024. }{
  3025. {name: "missing base URL"},
  3026. {name: "official host", baseURL: "https://api.openai.com"},
  3027. {name: "official versioned URL", baseURL: "https://API.OPENAI.COM:443/v1/"},
  3028. }
  3029. for _, tt := range tests {
  3030. t.Run(tt.name, func(t *testing.T) {
  3031. var gotURL string
  3032. s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  3033. gotURL = req.URL.String()
  3034. return &http.Response{
  3035. StatusCode: http.StatusOK,
  3036. Header: make(http.Header),
  3037. Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)),
  3038. }, nil
  3039. }})
  3040. manifest, err := s.FetchCodexModelsManifest(
  3041. context.Background(),
  3042. newCodexModelsAPIKeyTestAccount(tt.baseURL),
  3043. "0.144.0",
  3044. "",
  3045. )
  3046. require.NoError(t, err)
  3047. parsedURL, parseErr := url.Parse(gotURL)
  3048. require.NoError(t, parseErr)
  3049. require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname()))
  3050. require.Equal(t, "/v1/models", parsedURL.Path)
  3051. require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version"))
  3052. models := decodeCodexManifestModels(t, manifest.Body)
  3053. require.Len(t, models, 1)
  3054. requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol")
  3055. require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
  3056. })
  3057. }
  3058. }
  3059. // --- OAuth manifest 账号级缓存(统一缓存策略) ---
  3060. // newCodexModelsOAuthCacheServer 返回一个记录调用次数、返回给定 manifest 的
  3061. // ChatGPT Codex models 测试上游。
  3062. func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) {
  3063. t.Helper()
  3064. calls := &atomic.Int32{}
  3065. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  3066. calls.Add(1)
  3067. w.Header().Set("Content-Type", "application/json")
  3068. _, _ = w.Write([]byte(body))
  3069. }))
  3070. t.Cleanup(server.Close)
  3071. original := chatgptCodexModelsURL
  3072. chatgptCodexModelsURL = server.URL
  3073. t.Cleanup(func() { chatgptCodexModelsURL = original })
  3074. return server, calls
  3075. }
  3076. func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) {
  3077. s.openAIModelsCache.mu.Lock()
  3078. for key, entry := range s.openAIModelsCache.entries {
  3079. entry.expiresAt = time.Now().Add(-age)
  3080. entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age)
  3081. s.openAIModelsCache.entries[key] = entry
  3082. }
  3083. s.openAIModelsCache.mu.Unlock()
  3084. }
  3085. func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) {
  3086. _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.5"}]}`)
  3087. s := &OpenAIGatewayService{}
  3088. account := newCodexModelsTestAccount()
  3089. for i := 0; i < 10; i++ {
  3090. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3091. require.NoError(t, err)
  3092. require.False(t, manifest.NotModified)
  3093. require.JSONEq(t, `{"models":[{"slug":"gpt-5.5"}]}`, string(manifest.Body))
  3094. }
  3095. require.EqualValues(t, 1, calls.Load(), "新鲜期内重复请求不得打到 chatgpt.com")
  3096. }
  3097. func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackground(t *testing.T) {
  3098. calls := &atomic.Int32{}
  3099. refreshStarted := make(chan struct{})
  3100. var refreshOnce sync.Once
  3101. release := make(chan struct{})
  3102. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  3103. if calls.Add(1) == 2 {
  3104. refreshOnce.Do(func() { close(refreshStarted) })
  3105. <-release
  3106. }
  3107. w.Header().Set("Content-Type", "application/json")
  3108. _, _ = w.Write([]byte(`{"models":[{"slug":"new"}]}`))
  3109. }))
  3110. t.Cleanup(server.Close)
  3111. original := chatgptCodexModelsURL
  3112. chatgptCodexModelsURL = server.URL
  3113. t.Cleanup(func() { chatgptCodexModelsURL = original })
  3114. s := &OpenAIGatewayService{}
  3115. account := newCodexModelsTestAccount()
  3116. first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3117. require.NoError(t, err)
  3118. require.Contains(t, string(first.Body), `"new"`)
  3119. expireCodexModelsManifestCache(s, 2*time.Minute)
  3120. resultCh := make(chan *OpenAIModelsResponse, 1)
  3121. errCh := make(chan error, 1)
  3122. go func() {
  3123. manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3124. resultCh <- manifest
  3125. errCh <- fetchErr
  3126. }()
  3127. select {
  3128. case <-refreshStarted:
  3129. case <-time.After(time.Second):
  3130. t.Fatal("乐观期后台刷新未触发")
  3131. }
  3132. // 乐观期:立即返回旧值,不同步等待刷新。
  3133. select {
  3134. case manifest := <-resultCh:
  3135. require.NoError(t, <-errCh)
  3136. require.Contains(t, string(manifest.Body), `"new"`)
  3137. case <-time.After(100 * time.Millisecond):
  3138. t.Fatal("乐观期必须立即返回缓存旧值")
  3139. }
  3140. require.EqualValues(t, 2, calls.Load())
  3141. close(release)
  3142. deadline := time.Now().Add(time.Second)
  3143. for time.Now().Before(deadline) {
  3144. manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3145. if fetchErr == nil && manifest != nil && !manifest.NotModified {
  3146. require.Contains(t, string(manifest.Body), `"new"`)
  3147. }
  3148. s.openAIModelsCache.mu.Lock()
  3149. fresh := len(s.openAIModelsCache.entries) > 0
  3150. s.openAIModelsCache.mu.Unlock()
  3151. if fresh {
  3152. break
  3153. }
  3154. time.Sleep(10 * time.Millisecond)
  3155. }
  3156. require.EqualValues(t, 2, calls.Load(), "后台刷新必须单飞")
  3157. }
  3158. func TestFetchCodexModelsManifestOAuthOverdueSynchronousRefreshAndFailure(t *testing.T) {
  3159. t.Run("overdue refresh waits and re-caches", func(t *testing.T) {
  3160. calls := &atomic.Int32{}
  3161. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  3162. if calls.Add(1) == 2 {
  3163. w.Header().Set("Content-Type", "application/json")
  3164. _, _ = w.Write([]byte(`{"models":[{"slug":"refreshed"}]}`))
  3165. return
  3166. }
  3167. w.Header().Set("Content-Type", "application/json")
  3168. _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`))
  3169. }))
  3170. t.Cleanup(server.Close)
  3171. original := chatgptCodexModelsURL
  3172. chatgptCodexModelsURL = server.URL
  3173. t.Cleanup(func() { chatgptCodexModelsURL = original })
  3174. s := &OpenAIGatewayService{}
  3175. account := newCodexModelsTestAccount()
  3176. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3177. require.NoError(t, err)
  3178. // 超过 5 分钟:缓存条目被丢弃,同步等待上游刷新。
  3179. expireCodexModelsManifestCache(s, 6*time.Minute)
  3180. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3181. require.NoError(t, err)
  3182. require.Contains(t, string(manifest.Body), `"refreshed"`)
  3183. require.EqualValues(t, 2, calls.Load())
  3184. // 新内容写回缓存并重新开始新鲜期。
  3185. again, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3186. require.NoError(t, err)
  3187. require.Contains(t, string(again.Body), `"refreshed"`)
  3188. require.EqualValues(t, 2, calls.Load())
  3189. })
  3190. t.Run("overdue upstream failure returns error not stale cache", func(t *testing.T) {
  3191. fail := &atomic.Bool{}
  3192. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  3193. if fail.Load() {
  3194. w.WriteHeader(http.StatusInternalServerError)
  3195. _, _ = w.Write([]byte(`{"detail":"boom"}`))
  3196. return
  3197. }
  3198. w.Header().Set("Content-Type", "application/json")
  3199. _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`))
  3200. }))
  3201. t.Cleanup(server.Close)
  3202. original := chatgptCodexModelsURL
  3203. chatgptCodexModelsURL = server.URL
  3204. t.Cleanup(func() { chatgptCodexModelsURL = original })
  3205. s := &OpenAIGatewayService{}
  3206. account := newCodexModelsTestAccount()
  3207. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3208. require.NoError(t, err)
  3209. fail.Store(true)
  3210. expireCodexModelsManifestCache(s, 6*time.Minute)
  3211. _, err = s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3212. require.Error(t, err, "超期后上游失败必须返回错误而非旧缓存")
  3213. require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
  3214. })
  3215. }
  3216. func TestFetchCodexModelsManifestOAuthTokenChangeCacheMiss(t *testing.T) {
  3217. _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[]}`)
  3218. s := &OpenAIGatewayService{}
  3219. account := newCodexModelsTestAccount()
  3220. _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3221. require.NoError(t, err)
  3222. require.EqualValues(t, 1, calls.Load())
  3223. // 令牌刷新后缓存键变化,下一次拉取视为未命中并同步请求上游。
  3224. account.Credentials["access_token"] = "rotated-access-token"
  3225. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3226. require.NoError(t, err)
  3227. require.Contains(t, string(manifest.Body), `"models"`)
  3228. require.EqualValues(t, 2, calls.Load())
  3229. }
  3230. func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering(t *testing.T) {
  3231. started := make(chan struct{})
  3232. var startedOnce sync.Once
  3233. release := make(chan struct{})
  3234. calls := &atomic.Int32{}
  3235. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  3236. calls.Add(1)
  3237. startedOnce.Do(func() { close(started) })
  3238. <-release
  3239. w.Header().Set("Content-Type", "application/json")
  3240. _, _ = w.Write([]byte(`{"models":[{"slug":"model-a"},{"slug":"model-b"}]}`))
  3241. }))
  3242. t.Cleanup(server.Close)
  3243. original := chatgptCodexModelsURL
  3244. chatgptCodexModelsURL = server.URL
  3245. t.Cleanup(func() { chatgptCodexModelsURL = original })
  3246. s := &OpenAIGatewayService{
  3247. accountRepo: codexModelsVisibilityAccountRepo{
  3248. byGroup: map[int64][]Account{
  3249. 91: {},
  3250. 92: {},
  3251. },
  3252. },
  3253. }
  3254. account := newCodexModelsTestAccount()
  3255. groupA := &Group{ID: 91, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-a"}}}
  3256. groupB := &Group{ID: 92, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-b"}}}
  3257. begin := make(chan struct{})
  3258. type result struct {
  3259. slugs []string
  3260. err error
  3261. }
  3262. results := make(chan result, 2)
  3263. for _, group := range []*Group{groupA, groupB} {
  3264. go func(g *Group) {
  3265. <-begin
  3266. manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
  3267. if err == nil {
  3268. err = s.MergeGroupConfiguredCodexModels(context.Background(), g, manifest, "")
  3269. }
  3270. slugs := []string{}
  3271. if err == nil {
  3272. slugs = codexManifestModelSlugs(t, manifest.Body)
  3273. }
  3274. results <- result{slugs: slugs, err: err}
  3275. }(group)
  3276. }
  3277. close(begin)
  3278. select {
  3279. case <-started:
  3280. case <-time.After(time.Second):
  3281. t.Fatal("upstream request did not start")
  3282. }
  3283. close(release)
  3284. got := map[int64][]string{91: nil, 92: nil}
  3285. for i := 0; i < 2; i++ {
  3286. r := <-results
  3287. require.NoError(t, r.err)
  3288. if len(r.slugs) == 1 && r.slugs[0] == "model-a" {
  3289. got[91] = r.slugs
  3290. } else if len(r.slugs) == 1 && r.slugs[0] == "model-b" {
  3291. got[92] = r.slugs
  3292. } else {
  3293. t.Fatalf("unexpected filtered slugs: %v", r.slugs)
  3294. }
  3295. }
  3296. require.Equal(t, []string{"model-a"}, got[91])
  3297. require.Equal(t, []string{"model-b"}, got[92])
  3298. require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求")
  3299. }