| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
- "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
- "github.com/stretchr/testify/require"
- "golang.org/x/net/http2"
- )
- type codexModelsHTTPUpstreamStub struct {
- do func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error)
- }
- type codexModelsVisibilityAccountRepo struct {
- AccountRepository
- byGroup map[int64][]Account
- }
- func (r codexModelsVisibilityAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
- accounts := r.byGroup[groupID]
- return append([]Account(nil), accounts...), nil
- }
- func (r codexModelsVisibilityAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
- if groupID == nil {
- return nil, nil
- }
- accounts := r.byGroup[*groupID]
- return append([]Account(nil), accounts...), nil
- }
- type countingCodexModelsAccountRepo struct {
- AccountRepository
- accounts []Account
- err error
- availabilityErr error
- groupID *int64
- platforms []string
- includeGrouped bool
- calls atomic.Int32
- }
- func (r *countingCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]Account, error) {
- r.calls.Add(1)
- if r.err != nil {
- return nil, r.err
- }
- return append([]Account(nil), r.accounts...), nil
- }
- func (r *countingCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, platforms []string, includeGrouped bool) ([]Account, error) {
- if groupID != nil {
- value := *groupID
- r.groupID = &value
- }
- r.platforms = append([]string(nil), platforms...)
- r.includeGrouped = includeGrouped
- if r.availabilityErr != nil {
- return nil, r.availabilityErr
- }
- return append([]Account(nil), r.accounts...), nil
- }
- type splitCodexModelsAccountRepo struct {
- AccountRepository
- schedulable map[int64][]Account
- catalog map[int64][]Account
- all map[int64][]Account
- }
- func (r splitCodexModelsAccountRepo) ListSchedulableByGroupID(_ context.Context, groupID int64) ([]Account, error) {
- return append([]Account(nil), r.schedulable[groupID]...), nil
- }
- func (r splitCodexModelsAccountRepo) ListByGroup(_ context.Context, groupID int64) ([]Account, error) {
- accounts := r.all[groupID]
- if accounts == nil {
- accounts = r.catalog[groupID]
- }
- return append([]Account(nil), accounts...), nil
- }
- func (r splitCodexModelsAccountRepo) ListModelAvailabilityCandidates(_ context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
- if groupID == nil {
- return nil, nil
- }
- return append([]Account(nil), r.catalog[*groupID]...), nil
- }
- func newCodexCatalogMappedAccount(
- id int64,
- target string,
- displayName string,
- levels []string,
- modalities []string,
- contextWindow int64,
- schedulable bool,
- extraMapping map[string]any,
- ) Account {
- reasoning := true
- mapping := map[string]any{"my-coder": target}
- for key, value := range extraMapping {
- mapping[key] = value
- }
- account := Account{
- ID: id,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Status: StatusActive,
- Schedulable: schedulable,
- Credentials: map[string]any{
- "base_url": fmt.Sprintf("https://provider-%d.example/v1", id),
- "model_mapping": mapping,
- },
- }
- models := map[string]UpstreamModelMetadata{
- target: {
- ID: target,
- DisplayName: displayName,
- Description: displayName + " upstream",
- Reasoning: &reasoning,
- SupportedReasoningLevels: levels,
- InputModalities: modalities,
- ContextWindow: contextWindow,
- },
- }
- for _, value := range extraMapping {
- exclusive, _ := value.(string)
- if exclusive == "" || exclusive == target {
- continue
- }
- models[exclusive] = UpstreamModelMetadata{
- ID: exclusive,
- DisplayName: "Exclusive Model",
- Description: "Only mapped on the unschedulable account",
- Reasoning: &reasoning,
- SupportedReasoningLevels: []string{"high"},
- InputModalities: []string{"text", "image"},
- ContextWindow: 1_000_000,
- }
- }
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: models})
- return account
- }
- func TestFilterCodexModelIDsForGroupOmitsWildcardKeys(t *testing.T) {
- t.Parallel()
- got := FilterCodexModelIDsForGroup(
- []string{"deepseek-v4-pro", "foo-*", " bar-* ", "gpt-5.5"},
- &Group{Platform: PlatformDeepseek},
- )
- require.Equal(t, []string{"deepseek-v4-pro", "gpt-5.5"}, got)
- }
- func decodeCodexManifestModels(t *testing.T, body []byte) []map[string]any {
- t.Helper()
- var envelope struct {
- Models []map[string]any `json:"models"`
- }
- require.NoError(t, json.Unmarshal(body, &envelope))
- return envelope.Models
- }
- func codexManifestModelSlugs(t *testing.T, body []byte) []string {
- t.Helper()
- models := decodeCodexManifestModels(t, body)
- slugs := make([]string, 0, len(models))
- for _, model := range models {
- slug, ok := model["slug"].(string)
- require.True(t, ok)
- slugs = append(slugs, slug)
- }
- return slugs
- }
- func requireCompleteConfiguredCodexModel(t *testing.T, model map[string]any, slug string) {
- t.Helper()
- require.Equal(t, slug, model["slug"])
- require.NotEmpty(t, model["display_name"])
- require.NotEmpty(t, model["description"])
- require.Equal(t, "unified_exec", model["shell_type"])
- require.Equal(t, "list", model["visibility"])
- require.Equal(t, true, model["supported_in_api"])
- require.NotNil(t, model["priority"])
- require.Equal(t, []any{}, model["additional_speed_tiers"])
- require.IsType(t, []any{}, model["service_tiers"])
- require.Contains(t, model, "default_service_tier")
- require.Contains(t, model, "availability_nux")
- require.Contains(t, model, "upgrade")
- require.Contains(t, model, "default_verbosity")
- require.Contains(t, model, "apply_patch_tool_type")
- require.Contains(t, model, "auto_compact_token_limit")
- require.Contains(t, model, "comp_hash")
- require.Contains(t, model, "auto_review_model_override")
- require.Contains(t, model, "model_specialty")
- require.Contains(t, model, "tool_mode")
- require.Contains(t, model, "multi_agent_version")
- require.Equal(t, true, model["supports_reasoning_summary_parameter"])
- require.Contains(t, model, "include_skills_usage_instructions")
- require.Contains(t, model, "include_plugin_usage_instructions")
- require.Contains(t, model, "include_apps_usage_instructions")
- require.Contains(t, model, "supports_image_detail_original")
- require.Contains(t, model, "node_repl_auto_review_required")
- require.Contains(t, model, "node_repl_disabled")
- require.Contains(t, model, "truncation_policy")
- require.Contains(t, model, "supports_parallel_tool_calls")
- require.Contains(t, model, "experimental_supported_tools")
- modelMessages, ok := model["model_messages"].(map[string]any)
- require.True(t, ok)
- require.NotEmpty(t, modelMessages["instructions_template"])
- for _, key := range []string{
- "instructions_variables",
- "approvals",
- "collaboration_modes",
- "auto_review",
- "permissions",
- "multi_agent",
- "token_budget",
- "guardian_v2",
- } {
- require.Contains(t, modelMessages, key)
- }
- }
- func effortsFromManifestModel(t *testing.T, model map[string]any) []string {
- t.Helper()
- levels, ok := model["supported_reasoning_levels"].([]any)
- require.True(t, ok)
- efforts := make([]string, 0, len(levels))
- for _, rawLevel := range levels {
- level, ok := rawLevel.(map[string]any)
- require.True(t, ok)
- effort, ok := level["effort"].(string)
- require.True(t, ok)
- efforts = append(efforts, effort)
- }
- return efforts
- }
- func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t *testing.T) {
- t.Parallel()
- deepSeek := newConfiguredCodexModelDescriptor("deepseek-v4-pro")
- require.Equal(t, "DeepSeek V4 Pro", deepSeek.DisplayName)
- require.Equal(t, int64(1_000_000), deepSeek.ContextWindow)
- require.Equal(t, int64(1_000_000), deepSeek.MaxContextWindow)
- require.NotNil(t, deepSeek.DefaultReasoningLevel)
- require.Equal(t, "high", *deepSeek.DefaultReasoningLevel)
- require.Equal(t, []configuredCodexReasoningLevel{
- {Effort: "low", Description: "Fast responses with lighter reasoning"},
- {Effort: "high", Description: "Greater reasoning depth for coding and agent tasks"},
- {Effort: "max", Description: "Maximum reasoning depth for complex tasks"},
- }, deepSeek.SupportedReasoningLevels)
- require.True(t, deepSeek.SupportsParallelToolCalls)
- require.Equal(t, []string{"text"}, deepSeek.InputModalities)
- grok := newConfiguredCodexModelDescriptor("grok-4.6")
- require.Equal(t, "Grok 4.6", grok.DisplayName)
- require.Equal(t, int64(500_000), grok.ContextWindow)
- require.Equal(t, int64(500_000), grok.MaxContextWindow)
- require.NotNil(t, grok.DefaultReasoningLevel)
- require.Equal(t, "high", *grok.DefaultReasoningLevel)
- require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grok.SupportedReasoningLevels))
- require.True(t, grok.SupportsParallelToolCalls)
- require.Equal(t, []string{"text"}, grok.InputModalities)
- require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
- require.NotContains(t, grok.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
- grokAlias := newConfiguredCodexModelDescriptor("xai/grok-4.6-latest")
- require.Equal(t, "Grok 4.6", grokAlias.DisplayName)
- require.NotNil(t, grokAlias.DefaultReasoningLevel)
- require.Equal(t, "high", *grokAlias.DefaultReasoningLevel)
- require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(grokAlias.SupportedReasoningLevels))
- grok45 := newConfiguredCodexModelDescriptor("grok-4.5")
- require.Equal(t, []string{"low", "medium", "high"}, effortsFromConfiguredCodexLevels(grok45.SupportedReasoningLevels))
- grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning")
- require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName)
- require.NotNil(t, grokNonReasoning.DefaultReasoningLevel)
- require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel)
- require.Equal(t, []configuredCodexReasoningLevel{
- {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
- }, grokNonReasoning.SupportedReasoningLevels)
- claude := newConfiguredCodexModelDescriptor("claude-opus-4-6")
- require.Equal(t, "Claude Opus 4.6", claude.DisplayName)
- require.NotNil(t, claude.DefaultReasoningLevel)
- require.Equal(t, "medium", *claude.DefaultReasoningLevel)
- require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(claude.SupportedReasoningLevels))
- require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "xhigh"})
- require.NotContains(t, claude.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
- require.True(t, claude.SupportsParallelToolCalls)
- claudeOpus5 := newConfiguredCodexModelDescriptor("claude-opus-5")
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(claudeOpus5.SupportedReasoningLevels))
- providerQualifiedClaude := newConfiguredCodexModelDescriptor("anthropic/claude-sonnet-4-6")
- require.Equal(t, "Claude Sonnet 4.6", providerQualifiedClaude.DisplayName)
- require.Equal(t, []string{"low", "medium", "high", "max"}, effortsFromConfiguredCodexLevels(providerQualifiedClaude.SupportedReasoningLevels))
- claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001")
- require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName)
- require.NotNil(t, claudeHaiku.DefaultReasoningLevel)
- require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel)
- require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels))
- gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol")
- require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName)
- require.Equal(t, "OpenAI GPT coding model routed through Sub2API.", gpt56.Description)
- require.NotNil(t, gpt56.DefaultReasoningLevel)
- require.Equal(t, "low", *gpt56.DefaultReasoningLevel)
- require.Equal(t, configuredCodexGPTReasoningLevels("gpt-5.6-sol"), gpt56.SupportedReasoningLevels)
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt56.SupportedReasoningLevels))
- require.True(t, gpt56.SupportsParallelToolCalls)
- require.True(t, gpt56.SupportVerbosity)
- require.Equal(t, []string{"text"}, gpt56.InputModalities)
- require.Equal(t, int64(872_000), gpt56.MaxContextWindow)
- require.Equal(t, configuredCodexTruncationPolicy{Mode: "tokens", Limit: 10_000}, gpt56.TruncationPolicy)
- require.NotNil(t, gpt56.DefaultVerbosity)
- require.Equal(t, "low", *gpt56.DefaultVerbosity)
- require.True(t, gpt56.SupportsReasoningSummaryParameter)
- require.Equal(t, "none", gpt56.DefaultReasoningSummary)
- gpt56Luna := newConfiguredCodexModelDescriptor("gpt-5.6-luna")
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels))
- require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel)
- gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra")
- require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName)
- require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6Astra.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6."))
- require.NotNil(t, gpt6Astra.DefaultReasoningLevel)
- require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel)
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels))
- require.NotNil(t, gpt6Astra.MultiAgentReasoningEffort)
- require.Equal(t, "xhigh", *gpt6Astra.MultiAgentReasoningEffort)
- require.Equal(t, "v2", gpt6Astra.MultiAgentVersion)
- require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
- require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra"))
- require.Equal(t, []configuredCodexServiceTier{{
- ID: "priority",
- Name: "Fast",
- Description: "Priority processing for lower latency.",
- }}, gpt6Astra.ServiceTiers)
- require.True(t, isOpenAICodexImageInputModel("gpt-6-astra"))
- require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra"))
- require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01"))
- require.False(t, isOpenAIGPT6AstraModel("gpt-6-other"))
- require.Equal(t, int64(1_050_000), gpt6Astra.ContextWindow)
- require.Equal(t, int64(1_050_000), gpt6Astra.MaxContextWindow)
- gpt6 := newConfiguredCodexModelDescriptor("gpt-6")
- require.Equal(t, "GPT-6 (Astra)", gpt6.DisplayName)
- require.True(t, strings.HasPrefix(strings.TrimSpace(gpt6.ModelMessages.InstructionsTemplate), "You are Codex, an agent based on GPT-6."))
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max", "ultra"}, effortsFromConfiguredCodexLevels(gpt6.SupportedReasoningLevels))
- require.NotNil(t, gpt6.MultiAgentReasoningEffort)
- require.Equal(t, "xhigh", *gpt6.MultiAgentReasoningEffort)
- require.Equal(t, int64(1_050_000), gpt6.ContextWindow)
- gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5")
- require.Equal(t, "GPT-5.5", gpt55.DisplayName)
- require.NotNil(t, gpt55.DefaultReasoningLevel)
- require.Equal(t, "medium", *gpt55.DefaultReasoningLevel)
- require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromConfiguredCodexLevels(gpt55.SupportedReasoningLevels))
- require.NotContains(t, gpt55.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "max"})
- require.NotNil(t, gpt55.DefaultVerbosity)
- require.Equal(t, "low", *gpt55.DefaultVerbosity)
- gpt4o := newConfiguredCodexModelDescriptor("gpt-4o")
- require.Equal(t, "gpt-4o", gpt4o.DisplayName)
- require.NotNil(t, gpt4o.DefaultReasoningLevel)
- require.Equal(t, "none", *gpt4o.DefaultReasoningLevel)
- require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels))
- require.True(t, gpt4o.SupportsParallelToolCalls)
- image := newConfiguredCodexModelDescriptor("gpt-image-2")
- require.Equal(t, "gpt-image-2", image.DisplayName)
- require.NotNil(t, image.DefaultReasoningLevel)
- require.Equal(t, "none", *image.DefaultReasoningLevel)
- require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels))
- custom := newConfiguredCodexModelDescriptor("company-coding-model")
- require.Equal(t, "company-coding-model", custom.DisplayName)
- require.Equal(t, int64(272_000), custom.ContextWindow)
- require.NotNil(t, custom.DefaultReasoningLevel)
- require.Equal(t, "none", *custom.DefaultReasoningLevel)
- require.Equal(t, []configuredCodexReasoningLevel{
- {Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
- }, custom.SupportedReasoningLevels)
- require.False(t, custom.SupportsParallelToolCalls)
- require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate)
- require.Equal(t, "auto", custom.DefaultReasoningSummary)
- require.Equal(t, configuredCodexTruncationPolicy{Mode: "bytes", Limit: 10_000}, custom.TruncationPolicy)
- }
- func TestBuildCodexModelsManifestUsesGPT6AstraInstructions(t *testing.T) {
- body, err := BuildCodexModelsManifest([]string{"gpt-6-astra"})
- require.NoError(t, err)
- var manifest struct {
- Models []struct {
- ModelMessages struct {
- InstructionsTemplate string `json:"instructions_template"`
- } `json:"model_messages"`
- } `json:"models"`
- }
- require.NoError(t, json.Unmarshal(body, &manifest))
- require.Len(t, manifest.Models, 1)
- require.True(t, strings.HasPrefix(
- strings.TrimSpace(manifest.Models[0].ModelMessages.InstructionsTemplate),
- "You are Codex, an agent based on GPT-6.",
- ))
- }
- func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []string {
- efforts := make([]string, 0, len(levels))
- for _, level := range levels {
- efforts = append(efforts, level.Effort)
- }
- return efforts
- }
- // Scenario: 无推理模型可直接选中。
- func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) {
- t.Parallel()
- body, err := BuildCodexModelsManifest([]string{"company-coding-model"})
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, "none", models[0]["default_reasoning_level"])
- levels, ok := models[0]["supported_reasoning_levels"].([]any)
- require.True(t, ok)
- require.Len(t, levels, 1)
- firstLevel, ok := levels[0].(map[string]any)
- require.True(t, ok)
- require.Equal(t, "none", firstLevel["effort"])
- }
- // Scenario: 已知推理模型保留真实档位。
- func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) {
- t.Parallel()
- body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"})
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, "low", models[0]["default_reasoning_level"])
- levels, ok := models[0]["supported_reasoning_levels"].([]any)
- require.True(t, ok)
- require.Len(t, levels, 6)
- firstLevel, ok := levels[0].(map[string]any)
- require.True(t, ok)
- require.NotEqual(t, "none", firstLevel["effort"])
- }
- // Scenario: 支持 Fast 的 GPT 型号在目录中声明 priority service tier。
- func TestBuildCodexModelsManifestAdvertisesPriorityServiceTierForFastGPTModels(t *testing.T) {
- t.Parallel()
- body, err := BuildCodexModelsManifest([]string{
- "gpt-5.4-mini",
- "gpt-5.5",
- "gpt-5.6-terra",
- })
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 3)
- for _, model := range models {
- require.Equal(t, []any{
- map[string]any{
- "id": "priority",
- "name": "Fast",
- "description": "Priority processing for lower latency.",
- },
- }, model["service_tiers"])
- require.Nil(t, model["default_service_tier"])
- }
- }
- // Scenario: GPT-5.6 Sol 在 Fast 之外额外声明 ultrafast service tier。
- func TestBuildCodexModelsManifestAdvertisesUltrafastServiceTierForSol(t *testing.T) {
- t.Parallel()
- body, err := BuildCodexModelsManifest([]string{
- "gpt-5.6-sol",
- "gpt-5.6",
- })
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 2)
- wantTiers := []any{
- map[string]any{
- "id": "priority",
- "name": "Fast",
- "description": "Priority processing for lower latency.",
- },
- map[string]any{
- "id": "ultrafast",
- "name": "Ultrafast",
- "description": "Ultra-low latency processing.",
- },
- }
- for _, model := range models {
- require.Equal(t, wantTiers, model["service_tiers"])
- require.Nil(t, model["default_service_tier"])
- }
- }
- // Scenario: 未明确支持 Fast 的型号不推测 service tier。
- func TestBuildCodexModelsManifestLeavesServiceTiersEmptyForOtherModels(t *testing.T) {
- t.Parallel()
- body, err := BuildCodexModelsManifest([]string{
- "gpt-4o",
- "claude-opus-4-6",
- "deepseek-v4-pro",
- "company-coding-model",
- })
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 4)
- for _, model := range models {
- require.Equal(t, []any{}, model["service_tiers"])
- require.Nil(t, model["default_service_tier"])
- }
- }
- // Scenario: 专用图片生成模型不进入 Codex 主模型目录。
- func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) {
- t.Parallel()
- body, err := BuildCodexModelsManifest([]string{
- "grok-4.6",
- "gpt-image-1",
- "gpt-image-1.5",
- "gpt-image-2",
- "openai/gpt-image-2",
- "gemini-2.5-flash-image",
- "gemini-3.1-flash-image-preview",
- "gemini-3-pro-image",
- "google/gemini-3-pro-image",
- "google/models/gemini-2.5-flash-image-preview",
- "grok-imagine-image",
- "grok-imagine-video",
- "xai/grok-imagine-image-quality",
- "grok-4.5",
- })
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- slugs := make([]string, 0, len(models))
- for _, model := range models {
- slug, _ := model["slug"].(string)
- slugs = append(slugs, slug)
- }
- require.Equal(t, []string{"grok-4.6", "grok-4.5"}, slugs)
- }
- func TestBuildCodexModelsManifestForGroupAdvertisesOfficialGrokResponsesImageInput(t *testing.T) {
- t.Parallel()
- const groupID int64 = 701
- svc := &GatewayService{
- accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {{
- ID: 1,
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "token",
- },
- }},
- }},
- }
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"grok-4.5"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- require.Equal(t, "Grok 4.5", models[0]["display_name"])
- require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
- }
- func TestBuildCodexModelsManifestForGroupAdvertisesOfficialOpenAIResponsesImageInput(t *testing.T) {
- t.Parallel()
- const groupID int64 = 702
- svc := &GatewayService{
- accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {{
- ID: 2,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- }},
- }},
- }
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"gpt-5.6-sol"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- }
- func TestBuildCodexModelsManifestForGroupUsesProviderImageCapabilities(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- model string
- accounts []Account
- modalities []any
- }{
- {
- name: "official Grok 4.6",
- model: "grok-4.6",
- accounts: []Account{{
- ID: 10, Platform: PlatformGrok, Type: AccountTypeOAuth,
- }},
- modalities: []any{"text", "image"},
- },
- {
- name: "official Grok Build vision host",
- model: "grok-build-0.1",
- accounts: []Account{{
- ID: 11, Platform: PlatformGrok, Type: AccountTypeOAuth,
- }},
- modalities: []any{"text", "image"},
- },
- {
- name: "official Grok 4.20 vision model",
- model: "grok-4.20-0309-reasoning",
- accounts: []Account{{
- ID: 23, Platform: PlatformGrok, Type: AccountTypeOAuth,
- }},
- modalities: []any{"text", "image"},
- },
- {
- name: "Grok 3 Mini is text only",
- model: "grok-3-mini",
- accounts: []Account{{
- ID: 24, Platform: PlatformGrok, Type: AccountTypeOAuth,
- }},
- modalities: []any{"text"},
- },
- {
- name: "Grok Composer has only Chat image bridge",
- model: "grok-composer-2.5-fast",
- accounts: []Account{{
- ID: 12, Platform: PlatformGrok, Type: AccountTypeOAuth,
- }},
- modalities: []any{"text"},
- },
- {
- name: "custom Grok host",
- model: "grok-4.5",
- accounts: []Account{{
- ID: 13, Platform: PlatformGrok, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
- }},
- modalities: []any{"text"},
- },
- {
- name: "malformed Grok host",
- model: "grok-4.5",
- accounts: []Account{{
- ID: 19, Platform: PlatformGrok, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"base_url": "::invalid::url"},
- }},
- modalities: []any{"text"},
- },
- {
- name: "mixed official and custom Grok candidates",
- model: "grok-4.5",
- accounts: []Account{
- {ID: 14, Platform: PlatformGrok, Type: AccountTypeOAuth},
- {
- ID: 15, Platform: PlatformGrok, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"base_url": "https://relay.example.test/v1"},
- },
- },
- modalities: []any{"text"},
- },
- {
- name: "DeepSeek V4",
- model: "deepseek-v4-pro",
- accounts: []Account{{
- ID: 16, Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
- }},
- modalities: []any{"text"},
- },
- {
- name: "official OpenAI API key",
- model: "gpt-5.6-sol",
- accounts: []Account{{
- ID: 17, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- }},
- modalities: []any{"text", "image"},
- },
- {
- name: "official OpenAI legacy text model",
- model: "gpt-3.5-turbo",
- accounts: []Account{{
- ID: 20, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- }},
- modalities: []any{"text"},
- },
- {
- name: "custom OpenAI-compatible host",
- model: "gpt-5.6-sol",
- accounts: []Account{{
- ID: 18, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
- }},
- modalities: []any{"text", "image"},
- },
- {
- name: "custom OpenAI-compatible host with unknown model",
- model: "company-coding-model",
- accounts: []Account{{
- ID: 29, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
- }},
- modalities: []any{"text"},
- },
- }
- for i, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- groupID := int64(710 + i)
- svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: tt.accounts,
- }}}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{tt.model},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, tt.modalities, models[0]["input_modalities"])
- })
- }
- }
- func TestBuildCodexModelsManifestForGroupPrefersSyncedOpenAIImageCapabilities(t *testing.T) {
- t.Parallel()
- newAccount := func(id int64, modalities []string) Account {
- account := Account{
- ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"base_url": "https://openai-compatible.example.test/v1"},
- }
- if modalities != nil {
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "gpt-5.6-sol": {ID: "gpt-5.6-sol", InputModalities: modalities},
- }})
- }
- return account
- }
- tests := []struct {
- name string
- accounts []Account
- modalities []any
- }{
- {
- name: "explicit text-only snapshot narrows local fallback",
- accounts: []Account{
- newAccount(30, nil),
- newAccount(31, []string{"text"}),
- },
- modalities: []any{"text"},
- },
- {
- name: "explicit multimodal snapshot preserves local fallback",
- accounts: []Account{
- newAccount(32, nil),
- newAccount(33, []string{"text", "image"}),
- },
- modalities: []any{"text", "image"},
- },
- }
- for i, tt := range tests {
- tt := tt
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- groupID := int64(760 + i)
- svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: tt.accounts,
- }}}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- "",
- []string{"gpt-5.6-sol"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, tt.modalities, models[0]["input_modalities"])
- })
- }
- }
- func TestBuildCodexModelsManifestForGroupUsesExplicitCompositeResponsesRouteModel(t *testing.T) {
- t.Parallel()
- const groupID int64 = 730
- routeRepo := compositeRouteRepoStub{routes: []CompositeModelRoute{{
- ID: 1,
- GroupID: groupID,
- PublicModel: "vision-alias",
- MatchType: CompositeRouteMatchExact,
- TargetPlatform: PlatformGrok,
- UpstreamModel: "grok-4.5",
- Endpoint: CompositeRouteEndpointResponses,
- Enabled: true,
- }}}
- svc := &GatewayService{
- accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {{ID: 20, Platform: PlatformGrok, Type: AccountTypeOAuth}},
- }},
- compositeResolver: NewCompositeRouteResolver(routeRepo),
- }
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"vision-alias"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- }
- func TestBuildCodexModelsManifestForGroupUsesAccountMappingOwnershipAndMappedModel(t *testing.T) {
- t.Parallel()
- const groupID int64 = 731
- svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {{
- ID: 21,
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"vision-alias": "grok-4.5"},
- },
- }},
- }}}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"vision-alias"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- }
- // Scenario: a Composite exact alias inherits metadata from its unique mapped target model.
- func TestBuildCodexModelsManifestForGroupUsesMappedTargetMetadataForCompositeAlias(t *testing.T) {
- t.Parallel()
- const groupID int64 = 733
- svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {{
- ID: 23,
- Platform: PlatformAnthropic,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
- },
- }},
- }}}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"reasoning-alias"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, "reasoning-alias", models[0]["slug"])
- require.Equal(t, "reasoning-alias", models[0]["display_name"])
- require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
- require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromManifestModel(t, models[0]))
- }
- // Scenario: conflicting targets on the same platform keep the public alias but do not guess capabilities.
- func TestBuildCodexModelsManifestForGroupUsesSafeFallbackForConflictingAliasTargets(t *testing.T) {
- t.Parallel()
- const groupID int64 = 734
- svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {
- {
- ID: 24,
- Platform: PlatformAnthropic,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"shared-alias": "claude-opus-4-8"},
- },
- },
- {
- ID: 25,
- Platform: PlatformAnthropic,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"shared-alias": "claude-haiku-4-5-20251001"},
- },
- },
- },
- }}}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"shared-alias"},
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, "shared-alias", models[0]["slug"])
- require.Equal(t, "shared-alias", models[0]["display_name"])
- require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
- require.Empty(t, effortsFromManifestModel(t, models[0]))
- }
- // Scenario: a media-only target remains hidden even when exposed through an ordinary alias.
- func TestBuildCodexModelsManifestForGroupOmitsDedicatedMediaTargetAlias(t *testing.T) {
- t.Parallel()
- const groupID int64 = 735
- svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
- groupID: {{
- ID: 26,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"creative-alias": "gpt-image-2"},
- },
- }},
- }}}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"creative-alias"},
- )
- require.NoError(t, err)
- require.Empty(t, decodeCodexManifestModels(t, body))
- }
- func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
- t.Parallel()
- const groupID int64 = 732
- repo := &countingCodexModelsAccountRepo{accounts: []Account{{
- ID: 22,
- Platform: PlatformGrok,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "vision-alias-a": "grok-4.5",
- "vision-alias-b": "grok-4.6",
- },
- },
- }}}
- svc := &GatewayService{accountRepo: repo}
- _, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformComposite},
- "",
- []string{"vision-alias-a", "vision-alias-b", "deepseek-v4-pro"},
- )
- require.NoError(t, err)
- require.Equal(t, int32(1), repo.calls.Load())
- require.NotNil(t, repo.groupID)
- require.Equal(t, groupID, *repo.groupID)
- require.False(t, repo.includeGrouped)
- require.Contains(t, repo.platforms, PlatformOpenAI)
- require.Contains(t, repo.platforms, PlatformGrok)
- require.Contains(t, repo.platforms, PlatformDeepseek)
- require.Contains(t, repo.platforms, PlatformMiniMax)
- require.NotContains(t, repo.platforms, PlatformComposite)
- }
- func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) {
- t.Parallel()
- repo := &countingCodexModelsAccountRepo{}
- svc := &GatewayService{accountRepo: repo}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: 733, Platform: PlatformDeepseek},
- "",
- []string{"deepseek-v4-pro"},
- )
- require.NoError(t, err)
- require.Equal(t, int32(1), repo.calls.Load())
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 1)
- require.Equal(t, []any{"text"}, models[0]["input_modalities"])
- }
- func TestBuildCodexModelsManifestForGroupFallsBackWhenCapabilityLookupFails(t *testing.T) {
- t.Parallel()
- repo := &countingCodexModelsAccountRepo{err: errors.New("account repository unavailable")}
- svc := &GatewayService{accountRepo: repo}
- body, err := svc.BuildCodexModelsManifestForGroup(
- context.Background(),
- &Group{ID: 734, Platform: PlatformComposite},
- "",
- []string{"gpt-5.6-sol", "grok-4.5"},
- )
- require.NoError(t, err)
- require.Equal(t, int32(1), repo.calls.Load())
- models := decodeCodexManifestModels(t, body)
- require.Len(t, models, 2)
- require.Equal(t, []any{"text"}, models[0]["input_modalities"])
- require.Equal(t, []any{"text"}, models[1]["input_modalities"])
- }
- func TestMergeGroupConfiguredCodexModelsInjectsCurrentGroupAliases(t *testing.T) {
- t.Parallel()
- const groupID int64 = 71
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- groupID: {
- {
- Platform: PlatformOpenAI,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "deepseek-4-pro": "deepseek-v4-pro",
- },
- },
- },
- },
- 72: {
- {
- Platform: PlatformOpenAI,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"other-group-model": "upstream-model"},
- },
- },
- },
- },
- }}
- manifest := &OpenAIModelsResponse{
- Body: []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6","unknown":{"kept":true}}],"metadata":{"version":1}}`),
- }
- err := svc.MergeGroupConfiguredCodexModels(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- manifest,
- "",
- )
- require.NoError(t, err)
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 2)
- require.Equal(t, "gpt-5.6", models[0]["slug"])
- require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
- requireCompleteConfiguredCodexModel(t, models[1], "deepseek-4-pro")
- require.EqualValues(t, 1_000_000, models[1]["context_window"])
- require.EqualValues(t, 1_000_000, models[1]["max_context_window"])
- require.Equal(t, "high", models[1]["default_reasoning_level"])
- require.Len(t, models[1]["supported_reasoning_levels"], 3)
- require.NotContains(t, string(manifest.Body), "other-group-model")
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- }
- // Mixed groups retain configured metadata alongside defaults for unmapped accounts.
- func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t *testing.T) {
- t.Parallel()
- const groupID int64 = 77
- reasoning := true
- arkAccount := Account{
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "glm-5.3": "glm-5.3",
- "gpt-image-2": "gpt-image-2",
- },
- },
- }
- arkAccount.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "glm-5.3": {
- ID: "glm-5.3",
- DisplayName: "GLM 5.3",
- Description: "Ark coding model",
- Reasoning: &reasoning,
- DefaultReasoningLevel: "medium",
- SupportedReasoningLevels: []string{"low", "medium", "high"},
- InputModalities: []string{"text"},
- ContextWindow: 1_000_000,
- },
- }})
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- groupID: {
- {
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- },
- arkAccount,
- },
- },
- }}
- group := &Group{ID: groupID, Platform: PlatformOpenAI}
- manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
- require.NoError(t, err)
- require.True(t, configured)
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Equal(t, "glm-5.3", models[0]["slug"])
- require.Contains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-5.6-sol")
- require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "gpt-image-2")
- require.NotContains(t, codexManifestModelSlugs(t, manifest.Body), "codex-auto-review")
- require.Equal(t, "GLM 5.3", models[0]["display_name"])
- require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
- require.Equal(t, "medium", models[0]["default_reasoning_level"])
- require.Equal(t, []any{"text"}, models[0]["input_modalities"])
- require.EqualValues(t, 1_000_000, models[0]["context_window"])
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- notModified, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
- context.Background(),
- group,
- "W/"+manifest.ETag,
- )
- require.NoError(t, err)
- require.True(t, configured)
- require.True(t, notModified.NotModified)
- require.Empty(t, notModified.Body)
- require.Equal(t, manifest.ETag, notModified.ETag)
- }
- // Scenario: OpenAI 通配映射展开组内精确选择,但不发布通配符 slug。
- func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWildcardMapping(t *testing.T) {
- t.Parallel()
- const groupID int64 = 80
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- groupID: {{
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{
- "model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"},
- },
- }},
- },
- }}
- group := &Group{
- ID: groupID,
- Platform: PlatformOpenAI,
- ModelAllowlist: GroupModelAllowlist{
- Enabled: true,
- Models: []string{"gpt-5.6"},
- },
- }
- manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
- require.NoError(t, err)
- require.True(t, configured)
- require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
- require.NotContains(t, string(manifest.Body), "gpt-*")
- }
- // Scenario: OpenAI 配置目录对仅因瞬态状态退出当前调度池的账号取能力交集,
- // 且不发布其独有模型。持久 schedulable 仍为 true。
- func TestBuildGroupConfiguredCodexModelsManifestIntersectsTransientlyUnschedulableMappedAccounts(t *testing.T) {
- t.Parallel()
- const groupID int64 = 79
- schedulable := newCodexCatalogMappedAccount(
- 41,
- "gpt-5.6-sol",
- "GPT-5.6 Sol",
- []string{"low", "medium", "high", "xhigh"},
- []string{"text", "image"},
- 1_000_000,
- true,
- nil,
- )
- transientlyUnschedulable := newCodexCatalogMappedAccount(
- 42,
- "glm-5.3",
- "GLM 5.3",
- []string{"low", "medium", "high"},
- []string{"text"},
- 272_000,
- true,
- map[string]any{"exclusive-model": "exclusive-upstream"},
- )
- svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
- schedulable: map[int64][]Account{groupID: {schedulable}},
- catalog: map[int64][]Account{groupID: {schedulable, transientlyUnschedulable}},
- }}
- manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- "",
- )
- require.NoError(t, err)
- require.True(t, configured)
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- require.Equal(t, "my-coder", models[0]["slug"])
- require.Equal(t, "my-coder", models[0]["display_name"])
- require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
- require.Equal(t, []any{"text"}, models[0]["input_modalities"])
- require.EqualValues(t, 272_000, models[0]["context_window"])
- }
- // Scenario: 管理员持久禁用的账号不能继续收窄 Codex 能力目录。
- func TestBuildGroupConfiguredCodexModelsManifestIgnoresPersistentlyDisabledMappedAccounts(t *testing.T) {
- t.Parallel()
- const groupID int64 = 80
- enabled := newCodexCatalogMappedAccount(
- 51,
- "gpt-5.6-sol",
- "GPT-5.6 Sol",
- []string{"low", "medium", "high", "xhigh"},
- []string{"text", "image"},
- 1_000_000,
- true,
- nil,
- )
- disabled := newCodexCatalogMappedAccount(
- 52,
- "glm-5.3",
- "GLM 5.3",
- []string{"low", "medium", "high"},
- []string{"text"},
- 272_000,
- false,
- nil,
- )
- svc := &OpenAIGatewayService{accountRepo: splitCodexModelsAccountRepo{
- schedulable: map[int64][]Account{groupID: {enabled}},
- catalog: map[int64][]Account{groupID: {enabled}},
- all: map[int64][]Account{groupID: {enabled, disabled}},
- }}
- manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- "",
- )
- require.NoError(t, err)
- require.True(t, configured)
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- require.Equal(t, "my-coder", models[0]["slug"])
- require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- require.EqualValues(t, 1_000_000, models[0]["context_window"])
- }
- // Scenario: 没有管理员模型配置时保留现有上游发现路径。
- func TestBuildGroupConfiguredCodexModelsManifestFallsThroughWithoutConfiguration(t *testing.T) {
- t.Parallel()
- const groupID int64 = 78
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- groupID: {{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}},
- },
- }}
- manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- "",
- )
- require.NoError(t, err)
- require.False(t, configured)
- require.Nil(t, manifest)
- }
- func TestMergeGroupConfiguredCodexModelsFiltersAutoReviewByDefault(t *testing.T) {
- t.Parallel()
- const groupID int64 = 74
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
- manifest := &OpenAIModelsResponse{
- 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"}]}`),
- }
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- manifest,
- "",
- ))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- require.Equal(t, "gpt-5.6", models[0]["slug"])
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- }
- // Scenario: OpenAI 账号映射不启用 Auto Review。
- func TestMergeGroupConfiguredCodexModelsFiltersAccountMappedAutoReviewByDefault(t *testing.T) {
- t.Parallel()
- const groupID int64 = 75
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- groupID: {
- {
- Platform: PlatformOpenAI,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- openai.CodexUsageProbeModel: openai.CodexUsageProbeModel,
- },
- },
- },
- },
- },
- }}
- manifest := &OpenAIModelsResponse{
- Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"hide","model_messages":{"auto_review":{"enabled":true}}},{"slug":"gpt-5.6","visibility":"list"}]}`),
- }
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(
- context.Background(),
- &Group{ID: groupID, Platform: PlatformOpenAI},
- manifest,
- "",
- ))
- require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
- }
- // Scenario: 启用的分组自定义列表允许 Auto Review。
- func TestMergeGroupConfiguredCodexModelsKeepsExplicitAutoReviewSelection(t *testing.T) {
- t.Parallel()
- const groupID int64 = 76
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{}}
- manifest := &OpenAIModelsResponse{
- Body: []byte(`{"models":[{"slug":"codex-auto-review","visibility":"list"},{"slug":"gpt-5.6","visibility":"list"}]}`),
- }
- group := &Group{
- ID: groupID,
- Platform: PlatformOpenAI,
- ModelAllowlist: GroupModelAllowlist{
- Enabled: true,
- Models: []string{openai.CodexUsageProbeModel},
- },
- }
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
- require.Equal(t, []string{"codex-auto-review"}, codexManifestModelSlugs(t, manifest.Body))
- }
- func TestMergeGroupConfiguredCodexModelsHonorsCustomListAndFinalETag(t *testing.T) {
- t.Parallel()
- const groupID int64 = 73
- svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- groupID: {
- {
- Platform: PlatformOpenAI,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "deepseek-4-pro": "deepseek-v4-pro",
- "hidden-alias": "hidden-upstream",
- },
- },
- },
- },
- },
- }}
- group := &Group{
- ID: groupID,
- Platform: PlatformOpenAI,
- ModelAllowlist: GroupModelAllowlist{
- Enabled: true,
- Models: []string{"deepseek-4-pro"},
- },
- }
- upstreamBody := []byte(`{"models":[{"slug":"gpt-5.6","display_name":"GPT-5.6"}]}`)
- manifest := &OpenAIModelsResponse{Body: upstreamBody}
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, manifest, ""))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- requireCompleteConfiguredCodexModel(t, models[0], "deepseek-4-pro")
- finalETag := manifest.ETag
- second := &OpenAIModelsResponse{Body: upstreamBody}
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, finalETag))
- require.True(t, second.NotModified)
- require.Empty(t, second.Body)
- require.Equal(t, finalETag, second.ETag)
- }
- type codexModelsBlockingBody struct {
- ctx context.Context
- readStarted chan struct{}
- startedOnce *sync.Once
- release <-chan struct{}
- body *strings.Reader
- }
- func (b *codexModelsBlockingBody) Read(p []byte) (int, error) {
- b.startedOnce.Do(func() { close(b.readStarted) })
- select {
- case <-b.release:
- return b.body.Read(p)
- case <-b.ctx.Done():
- return 0, b.ctx.Err()
- }
- }
- func (b *codexModelsBlockingBody) Close() error { return nil }
- func (s *codexModelsHTTPUpstreamStub) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
- return s.do(req, proxyURL, accountID, accountConcurrency)
- }
- func (s *codexModelsHTTPUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
- return s.Do(req, proxyURL, accountID, accountConcurrency)
- }
- func TestIsRetryableCodexModelsManifestTransportError(t *testing.T) {
- tests := []struct {
- name string
- err error
- retryable bool
- }{
- {name: "nil", err: nil},
- {name: "configuration error", err: errors.New("invalid proxy URL")},
- {name: "upstream configuration error", err: errors.New("upstream error: invalid proxy")},
- {name: "proxy connection configuration error", err: errors.New("proxy connection error: invalid configuration")},
- {name: "canceled request", err: context.Canceled},
- {
- name: "redirect policy error",
- err: &url.Error{
- Op: "Get",
- URL: "https://upstream.example/v1/models",
- Err: errors.New("stopped after 10 redirects"),
- },
- },
- {name: "deadline exceeded", err: context.DeadlineExceeded, retryable: true},
- {name: "unexpected EOF", err: io.ErrUnexpectedEOF, retryable: true},
- {name: "closed connection", err: net.ErrClosed, retryable: true},
- {
- name: "network operation",
- err: &net.OpError{
- Op: "read",
- Net: "tcp",
- Err: errors.New("connection reset"),
- },
- retryable: true,
- },
- {
- name: "DNS error",
- err: &net.DNSError{Err: "temporary failure", Name: "upstream.example"},
- retryable: true,
- },
- {
- name: "typed HTTP2 GOAWAY",
- err: http2.GoAwayError{ErrCode: http2.ErrCodeNo},
- retryable: true,
- },
- {
- name: "stdlib HTTP2 GOAWAY",
- err: errors.New("http2: server sent GOAWAY and closed the connection; LastStreamID=1, ErrCode=NO_ERROR"),
- retryable: true,
- },
- {
- name: "stdlib HTTP2 refused stream",
- err: errors.New("stream error: stream ID 3; REFUSED_STREAM"),
- retryable: true,
- },
- {
- name: "stdlib HTTP2 connection error",
- err: errors.New(`Get "https://upstream.example/v1/models": connection error: PROTOCOL_ERROR`),
- retryable: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := isRetryableCodexModelsManifestTransportError(tt.err); got != tt.retryable {
- t.Fatalf("retryable = %v, want %v", got, tt.retryable)
- }
- })
- }
- }
- func TestIsRetryableCodexModelsManifestStatus(t *testing.T) {
- t.Parallel()
- tests := []struct {
- name string
- statusCode int
- useAPIKeyUpstream bool
- retryable bool
- }{
- {name: "api key 404", statusCode: http.StatusNotFound, useAPIKeyUpstream: true, retryable: true},
- {name: "api key 405", statusCode: http.StatusMethodNotAllowed, useAPIKeyUpstream: true, retryable: true},
- {name: "oauth 404", statusCode: http.StatusNotFound},
- {name: "oauth 405", statusCode: http.StatusMethodNotAllowed},
- {name: "api key 401", statusCode: http.StatusUnauthorized, useAPIKeyUpstream: true},
- {name: "oauth 401", statusCode: http.StatusUnauthorized, retryable: true},
- {name: "api key 400", statusCode: http.StatusBadRequest, useAPIKeyUpstream: true},
- {name: "api key 403", statusCode: http.StatusForbidden, useAPIKeyUpstream: true},
- {name: "rate limited", statusCode: http.StatusTooManyRequests, retryable: true},
- {name: "server error", statusCode: http.StatusServiceUnavailable, retryable: true},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- t.Parallel()
- require.Equal(t, tt.retryable, isRetryableCodexModelsManifestStatus(tt.statusCode, tt.useAPIKeyUpstream))
- })
- }
- }
- func newCodexModelsAPIKeyTestService(upstream HTTPUpstream) *OpenAIGatewayService {
- return &OpenAIGatewayService{
- cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{
- Enabled: false,
- }}},
- httpUpstream: upstream,
- }
- }
- func newCodexModelsAPIKeyTestAccount(baseURL string) *Account {
- credentials := map[string]any{"api_key": "sk-upstream"}
- if baseURL != "" {
- credentials["base_url"] = baseURL
- }
- return &Account{
- ID: 2,
- Platform: PlatformOpenAI,
- Type: AccountTypeAPIKey,
- Credentials: credentials,
- Concurrency: 3,
- }
- }
- func newCodexModelsTestAccount() *Account {
- return &Account{
- ID: 1,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "access_token": "test-access-token",
- "chatgpt_account_id": "acc-123",
- },
- }
- }
- func TestFetchCodexModelsManifestPassthrough(t *testing.T) {
- manifestBody := `{"models":[{"slug":"gpt-5.5","display_name":"GPT-5.5"}]}`
- var gotAuth, gotAccountID, gotOriginator, gotClientVersion string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAuth = r.Header.Get("Authorization")
- gotAccountID = r.Header.Get("chatgpt-account-id")
- gotOriginator = r.Header.Get("Originator")
- gotClientVersion = r.URL.Query().Get("client_version")
- w.Header().Set("ETag", `W/"abc123"`)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(manifestBody))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{}
- manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", "")
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if string(manifest.Body) != manifestBody {
- t.Errorf("body not passed through verbatim: got %q", manifest.Body)
- }
- if manifest.ETag != `W/"abc123"` {
- t.Errorf("etag not passed through: got %q", manifest.ETag)
- }
- if gotAuth != "Bearer test-access-token" {
- t.Errorf("authorization header: got %q", gotAuth)
- }
- if gotAccountID != "acc-123" {
- t.Errorf("chatgpt-account-id header: got %q", gotAccountID)
- }
- if gotOriginator != openai.CodexDefaultOriginator {
- t.Errorf("originator header: got %q", gotOriginator)
- }
- if gotClientVersion != "0.137.0" {
- t.Errorf("client_version query: got %q", gotClientVersion)
- }
- }
- func TestFetchCodexModelsManifestAgentIdentityUsesAssertionWithoutOAuthToken(t *testing.T) {
- key, privateKey := newTestAgentIdentityKey(t)
- account := &Account{
- ID: 3,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "auth_mode": OpenAIAuthModeAgentIdentity,
- "agent_runtime_id": key.runtimeID,
- "agent_private_key": privateKey,
- "task_id": key.taskID,
- "chatgpt_account_id": "acc-agent",
- },
- }
- var gotAuth, gotAccountID string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotAuth = r.Header.Get("Authorization")
- gotAccountID = r.Header.Get("chatgpt-account-id")
- _, _ = w.Write([]byte(`{"models":[]}`))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{}
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if string(manifest.Body) != `{"models":[]}` {
- t.Fatalf("unexpected manifest body: %q", manifest.Body)
- }
- if !strings.HasPrefix(gotAuth, "AgentAssertion ") {
- t.Fatalf("authorization scheme: got %q", strings.SplitN(gotAuth, " ", 2)[0])
- }
- if gotAccountID != "acc-agent" {
- t.Fatalf("chatgpt-account-id header: got %q", gotAccountID)
- }
- }
- func TestFetchCodexModelsManifestAgentIdentityRecoversInvalidTaskOnce(t *testing.T) {
- key, privateKey := newTestAgentIdentityKey(t)
- account := &Account{
- ID: 4,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "auth_mode": OpenAIAuthModeAgentIdentity,
- "agent_runtime_id": key.runtimeID,
- "agent_private_key": privateKey,
- "task_id": "task-models-old",
- "chatgpt_account_id": "acc-agent-recovery",
- },
- }
- repo := &stubQuotaAccountRepo{accounts: map[int64]*Account{account.ID: account}}
- modelsCalls := 0
- registerCalls := 0
- var assertions []string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("content-type", "application/json")
- if strings.Contains(r.URL.Path, "/task/register") {
- registerCalls++
- _, _ = w.Write([]byte(`{"task_id":"task-models-new"}`))
- return
- }
- modelsCalls++
- assertions = append(assertions, r.Header.Get("Authorization"))
- if modelsCalls == 1 {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":{"code":"invalid_task_id"}}`))
- return
- }
- _, _ = w.Write([]byte(`{"models":[]}`))
- }))
- defer server.Close()
- originalModelsURL := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = originalModelsURL })
- originalAuthBase := openAIAgentIdentityAuthAPIBaseURL
- openAIAgentIdentityAuthAPIBaseURL = server.URL
- t.Cleanup(func() { openAIAgentIdentityAuthAPIBaseURL = originalAuthBase })
- s := &OpenAIGatewayService{accountRepo: repo}
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.Equal(t, `{"models":[]}`, string(manifest.Body))
- require.Equal(t, 2, modelsCalls)
- require.Equal(t, 1, registerCalls)
- require.Len(t, assertions, 2)
- require.Equal(t, "task-models-old", decodeAgentAssertionTask(t, assertions[0]))
- require.Equal(t, "task-models-new", decodeAgentAssertionTask(t, assertions[1]))
- }
- func TestFetchCodexModelsManifestAgentIdentityRedactsUpstreamErrors(t *testing.T) {
- key, privateKey := newTestAgentIdentityKey(t)
- account := &Account{
- ID: 5,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "auth_mode": OpenAIAuthModeAgentIdentity,
- "agent_runtime_id": key.runtimeID,
- "agent_private_key": privateKey,
- "task_id": key.taskID,
- "chatgpt_account_id": "acc-agent-redaction",
- },
- }
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusBadRequest)
- _, _ = fmt.Fprintf(w, `{"error":"%s %s %s AgentAssertion leaked"}`, key.runtimeID, key.taskID, privateKey)
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = original })
- s := &OpenAIGatewayService{}
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.Error(t, err)
- require.NotContains(t, err.Error(), key.runtimeID)
- require.NotContains(t, err.Error(), key.taskID)
- require.NotContains(t, err.Error(), privateKey)
- require.NotContains(t, err.Error(), "AgentAssertion leaked")
- require.Contains(t, err.Error(), "[redacted]")
- }
- func TestFetchCodexModelsManifestDefaultClientVersion(t *testing.T) {
- var gotClientVersion string
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- gotClientVersion = r.URL.Query().Get("client_version")
- _, _ = w.Write([]byte(`{"models":[]}`))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{}
- if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "", ""); err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if gotClientVersion != CodexCanonicalClientVersion() {
- t.Errorf("default client_version: got %q, want %q", gotClientVersion, CodexCanonicalClientVersion())
- }
- }
- func TestFetchCodexModelsManifestNotModified(t *testing.T) {
- manifestBody := `{"models":[{"slug":"gpt-5.5"}]}`
- var calls atomic.Int32
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- calls.Add(1)
- w.Header().Set("ETag", `W/"abc123"`)
- _, _ = w.Write([]byte(manifestBody))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{}
- account := newCodexModelsTestAccount()
- first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if first.NotModified {
- t.Fatal("cold fetch must return the manifest body")
- }
- if first.ETag != `W/"abc123"` {
- t.Errorf("etag not passed through: got %q", first.ETag)
- }
- // OAuth 路径接入缓存后,客户端 If-None-Match 与缓存内容 ETag 比较,不再透传上游:
- // 新鲜期内命中缓存且匹配直接 304,零上游请求。
- second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", `W/"abc123"`)
- if err != nil {
- t.Fatalf("cached fetch returned error: %v", err)
- }
- if !second.NotModified {
- t.Fatal("matching cached ETag must return NotModified")
- }
- if second.ETag != `W/"abc123"` {
- t.Errorf("NotModified etag: got %q", second.ETag)
- }
- if got := calls.Load(); got != 1 {
- t.Errorf("upstream calls: got %d, want 1", got)
- }
- }
- func TestFetchCodexModelsManifestUpstreamError(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.Error(w, `{"detail":"boom"}`, http.StatusInternalServerError)
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{}
- if _, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.137.0", ""); err == nil {
- t.Fatal("expected error for upstream 500, got nil")
- }
- }
- func TestFetchCodexModelsManifestMissingToken(t *testing.T) {
- account := newCodexModelsTestAccount()
- delete(account.Credentials, "access_token")
- s := &OpenAIGatewayService{}
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", ""); err == nil {
- t.Fatal("expected error for missing access token, got nil")
- }
- }
- func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
- manifestBody := `{"models":[{"slug":"deepseek-v4-pro"}]}`
- var gotRequest *http.Request
- var gotProxyURL string
- var gotAccountID int64
- var gotConcurrency int
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
- gotRequest = req
- gotProxyURL = proxyURL
- gotAccountID = accountID
- gotConcurrency = accountConcurrency
- header := make(http.Header)
- header.Set("ETag", `W/"api-key-manifest"`)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: header,
- Body: io.NopCloser(strings.NewReader(manifestBody)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- manifest, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"),
- "0.144.0",
- "",
- )
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if gotRequest == nil {
- t.Fatal("expected request to custom API key upstream")
- }
- if gotRequest.Method != http.MethodGet {
- t.Errorf("method: got %q", gotRequest.Method)
- }
- if gotRequest.URL.String() != "https://upstream.example/v1/models?client_version=0.144.0" {
- t.Errorf("request URL: got %q", gotRequest.URL.String())
- }
- if gotRequest.Header.Get("Authorization") != "Bearer sk-upstream" {
- t.Errorf("authorization header: got %q", gotRequest.Header.Get("Authorization"))
- }
- if gotRequest.Header.Get("Originator") != openai.CodexDefaultOriginator {
- t.Errorf("originator header: got %q", gotRequest.Header.Get("Originator"))
- }
- if gotRequest.Header.Get("Version") != "0.144.0" {
- t.Errorf("version header must match the client_version query param: got %q", gotRequest.Header.Get("Version"))
- }
- if gotRequest.Header.Get("User-Agent") != CodexCanonicalUserAgent() {
- t.Errorf("user-agent header: got %q", gotRequest.Header.Get("User-Agent"))
- }
- if gotRequest.Header.Get("chatgpt-account-id") != "" {
- t.Errorf("chatgpt-account-id must not be sent to API key upstream: got %q", gotRequest.Header.Get("chatgpt-account-id"))
- }
- if gotProxyURL != "" || gotAccountID != 2 || gotConcurrency != 3 {
- t.Errorf("upstream routing metadata: proxy=%q account_id=%d concurrency=%d", gotProxyURL, gotAccountID, gotConcurrency)
- }
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-pro")
- require.Equal(t, "DeepSeek V4 Pro", models[0]["display_name"])
- require.EqualValues(t, 1_000_000, models[0]["context_window"])
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag)
- }
- // Scenario: 完整上游清单没有 ETag 时,最终正文仍生成强 ETag 并支持 304。
- func TestFetchCodexModelsManifestAPIKeyCompleteBodyWithoutUpstreamETagUsesFinalBodyETag(t *testing.T) {
- completeBody, err := BuildCodexModelsManifest([]string{"custom-complete-model"})
- require.NoError(t, err)
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(bytes.NewReader(completeBody)),
- }, nil
- }}
- svc := newCodexModelsAPIKeyTestService(upstream)
- svc.accountRepo = codexModelsVisibilityAccountRepo{}
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
- group := &Group{ID: 82, Platform: PlatformOpenAI}
- first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
- require.NoError(t, err)
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, first, ""))
- require.Equal(t, codexModelsManifestBodyETag(first.Body), first.ETag)
- require.NotEmpty(t, first.ETag)
- second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
- require.NoError(t, err)
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
- require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, first.ETag))
- require.True(t, second.NotModified)
- require.Empty(t, second.Body)
- require.Equal(t, int32(1), calls.Load())
- }
- func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) {
- upstreamBody := `{"object":"list","data":[{"id":"gpt-5.6","object":"model"},{"id":" ","object":"model"},{"id":"gpt-5.6-codex","object":"model"}]}`
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- header := make(http.Header)
- header.Set("ETag", `W/"openai-list"`)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: header,
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- manifest, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example/v1"),
- "0.144.0",
- "",
- )
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 2)
- requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6")
- requireCompleteConfiguredCodexModel(t, models[1], "gpt-5.6-codex")
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- require.Equal(t, []any{"text", "image"}, models[1]["input_modalities"])
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- require.Equal(t, `W/"openai-list"`, manifest.upstreamETag)
- }
- func TestConvertOpenAIModelListToCodexManifestUsesCompleteDescriptors(t *testing.T) {
- upstreamBody := `{"object":"list","data":[{"id":"gpt-5.5","object":"model"}]}`
- converted := convertOpenAIModelListToCodexManifest([]byte(upstreamBody))
- models := decodeCodexManifestModels(t, converted)
- require.Len(t, models, 1)
- requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.5")
- require.Equal(t, "GPT-5.5", models[0]["display_name"])
- require.Equal(t, "medium", models[0]["default_reasoning_level"])
- require.Len(t, models[0]["supported_reasoning_levels"], 4)
- }
- func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *testing.T) {
- t.Parallel()
- svc := &OpenAIGatewayService{}
- manifest := &OpenAIModelsResponse{
- 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"}}`),
- }
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- requireCompleteConfiguredCodexModel(t, models[0], "grok-4.6")
- require.Equal(t, "Provider supplied", models[0]["description"])
- require.Equal(t, map[string]any{"kept": true}, models[0]["unknown"])
- require.Equal(t, []any{
- map[string]any{
- "id": "provider-priority",
- "name": "Provider Fast",
- "description": "Provider supplied tier.",
- },
- }, models[0]["service_tiers"])
- require.Equal(t, []any{"text"}, models[0]["input_modalities"])
- require.Equal(t, []string{"low", "medium", "high", "xhigh"}, effortsFromManifestModel(t, models[0]))
- modelMessages, ok := models[0]["model_messages"].(map[string]any)
- require.True(t, ok)
- require.NotEmpty(t, modelMessages["instructions_template"])
- require.Equal(t, map[string]any{"enabled": true}, modelMessages["auto_review"])
- require.Equal(t, map[string]any{"mode": "tokens", "limit": float64(10_000)}, models[0]["truncation_policy"])
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- var envelope map[string]any
- require.NoError(t, json.Unmarshal(manifest.Body, &envelope))
- require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
- }
- func TestCompleteAPIKeyCodexModelsManifestForClientUsesKnownGPTImageFallback(t *testing.T) {
- t.Parallel()
- svc := &OpenAIGatewayService{}
- manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[
- {"slug":"gpt-5.6-sol"},
- {"slug":"company-coding-model"},
- {"slug":"gpt-4o","input_modalities":["text"]}
- ]}`)}
- account := newCodexModelsAPIKeyTestAccount("https://openai-compatible.example.test/v1")
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 3)
- bySlug := make(map[string]map[string]any, len(models))
- for _, model := range models {
- slug, ok := model["slug"].(string)
- require.True(t, ok)
- bySlug[slug] = model
- }
- require.Equal(t, []any{"text", "image"}, bySlug["gpt-5.6-sol"]["input_modalities"])
- require.Equal(t, []any{"text"}, bySlug["company-coding-model"]["input_modalities"])
- require.Equal(t, []any{"text"}, bySlug["gpt-4o"]["input_modalities"])
- }
- // Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。
- func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) {
- t.Parallel()
- reasoning := true
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "future-reasoner": {
- ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability",
- Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
- SupportedReasoningLevels: []string{"low", "high", "ultra"},
- InputModalities: []string{"text", "image"},
- ContextWindow: 999_000,
- },
- }})
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"future-reasoner","object":"model"}]}`)),
- }, nil
- }}
- svc := newCodexModelsAPIKeyTestService(upstream)
- manifest, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
- require.NoError(t, err)
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- require.Equal(t, "Future Reasoner", models[0]["display_name"])
- require.Equal(t, "Synced upstream capability", models[0]["description"])
- require.Equal(t, "ultra", models[0]["default_reasoning_level"])
- require.Equal(t, []string{"low", "high", "ultra"}, effortsFromManifestModel(t, models[0]))
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- require.EqualValues(t, 999_000, models[0]["context_window"])
- require.EqualValues(t, 999_000, models[0]["max_context_window"])
- }
- func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWithoutOverwritingExplicitMetadata(t *testing.T) {
- t.Parallel()
- reasoning := true
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "provider-model": {
- ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description",
- Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
- SupportedReasoningLevels: []string{"high", "ultra"},
- InputModalities: []string{"text", "image"},
- ContextWindow: 999_000,
- },
- }})
- manifest := &OpenAIModelsResponse{Body: []byte(`{"models":[{
- "slug":"provider-model",
- "description":"Provider supplied",
- "context_window":64000,
- "max_context_window":64000
- }]}`)}
- svc := &OpenAIGatewayService{}
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- require.Equal(t, "Synced Display", models[0]["display_name"])
- require.Equal(t, "Provider supplied", models[0]["description"])
- require.Equal(t, "ultra", models[0]["default_reasoning_level"])
- require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, models[0]))
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- require.EqualValues(t, 64_000, models[0]["context_window"])
- require.EqualValues(t, 64_000, models[0]["max_context_window"])
- }
- // Scenario: 原生 manifest 的缺失字段在命中缓存后仍使用账号当前同步快照,而不是缓存中的本地默认值。
- func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedNativeManifest(t *testing.T) {
- t.Parallel()
- reasoning := true
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
- setSnapshot := func(displayName string, contextWindow int64) {
- account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
- "deepseek-v4-pro": {
- ID: "deepseek-v4-pro", DisplayName: displayName,
- Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
- SupportedReasoningLevels: []string{"high", "ultra"},
- InputModalities: []string{"text", "image"},
- ContextWindow: contextWindow,
- },
- }})
- }
- setSnapshot("Synced DeepSeek", 256_000)
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Content-Type": []string{"application/json"}},
- Body: io.NopCloser(strings.NewReader(`{"models":[{
- "slug":"deepseek-v4-pro",
- "description":"Provider supplied"
- }]}`)),
- }, nil
- }}
- svc := newCodexModelsAPIKeyTestService(upstream)
- first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
- require.NoError(t, err)
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
- firstModel := decodeCodexManifestModels(t, first.Body)[0]
- require.Equal(t, "Synced DeepSeek", firstModel["display_name"])
- require.Equal(t, "Provider supplied", firstModel["description"])
- require.Equal(t, "ultra", firstModel["default_reasoning_level"])
- require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, firstModel))
- require.Equal(t, []any{"text", "image"}, firstModel["input_modalities"])
- require.EqualValues(t, 256_000, firstModel["context_window"])
- setSnapshot("Refreshed DeepSeek", 512_000)
- second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
- require.NoError(t, err)
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
- secondModel := decodeCodexManifestModels(t, second.Body)[0]
- require.Equal(t, "Refreshed DeepSeek", secondModel["display_name"])
- require.Equal(t, "Provider supplied", secondModel["description"])
- require.EqualValues(t, 512_000, secondModel["context_window"])
- require.Equal(t, int32(1), calls.Load(), "second response should use the cached upstream source body")
- }
- func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) {
- t.Parallel()
- svc := &OpenAIGatewayService{}
- 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"}]}`)}
- account := newCodexModelsAPIKeyTestAccount("")
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 5)
- bySlug := make(map[string]map[string]any, len(models))
- for _, model := range models {
- slug, ok := model["slug"].(string)
- require.True(t, ok)
- bySlug[slug] = model
- }
- for _, slug := range []string{"gpt-6-astra", "gpt-5.6-sol", "gpt-4o"} {
- require.Equal(t, []any{"text", "image"}, bySlug[slug]["input_modalities"])
- require.Equal(t, true, bySlug[slug]["supports_image_detail_original"])
- }
- for _, slug := range []string{"gpt-3.5-turbo", "gpt-4"} {
- require.Equal(t, []any{"text"}, bySlug[slug]["input_modalities"])
- require.Equal(t, false, bySlug[slug]["supports_image_detail_original"])
- }
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- }
- func TestCompleteAPIKeyCodexModelsManifestForClientFiltersOfficialNonAgentModels(t *testing.T) {
- t.Parallel()
- svc := &OpenAIGatewayService{}
- 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"}]}`)}
- account := newCodexModelsAPIKeyTestAccount("")
- require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
- require.Equal(t, []string{"gpt-5.6-sol", "o4-mini", "codex-mini-latest"}, codexManifestModelSlugs(t, manifest.Body))
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- }
- func TestAdjustAPIKeyCodexModelsManifest(t *testing.T) {
- tests := []struct {
- name string
- body string
- want string
- }{
- {
- name: "affected models disable responses lite and preserve unknown fields",
- 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}}`,
- 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}}`,
- },
- {
- name: "unaffected model unchanged",
- body: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
- want: ` {"models":[{"slug":"gpt-5.6-codex","use_responses_lite":true}]} `,
- },
- {
- name: "false missing and alternate entries unchanged",
- 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}]}`,
- 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}]}`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := adjustAPIKeyCodexModelsManifest([]byte(tt.body), nil)
- require.NoError(t, err)
- require.Equal(t, tt.want, string(got))
- })
- }
- }
- func TestFetchCodexModelsManifestAPIKeyDisablesResponsesLiteForAffectedModels(t *testing.T) {
- const upstreamBody = `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true},{"slug":"gpt-5.6-codex","use_responses_lite":true}],"metadata":{"version":1}}`
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Etag": []string{`"upstream-strong"`}},
- Body: io.NopCloser(strings.NewReader(upstreamBody)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", "")
- require.NoError(t, err)
- 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))
- require.Equal(t, codexModelsManifestBodyETag(manifest.Body), manifest.ETag)
- require.Equal(t, `"upstream-strong"`, manifest.upstreamETag)
- notModified, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsAPIKeyTestAccount("https://upstream.example"), "0.145.0", manifest.ETag)
- require.NoError(t, err)
- require.True(t, notModified.NotModified)
- require.Equal(t, manifest.ETag, notModified.ETag)
- }
- func TestFetchCodexModelsManifestOAuthPreservesResponsesLite(t *testing.T) {
- const manifestBody = ` {"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]} `
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- _, _ = w.Write([]byte(manifestBody))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{}
- manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.145.0", "")
- require.NoError(t, err)
- require.Equal(t, manifestBody, string(manifest.Body))
- }
- func TestConvertOpenAIModelListToCompleteCodexManifest(t *testing.T) {
- t.Parallel()
- body := []byte(`{"object":"list","data":[{"id":"deepseek-v4-flash"},{"id":"deepseek-v4-pro"}]}`)
- models := decodeCodexManifestModels(t, convertOpenAIModelListToCodexManifest(body))
- require.Len(t, models, 2)
- requireCompleteConfiguredCodexModel(t, models[0], "deepseek-v4-flash")
- requireCompleteConfiguredCodexModel(t, models[1], "deepseek-v4-pro")
- require.Equal(t, "DeepSeek V4 Flash", models[0]["display_name"])
- require.Equal(t, "DeepSeek V4 Pro", models[1]["display_name"])
- require.EqualValues(t, 1_000_000, models[0]["context_window"])
- require.EqualValues(t, 1_000_000, models[1]["context_window"])
- }
- func TestConvertOpenAIModelListToCodexManifestLeavesUnsupportedBodiesUnchanged(t *testing.T) {
- tests := []struct {
- name string
- body string
- want string
- }{
- {
- name: "codex manifest unchanged",
- body: `{"models":[{"slug":"m-1"}]}`,
- want: `{"models":[{"slug":"m-1"}]}`,
- },
- {
- name: "empty data unchanged",
- body: `{"object":"list","data":[]}`,
- want: `{"object":"list","data":[]}`,
- },
- {
- name: "data not an array unchanged",
- body: `{"object":"list","data":{"id":"m-1"}}`,
- want: `{"object":"list","data":{"id":"m-1"}}`,
- },
- {
- name: "entries without usable IDs unchanged",
- body: `{"object":"list","data":[{"id":""},{"object":"model"}]}`,
- want: `{"object":"list","data":[{"id":""},{"object":"model"}]}`,
- },
- {
- name: "invalid JSON unchanged",
- body: `{"data":`,
- want: `{"data":`,
- },
- {
- name: "non-object unchanged",
- body: `[]`,
- want: `[]`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- if got := string(convertOpenAIModelListToCodexManifest([]byte(tt.body))); got != tt.want {
- t.Errorf("got %q, want %q", got, tt.want)
- }
- })
- }
- }
- func TestFetchCodexModelsManifestUsesConfiguredBodyLimit(t *testing.T) {
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6"}]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- s.cfg.Gateway.ModelsListReadMaxBytes = 8
- _, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example"),
- "0.144.0",
- "",
- )
- require.Error(t, err)
- require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
- require.Contains(t, err.Error(), "response exceeds 8 bytes")
- require.True(t, IsRetryableCodexModelsManifestError(err))
- }
- func TestFetchCodexModelsManifestAcceptsConfiguredLimitAboveLegacyBoundary(t *testing.T) {
- manifestBody := `{"models":[{"slug":"gpt-5.6","display_name":"` + strings.Repeat("x", (8<<20)+1024) + `"}]}`
- require.Greater(t, len(manifestBody), 8<<20)
- require.Less(t, len(manifestBody), 16<<20)
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- _, _ = io.WriteString(w, manifestBody)
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- s := &OpenAIGatewayService{cfg: &config.Config{}}
- s.cfg.Gateway.ModelsListReadMaxBytes = 16 << 20
- manifest, err := s.FetchCodexModelsManifest(context.Background(), newCodexModelsTestAccount(), "0.144.0", "")
- require.NoError(t, err)
- require.True(t, bytes.Equal([]byte(manifestBody), manifest.Body), "manifest body must be returned intact")
- }
- func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
- tests := []struct {
- name string
- body string
- }{
- {name: "OpenAI models list", body: `{"object":"list","data":[]}`},
- {name: "invalid JSON", body: `{"models":`},
- {name: "non-object", body: `[]`},
- {name: "null object", body: `null`},
- {name: "missing models", body: `{}`},
- {name: "models object", body: `{"models":{}}`},
- {name: "models null", body: `{"models":null}`},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(tt.body)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- _, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example"),
- "0.144.0",
- "",
- )
- if err == nil {
- t.Fatal("expected invalid manifest error, got nil")
- }
- if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_INVALID_MANIFEST" {
- t.Errorf("error reason: got %q", infraerrors.Reason(err))
- }
- if !IsRetryableCodexModelsManifestError(err) {
- t.Error("invalid upstream manifest must be retryable")
- }
- })
- }
- }
- func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- body := `{"object":"list","data":[]}`
- if calls.Add(1) > 1 {
- body = `{"models":[{"slug":"gpt-5.6"}]}`
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(body)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err == nil {
- t.Fatal("expected invalid manifest error on first fetch")
- }
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- if err != nil {
- t.Fatalf("second fetch returned error: %v", err)
- }
- if got, want := string(manifest.Body), `{"models":[{"slug":"gpt-5.6"}]}`; got != want {
- t.Errorf("body: got %q, want %q", got, want)
- }
- if got := calls.Load(); got != 2 {
- t.Errorf("upstream calls: got %d, want 2", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeySharedRefreshSurvivesCallerCancellation(t *testing.T) {
- const manifestBody = `{"models":[{"slug":"gpt-5.6"}]}`
- var calls atomic.Int32
- var readStartedOnce sync.Once
- readStarted := make(chan struct{})
- deadlineRemaining := make(chan time.Duration, 1)
- release := make(chan struct{})
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- deadline, ok := req.Context().Deadline()
- if !ok {
- deadlineRemaining <- 0
- } else {
- deadlineRemaining <- time.Until(deadline)
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Etag": []string{`W/"shared"`}},
- Body: &codexModelsBlockingBody{
- ctx: req.Context(),
- readStarted: readStarted,
- startedOnce: &readStartedOnce,
- release: release,
- body: strings.NewReader(manifestBody),
- },
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- firstCtx, cancelFirst := context.WithCancel(context.Background())
- firstErr := make(chan error, 1)
- go func() {
- _, err := s.FetchCodexModelsManifest(firstCtx, account, "0.144.0", "")
- firstErr <- err
- }()
- select {
- case <-readStarted:
- case <-time.After(time.Second):
- t.Fatal("upstream body read did not start")
- }
- remaining := <-deadlineRemaining
- if remaining < 14*time.Second || remaining > codexModelsManifestRequestTimeout {
- t.Errorf("detached refresh deadline: got %s, want approximately %s", remaining, codexModelsManifestRequestTimeout)
- }
- cancelFirst()
- select {
- case err := <-firstErr:
- if !errors.Is(err, context.Canceled) {
- t.Fatalf("first caller error: got %v, want context.Canceled", err)
- }
- case <-time.After(time.Second):
- t.Fatal("canceled caller did not return promptly")
- }
- secondResult := make(chan struct {
- manifest *OpenAIModelsResponse
- err error
- }, 1)
- go func() {
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- secondResult <- struct {
- manifest *OpenAIModelsResponse
- err error
- }{manifest: manifest, err: err}
- }()
- time.Sleep(50 * time.Millisecond)
- if got := calls.Load(); got != 1 {
- t.Errorf("upstream calls before shared refresh completed: got %d, want 1", got)
- }
- close(release)
- select {
- case result := <-secondResult:
- if result.err != nil {
- t.Fatalf("second caller returned error: %v", result.err)
- }
- if string(result.manifest.Body) != manifestBody {
- t.Errorf("second caller body: got %q", result.manifest.Body)
- }
- case <-time.After(time.Second):
- t.Fatal("second caller did not receive shared refresh result")
- }
- if got := calls.Load(); got != 1 {
- t.Errorf("total upstream calls: got %d, want 1", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyConcurrentRequestsShareRefresh(t *testing.T) {
- const callers = 8
- var calls atomic.Int32
- started := make(chan struct{})
- var startedOnce sync.Once
- release := make(chan struct{})
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- startedOnce.Do(func() { close(started) })
- <-release
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- begin := make(chan struct{})
- errs := make(chan error, callers)
- for i := 0; i < callers; i++ {
- go func() {
- <-begin
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- errs <- err
- }()
- }
- close(begin)
- select {
- case <-started:
- case <-time.After(time.Second):
- t.Fatal("upstream request did not start")
- }
- time.Sleep(50 * time.Millisecond)
- if got := calls.Load(); got != 1 {
- t.Errorf("concurrent upstream calls: got %d, want 1", got)
- }
- close(release)
- for i := 0; i < callers; i++ {
- if err := <-errs; err != nil {
- t.Errorf("caller %d returned error: %v", i, err)
- }
- }
- }
- func TestFetchCodexModelsManifestAPIKeyFreshCacheHandlesETagLocally(t *testing.T) {
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- if got := req.Header.Get("If-None-Match"); got != "" {
- t.Errorf("cache refresh must not inherit a caller's If-None-Match: got %q", got)
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: http.Header{"Etag": []string{`W/"cached"`}},
- Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
- t.Fatalf("initial fetch returned error: %v", err)
- }
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", `W/"cached"`)
- if err != nil {
- t.Fatalf("cached fetch returned error: %v", err)
- }
- if !manifest.NotModified {
- t.Fatal("matching cached ETag must return NotModified")
- }
- if got := calls.Load(); got != 1 {
- t.Errorf("upstream calls: got %d, want 1", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyCacheSurvivesClientMutation(t *testing.T) {
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- s.accountRepo = codexModelsVisibilityAccountRepo{}
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- require.NoError(t, err)
- require.Contains(t, string(first.Body), "model-a")
- require.Contains(t, string(first.Body), "model-b")
- require.NoError(t, s.CompleteAPIKeyCodexModelsManifestForClient(first, account))
- require.NoError(t, s.MergeGroupConfiguredCodexModels(
- context.Background(),
- &Group{
- ID: 81,
- Platform: PlatformOpenAI,
- ModelAllowlist: GroupModelAllowlist{
- Enabled: true,
- Models: []string{"model-a"},
- },
- },
- first,
- "",
- ))
- require.Equal(t, []string{"model-a"}, codexManifestModelSlugs(t, first.Body))
- second, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- require.NoError(t, err)
- require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, second.Body))
- require.Equal(t, int32(1), calls.Load())
- var wg sync.WaitGroup
- for i := 0; i < 8; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- require.NoError(t, fetchErr)
- require.NoError(t, s.MergeGroupConfiguredCodexModels(
- context.Background(),
- &Group{
- ID: 82,
- Platform: PlatformOpenAI,
- ModelAllowlist: GroupModelAllowlist{
- Enabled: true,
- Models: []string{"model-b"},
- },
- },
- manifest,
- "",
- ))
- require.Equal(t, []string{"model-b"}, codexManifestModelSlugs(t, manifest.Body))
- }()
- }
- wg.Wait()
- third, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- require.NoError(t, err)
- require.Equal(t, []string{"model-a", "model-b"}, codexManifestModelSlugs(t, third.Body))
- require.Equal(t, int32(1), calls.Load())
- }
- func TestFetchCodexModelsManifestAPIKeyCacheKeyIsolatesRequestIdentity(t *testing.T) {
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- base := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- fetch := func(account *Account, version string) {
- t.Helper()
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, version, ""); err != nil {
- t.Fatalf("fetch returned error: %v", err)
- }
- }
- fetch(base, "0.144.0")
- fetch(base, "0.144.0")
- differentAccount := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- differentAccount.ID = 3
- fetch(differentAccount, "0.144.0")
- differentToken := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- differentToken.Credentials["api_key"] = "sk-other"
- fetch(differentToken, "0.144.0")
- differentUpstream := newCodexModelsAPIKeyTestAccount("https://other-upstream.example")
- fetch(differentUpstream, "0.144.0")
- fetch(base, "0.145.0")
- differentHeaders := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- differentHeaders.Credentials[credKeyHeaderOverrideEnabled] = true
- differentHeaders.Credentials[credKeyHeaderOverrides] = map[string]any{"x-tenant": "other"}
- fetch(differentHeaders, "0.144.0")
- proxyID := int64(9)
- differentProxy := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- differentProxy.ProxyID = &proxyID
- differentProxy.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 8080}
- fetch(differentProxy, "0.144.0")
- fetch(differentProxy, "0.144.0")
- if got := calls.Load(); got != 7 {
- t.Errorf("isolated upstream calls: got %d, want 7", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.T) {
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- body := `{"models":[]}`
- switch {
- case strings.Contains(req.URL.Host, "large-source"):
- body = `{"object":"list","data":[{"id":"model-a","padding":"` + strings.Repeat("x", 1<<20) + `"}]}`
- case strings.Contains(req.URL.Host, "large"):
- body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(body)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- fetch := func(account *Account) {
- t.Helper()
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
- t.Fatalf("fetch returned error: %v", err)
- }
- }
- small := newCodexModelsAPIKeyTestAccount("https://small.example")
- fetch(small)
- fetch(small)
- large := newCodexModelsAPIKeyTestAccount("https://large.example")
- large.ID = 3
- fetch(large)
- fetch(large)
- largeSource := newCodexModelsAPIKeyTestAccount("https://large-source.example")
- largeSource.ID = 4
- fetch(largeSource)
- fetch(largeSource)
- if got := calls.Load(); got != 5 {
- t.Fatalf("body-size bounded cache calls: got %d, want 5", got)
- }
- for i := int64(10); i < 523; i++ {
- account := newCodexModelsAPIKeyTestAccount("https://bounded.example")
- account.ID = i
- fetch(account)
- }
- last := newCodexModelsAPIKeyTestAccount("https://bounded.example")
- last.ID = 522
- fetch(last)
- if got := calls.Load(); got != 518 {
- t.Fatalf("most recent cache entry was not retained: calls=%d, want 518", got)
- }
- first := newCodexModelsAPIKeyTestAccount("https://bounded.example")
- first.ID = 10
- fetch(first)
- if got := calls.Load(); got != 519 {
- t.Errorf("oldest cache entry was not evicted: calls=%d, want 519", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyServesStaleWhileRefreshing(t *testing.T) {
- var calls atomic.Int32
- refreshStarted := make(chan struct{})
- releaseRefresh := make(chan struct{})
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- call := calls.Add(1)
- body := `{"models":[{"slug":"old"}]}`
- if call > 1 {
- if call == 2 {
- close(refreshStarted)
- }
- <-releaseRefresh
- body = `{"models":[{"slug":"new"}]}`
- }
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(body)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
- t.Fatalf("initial fetch returned error: %v", err)
- }
- s.openAIModelsCache.mu.Lock()
- for key, entry := range s.openAIModelsCache.entries {
- entry.expiresAt = time.Now().Add(-time.Second)
- s.openAIModelsCache.entries[key] = entry
- }
- s.openAIModelsCache.mu.Unlock()
- resultCh := make(chan struct {
- manifest *OpenAIModelsResponse
- err error
- }, 1)
- go func() {
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- resultCh <- struct {
- manifest *OpenAIModelsResponse
- err error
- }{manifest: manifest, err: err}
- }()
- select {
- case <-refreshStarted:
- case <-time.After(time.Second):
- t.Fatal("background refresh did not start")
- }
- var staleResult struct {
- manifest *OpenAIModelsResponse
- err error
- }
- select {
- case staleResult = <-resultCh:
- case <-time.After(100 * time.Millisecond):
- t.Error("stale manifest was not returned while refresh was blocked")
- close(releaseRefresh)
- staleResult = <-resultCh
- }
- if staleResult.err != nil {
- t.Fatalf("stale fetch returned error: %v", staleResult.err)
- }
- if got := string(staleResult.manifest.Body); got != `{"models":[{"slug":"old"}]}` {
- t.Errorf("stale body: got %q", got)
- }
- if got := calls.Load(); got != 2 {
- t.Errorf("upstream calls during stale refresh: got %d, want 2", got)
- }
- select {
- case <-releaseRefresh:
- default:
- close(releaseRefresh)
- }
- deadline := time.Now().Add(time.Second)
- for {
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- if err == nil && string(manifest.Body) == `{"models":[{"slug":"new"}]}` {
- break
- }
- if time.Now().After(deadline) {
- t.Fatalf("refreshed manifest was not cached: manifest=%v err=%v", manifest, err)
- }
- time.Sleep(10 * time.Millisecond)
- }
- if got := calls.Load(); got != 2 {
- t.Errorf("stale refresh was not deduplicated: calls=%d, want 2", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyRevalidatesStaleETag(t *testing.T) {
- var calls atomic.Int32
- refreshDone := make(chan struct{})
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- call := calls.Add(1)
- if call == 1 {
- header := make(http.Header)
- header.Set("ETag", `"upstream-cached"`)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: header,
- Body: io.NopCloser(strings.NewReader(`{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":true}]}`)),
- }, nil
- }
- if got := req.Header.Get("If-None-Match"); got != `"upstream-cached"` {
- t.Errorf("background revalidation If-None-Match: got %q", got)
- }
- close(refreshDone)
- header := make(http.Header)
- header.Set("ETag", `"upstream-cached"`)
- return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- if _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", ""); err != nil {
- t.Fatalf("initial fetch returned error: %v", err)
- }
- s.openAIModelsCache.mu.Lock()
- for key, entry := range s.openAIModelsCache.entries {
- entry.expiresAt = time.Now().Add(-time.Second)
- s.openAIModelsCache.entries[key] = entry
- }
- s.openAIModelsCache.mu.Unlock()
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- if err != nil {
- t.Fatalf("stale fetch returned error: %v", err)
- }
- if got := string(manifest.Body); got != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
- t.Fatalf("stale body: got %q", got)
- }
- select {
- case <-refreshDone:
- case <-time.After(time.Second):
- t.Fatal("ETag revalidation did not complete")
- }
- deadline := time.Now().Add(time.Second)
- for {
- s.openAIModelsCache.mu.Lock()
- fresh := false
- for _, entry := range s.openAIModelsCache.entries {
- fresh = time.Now().Before(entry.expiresAt)
- }
- s.openAIModelsCache.mu.Unlock()
- if fresh {
- break
- }
- if time.Now().After(deadline) {
- t.Fatal("304 revalidation did not renew the cached manifest")
- }
- time.Sleep(10 * time.Millisecond)
- }
- manifest, err = s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- if err != nil || string(manifest.Body) != `{"models":[{"slug":"gpt-5.6-sol","use_responses_lite":false}]}` {
- t.Fatalf("renewed cached manifest: body=%q err=%v", manifest.Body, err)
- }
- if got := calls.Load(); got != 2 {
- t.Errorf("upstream calls: got %d, want 2", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyColdCacheHandlesNotModifiedLocally(t *testing.T) {
- var gotIfNoneMatch string
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- gotIfNoneMatch = req.Header.Get("If-None-Match")
- header := make(http.Header)
- header.Set("ETag", `W/"api-key-manifest"`)
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: header,
- Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- manifest, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example"),
- "0.144.0",
- `W/"api-key-manifest"`,
- )
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if !manifest.NotModified {
- t.Error("expected NotModified to be true")
- }
- if manifest.ETag != `W/"api-key-manifest"` {
- t.Errorf("etag not passed through: got %q", manifest.ETag)
- }
- if gotIfNoneMatch != "" {
- t.Errorf("cold shared refresh must not inherit caller if-none-match: got %q", gotIfNoneMatch)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyDoesNotCacheUnexpectedColdNotModified(t *testing.T) {
- var calls atomic.Int32
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- calls.Add(1)
- if got := req.Header.Get("If-None-Match"); got != "" {
- t.Errorf("cold shared refresh If-None-Match: got %q", got)
- }
- header := make(http.Header)
- header.Set("ETag", `W/"unexpected"`)
- return &http.Response{StatusCode: http.StatusNotModified, Header: header, Body: http.NoBody}, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- account := newCodexModelsAPIKeyTestAccount("https://upstream.example")
- for i := 0; i < 2; i++ {
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.144.0", "")
- if err != nil {
- t.Fatalf("fetch %d returned error: %v", i, err)
- }
- if !manifest.NotModified {
- t.Fatalf("fetch %d: expected upstream NotModified response", i)
- }
- }
- if got := calls.Load(); got != 2 {
- t.Errorf("unexpected cold 304 was cached: upstream calls=%d, want 2", got)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyPreservesBaseURLQuery(t *testing.T) {
- var gotURL string
- upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- gotURL = req.URL.String()
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- _, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example/v1?tenant=acme"),
- "0.144.0",
- "",
- )
- if err != nil {
- t.Fatalf("FetchCodexModelsManifest returned error: %v", err)
- }
- if gotURL != "https://upstream.example/v1/models?client_version=0.144.0&tenant=acme" {
- t.Errorf("request URL: got %q", gotURL)
- }
- }
- func TestFetchCodexModelsManifestAPIKeyRejectsBaseURLFragment(t *testing.T) {
- called := false
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- called = true
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"models":[]}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- _, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example/v1#models"),
- "0.144.0",
- "",
- )
- if err == nil {
- t.Fatal("expected invalid upstream base URL error, got nil")
- }
- if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_API_KEY_UPSTREAM_INVALID" {
- t.Errorf("error reason: got %q", infraerrors.Reason(err))
- }
- if called {
- t.Fatal("fragment-bearing base URL must be rejected before the upstream request")
- }
- }
- // codexModelsAccountStateRepo records account state transitions triggered by
- // manifest upstream errors (#4544).
- type codexModelsAccountStateRepo struct {
- AccountRepository
- mu sync.Mutex
- setErrorCalls int
- lastErrorMsg string
- setTempUnschedCalls int
- lastTempReason string
- }
- func (r *codexModelsAccountStateRepo) SetError(_ context.Context, _ int64, errorMsg string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.setErrorCalls++
- r.lastErrorMsg = errorMsg
- return nil
- }
- func (r *codexModelsAccountStateRepo) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
- r.mu.Lock()
- defer r.mu.Unlock()
- r.setTempUnschedCalls++
- r.lastTempReason = reason
- return nil
- }
- func newCodexModels401TestService(repo AccountRepository) *OpenAIGatewayService {
- rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- s := &OpenAIGatewayService{rateLimitService: rateLimitService}
- rateLimitService.SetAccountRuntimeBlocker(s)
- return s
- }
- func TestFetchCodexModelsManifestOAuth401MarksAccountUnschedulable(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"detail":{"message":"invalid token"}}`))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- repo := &codexModelsAccountStateRepo{}
- s := newCodexModels401TestService(repo)
- account := newCodexModelsTestAccount()
- account.Credentials["refresh_token"] = "test-refresh-token"
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.Error(t, err)
- require.True(t, IsRetryableCodexModelsManifestError(err), "manifest 401 should allow account failover")
- require.Equal(t, 1, repo.setTempUnschedCalls, "OAuth 401 should temp-unschedule the account")
- require.Equal(t, 0, repo.setErrorCalls)
- require.True(t, s.isOpenAIAccountRuntimeBlocked(account), "account should be runtime-blocked after manifest 401")
- }
- func TestFetchCodexModelsManifestOAuth401TokenRevokedDisablesAccount(t *testing.T) {
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":{"code":"token_revoked","message":"token has been revoked"}}`))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- repo := &codexModelsAccountStateRepo{}
- s := newCodexModels401TestService(repo)
- account := newCodexModelsTestAccount()
- account.Credentials["refresh_token"] = "test-refresh-token"
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.Error(t, err)
- require.True(t, IsRetryableCodexModelsManifestError(err))
- require.Equal(t, 1, repo.setErrorCalls, "revoked token should permanently disable the account")
- require.Contains(t, repo.lastErrorMsg, "Token revoked")
- require.Equal(t, 0, repo.setTempUnschedCalls)
- }
- func TestFetchCodexModelsManifestAgentIdentity401DoesNotDisableAccount(t *testing.T) {
- key, privateKey := newTestAgentIdentityKey(t)
- account := &Account{
- ID: 6,
- Platform: PlatformOpenAI,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "auth_mode": OpenAIAuthModeAgentIdentity,
- "agent_runtime_id": key.runtimeID,
- "agent_private_key": privateKey,
- "task_id": key.taskID,
- "chatgpt_account_id": "acc-agent-401",
- },
- }
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"detail":"some non-task 401"}`))
- }))
- defer server.Close()
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- defer func() { chatgptCodexModelsURL = original }()
- repo := &codexModelsAccountStateRepo{}
- s := newCodexModels401TestService(repo)
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.Error(t, err)
- require.Equal(t, 0, repo.setErrorCalls, "agent identity 401s must not disable the account")
- require.Equal(t, 0, repo.setTempUnschedCalls)
- }
- func TestFetchCodexModelsManifestAPIKey401KeepsNoFailoverAndNoDisable(t *testing.T) {
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusUnauthorized,
- Status: "401 Unauthorized",
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"error":"invalid api key"}`)),
- }, nil
- }}
- repo := &codexModelsAccountStateRepo{}
- s := newCodexModelsAPIKeyTestService(upstream)
- s.rateLimitService = NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
- _, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example"),
- "0.144.0",
- "",
- )
- require.Error(t, err)
- require.False(t, IsRetryableCodexModelsManifestError(err), "custom upstream manifest 401 keeps the no-failover behavior")
- require.Equal(t, 0, repo.setErrorCalls, "custom upstream manifest 401 must not disable the account")
- require.Equal(t, 0, repo.setTempUnschedCalls)
- }
- func TestFetchCodexModelsManifestAPIKeyUpstreamError(t *testing.T) {
- upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- return &http.Response{
- StatusCode: http.StatusTooManyRequests,
- Status: "429 Too Many Requests",
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"error":"rate limited"}`)),
- }, nil
- }}
- s := newCodexModelsAPIKeyTestService(upstream)
- _, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount("https://upstream.example"),
- "0.144.0",
- "",
- )
- if err == nil {
- t.Fatal("expected error for upstream 429, got nil")
- }
- if infraerrors.Code(err) != http.StatusBadGateway {
- t.Errorf("error status: got %d, want %d", infraerrors.Code(err), http.StatusBadGateway)
- }
- if infraerrors.Reason(err) != "OPENAI_CODEX_MODELS_UPSTREAM_FAILED" {
- t.Errorf("error reason: got %q", infraerrors.Reason(err))
- }
- }
- func TestFetchCodexModelsManifestAPIKeyUsesOfficialOpenAIModelsEndpoint(t *testing.T) {
- tests := []struct {
- name string
- baseURL string
- }{
- {name: "missing base URL"},
- {name: "official host", baseURL: "https://api.openai.com"},
- {name: "official versioned URL", baseURL: "https://API.OPENAI.COM:443/v1/"},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var gotURL string
- s := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
- gotURL = req.URL.String()
- return &http.Response{
- StatusCode: http.StatusOK,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol"}]}`)),
- }, nil
- }})
- manifest, err := s.FetchCodexModelsManifest(
- context.Background(),
- newCodexModelsAPIKeyTestAccount(tt.baseURL),
- "0.144.0",
- "",
- )
- require.NoError(t, err)
- parsedURL, parseErr := url.Parse(gotURL)
- require.NoError(t, parseErr)
- require.Equal(t, "api.openai.com", strings.ToLower(parsedURL.Hostname()))
- require.Equal(t, "/v1/models", parsedURL.Path)
- require.Equal(t, "0.144.0", parsedURL.Query().Get("client_version"))
- models := decodeCodexManifestModels(t, manifest.Body)
- require.Len(t, models, 1)
- requireCompleteConfiguredCodexModel(t, models[0], "gpt-5.6-sol")
- require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
- })
- }
- }
- // --- OAuth manifest 账号级缓存(统一缓存策略) ---
- // newCodexModelsOAuthCacheServer 返回一个记录调用次数、返回给定 manifest 的
- // ChatGPT Codex models 测试上游。
- func newCodexModelsOAuthCacheServer(t *testing.T, body string) (*httptest.Server, *atomic.Int32) {
- t.Helper()
- calls := &atomic.Int32{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- calls.Add(1)
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(body))
- }))
- t.Cleanup(server.Close)
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = original })
- return server, calls
- }
- func expireCodexModelsManifestCache(s *OpenAIGatewayService, age time.Duration) {
- s.openAIModelsCache.mu.Lock()
- for key, entry := range s.openAIModelsCache.entries {
- entry.expiresAt = time.Now().Add(-age)
- entry.staleUntil = time.Now().Add(openAIModelsCacheStaleTTL - age)
- s.openAIModelsCache.entries[key] = entry
- }
- s.openAIModelsCache.mu.Unlock()
- }
- func TestFetchCodexModelsManifestOAuthFreshWindowZeroUpstreamRequests(t *testing.T) {
- _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.5"}]}`)
- s := &OpenAIGatewayService{}
- account := newCodexModelsTestAccount()
- for i := 0; i < 10; i++ {
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.False(t, manifest.NotModified)
- require.JSONEq(t, `{"models":[{"slug":"gpt-5.5"}]}`, string(manifest.Body))
- }
- require.EqualValues(t, 1, calls.Load(), "新鲜期内重复请求不得打到 chatgpt.com")
- }
- func TestFetchCodexModelsManifestOAuthStaleServesOldValueAndRefreshesInBackground(t *testing.T) {
- calls := &atomic.Int32{}
- refreshStarted := make(chan struct{})
- var refreshOnce sync.Once
- release := make(chan struct{})
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- if calls.Add(1) == 2 {
- refreshOnce.Do(func() { close(refreshStarted) })
- <-release
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"models":[{"slug":"new"}]}`))
- }))
- t.Cleanup(server.Close)
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = original })
- s := &OpenAIGatewayService{}
- account := newCodexModelsTestAccount()
- first, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.Contains(t, string(first.Body), `"new"`)
- expireCodexModelsManifestCache(s, 2*time.Minute)
- resultCh := make(chan *OpenAIModelsResponse, 1)
- errCh := make(chan error, 1)
- go func() {
- manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- resultCh <- manifest
- errCh <- fetchErr
- }()
- select {
- case <-refreshStarted:
- case <-time.After(time.Second):
- t.Fatal("乐观期后台刷新未触发")
- }
- // 乐观期:立即返回旧值,不同步等待刷新。
- select {
- case manifest := <-resultCh:
- require.NoError(t, <-errCh)
- require.Contains(t, string(manifest.Body), `"new"`)
- case <-time.After(100 * time.Millisecond):
- t.Fatal("乐观期必须立即返回缓存旧值")
- }
- require.EqualValues(t, 2, calls.Load())
- close(release)
- deadline := time.Now().Add(time.Second)
- for time.Now().Before(deadline) {
- manifest, fetchErr := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- if fetchErr == nil && manifest != nil && !manifest.NotModified {
- require.Contains(t, string(manifest.Body), `"new"`)
- }
- s.openAIModelsCache.mu.Lock()
- fresh := len(s.openAIModelsCache.entries) > 0
- s.openAIModelsCache.mu.Unlock()
- if fresh {
- break
- }
- time.Sleep(10 * time.Millisecond)
- }
- require.EqualValues(t, 2, calls.Load(), "后台刷新必须单飞")
- }
- func TestFetchCodexModelsManifestOAuthOverdueSynchronousRefreshAndFailure(t *testing.T) {
- t.Run("overdue refresh waits and re-caches", func(t *testing.T) {
- calls := &atomic.Int32{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- if calls.Add(1) == 2 {
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"models":[{"slug":"refreshed"}]}`))
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`))
- }))
- t.Cleanup(server.Close)
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = original })
- s := &OpenAIGatewayService{}
- account := newCodexModelsTestAccount()
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- // 超过 5 分钟:缓存条目被丢弃,同步等待上游刷新。
- expireCodexModelsManifestCache(s, 6*time.Minute)
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.Contains(t, string(manifest.Body), `"refreshed"`)
- require.EqualValues(t, 2, calls.Load())
- // 新内容写回缓存并重新开始新鲜期。
- again, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.Contains(t, string(again.Body), `"refreshed"`)
- require.EqualValues(t, 2, calls.Load())
- })
- t.Run("overdue upstream failure returns error not stale cache", func(t *testing.T) {
- fail := &atomic.Bool{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- if fail.Load() {
- w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte(`{"detail":"boom"}`))
- return
- }
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"models":[{"slug":"stale"}]}`))
- }))
- t.Cleanup(server.Close)
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = original })
- s := &OpenAIGatewayService{}
- account := newCodexModelsTestAccount()
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- fail.Store(true)
- expireCodexModelsManifestCache(s, 6*time.Minute)
- _, err = s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.Error(t, err, "超期后上游失败必须返回错误而非旧缓存")
- require.Equal(t, "OPENAI_CODEX_MODELS_UPSTREAM_FAILED", infraerrors.Reason(err))
- })
- }
- func TestFetchCodexModelsManifestOAuthTokenChangeCacheMiss(t *testing.T) {
- _, calls := newCodexModelsOAuthCacheServer(t, `{"models":[]}`)
- s := &OpenAIGatewayService{}
- account := newCodexModelsTestAccount()
- _, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.EqualValues(t, 1, calls.Load())
- // 令牌刷新后缓存键变化,下一次拉取视为未命中并同步请求上游。
- account.Credentials["access_token"] = "rotated-access-token"
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- require.NoError(t, err)
- require.Contains(t, string(manifest.Body), `"models"`)
- require.EqualValues(t, 2, calls.Load())
- }
- func TestFetchCodexModelsManifestOAuthSharedAcrossGroupsWithIndependentFiltering(t *testing.T) {
- started := make(chan struct{})
- var startedOnce sync.Once
- release := make(chan struct{})
- calls := &atomic.Int32{}
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- calls.Add(1)
- startedOnce.Do(func() { close(started) })
- <-release
- w.Header().Set("Content-Type", "application/json")
- _, _ = w.Write([]byte(`{"models":[{"slug":"model-a"},{"slug":"model-b"}]}`))
- }))
- t.Cleanup(server.Close)
- original := chatgptCodexModelsURL
- chatgptCodexModelsURL = server.URL
- t.Cleanup(func() { chatgptCodexModelsURL = original })
- s := &OpenAIGatewayService{
- accountRepo: codexModelsVisibilityAccountRepo{
- byGroup: map[int64][]Account{
- 91: {},
- 92: {},
- },
- },
- }
- account := newCodexModelsTestAccount()
- groupA := &Group{ID: 91, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-a"}}}
- groupB := &Group{ID: 92, Platform: PlatformOpenAI, ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"model-b"}}}
- begin := make(chan struct{})
- type result struct {
- slugs []string
- err error
- }
- results := make(chan result, 2)
- for _, group := range []*Group{groupA, groupB} {
- go func(g *Group) {
- <-begin
- manifest, err := s.FetchCodexModelsManifest(context.Background(), account, "0.137.0", "")
- if err == nil {
- err = s.MergeGroupConfiguredCodexModels(context.Background(), g, manifest, "")
- }
- slugs := []string{}
- if err == nil {
- slugs = codexManifestModelSlugs(t, manifest.Body)
- }
- results <- result{slugs: slugs, err: err}
- }(group)
- }
- close(begin)
- select {
- case <-started:
- case <-time.After(time.Second):
- t.Fatal("upstream request did not start")
- }
- close(release)
- got := map[int64][]string{91: nil, 92: nil}
- for i := 0; i < 2; i++ {
- r := <-results
- require.NoError(t, r.err)
- if len(r.slugs) == 1 && r.slugs[0] == "model-a" {
- got[91] = r.slugs
- } else if len(r.slugs) == 1 && r.slugs[0] == "model-b" {
- got[92] = r.slugs
- } else {
- t.Fatalf("unexpected filtered slugs: %v", r.slugs)
- }
- }
- require.Equal(t, []string{"model-a"}, got[91])
- require.Equal(t, []string{"model-b"}, got[92])
- require.EqualValues(t, 1, calls.Load(), "同一账号两个分组同时请求时只发一次上游请求")
- }
|