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:
Wesley Liddick
2026-09-05 16:07:50 +08:00
committed by GitHub
16 changed files with 545 additions and 120 deletions
+1
View File
@@ -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)
+6 -3
View File
@@ -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 == "" {
+149 -28
View File
@@ -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 == "" {
+273 -78
View File
@@ -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: '输入自定义模型名称',