openai_models_list.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "fmt"
  7. "net/http"
  8. "sort"
  9. "strings"
  10. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  11. )
  12. // FetchOpenAIModelsList discovers a single account's raw public model catalog.
  13. // API keys use the standard endpoint; OAuth reuses the authenticated, cached
  14. // Codex source. Account mappings and group policy are applied after this cache.
  15. func (s *OpenAIGatewayService) FetchOpenAIModelsList(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) {
  16. if s == nil || account == nil {
  17. return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_MODELS_ACCOUNT_REQUIRED", "OpenAI account is required")
  18. }
  19. credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
  20. if err != nil {
  21. return nil, fmt.Errorf("resolve model list credentials: %w", err)
  22. }
  23. if credentialAccount.IsOpenAIOAuth() {
  24. clientVersion := CodexCanonicalClientVersion()
  25. if s.settingService != nil {
  26. clientVersion = s.settingService.GetOpenAICodexClientVersion(ctx)
  27. }
  28. response, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "")
  29. if err != nil {
  30. return nil, err
  31. }
  32. body, err := standardOpenAIModelsBody(response.Body, true)
  33. if err != nil {
  34. return nil, invalidOpenAIModelsList(err)
  35. }
  36. return &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}, nil
  37. }
  38. req, err := buildOpenAIAPIKeyModelsRequest(ctx, credentialAccount, s.validateUpstreamBaseURL)
  39. if err != nil {
  40. return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_REQUEST_INVALID", "cannot build upstream model list request: %v", err)
  41. }
  42. request := openAIModelsRequest{
  43. url: req.URL.String(), headers: req.Header,
  44. proxyURL: upstreamModelsProxyURL(account), accountID: account.ID,
  45. credentialAccountID: credentialAccount.ID, credentialAccount: credentialAccount,
  46. accountConcurrency: account.Concurrency, useAPIKeyUpstream: true,
  47. standardModelsList: true,
  48. }
  49. response, err := s.fetchCachedOpenAIModels(ctx, request, func(fetchCtx context.Context, etag string) (*OpenAIModelsResponse, error) {
  50. response, err := s.fetchOpenAIModelsUpstream(fetchCtx, request, etag)
  51. if err != nil || response.NotModified {
  52. return response, err
  53. }
  54. body, err := standardOpenAIModelsBody(response.Body, false)
  55. if err != nil {
  56. return nil, invalidOpenAIModelsList(err)
  57. }
  58. response.Body = body
  59. response.ETag = codexModelsManifestBodyETag(body)
  60. return response, nil
  61. }, "")
  62. if err != nil {
  63. return nil, err
  64. }
  65. if response.NotModified {
  66. return nil, invalidOpenAIModelsList(fmt.Errorf("upstream returned 304 without a cached catalog"))
  67. }
  68. return response, nil
  69. }
  70. func invalidOpenAIModelsList(err error) error {
  71. return &codexModelsManifestUpstreamError{
  72. err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_INVALID", "invalid upstream model list: %v", err),
  73. retryable: true,
  74. }
  75. }
  76. // modelCatalogEntries requires a real array. An empty array is authoritative;
  77. // absent fields, null and error envelopes must not become successful empty lists.
  78. func modelCatalogEntries(body []byte, field string) (map[string]json.RawMessage, []json.RawMessage, error) {
  79. var envelope map[string]json.RawMessage
  80. if err := json.Unmarshal(body, &envelope); err != nil {
  81. return nil, nil, err
  82. }
  83. raw := bytes.TrimSpace(envelope[field])
  84. if len(raw) == 0 || raw[0] != '[' {
  85. return nil, nil, fmt.Errorf("missing or invalid %s array", field)
  86. }
  87. var entries []json.RawMessage
  88. if err := json.Unmarshal(raw, &entries); err != nil {
  89. return nil, nil, err
  90. }
  91. return envelope, entries, nil
  92. }
  93. func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) {
  94. field, idField := "data", "id"
  95. if fromManifest {
  96. field, idField = "models", "slug"
  97. }
  98. _, entries, err := modelCatalogEntries(body, field)
  99. if err != nil {
  100. return nil, err
  101. }
  102. models := make([]json.RawMessage, 0, len(entries))
  103. seen := make(map[string]struct{}, len(entries))
  104. for _, raw := range entries {
  105. var entry map[string]json.RawMessage
  106. if err := json.Unmarshal(raw, &entry); err != nil || entry == nil {
  107. return nil, fmt.Errorf("model entry must be an object")
  108. }
  109. var id string
  110. if err := json.Unmarshal(entry[idField], &id); err != nil || strings.TrimSpace(id) == "" {
  111. return nil, fmt.Errorf("model entry has no valid %s", idField)
  112. }
  113. id = strings.TrimSpace(id)
  114. if _, ok := seen[id]; ok || strings.Contains(id, "*") {
  115. continue
  116. }
  117. seen[id] = struct{}{}
  118. if fromManifest {
  119. entry = make(map[string]json.RawMessage)
  120. }
  121. entry["id"], _ = json.Marshal(id)
  122. entry["object"] = json.RawMessage(`"model"`)
  123. if len(entry["created"]) == 0 || string(entry["created"]) == "null" {
  124. entry["created"] = json.RawMessage(`0`)
  125. }
  126. if len(entry["owned_by"]) == 0 || string(entry["owned_by"]) == "null" {
  127. entry["owned_by"] = json.RawMessage(`"openai"`)
  128. }
  129. encoded, err := json.Marshal(entry)
  130. if err != nil {
  131. return nil, err
  132. }
  133. models = append(models, encoded)
  134. }
  135. return json.Marshal(struct {
  136. Object string `json:"object"`
  137. Data []json.RawMessage `json:"data"`
  138. }{Object: "list", Data: models})
  139. }
  140. // projectAccountModelsBody applies the same public-name mapping to both model
  141. // representations while retaining the source entry's metadata. It never changes
  142. // the shared response and never synthesizes models absent from this account.
  143. func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) {
  144. if account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0 {
  145. return body, nil
  146. }
  147. field, idField := "data", "id"
  148. if codex {
  149. field, idField = "models", "slug"
  150. }
  151. envelope, entries, err := modelCatalogEntries(body, field)
  152. if err != nil {
  153. return nil, err
  154. }
  155. byID := make(map[string]json.RawMessage, len(entries))
  156. candidates := make([]string, 0, len(entries))
  157. for _, raw := range entries {
  158. var entry map[string]json.RawMessage
  159. if json.Unmarshal(raw, &entry) != nil {
  160. continue
  161. }
  162. var id string
  163. if json.Unmarshal(entry[idField], &id) != nil {
  164. continue
  165. }
  166. id = strings.TrimSpace(id)
  167. if id == "" || strings.Contains(id, "*") {
  168. continue
  169. }
  170. // Apply the allowlist to public names after mapping, not upstream targets.
  171. if codex {
  172. if isCodexDedicatedMediaModel(id) {
  173. continue
  174. }
  175. if strings.HasPrefix(id, codexAutoModelPrefix) && len(FilterCodexModelIDsForGroup([]string{id}, group)) == 0 {
  176. continue
  177. }
  178. }
  179. if _, ok := byID[id]; !ok {
  180. byID[id] = raw
  181. candidates = append(candidates, id)
  182. }
  183. }
  184. aliases := make([]string, 0, len(account.GetModelMapping()))
  185. for alias := range account.GetModelMapping() {
  186. aliases = append(aliases, alias)
  187. }
  188. sort.Strings(aliases)
  189. candidates = append(candidates, aliases...)
  190. if group.ModelAllowlistEnabled() {
  191. candidates = append(candidates, group.ModelAllowlist.Models...)
  192. }
  193. projected := make([]json.RawMessage, 0, len(candidates))
  194. seen := make(map[string]struct{}, len(candidates))
  195. for _, id := range candidates {
  196. id = strings.TrimSpace(id)
  197. if id == "" || strings.Contains(id, "*") {
  198. continue
  199. }
  200. if _, ok := seen[id]; ok {
  201. continue
  202. }
  203. target, matched := account.ResolveMappedModel(id)
  204. raw, available := byID[strings.TrimSpace(target)]
  205. if !matched || !available {
  206. continue
  207. }
  208. seen[id] = struct{}{}
  209. var entry map[string]json.RawMessage
  210. if err := json.Unmarshal(raw, &entry); err != nil {
  211. return nil, err
  212. }
  213. entry[idField], _ = json.Marshal(id)
  214. if id != target {
  215. entry["display_name"], _ = json.Marshal(id)
  216. }
  217. encoded, err := json.Marshal(entry)
  218. if err != nil {
  219. return nil, err
  220. }
  221. projected = append(projected, encoded)
  222. }
  223. envelope[field], err = json.Marshal(projected)
  224. if err != nil {
  225. return nil, err
  226. }
  227. return json.Marshal(envelope)
  228. }
  229. // ApplyPinnedCodexModelsMapping is used by pinned discovery and its scheduler
  230. // fallback. The ordinary (non-pinned) Codex path retains its local catalog policy.
  231. func ApplyPinnedCodexModelsMapping(response *OpenAIModelsResponse, account *Account, group *Group) error {
  232. if group == nil || group.Platform != PlatformOpenAI || !group.CodexModelsManifestConfig.Enabled {
  233. return nil
  234. }
  235. body, err := projectAccountModelsBody(response.Body, account, group, true)
  236. if err != nil {
  237. return err
  238. }
  239. response.Body = body
  240. response.ETag = codexModelsManifestBodyETag(body)
  241. return nil
  242. }
  243. // FetchPinnedOpenAIModelsList includes explicitly enabled scheduler fallback.
  244. // An authoritative empty catalog is success, including after group filtering.
  245. func (s *OpenAIGatewayService) FetchPinnedOpenAIModelsList(ctx context.Context, group *Group, maxAccountSwitches int, ifNoneMatch string) (*OpenAIModelsResponse, *Account, error) {
  246. fetch := func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) {
  247. response, err := s.FetchOpenAIModelsList(ctx, account)
  248. if err != nil {
  249. return nil, err
  250. }
  251. response.Body, err = projectAccountModelsBody(response.Body, account, group, false)
  252. return response, err
  253. }
  254. results, err := s.fetchPinnedOpenAIModels(ctx, group, fetch)
  255. if err != nil && ctx.Err() == nil && group != nil && group.CodexModelsManifestConfig.FallbackToScheduler {
  256. results, err = s.fetchScheduledOpenAIModels(ctx, group, maxAccountSwitches, fetch)
  257. }
  258. if err != nil {
  259. return nil, nil, err
  260. }
  261. models := make([]json.RawMessage, 0)
  262. modelIDs := make([]string, 0)
  263. byID := make(map[string]json.RawMessage)
  264. for _, result := range results {
  265. _, entries, err := modelCatalogEntries(result.response.Body, "data")
  266. if err != nil {
  267. return nil, nil, err
  268. }
  269. for _, raw := range entries {
  270. var model struct {
  271. ID string `json:"id"`
  272. }
  273. if err := json.Unmarshal(raw, &model); err != nil {
  274. return nil, nil, err
  275. }
  276. if _, exists := byID[model.ID]; !exists {
  277. byID[model.ID] = raw
  278. modelIDs = append(modelIDs, model.ID)
  279. models = append(models, raw)
  280. }
  281. }
  282. }
  283. if group.ModelAllowlistEnabled() {
  284. models = selectModelCatalogEntries(byID, group.ModelAllowlist.FilterForListing(modelIDs))
  285. }
  286. body, err := json.Marshal(struct {
  287. Object string `json:"object"`
  288. Data []json.RawMessage `json:"data"`
  289. }{Object: "list", Data: models})
  290. if err != nil {
  291. return nil, nil, err
  292. }
  293. response := &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}
  294. return openAIModelsResponseForClient(response, ifNoneMatch), results[0].account, nil
  295. }
  296. func selectModelCatalogEntries(byID map[string]json.RawMessage, selected []string) []json.RawMessage {
  297. models := make([]json.RawMessage, 0, len(selected))
  298. seen := make(map[string]struct{}, len(selected))
  299. for _, id := range selected {
  300. id = strings.TrimSpace(id)
  301. if _, ok := seen[id]; ok {
  302. continue
  303. }
  304. if raw, exists := byID[id]; exists {
  305. models = append(models, raw)
  306. seen[id] = struct{}{}
  307. }
  308. }
  309. return models
  310. }
  311. func orderPinnedCodexModelsBySelection(body []byte, allowlist GroupModelAllowlist) ([]byte, error) {
  312. envelope, entries, err := modelCatalogEntries(body, "models")
  313. if err != nil {
  314. return nil, err
  315. }
  316. byID := make(map[string]json.RawMessage, len(entries))
  317. modelIDs := make([]string, 0, len(entries))
  318. for _, raw := range entries {
  319. var entry struct {
  320. Slug string `json:"slug"`
  321. }
  322. if err := json.Unmarshal(raw, &entry); err != nil {
  323. return nil, err
  324. }
  325. byID[entry.Slug] = raw
  326. modelIDs = append(modelIDs, entry.Slug)
  327. }
  328. envelope["models"], err = json.Marshal(selectModelCatalogEntries(byID, allowlist.FilterForListing(modelIDs)))
  329. if err != nil {
  330. return nil, err
  331. }
  332. return json.Marshal(envelope)
  333. }
  334. func (s *OpenAIGatewayService) fetchScheduledOpenAIModels(ctx context.Context, group *Group, maxSwitches int, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) {
  335. if maxSwitches <= 0 {
  336. maxSwitches = 3
  337. }
  338. excluded := make(map[int64]struct{})
  339. var lastErr error
  340. for attempt := 0; attempt <= maxSwitches; attempt++ {
  341. if err := ctx.Err(); err != nil {
  342. return nil, err
  343. }
  344. account, err := s.SelectAccountForModelWithExclusions(ctx, &group.ID, "", "", excluded)
  345. if err != nil {
  346. if lastErr != nil {
  347. return nil, lastErr
  348. }
  349. return nil, ErrNoPinnedCodexModelsAccounts
  350. }
  351. response, err := fetch(ctx, account)
  352. if err == nil {
  353. return []pinnedOpenAIModelsResult{{account: account, response: response}}, nil
  354. }
  355. lastErr = err
  356. if !IsRetryableCodexModelsManifestError(err) {
  357. return nil, err
  358. }
  359. excluded[account.ID] = struct{}{}
  360. }
  361. return nil, lastErr
  362. }