Files
sub2api/backend/internal/service/upstream_models.go
T
Long LiandCursor 983a3db3af fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync
Official OpenAI hosts and models.dev entries without an api field left
Astra falling back to reasoning effort none and empty Fast tiers. Persist
complete per-model metadata, advertise Astra reasoning/Fast locally, and
surface partial sync warnings when image models remain incomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 14:45:06 +09:00

1488 lines
51 KiB
Go

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"`
}
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)
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 {
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 ||
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, &registry); 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 {
setAnthropicAPIKeyAuthHeader(req.Header, account, apiKeyAuthToken)
}
// 账号级请求头覆写:模型列表探测与真实转发保持一致的最终头
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)
}
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 := s.validateUpstreamBaseURL(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)
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
}