mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Merge pull request #6628 from baryon/contrib/openai-gpt6-astra-capability-sync
fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync
This commit is contained in:
@@ -22,6 +22,7 @@ var DefaultModels = []Model{
|
||||
{ID: "gpt-5.6", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 (Sol)"},
|
||||
{ID: "gpt-5.6-terra", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Terra"},
|
||||
{ID: "gpt-5.6-luna", Object: "model", Created: 1780876800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.6 Luna"},
|
||||
{ID: "gpt-6-astra", Object: "model", Created: 1783468800, OwnedBy: "openai", Type: "model", DisplayName: "GPT-6 Astra"},
|
||||
{ID: "gpt-5.5", Object: "model", Created: 1776873600, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.5"},
|
||||
{ID: "gpt-5.4", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4"},
|
||||
{ID: "gpt-5.4-mini", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT-5.4 Mini"},
|
||||
|
||||
@@ -10,6 +10,18 @@ func TestDefaultModelsIncludeBareGPT56Alias(t *testing.T) {
|
||||
require.Contains(t, DefaultModelIDs(), "gpt-5.6")
|
||||
}
|
||||
|
||||
func TestDefaultModelsIncludeGPT6Astra(t *testing.T) {
|
||||
require.Contains(t, DefaultModelIDs(), "gpt-6-astra")
|
||||
var displayName string
|
||||
for _, model := range DefaultModels {
|
||||
if model.ID == "gpt-6-astra" {
|
||||
displayName = model.DisplayName
|
||||
break
|
||||
}
|
||||
}
|
||||
require.Equal(t, "GPT-6 Astra", displayName)
|
||||
}
|
||||
|
||||
func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) {
|
||||
require.NotEmpty(t, DefaultModels)
|
||||
require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID)
|
||||
|
||||
@@ -1648,16 +1648,19 @@ func (s *BillingService) applyModelSpecificPricingPolicyEx(model string, pricing
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT-5.x 模型 Fast/priority
|
||||
// 的标准价倍率:gpt-5.6 系列与 gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast
|
||||
// openAIModelFastPricingRatio 返回业务口径下 OpenAI GPT 模型 Fast/priority
|
||||
// 的标准价倍率:gpt-5.6 / gpt-6-astra / gpt-5.4 为 2x,gpt-5.5 为 2.5x。未定义 Fast
|
||||
// 档的模型(如 gpt-5.5-pro、gpt-5.4-mini/nano)返回 0。
|
||||
func openAIModelFastPricingRatio(normalized string) float64 {
|
||||
switch normalized {
|
||||
case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna":
|
||||
case "gpt-5.4", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-6-astra":
|
||||
return 2.0
|
||||
case "gpt-5.5":
|
||||
return 2.5
|
||||
default:
|
||||
if isOpenAIGPT6AstraModel(normalized) {
|
||||
return 2.0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,7 @@ func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescr
|
||||
descriptor.SupportedReasoningLevels = configuredCodexGPTReasoningLevels(modelID)
|
||||
descriptor.DefaultReasoningSummary = "none"
|
||||
descriptor.TruncationPolicy = configuredCodexTruncationPolicy{Mode: "tokens", Limit: configuredCodexToolOutputMaxTokens}
|
||||
if isOpenAIGPT56Model(modelID) {
|
||||
if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) {
|
||||
descriptor.MaxContextWindow = configuredCodexGPT56MaxContext
|
||||
}
|
||||
}
|
||||
@@ -516,7 +516,8 @@ func configuredCodexSupportsPriorityServiceTier(modelID string) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
// GPT-6 Astra advertises Fast via service_tier=priority in public model metadata.
|
||||
return isOpenAIGPT6AstraModel(modelID)
|
||||
}
|
||||
|
||||
func configuredCodexGrokReasoningLevels(modelID string) []configuredCodexReasoningLevel {
|
||||
@@ -575,7 +576,7 @@ func configuredCodexGPTReasoningLevels(modelID string) []configuredCodexReasonin
|
||||
{Effort: "xhigh", Description: "Extra-high reasoning depth for difficult tasks"},
|
||||
}
|
||||
normalized := getNormalizedCodexModel(modelID)
|
||||
if isOpenAIGPT56Model(modelID) {
|
||||
if isOpenAIGPT56Model(modelID) || isOpenAIGPT6AstraModel(modelID) {
|
||||
levels = append(levels, configuredCodexReasoningLevel{
|
||||
Effort: "max",
|
||||
Description: "Maximum reasoning depth for complex tasks",
|
||||
@@ -600,12 +601,13 @@ func isOpenAICodexGPTModel(modelID string) bool {
|
||||
|
||||
func isOpenAICodexReasoningGPTModel(modelID string) bool {
|
||||
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
|
||||
return strings.HasPrefix(normalized, "gpt-5")
|
||||
return isOpenAIGPT6AstraModel(normalized) || strings.HasPrefix(normalized, "gpt-5")
|
||||
}
|
||||
|
||||
func isOpenAICodexImageInputModel(modelID string) bool {
|
||||
normalized := canonicalizeOpenAIModelAliasSpelling(modelID)
|
||||
return strings.HasPrefix(normalized, "gpt-5") ||
|
||||
return isOpenAIGPT6AstraModel(normalized) ||
|
||||
strings.HasPrefix(normalized, "gpt-5") ||
|
||||
strings.HasPrefix(normalized, "gpt-4o") ||
|
||||
strings.HasPrefix(normalized, "gpt-4.1") ||
|
||||
strings.HasPrefix(normalized, "gpt-4.5") ||
|
||||
|
||||
@@ -349,6 +349,24 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt56Luna.SupportedReasoningLevels))
|
||||
require.Equal(t, "medium", *gpt56Luna.DefaultReasoningLevel)
|
||||
|
||||
gpt6Astra := newConfiguredCodexModelDescriptor("gpt-6-astra")
|
||||
require.Equal(t, "GPT-6 Astra", gpt6Astra.DisplayName)
|
||||
require.NotNil(t, gpt6Astra.DefaultReasoningLevel)
|
||||
require.Equal(t, "medium", *gpt6Astra.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, effortsFromConfiguredCodexLevels(gpt6Astra.SupportedReasoningLevels))
|
||||
require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "ultra"})
|
||||
require.NotContains(t, gpt6Astra.SupportedReasoningLevels, configuredCodexReasoningLevel{Effort: "none"})
|
||||
require.True(t, configuredCodexSupportsPriorityServiceTier("gpt-6-astra"))
|
||||
require.Equal(t, []configuredCodexServiceTier{{
|
||||
ID: "priority",
|
||||
Name: "Fast",
|
||||
Description: "Priority processing for lower latency.",
|
||||
}}, gpt6Astra.ServiceTiers)
|
||||
require.True(t, isOpenAICodexImageInputModel("gpt-6-astra"))
|
||||
require.True(t, isOpenAICodexReasoningGPTModel("openai/gpt-6-astra"))
|
||||
require.True(t, isOpenAIGPT6AstraModel("gpt-6-astra-2026-09-01"))
|
||||
require.False(t, isOpenAIGPT6AstraModel("gpt-6-other"))
|
||||
|
||||
gpt55 := newConfiguredCodexModelDescriptor("gpt-5.5")
|
||||
require.Equal(t, "GPT-5.5", gpt55.DisplayName)
|
||||
require.NotNil(t, gpt55.DefaultReasoningLevel)
|
||||
|
||||
@@ -124,6 +124,14 @@ func isOpenAIGPT56Model(model string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isOpenAIGPT6AstraModel reports GPT-6 Astra and dated/provider-prefixed variants.
|
||||
// Bare "gpt-6" and other future gpt-6-* models are intentionally excluded until
|
||||
// their capabilities are confirmed (see #6622).
|
||||
func isOpenAIGPT6AstraModel(model string) bool {
|
||||
normalized := canonicalizeOpenAIModelAliasSpelling(model)
|
||||
return normalized == "gpt-6-astra" || strings.HasPrefix(normalized, "gpt-6-astra-")
|
||||
}
|
||||
|
||||
func appendUsageBillingModelCandidate(candidates []string, seen map[string]struct{}, model string) []string {
|
||||
trimmed := strings.TrimSpace(model)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -24,6 +24,7 @@ const (
|
||||
modelsDevRegistryTTL = 6 * time.Hour
|
||||
UpstreamModelMetadataExtraKey = "upstream_model_metadata"
|
||||
UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete"
|
||||
UpstreamModelMetadataPartialCode = "upstream_model_metadata_partial"
|
||||
)
|
||||
|
||||
type UpstreamModelMetadata struct {
|
||||
@@ -197,7 +198,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
@@ -222,10 +229,18 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou
|
||||
}
|
||||
}
|
||||
|
||||
// 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"
|
||||
metadataIncomplete := upstreamCatalogNeedsRegistry(models, catalog.Metadata)
|
||||
if metadataIncomplete {
|
||||
if registryMetadata, registryErr := s.fetchModelsDevMetadata(ctx, account, models); registryErr == nil {
|
||||
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)
|
||||
@@ -243,25 +258,34 @@ func (s *AccountTestService) SyncUpstreamModelCatalog(ctx context.Context, accou
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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 len(catalog.Metadata) == 0 || account == nil || account.ID <= 0 || s.accountRepo == nil {
|
||||
return catalog, nil
|
||||
|
||||
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.",
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -293,6 +317,18 @@ func configuredUpstreamModelsForCapabilitySync(account *Account) []string {
|
||||
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
|
||||
@@ -311,13 +347,7 @@ func upstreamCatalogNeedsRegistry(models []string, metadata map[string]UpstreamM
|
||||
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 {
|
||||
if !ok || !upstreamModelMetadataIsComplete(model) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -334,6 +364,52 @@ func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool {
|
||||
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
|
||||
@@ -529,6 +605,13 @@ func upstreamModelRegistryBaseURL(account *Account) string {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -553,6 +636,44 @@ func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseUR
|
||||
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 == "" {
|
||||
|
||||
@@ -795,6 +795,71 @@ func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(
|
||||
require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot")
|
||||
}
|
||||
|
||||
// Scenario: 图片专用模型缺少 context 时,不阻止 agent 模型能力落库,也不误报整批失败。
|
||||
func TestSyncUpstreamModelCatalogIgnoresDedicatedMediaModelsForCompleteness(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[
|
||||
{"id":"gpt-6-astra","object":"model"},
|
||||
{"id":"gpt-image-2","object":"model"}
|
||||
]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"openai": {
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"gpt-6-astra": {
|
||||
"id": "gpt-6-astra",
|
||||
"name": "GPT-6 Astra",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
|
||||
"modalities": {"input":["text","image"],"output":["text"]},
|
||||
"limit": {"context":1050000,"output":128000}
|
||||
},
|
||||
"gpt-image-2": {
|
||||
"id": "gpt-image-2",
|
||||
"name": "gpt-image-2",
|
||||
"reasoning": false,
|
||||
"modalities": {"input":["text","image"],"output":["image"]},
|
||||
"limit": {"context":0,"output":0}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 113, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-6-astra": "gpt-6-astra",
|
||||
"gpt-image-2": "gpt-image-2",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, catalog.Warnings, "media generators must not keep agent capability sync in a failed state")
|
||||
require.NotNil(t, repo.updates)
|
||||
|
||||
encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
|
||||
require.NoError(t, err)
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.Contains(t, snapshot.Models, "gpt-6-astra")
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, snapshot.Models["gpt-6-astra"].SupportedReasoningLevels)
|
||||
require.NotContains(t, snapshot.Models, "gpt-image-2")
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -820,101 +885,231 @@ func TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
|
||||
require.Contains(t, err.Error(), "response exceeds 8 bytes")
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) {
|
||||
func TestMatchModelsDevProviderFallsBackToOpenAIProviderWithoutAPIField(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"grok-4.5"},{"id":"grok-4.5"},{"id":"grok-imagine"}]}`)),
|
||||
}}
|
||||
svc := &AccountTestService{
|
||||
httpUpstream: upstream,
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
registry := map[string]modelsDevProvider{
|
||||
"openai": {
|
||||
ID: "openai",
|
||||
Name: "OpenAI",
|
||||
// Official models.dev entry currently omits `api`.
|
||||
Models: map[string]modelsDevModel{
|
||||
"gpt-6-astra": {ID: "gpt-6-astra", Name: "GPT-6 Astra"},
|
||||
},
|
||||
},
|
||||
"opencode": {
|
||||
ID: "opencode",
|
||||
API: "https://opencode.ai/zen/v1",
|
||||
Models: map[string]modelsDevModel{
|
||||
"x-preview-f-free": {ID: "x-preview-f-free", Name: "Ox"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
models, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
|
||||
ID: 9,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
for _, baseURL := range []string{
|
||||
"https://api.openai.com",
|
||||
"https://api.openai.com/v1",
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
} {
|
||||
provider, ok := matchModelsDevProvider(registry, baseURL)
|
||||
require.True(t, ok, baseURL)
|
||||
require.Equal(t, "openai", provider.ID, baseURL)
|
||||
require.Contains(t, provider.Models, "gpt-6-astra", baseURL)
|
||||
}
|
||||
|
||||
_, ok := matchModelsDevProvider(registry, "https://compatible.example/v1")
|
||||
require.False(t, ok, "custom hosts must not inherit the official OpenAI provider by name")
|
||||
|
||||
provider, ok := matchModelsDevProvider(registry, "https://opencode.ai/zen/v1")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "opencode", provider.ID)
|
||||
}
|
||||
|
||||
// Scenario: 官方 OpenAI ID-only /models + 无 api 字段的 models.dev openai 条目仍能补齐并落库。
|
||||
func TestSyncUpstreamModelCatalogEnrichesOfficialOpenAIHostWithoutRegistryAPIField(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[
|
||||
{"id":"gpt-6-astra","object":"model"},
|
||||
{"id":"gpt-5.6-sol","object":"model"}
|
||||
]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"openai": {
|
||||
"id": "openai",
|
||||
"name": "OpenAI",
|
||||
"models": {
|
||||
"gpt-6-astra": {
|
||||
"id": "gpt-6-astra",
|
||||
"name": "GPT-6 Astra",
|
||||
"description": "OpenAI GPT-6 Astra",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
|
||||
"modalities": {"input":["text","image","pdf"],"output":["text"]},
|
||||
"limit": {"context":1050000,"output":128000}
|
||||
},
|
||||
"gpt-5.6-sol": {
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
|
||||
"modalities": {"input":["text","image"],"output":["text"]},
|
||||
"limit": {"context":1050000,"output":128000}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 110, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "xai-key",
|
||||
"base_url": "https://xai.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"grok-4.5", "grok-imagine"}, models)
|
||||
require.Equal(t, "https://xai.example.com/v1/models", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer xai-key", upstream.lastReq.Header.Get("Authorization"))
|
||||
}
|
||||
require.Empty(t, catalog.Warnings)
|
||||
require.Equal(t, []string{"gpt-5.6-sol", "gpt-6-astra"}, catalog.Models)
|
||||
|
||||
func TestFetchUpstreamSupportedModelsParsesGrokOAuthResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
astra := catalog.Metadata["gpt-6-astra"]
|
||||
require.Equal(t, "GPT-6 Astra", astra.DisplayName)
|
||||
require.NotNil(t, astra.Reasoning)
|
||||
require.True(t, *astra.Reasoning)
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, astra.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, astra.InputModalities)
|
||||
require.Equal(t, int64(1_050_000), astra.ContextWindow)
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"model":"grok-4.5"},{"model":"grok-4.5"},{"modelId":"grok-build-0.1"}]}`)),
|
||||
}}
|
||||
svc := &AccountTestService{
|
||||
httpUpstream: upstream,
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
grokTokenProvider: NewGrokTokenProvider(nil, nil),
|
||||
}
|
||||
|
||||
models, err := svc.FetchUpstreamSupportedModels(context.Background(), grokOAuthModelSyncTestAccount(""))
|
||||
require.NotNil(t, repo.updates)
|
||||
encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"grok-4.5", "grok-build-0.1"}, models)
|
||||
require.Equal(t, "https://cli-chat-proxy.grok.com/v1/models", upstream.lastReq.URL.String())
|
||||
require.Equal(t, "Bearer oauth-access-token", upstream.lastReq.Header.Get("Authorization"))
|
||||
require.Equal(t, grokCLIVersion, upstream.lastReq.Header.Get("X-Grok-Client-Version"))
|
||||
require.Equal(t, "interactive", upstream.lastReq.Header.Get("X-Grok-Client-Mode"))
|
||||
require.Equal(t, "grok-user-id", upstream.lastReq.Header.Get("X-UserID"))
|
||||
require.Equal(t, "grok-user@example.com", upstream.lastReq.Header.Get("X-Email"))
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.Equal(t, "models.dev", snapshot.Source)
|
||||
require.Equal(t, astra, snapshot.Models["gpt-6-astra"])
|
||||
}
|
||||
|
||||
func TestBuildUpstreamModelsRequestGrokOAuthDoesNotSendIdentityToCustomBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
svc := &AccountTestService{
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
grokTokenProvider: NewGrokTokenProvider(nil, nil),
|
||||
}
|
||||
req, err := svc.buildUpstreamModelsRequest(context.Background(), grokOAuthModelSyncTestAccount("https://relay.example/v1"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://relay.example/v1/models", req.URL.String())
|
||||
require.Empty(t, req.Header.Get("X-UserID"))
|
||||
require.Empty(t, req.Header.Get("X-Email"))
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsDoesNotExposeUpstreamBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"SECRET_TOKEN should not be exposed"}`)),
|
||||
// Scenario: 同一批同步里部分模型能力完整时仍落库完整条目,并对不完整条目告警。
|
||||
func TestSyncUpstreamModelCatalogPersistsCompleteModelsWhenSomeRemainIncomplete(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[
|
||||
{
|
||||
"id":"complete-model",
|
||||
"display_name":"Complete",
|
||||
"reasoning":true,
|
||||
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":128000
|
||||
},
|
||||
{
|
||||
"id":"incomplete-model",
|
||||
"display_name":"Incomplete Only"
|
||||
}
|
||||
]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"provider": {
|
||||
"id": "provider",
|
||||
"api": "https://provider.example/v1",
|
||||
"models": {}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
svc := &AccountTestService{
|
||||
httpUpstream: upstream,
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.FetchUpstreamSupportedModels(context.Background(), &Account{
|
||||
ID: 8,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 111, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"complete-model", "incomplete-model"}, catalog.Models)
|
||||
require.Equal(t, UpstreamModelMetadataPartialCode, catalog.Warnings[0].Code)
|
||||
require.NotNil(t, repo.updates)
|
||||
|
||||
encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
|
||||
require.NoError(t, err)
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.Contains(t, snapshot.Models, "complete-model")
|
||||
require.NotContains(t, snapshot.Models, "incomplete-model")
|
||||
require.Equal(t, []string{"low", "high"}, snapshot.Models["complete-model"].SupportedReasoningLevels)
|
||||
}
|
||||
|
||||
// Scenario: 上游清单未包含管理员 mapping 目标时,仍按 mapping 补齐并写入快照。
|
||||
func TestSyncUpstreamModelCatalogEnrichesConfiguredMappingModelsMissingFromUpstreamList(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"gpt-5.6-sol","object":"model"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"openai": {
|
||||
"id": "openai",
|
||||
"models": {
|
||||
"gpt-5.6-sol": {
|
||||
"id": "gpt-5.6-sol",
|
||||
"name": "GPT-5.6 Sol",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
|
||||
"modalities": {"input":["text","image"],"output":["text"]},
|
||||
"limit": {"context":1050000,"output":128000}
|
||||
},
|
||||
"gpt-6-astra": {
|
||||
"id": "gpt-6-astra",
|
||||
"name": "GPT-6 Astra",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high","xhigh","max"]}],
|
||||
"modalities": {"input":["text","image"],"output":["text"]},
|
||||
"limit": {"context":1050000,"output":128000}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 112, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "openai-key",
|
||||
"base_url": "https://openai.example.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"gpt-6-astra": "gpt-6-astra",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.NotContains(t, err.Error(), "SECRET_TOKEN")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"gpt-5.6-sol"}, catalog.Models, "UI model list stays upstream-only")
|
||||
require.Empty(t, catalog.Warnings)
|
||||
require.Contains(t, catalog.Metadata, "gpt-6-astra")
|
||||
require.Equal(t, []string{"low", "medium", "high", "xhigh", "max"}, catalog.Metadata["gpt-6-astra"].SupportedReasoningLevels)
|
||||
|
||||
var syncErr *UpstreamModelSyncError
|
||||
require.True(t, errors.As(err, &syncErr))
|
||||
require.Equal(t, UpstreamModelSyncErrorUpstream, syncErr.Kind)
|
||||
require.NotContains(t, syncErr.SafeMessage(), "SECRET_TOKEN")
|
||||
require.Contains(t, syncErr.SafeMessage(), "HTTP 502")
|
||||
encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
|
||||
require.NoError(t, err)
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.Contains(t, snapshot.Models, "gpt-5.6-sol")
|
||||
require.Contains(t, snapshot.Models, "gpt-6-astra")
|
||||
}
|
||||
|
||||
@@ -5061,8 +5061,11 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
|
||||
if (upstreamModelsPreviewed.value || hasConcreteMappedTarget) {
|
||||
try {
|
||||
const result = await adminAPI.accounts.syncUpstreamModels(account.id)
|
||||
if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
const warnings = result.warnings ?? []
|
||||
if (warnings.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
|
||||
} else if (warnings.some(warning => warning.code === 'upstream_model_metadata_partial')) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataPartial'))
|
||||
}
|
||||
} catch {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsFailed'))
|
||||
|
||||
@@ -4194,7 +4194,14 @@ const syncAntigravityUpstreamModels = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.warnings?.some((warning) => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
const warnings = result.warnings ?? []
|
||||
const hasPartialMetadata = warnings.some(
|
||||
(warning) => warning.code === 'upstream_model_metadata_partial'
|
||||
)
|
||||
const hasIncompleteMetadata = warnings.some(
|
||||
(warning) => warning.code === 'upstream_model_metadata_incomplete'
|
||||
)
|
||||
if (hasIncompleteMetadata) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
|
||||
return
|
||||
}
|
||||
@@ -4203,6 +4210,9 @@ const syncAntigravityUpstreamModels = async () => {
|
||||
} else {
|
||||
appStore.showInfo(t('admin.accounts.syncUpstreamModelsNoChanges', { count: upstreamModels.length }))
|
||||
}
|
||||
if (hasPartialMetadata) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataPartial'))
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('admin.accounts.syncUpstreamModelsFailed')
|
||||
appStore.showError(t('admin.accounts.syncUpstreamModelsError', { message }))
|
||||
|
||||
@@ -327,7 +327,14 @@ const syncUpstreamModels = async () => {
|
||||
}
|
||||
|
||||
emit('update:modelValue', newModels)
|
||||
if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
const warnings = result.warnings ?? []
|
||||
const hasPartialMetadata = warnings.some(
|
||||
warning => warning.code === 'upstream_model_metadata_partial'
|
||||
)
|
||||
const hasIncompleteMetadata = warnings.some(
|
||||
warning => warning.code === 'upstream_model_metadata_incomplete'
|
||||
)
|
||||
if (hasIncompleteMetadata) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
|
||||
return
|
||||
}
|
||||
@@ -336,6 +343,9 @@ const syncUpstreamModels = async () => {
|
||||
} else {
|
||||
appStore.showInfo(t('admin.accounts.syncUpstreamModelsNoChanges', { count: upstreamModels.length }))
|
||||
}
|
||||
if (hasPartialMetadata) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataPartial'))
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t('admin.accounts.syncUpstreamModelsFailed')
|
||||
appStore.showError(t('admin.accounts.syncUpstreamModelsError', { message }))
|
||||
|
||||
@@ -153,6 +153,41 @@ describe('ModelWhitelistSelector', () => {
|
||||
expect(showSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows success and a partial warning when some capabilities were saved', async () => {
|
||||
syncUpstreamModels.mockResolvedValue({
|
||||
models: ['gpt-6-astra', 'gpt-image-2'],
|
||||
warnings: [
|
||||
{
|
||||
code: 'upstream_model_metadata_partial',
|
||||
message: 'Some model capabilities were saved; remaining models are still incomplete.'
|
||||
}
|
||||
]
|
||||
})
|
||||
const wrapper = mount(ModelWhitelistSelector, {
|
||||
props: {
|
||||
modelValue: [],
|
||||
platform: 'openai',
|
||||
accountId: 46
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
ModelIcon: true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const syncButton = wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === 'admin.accounts.syncUpstreamModels')
|
||||
expect(syncButton).toBeDefined()
|
||||
await syncButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-6-astra', 'gpt-image-2']]])
|
||||
expect(showSuccess).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsSuccess')
|
||||
expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataPartial')
|
||||
})
|
||||
|
||||
it('reports a successful preview so account creation can persist metadata', async () => {
|
||||
syncUpstreamModelsPreview.mockResolvedValue({
|
||||
models: ['x-preview-f-free'],
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('useModelWhitelist', () => {
|
||||
expect(models).toContain('gpt-5.4-2026-03-05')
|
||||
expect(models).toContain('codex-auto-review')
|
||||
expect(models).toContain('gpt-5.6')
|
||||
expect(models).toContain('gpt-6-astra')
|
||||
})
|
||||
|
||||
it('openai 模型列表不再暴露已下线的 ChatGPT 登录 Codex 模型', () => {
|
||||
|
||||
@@ -7,8 +7,10 @@ const openaiModels = [
|
||||
// GPT-5.2 系列
|
||||
'gpt-5.2', 'gpt-5.2-2025-12-11', 'gpt-5.2-chat-latest',
|
||||
'gpt-5.2-pro', 'gpt-5.2-pro-2025-12-11',
|
||||
// GPT-5.6 系列
|
||||
// GPT-5.6 系列
|
||||
'gpt-5.6', 'gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna',
|
||||
// GPT-6 系列
|
||||
'gpt-6-astra',
|
||||
// GPT-5.5 系列
|
||||
'gpt-5.5',
|
||||
// GPT-5.4 系列
|
||||
@@ -294,6 +296,7 @@ const openaiPresetMappings = [
|
||||
{ label: 'GPT-5.6 Sol', from: 'gpt-5.6-sol', to: 'gpt-5.6-sol', color: 'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400' },
|
||||
{ label: 'GPT-5.6 Terra', from: 'gpt-5.6-terra', to: 'gpt-5.6-terra', color: 'bg-lime-100 text-lime-700 hover:bg-lime-200 dark:bg-lime-900/30 dark:text-lime-400' },
|
||||
{ label: 'GPT-5.6 Luna', from: 'gpt-5.6-luna', to: 'gpt-5.6-luna', color: 'bg-sky-100 text-sky-700 hover:bg-sky-200 dark:bg-sky-900/30 dark:text-sky-400' },
|
||||
{ label: 'GPT-6 Astra', from: 'gpt-6-astra', to: 'gpt-6-astra', color: 'bg-fuchsia-100 text-fuchsia-700 hover:bg-fuchsia-200 dark:bg-fuchsia-900/30 dark:text-fuchsia-400' },
|
||||
{ label: 'GPT-5.5', from: 'gpt-5.5', to: 'gpt-5.5', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' },
|
||||
{ label: 'GPT-5.4', from: 'gpt-5.4', to: 'gpt-5.4', color: 'bg-rose-100 text-rose-700 hover:bg-rose-200 dark:bg-rose-900/30 dark:text-rose-400' },
|
||||
{ label: 'Haiku→5.4', from: 'claude-haiku-4-5-20251001', to: 'gpt-5.4', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
|
||||
|
||||
@@ -767,7 +767,9 @@ export default {
|
||||
syncUpstreamModelsFailed: 'Failed to sync upstream models',
|
||||
syncUpstreamModelsError: 'Failed to sync upstream models: {message}',
|
||||
syncUpstreamModelsMetadataIncomplete:
|
||||
'Model IDs were synced, but capability metadata is incomplete and was not updated.',
|
||||
'Model IDs were synced, but no capability metadata could be updated.',
|
||||
syncUpstreamModelsMetadataPartial:
|
||||
'Some model capabilities were updated; remaining models are still incomplete.',
|
||||
clearAllModels: 'Clear all models',
|
||||
customModelName: 'Custom model name',
|
||||
enterCustomModelName: 'Enter custom model name',
|
||||
|
||||
@@ -842,7 +842,8 @@ export default {
|
||||
syncUpstreamModelsEmpty: '上游没有返回可同步的模型',
|
||||
syncUpstreamModelsFailed: '同步上游模型失败',
|
||||
syncUpstreamModelsError: '同步上游模型失败:{message}',
|
||||
syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但能力元数据不完整,能力信息未更新。',
|
||||
syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但未能更新任何能力元数据。',
|
||||
syncUpstreamModelsMetadataPartial: '已更新部分模型的能力元数据;其余模型能力仍不完整。',
|
||||
clearAllModels: '清除所有模型',
|
||||
customModelName: '自定义模型名称',
|
||||
enterCustomModelName: '输入自定义模型名称',
|
||||
|
||||
Reference in New Issue
Block a user