mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 00:10:31 +00:00
Account model mappings decide availability, but generated Codex catalogs previously inferred capabilities from local model-name tables. Compatible upstreams can expose unknown models such as OpenCode x-preview-f-free, so reasoning levels, image input, and context limits were missing or wrong. Sync capability metadata from the account model endpoint, enrich incomplete lists from the Models.dev provider matching the account base URL, and persist only complete snapshots. Mixed groups consume the safe intersection across schedulable accounts. When IDs sync without complete metadata, return an explicit warning and preserve the previous snapshot. Third-party Responses providers often omit /models. If that endpoint returns 404 or 405 and the account already has concrete model mappings, use those configured upstream IDs for capability enrichment. Authentication, rate-limit, server, and network failures remain hard errors, and metadata is never guessed across providers by model name alone. Advertise a single none choice for non-reasoning models so current Codex can select them, then omit that catalog placeholder when forwarding to compatible Responses upstreams while preserving official OpenAI request semantics.
1296 lines
44 KiB
Go
1296 lines
44 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"
|
|
)
|
|
|
|
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 metadata is available.
|
|
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
|
|
}
|
|
}
|
|
|
|
source := "upstream"
|
|
metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata)
|
|
if metadataIncomplete {
|
|
if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); 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,
|
|
)
|
|
}
|
|
}
|
|
|
|
if upstreamCatalogNeedsRegistry(models, catalog.Metadata) {
|
|
catalog.Warnings = append(catalog.Warnings, UpstreamModelSyncWarning{
|
|
Code: UpstreamModelMetadataIncompleteCode,
|
|
Message: "Model IDs were synced, but capability metadata is incomplete.",
|
|
})
|
|
return catalog, nil
|
|
}
|
|
if len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil {
|
|
return catalog, nil
|
|
}
|
|
snapshot := UpstreamModelMetadataSnapshot{
|
|
Source: source,
|
|
SyncedAt: time.Now().UTC().Format(time.RFC3339),
|
|
Models: catalog.Metadata,
|
|
}
|
|
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)
|
|
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 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 || !upstreamModelMetadataIsUseful(model) {
|
|
return true
|
|
}
|
|
if model.Reasoning == nil || len(model.InputModalities) == 0 || model.ContextWindow <= 0 {
|
|
return true
|
|
}
|
|
if *model.Reasoning && len(model.SupportedReasoningLevels) == 0 {
|
|
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
|
|
}
|
|
|
|
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) {
|
|
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
|
|
}
|
|
|
|
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() }()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, upstreamModelsBodyLimit+1))
|
|
if err != nil {
|
|
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
|
|
}
|
|
if int64(len(body)) > upstreamModelsBodyLimit {
|
|
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", upstreamModelsBodyLimit))
|
|
}
|
|
|
|
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.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
|
|
}
|
|
|
|
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")))
|
|
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":
|
|
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
|
|
}
|