| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225 |
- package service
- import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "log/slog"
- "net/http"
- "strings"
- "sync"
- "time"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- )
- // ErrNoPinnedCodexModelsAccounts signals that an enabled pinned-accounts
- // manifest config has no usable account right now (inactive, unschedulable,
- // or expired members are skipped, and remaining IDs are no longer bound).
- var ErrNoPinnedCodexModelsAccounts = errors.New("no usable pinned codex models manifest accounts")
- // isPinnedCodexModelsAccountUsable decides whether a pinned account may fetch
- // the manifest. Deliberately narrower than Account.IsSchedulable(): priority,
- // load factor, rate-limit windows, overload windows and temporary
- // unschedulable cooldowns are ignored, because the pinned mode exists to keep
- // the manifest deterministic regardless of scheduler state.
- func isPinnedCodexModelsAccountUsable(account *Account) bool {
- if account == nil || !account.IsActive() || !account.Schedulable {
- return false
- }
- if account.AutoPauseOnExpired && account.ExpiresAt != nil && !account.ExpiresAt.After(time.Now()) {
- return false
- }
- return true
- }
- // mergeCodexModelsManifestBodies merges manifest bodies from the pinned
- // accounts: the first body's top-level envelope is the base, and the models
- // arrays are unioned by slug with first-seen (config-order) entries winning.
- // Entries without a usable slug are kept once in order of appearance.
- func mergeCodexModelsManifestBodies(bodies [][]byte) ([]byte, error) {
- if len(bodies) == 0 {
- return nil, errors.New("no codex models manifest bodies to merge")
- }
- var base map[string]json.RawMessage
- if err := json.Unmarshal(bodies[0], &base); err != nil {
- return nil, fmt.Errorf("decode base codex models manifest: %w", err)
- }
- if base == nil {
- return nil, errors.New("base codex models manifest is not a JSON object")
- }
- mergedModels := make([]json.RawMessage, 0, len(bodies)*8)
- seenSlug := make(map[string]struct{})
- seenSlugLess := make(map[string]struct{})
- for _, body := range bodies {
- var envelope map[string]json.RawMessage
- if err := json.Unmarshal(body, &envelope); err != nil {
- return nil, fmt.Errorf("decode codex models manifest body: %w", err)
- }
- modelsRaw, ok := envelope["models"]
- if !ok {
- continue
- }
- var models []json.RawMessage
- if err := json.Unmarshal(modelsRaw, &models); err != nil {
- return nil, fmt.Errorf("decode codex models manifest models array: %w", err)
- }
- for _, raw := range models {
- var model struct {
- Slug string `json:"slug"`
- }
- slug := ""
- if err := json.Unmarshal(raw, &model); err == nil {
- slug = strings.TrimSpace(model.Slug)
- }
- if slug != "" {
- if _, exists := seenSlug[slug]; exists {
- continue
- }
- seenSlug[slug] = struct{}{}
- } else {
- fingerprint := string(bytes.TrimSpace(raw))
- if _, exists := seenSlugLess[fingerprint]; exists {
- continue
- }
- seenSlugLess[fingerprint] = struct{}{}
- }
- mergedModels = append(mergedModels, raw)
- }
- }
- encodedModels, err := json.Marshal(mergedModels)
- if err != nil {
- return nil, fmt.Errorf("encode merged codex models: %w", err)
- }
- base["models"] = encodedModels
- merged, err := json.Marshal(base)
- if err != nil {
- return nil, fmt.Errorf("encode merged codex models manifest: %w", err)
- }
- return merged, nil
- }
- // FetchPinnedCodexModelsManifest fetches the Codex models manifest using only
- // the accounts pinned on the group's codex_models_manifest_config, ignoring
- // scheduler priorities, load factors and rate-limit/overload windows. All
- // usable accounts are fetched concurrently; successful bodies are merged by
- // slug with config-order precedence. It returns the merged manifest and the
- // first successful account (in config order) for ops attribution.
- //
- // - no usable account → ErrNoPinnedCodexModelsAccounts
- // - every fetch failed → the last upstream error
- // - partial failure → successful accounts are still merged and a warning
- // naming the failed account IDs is logged.
- func (s *OpenAIGatewayService) FetchPinnedCodexModelsManifest(ctx context.Context, group *Group, clientVersion string) (*OpenAIModelsResponse, *Account, error) {
- results, err := s.fetchPinnedOpenAIModels(ctx, group, func(ctx context.Context, account *Account) (*OpenAIModelsResponse, error) {
- manifest, err := s.FetchCodexModelsManifest(ctx, account, clientVersion, "")
- if err != nil {
- return nil, err
- }
- if err := s.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
- return nil, err
- }
- if err := ApplyPinnedCodexModelsMapping(manifest, account, group); err != nil {
- return nil, err
- }
- return manifest, nil
- })
- if err != nil {
- return nil, nil, err
- }
- bodies := make([][]byte, 0, len(results))
- for _, result := range results {
- bodies = append(bodies, result.response.Body)
- }
- merged, err := mergeCodexModelsManifestBodies(bodies)
- if err != nil {
- return nil, nil, fmt.Errorf("merge pinned codex models manifests: %w", err)
- }
- return &OpenAIModelsResponse{Body: merged, ETag: codexModelsManifestBodyETag(merged)}, results[0].account, nil
- }
- type pinnedOpenAIModelsResult struct {
- account *Account
- response *OpenAIModelsResponse
- }
- // fetchPinnedOpenAIModels shares membership, eligibility, fanout and partial
- // failure policy between ordinary model lists and Codex manifests.
- func (s *OpenAIGatewayService) fetchPinnedOpenAIModels(ctx context.Context, group *Group, fetch func(context.Context, *Account) (*OpenAIModelsResponse, error)) ([]pinnedOpenAIModelsResult, error) {
- if s == nil || s.accountRepo == nil || group == nil {
- return nil, ErrNoPinnedCodexModelsAccounts
- }
- cfg := group.CodexModelsManifestConfig
- if group.Platform != PlatformOpenAI || !cfg.Enabled || len(cfg.AccountIDs) == 0 {
- return nil, ErrNoPinnedCodexModelsAccounts
- }
- members, err := s.accountRepo.ListByGroup(ctx, group.ID)
- if err != nil {
- return nil, fmt.Errorf("load pinned codex models manifest accounts: %w", err)
- }
- memberByID := make(map[int64]Account, len(members))
- for _, member := range members {
- memberByID[member.ID] = member
- }
- // 按配置顺序筛选可用账号;已解绑/已删除的 ID 直接跳过。
- usable := make([]Account, 0, len(cfg.AccountIDs))
- for _, id := range cfg.AccountIDs {
- member, ok := memberByID[id]
- if !ok || member.Platform != PlatformOpenAI {
- continue
- }
- if !isPinnedCodexModelsAccountUsable(&member) {
- continue
- }
- usable = append(usable, member)
- }
- if len(usable) == 0 {
- return nil, ErrNoPinnedCodexModelsAccounts
- }
- results := make([]pinnedOpenAIModelsResult, len(usable))
- fetchErrs := make([]error, len(usable))
- var fetchGroup sync.WaitGroup
- for i := range usable {
- fetchGroup.Add(1)
- go func() {
- defer fetchGroup.Done()
- response, err := fetch(ctx, &usable[i])
- results[i] = pinnedOpenAIModelsResult{account: &usable[i], response: response}
- fetchErrs[i] = err
- }()
- }
- fetchGroup.Wait()
- if err := ctx.Err(); err != nil {
- return nil, err
- }
- successes := make([]pinnedOpenAIModelsResult, 0, len(results))
- failedIDs := make([]int64, 0)
- var lastErr error
- for i, result := range results {
- if fetchErrs[i] != nil || result.response == nil {
- failedIDs = append(failedIDs, usable[i].ID)
- if fetchErrs[i] != nil {
- lastErr = fetchErrs[i]
- }
- continue
- }
- successes = append(successes, result)
- }
- if len(successes) == 0 {
- if lastErr == nil {
- lastErr = infraerrors.New(http.StatusBadGateway, "OPENAI_MODELS_UPSTREAM_FAILED", "pinned model discovery accounts all failed")
- }
- return nil, lastErr
- }
- if len(failedIDs) > 0 {
- slog.Warn("openai_models_pinned_partial_failure", "group_id", group.ID, "failed_account_ids", failedIDs)
- }
- return successes, nil
- }
|