package service import ( "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "sort" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" ) const ( upstreamModelsBodyLimit int64 = 8 << 20 modelsDevRegistryURL = "https://models.dev/api.json" modelsDevRegistryTTL = 6 * time.Hour UpstreamModelMetadataExtraKey = "upstream_model_metadata" UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete" UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial" ) type UpstreamModelMetadata struct { ID string `json:"id"` DisplayName string `json:"display_name,omitempty"` Description string `json:"description,omitempty"` Reasoning *bool `json:"reasoning,omitempty"` DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"` SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` InputModalities []string `json:"input_modalities,omitempty"` ContextWindow int64 `json:"context_window,omitempty"` MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` CodexToolCapabilities map[string]json.RawMessage `json:"codex_tool_capabilities,omitempty"` } type UpstreamModelMetadataSnapshot struct { Source string `json:"source"` SyncedAt string `json:"synced_at"` Models map[string]UpstreamModelMetadata `json:"models"` } type UpstreamModelCatalog struct { Models []string `json:"models"` Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"` Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"` } type UpstreamModelSyncWarning struct { Code string `json:"code"` Message string `json:"message"` } type modelsDevProvider struct { ID string `json:"id"` Name string `json:"name"` API string `json:"api"` Models map[string]modelsDevModel `json:"models"` } type modelsDevModel struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Reasoning *bool `json:"reasoning"` ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` Modalities modelsDevModalities `json:"modalities"` Limit modelsDevLimit `json:"limit"` } type modelsDevReasoningOption struct { Type string `json:"type"` Values []any `json:"values"` } type modelsDevModalities struct { Input []string `json:"input"` Output []string `json:"output"` } type modelsDevLimit struct { Context int64 `json:"context"` Output int64 `json:"output"` } func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) { if a == nil { return } if a.Extra == nil { a.Extra = make(map[string]any) } a.Extra[UpstreamModelMetadataExtraKey] = snapshot } func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot { if a == nil || a.Extra == nil { return nil } raw, ok := a.Extra[UpstreamModelMetadataExtraKey] if !ok || raw == nil { return nil } body, err := json.Marshal(raw) if err != nil { return nil } var snapshot UpstreamModelMetadataSnapshot if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 { return nil } return &snapshot } func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) { snapshot := a.GetUpstreamModelMetadataSnapshot() if snapshot == nil { return UpstreamModelMetadata{}, false } metadata, ok := snapshot.Models[strings.TrimSpace(modelID)] return metadata, ok } // UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping. type UpstreamModelSyncErrorKind string const ( // UpstreamModelSyncErrorConfiguration means the account or server configuration cannot perform the sync. UpstreamModelSyncErrorConfiguration UpstreamModelSyncErrorKind = "configuration" // UpstreamModelSyncErrorUnsupported means the account format is intentionally unsupported for live model sync. UpstreamModelSyncErrorUnsupported UpstreamModelSyncErrorKind = "unsupported" // UpstreamModelSyncErrorUpstream means the configured upstream failed or returned an unusable response. UpstreamModelSyncErrorUpstream UpstreamModelSyncErrorKind = "upstream" // UpstreamModelSyncErrorInternal means local persistence or service state failed after a valid upstream response. UpstreamModelSyncErrorInternal UpstreamModelSyncErrorKind = "internal" ) // UpstreamModelSyncError keeps internal failure details wrapped while exposing a safe client message. type UpstreamModelSyncError struct { Kind UpstreamModelSyncErrorKind Message string StatusCode int Err error } func (e *UpstreamModelSyncError) Error() string { if e == nil { return "" } if e.Err == nil { return e.Message } return e.Message + ": " + e.Err.Error() } func (e *UpstreamModelSyncError) Unwrap() error { if e == nil { return nil } return e.Err } // SafeMessage returns the sanitized message that can be sent to API clients. func (e *UpstreamModelSyncError) SafeMessage() string { if e == nil || strings.TrimSpace(e.Message) == "" { return "Failed to sync upstream models" } return e.Message } func newUpstreamModelSyncConfigError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorConfiguration, Message: message, Err: err} } func newUpstreamModelSyncUnsupportedError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUnsupported, Message: message, Err: err} } func newUpstreamModelSyncUpstreamError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err} } func newUpstreamModelSyncInternalError(message string, err error) error { return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorInternal, Message: message, Err: err} } // FetchUpstreamSupportedModels fetches only live model IDs. The admin sync path // uses SyncUpstreamModelCatalog so capability metadata can also be persisted. func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, account *Account) ([]string, error) { models, _, err := s.fetchUpstreamModelList(ctx, account) return models, err } // SyncUpstreamModelCatalog fetches the account's live model list, enriches // missing capability fields from the provider registry used by the upstream, // and persists a normalized account snapshot when complete metadata is available. // // Persistence is per-model: models with complete capability fields are saved even // when other IDs in the same sync remain incomplete. An incomplete warning is // still returned so admins can tell ID sync succeeded without a full capability // snapshot. When no model is complete, the existing account snapshot is left // untouched. func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, account *Account) (*UpstreamModelCatalog, error) { models, body, err := s.fetchUpstreamModelList(ctx, account) liveListAvailable := err == nil if err != nil { configuredModels := configuredUpstreamModelsForCapabilitySync(account) if !upstreamModelListEndpointUnsupported(err) || len(configuredModels) == 0 { return nil, err } models = configuredModels body = nil slog.Info("upstream model list endpoint unavailable; using configured models for capability sync", "account_id", upstreamModelSyncAccountID(account), "platform", upstreamModelSyncPlatform(account), "status_code", upstreamModelSyncStatusCode(err), "model_count", len(models), ) } catalog := &UpstreamModelCatalog{Models: models, Metadata: make(map[string]UpstreamModelMetadata)} if len(body) > 0 { _, directMetadata, parseErr := extractUpstreamModelCatalog(body, account != nil && account.IsGrok()) if parseErr == nil { catalog.Metadata = directMetadata } } // Capability enrichment also covers concrete model_mapping targets. Admins may // whitelist models that the live /models list omitted; those still need registry // metadata so Codex catalogs can advertise reasoning and modalities. enrichIDs := dedupeAndSortModelIDs(append(append([]string{}, models...), configuredUpstreamModelsForCapabilitySync(account)...)) // Dedicated image/video generators are not Codex agent catalog entries and often // omit context windows in public registries. Keep them out of completeness checks // so they do not mask successful agent-model capability sync. capabilityIDs := capabilitySyncModelIDs(enrichIDs) source := "upstream" if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, enrichIDs); registryErr == nil { for modelID, fallback := range registryMetadata { current := catalog.Metadata[modelID] merged, changed := mergeUpstreamModelMetadata(current, fallback) catalog.Metadata[modelID] = merged if changed { source = "models.dev" } } } else { slog.Warn("upstream model capability metadata enrichment failed", "account_id", upstreamModelSyncAccountID(account), "platform", upstreamModelSyncPlatform(account), "error", registryErr, ) } } completeMetadata := completeUpstreamModelMetadataSubset(capabilityIDs, catalog.Metadata) persistedCapabilities := false if len(completeMetadata) > 0 && account != nil && account.ID > 0 && s.accountRepo != nil { // Retain known metadata only for models still listed or explicitly mapped. if previous := account.GetUpstreamModelMetadataSnapshot(); previous != nil { retainedModels := capabilityIDs if !liveListAvailable { retainedModels = append([]string(nil), capabilityIDs...) for modelID := range previous.Models { retainedModels = append(retainedModels, modelID) } } for _, modelID := range retainedModels { old, exists := previous.Models[modelID] if !exists { continue } if entry, ok := completeMetadata[modelID]; ok { if entry.CodexToolCapabilities == nil { entry.CodexToolCapabilities = make(map[string]json.RawMessage) } applyCodexToolCapabilities(entry.CodexToolCapabilities, old.CodexToolCapabilities, false) completeMetadata[modelID] = entry } else { completeMetadata[modelID] = old } } } snapshot := UpstreamModelMetadataSnapshot{ Source: source, SyncedAt: time.Now().UTC().Format(time.RFC3339), Models: completeMetadata, } if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{UpstreamModelMetadataExtraKey: snapshot}); err != nil { return nil, newUpstreamModelSyncInternalError("Failed to save upstream model metadata", err) } account.SetUpstreamModelMetadataSnapshot(snapshot) persistedCapabilities = true } if upstreamCatalogNeedsRegistry(capabilityIDs, catalog.Metadata) { if persistedCapabilities { catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ Code: UpstreamModelMetadataPartialCode, Message: "Some model capabilities were saved; remaining models are still incomplete.", }) } else { catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{ Code: UpstreamModelMetadataIncompleteCode, Message: "Model IDs were synced, but capability metadata is incomplete.", }) } } return catalog, nil } func upstreamModelSyncStatusCode(err error) int { var syncErr *UpstreamModelSyncError if errors.As(err, &syncErr) { return syncErr.StatusCode } return 0 } func upstreamModelListEndpointUnsupported(err error) bool { statusCode := upstreamModelSyncStatusCode(err) return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed } func configuredUpstreamModelsForCapabilitySync(account *Account) []string { if account == nil { return nil } models := make([]string, 0) for _, mappedModel := range account.GetModelMapping() { mappedModel = strings.TrimSpace(mappedModel) if mappedModel == "" || strings.Contains(mappedModel, "*") { continue } models = append(models, mappedModel) } return dedupeAndSortModelIDs(models) } func capabilitySyncModelIDs(modelIDs []string) []string { filtered := make([]string, 0, len(modelIDs)) for _, modelID := range modelIDs { modelID = strings.TrimSpace(modelID) if modelID == "" || isCodexDedicatedMediaModel(modelID) { continue } filtered = append(filtered, modelID) } return filtered } func upstreamModelSyncAccountID(account *Account) int64 { if account == nil { return 0 } return account.ID } func upstreamModelSyncPlatform(account *Account) string { if account == nil { return "" } return account.Platform } func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamModelMetadata) bool { for _, modelID := range models { modelID = strings.TrimSpace(modelID) model, ok := metadata[modelID] if !ok || !upstreamModelMetadataIsComplete(model) { return true } } return false } func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { return strings.TrimSpace(metadata.DisplayName) != "" || strings.TrimSpace(metadata.Description) != "" || metadata.Reasoning != nil || len(metadata.SupportedReasoningLevels) > 0 || len(metadata.InputModalities) > 0 || len(metadata.CodexToolCapabilities) > 0 || metadata.ContextWindow > 0 || metadata.MaxOutputTokens > 0 } // upstreamModelMetadataIsComplete reports whether a snapshot entry is safe to // persist and later prefer over local Codex name-based fallbacks. func upstreamModelMetadataIsComplete(metadata UpstreamModelMetadata) bool { if metadata.Reasoning == nil { return false } if len(normalizeCodexInputModalities(metadata.InputModalities)) == 0 { return false } if metadata.ContextWindow <= 0 { return false } if *metadata.Reasoning && len(normalizeReasoningLevels(metadata.SupportedReasoningLevels)) == 0 { return false } return true } func completeUpstreamModelMetadataSubset( modelIDs []string, metadata map[string]UpstreamModelMetadata, ) map[string]UpstreamModelMetadata { if len(metadata) == 0 { return nil } complete := make(map[string]UpstreamModelMetadata) for _, modelID := range modelIDs { modelID = strings.TrimSpace(modelID) if modelID == "" { continue } entry, ok := metadata[modelID] if !ok || !upstreamModelMetadataIsComplete(entry) { continue } if strings.TrimSpace(entry.ID) == "" { entry.ID = modelID } complete[modelID] = entry } if len(complete) == 0 { return nil } return complete } func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (UpstreamModelMetadata, bool) { merged := primary changed := false if strings.TrimSpace(merged.ID) == "" && strings.TrimSpace(fallback.ID) != "" { merged.ID = strings.TrimSpace(fallback.ID) changed = true } if strings.TrimSpace(merged.DisplayName) == "" && strings.TrimSpace(fallback.DisplayName) != "" { merged.DisplayName = strings.TrimSpace(fallback.DisplayName) changed = true } if strings.TrimSpace(merged.Description) == "" && strings.TrimSpace(fallback.Description) != "" { merged.Description = strings.TrimSpace(fallback.Description) changed = true } if merged.Reasoning == nil && fallback.Reasoning != nil { reasoning := *fallback.Reasoning merged.Reasoning = &reasoning changed = true } if strings.TrimSpace(merged.DefaultReasoningLevel) == "" && strings.TrimSpace(fallback.DefaultReasoningLevel) != "" { merged.DefaultReasoningLevel = strings.TrimSpace(fallback.DefaultReasoningLevel) changed = true } if len(merged.SupportedReasoningLevels) == 0 && len(fallback.SupportedReasoningLevels) > 0 { merged.SupportedReasoningLevels = append([]string(nil), fallback.SupportedReasoningLevels...) changed = true } if len(merged.InputModalities) == 0 && len(fallback.InputModalities) > 0 { merged.InputModalities = append([]string(nil), fallback.InputModalities...) changed = true } if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 { merged.ContextWindow = fallback.ContextWindow changed = true } if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 { merged.MaxOutputTokens = fallback.MaxOutputTokens changed = true } return merged, changed } func (s *AccountTestService) fetchModelsDevMetadata( ctx context.Context, account *Account, modelIDs []string, ) (map[string]UpstreamModelMetadata, error) { if s == nil || s.httpUpstream == nil || account == nil { return nil, fmt.Errorf("model metadata registry is not configured") } registry, err := s.fetchModelsDevRegistry(ctx, account) if err != nil { return nil, err } provider, ok := matchModelsDevProvider(registry, upstreamModelRegistryBaseURL(account)) if !ok { return nil, fmt.Errorf("no model metadata provider matches account base URL") } metadata := make(map[string]UpstreamModelMetadata) for _, modelID := range modelIDs { modelID = strings.TrimSpace(modelID) model, found := provider.Models[modelID] if !found { for candidateID, candidate := range provider.Models { if strings.EqualFold(strings.TrimSpace(candidateID), modelID) || strings.EqualFold(strings.TrimSpace(candidate.ID), modelID) { model = candidate found = true break } } } if !found { continue } entry := upstreamMetadataFromModelsDevModel(modelID, model) if upstreamModelMetadataIsUseful(entry) { metadata[modelID] = entry } } return metadata, nil } func (s *AccountTestService) fetchModelsDevRegistry(ctx context.Context, account *Account) (map[string]modelsDevProvider, error) { now := time.Now() s.modelMetadataRegistryMu.Lock() if len(s.modelMetadataRegistry) > 0 && now.Sub(s.modelMetadataRegistryAt) < modelsDevRegistryTTL { cached := s.modelMetadataRegistry s.modelMetadataRegistryMu.Unlock() return cached, nil } s.modelMetadataRegistryMu.Unlock() req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsDevRegistryURL, nil) if err != nil { return nil, err } req.Header.Set("Accept", "application/json") resp, err := s.doUpstreamModelsRequest(req, upstreamModelsProxyURL(account), account) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("model metadata registry returned HTTP %d", resp.StatusCode) } body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1)) if err != nil { return nil, err } if int64(len(body)) > upstreamModelsBodyLimit { return nil, fmt.Errorf("model metadata registry response exceeds %d bytes", upstreamModelsBodyLimit) } var registry map[string]modelsDevProvider if err := json.Unmarshal(body, ®istry); err != nil { return nil, fmt.Errorf("parse model metadata registry: %w", err) } if len(registry) == 0 { return nil, fmt.Errorf("model metadata registry is empty") } s.modelMetadataRegistryMu.Lock() s.modelMetadataRegistry = registry s.modelMetadataRegistryAt = now s.modelMetadataRegistryMu.Unlock() return registry, nil } func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata { levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions) reasoning := model.Reasoning if reasoning == nil && len(levels) > 0 { inferred := true reasoning = &inferred } metadata := UpstreamModelMetadata{ ID: strings.TrimSpace(modelID), DisplayName: strings.TrimSpace(model.Name), Description: strings.TrimSpace(model.Description), Reasoning: reasoning, SupportedReasoningLevels: levels, InputModalities: normalizeCodexInputModalities(model.Modalities.Input), ContextWindow: model.Limit.Context, MaxOutputTokens: model.Limit.Output, } if len(levels) > 0 { metadata.DefaultReasoningLevel = levels[0] } if strings.TrimSpace(model.ID) != "" { metadata.ID = strings.TrimSpace(model.ID) } return metadata } func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string { levels := make([]string, 0) for _, option := range options { if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") { continue } for _, value := range option.Values { if value == nil { levels = append(levels, "none") continue } if effort, ok := value.(string); ok { levels = append(levels, effort) } } } return normalizeReasoningLevels(levels) } func upstreamModelRegistryBaseURL(account *Account) string { if account == nil { return "" } switch { case account.IsOpenAI() || account.IsCNProvider(): return account.GetOpenAIFormatBaseURL() case account.IsGrok(): return account.GetGrokBaseURL() case account.IsGemini(): return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) case account.IsAnthropic(): return account.GetBaseURL() case account.Platform == PlatformAntigravity: return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) default: return strings.TrimSpace(account.GetCredential("base_url")) } } func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { if provider, ok := matchModelsDevProviderByAPIURL(registry, accountBaseURL); ok { return provider, true } return matchModelsDevProviderByKnownHost(registry, accountBaseURL) } func matchModelsDevProviderByAPIURL(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL) if accountBaseURL == "" { return modelsDevProvider{}, false } var best modelsDevProvider bestScore := -1 for _, provider := range registry { providerBaseURL := normalizeModelRegistryBaseURL(provider.API) if providerBaseURL == "" { continue } if accountBaseURL != providerBaseURL && !strings.HasPrefix(accountBaseURL, providerBaseURL+"/") && !strings.HasPrefix(providerBaseURL, accountBaseURL+"/") { continue } if len(providerBaseURL) > bestScore { best = provider bestScore = len(providerBaseURL) } } return best, bestScore >= 0 } // matchModelsDevProviderByKnownHost covers first-party hosts whose models.dev // entries omit the `api` field (notably the official OpenAI provider). Custom // compatible gateways must still match by API URL so same-named models are not // cross-attributed across vendors. func matchModelsDevProviderByKnownHost(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) { host := modelRegistryHostname(accountBaseURL) if host == "" { return modelsDevProvider{}, false } providerID := "" switch host { case "api.openai.com", "chatgpt.com": providerID = "openai" default: return modelsDevProvider{}, false } provider, ok := registry[providerID] if !ok || len(provider.Models) == 0 { return modelsDevProvider{}, false } if strings.TrimSpace(provider.ID) == "" { provider.ID = providerID } return provider, true } func modelRegistryHostname(raw string) string { normalized := normalizeModelRegistryBaseURL(raw) if normalized == "" { return "" } parsed, err := url.Parse(normalized) if err != nil { return "" } return strings.ToLower(strings.TrimSpace(parsed.Hostname())) } func normalizeModelRegistryBaseURL(raw string) string { parsed, err := url.Parse(strings.TrimSpace(raw)) if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "" } path := strings.TrimRight(parsed.Path, "/") if strings.HasSuffix(strings.ToLower(path), "/models") { path = strings.TrimRight(path[:len(path)-len("/models")], "/") } return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path } func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) { if s == nil { return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil) } if account == nil { return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil) } if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey { models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account) return models, nil, err } if s.httpUpstream == nil { return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil) } req, err := s.buildUpstreamModelsRequest(ctx, account) if err != nil { return nil, nil, err } proxyURL := upstreamModelsProxyURL(account) resp, err := s.doUpstreamModelsRequest(req, proxyURL, account) if err != nil { return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err) } defer func() { _ = resp.Body.Close() }() bodyLimit := resolveModelsListReadLimit(s.cfg) body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+1)) if err != nil { return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err) } if int64(len(body)) > bodyLimit { return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit)) } if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, nil, &UpstreamModelSyncError{ Kind: UpstreamModelSyncErrorUpstream, Message: fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode), StatusCode: resp.StatusCode, Err: fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode), } } extractModels := extractUpstreamModelIDs if account.IsGrok() { extractModels = extractGrokUpstreamModelIDs } models, err := extractModels(body) if err != nil { return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err) } if len(models) == 0 { return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) } return models, body, nil } func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { switch { case account.Platform == PlatformAntigravity: return s.buildAntigravityAPIKeyModelsRequest(ctx, account) case account.IsGrok(): return s.buildGrokUpstreamModelsRequest(ctx, account) case account.IsOpenAI() || account.IsCNProvider(): // 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)复用 OpenAI /v1/models 探测。 return s.buildOpenAIUpstreamModelsRequest(ctx, account) case account.IsGemini(): return s.buildGeminiUpstreamModelsRequest(ctx, account) case account.IsAnthropic(): return s.buildAnthropicUpstreamModelsRequest(ctx, account) default: return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported platform for upstream model sync: %s", account.Platform), nil, ) } } func (s *AccountTestService) buildGrokUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { if account == nil { return nil, newUpstreamModelSyncConfigError("Account is required", nil) } var ( authToken string normalizedBaseURL string isOAuth = account.IsGrokOAuth() ) switch account.Type { case AccountTypeAPIKey: authToken = strings.TrimSpace(account.GetCredential("api_key")) if authToken == "" { return nil, newUpstreamModelSyncConfigError("No Grok API key is available", nil) } baseURL := strings.TrimSpace(account.GetCredential("base_url")) if baseURL == "" { baseURL = "https://api.x.ai" } validatedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } normalizedBaseURL = validatedBaseURL case AccountTypeOAuth: if s.grokTokenProvider == nil { return nil, newUpstreamModelSyncConfigError("Grok token provider is not configured", nil) } accessToken, err := s.grokTokenProvider.GetAccessTokenForManualTest(ctx, account) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to get Grok access token", err) } authToken = strings.TrimSpace(accessToken) if authToken == "" { return nil, newUpstreamModelSyncConfigError("No Grok access token is available", nil) } validator, err := grokBaseURLValidator(account, s.cfg) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } baseURL := account.GetGrokBaseURL() if s.settingService != nil { baseURL = s.settingService.ResolveGrokBaseURL(ctx, account) } validatedBaseURL, err := validator(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok base URL", err) } normalizedBaseURL = validatedBaseURL default: return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported Grok account type for upstream model sync: %s", account.Type), nil, ) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIModelsURL(normalizedBaseURL), nil) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Grok model list URL", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+authToken) if isOAuth { // The shared HTTP transport adds the official CLI marker/version for the // exact proxy host. Keep the request builder aligned with the other Grok // probes and only forward account identity headers to that trusted host. applyGrokCLIHeaders(req.Header) if isGrokCLIProxyTarget(req.URL.String()) { if userID := strings.TrimSpace(account.GetCredential("sub")); userID != "" { req.Header.Set("X-UserID", userID) } if email := strings.TrimSpace(account.GetCredential("email")); email != "" { req.Header.Set("X-Email", email) } } } account.ApplyHeaderOverrides(req.Header) return req, nil } func (s *AccountTestService) buildAnthropicUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { if account.IsBedrock() || account.Type == AccountTypeServiceAccount { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported Anthropic account type for upstream model sync: %s", account.Type), nil, ) } baseURL := "https://api.anthropic.com" authHeaderName := "" authHeaderValue := "" apiKeyAuthToken := "" betaHeader := "" if account.IsOAuth() { accessToken := strings.TrimSpace(account.GetCredential("access_token")) if accessToken == "" && s.claudeTokenProvider != nil { token, tokenErr := s.claudeTokenProvider.GetAccessToken(ctx, account) if tokenErr != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to get Anthropic access token", tokenErr) } accessToken = strings.TrimSpace(token) } if accessToken == "" { return nil, newUpstreamModelSyncConfigError("No Anthropic access token is available", nil) } authHeaderName = "Authorization" authHeaderValue = "Bearer " + accessToken betaHeader = claude.DefaultBetaHeader } else if account.Type == AccountTypeAPIKey { apiKey := strings.TrimSpace(account.GetCredential("api_key")) if apiKey == "" { return nil, newUpstreamModelSyncConfigError("No Anthropic API key is available", nil) } baseURL = account.GetBaseURL() if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.anthropic.com" } apiKeyAuthToken = apiKey betaHeader = claude.APIKeyBetaHeader } else { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported Anthropic account type for upstream model sync: %s", account.Type), nil, ) } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Anthropic base URL", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildV1ModelsURL(normalizedBaseURL), nil) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Anthropic model list URL", err) } for key, value := range claude.DefaultHeaders { req.Header.Set(key, value) } req.Header.Set("Accept", "application/json") req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-beta", betaHeader) if authHeaderName != "" { req.Header.Set(authHeaderName, authHeaderValue) } else { // Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer,其余保持 // extra/default 行为。 setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken, normalizedBaseURL) } // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) return req, nil } func (s *AccountTestService) buildAntigravityAPIKeyModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported Antigravity account type for upstream model sync: %s", account.Type), nil, ) } apiKey := strings.TrimSpace(account.GetCredential("api_key")) if apiKey == "" { return nil, newUpstreamModelSyncConfigError("No Antigravity API key is available", nil) } baseURL := strings.TrimRight(strings.TrimSpace(account.GetCredential("base_url")), "/") if baseURL == "" { return nil, newUpstreamModelSyncConfigError("Antigravity API-key base URL is required for upstream model sync", nil) } if !strings.HasSuffix(strings.ToLower(baseURL), "/antigravity") { return nil, newUpstreamModelSyncUnsupportedError( "Antigravity API-key upstream model sync requires a compatible gateway base URL ending in /antigravity; use Antigravity OAuth for official Cloud Code upstreams", nil, ) } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Antigravity base URL", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildV1ModelsURL(normalizedBaseURL), nil) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Antigravity model list URL", err) } for key, value := range claude.DefaultHeaders { req.Header.Set(key, value) } req.Header.Set("Accept", "application/json") req.Header.Set("anthropic-version", "2023-06-01") req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader) req.Header.Set("x-api-key", apiKey) return req, nil } func (s *AccountTestService) buildOpenAIUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { if account.IsOpenAIOAuth() { return s.buildOpenAIOAuthUpstreamModelsRequest(ctx, account) } return buildOpenAIAPIKeyModelsRequest(ctx, account, s.validateUpstreamBaseURL) } // buildOpenAIAPIKeyModelsRequest is shared by admin discovery and public model // listing. Codex content negotiation is intentionally absent from this request. func buildOpenAIAPIKeyModelsRequest(ctx context.Context, account *Account, validateBaseURL func(string) (string, error)) (*http.Request, error) { if account.Type != AccountTypeAPIKey { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", account.Type), nil, ) } apiKey := strings.TrimSpace(account.GetOpenAIProtocolAPIKey()) if apiKey == "" { return nil, newUpstreamModelSyncConfigError("No OpenAI API key is available", nil) } // 协议感知:Anthropic 协议账号的凭证 base_url 指向 /anthropic 端点,模型 // 列表同步需使用 OpenAI 格式 base(供应商 × 模式默认)。 baseURL := account.GetOpenAIFormatBaseURL() if strings.TrimSpace(baseURL) == "" { baseURL = "https://api.openai.com" } normalizedBaseURL, err := validateBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI base URL", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildOpenAIModelsURL(normalizedBaseURL), nil) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI model list URL", err) } req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+apiKey) // 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头 account.ApplyHeaderOverrides(req.Header) return req, nil } // buildOpenAIOAuthUpstreamModelsRequest uses ChatGPT's Codex model manifest. // OAuth subscriptions do not expose the public Platform API /v1/models endpoint, // so treating them like API-key accounts makes the admin sync button fail locally. func (s *AccountTestService) buildOpenAIOAuthUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { credentialAccount, err := resolveCredentialAccount(ctx, s.accountRepo, account) if err != nil { return nil, newUpstreamModelSyncConfigError("Failed to resolve OpenAI account credentials", err) } if !credentialAccount.IsOpenAIOAuth() { return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported OpenAI account type for upstream model sync: %s", credentialAccount.Type), nil, ) } modelsURL, err := buildCodexModelsManifestURL( chatgptCodexModelsURL, false, CodexCanonicalClientVersion(), ) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list URL", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, modelsURL.String(), nil) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid OpenAI Codex model list request", err) } if credentialAccount.IsOpenAIAgentIdentity() { authHeaders, authErr := buildAgentIdentityAuthenticationHeaders( ctx, s.accountRepo, s.agentIdentityWS, &s.agentIdentityTaskMu, credentialAccount, ) if authErr != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to build OpenAI Agent Identity authentication", authErr) } for key, values := range authHeaders { for _, value := range values { req.Header.Add(key, value) } } } else { accessToken := strings.TrimSpace(credentialAccount.GetOpenAIAccessToken()) if accessToken == "" { return nil, newUpstreamModelSyncConfigError("No OpenAI access token is available", nil) } req.Header.Set("Authorization", "Bearer "+accessToken) } identity := resolveCodexOutboundIdentity(credentialAccount.GetOpenAIUserAgent()) req.Header.Set("Accept", "application/json") req.Header.Set("Originator", identity.originator) req.Header.Set("User-Agent", identity.userAgent) req.Header.Set("Version", identity.version) setOpenAIChatGPTAccountHeaders(req.Header, credentialAccount) credentialAccount.ApplyHeaderOverrides(req.Header) enforceCodexIdentityHeadersWithUA(req.Header, credentialAccount.GetOpenAIUserAgent()) return req, nil } func (s *AccountTestService) buildGeminiUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) { baseURL := account.GetGeminiBaseURL(geminicli.AIStudioBaseURL) if strings.TrimSpace(baseURL) == "" { baseURL = geminicli.AIStudioBaseURL } normalizedBaseURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Gemini base URL", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, buildGeminiModelsURL(normalizedBaseURL), nil) if err != nil { return nil, newUpstreamModelSyncConfigError("Invalid Gemini model list URL", err) } req.Header.Set("Accept", "application/json") switch account.Type { case AccountTypeAPIKey: apiKey := strings.TrimSpace(account.GetCredential("api_key")) if apiKey == "" { return nil, newUpstreamModelSyncConfigError("No Gemini API key is available", nil) } req.Header.Set("x-goog-api-key", apiKey) case AccountTypeOAuth: if strings.TrimSpace(account.GetCredential("project_id")) != "" { return nil, newUpstreamModelSyncUnsupportedError("Gemini Code Assist model listing is not supported by this sync button", nil) } if s.geminiTokenProvider == nil { return nil, newUpstreamModelSyncConfigError("Gemini token provider is not configured", nil) } accessToken, tokenErr := s.geminiTokenProvider.GetAccessToken(ctx, account) if tokenErr != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to get Gemini access token", tokenErr) } accessToken = strings.TrimSpace(accessToken) if accessToken == "" { return nil, newUpstreamModelSyncConfigError("No Gemini access token is available", nil) } req.Header.Set("Authorization", "Bearer "+accessToken) default: return nil, newUpstreamModelSyncUnsupportedError( fmt.Sprintf("Unsupported Gemini account type for upstream model sync: %s", account.Type), nil, ) } return req, nil } func (s *AccountTestService) fetchAntigravityOAuthUpstreamModels(ctx context.Context, account *Account) ([]string, error) { if s.antigravityGatewayService == nil || s.antigravityGatewayService.GetTokenProvider() == nil { return nil, newUpstreamModelSyncConfigError("Antigravity token provider is not configured", nil) } accessToken, err := s.antigravityGatewayService.GetTokenProvider().GetAccessToken(ctx, account) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to get Antigravity access token", err) } accessToken = strings.TrimSpace(accessToken) if accessToken == "" { return nil, newUpstreamModelSyncConfigError("No Antigravity access token is available", nil) } client, err := antigravity.NewClient(upstreamModelsProxyURL(account)) if err != nil { return nil, newUpstreamModelSyncConfigError("Failed to configure Antigravity client", err) } modelsResp, _, err := client.FetchAvailableModels( ctx, accessToken, strings.TrimSpace(account.GetCredential("project_id")), resolveModelsListReadLimit(s.cfg), ) if err != nil { return nil, newUpstreamModelSyncUpstreamError("Failed to fetch Antigravity available models", err) } if modelsResp == nil || len(modelsResp.Models) == 0 { return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil) } models := make([]string, 0, len(modelsResp.Models)) for modelID := range modelsResp.Models { models = append(models, strings.TrimSpace(modelID)) } return dedupeAndSortModelIDs(models), nil } func (s *AccountTestService) doUpstreamModelsRequest(req *http.Request, proxyURL string, account *Account) (*http.Response, error) { if s.tlsFPProfileService == nil { return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, nil) } return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account)) } func upstreamModelsProxyURL(account *Account) string { if account != nil && account.ProxyID != nil && account.Proxy != nil { return account.Proxy.URL() } return "" } func buildV1ModelsURL(base string) string { normalized := strings.TrimRight(strings.TrimSpace(base), "/") if strings.HasSuffix(normalized, "/v1/models") { return normalized } if strings.HasSuffix(normalized, "/v1") { return normalized + "/models" } return normalized + "/v1/models" } func buildOpenAIModelsURL(base string) string { return buildOpenAIEndpointURL(base, "/v1/models") } func buildGeminiModelsURL(base string) string { normalized := strings.TrimRight(strings.TrimSpace(base), "/") if strings.HasSuffix(normalized, "/v1beta/models") { return normalized } if strings.HasSuffix(normalized, "/v1beta") { return normalized + "/models" } return normalized + "/v1beta/models" } type upstreamModelEntry struct { ID string `json:"id"` Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` Name string `json:"name"` Meta json.RawMessage `json:"_meta"` } type upstreamModelEntryMetadata struct { ID string `json:"id"` Slug string `json:"slug"` Model string `json:"model"` ModelID string `json:"modelId"` ModelIDSnake string `json:"model_id"` Name string `json:"name"` } type upstreamModelCapabilityEntry struct { upstreamModelEntry DisplayName string `json:"display_name"` Description string `json:"description"` Reasoning *bool `json:"reasoning"` DefaultReasoningLevel string `json:"default_reasoning_level"` SupportedReasoningLevels []json.RawMessage `json:"supported_reasoning_levels"` ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"` InputModalities []string `json:"input_modalities"` Modalities modelsDevModalities `json:"modalities"` ContextWindow int64 `json:"context_window"` MaxContextWindow int64 `json:"max_context_window"` MaxOutputTokens int64 `json:"max_output_tokens"` Limit modelsDevLimit `json:"limit"` } func extractUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, upstreamModelEntryID) } func extractGrokUpstreamModelIDs(body []byte) ([]string, error) { return extractUpstreamModelIDsWithSelector(body, grokUpstreamModelEntryID) } func extractUpstreamModelCatalog(body []byte, grok bool) ([]string, map[string]UpstreamModelMetadata, error) { entries, err := extractUpstreamModelRawEntries(body) if err != nil { return nil, nil, err } selectID := upstreamModelEntryID if grok { selectID = grokUpstreamModelEntryID } models := make([]string, 0, len(entries)) metadata := make(map[string]UpstreamModelMetadata) for _, raw := range entries { var capability upstreamModelCapabilityEntry if err := json.Unmarshal(raw, &capability); err != nil { continue } modelID := strings.TrimSpace(selectID(capability.upstreamModelEntry)) if modelID == "" { continue } models = append(models, modelID) entry := upstreamMetadataFromCapabilityEntry(modelID, capability) var fields map[string]json.RawMessage if err := json.Unmarshal(raw, &fields); err == nil { entry.CodexToolCapabilities = make(map[string]json.RawMessage) applyCodexToolCapabilities(entry.CodexToolCapabilities, fields, true) } if upstreamModelMetadataIsUseful(entry) { metadata[modelID] = entry } } return dedupeAndSortModelIDs(models), metadata, nil } func extractUpstreamModelRawEntries(body []byte) ([]json.RawMessage, error) { var response struct { Data []json.RawMessage `json:"data"` Models []json.RawMessage `json:"models"` } if err := json.Unmarshal(body, &response); err == nil && (response.Data != nil || response.Models != nil) { entries := make([]json.RawMessage, 0, len(response.Data)+len(response.Models)) entries = append(entries, response.Data...) entries = append(entries, response.Models...) return entries, nil } var entries []json.RawMessage if err := json.Unmarshal(body, &entries); err != nil { return nil, fmt.Errorf("parse upstream model catalog: %w", err) } return entries, nil } func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapabilityEntry) UpstreamModelMetadata { levels := reasoningLevelsFromRawEntries(entry.SupportedReasoningLevels) if len(levels) == 0 { levels = reasoningLevelsFromModelsDevOptions(entry.ReasoningOptions) } reasoning := entry.Reasoning if reasoning == nil && len(levels) > 0 { inferred := len(levels) != 1 || levels[0] != "none" reasoning = &inferred } modalities := entry.InputModalities if len(modalities) == 0 { modalities = entry.Modalities.Input } contextWindow := entry.ContextWindow if contextWindow <= 0 { contextWindow = entry.MaxContextWindow } if contextWindow <= 0 { contextWindow = entry.Limit.Context } maxOutputTokens := entry.MaxOutputTokens if maxOutputTokens <= 0 { maxOutputTokens = entry.Limit.Output } defaultReasoningLevel := normalizeReasoningLevel(entry.DefaultReasoningLevel) if defaultReasoningLevel == "" && len(levels) > 0 { defaultReasoningLevel = levels[0] } displayName := strings.TrimSpace(entry.DisplayName) if displayName == "" && strings.TrimSpace(entry.Name) != "" && strings.TrimSpace(entry.Name) != modelID { displayName = strings.TrimSpace(entry.Name) } return UpstreamModelMetadata{ ID: modelID, DisplayName: displayName, Description: strings.TrimSpace(entry.Description), Reasoning: reasoning, DefaultReasoningLevel: defaultReasoningLevel, SupportedReasoningLevels: levels, InputModalities: normalizeCodexInputModalities(modalities), ContextWindow: contextWindow, MaxOutputTokens: maxOutputTokens, } } func reasoningLevelsFromRawEntries(entries []json.RawMessage) []string { levels := make([]string, 0, len(entries)) for _, raw := range entries { var effort string if err := json.Unmarshal(raw, &effort); err == nil { levels = append(levels, effort) continue } var level struct { Effort string `json:"effort"` } if err := json.Unmarshal(raw, &level); err == nil { levels = append(levels, level.Effort) } } return normalizeReasoningLevels(levels) } func normalizeReasoningLevels(levels []string) []string { seen := make(map[string]struct{}, len(levels)) normalized := make([]string, 0, len(levels)) for _, level := range levels { level = normalizeReasoningLevel(level) if level == "" { continue } if _, exists := seen[level]; exists { continue } seen[level] = struct{}{} normalized = append(normalized, level) } return normalized } func normalizeReasoningLevel(level string) string { level = strings.ToLower(strings.TrimSpace(level)) switch level { case "off", "disabled": return "none" case "extra-high", "extra_high": return "xhigh" case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra": return level default: return "" } } func normalizeCodexInputModalities(modalities []string) []string { seen := make(map[string]struct{}, len(modalities)) normalized := make([]string, 0, len(modalities)) for _, modality := range modalities { modality = strings.ToLower(strings.TrimSpace(modality)) if modality != "text" && modality != "image" { continue } if _, exists := seen[modality]; exists { continue } seen[modality] = struct{}{} normalized = append(normalized, modality) } return normalized } func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) { var response struct { Data []upstreamModelEntry `json:"data"` Models []upstreamModelEntry `json:"models"` } if err := json.Unmarshal(body, &response); err != nil { var arrayResponse []upstreamModelEntry if arrayErr := json.Unmarshal(body, &arrayResponse); arrayErr != nil { return nil, fmt.Errorf("parse upstream model list: %w", err) } models := make([]string, 0, len(arrayResponse)) for _, entry := range arrayResponse { models = append(models, selectID(entry)) } return dedupeAndSortModelIDs(models), nil } models := make([]string, 0, len(response.Data)+len(response.Models)) for _, entry := range response.Data { models = append(models, selectID(entry)) } for _, entry := range response.Models { models = append(models, selectID(entry)) } if len(models) == 0 { var arrayResponse []upstreamModelEntry if err := json.Unmarshal(body, &arrayResponse); err == nil { for _, entry := range arrayResponse { models = append(models, selectID(entry)) } } } return dedupeAndSortModelIDs(models), nil } func upstreamModelEntryID(entry upstreamModelEntry) string { modelID := strings.TrimSpace(entry.ID) if modelID == "" { modelID = strings.TrimSpace(entry.Slug) } if modelID == "" { modelID = strings.TrimSpace(entry.Name) } return strings.TrimPrefix(modelID, "models/") } func grokUpstreamModelEntryID(entry upstreamModelEntry) string { candidates := []string{ entry.Model, entry.ModelID, entry.ModelIDSnake, entry.ID, entry.Slug, } if len(entry.Meta) > 0 { var meta upstreamModelEntryMetadata if err := json.Unmarshal(entry.Meta, &meta); err == nil { candidates = append(candidates, meta.Model, meta.ModelID, meta.ModelIDSnake, meta.ID, meta.Slug, meta.Name, ) } } // `name` is a display label in the Grok catalog, so keep it as the final // compatibility fallback rather than preferring it over protocol model IDs. candidates = append(candidates, entry.Name) for _, candidate := range candidates { modelID := strings.TrimSpace(candidate) if modelID != "" { return strings.TrimPrefix(modelID, "models/") } } return "" } func dedupeAndSortModelIDs(models []string) []string { seen := make(map[string]struct{}, len(models)) result := make([]string, 0, len(models)) for _, model := range models { model = strings.TrimSpace(model) if model == "" { continue } if _, exists := seen[model]; exists { continue } seen[model] = struct{}{} result = append(result, model) } sort.Strings(result) return result }