openai_codex_models_pinned.go 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. package service
  2. import (
  3. "bytes"
  4. "context"
  5. "encoding/json"
  6. "errors"
  7. "fmt"
  8. "log/slog"
  9. "net/http"
  10. "strings"
  11. "sync"
  12. "time"
  13. infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
  14. )
  15. // ErrNoPinnedCodexModelsAccounts signals that an enabled pinned-accounts
  16. // manifest config has no usable account right now (inactive, unschedulable,
  17. // or expired members are skipped, and remaining IDs are no longer bound).
  18. var ErrNoPinnedCodexModelsAccounts = errors.New("no usable pinned codex models manifest accounts")
  19. // isPinnedCodexModelsAccountUsable decides whether a pinned account may fetch
  20. // the manifest. Deliberately narrower than Account.IsSchedulable(): priority,
  21. // load factor, rate-limit windows, overload windows and temporary
  22. // unschedulable cooldowns are ignored, because the pinned mode exists to keep
  23. // the manifest deterministic regardless of scheduler state.
  24. func isPinnedCodexModelsAccountUsable(account *Account) bool {
  25. if account == nil || !account.IsActive() || !account.Schedulable {
  26. return false
  27. }
  28. if account.AutoPauseOnExpired && account.ExpiresAt != nil && !account.ExpiresAt.After(time.Now()) {
  29. return false
  30. }
  31. return true
  32. }
  33. // mergeCodexModelsManifestBodies merges manifest bodies from the pinned
  34. // accounts: the first body's top-level envelope is the base, and the models
  35. // arrays are unioned by slug with first-seen (config-order) entries winning.
  36. // Entries without a usable slug are kept once in order of appearance.
  37. func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) {
  38. if len(bodies) == 0 {
  39. return nil, errors.New("no codex models manifest bodies to merge")
  40. }
  41. var base map[string]json.RawMessage
  42. if err := json.Unmarshal(bodies[0], &base); err != nil {
  43. return nil, fmt.Errorf("decode base codex models manifest: %w", err)
  44. }
  45. if base == nil {
  46. return nil, errors.New("base codex models manifest is not a JSON object")
  47. }
  48. mergedModels := make([]json.RawMessage, 0, len(bodies)*8)
  49. seenSlug := make(map[string]struct{})
  50. seenSlugLess := make(map[string]struct{})
  51. for _, body := range bodies {
  52. var envelope map[string]json.RawMessage
  53. if err := json.Unmarshal(body, &envelope); err != nil {
  54. return nil, fmt.Errorf("decode codex models manifest body: %w", err)
  55. }
  56. modelsRaw, ok := envelope["models"]
  57. if !ok {
  58. continue
  59. }
  60. var models []json.RawMessage
  61. if err := json.Unmarshal(modelsRaw, &models); err != nil {
  62. return nil, fmt.Errorf("decode codex models manifest models array: %w", err)
  63. }
  64. for _, raw := range models {
  65. var model struct {
  66. Slug string `json:"slug"`
  67. }
  68. slug := ""
  69. if err := json.Unmarshal(raw, &model); err == nil {
  70. slug = strings.TrimSpace(model.Slug)
  71. }
  72. if slug != "" {
  73. if _, exists := seenSlug[slug]; exists {
  74. continue
  75. }
  76. seenSlug[slug] = struct{}{}
  77. } else {
  78. fingerprint := string(bytes.TrimSpace(raw))
  79. if _, exists := seenSlugLess[fingerprint]; exists {
  80. continue
  81. }
  82. seenSlugLess[fingerprint] = struct{}{}
  83. }
  84. mergedModels = append(mergedModels, raw)
  85. }
  86. }
  87. encodedModels, err := json.Marshal(mergedModels)
  88. if err != nil {
  89. return nil, fmt.Errorf("encode merged codex models: %w", err)
  90. }
  91. base["models"] = encodedModels
  92. merged, err := json.Marshal(base)
  93. if err != nil {
  94. return nil, fmt.Errorf("encode merged codex models manifest: %w", err)
  95. }
  96. return merged, nil
  97. }
  98. // FetchPinnedCodexModelsManifest fetches the Codex models manifest using only
  99. // the accounts pinned on the group's codex_models_manifest_config, ignoring
  100. // scheduler priorities, load factors and rate-limit/overload windows. All
  101. // usable accounts are fetched concurrently; successful bodies are merged by
  102. // slug with config-order precedence. It returns the merged manifest and the
  103. // first successful account (in config order) for ops attribution.
  104. //
  105. // - no usable account → ErrNoPinnedCodexModelsAccounts
  106. // - every fetch failed → the last upstream error
  107. // - partial failure → successful accounts are still merged and a warning
  108. // naming the failed account IDs is logged.
  109. func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*OpenAIModelsResponse, *Account, error) {
  110. results, err := s.fetchPinnedOpenAIModels(ctx, group, func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) {
  111. manifest, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "")
  112. if err != nil {
  113. return nil, err
  114. }
  115. if err := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
  116. return nil, err
  117. }
  118. if err := ApplyPinnedCodexModelsMapping(manifest, account, group); err != nil {
  119. return nil, err
  120. }
  121. return manifest, nil
  122. })
  123. if err != nil {
  124. return nil, nil, err
  125. }
  126. bodies := make([][]byte, 0, len(results))
  127. for _, result := range results {
  128. bodies = append(bodies, result.response.Body)
  129. }
  130. merged, err := mergeCodexModelsManifestBodies(bodies)
  131. if err != nil {
  132. return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err)
  133. }
  134. return &OpenAIModelsResponse{Body: merged, ETag: codexModelsManifestBodyETag(merged)}, results[0].account, nil
  135. }
  136. type pinnedOpenAIModelsResult struct {
  137. account *Account
  138. response *OpenAIModelsResponse
  139. }
  140. // fetchPinnedOpenAIModels shares membership, eligibility, fanout and partial
  141. // failure policy between ordinary model lists and Codex manifests.
  142. func (s *OpenAIGatewayService) fetchPinnedOpenAIModels(ctx context.Context, group *Group, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) {
  143. if s == nil || s.accountRepo == nil || group == nil {
  144. return nil, ErrNoPinnedCodexModelsAccounts
  145. }
  146. cfg := group.CodexModelsManifestConfig
  147. if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 {
  148. return nil, ErrNoPinnedCodexModelsAccounts
  149. }
  150. members, err := s.accountRepo.ListByGroup(ctx, group.ID)
  151. if err != nil {
  152. return nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err)
  153. }
  154. memberByID := make(map[int64]Account, len(members))
  155. for _, member := range members {
  156. memberByID[member.ID] = member
  157. }
  158. // 按配置顺序筛选可用账号;已解绑/已删除的 ID 直接跳过。
  159. usable := make([]Account, 0, len(cfg.AccountIDs))
  160. for _, id := range cfg.AccountIDs {
  161. member, ok := memberByID[id]
  162. if !ok || member.Platform != PlatformOpenAI {
  163. continue
  164. }
  165. if !isPinnedCodexModelsAccountUsable(&member) {
  166. continue
  167. }
  168. usable = append(usable, member)
  169. }
  170. if len(usable) == 0 {
  171. return nil, ErrNoPinnedCodexModelsAccounts
  172. }
  173. results := make([]pinnedOpenAIModelsResult, len(usable))
  174. fetchErrs := make([]error, len(usable))
  175. var fetchGroup sync.WaitGroup
  176. for i := range usable {
  177. fetchGroup.Add(1)
  178. go func() {
  179. defer fetchGroup.Done()
  180. response, err := fetch(ctx, &usable[i])
  181. results[i] = pinnedOpenAIModelsResult{account: &usable[i], response: response}
  182. fetchErrs[i] = err
  183. }()
  184. }
  185. fetchGroup.Wait()
  186. if err := ctx.Err(); err != nil {
  187. return nil, err
  188. }
  189. successes := make([]pinnedOpenAIModelsResult, 0, len(results))
  190. failedIDs := make([]int64, 0)
  191. var lastErr error
  192. for i, result := range results {
  193. if fetchErrs[i] != nil || result.response == nil {
  194. failedIDs = append(failedIDs, usable[i].ID)
  195. if fetchErrs[i] != nil {
  196. lastErr = fetchErrs[i]
  197. }
  198. continue
  199. }
  200. successes = append(successes, result)
  201. }
  202. if len(successes) == 0 {
  203. if lastErr == nil {
  204. lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_FAILED", "pinned model discovery accounts all failed")
  205. }
  206. return nil, lastErr
  207. }
  208. if len(failedIDs) > 0 {
  209. slog.Warn("openai_models_pinned_partial_failure", "group_id", group.ID, "failed_account_ids", failedIDs)
  210. }
  211. return successes, nil
  212. }