mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:28:39 +08:00
fix(codex): sync routed capabilities from upstream
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.
This commit is contained in:
@@ -2774,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2793,7 +2795,7 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
|
||||
@@ -2824,13 +2826,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2843,7 +2847,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
|
||||
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
}
|
||||
|
||||
type syncUpstreamHTTPUpstream struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
resp *http.Response
|
||||
responses []*http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
if u.err != nil {
|
||||
return nil, u.err
|
||||
}
|
||||
if len(u.responses) > 0 {
|
||||
resp := u.responses[0]
|
||||
u.responses = u.responses[1:]
|
||||
return resp, nil
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
@@ -347,6 +353,46 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
|
||||
require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"custom-thinking-model",
|
||||
"reasoning":true,
|
||||
"default_reasoning_level":"high",
|
||||
"supported_reasoning_levels":["low","high"],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":256000
|
||||
}]}`)),
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
|
||||
metadata := resp.Data.Metadata["custom-thinking-model"]
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -377,3 +423,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
|
||||
require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
|
||||
require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
|
||||
}
|
||||
|
||||
// Scenario: 能力补全失败显示部分成功。
|
||||
func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 46,
|
||||
Name: "opencode-id-only-model-list",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "opencode-key",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Models []string `json:"models"`
|
||||
Warnings []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
|
||||
require.Len(t, resp.Data.Warnings, 1)
|
||||
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
|
||||
}
|
||||
|
||||
@@ -146,6 +146,9 @@ type AccountTestService struct {
|
||||
cfg *config.Config
|
||||
settingService *SettingService
|
||||
tlsFPProfileService *TLSFingerprintProfileService
|
||||
modelMetadataRegistryMu sync.Mutex
|
||||
modelMetadataRegistry map[string]modelsDevProvider
|
||||
modelMetadataRegistryAt time.Time
|
||||
agentIdentityTaskMu sync.Mutex
|
||||
agentIdentityWS agentIdentityWSConnectionInvalidator
|
||||
// grokWSDialer is optional; realtime account tests use the default OpenAI-style
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
package service
|
||||
|
||||
import "strings"
|
||||
|
||||
func groupCodexModelMetadata(
|
||||
platform string,
|
||||
modelID string,
|
||||
accounts []Account,
|
||||
compositeRoutes []CompositeModelRoute,
|
||||
compositeRoutesAvailable bool,
|
||||
) (codexModelMetadataOverride, bool) {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if modelID == "" {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
upstreamModel := modelID
|
||||
if platform == PlatformComposite {
|
||||
var resolved bool
|
||||
platform, upstreamModel, resolved = resolveCodexCompositeModelTarget(
|
||||
modelID,
|
||||
accounts,
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
)
|
||||
if !resolved {
|
||||
if codexExplicitModelTargetsConflict(accounts, modelID) {
|
||||
return codexModelMetadataOverride{
|
||||
reasoningConflict: true,
|
||||
inputModalitiesConflict: true,
|
||||
}, true
|
||||
}
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
}
|
||||
if !isConcreteRequestPlatform(platform) {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
|
||||
explicitClaims := false
|
||||
if upstreamModel == modelID {
|
||||
for _, account := range accounts {
|
||||
if account.Platform == platform && codexExplicitModelMappingClaims(account, modelID) {
|
||||
explicitClaims = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if explicitClaims && codexExplicitModelTargetsConflict(accounts, modelID) {
|
||||
return codexModelMetadataOverride{
|
||||
reasoningConflict: true,
|
||||
inputModalitiesConflict: true,
|
||||
}, true
|
||||
}
|
||||
|
||||
candidates := make([]UpstreamModelMetadata, 0)
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
if account.Platform != platform {
|
||||
continue
|
||||
}
|
||||
var lookupModel string
|
||||
if explicitClaims {
|
||||
if !codexExplicitModelMappingClaims(*account, modelID) {
|
||||
continue
|
||||
}
|
||||
lookupModel = account.GetMappedModel(modelID)
|
||||
} else {
|
||||
if !account.IsModelSupported(upstreamModel) {
|
||||
continue
|
||||
}
|
||||
lookupModel = account.GetMappedModel(upstreamModel)
|
||||
}
|
||||
metadata, ok := account.GetUpstreamModelMetadata(lookupModel)
|
||||
if !ok {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
candidates = append(candidates, metadata)
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
return codexModelMetadataOverride{}, false
|
||||
}
|
||||
return intersectUpstreamModelMetadata(modelID, candidates), true
|
||||
}
|
||||
|
||||
func codexExplicitModelTargetsConflict(accounts []Account, modelID string) bool {
|
||||
targets := make(map[string]struct{})
|
||||
for i := range accounts {
|
||||
account := &accounts[i]
|
||||
mappedModel, matched := account.ResolveMappedModel(modelID)
|
||||
mappedModel = strings.TrimSpace(mappedModel)
|
||||
if !matched || mappedModel == "" {
|
||||
continue
|
||||
}
|
||||
targets[strings.TrimSpace(account.Platform)+"\x00"+mappedModel] = struct{}{}
|
||||
}
|
||||
return len(targets) > 1
|
||||
}
|
||||
|
||||
func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMetadata) codexModelMetadataOverride {
|
||||
result := codexModelMetadataOverride{UpstreamModelMetadata: UpstreamModelMetadata{ID: strings.TrimSpace(modelID)}}
|
||||
for _, candidate := range candidates {
|
||||
if result.DisplayName == "" && strings.TrimSpace(candidate.DisplayName) != "" {
|
||||
result.DisplayName = strings.TrimSpace(candidate.DisplayName)
|
||||
}
|
||||
if result.Description == "" && strings.TrimSpace(candidate.Description) != "" {
|
||||
result.Description = strings.TrimSpace(candidate.Description)
|
||||
}
|
||||
}
|
||||
|
||||
reasoningKnown := true
|
||||
reasoningValue := false
|
||||
for i, candidate := range candidates {
|
||||
if candidate.Reasoning == nil {
|
||||
reasoningKnown = false
|
||||
break
|
||||
}
|
||||
if i == 0 {
|
||||
reasoningValue = *candidate.Reasoning
|
||||
continue
|
||||
}
|
||||
if reasoningValue != *candidate.Reasoning {
|
||||
reasoningKnown = false
|
||||
result.reasoningConflict = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if reasoningKnown {
|
||||
result.Reasoning = &reasoningValue
|
||||
if reasoningValue {
|
||||
levels := normalizeReasoningLevels(candidates[0].SupportedReasoningLevels)
|
||||
for _, candidate := range candidates[1:] {
|
||||
levels = intersectOrderedStrings(levels, normalizeReasoningLevels(candidate.SupportedReasoningLevels))
|
||||
}
|
||||
result.SupportedReasoningLevels = levels
|
||||
if len(levels) == 0 {
|
||||
result.reasoningConflict = true
|
||||
} else {
|
||||
sharedDefault := normalizeReasoningLevel(candidates[0].DefaultReasoningLevel)
|
||||
for _, candidate := range candidates[1:] {
|
||||
if normalizeReasoningLevel(candidate.DefaultReasoningLevel) != sharedDefault {
|
||||
sharedDefault = ""
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stringSliceContains(levels, sharedDefault) {
|
||||
sharedDefault = levels[0]
|
||||
}
|
||||
result.DefaultReasoningLevel = sharedDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modalitiesKnown := true
|
||||
modalities := normalizeCodexInputModalities(candidates[0].InputModalities)
|
||||
if len(modalities) == 0 {
|
||||
modalitiesKnown = false
|
||||
}
|
||||
for _, candidate := range candidates[1:] {
|
||||
candidateModalities := normalizeCodexInputModalities(candidate.InputModalities)
|
||||
if len(candidateModalities) == 0 {
|
||||
modalitiesKnown = false
|
||||
break
|
||||
}
|
||||
modalities = intersectOrderedStrings(modalities, candidateModalities)
|
||||
}
|
||||
if modalitiesKnown && len(modalities) > 0 {
|
||||
result.InputModalities = modalities
|
||||
} else if modalitiesKnown {
|
||||
result.inputModalitiesConflict = true
|
||||
}
|
||||
|
||||
contextKnown := true
|
||||
for i, candidate := range candidates {
|
||||
if candidate.ContextWindow <= 0 {
|
||||
contextKnown = false
|
||||
break
|
||||
}
|
||||
if i == 0 || candidate.ContextWindow < result.ContextWindow {
|
||||
result.ContextWindow = candidate.ContextWindow
|
||||
}
|
||||
}
|
||||
if !contextKnown {
|
||||
result.ContextWindow = 0
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyUpstreamModelMetadataToCodexDescriptor(
|
||||
descriptor *configuredCodexModelDescriptor,
|
||||
metadata codexModelMetadataOverride,
|
||||
) {
|
||||
if descriptor == nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(metadata.DisplayName) != "" {
|
||||
descriptor.DisplayName = strings.TrimSpace(metadata.DisplayName)
|
||||
}
|
||||
if strings.TrimSpace(metadata.Description) != "" {
|
||||
descriptor.Description = strings.TrimSpace(metadata.Description)
|
||||
}
|
||||
if metadata.reasoningConflict {
|
||||
descriptor.DefaultReasoningLevel = nil
|
||||
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
|
||||
} else if metadata.Reasoning != nil && !*metadata.Reasoning {
|
||||
none := "none"
|
||||
descriptor.DefaultReasoningLevel = &none
|
||||
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{{
|
||||
Effort: "none",
|
||||
Description: configuredCodexReasoningLevelDescription("none"),
|
||||
}}
|
||||
} else if metadata.Reasoning != nil && *metadata.Reasoning {
|
||||
levels := normalizeReasoningLevels(metadata.SupportedReasoningLevels)
|
||||
if len(levels) == 0 {
|
||||
descriptor.DefaultReasoningLevel = nil
|
||||
descriptor.SupportedReasoningLevels = []configuredCodexReasoningLevel{}
|
||||
} else {
|
||||
defaultLevel := normalizeReasoningLevel(metadata.DefaultReasoningLevel)
|
||||
if !stringSliceContains(levels, defaultLevel) {
|
||||
defaultLevel = levels[0]
|
||||
}
|
||||
descriptor.DefaultReasoningLevel = &defaultLevel
|
||||
descriptor.SupportedReasoningLevels = make([]configuredCodexReasoningLevel, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
descriptor.SupportedReasoningLevels = append(descriptor.SupportedReasoningLevels, configuredCodexReasoningLevel{
|
||||
Effort: level,
|
||||
Description: configuredCodexReasoningLevelDescription(level),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.inputModalitiesConflict {
|
||||
descriptor.InputModalities = []string{"text"}
|
||||
} else if modalities := normalizeCodexInputModalities(metadata.InputModalities); len(modalities) > 0 {
|
||||
descriptor.InputModalities = modalities
|
||||
}
|
||||
if metadata.ContextWindow > 0 {
|
||||
descriptor.ContextWindow = metadata.ContextWindow
|
||||
descriptor.MaxContextWindow = metadata.ContextWindow
|
||||
}
|
||||
}
|
||||
|
||||
func configuredCodexReasoningLevelDescription(level string) string {
|
||||
switch level {
|
||||
case "none":
|
||||
return "Use the model's default behavior without configurable reasoning"
|
||||
case "minimal":
|
||||
return "Minimal reasoning for the fastest responses"
|
||||
case "low":
|
||||
return "Fast responses with lighter reasoning"
|
||||
case "medium":
|
||||
return "Balanced reasoning for most coding tasks"
|
||||
case "high":
|
||||
return "Greater reasoning depth for coding and agent tasks"
|
||||
case "xhigh":
|
||||
return "Extra-high reasoning depth for difficult tasks"
|
||||
case "max":
|
||||
return "Maximum reasoning depth for complex tasks"
|
||||
default:
|
||||
return "Reasoning effort supported by the upstream model"
|
||||
}
|
||||
}
|
||||
|
||||
func intersectOrderedStrings(left, right []string) []string {
|
||||
rightSet := make(map[string]struct{}, len(right))
|
||||
for _, value := range right {
|
||||
rightSet[value] = struct{}{}
|
||||
}
|
||||
intersection := make([]string, 0, len(left))
|
||||
for _, value := range left {
|
||||
if _, ok := rightSet[value]; ok {
|
||||
intersection = append(intersection, value)
|
||||
}
|
||||
}
|
||||
return intersection
|
||||
}
|
||||
|
||||
func stringSliceContains(values []string, target string) bool {
|
||||
if target == "" {
|
||||
return false
|
||||
}
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Scenario: mixed groups prefer capability metadata synced for the routed account.
|
||||
func TestBuildCodexModelsManifestForGroupUsesSyncedAccountMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 735
|
||||
account := Account{
|
||||
ID: 25,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"model_mapping": map[string]any{"x-preview-f-free": "x-preview-f-free"},
|
||||
},
|
||||
Extra: map[string]any{
|
||||
UpstreamModelMetadataExtraKey: map[string]any{
|
||||
"source": "models.dev",
|
||||
"models": map[string]any{
|
||||
"x-preview-f-free": map[string]any{
|
||||
"id": "x-preview-f-free",
|
||||
"display_name": "Ox Alpha Free (Unlimited)",
|
||||
"description": "Stealth reasoning model",
|
||||
"reasoning": true,
|
||||
"supported_reasoning_levels": []any{"low", "high", "max"},
|
||||
"input_modalities": []any{"text", "image"},
|
||||
"context_window": float64(1_000_000),
|
||||
"max_output_tokens": float64(131_072),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {account},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(),
|
||||
&Group{ID: groupID, Platform: PlatformComposite},
|
||||
"",
|
||||
[]string{"x-preview-f-free"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "Ox Alpha Free (Unlimited)", models[0]["display_name"])
|
||||
require.Equal(t, "low", models[0]["default_reasoning_level"])
|
||||
require.Equal(t, []string{"low", "high", "max"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 1_000_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
// Scenario: an explicitly non-reasoning model remains directly selectable in Codex.
|
||||
func TestBuildCodexModelsManifestForGroupUsesNoneForExplicitNonReasoningMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 737
|
||||
reasoning := false
|
||||
account := Account{
|
||||
ID: 28, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"company-coding-model": "company-coding-model"},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"company-coding-model": {
|
||||
ID: "company-coding-model", Reasoning: &reasoning,
|
||||
InputModalities: []string{"text"}, ContextWindow: 64_000,
|
||||
},
|
||||
}})
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {account},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"company-coding-model"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "none", models[0]["default_reasoning_level"])
|
||||
require.Equal(t, []string{"none"}, effortsFromManifestModel(t, models[0]))
|
||||
}
|
||||
|
||||
// Scenario: multiple schedulable accounts advertise only their shared capabilities.
|
||||
func TestBuildCodexModelsManifestForGroupIntersectsSyncedAccountMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 736
|
||||
reasoning := true
|
||||
newAccount := func(id int64, levels, modalities []string, contextWindow int64) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"shared-model": "shared-model"},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"shared-model": {
|
||||
ID: "shared-model", Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: levels,
|
||||
InputModalities: modalities,
|
||||
ContextWindow: contextWindow,
|
||||
},
|
||||
}})
|
||||
return account
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
newAccount(26, []string{"low", "high"}, []string{"text", "image"}, 256_000),
|
||||
newAccount(27, []string{"high", "max"}, []string{"text"}, 128_000),
|
||||
},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, []string{"high"}, effortsFromManifestModel(t, models[0]))
|
||||
require.Equal(t, "high", models[0]["default_reasoning_level"])
|
||||
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
|
||||
require.EqualValues(t, 128_000, models[0]["context_window"])
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupDoesNotAdvertiseNoneWhenAccountReasoningConflicts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const groupID int64 = 738
|
||||
reasoning := true
|
||||
noReasoning := false
|
||||
newAccount := func(id int64, metadata UpstreamModelMetadata) Account {
|
||||
account := Account{
|
||||
ID: id, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"shared-model": "shared-model"},
|
||||
},
|
||||
}
|
||||
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
|
||||
"shared-model": metadata,
|
||||
}})
|
||||
return account
|
||||
}
|
||||
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
|
||||
groupID: {
|
||||
newAccount(29, UpstreamModelMetadata{
|
||||
ID: "shared-model", Reasoning: &reasoning,
|
||||
SupportedReasoningLevels: []string{"low", "high"},
|
||||
InputModalities: []string{"text"}, ContextWindow: 128_000,
|
||||
}),
|
||||
newAccount(30, UpstreamModelMetadata{
|
||||
ID: "shared-model", Reasoning: &noReasoning,
|
||||
InputModalities: []string{"text"}, ContextWindow: 128_000,
|
||||
}),
|
||||
},
|
||||
}}}
|
||||
|
||||
body, err := svc.BuildCodexModelsManifestForGroup(
|
||||
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-model"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
_, hasDefault := models[0]["default_reasoning_level"]
|
||||
require.False(t, hasDefault)
|
||||
require.Empty(t, models[0]["supported_reasoning_levels"])
|
||||
}
|
||||
@@ -292,13 +292,23 @@ type configuredCodexModelDescriptor struct {
|
||||
MultiAgentVersion any `json:"multi_agent_version"`
|
||||
}
|
||||
|
||||
type codexModelMetadataOverride struct {
|
||||
UpstreamModelMetadata
|
||||
reasoningConflict bool
|
||||
inputModalitiesConflict bool
|
||||
}
|
||||
|
||||
func newConfiguredCodexModelDescriptor(modelID string) configuredCodexModelDescriptor {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
noReasoningLevel := "none"
|
||||
descriptor := configuredCodexModelDescriptor{
|
||||
Slug: modelID,
|
||||
DisplayName: modelID,
|
||||
Description: "Custom model routed through Sub2API.",
|
||||
SupportedReasoningLevels: []configuredCodexReasoningLevel{},
|
||||
Slug: modelID,
|
||||
DisplayName: modelID,
|
||||
Description: "Custom model routed through Sub2API.",
|
||||
DefaultReasoningLevel: &noReasoningLevel,
|
||||
SupportedReasoningLevels: []configuredCodexReasoningLevel{
|
||||
{Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
|
||||
},
|
||||
ShellType: "unified_exec",
|
||||
Visibility: "list",
|
||||
SupportedInAPI: true,
|
||||
@@ -606,7 +616,7 @@ func claudeCodexDisplayName(modelID string) string {
|
||||
// routed through a custom provider. The response is also suitable for saving
|
||||
// as model_catalog_json in clients that do not refresh custom-provider catalogs.
|
||||
func BuildCodexModelsManifest(modelIDs []string) ([]byte, error) {
|
||||
return buildCodexModelsManifest(modelIDs, nil, nil)
|
||||
return buildCodexModelsManifest(modelIDs, nil, nil, nil)
|
||||
}
|
||||
|
||||
// BuildCodexModelsManifestForGroup derives input capabilities from the
|
||||
@@ -626,7 +636,7 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
||||
if effectivePlatform == "" {
|
||||
effectivePlatform = group.Platform
|
||||
}
|
||||
if effectivePlatform != PlatformOpenAI && effectivePlatform != PlatformGrok && effectivePlatform != PlatformComposite {
|
||||
if effectivePlatform != PlatformComposite && !isConcreteRequestPlatform(effectivePlatform) {
|
||||
return BuildCodexModelsManifest(modelIDs)
|
||||
}
|
||||
|
||||
@@ -650,7 +660,9 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
)
|
||||
modelMetadata := make(map[string]codexModelMetadataOverride, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
modelID = strings.TrimSpace(modelID)
|
||||
if groupCodexModelSupportsImageInput(
|
||||
effectivePlatform,
|
||||
modelID,
|
||||
@@ -658,13 +670,27 @@ func (s *GatewayService) BuildCodexModelsManifestForGroup(
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
) {
|
||||
imageInputModels[strings.TrimSpace(modelID)] = true
|
||||
imageInputModels[modelID] = true
|
||||
}
|
||||
if metadata, ok := groupCodexModelMetadata(
|
||||
effectivePlatform,
|
||||
modelID,
|
||||
accounts,
|
||||
compositeRoutes,
|
||||
compositeRoutesAvailable,
|
||||
); ok {
|
||||
modelMetadata[modelID] = metadata
|
||||
}
|
||||
}
|
||||
return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels)
|
||||
return buildCodexModelsManifest(modelIDs, imageInputModels, metadataModels, modelMetadata)
|
||||
}
|
||||
|
||||
func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]bool, metadataModels map[string]string) ([]byte, error) {
|
||||
func buildCodexModelsManifest(
|
||||
modelIDs []string,
|
||||
imageInputModels map[string]bool,
|
||||
metadataModels map[string]string,
|
||||
modelMetadata map[string]codexModelMetadataOverride,
|
||||
) ([]byte, error) {
|
||||
seen := make(map[string]struct{}, len(modelIDs))
|
||||
models := make([]configuredCodexModelDescriptor, 0, len(modelIDs))
|
||||
for _, modelID := range modelIDs {
|
||||
@@ -688,6 +714,9 @@ func buildCodexModelsManifest(modelIDs []string, imageInputModels map[string]boo
|
||||
if imageInputModels[modelID] {
|
||||
descriptor.InputModalities = []string{"text", "image"}
|
||||
}
|
||||
if metadata, ok := modelMetadata[modelID]; ok {
|
||||
applyUpstreamModelMetadataToCodexDescriptor(&descriptor, metadata)
|
||||
}
|
||||
models = append(models, descriptor)
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
|
||||
@@ -182,8 +182,11 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t
|
||||
|
||||
grokNonReasoning := newConfiguredCodexModelDescriptor("grok-4.20-0309-non-reasoning")
|
||||
require.Equal(t, "Grok 4.20 Non Reasoning", grokNonReasoning.DisplayName)
|
||||
require.Nil(t, grokNonReasoning.DefaultReasoningLevel)
|
||||
require.Empty(t, grokNonReasoning.SupportedReasoningLevels)
|
||||
require.NotNil(t, grokNonReasoning.DefaultReasoningLevel)
|
||||
require.Equal(t, "none", *grokNonReasoning.DefaultReasoningLevel)
|
||||
require.Equal(t, []configuredCodexReasoningLevel{
|
||||
{Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
|
||||
}, grokNonReasoning.SupportedReasoningLevels)
|
||||
|
||||
claude := newConfiguredCodexModelDescriptor("claude-opus-4-6")
|
||||
require.Equal(t, "Claude Opus 4.6", claude.DisplayName)
|
||||
@@ -203,8 +206,9 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t
|
||||
|
||||
claudeHaiku := newConfiguredCodexModelDescriptor("claude-haiku-4-5-20251001")
|
||||
require.Equal(t, "Claude Haiku 4.5", claudeHaiku.DisplayName)
|
||||
require.Nil(t, claudeHaiku.DefaultReasoningLevel)
|
||||
require.Empty(t, claudeHaiku.SupportedReasoningLevels)
|
||||
require.NotNil(t, claudeHaiku.DefaultReasoningLevel)
|
||||
require.Equal(t, "none", *claudeHaiku.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(claudeHaiku.SupportedReasoningLevels))
|
||||
|
||||
gpt56 := newConfiguredCodexModelDescriptor("gpt-5.6-sol")
|
||||
require.Equal(t, "GPT-5.6 Sol", gpt56.DisplayName)
|
||||
@@ -238,20 +242,25 @@ func TestNewConfiguredCodexModelDescriptorUsesProviderMetadataAndSafeFallback(t
|
||||
|
||||
gpt4o := newConfiguredCodexModelDescriptor("gpt-4o")
|
||||
require.Equal(t, "gpt-4o", gpt4o.DisplayName)
|
||||
require.Nil(t, gpt4o.DefaultReasoningLevel)
|
||||
require.Empty(t, gpt4o.SupportedReasoningLevels)
|
||||
require.NotNil(t, gpt4o.DefaultReasoningLevel)
|
||||
require.Equal(t, "none", *gpt4o.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(gpt4o.SupportedReasoningLevels))
|
||||
require.True(t, gpt4o.SupportsParallelToolCalls)
|
||||
|
||||
image := newConfiguredCodexModelDescriptor("gpt-image-2")
|
||||
require.Equal(t, "gpt-image-2", image.DisplayName)
|
||||
require.Nil(t, image.DefaultReasoningLevel)
|
||||
require.Empty(t, image.SupportedReasoningLevels)
|
||||
require.NotNil(t, image.DefaultReasoningLevel)
|
||||
require.Equal(t, "none", *image.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"none"}, effortsFromConfiguredCodexLevels(image.SupportedReasoningLevels))
|
||||
|
||||
custom := newConfiguredCodexModelDescriptor("company-coding-model")
|
||||
require.Equal(t, "company-coding-model", custom.DisplayName)
|
||||
require.Equal(t, int64(272_000), custom.ContextWindow)
|
||||
require.Nil(t, custom.DefaultReasoningLevel)
|
||||
require.Empty(t, custom.SupportedReasoningLevels)
|
||||
require.NotNil(t, custom.DefaultReasoningLevel)
|
||||
require.Equal(t, "none", *custom.DefaultReasoningLevel)
|
||||
require.Equal(t, []configuredCodexReasoningLevel{
|
||||
{Effort: "none", Description: configuredCodexReasoningLevelDescription("none")},
|
||||
}, custom.SupportedReasoningLevels)
|
||||
require.False(t, custom.SupportsParallelToolCalls)
|
||||
require.NotEmpty(t, custom.ModelMessages.InstructionsTemplate)
|
||||
require.Equal(t, "auto", custom.DefaultReasoningSummary)
|
||||
@@ -266,6 +275,40 @@ func effortsFromConfiguredCodexLevels(levels []configuredCodexReasoningLevel) []
|
||||
return efforts
|
||||
}
|
||||
|
||||
// Scenario: 无推理模型可直接选中。
|
||||
func TestBuildCodexModelsManifestUsesSingleNoneReasoningChoiceForCustomModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := BuildCodexModelsManifest([]string{"company-coding-model"})
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "none", models[0]["default_reasoning_level"])
|
||||
levels, ok := models[0]["supported_reasoning_levels"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, levels, 1)
|
||||
firstLevel, ok := levels[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "none", firstLevel["effort"])
|
||||
}
|
||||
|
||||
// Scenario: 已知推理模型保留真实档位。
|
||||
func TestBuildCodexModelsManifestKeepsKnownReasoningChoices(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body, err := BuildCodexModelsManifest([]string{"gpt-5.6-sol"})
|
||||
require.NoError(t, err)
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
require.Equal(t, "low", models[0]["default_reasoning_level"])
|
||||
levels, ok := models[0]["supported_reasoning_levels"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, levels, 6)
|
||||
firstLevel, ok := levels[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.NotEqual(t, "none", firstLevel["effort"])
|
||||
}
|
||||
|
||||
// Scenario: 专用图片生成模型不进入 Codex 主模型目录。
|
||||
func TestBuildCodexModelsManifestOmitsDedicatedImageModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -679,7 +722,7 @@ func TestBuildCodexModelsManifestForGroupLoadsAccountsOnce(t *testing.T) {
|
||||
require.Equal(t, int32(1), repo.calls.Load())
|
||||
}
|
||||
|
||||
func TestBuildCodexModelsManifestForGroupSkipsCapabilityLookupForTextOnlyPlatform(t *testing.T) {
|
||||
func TestBuildCodexModelsManifestForGroupUsesFallbackWhenTextOnlyPlatformHasNoSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := &countingCodexModelsAccountRepo{}
|
||||
@@ -691,7 +734,7 @@ func TestBuildCodexModelsManifestForGroupSkipsCapabilityLookupForTextOnlyPlatfor
|
||||
[]string{"deepseek-v4-pro"},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int32(0), repo.calls.Load())
|
||||
require.Equal(t, int32(1), repo.calls.Load())
|
||||
|
||||
models := decodeCodexManifestModels(t, body)
|
||||
require.Len(t, models, 1)
|
||||
|
||||
@@ -20,6 +20,11 @@ import (
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
beginUpstreamResponseModelObservation(c)
|
||||
filteredBody, filterErr := filterOpenAIResponsesNoneReasoningEffortForAccount(account, body)
|
||||
if filterErr != nil {
|
||||
return nil, filterErr
|
||||
}
|
||||
body = filteredBody
|
||||
clearGrokResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesNamespaceNames(c)
|
||||
|
||||
@@ -55,6 +55,69 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string {
|
||||
return buildOpenAIResponsesURL(base)
|
||||
}
|
||||
|
||||
func shouldPreserveOpenAIResponsesNoneReasoningEffort(account *Account) bool {
|
||||
if account == nil {
|
||||
return false
|
||||
}
|
||||
if account.IsOpenAIOAuthLike() {
|
||||
return true
|
||||
}
|
||||
if !account.IsOpenAIApiKey() {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.TrimSpace(account.GetCredential("base_url"))
|
||||
return baseURL == "" || isOfficialOpenAIModelsBaseURL(baseURL)
|
||||
}
|
||||
|
||||
// Codex 0.149.0 needs a single advertised effort to directly select a visible
|
||||
// non-reasoning model. Treat that catalog-only "none" value as omission for
|
||||
// compatible upstreams, while preserving official OpenAI request semantics.
|
||||
func filterOpenAIResponsesNoneReasoningEffortForAccount(account *Account, body []byte) ([]byte, error) {
|
||||
if len(body) == 0 || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
out := body
|
||||
for _, path := range []string{"reasoning.effort", "reasoning_effort"} {
|
||||
effort := gjson.GetBytes(out, path)
|
||||
if effort.Type != gjson.String || !strings.EqualFold(strings.TrimSpace(effort.String()), "none") {
|
||||
continue
|
||||
}
|
||||
next, err := sjson.DeleteBytes(out, path)
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("strip %s none placeholder: %w", path, err)
|
||||
}
|
||||
out = next
|
||||
}
|
||||
if reasoning := gjson.GetBytes(out, "reasoning"); reasoning.IsObject() && len(reasoning.Map()) == 0 {
|
||||
next, err := sjson.DeleteBytes(out, "reasoning")
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("strip empty reasoning object: %w", err)
|
||||
}
|
||||
out = next
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body map[string]any) {
|
||||
if body == nil || shouldPreserveOpenAIResponsesNoneReasoningEffort(account) {
|
||||
return
|
||||
}
|
||||
if effort, ok := body["reasoning_effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
|
||||
delete(body, "reasoning_effort")
|
||||
}
|
||||
reasoning, ok := body["reasoning"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if effort, ok := reasoning["effort"].(string); ok && strings.EqualFold(strings.TrimSpace(effort), "none") {
|
||||
delete(reasoning, "effort")
|
||||
}
|
||||
if len(reasoning) == 0 {
|
||||
delete(body, "reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点:
|
||||
// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端
|
||||
// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。
|
||||
|
||||
@@ -270,3 +270,63 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
|
||||
require.True(t, changed)
|
||||
require.False(t, gjson.GetBytes(normalized, "parallel_tool_calls").Exists())
|
||||
}
|
||||
|
||||
func TestFilterOpenAIResponsesNoneReasoningEffortForAccount(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
body string
|
||||
wantNested bool
|
||||
wantFlat bool
|
||||
wantSummary bool
|
||||
wantReasoning bool
|
||||
}{
|
||||
{
|
||||
name: "custom compatible endpoint strips none placeholders",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}},
|
||||
body: `{"reasoning":{"effort":"none"},"reasoning_effort":"NONE"}`,
|
||||
wantReasoning: false,
|
||||
},
|
||||
{
|
||||
name: "third-party platform keeps other reasoning members",
|
||||
account: &Account{Platform: PlatformGrok, Type: AccountTypeAPIKey},
|
||||
body: `{"reasoning":{"effort":" none ","summary":"auto"}}`,
|
||||
wantSummary: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
{
|
||||
name: "non-none effort is unchanged",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"base_url": "https://compat.example/v1"}},
|
||||
body: `{"reasoning":{"effort":"high"},"reasoning_effort":"low"}`,
|
||||
wantNested: true,
|
||||
wantFlat: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
{
|
||||
name: "official OpenAI API key preserves none",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
body: `{"reasoning":{"effort":"none"},"reasoning_effort":"none"}`,
|
||||
wantNested: true,
|
||||
wantFlat: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
{
|
||||
name: "OpenAI OAuth preserves none",
|
||||
account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
|
||||
body: `{"reasoning":{"effort":"none"}}`,
|
||||
wantNested: true,
|
||||
wantReasoning: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := filterOpenAIResponsesNoneReasoningEffortForAccount(tt.account, []byte(tt.body))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantNested, gjson.GetBytes(got, "reasoning.effort").Exists())
|
||||
require.Equal(t, tt.wantFlat, gjson.GetBytes(got, "reasoning_effort").Exists())
|
||||
require.Equal(t, tt.wantSummary, gjson.GetBytes(got, "reasoning.summary").Exists())
|
||||
require.Equal(t, tt.wantReasoning, gjson.GetBytes(got, "reasoning").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,36 @@ func TestForwardResponses_ForceChatCompletionsRoutesNonStreamingToChatCompletion
|
||||
require.False(t, result.Stream)
|
||||
}
|
||||
|
||||
// Scenario: 第三方无推理模型不收到兼容档位。
|
||||
func TestForwardResponses_ForceChatCompletionsOmitsNoneReasoningEffort(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
body := []byte(`{"model":"company-coding-model","input":"hello","reasoning":{"effort":"none"},"stream":false}`)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(
|
||||
`{"id":"chatcmpl_none","object":"chat.completion","model":"company-coding-model","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`,
|
||||
)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: rawChatCompletionsTestConfig(),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, forceChatResponsesFallbackAccount(), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, "company-coding-model", gjson.GetBytes(upstream.lastBody, "model").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "reasoning_effort").Exists())
|
||||
require.Nil(t, result.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestForwardResponses_PassthroughFlagWithUnsupportedResponsesUsesAccountMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ func (s *OpenAIGatewayService) shouldBridgeOpenAIWSHTTP(account *Account, payloa
|
||||
return threshold > 0 && int64(payloadBytes) >= threshold
|
||||
}
|
||||
|
||||
func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) {
|
||||
func prepareOpenAIWSHTTPBridgeBody(account *Account, payload []byte) ([]byte, error) {
|
||||
var body map[string]any
|
||||
if err := decodeOpenAIJSONUseNumber(payload, &body); err != nil {
|
||||
return nil, err
|
||||
@@ -126,6 +126,7 @@ func prepareOpenAIWSHTTPBridgeBody(payload []byte) ([]byte, error) {
|
||||
delete(body, "type")
|
||||
delete(body, "generate")
|
||||
delete(body, "previous_response_id")
|
||||
deleteOpenAIResponsesNoneReasoningEffortFromObject(account, body)
|
||||
body["stream"] = true
|
||||
return json.Marshal(body)
|
||||
}
|
||||
@@ -305,7 +306,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
responseModelObserver := &upstreamResponseModelObserver{}
|
||||
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(payload)
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(account, payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("prepare http bridge body: %w", err)
|
||||
}
|
||||
@@ -826,7 +827,7 @@ func (s *OpenAIGatewayService) proxyOpenAIWSHTTPBridgeTurn(
|
||||
}
|
||||
|
||||
func resolveGrokWSCacheIdentity(c *gin.Context, account *Account, seedPayload, currentPayload []byte, originalModel string) (string, error) {
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(seedPayload)
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(account, seedPayload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestResolveOpenAIWSClientFirstMessageTimeout(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) {
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`))
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create","generate":true,"model":"gpt-5","stream":false,"previous_response_id":"resp_prev","input":"hi","sequence":900719925474099312345}`))
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(body, "type").Exists())
|
||||
require.False(t, gjson.GetBytes(body, "generate").Exists())
|
||||
@@ -40,10 +40,26 @@ func TestPrepareOpenAIWSHTTPBridgeBodyStripsWSFields(t *testing.T) {
|
||||
require.True(t, gjson.GetBytes(body, "stream").Bool())
|
||||
require.Equal(t, "hi", gjson.GetBytes(body, "input").String())
|
||||
require.Equal(t, "900719925474099312345", gjson.GetBytes(body, "sequence").Raw)
|
||||
_, err = prepareOpenAIWSHTTPBridgeBody([]byte(`{"type":"response.create"}{"trailing":true}`))
|
||||
_, err = prepareOpenAIWSHTTPBridgeBody(nil, []byte(`{"type":"response.create"}{"trailing":true}`))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPrepareOpenAIWSHTTPBridgeBodyStripsNoneReasoningForCompatibleEndpoint(t *testing.T) {
|
||||
payload := []byte(`{"type":"response.create","model":"company-coding-model","reasoning":{"effort":"none"},"input":"hi"}`)
|
||||
compatible := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{
|
||||
"base_url": "https://compat.example/v1",
|
||||
}}
|
||||
|
||||
body, err := prepareOpenAIWSHTTPBridgeBody(compatible, payload)
|
||||
require.NoError(t, err)
|
||||
require.False(t, gjson.GetBytes(body, "reasoning.effort").Exists())
|
||||
require.False(t, gjson.GetBytes(body, "reasoning").Exists())
|
||||
|
||||
officialBody, err := prepareOpenAIWSHTTPBridgeBody(&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}, payload)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "none", gjson.GetBytes(officialBody, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestProxyOpenAIWSHTTPBridgeTurnAPIKeyAdaptsClientTools(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -3,18 +3,127 @@ 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
|
||||
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
|
||||
@@ -26,13 +135,16 @@ const (
|
||||
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
|
||||
Err error
|
||||
Kind UpstreamModelSyncErrorKind
|
||||
Message string
|
||||
StatusCode int
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *UpstreamModelSyncError) Error() string {
|
||||
@@ -72,48 +184,431 @@ func newUpstreamModelSyncUpstreamError(message string, err error) error {
|
||||
return &UpstreamModelSyncError{Kind: UpstreamModelSyncErrorUpstream, Message: message, Err: err}
|
||||
}
|
||||
|
||||
// FetchUpstreamSupportedModels fetches the live model list from the account's upstream API format.
|
||||
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, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
|
||||
}
|
||||
if account == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Account is required", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil)
|
||||
}
|
||||
|
||||
if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey {
|
||||
return s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
return models, nil, err
|
||||
}
|
||||
|
||||
if s.httpUpstream == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
|
||||
}
|
||||
|
||||
req, err := s.buildUpstreamModelsRequest(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
proxyURL := upstreamModelsProxyURL(account)
|
||||
resp, err := s.doUpstreamModelsRequest(req, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err)
|
||||
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, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to read upstream model list", err)
|
||||
}
|
||||
if int64(len(body)) > upstreamModelsBodyLimit {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", 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, newUpstreamModelSyncUpstreamError(
|
||||
fmt.Sprintf("Upstream model list request failed with HTTP %d", resp.StatusCode),
|
||||
fmt.Errorf("upstream model list returned HTTP %d", resp.StatusCode),
|
||||
)
|
||||
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
|
||||
@@ -122,13 +617,13 @@ func (s *AccountTestService) FetchUpstreamSupportedModels(ctx context.Context, a
|
||||
}
|
||||
models, err := extractModels(body)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response was not valid JSON", err)
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream returned no supported models", nil)
|
||||
}
|
||||
|
||||
return models, nil
|
||||
return models, body, nil
|
||||
}
|
||||
|
||||
func (s *AccountTestService) buildUpstreamModelsRequest(ctx context.Context, account *Account) (*http.Request, error) {
|
||||
@@ -498,6 +993,7 @@ func buildGeminiModelsURL(base string) string {
|
||||
|
||||
type upstreamModelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Model string `json:"model"`
|
||||
ModelID string `json:"modelId"`
|
||||
ModelIDSnake string `json:"model_id"`
|
||||
@@ -507,12 +1003,29 @@ type upstreamModelEntry struct {
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -521,6 +1034,166 @@ 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"`
|
||||
@@ -561,6 +1234,9 @@ func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamMode
|
||||
|
||||
func upstreamModelEntryID(entry upstreamModelEntry) string {
|
||||
modelID := strings.TrimSpace(entry.ID)
|
||||
if modelID == "" {
|
||||
modelID = strings.TrimSpace(entry.Slug)
|
||||
}
|
||||
if modelID == "" {
|
||||
modelID = strings.TrimSpace(entry.Name)
|
||||
}
|
||||
@@ -573,6 +1249,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string {
|
||||
entry.ModelID,
|
||||
entry.ModelIDSnake,
|
||||
entry.ID,
|
||||
entry.Slug,
|
||||
}
|
||||
if len(entry.Meta) > 0 {
|
||||
var meta upstreamModelEntryMetadata
|
||||
@@ -582,6 +1259,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string {
|
||||
meta.ModelID,
|
||||
meta.ModelIDSnake,
|
||||
meta.ID,
|
||||
meta.Slug,
|
||||
meta.Name,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,6 +14,28 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type upstreamModelMetadataRepoStub struct {
|
||||
AccountRepository
|
||||
accountID int64
|
||||
updates map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
func headerValuesEqualFold(header http.Header, name string) []string {
|
||||
for key, values := range header {
|
||||
if strings.EqualFold(key, name) {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *upstreamModelMetadataRepoStub) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
|
||||
r.accountID = id
|
||||
r.updates = updates
|
||||
return r.err
|
||||
}
|
||||
|
||||
func upstreamModelSyncTestConfig() *config.Config {
|
||||
return &config.Config{
|
||||
Security: config.SecurityConfig{
|
||||
@@ -342,6 +365,336 @@ func TestFetchUpstreamSupportedModelsParsesOpenAIResponse(t *testing.T) {
|
||||
require.Equal(t, "Bearer openai-key", upstream.lastReq.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
// Scenario: ID-only 模型列表从 Models.dev 补齐能力。
|
||||
func TestSyncUpstreamModelCatalogEnrichesOpenCodeIDOnlyListAndPersistsSnapshot(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":"x-preview-f-free","object":"model"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"opencode": {
|
||||
"id": "opencode",
|
||||
"name": "OpenCode Zen",
|
||||
"api": "https://opencode.ai/zen/v1",
|
||||
"models": {
|
||||
"x-preview-f-free": {
|
||||
"id": "x-preview-f-free",
|
||||
"name": "Ox Alpha Free (Unlimited)",
|
||||
"description": "Stealth reasoning model for coding, agentic tasks, and tool use",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","high","max"]}],
|
||||
"modalities": {"input":["text","image","video"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{
|
||||
accountRepo: repo,
|
||||
httpUpstream: upstream,
|
||||
cfg: upstreamModelSyncTestConfig(),
|
||||
}
|
||||
account := &Account{
|
||||
ID: 91,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "opencode-key",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
"header_override_enabled": true,
|
||||
"header_overrides": map[string]any{
|
||||
"X-Custom-Account-Header": "account-secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
require.Equal(t, "https://opencode.ai/zen/v1/models", upstream.requests[0].URL.String())
|
||||
require.Equal(t, []string{"account-secret"}, headerValuesEqualFold(upstream.requests[0].Header, "X-Custom-Account-Header"))
|
||||
require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
|
||||
require.Empty(t, upstream.requests[1].Header.Get("Authorization"))
|
||||
require.Empty(t, upstream.requests[1].Header.Get("x-api-key"))
|
||||
require.Empty(t, headerValuesEqualFold(upstream.requests[1].Header, "X-Custom-Account-Header"))
|
||||
|
||||
metadata := catalog.Metadata["x-preview-f-free"]
|
||||
require.Equal(t, "Ox Alpha Free (Unlimited)", metadata.DisplayName)
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"low", "high", "max"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(1_000_000), metadata.ContextWindow)
|
||||
require.Equal(t, int64(131_072), metadata.MaxOutputTokens)
|
||||
require.Equal(t, int64(91), repo.accountID)
|
||||
|
||||
rawSnapshot, ok := repo.updates[UpstreamModelMetadataExtraKey]
|
||||
require.True(t, ok)
|
||||
encoded, err := json.Marshal(rawSnapshot)
|
||||
require.NoError(t, err)
|
||||
var snapshot UpstreamModelMetadataSnapshot
|
||||
require.NoError(t, json.Unmarshal(encoded, &snapshot))
|
||||
require.Equal(t, "models.dev", snapshot.Source)
|
||||
require.Equal(t, metadata, snapshot.Models["x-preview-f-free"])
|
||||
}
|
||||
|
||||
// Scenario: 不提供 /models 的兼容上游使用管理员已配置模型继续同步能力。
|
||||
func TestSyncUpstreamModelCatalogUsesConfiguredModelsWhenListEndpointUnsupported(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"configured-provider": {
|
||||
"id": "configured-provider",
|
||||
"name": "Configured Provider",
|
||||
"api": "https://provider.example/v1",
|
||||
"models": {
|
||||
"glm-5.3": {
|
||||
"id": "glm-5.3",
|
||||
"name": "GLM-5.3",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","medium","high"]}],
|
||||
"modalities": {"input":["text"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
account := &Account{
|
||||
ID: 97, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "key",
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"public-glm": "glm-5.3",
|
||||
"duplicate": "glm-5.3",
|
||||
"wildcard": "glm-*",
|
||||
"empty": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"glm-5.3"}, catalog.Models)
|
||||
require.Empty(t, catalog.Warnings)
|
||||
require.Len(t, upstream.requests, 2)
|
||||
require.Equal(t, "https://provider.example/v1/models", upstream.requests[0].URL.String())
|
||||
require.Equal(t, modelsDevRegistryURL, upstream.requests[1].URL.String())
|
||||
metadata := catalog.Metadata["glm-5.3"]
|
||||
require.Equal(t, []string{"low", "medium", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(1_000_000), metadata.ContextWindow)
|
||||
require.NotNil(t, repo.updates)
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogDoesNotUseConfiguredModelsForRealUpstreamFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{name: "unauthorized", statusCode: http.StatusUnauthorized},
|
||||
{name: "rate limited", statusCode: http.StatusTooManyRequests},
|
||||
{name: "server error", statusCode: http.StatusBadGateway},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: tt.statusCode,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"failed"}`)),
|
||||
}}
|
||||
svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 98, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "key",
|
||||
"base_url": "https://provider.example/v1",
|
||||
"model_mapping": map[string]any{"public-glm": "glm-5.3"},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Len(t, upstream.requests, 1)
|
||||
require.Equal(t, tt.statusCode, upstreamModelSyncStatusCode(err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogRequiresConfiguredModelsForUnsupportedListEndpoint(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusMethodNotAllowed,
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"method not allowed"}`)),
|
||||
}}
|
||||
svc := &AccountTestService{httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 99, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, http.StatusMethodNotAllowed, upstreamModelSyncStatusCode(err))
|
||||
require.Len(t, upstream.requests, 1)
|
||||
}
|
||||
|
||||
// Scenario: 完整上游模型清单优先保存能力。
|
||||
func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"slug":"custom-thinking-model",
|
||||
"display_name":"Upstream Display",
|
||||
"description":"Upstream description",
|
||||
"default_reasoning_level":"high",
|
||||
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":256000
|
||||
}]}`)),
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 92, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 1, "complete upstream metadata must not be replaced by a registry fetch")
|
||||
metadata := catalog.Metadata["custom-thinking-model"]
|
||||
require.Equal(t, "Upstream Display", metadata.DisplayName)
|
||||
require.Equal(t, "high", metadata.DefaultReasoningLevel)
|
||||
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(256_000), metadata.ContextWindow)
|
||||
}
|
||||
|
||||
// Scenario: 上游明确声明无推理能力时保存 false。
|
||||
func TestSyncUpstreamModelCatalogPersistsExplicitNonReasoningCapability(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"company-coding-model",
|
||||
"display_name":"Company Coding Model",
|
||||
"reasoning":false,
|
||||
"input_modalities":["text"],
|
||||
"context_window":64000
|
||||
}]}`)),
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 94, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, upstream.requests, 1)
|
||||
metadata := catalog.Metadata["company-coding-model"]
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.False(t, *metadata.Reasoning)
|
||||
require.Empty(t, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text"}, metadata.InputModalities)
|
||||
require.Equal(t, int64(64_000), metadata.ContextWindow)
|
||||
require.NotNil(t, repo.updates)
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogClassifiesSnapshotPersistenceFailureAsInternal(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"company-coding-model",
|
||||
"reasoning":false,
|
||||
"input_modalities":["text"],
|
||||
"context_window":64000
|
||||
}]}`)),
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{err: errors.New("database unavailable")}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
_, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 95, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
})
|
||||
require.Error(t, err)
|
||||
var syncErr *UpstreamModelSyncError
|
||||
require.ErrorAs(t, err, &syncErr)
|
||||
require.Equal(t, UpstreamModelSyncErrorInternal, syncErr.Kind)
|
||||
}
|
||||
|
||||
// Scenario: 元数据源失败时保留已有快照。
|
||||
func TestSyncUpstreamModelCatalogDoesNotOverwriteSnapshotWhenRegistryFails(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`))},
|
||||
{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 93, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://opencode.ai/zen/v1"},
|
||||
Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
|
||||
"source": "models.dev", "models": map[string]any{"x-preview-f-free": map[string]any{"reasoning": true}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"x-preview-f-free"}, catalog.Models)
|
||||
require.Empty(t, catalog.Metadata)
|
||||
require.Equal(t, []UpstreamModelSyncWarning{{
|
||||
Code: UpstreamModelMetadataIncompleteCode,
|
||||
Message: "Model IDs were synced, but capability metadata is incomplete.",
|
||||
}}, catalog.Warnings)
|
||||
require.Nil(t, repo.updates, "a failed metadata enrichment must not erase a previously saved snapshot")
|
||||
}
|
||||
|
||||
func TestSyncUpstreamModelCatalogDoesNotPersistPartialMetadataWhenRegistryFails(t *testing.T) {
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"partially-described-model",
|
||||
"display_name":"Partial Model"
|
||||
}]}`))},
|
||||
{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(`{"error":"unavailable"}`))},
|
||||
}}
|
||||
repo := &upstreamModelMetadataRepoStub{}
|
||||
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
|
||||
|
||||
catalog, err := svc.SyncUpstreamModelCatalog(context.Background(), &Account{
|
||||
ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
Extra: map[string]any{UpstreamModelMetadataExtraKey: map[string]any{
|
||||
"source": "upstream", "models": map[string]any{"partially-described-model": map[string]any{
|
||||
"reasoning": true, "supported_reasoning_levels": []any{"low", "high"},
|
||||
}},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"partially-described-model"}, catalog.Models)
|
||||
require.Equal(t, "Partial Model", catalog.Metadata["partially-described-model"].DisplayName)
|
||||
require.Equal(t, UpstreamModelMetadataIncompleteCode, catalog.Warnings[0].Code)
|
||||
require.Nil(t, repo.updates, "partial metadata must not replace a more complete persisted snapshot")
|
||||
}
|
||||
|
||||
func TestFetchUpstreamSupportedModelsParsesGrokAPIKeyResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -541,6 +541,25 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
|
||||
|
||||
export interface SyncUpstreamModelsResult {
|
||||
models: string[]
|
||||
metadata?: Record<string, UpstreamModelMetadata>
|
||||
warnings?: UpstreamModelSyncWarning[]
|
||||
}
|
||||
|
||||
export interface UpstreamModelSyncWarning {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UpstreamModelMetadata {
|
||||
id: string
|
||||
display_name?: string
|
||||
description?: string
|
||||
reasoning?: boolean
|
||||
default_reasoning_level?: string
|
||||
supported_reasoning_levels?: string[]
|
||||
input_modalities?: string[]
|
||||
context_window?: number
|
||||
max_output_tokens?: number
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1402,7 +1402,12 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" :sync-credentials="syncPreviewCredentials" />
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:platform="form.platform"
|
||||
:sync-credentials="syncPreviewCredentials"
|
||||
@upstream-synced="upstreamModelsPreviewed = true"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
@@ -1884,7 +1889,12 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" platform="anthropic" :sync-credentials="syncPreviewCredentials" />
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
platform="anthropic"
|
||||
:sync-credentials="syncPreviewCredentials"
|
||||
@upstream-synced="upstreamModelsPreviewed = true"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
|
||||
@@ -2220,7 +2230,12 @@
|
||||
|
||||
<!-- Whitelist Mode -->
|
||||
<div v-if="modelRestrictionMode === 'whitelist'">
|
||||
<ModelWhitelistSelector v-model="allowedModels" :platform="form.platform" :sync-credentials="syncPreviewCredentials" />
|
||||
<ModelWhitelistSelector
|
||||
v-model="allowedModels"
|
||||
:platform="form.platform"
|
||||
:sync-credentials="syncPreviewCredentials"
|
||||
@upstream-synced="upstreamModelsPreviewed = true"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
|
||||
<span v-if="allowedModels.length === 0">{{
|
||||
@@ -4093,6 +4108,7 @@ const modelMappings = ref<ModelMapping[]>([])
|
||||
const openAICompactModelMappings = ref<ModelMapping[]>([])
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
const upstreamModelsPreviewed = ref(false)
|
||||
const DEFAULT_POOL_MODE_RETRY_COUNT = 3
|
||||
const MAX_POOL_MODE_RETRY_COUNT = 10
|
||||
const DEFAULT_POOL_MODE_RETRY_STATUS_CODES = [401, 403, 429]
|
||||
@@ -4579,6 +4595,7 @@ watch(
|
||||
}
|
||||
// Clear model-related settings
|
||||
allowedModels.value = []
|
||||
upstreamModelsPreviewed.value = false
|
||||
modelMappings.value = []
|
||||
// Antigravity: 默认使用映射模式并填充默认映射
|
||||
if (newPlatform === 'antigravity') {
|
||||
@@ -4970,6 +4987,16 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
|
||||
if (upstreamModelsPreviewed.value) {
|
||||
try {
|
||||
const result = await adminAPI.accounts.syncUpstreamModels(account.id)
|
||||
if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
|
||||
}
|
||||
} catch {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsFailed'))
|
||||
}
|
||||
}
|
||||
if (
|
||||
payload.type === 'apikey' &&
|
||||
payload.upstream_billing_probe_enabled === true
|
||||
@@ -5110,6 +5137,7 @@ const resetForm = () => {
|
||||
grokOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
antigravityMixedChannelConfirmed.value = false
|
||||
upstreamModelsPreviewed.value = false
|
||||
clearMixedChannelDialog()
|
||||
}
|
||||
|
||||
|
||||
@@ -4064,6 +4064,10 @@ const syncAntigravityUpstreamModels = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (result.warnings?.some((warning) => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
|
||||
return
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length }))
|
||||
} else {
|
||||
|
||||
@@ -172,6 +172,7 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]]
|
||||
'upstream-synced': []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
@@ -312,6 +313,10 @@ const syncUpstreamModels = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.accountId) {
|
||||
emit('upstream-synced')
|
||||
}
|
||||
|
||||
const newModels = [...props.modelValue]
|
||||
let addedCount = 0
|
||||
for (const model of upstreamModels) {
|
||||
@@ -322,6 +327,10 @@ const syncUpstreamModels = async () => {
|
||||
}
|
||||
|
||||
emit('update:modelValue', newModels)
|
||||
if (result.warnings?.some(warning => warning.code === 'upstream_model_metadata_incomplete')) {
|
||||
appStore.showWarning(t('admin.accounts.syncUpstreamModelsMetadataIncomplete'))
|
||||
return
|
||||
}
|
||||
if (addedCount > 0) {
|
||||
appStore.showSuccess(t('admin.accounts.syncUpstreamModelsSuccess', { count: addedCount, total: upstreamModels.length }))
|
||||
} else {
|
||||
|
||||
@@ -5,12 +5,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const {
|
||||
createAccountMock,
|
||||
probeUpstreamBillingMock,
|
||||
syncUpstreamModelsMock,
|
||||
showWarningMock,
|
||||
importCodexSessionMock,
|
||||
createOpenAICodexPATMock,
|
||||
authIsSimpleMode,
|
||||
} = vi.hoisted(() => ({
|
||||
createAccountMock: vi.fn(),
|
||||
probeUpstreamBillingMock: vi.fn(),
|
||||
syncUpstreamModelsMock: vi.fn(),
|
||||
showWarningMock: vi.fn(),
|
||||
importCodexSessionMock: vi.fn(),
|
||||
createOpenAICodexPATMock: vi.fn(),
|
||||
authIsSimpleMode: { value: true },
|
||||
@@ -20,7 +24,7 @@ vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
showWarning: showWarningMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -37,6 +41,7 @@ vi.mock('@/api/admin', () => ({
|
||||
accounts: {
|
||||
create: createAccountMock,
|
||||
probeUpstreamBilling: probeUpstreamBillingMock,
|
||||
syncUpstreamModels: syncUpstreamModelsMock,
|
||||
checkMixedChannelRisk: vi.fn().mockResolvedValue({ has_risk: false }),
|
||||
importCodexSession: importCodexSessionMock,
|
||||
createOpenAICodexPAT: createOpenAICodexPATMock,
|
||||
@@ -120,8 +125,8 @@ const ModelWhitelistSelectorStub = defineComponent({
|
||||
platform: String,
|
||||
syncCredentials: Object,
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
template: '<div data-testid="model-whitelist-selector" />',
|
||||
emits: ['update:modelValue', 'upstream-synced'],
|
||||
template: '<button type="button" data-testid="model-whitelist-selector" @click="$emit(\'upstream-synced\')">models</button>',
|
||||
})
|
||||
|
||||
function mountModal(groups: any[] = []) {
|
||||
@@ -190,6 +195,8 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
authIsSimpleMode.value = true
|
||||
createAccountMock.mockReset().mockResolvedValue({ id: 42, platform: 'openai', type: 'apikey' })
|
||||
probeUpstreamBillingMock.mockReset().mockResolvedValue({})
|
||||
syncUpstreamModelsMock.mockReset().mockResolvedValue({ models: [], metadata: {} })
|
||||
showWarningMock.mockReset()
|
||||
importCodexSessionMock.mockReset().mockResolvedValue({
|
||||
created: 1,
|
||||
updated: 0,
|
||||
@@ -236,6 +243,39 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
|
||||
expect(createAccountMock.mock.calls[0]?.[0]?.extra?.openai_long_context_billing_enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('persists upstream model metadata after creating an account from preview', async () => {
|
||||
const wrapper = mountModal()
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
await selectButtonByText(wrapper, 'API Key')
|
||||
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account')
|
||||
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
|
||||
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
|
||||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(createAccountMock).toHaveBeenCalledOnce()
|
||||
expect(syncUpstreamModelsMock).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('warns when post-create capability metadata remains incomplete', async () => {
|
||||
syncUpstreamModelsMock.mockResolvedValue({
|
||||
models: ['x-preview-f-free'],
|
||||
warnings: [{ code: 'upstream_model_metadata_incomplete', message: 'metadata incomplete' }],
|
||||
})
|
||||
const wrapper = mountModal()
|
||||
await selectButtonByText(wrapper, 'OpenAI')
|
||||
await selectButtonByText(wrapper, 'API Key')
|
||||
await wrapper.get('form#create-account-form input[type="text"]').setValue('OpenCode account')
|
||||
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
|
||||
await wrapper.get('[data-testid="model-whitelist-selector"]').trigger('click')
|
||||
await wrapper.get('form#create-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(showWarningMock).toHaveBeenCalledWith(
|
||||
'admin.accounts.syncUpstreamModelsMetadataIncomplete'
|
||||
)
|
||||
})
|
||||
|
||||
// namespace 摊平是仅 OAuth 的兼容开关:API Key 走 chat completions 回退桥时由桥自行摊平
|
||||
it('shows the Codex namespace flatten toggle only for OpenAI OAuth accounts', async () => {
|
||||
const wrapper = mountModal()
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
const copyToClipboard = vi.fn().mockResolvedValue(true)
|
||||
const {
|
||||
copyToClipboard,
|
||||
showError,
|
||||
showSuccess,
|
||||
showInfo,
|
||||
showWarning,
|
||||
syncUpstreamModels,
|
||||
syncUpstreamModelsPreview
|
||||
} = vi.hoisted(() => ({
|
||||
copyToClipboard: vi.fn().mockResolvedValue(true),
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
showWarning: vi.fn(),
|
||||
syncUpstreamModels: vi.fn(),
|
||||
syncUpstreamModelsPreview: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async () => {
|
||||
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
|
||||
@@ -15,12 +31,20 @@ vi.mock('vue-i18n', async () => {
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn()
|
||||
showError,
|
||||
showSuccess,
|
||||
showInfo,
|
||||
showWarning
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
accountsAPI: {
|
||||
syncUpstreamModels,
|
||||
syncUpstreamModelsPreview
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useClipboard', () => ({
|
||||
useClipboard: () => ({
|
||||
copyToClipboard
|
||||
@@ -29,11 +53,12 @@ vi.mock('@/composables/useClipboard', () => ({
|
||||
|
||||
import ModelWhitelistSelector from '../ModelWhitelistSelector.vue'
|
||||
|
||||
function mountSelector() {
|
||||
function mountSelector(props: Record<string, unknown> = {}) {
|
||||
return mount(ModelWhitelistSelector, {
|
||||
props: {
|
||||
modelValue: [],
|
||||
platform: 'openai'
|
||||
platform: 'openai',
|
||||
...props,
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
@@ -58,6 +83,12 @@ function findModelRow(wrapper: ReturnType<typeof mountSelector>, modelId: string
|
||||
describe('ModelWhitelistSelector', () => {
|
||||
beforeEach(() => {
|
||||
copyToClipboard.mockClear()
|
||||
showError.mockReset()
|
||||
showSuccess.mockReset()
|
||||
showInfo.mockReset()
|
||||
showWarning.mockReset()
|
||||
syncUpstreamModels.mockReset()
|
||||
syncUpstreamModelsPreview.mockReset()
|
||||
})
|
||||
|
||||
it('copies a model ID without selecting the model', async () => {
|
||||
@@ -86,4 +117,71 @@ describe('ModelWhitelistSelector', () => {
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[['gpt-5.6-sol']]])
|
||||
expect(copyToClipboard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns when model IDs sync but capability metadata is incomplete', async () => {
|
||||
syncUpstreamModels.mockResolvedValue({
|
||||
models: ['x-preview-f-free'],
|
||||
warnings: [
|
||||
{
|
||||
code: 'upstream_model_metadata_incomplete',
|
||||
message: 'Model IDs were synced, but capability metadata could not be updated.'
|
||||
}
|
||||
]
|
||||
})
|
||||
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([[['x-preview-f-free']]])
|
||||
expect(showWarning).toHaveBeenCalledWith('admin.accounts.syncUpstreamModelsMetadataIncomplete')
|
||||
expect(showSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a successful preview so account creation can persist metadata', async () => {
|
||||
syncUpstreamModelsPreview.mockResolvedValue({
|
||||
models: ['x-preview-f-free'],
|
||||
metadata: {
|
||||
'x-preview-f-free': {
|
||||
id: 'x-preview-f-free',
|
||||
reasoning: true,
|
||||
supported_reasoning_levels: ['low', 'high', 'max'],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mountSelector({
|
||||
syncCredentials: {
|
||||
platform: 'openai',
|
||||
type: 'apikey',
|
||||
base_url: 'https://opencode.ai/zen/v1',
|
||||
api_key: 'test-key',
|
||||
},
|
||||
})
|
||||
const syncButton = wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === 'admin.accounts.syncUpstreamModels')
|
||||
|
||||
expect(syncButton).toBeDefined()
|
||||
await syncButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(syncUpstreamModelsPreview).toHaveBeenCalledOnce()
|
||||
expect(wrapper.emitted('upstream-synced')).toEqual([[]])
|
||||
expect(wrapper.emitted('update:modelValue')).toEqual([[['x-preview-f-free']]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -743,6 +743,8 @@ export default {
|
||||
syncUpstreamModelsEmpty: 'Upstream returned no models to sync',
|
||||
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.',
|
||||
clearAllModels: 'Clear all models',
|
||||
customModelName: 'Custom model name',
|
||||
enterCustomModelName: 'Enter custom model name',
|
||||
|
||||
@@ -811,6 +811,7 @@ export default {
|
||||
syncUpstreamModelsEmpty: '上游没有返回可同步的模型',
|
||||
syncUpstreamModelsFailed: '同步上游模型失败',
|
||||
syncUpstreamModelsError: '同步上游模型失败:{message}',
|
||||
syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但能力元数据不完整,能力信息未更新。',
|
||||
clearAllModels: '清除所有模型',
|
||||
customModelName: '自定义模型名称',
|
||||
enterCustomModelName: '输入自定义模型名称',
|
||||
|
||||
Reference in New Issue
Block a user