| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "net/http"
- "sort"
- "strings"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- // FetchOpenAIModelsList discovers a single account's raw public model catalog.
- // API keys use the standard endpoint; OAuth reuses the authenticated, cached
- // Codex source. Account mappings and group policy are applied after this cache.
- func (s *OpenAIGatewayService) FetchOpenAIModelsList(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) {
- if s == nil || account == nil {
- return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_MODELS_ACCOUNT_REQUIRED", "OpenAI account is required")
- }
- credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account)
- if err != nil {
- return nil, fmt.Errorf("resolve model list credentials: %w", err)
- }
- if credentialAccount.IsOpenAIOAuth() {
- clientVersion := CodexCanonicalClientVersion()
- if s.settingService != nil {
- clientVersion = s.settingService.GetOpenAICodexClientVersion(ctx)
- }
- response, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "")
- if err != nil {
- return nil, err
- }
- body, err := standardOpenAIModelsBody(response.Body, true)
- if err != nil {
- return nil, invalidOpenAIModelsList(err)
- }
- return &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}, nil
- }
- req, err := buildOpenAIAPIKeyModelsRequest(ctx, credentialAccount, s.validateUpstreamBaseURL)
- if err != nil {
- return nil, infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_REQUEST_INVALID", "cannot build upstream model list request: %v", err)
- }
- request := openAIModelsRequest{
- url: req.URL.String(), headers: req.Header,
- proxyURL: upstreamModelsProxyURL(account), accountID: account.ID,
- credentialAccountID: credentialAccount.ID, credentialAccount: credentialAccount,
- accountConcurrency: account.Concurrency, useAPIKeyUpstream: true,
- standardModelsList: true,
- }
- response, err := s.fetchCachedOpenAIModels(ctx, request, func(fetchCtx context.Context, etag string) (*OpenAIModelsResponse, error) {
- response, err := s.fetchOpenAIModelsUpstream(fetchCtx, request, etag)
- if err != nil || response.NotModified {
- return response, err
- }
- body, err := standardOpenAIModelsBody(response.Body, false)
- if err != nil {
- return nil, invalidOpenAIModelsList(err)
- }
- response.Body = body
- response.ETag = codexModelsManifestBodyETag(body)
- return response, nil
- }, "")
- if err != nil {
- return nil, err
- }
- if response.NotModified {
- return nil, invalidOpenAIModelsList(fmt.Errorf("upstream returned 304 without a cached catalog"))
- }
- return response, nil
- }
- func invalidOpenAIModelsList(err error) error {
- return &codexModelsManifestUpstreamError{
- err: infraerrors.Newf(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_INVALID", "invalid upstream model list: %v", err),
- retryable: true,
- }
- }
- // modelCatalogEntries requires a real array. An empty array is authoritative;
- // absent fields, null and error envelopes must not become successful empty lists.
- func modelCatalogEntries(body []byte, field string) (map[string]json.RawMessage, []json.RawMessage, error) {
- var envelope map[string]json.RawMessage
- if err := json.Unmarshal(body, &envelope); err != nil {
- return nil, nil, err
- }
- raw := bytes.TrimSpace(envelope[field])
- if len(raw) == 0 || raw[0] != '[' {
- return nil, nil, fmt.Errorf("missing or invalid %s array", field)
- }
- var entries []json.RawMessage
- if err := json.Unmarshal(raw, &entries); err != nil {
- return nil, nil, err
- }
- return envelope, entries, nil
- }
- func standardOpenAIModelsBody(body []byte, fromManifest bool) ([]byte, error) {
- field, idField := "data", "id"
- if fromManifest {
- field, idField = "models", "slug"
- }
- _, entries, err := modelCatalogEntries(body, field)
- if err != nil {
- return nil, err
- }
- models := make([]json.RawMessage, 0, len(entries))
- seen := make(map[string]struct{}, len(entries))
- for _, raw := range entries {
- var entry map[string]json.RawMessage
- if err := json.Unmarshal(raw, &entry); err != nil || entry == nil {
- return nil, fmt.Errorf("model entry must be an object")
- }
- var id string
- if err := json.Unmarshal(entry[idField], &id); err != nil || strings.TrimSpace(id) == "" {
- return nil, fmt.Errorf("model entry has no valid %s", idField)
- }
- id = strings.TrimSpace(id)
- if _, ok := seen[id]; ok || strings.Contains(id, "*") {
- continue
- }
- seen[id] = struct{}{}
- if fromManifest {
- entry = make(map[string]json.RawMessage)
- }
- entry["id"], _ = json.Marshal(id)
- entry["object"] = json.RawMessage(`"model"`)
- if len(entry["created"]) == 0 || string(entry["created"]) == "null" {
- entry["created"] = json.RawMessage(`0`)
- }
- if len(entry["owned_by"]) == 0 || string(entry["owned_by"]) == "null" {
- entry["owned_by"] = json.RawMessage(`"openai"`)
- }
- encoded, err := json.Marshal(entry)
- if err != nil {
- return nil, err
- }
- models = append(models, encoded)
- }
- return json.Marshal(struct {
- Object string `json:"object"`
- Data []json.RawMessage `json:"data"`
- }{Object: "list", Data: models})
- }
- // projectAccountModelsBody applies the same public-name mapping to both model
- // representations while retaining the source entry's metadata. It never changes
- // the shared response and never synthesizes models absent from this account.
- func projectAccountModelsBody(body []byte, account *Account, group *Group, codex bool) ([]byte, error) {
- if account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0 {
- return body, nil
- }
- field, idField := "data", "id"
- if codex {
- field, idField = "models", "slug"
- }
- envelope, entries, err := modelCatalogEntries(body, field)
- if err != nil {
- return nil, err
- }
- byID := make(map[string]json.RawMessage, len(entries))
- candidates := make([]string, 0, len(entries))
- for _, raw := range entries {
- var entry map[string]json.RawMessage
- if json.Unmarshal(raw, &entry) != nil {
- continue
- }
- var id string
- if json.Unmarshal(entry[idField], &id) != nil {
- continue
- }
- id = strings.TrimSpace(id)
- if id == "" || strings.Contains(id, "*") {
- continue
- }
- // Apply the allowlist to public names after mapping, not upstream targets.
- if codex {
- if isCodexDedicatedMediaModel(id) {
- continue
- }
- if strings.HasPrefix(id, codexAutoModelPrefix) && len(FilterCodexModelIDsForGroup([]string{id}, group)) == 0 {
- continue
- }
- }
- if _, ok := byID[id]; !ok {
- byID[id] = raw
- candidates = append(candidates, id)
- }
- }
- aliases := make([]string, 0, len(account.GetModelMapping()))
- for alias := range account.GetModelMapping() {
- aliases = append(aliases, alias)
- }
- sort.Strings(aliases)
- candidates = append(candidates, aliases...)
- if group.ModelAllowlistEnabled() {
- candidates = append(candidates, group.ModelAllowlist.Models...)
- }
- projected := make([]json.RawMessage, 0, len(candidates))
- seen := make(map[string]struct{}, len(candidates))
- for _, id := range candidates {
- id = strings.TrimSpace(id)
- if id == "" || strings.Contains(id, "*") {
- continue
- }
- if _, ok := seen[id]; ok {
- continue
- }
- target, matched := account.ResolveMappedModel(id)
- raw, available := byID[strings.TrimSpace(target)]
- if !matched || !available {
- continue
- }
- seen[id] = struct{}{}
- var entry map[string]json.RawMessage
- if err := json.Unmarshal(raw, &entry); err != nil {
- return nil, err
- }
- entry[idField], _ = json.Marshal(id)
- if id != target {
- entry["display_name"], _ = json.Marshal(id)
- }
- encoded, err := json.Marshal(entry)
- if err != nil {
- return nil, err
- }
- projected = append(projected, encoded)
- }
- envelope[field], err = json.Marshal(projected)
- if err != nil {
- return nil, err
- }
- return json.Marshal(envelope)
- }
- // ApplyPinnedCodexModelsMapping is used by pinned discovery and its scheduler
- // fallback. The ordinary (non-pinned) Codex path retains its local catalog policy.
- func ApplyPinnedCodexModelsMapping(response *OpenAIModelsResponse, account *Account, group *Group) error {
- if group == nil || group.Platform != PlatformOpenAI || !group.CodexModelsManifestConfig.Enabled {
- return nil
- }
- body, err := projectAccountModelsBody(response.Body, account, group, true)
- if err != nil {
- return err
- }
- response.Body = body
- response.ETag = codexModelsManifestBodyETag(body)
- return nil
- }
- // FetchPinnedOpenAIModelsList includes explicitly enabled scheduler fallback.
- // An authoritative empty catalog is success, including after group filtering.
- func (s *OpenAIGatewayService) FetchPinnedOpenAIModelsList(ctx context.Context, group *Group, maxAccountSwitches int, ifNoneMatch string) (*OpenAIModelsResponse, *Account, error) {
- fetch := func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) {
- response, err := s.FetchOpenAIModelsList(ctx, account)
- if err != nil {
- return nil, err
- }
- response.Body, err = projectAccountModelsBody(response.Body, account, group, false)
- return response, err
- }
- results, err := s.fetchPinnedOpenAIModels(ctx, group, fetch)
- if err != nil && ctx.Err() == nil && group != nil && group.CodexModelsManifestConfig.FallbackToScheduler {
- results, err = s.fetchScheduledOpenAIModels(ctx, group, maxAccountSwitches, fetch)
- }
- if err != nil {
- return nil, nil, err
- }
- models := make([]json.RawMessage, 0)
- modelIDs := make([]string, 0)
- byID := make(map[string]json.RawMessage)
- for _, result := range results {
- _, entries, err := modelCatalogEntries(result.response.Body, "data")
- if err != nil {
- return nil, nil, err
- }
- for _, raw := range entries {
- var model struct {
- ID string `json:"id"`
- }
- if err := json.Unmarshal(raw, &model); err != nil {
- return nil, nil, err
- }
- if _, exists := byID[model.ID]; !exists {
- byID[model.ID] = raw
- modelIDs = append(modelIDs, model.ID)
- models = append(models, raw)
- }
- }
- }
- if group.ModelAllowlistEnabled() {
- models = selectModelCatalogEntries(byID, group.ModelAllowlist.FilterForListing(modelIDs))
- }
- body, err := json.Marshal(struct {
- Object string `json:"object"`
- Data []json.RawMessage `json:"data"`
- }{Object: "list", Data: models})
- if err != nil {
- return nil, nil, err
- }
- response := &OpenAIModelsResponse{Body: body, ETag: codexModelsManifestBodyETag(body)}
- return openAIModelsResponseForClient(response, ifNoneMatch), results[0].account, nil
- }
- func selectModelCatalogEntries(byID map[string]json.RawMessage, selected []string) []json.RawMessage {
- models := make([]json.RawMessage, 0, len(selected))
- seen := make(map[string]struct{}, len(selected))
- for _, id := range selected {
- id = strings.TrimSpace(id)
- if _, ok := seen[id]; ok {
- continue
- }
- if raw, exists := byID[id]; exists {
- models = append(models, raw)
- seen[id] = struct{}{}
- }
- }
- return models
- }
- func orderPinnedCodexModelsBySelection(body []byte, allowlist GroupModelAllowlist) ([]byte, error) {
- envelope, entries, err := modelCatalogEntries(body, "models")
- if err != nil {
- return nil, err
- }
- byID := make(map[string]json.RawMessage, len(entries))
- modelIDs := make([]string, 0, len(entries))
- for _, raw := range entries {
- var entry struct {
- Slug string `json:"slug"`
- }
- if err := json.Unmarshal(raw, &entry); err != nil {
- return nil, err
- }
- byID[entry.Slug] = raw
- modelIDs = append(modelIDs, entry.Slug)
- }
- envelope["models"], err = json.Marshal(selectModelCatalogEntries(byID, allowlist.FilterForListing(modelIDs)))
- if err != nil {
- return nil, err
- }
- return json.Marshal(envelope)
- }
- func (s *OpenAIGatewayService) fetchScheduledOpenAIModels(ctx context.Context, group *Group, maxSwitches int, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) {
- if maxSwitches <= 0 {
- maxSwitches = 3
- }
- excluded := make(map[int64]struct{})
- var lastErr error
- for attempt := 0; attempt <= maxSwitches; attempt++ {
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- account, err := s.SelectAccountForModelWithExclusions(ctx, &group.ID, "", "", excluded)
- if err != nil {
- if lastErr != nil {
- return nil, lastErr
- }
- return nil, ErrNoPinnedCodexModelsAccounts
- }
- response, err := fetch(ctx, account)
- if err == nil {
- return []pinnedOpenAIModelsResult{{account: account, response: response}}, nil
- }
- lastErr = err
- if !IsRetryableCodexModelsManifestError(err) {
- return nil, err
- }
- excluded[account.ID] = struct{}{}
- }
- return nil, lastErr
- }
|