Merge pull request #5926 from baryon/contrib/routed-codex-model-catalog

feat(gateway): generate complete routed Codex model catalogs
This commit is contained in:
Wesley Liddick
2026-08-26 15:49:38 +08:00
committed by GitHub
49 changed files with 7908 additions and 169 deletions
@@ -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,29 +2795,35 @@ 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).
// POST /api/v1/admin/accounts/models/sync-upstream-preview
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
var req struct {
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
ModelMapping map[string]string `json:"model_mapping"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
modelMapping := make(map[string]any, len(req.ModelMapping))
for sourceModel, upstreamModel := range req.ModelMapping {
modelMapping[sourceModel] = upstreamModel
}
tempAccount := &service.Account{
Platform: req.Platform,
Type: req.Type,
Credentials: map[string]any{
"api_key": req.APIKey,
"base_url": req.BaseURL,
"api_key": req.APIKey,
"base_url": req.BaseURL,
"model_mapping": modelMapping,
},
}
@@ -2824,13 +2832,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 +2853,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
}
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
)
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
return router
}
@@ -347,6 +354,99 @@ 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)
}
// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
upstream := &syncUpstreamHTTPUpstream{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": {
"api": "https://provider.example/v1",
"models": {
"glm-5.3": {
"id": "glm-5.3",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","high"]}],
"modalities": {"input":["text"],"output":["text"]},
"limit": {"context":1000000,"output":131072}
}
}
}
}`)),
},
}}
router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/accounts/models/sync-upstream-preview",
strings.NewReader(`{
"platform":"openai",
"type":"apikey",
"base_url":"https://provider.example/v1",
"api_key":"key",
"model_mapping":{"public-glm":"glm-5.3"}
}`),
)
req.Header.Set("Content-Type", "application/json")
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{"glm-5.3"}, resp.Data.Models)
require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
}
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -377,3 +477,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)
}
+91 -8
View File
@@ -1140,6 +1140,79 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
}
// CodexModels returns the effective group model list using the manifest shape
// expected by Codex custom providers. Official OpenAI groups continue to use
// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved.
func (h *GatewayHandler) CodexModels(c *gin.Context) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey == nil || apiKey.Group == nil {
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
return
}
forcedPlatform := ""
if value, exists := middleware2.GetForcePlatformFromContext(c); exists {
forcedPlatform = strings.TrimSpace(value)
}
modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform)
modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group)
body, err := h.gatewayService.BuildCodexModelsManifestForGroup(
c.Request.Context(),
apiKey.Group,
forcedPlatform,
modelIDs,
)
if err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
etag := service.CodexModelsManifestETag(body)
c.Header("ETag", etag)
if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", body)
}
func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string {
if h == nil || h.gatewayService == nil || group == nil {
return nil
}
groupID := &group.ID
platform := strings.TrimSpace(platformOverride)
if platform == "" {
platform = group.Platform
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(ctx, groupID)
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
if group.CustomModelsListEnabled() {
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
}
if len(availableModels) > 0 {
return availableModels
}
return fallbackModels
}
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
fallbackModels := defaultCodexModelIDsForPlatform(platform)
if group.CustomModelsListEnabled() {
return filterModelsByCustomList(
customModelsListSource(platform, availableModels, fallbackModels),
fallbackModels,
group.ModelsListConfig.Models,
)
}
if len(availableModels) > 0 {
return availableModels
}
return fallbackModels
}
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
if h == nil || h.gatewayService == nil {
return nil
@@ -1340,9 +1413,26 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
return true
}
}
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
if normalizedClaudeModel != model {
for _, pattern := range availablePatterns {
if pattern == normalizedClaudeModel {
return true
}
}
}
return false
}
func defaultCodexModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformDeepseek:
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
default:
return defaultModelIDsForPlatform(platform)
}
}
func defaultModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformOpenAI:
@@ -1361,14 +1451,7 @@ func defaultModelIDsForPlatform(platform string) []string {
}
return ids
case service.PlatformAnthropic:
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
for _, model := range claude.DefaultModels {
ids = append(ids, model.ID)
}
for _, model := range antigravity.DefaultModels() {
ids = append(ids, model.ID)
}
return mergeModelIDs(ids, nil)
return claude.DefaultModelIDs()
case service.PlatformGrok:
return xai.DefaultModelIDs()
case service.PlatformComposite:
@@ -25,6 +25,22 @@ type gatewayModelsResponseForTest struct {
Data []gatewayModelItemForTest `json:"data"`
}
type codexModelsResponseForTest struct {
Models []struct {
Slug string `json:"slug"`
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
InputModalities []string `json:"input_modalities"`
ModelMessages map[string]json.RawMessage `json:"model_messages"`
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
AvailabilityNUX json.RawMessage `json:"availability_nux"`
Upgrade json.RawMessage `json:"upgrade"`
} `json:"models"`
}
type codexReasoningLevelForTest struct {
Effort string `json:"effort"`
}
type gatewayModelItemForTest struct {
ID string `json:"id"`
Object string `json:"object"`
@@ -52,6 +68,10 @@ func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Cont
return out, nil
}
func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
return s.ListSchedulableByGroupID(ctx, groupID)
}
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
return &GatewayHandler{
gatewayService: service.NewGatewayService(
@@ -70,6 +90,247 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
require.Contains(t, compositeIDs, antigravityIDs[0])
}
// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
require.Contains(t, anthropicIDs, "claude-opus-4-6")
require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
require.Contains(t, antigravityIDs, "gemini-2.5-flash")
}
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
tests := []struct {
name string
platform string
model string
efforts []string
modalities []string
}{
{
name: "Grok",
platform: service.PlatformGrok,
model: "grok-4.6",
efforts: []string{"low", "medium", "high", "xhigh"},
modalities: []string{"text", "image"},
},
{
name: "DeepSeek",
platform: service.PlatformDeepseek,
model: "deepseek-v4-pro",
efforts: []string{"low", "high", "max"},
modalities: []string{"text"},
},
{
name: "provider-qualified Claude",
platform: service.PlatformAnthropic,
model: "anthropic/claude-sonnet-4-6",
efforts: []string{"low", "medium", "high", "max"},
modalities: []string{"text"},
},
}
for index, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(100 + index)
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: tt.platform,
Credentials: map[string]any{
"model_mapping": map[string]any{tt.model: tt.model},
},
},
},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: tt.platform},
})
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Len(t, got.Models, 1)
require.Equal(t, tt.model, got.Models[0].Slug)
require.NotEmpty(t, got.Models[0].ModelMessages)
require.NotEmpty(t, got.Models[0].TruncationPolicy)
require.NotNil(t, got.Models[0].AvailabilityNUX)
require.NotNil(t, got.Models[0].Upgrade)
require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
require.Equal(t, tt.modalities, got.Models[0].InputModalities)
})
}
}
// Scenario: Composite manifests aggregate only administrator-configured models.
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 120
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 3,
Platform: service.PlatformOpenAI,
Status: service.StatusActive,
Schedulable: true,
Credentials: map[string]any{},
},
{
ID: 1,
Platform: service.PlatformOpenAI,
Credentials: map[string]any{
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
},
},
{
ID: 2,
Platform: service.PlatformGrok,
Credentials: map[string]any{
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
},
},
},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
}
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 122
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {{
ID: 1,
Platform: service.PlatformDeepseek,
Credentials: map[string]any{
"model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
},
}},
},
})
group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
first := httptest.NewRecorder()
firstContext, _ := gin.CreateTestContext(first)
firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
h.CodexModels(firstContext)
require.Equal(t, http.StatusOK, first.Code)
etag := first.Header().Get("ETag")
require.NotEmpty(t, etag)
require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
second := httptest.NewRecorder()
secondContext, _ := gin.CreateTestContext(second)
secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
h.CodexModels(secondContext)
require.Equal(t, http.StatusNotModified, second.Code)
require.Empty(t, second.Body.Bytes())
require.Equal(t, etag, second.Header().Get("ETag"))
}
// Scenario: group models_list_config limits the generated Codex manifest.
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 121
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformOpenAI,
Credentials: map[string]any{
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
},
},
{
ID: 2,
Platform: service.PlatformGrok,
Credentials: map[string]any{
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
},
},
},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
ID: groupID,
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"grok-4.6"},
},
},
})
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models))
}
func codexModelSlugsForTest(models []struct {
Slug string `json:"slug"`
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
InputModalities []string `json:"input_modalities"`
ModelMessages map[string]json.RawMessage `json:"model_messages"`
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
AvailabilityNUX json.RawMessage `json:"availability_nux"`
Upgrade json.RawMessage `json:"upgrade"`
}) []string {
slugs := make([]string, 0, len(models))
for _, model := range models {
slugs = append(slugs, model.Slug)
}
return slugs
}
func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
efforts := make([]string, 0, len(levels))
for _, level := range levels {
efforts = append(efforts, level.Effort)
}
return efforts
}
func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -193,6 +454,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
}
// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(64)
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
slugs := codexModelSlugsForTest(got.Models)
require.Contains(t, slugs, "claude-opus-4-6")
require.NotContains(t, slugs, "gemini-2.5-flash")
}
// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(65)
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
ids := modelIDsForTest(got.Data)
require.Contains(t, ids, "claude-opus-4-6")
require.Contains(t, ids, "gemini-2.5-flash")
}
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -457,6 +774,89 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
}
}
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
}
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 130
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformDeepseek,
Status: service.StatusActive,
Schedulable: true,
Credentials: map[string]any{},
},
},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
})
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
slugs := make([]string, 0, len(got.Models))
for _, model := range got.Models {
slugs = append(slugs, model.Slug)
}
require.Contains(t, slugs, "deepseek-v4-pro")
require.Contains(t, slugs, "deepseek-v4-flash")
require.NotContains(t, slugs, "claude-sonnet-4-6")
require.NotContains(t, slugs, "claude-opus-4-6")
}
func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 131
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformDeepseek,
Credentials: map[string]any{
"model_mapping": map[string]any{
"foo-*": "deepseek-v4-pro",
"deepseek-v4-pro": "deepseek-v4-pro",
},
},
},
},
},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
})
h.CodexModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
slugs := make([]string, 0, len(got.Models))
for _, model := range got.Models {
slugs = append(slugs, model.Slug)
}
require.Equal(t, []string{"deepseek-v4-pro"}, slugs)
}
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -15,9 +15,9 @@ import (
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
// provider-compatibility normalization and use a short-lived, asynchronously
// revalidated cache to tolerate canceled client requests.
// here. Groups with explicit account model mappings are generated locally;
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
// receive provider-compatibility normalization plus short-lived caching.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
if c.Request.Context().Err() != nil {
return
@@ -32,6 +32,24 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
return
}
ifNoneMatch := c.GetHeader("If-None-Match")
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeCodexModelsManifestResponse(c, configuredManifest)
return
}
maxAccountSwitches := h.maxAccountSwitches
if maxAccountSwitches <= 0 {
maxAccountSwitches = 3
@@ -56,7 +74,9 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
// 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。
setOpsSelectedAccount(c, account.ID, account.Platform)
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
// The client ETag represents the final group-specific body, so fetch the
// source manifest before applying local filtering and alias metadata.
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "")
if err != nil {
if c.Request.Context().Err() != nil {
return
@@ -70,18 +90,31 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
return
}
if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
return
}
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if c.Request.Context().Err() != nil {
return
}
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
writeCodexModelsManifestResponse(c, manifest)
return
}
}
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -2,6 +2,7 @@ package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -16,6 +17,7 @@ import (
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type codexModelsFailoverAccountRepo struct {
@@ -43,6 +45,14 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont
return accounts, nil
}
func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) {
return append([]service.Account(nil), r.accounts...), nil
}
func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) ([]service.Account, error) {
return append([]service.Account(nil), r.accounts...), nil
}
type codexModelsFailoverHTTPUpstream struct {
service.HTTPUpstream
mu sync.Mutex
@@ -116,6 +126,236 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) {
}
}
func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(43)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
{
ID: 1,
Name: "custom-openai",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://upstream.example/v1",
},
},
}}
upstream := &codexModelsFailoverHTTPUpstream{
firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`,
}
gatewayService := service.NewOpenAIGatewayService(
repo,
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
upstream,
nil, nil, nil, nil, nil, nil, nil, nil,
)
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
group := &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"codex-auto-review", "gpt-5.6"},
},
}
first := performCodexModelsRequestForGroup(t, handler, group, "")
if first.Code != http.StatusOK {
t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String())
}
if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
t.Fatalf("first body did not include the explicitly selected models: %s", body)
}
oldETag := first.Header().Get("ETag")
if oldETag == "" {
t.Fatal("first response did not include an ETag")
}
group.ModelsListConfig.Enabled = false
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
if second.Code != http.StatusOK {
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
}
if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
t.Fatalf("second body was not the filtered manifest: %s", body)
}
if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag {
t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag)
}
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
if third.Code != http.StatusNotModified {
t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String())
}
if third.Body.Len() != 0 {
t.Fatalf("third body: got %q, want empty", third.Body.String())
}
}
func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
{
ID: 1,
Name: "shared-api-key",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-shared",
"base_url": "https://upstream.example/v1",
},
},
}}
upstream := &codexModelsFailoverHTTPUpstream{
firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`,
}
gatewayService := service.NewOpenAIGatewayService(
repo,
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
upstream,
nil, nil, nil, nil, nil, nil, nil, nil,
)
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
groupA := &service.Group{
ID: 91,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"model-a"},
},
}
groupB := &service.Group{
ID: 92,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"model-b"},
},
}
firstA := performCodexModelsRequestForGroup(t, handler, groupA, "")
require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String())
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA))
firstB := performCodexModelsRequestForGroup(t, handler, groupB, "")
require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String())
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB))
etagA := firstA.Header().Get("ETag")
require.NotEmpty(t, etagA)
var wg sync.WaitGroup
results := make([]*httptest.ResponseRecorder, 8)
for i := range results {
wg.Add(1)
go func(index int) {
defer wg.Done()
if index%2 == 0 {
results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA)
return
}
results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "")
}(i)
}
wg.Wait()
sawGroupB := false
for _, recorder := range results {
require.NotNil(t, recorder)
switch recorder.Code {
case http.StatusNotModified:
require.Empty(t, recorder.Body.Bytes())
case http.StatusOK:
slugs := codexHandlerManifestSlugs(t, recorder)
if len(slugs) == 1 && slugs[0] == "model-b" {
sawGroupB = true
continue
}
require.Equal(t, []string{"model-a"}, slugs)
default:
t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String())
}
}
require.True(t, sawGroupB)
}
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(44)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
{
ID: 1,
Name: "ark-compatible",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Schedulable: true,
Priority: 0,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-ark",
"base_url": "https://ark.example/v1",
"model_mapping": map[string]any{
"glm-5.3": "glm-5.3",
},
},
},
{
ID: 2,
Name: "chatgpt-oauth",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: true,
Priority: 1,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-test",
},
},
}}
upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound}
gatewayService := service.NewOpenAIGatewayService(
repo,
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
upstream,
nil, nil, nil, nil, nil, nil, nil, nil,
)
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
recorder := performCodexModelsRequestForGroup(t, handler, &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
}, "")
if got := upstream.calls(); len(got) != 0 {
t.Fatalf("upstream account calls: got %v, want none", got)
}
if recorder.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
var envelope struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
}
if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" {
t.Fatalf("models: got %v, want only glm-5.3", envelope.Models)
}
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
}
}
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable)
@@ -148,8 +388,23 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
t.Fatalf("body: got %q, want %q", got, want)
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
})
}
}
// Scenario: an API-key upstream without /models is excluded only for this discovery request.
func TestCodexModelsFailsOverWhenAPIKeyModelsEndpointIsUnavailable(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} {
t.Run(http.StatusText(status), func(t *testing.T) {
handler, upstream, groupID := newCodexModelsFailoverTestHandler(status)
recorder := performCodexModelsRequest(t, handler, groupID)
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
t.Fatalf("upstream account calls: got %v, want %v", got, want)
}
if recorder.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
})
}
@@ -183,9 +438,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) {
if recorder.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
t.Fatalf("body: got %q, want %q", got, want)
}
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
}
func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
@@ -193,7 +446,6 @@ func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
http.StatusBadRequest,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusNotFound,
600,
}
for _, status := range statuses {
@@ -300,23 +552,79 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount
}
func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder {
return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI)
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "")
}
func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder {
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "")
}
func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
t.Helper()
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil)
if etag != "" {
c.Request.Header.Set("If-None-Match", etag)
}
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: platform},
GroupID: &group.ID,
Group: group,
})
handler.CodexModels(c)
return recorder
}
func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
t.Helper()
var envelope struct {
Models []struct {
Slug string `json:"slug"`
} `json:"models"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
}
slugs := make([]string, 0, len(envelope.Models))
for _, model := range envelope.Models {
slugs = append(slugs, model.Slug)
}
return slugs
}
func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) {
t.Helper()
var envelope struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
}
if len(envelope.Models) != 1 {
t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String())
}
model := envelope.Models[0]
if got := model["slug"]; got != slug {
t.Fatalf("slug: got %v, want %q", got, slug)
}
if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 {
t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"])
}
if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" {
t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"])
}
if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 {
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
}
modalities, ok := model["input_modalities"].([]any)
if !ok || len(modalities) != 1 || modalities[0] != "text" {
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
}
}
func equalInt64Slices(got, want []int64) bool {
if len(got) != len(want) {
return false
@@ -0,0 +1,69 @@
package claude
import (
"strings"
"unicode"
)
var (
effortLowMediumHigh = []string{"low", "medium", "high"}
effortLowMediumHighMax = []string{"low", "medium", "high", "max"}
effortLowMediumHighXHighMax = []string{"low", "medium", "high", "xhigh", "max"}
)
var effortFamilies = []struct {
family string
levels []string
}{
{family: "claude-mythos-preview", levels: effortLowMediumHighMax},
{family: "claude-mythos-5", levels: effortLowMediumHighXHighMax},
{family: "claude-fable-5", levels: effortLowMediumHighXHighMax},
{family: "claude-sonnet-4-6", levels: effortLowMediumHighMax},
{family: "claude-sonnet-5", levels: effortLowMediumHighXHighMax},
{family: "claude-opus-4-8", levels: effortLowMediumHighXHighMax},
{family: "claude-opus-4-7", levels: effortLowMediumHighXHighMax},
{family: "claude-opus-4-6", levels: effortLowMediumHighMax},
{family: "claude-opus-4-5", levels: effortLowMediumHigh},
{family: "claude-opus-5", levels: effortLowMediumHighXHighMax},
}
// EffortLevelsForModel returns the output_config.effort values accepted by a
// Claude model, ordered from the lightest to the deepest reasoning level.
func EffortLevelsForModel(model string) []string {
id := normalizeEffortModelID(model)
for _, entry := range effortFamilies {
if id == entry.family || strings.HasPrefix(id, entry.family+"-") {
return append([]string(nil), entry.levels...)
}
}
return nil
}
func normalizeEffortModelID(model string) string {
id := strings.ToLower(strings.TrimSpace(model))
id = strings.TrimPrefix(id, "models/")
if slash := strings.IndexByte(id, '/'); slash >= 0 {
id = strings.TrimPrefix(strings.TrimSpace(id[slash+1:]), "models/")
}
id = strings.TrimPrefix(id, "anthropic.")
id = strings.TrimSuffix(id, "-thinking")
if mapped, ok := ModelIDReverseOverrides[id]; ok {
id = mapped
}
if len(id) >= 9 {
suffix := id[len(id)-9:]
if suffix[0] == '-' {
digits := true
for _, r := range suffix[1:] {
if !unicode.IsDigit(r) {
digits = false
break
}
}
if digits {
id = id[:len(id)-9]
}
}
}
return id
}
@@ -0,0 +1,29 @@
package claude
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestEffortLevelsForModel(t *testing.T) {
t.Parallel()
tests := []struct {
model string
want []string
}{
{model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}},
{model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}},
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
{model: "claude-haiku-4-5-20251001", want: nil},
{model: "gpt-5.6", want: nil},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.want, EffortLevelsForModel(tt.model))
})
}
}
+24
View File
@@ -251,6 +251,30 @@ func IsGrokModelID(model string) bool {
return false
}
// IsGrokImagineModel reports whether model is a Grok Imagine image or video
// model. These media models cannot act as the primary Codex agent model.
func IsGrokImagineModel(model string) bool {
normalized := strings.ToLower(StripGrokProviderPrefix(model))
if normalized == "" {
return false
}
if strings.HasPrefix(normalized, "imagine") {
return true
}
switch {
case normalized == "grok-imagine",
normalized == "grok-imagine-1",
normalized == "grok-imagine-edit",
normalized == "grok-video-1.5":
return true
case strings.HasPrefix(normalized, "grok-imagine-image"),
strings.HasPrefix(normalized, "grok-imagine-video"):
return true
default:
return false
}
}
// IsGrokTextResponsesModelID reports whether model is a known Grok text model
// for the Responses API. Imagine image/video and unknown custom ids return false.
func IsGrokTextResponsesModelID(model string) bool {
+10
View File
@@ -58,6 +58,16 @@ func TestIsGrokModelID(t *testing.T) {
require.False(t, IsGrokModelID("claude-sonnet-4"))
}
func TestIsGrokImagineModel(t *testing.T) {
t.Parallel()
require.True(t, IsGrokImagineModel("grok-imagine-image"))
require.True(t, IsGrokImagineModel("grok-imagine-video-1.5-preview"))
require.True(t, IsGrokImagineModel("xai/grok-imagine-image-quality"))
require.True(t, IsGrokImagineModel("grok-video-1.5"))
require.False(t, IsGrokImagineModel("grok-4.6"))
require.False(t, IsGrokImagineModel("grok-build-0.1"))
}
func TestDefaultModelsIncludesGrok46(t *testing.T) {
t.Parallel()
ids := DefaultModelIDs()
+14 -6
View File
@@ -65,13 +65,13 @@ func RegisterGatewayRoutes(
h.Gateway.CountTokens(c)
}
}
codexModelsHandler := func(c *gin.Context) {
dispatchCodexModelsGateway(c, h.OpenAIGateway.CodexModels, h.Gateway.CodexModels)
}
modelsHandler := func(c *gin.Context) {
if c.Query("client_version") != "" {
switch getGroupPlatform(c) {
case service.PlatformOpenAI, service.PlatformComposite:
h.OpenAIGateway.CodexModels(c)
return
}
codexModelsHandler(c)
return
}
h.Gateway.Models(c)
}
@@ -377,7 +377,7 @@ func RegisterGatewayRoutes(
codexDirect.GET("/responses", func(c *gin.Context) {
h.OpenAIGateway.ResponsesWebSocket(c)
})
codexDirect.GET("/models", h.OpenAIGateway.CodexModels)
codexDirect.GET("/models", codexModelsHandler)
}
// OpenAI Chat Completions API(不带v1前缀的别名)— auto-route based on group platform
r.POST("/chat/completions", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
@@ -504,6 +504,14 @@ func RegisterGatewayRoutes(
}
func dispatchCodexModelsGateway(c *gin.Context, openAIHandler, generatedHandler gin.HandlerFunc) {
if getGroupPlatform(c) == service.PlatformOpenAI {
openAIHandler(c)
return
}
generatedHandler(c)
}
// getGroupPlatform extracts the group platform from the API Key stored in context.
func getGroupPlatform(c *gin.Context) string {
apiKey, ok := middleware.GetAPIKeyFromContext(c)
@@ -2,8 +2,12 @@ package routes
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -22,3 +26,39 @@ func TestGatewayRoutesCodexModelsManifestPathIsRegistered(t *testing.T) {
require.NotEmpty(t, registered["/models"], "GET /models should be registered")
require.Equal(t, registered["/v1/models"], registered["/models"], "root alias should use the same platform-aware handler")
}
func TestDispatchCodexModelsGatewayKeepsOnlyOpenAIOnLiveManifestHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
platform string
wantOpenAI bool
}{
{platform: service.PlatformOpenAI, wantOpenAI: true},
{platform: service.PlatformComposite},
{platform: service.PlatformGrok},
{platform: service.PlatformDeepseek},
}
for _, tt := range tests {
t.Run(tt.platform, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{Platform: tt.platform},
})
called := ""
dispatchCodexModelsGateway(c,
func(c *gin.Context) { called = "openai" },
func(c *gin.Context) { called = "generated" },
)
if tt.wantOpenAI {
require.Equal(t, "openai", called)
} else {
require.Equal(t, "generated", called)
}
})
}
}
@@ -147,6 +147,9 @@ type AccountTestService struct {
cfg *config.Config
settingService *SettingService
tlsFPProfileService *TLSFingerprintProfileService
modelMetadataRegistryMu sync.Mutex
modelMetadataRegistry map[string]modelsDevProvider
modelMetadataRegistryAt time.Time
pluginManager *PluginManager
agentIdentityTaskMu sync.Mutex
agentIdentityWS agentIdentityWSConnectionInvalidator
@@ -23,8 +23,19 @@ const (
CompositeRouteSourceExplicit = "route"
CompositeRouteSourceDetector = "detector"
CompositeRouteSourceAccount = "account_model"
)
// CompositeModelOwnership identifies the concrete provider that exposes a
// public model through an account-level exact mapping.
type CompositeModelOwnership struct {
TargetPlatform string
Matched bool
Ambiguous bool
}
type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error)
var (
ErrCompositeRouteNotFound = infraerrors.NotFound("COMPOSITE_ROUTE_NOT_FOUND", "composite route not found")
ErrCompositeRouteExists = infraerrors.Conflict("COMPOSITE_ROUTE_EXISTS", "composite route already exists")
@@ -8,6 +8,150 @@ import (
"github.com/stretchr/testify/require"
)
type compositeOwnershipAccountRepo struct {
AccountRepository
accounts []Account
}
func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
return r.accounts, nil
}
// Scenario: 唯一平台的精确别名可路由
func TestResolveCompositeModelOwnershipKeepsProviderAccountsIsolated(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
accounts: []Account{
{
ID: 1,
Platform: PlatformOpenAI,
Credentials: map[string]any{
"model_mapping": map[string]any{"gpt-public": "gpt-5"},
},
},
{
ID: 2,
Platform: PlatformDeepseek,
Credentials: map[string]any{
"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"},
},
},
},
}
svc := &GatewayService{accountRepo: repo}
deepSeekOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "reasoning-alias")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, deepSeekOwnership)
openAIOwnership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-public")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, openAIOwnership)
}
// Scenario: 通配符和空映射不声明所有权
func TestResolveCompositeModelOwnershipRequiresNonEmptyExactMappings(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
accounts: []Account{
{
ID: 1,
Platform: PlatformOpenAI,
Credentials: map[string]any{
"model_mapping": map[string]any{"*": "gpt-5", "gpt-*": "gpt-5", "empty-alias": ""},
},
},
{
ID: 2,
Platform: PlatformGrok,
Credentials: map[string]any{
"model_mapping": map[string]any{"grok-public": "grok-4"},
},
},
},
}
svc := &GatewayService{accountRepo: repo}
for _, model := range []string{"gpt-5", "empty-alias", "unknown-alias"} {
ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, model)
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{}, ownership, "model=%s", model)
}
ownership, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "grok-public")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformGrok, Matched: true}, ownership)
}
func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatformAliases(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
accounts: []Account{
{ID: 1, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5", "ambiguous": "gpt-5"}}},
{ID: 2, Platform: PlatformOpenAI, Credentials: map[string]any{"model_mapping": map[string]any{"shared-openai": "gpt-5.1"}}},
{ID: 3, Platform: PlatformDeepseek, Credentials: map[string]any{"model_mapping": map[string]any{"ambiguous": "deepseek-v4-pro"}}},
},
}
svc := &GatewayService{accountRepo: repo}
samePlatform, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "shared-openai")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, samePlatform)
ambiguous, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "ambiguous")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous)
}
func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
accounts: []Account{{
ID: 1,
Platform: PlatformDeepseek,
Credentials: map[string]any{"model_mapping": map[string]any{"reasoning-alias": "deepseek-v4-pro"}},
}},
}
resolver := NewCompositeRouteResolver(nil)
svc := NewGatewayService(
repo,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
nil,
resolver,
nil,
nil,
)
require.Same(t, resolver, svc.compositeResolver)
decision, err := resolver.Resolve(context.Background(), groupID, "reasoning-alias", CompositeRouteEndpointResponses)
require.NoError(t, err)
require.True(t, decision.Matched)
require.Equal(t, CompositeRouteSourceAccount, decision.Source)
require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
}
func TestDetectModelPlatform(t *testing.T) {
tests := []struct {
name string
@@ -8,13 +8,20 @@ import (
)
type CompositeRouteResolver struct {
repo CompositeModelRouteRepository
repo CompositeModelRouteRepository
modelOwnershipResolver CompositeModelOwnershipResolver
}
func NewCompositeRouteResolver(repo CompositeModelRouteRepository) *CompositeRouteResolver {
return &CompositeRouteResolver{repo: repo}
}
func (r *CompositeRouteResolver) SetModelOwnershipResolver(resolver CompositeModelOwnershipResolver) {
if r != nil {
r.modelOwnershipResolver = resolver
}
}
func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, model, endpoint string) (CompositeRouteDecision, error) {
model = strings.TrimSpace(model)
endpoint = normalizeCompositeRouteEndpoint(endpoint)
@@ -51,6 +58,35 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod
}
}
if r != nil && r.modelOwnershipResolver != nil && groupID > 0 {
ownership, err := r.modelOwnershipResolver(ctx, groupID, model)
if err != nil {
// A recognizable model can still use the existing detector when the
// account catalog is temporarily unavailable. Unknown aliases cannot.
if _, detectable := DetectModelPlatform(model); !detectable {
return decision, fmt.Errorf("resolve account model ownership: %w", err)
}
} else if ownership.Ambiguous {
decision.Reason = "model is exposed by multiple provider platforms"
return decision, nil
} else if ownership.Matched {
platform := strings.TrimSpace(ownership.TargetPlatform)
if !isConcreteRequestPlatform(platform) {
decision.Reason = "account model ownership has no concrete target platform"
return decision, nil
}
return CompositeRouteDecision{
Matched: true,
Source: CompositeRouteSourceAccount,
GroupID: groupID,
PublicModel: model,
TargetPlatform: platform,
UpstreamModel: model,
Endpoint: endpoint,
}, nil
}
}
if platform, ok := DetectModelPlatform(model); ok {
return CompositeRouteDecision{
Matched: true,
@@ -2,6 +2,7 @@ package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/require"
@@ -69,6 +70,98 @@ func TestCompositeRouteResolverExplicitExactRouteRewritesModel(t *testing.T) {
require.Equal(t, int64(10), decision.Route.ID)
}
// Scenario: 唯一平台的精确别名可路由
func TestCompositeRouteResolverUsesAccountModelOwnershipForUnprefixedAlias(t *testing.T) {
resolver := NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(_ context.Context, groupID int64, model string) (CompositeModelOwnership, error) {
require.Equal(t, int64(7), groupID)
require.Equal(t, "reasoning-alias", model)
return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
})
decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions)
require.NoError(t, err)
require.True(t, decision.Matched)
require.Equal(t, CompositeRouteSourceAccount, decision.Source)
require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
require.Equal(t, "reasoning-alias", decision.UpstreamModel)
}
func TestCompositeRouteResolverAccountOwnershipOverridesBuiltInDetector(t *testing.T) {
resolver := NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
})
decision, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses)
require.NoError(t, err)
require.True(t, decision.Matched)
require.Equal(t, CompositeRouteSourceAccount, decision.Source)
require.Equal(t, PlatformDeepseek, decision.TargetPlatform)
}
// Scenario: 显式路由保持最高优先级
func TestCompositeRouteResolverExplicitRouteBeatsAccountOwnership(t *testing.T) {
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []CompositeModelRoute{{
ID: 10,
GroupID: 7,
PublicModel: "reasoning-alias",
MatchType: CompositeRouteMatchExact,
TargetPlatform: PlatformOpenAI,
UpstreamModel: "gpt-5",
Endpoint: CompositeRouteEndpointAny,
Enabled: true,
}},
})
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
return CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, nil
})
decision, err := resolver.Resolve(context.Background(), 7, "reasoning-alias", CompositeRouteEndpointChatCompletions)
require.NoError(t, err)
require.True(t, decision.Matched)
require.Equal(t, CompositeRouteSourceExplicit, decision.Source)
require.Equal(t, PlatformOpenAI, decision.TargetPlatform)
require.Equal(t, "gpt-5", decision.UpstreamModel)
}
// Scenario: 跨平台同名别名不被猜测
func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.T) {
resolver := NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
return CompositeModelOwnership{Ambiguous: true}, nil
})
decision, err := resolver.Resolve(context.Background(), 7, "shared-alias", CompositeRouteEndpointChatCompletions)
require.NoError(t, err)
require.False(t, decision.Matched)
require.Empty(t, decision.TargetPlatform)
require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason)
}
func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) {
lookupErr := errors.New("account catalog unavailable")
resolver := NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
return CompositeModelOwnership{}, lookupErr
})
detected, err := resolver.Resolve(context.Background(), 7, "gpt-5", CompositeRouteEndpointResponses)
require.NoError(t, err)
require.True(t, detected.Matched)
require.Equal(t, CompositeRouteSourceDetector, detected.Source)
require.Equal(t, PlatformOpenAI, detected.TargetPlatform)
unknown, err := resolver.Resolve(context.Background(), 7, "company-model", CompositeRouteEndpointResponses)
require.ErrorIs(t, err, lookupErr)
require.False(t, unknown.Matched)
}
func TestCompositeRouteResolverPrefersEndpointSpecificLongestPrefix(t *testing.T) {
resolver := NewCompositeRouteResolver(compositeRouteRepoStub{
routes: []CompositeModelRoute{
@@ -564,6 +564,46 @@ func TestGetAvailableModels_UsesShortCacheAndSupportsInvalidation(t *testing.T)
require.Equal(t, int64(2), store)
}
// Scenario: 账号模型变更会失效所属平台缓存
func TestResolveCompositeModelOwnershipUsesModelsCacheInvalidation(t *testing.T) {
groupID := int64(9)
repo := &modelsListAccountRepoStub{
byGroup: map[int64][]Account{
groupID: {{
ID: 1,
Platform: PlatformDeepseek,
Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "deepseek-v4-pro"}},
}},
},
}
svc := &GatewayService{
accountRepo: repo,
modelsListCache: gocache.New(time.Minute, time.Minute),
modelsListCacheTTL: time.Minute,
}
first, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformDeepseek, Matched: true}, first)
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
repo.byGroup[groupID] = []Account{{
ID: 2,
Platform: PlatformOpenAI,
Credentials: map[string]any{"model_mapping": map[string]any{"company-model": "gpt-5"}},
}}
cached, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model")
require.NoError(t, err)
require.Equal(t, first, cached)
require.Equal(t, int64(1), repo.listByGroupCalls.Load())
svc.InvalidateAvailableModelsCache(&groupID, PlatformDeepseek)
refreshed, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "company-model")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{TargetPlatform: PlatformOpenAI, Matched: true}, refreshed)
require.Equal(t, int64(2), repo.listByGroupCalls.Load())
}
func TestGetAvailableModels_ErrorAndGlobalListBranches(t *testing.T) {
resetGatewayHotpathStatsForTest()
@@ -395,6 +395,61 @@ func TestGatewayService_SelectAccountForModelWithPlatform_Anthropic(t *testing.T
require.Equal(t, PlatformAnthropic, acc.Platform, "应只返回 anthropic 平台账户")
}
// Scenario: account-owned Composite aliases are scheduled only to accounts that declare the exact mapping.
func TestGatewayService_SelectAccountForModelWithExclusions_CompositeAliasRequiresOwningAccount(t *testing.T) {
groupID := int64(77)
repo := &mockAccountRepoForPlatform{
accounts: []Account{
{
ID: 1,
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Priority: 1,
Status: StatusActive,
Schedulable: true,
AccountGroups: []AccountGroup{{GroupID: groupID}},
},
{
ID: 2,
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Priority: 2,
Status: StatusActive,
Schedulable: true,
Credentials: map[string]any{
"model_mapping": map[string]any{"reasoning-alias": "claude-opus-4-8"},
},
AccountGroups: []AccountGroup{{GroupID: groupID}},
},
},
accountsByID: map[int64]*Account{},
}
for i := range repo.accounts {
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
}
group := &Group{ID: groupID, Platform: PlatformComposite, Status: StatusActive, Hydrated: true}
svc := &GatewayService{
accountRepo: repo,
groupRepo: &mockGroupRepoForGateway{groups: map[int64]*Group{groupID: group}},
cfg: testConfig(),
}
ctx := WithCompositeRouteDecision(context.Background(), CompositeRouteDecision{
Matched: true,
Source: CompositeRouteSourceAccount,
GroupID: groupID,
PublicModel: "reasoning-alias",
TargetPlatform: PlatformAnthropic,
UpstreamModel: "reasoning-alias",
Endpoint: CompositeRouteEndpointResponses,
})
account, err := svc.SelectAccountForModelWithExclusions(ctx, &groupID, "", "reasoning-alias", nil)
require.NoError(t, err)
require.NotNil(t, account)
require.Equal(t, int64(2), account.ID)
}
// TestGatewayService_SelectAccountForModelWithPlatform_Antigravity 测试 antigravity 单平台选择
func TestGatewayService_SelectAccountForModelWithPlatform_Antigravity(t *testing.T) {
ctx := context.Background()
@@ -2532,6 +2532,11 @@ func summarizeSelectionFailureStats(stats selectionFailureStats) string {
// isModelSupportedByAccountWithContext 根据账户平台检查模型支持(带 context)
// 对于 Antigravity 平台,会先获取映射后的最终模型名(包括 thinking 后缀)再检查支持
func (s *GatewayService) isModelSupportedByAccountWithContext(ctx context.Context, account *Account, requestedModel string) bool {
if source, ok := CompositeRouteSourceFromContext(ctx); ok && source == CompositeRouteSourceAccount {
if publicModel, modelOK := RequestedPublicModelFromContext(ctx); modelOK && !explicitModelMappingClaims(*account, publicModel) {
return false
}
}
if account.Platform == PlatformAntigravity {
if strings.TrimSpace(requestedModel) == "" {
return true
+84 -2
View File
@@ -71,8 +71,9 @@ const (
)
const (
cacheTTLTarget5m = "5m"
cacheTTLTarget1h = "1h"
cacheTTLTarget5m = "5m"
cacheTTLTarget1h = "1h"
compositeModelOwnershipCachePrefix = "composite-owner|"
)
// ForceCacheBillingContextKey 强制缓存计费上下文键
@@ -528,6 +529,10 @@ func modelsListCacheKey(groupID *int64, platform string) string {
return fmt.Sprintf("%d|%s", derefGroupID(groupID), strings.TrimSpace(platform))
}
func compositeModelOwnershipCacheKey(groupID int64, model string) string {
return fmt.Sprintf("%s%d|%s", compositeModelOwnershipCachePrefix, groupID, strings.TrimSpace(model))
}
func prefetchedStickyGroupIDFromContext(ctx context.Context) (int64, bool) {
return PrefetchedStickyGroupIDFromContext(ctx)
}
@@ -858,6 +863,9 @@ func NewGatewayService(
balanceNotifyService: balanceNotifyService,
userPlatformQuotaRepo: userPlatformQuotaRepo,
}
if compositeResolver != nil {
compositeResolver.SetModelOwnershipResolver(svc.resolveCompositeModelOwnership)
}
svc.userGroupRateResolver = newUserGroupRateResolver(
userGroupRateRepo,
svc.userGroupRateCache,
@@ -1447,6 +1455,59 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
return cloneStringSlice(models)
}
func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, groupID int64, model string) (CompositeModelOwnership, error) {
model = strings.TrimSpace(model)
if s == nil || s.accountRepo == nil || groupID <= 0 || model == "" {
return CompositeModelOwnership{}, nil
}
cacheKey := compositeModelOwnershipCacheKey(groupID, model)
if s.modelsListCache != nil {
if cached, found := s.modelsListCache.Get(cacheKey); found {
if ownership, ok := cached.(CompositeModelOwnership); ok {
return ownership, nil
}
}
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
if err != nil {
return CompositeModelOwnership{}, err
}
platforms := make(map[string]struct{})
for _, account := range accounts {
platform := strings.TrimSpace(account.Platform)
if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) {
continue
}
platforms[platform] = struct{}{}
}
ownership := CompositeModelOwnership{}
if len(platforms) == 1 {
for platform := range platforms {
ownership.TargetPlatform = platform
}
ownership.Matched = true
} else if len(platforms) > 1 {
ownership.Ambiguous = true
}
if s.modelsListCache != nil {
s.modelsListCache.Set(cacheKey, ownership, s.modelsListCacheTTL)
}
return ownership, nil
}
func explicitModelMappingClaims(account Account, model string) bool {
if account.Credentials == nil || model == "" {
return false
}
mapped, ok := stringMappingFromRaw(account.Credentials["model_mapping"])[model]
return ok && strings.TrimSpace(mapped) != ""
}
// GetSchedulablePlatforms returns the concrete platforms that currently have
// schedulable accounts in the target group.
func (s *GatewayService) GetSchedulablePlatforms(ctx context.Context, groupID *int64) map[string]struct{} {
@@ -1479,6 +1540,7 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform
if s == nil || s.modelsListCache == nil {
return
}
s.invalidateCompositeModelOwnershipCache(groupID)
normalizedPlatform := strings.TrimSpace(platform)
// 完整匹配时精准失效;否则按维度批量失效。
@@ -1507,6 +1569,26 @@ func (s *GatewayService) InvalidateAvailableModelsCache(groupID *int64, platform
}
}
func (s *GatewayService) invalidateCompositeModelOwnershipCache(groupID *int64) {
for key := range s.modelsListCache.Items() {
if !strings.HasPrefix(key, compositeModelOwnershipCachePrefix) {
continue
}
if groupID == nil {
s.modelsListCache.Delete(key)
continue
}
parts := strings.SplitN(strings.TrimPrefix(key, compositeModelOwnershipCachePrefix), "|", 2)
if len(parts) != 2 {
continue
}
cachedGroupID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && cachedGroupID == *groupID {
s.modelsListCache.Delete(key)
}
}
}
const debugGatewayBodyDefaultFilename = "gateway_debug.log"
// initDebugGatewayBodyFile 初始化网关调试日志文件。
@@ -0,0 +1,313 @@
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
}
}
}
explicitTargetsConflict := explicitClaims && codexExplicitModelTargetsConflictForPlatform(accounts, platform, modelID)
publicAlias := upstreamModel != modelID
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)
}
if strings.TrimSpace(lookupModel) != modelID {
publicAlias = true
}
metadata, ok := account.GetUpstreamModelMetadata(lookupModel)
if !ok {
if explicitTargetsConflict {
return codexModelMetadataOverride{
reasoningConflict: true,
inputModalitiesConflict: true,
}, true
}
return codexModelMetadataOverride{}, false
}
candidates = append(candidates, metadata)
}
if len(candidates) == 0 {
return codexModelMetadataOverride{}, false
}
metadata := intersectUpstreamModelMetadata(modelID, candidates)
if publicAlias {
metadata.DisplayName = modelID
metadata.Description = configuredCodexCustomDescription
}
return metadata, 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 codexExplicitModelTargetsConflictForPlatform(accounts []Account, platform, modelID string) bool {
targets := make(map[string]struct{})
for i := range accounts {
account := &accounts[i]
if account.Platform != platform {
continue
}
mappedModel, matched := account.ResolveMappedModel(modelID)
mappedModel = strings.TrimSpace(mappedModel)
if !matched || mappedModel == "" {
continue
}
targets[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,385 @@
package service
import (
"context"
"errors"
"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"])
}
// Scenario: the same public alias may target different models on one platform when complete snapshots can be intersected.
func TestBuildCodexModelsManifestForGroupIntersectsDifferentMappedTargetsWithoutLeakingAlias(t *testing.T) {
t.Parallel()
const groupID int64 = 739
reasoning := true
newAccount := func(id int64, target, displayName, description string, 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{"my-coder": target},
},
}
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
target: {
ID: target, DisplayName: displayName, Description: description, Reasoning: &reasoning,
SupportedReasoningLevels: levels,
InputModalities: modalities,
ContextWindow: contextWindow,
},
}})
return account
}
openAIAccount := newAccount(
31,
"gpt-5.6-sol",
"GPT-5.6 Sol",
"OpenAI upstream model",
[]string{"low", "medium", "high", "xhigh"},
[]string{"text", "image"},
272_000,
)
arkAccount := newAccount(
32,
"glm-5.3",
"GLM 5.3",
"Ark upstream model",
[]string{"low", "medium", "high"},
[]string{"text"},
1_000_000,
)
for _, accounts := range [][]Account{{openAIAccount, arkAccount}, {arkAccount, openAIAccount}} {
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
groupID: accounts,
}}}
body, err := svc.BuildCodexModelsManifestForGroup(
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
)
require.NoError(t, err)
models := decodeCodexManifestModels(t, body)
require.Len(t, models, 1)
require.Equal(t, "my-coder", models[0]["slug"])
require.Equal(t, "my-coder", models[0]["display_name"])
require.Equal(t, "Custom model routed through Sub2API.", models[0]["description"])
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
require.EqualValues(t, 272_000, models[0]["context_window"])
}
}
// Scenario: temporarily unschedulable mapped accounts still participate in capability intersection.
func TestBuildCodexModelsManifestForGroupIntersectsUnschedulableMappedAccounts(t *testing.T) {
t.Parallel()
const groupID int64 = 741
schedulable := newCodexCatalogMappedAccount(
41,
"gpt-5.6-sol",
"GPT-5.6 Sol",
[]string{"low", "medium", "high", "xhigh"},
[]string{"text", "image"},
1_000_000,
true,
nil,
)
unschedulable := newCodexCatalogMappedAccount(
42,
"glm-5.3",
"GLM 5.3",
[]string{"low", "medium", "high"},
[]string{"text"},
272_000,
false,
map[string]any{"exclusive-model": "exclusive-upstream"},
)
svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{
schedulable: map[int64][]Account{groupID: {schedulable}},
catalog: map[int64][]Account{groupID: {schedulable, unschedulable}},
}}
body, err := svc.BuildCodexModelsManifestForGroup(
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
)
require.NoError(t, err)
models := decodeCodexManifestModels(t, body)
require.Len(t, models, 1)
require.Equal(t, "my-coder", models[0]["slug"])
require.Equal(t, "my-coder", models[0]["display_name"])
require.Equal(t, []string{"low", "medium", "high"}, effortsFromManifestModel(t, models[0]))
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
require.EqualValues(t, 272_000, models[0]["context_window"])
}
// Scenario: deleting an account can widen the advertised contract.
func TestBuildCodexModelsManifestForGroupWidensAfterUnschedulableAccountIsRemoved(t *testing.T) {
t.Parallel()
const groupID int64 = 742
remaining := newCodexCatalogMappedAccount(
41,
"gpt-5.6-sol",
"GPT-5.6 Sol",
[]string{"low", "medium", "high", "xhigh"},
[]string{"text", "image"},
1_000_000,
true,
nil,
)
svc := &GatewayService{accountRepo: splitCodexModelsAccountRepo{
schedulable: map[int64][]Account{groupID: {remaining}},
catalog: map[int64][]Account{groupID: {remaining}},
}}
body, err := svc.BuildCodexModelsManifestForGroup(
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
)
require.NoError(t, err)
models := decodeCodexManifestModels(t, body)
require.Len(t, models, 1)
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
require.EqualValues(t, 1_000_000, models[0]["context_window"])
}
func TestBuildCodexModelsManifestForGroupFallsBackToSchedulableWhenListByGroupFails(t *testing.T) {
t.Parallel()
const groupID int64 = 743
repo := &countingCodexModelsAccountRepo{
accounts: []Account{newCodexCatalogMappedAccount(
41,
"gpt-5.6-sol",
"GPT-5.6 Sol",
[]string{"low", "medium", "high", "xhigh"},
[]string{"text", "image"},
1_000_000,
true,
nil,
)},
listByGroupErr: errors.New("group listing unavailable"),
}
svc := &GatewayService{accountRepo: repo}
body, err := svc.BuildCodexModelsManifestForGroup(
context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"my-coder"},
)
require.NoError(t, err)
require.Equal(t, int32(1), repo.calls.Load())
models := decodeCodexManifestModels(t, body)
require.Len(t, models, 1)
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
require.EqualValues(t, 1_000_000, models[0]["context_window"])
}
// Scenario: a Composite alias claimed across platforms remains ambiguous and fails closed.
func TestBuildCodexModelsManifestForGroupKeepsCrossPlatformAliasAmbiguityClosed(t *testing.T) {
t.Parallel()
const groupID int64 = 740
reasoning := true
newAccount := func(id int64, platform, target string) Account {
account := Account{
ID: id, Platform: platform, Type: AccountTypeAPIKey,
Credentials: map[string]any{"model_mapping": map[string]any{"shared-alias": target}},
}
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
target: {
ID: target, DisplayName: target, Reasoning: &reasoning,
SupportedReasoningLevels: []string{"low", "high"},
InputModalities: []string{"text", "image"},
ContextWindow: 128_000,
},
}})
return account
}
svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{
groupID: {
newAccount(33, PlatformOpenAI, "gpt-5.6-sol"),
newAccount(34, PlatformGrok, "grok-4.6"),
},
}}}
body, err := svc.BuildCodexModelsManifestForGroup(
context.Background(), &Group{ID: groupID, Platform: PlatformComposite}, "", []string{"shared-alias"},
)
require.NoError(t, err)
models := decodeCodexManifestModels(t, body)
require.Len(t, models, 1)
require.Equal(t, "shared-alias", models[0]["display_name"])
require.Empty(t, effortsFromManifestModel(t, models[0]))
require.Equal(t, []any{"text"}, models[0]["input_modalities"])
}
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"])
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -24,6 +24,11 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
}
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 协议账号原样返回。
@@ -271,6 +271,66 @@ func TestNormalizeOpenAIParallelToolCallsWithoutTools(t *testing.T) {
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())
})
}
}
// Lite 工具迁移到 input[].additional_tools 后,仍应按有工具请求处理。
func TestNormalizeOpenAIParallelToolCallsWithoutTools_KeepsResponsesLiteAdditionalTools(t *testing.T) {
liteBody := []byte(`{"input":[{"type":"message","role":"user","content":"hi"},{"type":"additional_tools","tools":[{"type":"function","name":"spawn_agent"}]}],"parallel_tool_calls":false}`)
@@ -57,6 +57,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)
}
@@ -836,7 +837,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 TestProxyOpenAIWSHTTPBridgeTurn_UpstreamDefaultServiceTierWinsOverRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
+695 -19
View File
@@ -3,17 +3,128 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/geminicli"
)
const (
upstreamModelsBodyLimit int64 = 8 << 20
modelsDevRegistryURL = "https://models.dev/api.json"
modelsDevRegistryTTL = 6 * time.Hour
UpstreamModelMetadataExtraKey = "upstream_model_metadata"
UpstreamModelMetadataIncompleteCode = "upstream_model_metadata_incomplete"
)
type UpstreamModelMetadata struct {
ID string `json:"id"`
DisplayName string `json:"display_name,omitempty"`
Description string `json:"description,omitempty"`
Reasoning *bool `json:"reasoning,omitempty"`
DefaultReasoningLevel string `json:"default_reasoning_level,omitempty"`
SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"`
InputModalities []string `json:"input_modalities,omitempty"`
ContextWindow int64 `json:"context_window,omitempty"`
MaxOutputTokens int64 `json:"max_output_tokens,omitempty"`
}
type UpstreamModelMetadataSnapshot struct {
Source string `json:"source"`
SyncedAt string `json:"synced_at"`
Models map[string]UpstreamModelMetadata `json:"models"`
}
type UpstreamModelCatalog struct {
Models []string `json:"models"`
Metadata map[string]UpstreamModelMetadata `json:"metadata,omitempty"`
Warnings []UpstreamModelSyncWarning `json:"warnings,omitempty"`
}
type UpstreamModelSyncWarning struct {
Code string `json:"code"`
Message string `json:"message"`
}
type modelsDevProvider struct {
ID string `json:"id"`
Name string `json:"name"`
API string `json:"api"`
Models map[string]modelsDevModel `json:"models"`
}
type modelsDevModel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Reasoning *bool `json:"reasoning"`
ReasoningOptions []modelsDevReasoningOption `json:"reasoning_options"`
Modalities modelsDevModalities `json:"modalities"`
Limit modelsDevLimit `json:"limit"`
}
type modelsDevReasoningOption struct {
Type string `json:"type"`
Values []any `json:"values"`
}
type modelsDevModalities struct {
Input []string `json:"input"`
Output []string `json:"output"`
}
type modelsDevLimit struct {
Context int64 `json:"context"`
Output int64 `json:"output"`
}
func (a *Account) SetUpstreamModelMetadataSnapshot(snapshot UpstreamModelMetadataSnapshot) {
if a == nil {
return
}
if a.Extra == nil {
a.Extra = make(map[string]any)
}
a.Extra[UpstreamModelMetadataExtraKey] = snapshot
}
func (a *Account) GetUpstreamModelMetadataSnapshot() *UpstreamModelMetadataSnapshot {
if a == nil || a.Extra == nil {
return nil
}
raw, ok := a.Extra[UpstreamModelMetadataExtraKey]
if !ok || raw == nil {
return nil
}
body, err := json.Marshal(raw)
if err != nil {
return nil
}
var snapshot UpstreamModelMetadataSnapshot
if err := json.Unmarshal(body, &snapshot); err != nil || len(snapshot.Models) == 0 {
return nil
}
return &snapshot
}
func (a *Account) GetUpstreamModelMetadata(modelID string) (UpstreamModelMetadata, bool) {
snapshot := a.GetUpstreamModelMetadataSnapshot()
if snapshot == nil {
return UpstreamModelMetadata{}, false
}
metadata, ok := snapshot.Models[strings.TrimSpace(modelID)]
return metadata, ok
}
// UpstreamModelSyncErrorKind classifies model sync failures for safe HTTP mapping.
type UpstreamModelSyncErrorKind string
@@ -24,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 {
@@ -70,49 +184,432 @@ 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, &registry); err != nil {
return nil, fmt.Errorf("parse model metadata registry: %w", err)
}
if len(registry) == 0 {
return nil, fmt.Errorf("model metadata registry is empty")
}
s.modelMetadataRegistryMu.Lock()
s.modelMetadataRegistry = registry
s.modelMetadataRegistryAt = now
s.modelMetadataRegistryMu.Unlock()
return registry, nil
}
func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata {
levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions)
reasoning := model.Reasoning
if reasoning == nil && len(levels) > 0 {
inferred := true
reasoning = &inferred
}
metadata := UpstreamModelMetadata{
ID: strings.TrimSpace(modelID),
DisplayName: strings.TrimSpace(model.Name),
Description: strings.TrimSpace(model.Description),
Reasoning: reasoning,
SupportedReasoningLevels: levels,
InputModalities: normalizeCodexInputModalities(model.Modalities.Input),
ContextWindow: model.Limit.Context,
MaxOutputTokens: model.Limit.Output,
}
if len(levels) > 0 {
metadata.DefaultReasoningLevel = levels[0]
}
if strings.TrimSpace(model.ID) != "" {
metadata.ID = strings.TrimSpace(model.ID)
}
return metadata
}
func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string {
levels := make([]string, 0)
for _, option := range options {
if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") {
continue
}
for _, value := range option.Values {
if value == nil {
levels = append(levels, "none")
continue
}
if effort, ok := value.(string); ok {
levels = append(levels, effort)
}
}
}
return normalizeReasoningLevels(levels)
}
func upstreamModelRegistryBaseURL(account *Account) string {
if account == nil {
return ""
}
switch {
case account.IsOpenAI() || account.IsCNProvider():
return account.GetOpenAIFormatBaseURL()
case account.IsGrok():
return account.GetGrokBaseURL()
case account.IsGemini():
return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
case account.IsAnthropic():
return account.GetBaseURL()
case account.Platform == PlatformAntigravity:
return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
default:
return strings.TrimSpace(account.GetCredential("base_url"))
}
}
func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) {
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() }()
bodyLimit := resolveModelsListReadLimit(s.cfg)
body, err := io.ReadAll(io.LimitReader(resp.Body, bodyLimit+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)) > bodyLimit {
return nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit))
return nil, nil, newUpstreamModelSyncUpstreamError("Upstream model list response is too large", fmt.Errorf("response exceeds %d bytes", bodyLimit))
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, 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
@@ -121,13 +618,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) {
@@ -577,12 +1074,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)
}
@@ -591,6 +1105,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", "ultra":
return level
default:
return ""
}
}
func normalizeCodexInputModalities(modalities []string) []string {
seen := make(map[string]struct{}, len(modalities))
normalized := make([]string, 0, len(modalities))
for _, modality := range modalities {
modality = strings.ToLower(strings.TrimSpace(modality))
if modality != "text" && modality != "image" {
continue
}
if _, exists := seen[modality]; exists {
continue
}
seen[modality] = struct{}{}
normalized = append(normalized, modality)
}
return normalized
}
func extractUpstreamModelIDsWithSelector(body []byte, selectID func(upstreamModelEntry) string) ([]string, error) {
var response struct {
Data []upstreamModelEntry `json:"data"`
@@ -646,6 +1320,7 @@ func grokUpstreamModelEntryID(entry upstreamModelEntry) string {
entry.ModelID,
entry.ModelIDSnake,
entry.ID,
entry.Slug,
}
if len(entry.Meta) > 0 {
var meta upstreamModelEntryMetadata
@@ -655,6 +1330,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{
@@ -394,6 +417,384 @@ 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"},{"effort":"ultra"}],
"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", "ultra"}, metadata.SupportedReasoningLevels)
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
require.Equal(t, int64(256_000), metadata.ContextWindow)
}
// Scenario: 上游 /models 增删型号后,正式同步用最新清单替换能力快照。
func TestSyncUpstreamModelCatalogReplacesSnapshotWhenUpstreamModelsChange(t *testing.T) {
upstream := &httpUpstreamRecorder{responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"data":[
{"id":"old-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
{"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000}
]}`)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"data":[
{"id":"kept-model","reasoning":false,"input_modalities":["text"],"context_window":128000},
{"id":"new-model","reasoning":false,"input_modalities":["text"],"context_window":256000}
]}`)),
},
}}
repo := &upstreamModelMetadataRepoStub{}
svc := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()}
account := &Account{
ID: 101, Platform: PlatformOpenAI, Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "key",
"base_url": "https://provider.example/v1",
},
}
first, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
require.NoError(t, err)
require.Equal(t, []string{"kept-model", "old-model"}, first.Models)
second, err := svc.SyncUpstreamModelCatalog(context.Background(), account)
require.NoError(t, err)
require.Equal(t, []string{"kept-model", "new-model"}, second.Models)
require.NotContains(t, second.Metadata, "old-model")
require.Contains(t, second.Metadata, "new-model")
encoded, err := json.Marshal(repo.updates[UpstreamModelMetadataExtraKey])
require.NoError(t, err)
var snapshot UpstreamModelMetadataSnapshot
require.NoError(t, json.Unmarshal(encoded, &snapshot))
require.NotContains(t, snapshot.Models, "old-model")
require.Contains(t, snapshot.Models, "new-model")
}
// 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 TestFetchUpstreamSupportedModelsUsesConfiguredBodyLimit(t *testing.T) {
t.Parallel()
+79
View File
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
buildCodexModelsManifestUrl,
fetchCodexModelsManifest
} from '../codex'
describe('Codex models API', () => {
afterEach(() => {
vi.unstubAllGlobals()
})
it('builds the authenticated Codex manifest endpoint from the public API base', () => {
expect(buildCodexModelsManifestUrl('https://example.com/api/v1/')).toBe(
'https://example.com/api/v1/models?client_version=0.147.0'
)
})
it('fetches a manifest with the current API key without adding it to the catalog', async () => {
const manifest = {
models: [
{
slug: 'grok-4.6',
default_reasoning_level: 'high',
supported_reasoning_levels: [
{ effort: 'low', description: 'Fast responses' },
{ effort: 'xhigh', description: 'Extra-high reasoning depth' }
],
input_modalities: ['text', 'image'],
model_messages: { instructions_template: 'Use the routed model.' }
},
{
slug: 'deepseek-v4-pro',
default_reasoning_level: 'high',
supported_reasoning_levels: [
{ effort: 'low', description: 'Fast responses' },
{ effort: 'max', description: 'Maximum reasoning depth' }
],
input_modalities: ['text'],
model_messages: { instructions_template: 'Use the routed model.' }
}
]
}
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => manifest
})
vi.stubGlobal('fetch', fetchMock)
const result = await fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test')
expect(fetchMock).toHaveBeenCalledWith(
'https://example.com/v1/models?client_version=0.147.0',
expect.objectContaining({
headers: {
Accept: 'application/json',
Authorization: 'Bearer sk-user-test'
}
})
)
expect(result.modelCount).toBe(2)
expect(JSON.parse(result.content)).toEqual(manifest)
expect(result.content).toContain('"effort": "xhigh"')
expect(result.content).toContain('"input_modalities"')
expect(result.content).toContain('"instructions_template"')
expect(result.content).not.toContain('sk-user-test')
})
it('rejects a successful response that is not a Codex manifest', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ object: 'list', data: [] })
}))
await expect(fetchCodexModelsManifest('https://example.com/v1', 'sk-user-test'))
.rejects.toThrow('valid manifest')
})
})
+20
View File
@@ -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
}
/**
@@ -558,6 +577,7 @@ export interface SyncUpstreamPreviewParams {
type: string
base_url?: string
api_key: string
model_mapping?: Record<string, string>
}
/**
+56
View File
@@ -0,0 +1,56 @@
export interface CodexModelsManifestResult {
content: string
modelCount: number
}
const DEFAULT_CODEX_CLIENT_VERSION = '0.147.0'
function normalizeCodexBaseUrl(baseUrl: string): string {
const fallback = typeof window !== 'undefined' ? window.location.origin : ''
const value = (baseUrl || fallback).trim().replace(/\/+$/, '')
if (!value) return '/v1'
return /\/v1$/i.test(value) ? value : `${value}/v1`
}
export function buildCodexModelsManifestUrl(
baseUrl: string,
clientVersion = DEFAULT_CODEX_CLIENT_VERSION
): string {
const url = normalizeCodexBaseUrl(baseUrl)
const params = new URLSearchParams({ client_version: clientVersion })
return `${url}/models?${params.toString()}`
}
function isCodexModelsManifest(value: unknown): value is { models: unknown[] } {
return typeof value === 'object' && value !== null && Array.isArray((value as { models?: unknown }).models)
}
export async function fetchCodexModelsManifest(
baseUrl: string,
apiKey: string,
signal?: AbortSignal
): Promise<CodexModelsManifestResult> {
const response = await fetch(buildCodexModelsManifestUrl(baseUrl), {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${apiKey}`
},
cache: 'no-store',
signal
})
if (!response.ok) {
throw new Error(`Codex models request failed with status ${response.status}`)
}
const payload: unknown = await response.json()
if (!isCodexModelsManifest(payload)) {
throw new Error('Codex models response is not a valid manifest')
}
return {
content: JSON.stringify(payload, null, 2),
modelCount: payload.models.length
}
}
@@ -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">{{
@@ -4072,11 +4087,17 @@ const syncPreviewCredentials = computed(() => {
const baseUrl = isCNPlatform.value && apiProtocol.value === 'adaptive'
? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim()
: apiKeyBaseUrl.value.trim()
const modelMapping = buildModelMappingObject(
modelRestrictionMode.value,
allowedModels.value,
modelMappings.value
)
return {
platform: form.platform,
type: form.type,
base_url: baseUrl || undefined,
api_key: apiKeyValue.value
api_key: apiKeyValue.value,
...(modelMapping ? { model_mapping: modelMapping } : {})
}
})
@@ -4093,6 +4114,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 +4601,7 @@ watch(
}
// Clear model-related settings
allowedModels.value = []
upstreamModelsPreviewed.value = false
modelMappings.value = []
// Antigravity: 使
if (newPlatform === 'antigravity') {
@@ -4970,6 +4993,23 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
const modelMapping = payload.credentials.model_mapping
const hasConcreteMappedTarget = payload.type === 'apikey' &&
typeof modelMapping === 'object' &&
modelMapping !== null &&
Object.values(modelMapping).some((target) =>
typeof target === 'string' && target.trim() !== '' && !target.includes('*')
)
if (upstreamModelsPreviewed.value || hasConcreteMappedTarget) {
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 +5150,7 @@ const resetForm = () => {
grokOAuth.resetState()
oauthFlowRef.value?.reset()
antigravityMixedChannelConfirmed.value = false
upstreamModelsPreviewed.value = false
clearMixedChannelDialog()
}
@@ -4132,6 +4132,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,12 @@ 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('update:modelValue', ['public-glm']); $emit('upstream-synced')"
>models</button>`,
})
function mountModal(groups: any[] = []) {
@@ -190,6 +199,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 +247,71 @@ 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('includes the current concrete model mapping in preview credentials', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
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 flushPromises()
expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({
model_mapping: { 'public-glm': 'public-glm' }
})
})
it('runs formal capability sync after creating an account with explicit mappings', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
await selectButtonByText(wrapper, 'API Key')
await wrapper.get('form#create-account-form input[type="text"]').setValue('Mapped account')
await wrapper.get('form#create-account-form input[type="password"]').setValue('test-api-key')
await selectButtonByText(wrapper, 'admin.accounts.modelMapping')
await selectButtonByText(wrapper, 'admin.accounts.addMapping')
await wrapper.get('input[placeholder="admin.accounts.requestModel"]').setValue('public-glm')
await wrapper.get('input[placeholder="admin.accounts.actualModel"]').setValue('glm-5.3')
await wrapper.get('form#create-account-form').trigger('submit.prevent')
await flushPromises()
expect(createAccountMock.mock.calls[0]?.[0]?.credentials?.model_mapping).toEqual({
'public-glm': 'glm-5.3'
})
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']]])
})
})
+303 -10
View File
@@ -172,6 +172,65 @@
</div>
</div>
<section
v-if="showCodexModelCatalog"
data-testid="codex-model-catalog"
class="overflow-hidden rounded-lg border border-gray-200 bg-gray-50 dark:border-dark-700 dark:bg-dark-800/50"
>
<div class="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('keys.useKeyModal.codexModelCatalog.title') }}
</h3>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('keys.useKeyModal.codexModelCatalog.description') }}
</p>
<p class="mt-1 truncate font-mono text-xs text-gray-700 dark:text-gray-300">
{{ codexModelCatalogPath }}
</p>
</div>
<button
v-if="codexModelManifestState === 'ready'"
type="button"
class="btn btn-primary min-h-9 flex-shrink-0 px-3 text-xs"
@click="downloadCodexModelManifest"
>
<Icon name="download" size="sm" class="mr-1.5" />
{{ t('keys.useKeyModal.codexModelCatalog.download') }}
</button>
<button
v-else
type="button"
data-testid="codex-model-catalog-fetch"
class="btn btn-primary min-h-9 flex-shrink-0 px-3 text-xs"
:disabled="codexModelManifestState === 'loading' || !apiKey"
@click="loadCodexModelManifest"
>
<Icon
name="refresh"
size="sm"
class="mr-1.5"
:class="codexModelManifestState === 'loading' ? 'animate-spin' : ''"
/>
{{ codexModelManifestState === 'error'
? t('keys.useKeyModal.codexModelCatalog.retry')
: t('keys.useKeyModal.codexModelCatalog.fetch') }}
</button>
</div>
<p
v-if="codexModelManifestState === 'ready'"
class="border-t border-gray-200 px-4 py-2 text-xs text-emerald-700 dark:border-dark-700 dark:text-emerald-300"
>
{{ t('keys.useKeyModal.codexModelCatalog.modelsCount', { count: codexModelManifestModelCount }) }}
</p>
<p
v-else-if="codexModelManifestState === 'error'"
class="border-t border-red-200 px-4 py-2 text-xs text-red-700 dark:border-red-900 dark:text-red-300"
>
{{ t('keys.useKeyModal.codexModelCatalog.errorDescription') }}
</p>
</section>
<!-- Usage Note -->
<div v-if="showPlatformNote" class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
<Icon name="infoCircle" size="md" class="text-blue-500 flex-shrink-0 mt-0.5" />
@@ -198,10 +257,18 @@
<script setup lang="ts">
import { ref, computed, h, watch, type Component } from 'vue'
import { useI18n } from 'vue-i18n'
import { saveAs } from 'file-saver'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { useClipboard } from '@/composables/useClipboard'
import { fetchCodexModelsManifest } from '@/api/codex'
import type { GroupPlatform } from '@/types'
import {
findCodexCatalogModel,
formatCodexReasoningEffortTomlLine,
parseCodexCatalogModels,
selectCodexConfigReasoningEffort
} from '@/utils/codexCatalogConfig'
interface Props {
show: boolean
@@ -239,6 +306,29 @@ const activeTab = ref<string>('unix')
const activeClientTab = ref<string>('claude')
type CodexAuthMode = 'legacy' | 'api-key'
const codexAuthMode = ref<CodexAuthMode>('legacy')
type CodexModelManifestState = 'idle' | 'loading' | 'ready' | 'error'
const codexModelManifestState = ref<CodexModelManifestState>('idle')
const codexModelManifestContent = ref('')
const codexModelManifestModelCount = ref(0)
let codexModelManifestController: AbortController | null = null
let codexModelManifestRequestID = 0
const showCodexModelCatalog = computed(() =>
props.show &&
(activeClientTab.value === 'codex' ||
(props.platform === 'openai' && activeClientTab.value === 'codex-ws'))
)
const codexModelCatalogPath = computed(() => {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
return joinConfigPath(configDir, 'codex-models.json', isWindows)
})
const codexManifestContext = computed(() => {
if (!showCodexModelCatalog.value) return ''
return `${props.platform}|${props.baseUrl}|${props.apiKey}`
})
// Reset tabs when platform changes
const defaultClientTab = computed(() => {
@@ -265,6 +355,14 @@ watch(() => props.platform, () => {
watch(() => props.show, (show) => {
if (show) {
codexAuthMode.value = 'legacy'
} else {
resetCodexModelManifest()
}
})
watch(codexManifestContext, (context, previousContext) => {
if (context !== previousContext) {
resetCodexModelManifest()
}
})
@@ -353,12 +451,14 @@ const clientTabs = computed((): TabConfig[] => {
case 'gemini':
return [
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'antigravity':
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
{ id: 'gemini', label: t('keys.useKeyModal.cliTabs.geminiCli'), icon: SparkleIcon },
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'grok':
@@ -368,9 +468,17 @@ const clientTabs = computed((): TabConfig[] => {
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
case 'deepseek':
case 'composite':
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
default:
return [
{ id: 'claude', label: t('keys.useKeyModal.cliTabs.claudeCode'), icon: TerminalIcon },
{ id: 'codex', label: t('keys.useKeyModal.cliTabs.codexCli'), icon: TerminalIcon },
{ id: 'opencode', label: t('keys.useKeyModal.cliTabs.opencode'), icon: TerminalIcon }
]
}
@@ -405,6 +513,13 @@ const currentTabs = computed(() => {
})
const platformDescription = computed(() => {
if (activeClientTab.value === 'codex' &&
props.platform !== 'openai' &&
props.platform !== 'grok' &&
props.platform !== 'deepseek' &&
props.platform !== 'composite') {
return t('keys.useKeyModal.routedCodex.description')
}
switch (props.platform) {
case 'openai':
if (activeClientTab.value === 'claude') {
@@ -423,12 +538,27 @@ const platformDescription = computed(() => {
return t('keys.useKeyModal.grok.codexDescription')
}
return t('keys.useKeyModal.grok.description')
case 'deepseek':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.deepseek.codexDescription')
: t('keys.useKeyModal.deepseek.description')
case 'composite':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.composite.codexDescription')
: t('keys.useKeyModal.composite.description')
default:
return t('keys.useKeyModal.description')
}
})
const platformNote = computed(() => {
if (activeClientTab.value === 'codex' &&
props.platform !== 'openai' &&
props.platform !== 'grok' &&
props.platform !== 'deepseek' &&
props.platform !== 'composite') {
return t('keys.useKeyModal.routedCodex.note')
}
switch (props.platform) {
case 'openai':
if (activeClientTab.value === 'claude') {
@@ -460,6 +590,14 @@ const platformNote = computed(() => {
return t('keys.useKeyModal.grok.noteWindows')
}
return t('keys.useKeyModal.grok.note')
case 'deepseek':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.deepseek.codexNote')
: t('keys.useKeyModal.note')
case 'composite':
return activeClientTab.value === 'codex'
? t('keys.useKeyModal.composite.codexNote')
: t('keys.useKeyModal.note')
default:
return t('keys.useKeyModal.note')
}
@@ -467,6 +605,66 @@ const platformNote = computed(() => {
const showPlatformNote = computed(() => activeClientTab.value !== 'opencode')
function resetCodexModelManifest() {
codexModelManifestController?.abort()
codexModelManifestController = null
codexModelManifestRequestID += 1
codexModelManifestState.value = 'idle'
codexModelManifestContent.value = ''
codexModelManifestModelCount.value = 0
}
async function loadCodexModelManifest() {
if (!showCodexModelCatalog.value || !props.apiKey) return
codexModelManifestController?.abort()
const controller = new AbortController()
const requestID = ++codexModelManifestRequestID
codexModelManifestController = controller
codexModelManifestState.value = 'loading'
try {
const result = await fetchCodexModelsManifest(props.baseUrl, props.apiKey, controller.signal)
if (requestID !== codexModelManifestRequestID) return
codexModelManifestContent.value = result.content
codexModelManifestModelCount.value = result.modelCount
codexModelManifestState.value = 'ready'
} catch (error) {
const errorName = error && typeof error === 'object' && 'name' in error
? String((error as { name?: unknown }).name || '')
: ''
if (requestID !== codexModelManifestRequestID || errorName === 'AbortError') return
codexModelManifestState.value = 'error'
} finally {
if (requestID === codexModelManifestRequestID) {
codexModelManifestController = null
}
}
}
function downloadCodexModelManifest() {
if (!codexModelManifestContent.value) return
saveAs(
new Blob([codexModelManifestContent.value], { type: 'application/json;charset=utf-8' }),
'codex-models.json'
)
}
const codexCatalogModelSlugs = computed(() =>
parseCodexCatalogModels(codexModelManifestContent.value).map((model) => model.slug)
)
function selectCodexCatalogModel(preferredModel: string): string {
if (codexCatalogModelSlugs.value.includes(preferredModel)) return preferredModel
return codexCatalogModelSlugs.value[0] || preferredModel
}
function codexReasoningEffortTomlLine(modelSlug: string): string {
return formatCodexReasoningEffortTomlLine(
selectCodexConfigReasoningEffort(findCodexCatalogModel(codexModelManifestContent.value, modelSlug))
)
}
const escapeHtml = (value: string) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
@@ -534,8 +732,14 @@ const currentFiles = computed((): FileConfig[] => {
}
return generateOpenAIFiles(baseUrl, apiKey)
case 'gemini':
if (activeClientTab.value === 'codex') {
return generateRoutedCodexFiles(apiBase, apiKey, 'gemini')
}
return [generateGeminiCliContent(baseUrl, apiKey)]
case 'antigravity':
if (activeClientTab.value === 'codex') {
return generateRoutedCodexFiles(apiBase, apiKey, 'antigravity')
}
if (activeClientTab.value === 'gemini') {
return [generateGeminiCliContent(`${baseUrl}/antigravity`, apiKey)]
}
@@ -548,7 +752,20 @@ const currentFiles = computed((): FileConfig[] => {
return generateGrokCodexFiles(apiBase, apiKey)
}
return generateGrokFiles(apiBase, apiKey)
case 'deepseek':
if (activeClientTab.value === 'codex') {
return generateRoutedCodexFiles(apiBase, apiKey, 'deepseek')
}
return generateAnthropicFiles(baseRoot, apiKey)
case 'composite':
if (activeClientTab.value === 'codex') {
return generateRoutedCodexFiles(apiBase, apiKey, 'composite')
}
return generateAnthropicFiles(baseRoot, apiKey)
default:
if (activeClientTab.value === 'codex' && props.platform) {
return generateRoutedCodexFiles(apiBase, apiKey, props.platform)
}
return generateAnthropicFiles(baseUrl, apiKey)
}
})
@@ -714,12 +931,15 @@ function generateOpenAIFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
const model = selectCodexCatalogModel('gpt-5.5')
const reasoningEffortLine = codexReasoningEffortTomlLine(model)
// config.toml content
const configContent = `model_provider = "OpenAI"
model = "gpt-5.5"
review_model = "gpt-5.5"
model_reasoning_effort = "xhigh"
disable_response_storage = true
model = "${model}"
review_model = "${model}"
${reasoningEffortLine}disable_response_storage = true
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
network_access = "enabled"
windows_wsl_setup_acknowledged = true
@@ -764,6 +984,10 @@ function joinConfigPath(dir: string, file: string, windows: boolean): string {
return `${dir}\\${file}`
}
function escapeTomlBasicString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function generateGrokFiles(baseUrl: string, apiKey: string): FileConfig[] {
// Prefer unix/cmd/powershell when shell tabs are shown; fall back to windows tab.
const shell = activeTab.value
@@ -912,6 +1136,7 @@ function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
const shell = activeTab.value
const isWindowsPath = shell === 'windows' || shell === 'cmd' || shell === 'powershell'
const configDir = isWindowsPath ? '%userprofile%\\.codex' : '~/.codex'
const model = selectCodexCatalogModel('grok-4.5')
let envPath: string
let envContent: string
@@ -937,9 +1162,10 @@ function generateGrokCodexFiles(baseUrl: string, apiKey: string): FileConfig[] {
# Switch model: grok-4.5 | grok-4.3 | grok-build-0.1 | grok-4.20-multi-agent-0309 (text / web_search)
model_provider = "sub2api"
model = "grok-4.5"
model = "${model}"
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
# Optional:
# review_model = "grok-4.5"
# review_model = "${model}"
# model_reasoning_effort = "medium"
# model_context_window = 500000
# disable_response_storage = true
@@ -973,16 +1199,83 @@ supports_websockets = false
]
}
function generateRoutedCodexFiles(
baseUrl: string,
apiKey: string,
platform: GroupPlatform
): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
const preferredModels: Partial<Record<GroupPlatform, string>> = {
openai: 'gpt-5.5',
anthropic: 'claude-sonnet-4-6',
gemini: 'gemini-2.5-pro',
antigravity: 'claude-sonnet-4-6',
grok: 'grok-4.5',
kimi: 'kimi-k2.5',
zhipu: 'glm-4.7',
deepseek: 'deepseek-v4-pro',
composite: 'gpt-5.5'
}
const preferredModel = preferredModels[platform] || ''
const model = selectCodexCatalogModel(preferredModel)
const labels: Record<GroupPlatform, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
gemini: 'Gemini',
antigravity: 'Antigravity',
grok: 'Grok',
kimi: 'Kimi',
zhipu: 'Zhipu',
deepseek: 'DeepSeek',
composite: 'Composite'
}
const label = labels[platform]
const envContent = isWindows
? `$env:SUB2API_API_KEY="${apiKey}"`
: `export SUB2API_API_KEY="${apiKey}"`
const configContent = `# Codex CLI -> Sub2API ${label} group
model_provider = "sub2api"
model = "${model}"
review_model = "${model}"
disable_response_storage = true
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
[model_providers.sub2api]
name = "Sub2API ${label}"
base_url = "${baseUrl}"
env_key = "SUB2API_API_KEY"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false`
return [
{ path: isWindows ? 'PowerShell' : 'Terminal', content: envContent },
{
path: joinConfigPath(configDir, 'config.toml', isWindows),
content: configContent,
hint: t(
platform === 'deepseek' || platform === 'composite'
? `keys.useKeyModal.${platform}.codexConfigTomlHint`
: 'keys.useKeyModal.routedCodex.configTomlHint'
)
}
]
}
function generateOpenAIWsFiles(baseUrl: string, apiKey: string): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
const model = selectCodexCatalogModel('gpt-5.5')
const reasoningEffortLine = codexReasoningEffortTomlLine(model)
// config.toml content with WebSocket v2
const configContent = `model_provider = "OpenAI"
model = "gpt-5.5"
review_model = "gpt-5.5"
model_reasoning_effort = "xhigh"
disable_response_storage = true
model = "${model}"
review_model = "${model}"
${reasoningEffortLine}disable_response_storage = true
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
network_access = "enabled"
windows_wsl_setup_acknowledged = true
@@ -1,9 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import { nextTick } from 'vue'
const { copyToClipboardMock } = vi.hoisted(() => ({
copyToClipboardMock: vi.fn().mockResolvedValue(true)
const { copyToClipboardMock, saveAsMock } = vi.hoisted(() => ({
copyToClipboardMock: vi.fn().mockResolvedValue(true),
saveAsMock: vi.fn()
}))
vi.mock('vue-i18n', () => ({
@@ -18,9 +19,26 @@ vi.mock('@/composables/useClipboard', () => ({
})
}))
vi.mock('file-saver', () => ({
saveAs: saveAsMock
}))
import UseKeyModal from '../UseKeyModal.vue'
function readBlobAsText(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => resolve(String(reader.result || '')))
reader.addEventListener('error', () => reject(reader.error))
reader.readAsText(blob)
})
}
describe('UseKeyModal', () => {
afterEach(() => {
vi.unstubAllGlobals()
saveAsMock.mockClear()
})
it('renders Grok Build and OpenCode setup for Grok groups', async () => {
const wrapper = mount(UseKeyModal, {
props: {
@@ -299,6 +317,7 @@ describe('UseKeyModal', () => {
expect(configToml).not.toContain('supports_websockets')
expect(configToml).not.toContain('responses_websockets_v2')
expect(configToml).toContain('[features]\ngoals = true')
expect(configToml).not.toContain('model_reasoning_effort = "xhigh"')
expect(codeBlocks).toContain('{\n "OPENAI_API_KEY": "sk-test"\n}')
expect(wrapper.text()).toContain('auth.json')
expect(wrapper.find('[data-testid="codex-api-key-restart-notice"]').exists()).toBe(false)
@@ -594,4 +613,234 @@ describe('UseKeyModal', () => {
expect(fable.options.thinking).toEqual({ type: 'adaptive' })
expect(fable.options.thinking).not.toHaveProperty('budgetTokens')
})
// Scenario: API Key users can fetch a routed group catalog and reference it from config.toml.
it('offers a downloadable Codex catalog for Composite API keys', async () => {
const manifest = {
models: [
{
slug: 'claude-opus-4-8',
default_reasoning_level: 'medium',
supported_reasoning_levels: [{ effort: 'max', description: 'Maximum reasoning depth' }],
input_modalities: ['text'],
model_messages: { instructions_template: 'Use the routed model.' }
},
{
slug: 'grok-4.6',
default_reasoning_level: 'high',
supported_reasoning_levels: [{ effort: 'xhigh', description: 'Extra-high reasoning depth' }],
input_modalities: ['text'],
model_messages: { instructions_template: 'Use the routed model.' }
}
]
}
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => manifest
})
vi.stubGlobal('fetch', fetchMock)
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-composite-test',
baseUrl: 'https://example.com/v1',
platform: 'composite'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const codexTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
)
expect(codexTab).toBeDefined()
await codexTab!.trigger('click')
await nextTick()
const unixConfig = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model_providers.sub2api]'))
expect(unixConfig).toContain('model_catalog_json = "~/.codex/codex-models.json"')
expect(unixConfig).toContain('env_key = "SUB2API_API_KEY"')
await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click')
await flushPromises()
expect(fetchMock).toHaveBeenCalledWith(
'https://example.com/v1/models?client_version=0.147.0',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer sk-composite-test' })
})
)
expect(wrapper.get('[data-testid="codex-model-catalog"]').text())
.toContain('keys.useKeyModal.codexModelCatalog.download')
const loadedUnixConfig = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model_providers.sub2api]'))
expect(loadedUnixConfig).toContain('model = "claude-opus-4-8"')
expect(loadedUnixConfig).toContain('review_model = "claude-opus-4-8"')
expect(loadedUnixConfig).not.toContain('model = "gpt-5.5"')
const downloadButton = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.codexModelCatalog.download')
)
expect(downloadButton).toBeDefined()
await downloadButton!.trigger('click')
expect(saveAsMock).toHaveBeenCalledWith(expect.any(Blob), 'codex-models.json')
const downloadedBlob = saveAsMock.mock.calls[0]?.[0] as Blob
expect(JSON.parse(await readBlobAsText(downloadedBlob))).toEqual(manifest)
const windowsTab = wrapper.findAll('button').find((button) => button.text().trim() === 'Windows')
expect(windowsTab).toBeDefined()
await windowsTab!.trigger('click')
await nextTick()
const windowsConfig = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model_providers.sub2api]'))
expect(windowsConfig).toContain(
'model_catalog_json = "%userprofile%\\\\.codex\\\\codex-models.json"'
)
})
it.each(['anthropic', 'gemini', 'antigravity', 'kimi', 'zhipu'] as const)(
'offers Codex catalog configuration for the %s routed group',
async (platform) => {
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: `sk-${platform}-test`,
baseUrl: 'https://example.com/v1',
platform
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const codexTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
)
expect(codexTab).toBeDefined()
await codexTab!.trigger('click')
await nextTick()
expect(wrapper.find('[data-testid="codex-model-catalog"]').exists()).toBe(true)
const config = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model_providers.sub2api]'))
expect(config).toContain('model_catalog_json = "~/.codex/codex-models.json"')
expect(config).toContain('base_url = "https://example.com/v1"')
expect(config).toContain('wire_api = "responses"')
}
)
// Scenario: the platform-preferred model remains selected when the downloaded catalog contains it.
it('keeps the preferred Composite default when it exists in the catalog', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
models: [
{ slug: 'claude-opus-4-8' },
{ slug: 'gpt-5.5' }
]
})
}))
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-composite-test',
baseUrl: 'https://example.com/v1',
platform: 'composite'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
const codexTab = wrapper.findAll('button').find((button) =>
button.text().includes('keys.useKeyModal.cliTabs.codexCli')
)
expect(codexTab).toBeDefined()
await codexTab!.trigger('click')
await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click')
await flushPromises()
const config = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('[model_providers.sub2api]'))
expect(config).toContain('model = "gpt-5.5"')
expect(config).toContain('review_model = "gpt-5.5"')
})
it('derives OpenAI Codex reasoning effort from the selected catalog descriptor', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
models: [
{
slug: 'glm-5.3',
default_reasoning_level: 'none',
supported_reasoning_levels: [{ effort: 'none' }]
}
]
})
}))
const wrapper = mount(UseKeyModal, {
props: {
show: true,
apiKey: 'sk-openai-test',
baseUrl: 'https://example.com/v1',
platform: 'openai'
},
global: {
stubs: {
BaseDialog: {
template: '<div><slot /><slot name="footer" /></div>'
},
Icon: {
template: '<span />'
}
}
}
})
await wrapper.get('[data-testid="codex-model-catalog-fetch"]').trigger('click')
await flushPromises()
const configToml = wrapper.findAll('pre code')
.map((code) => code.text())
.find((content) => content.includes('model_provider = "OpenAI"'))
expect(configToml).toContain('model = "glm-5.3"')
expect(configToml).not.toContain('model_reasoning_effort')
})
})
@@ -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',
+26
View File
@@ -188,6 +188,32 @@ export default {
codexNoteWindows:
'Set $env:SUB2API_API_KEY, save config.toml under %USERPROFILE%\\.codex. Prefer env_key auth; do not commit secrets.',
},
deepseek: {
description: 'Configure Claude Code, Codex, or OpenCode through the current DeepSeek group.',
codexDescription: 'Configure Codex with API key authentication through the current DeepSeek group.',
codexConfigTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.',
codexNote: 'Export SUB2API_API_KEY before starting Codex. The downloaded catalog contains model metadata only, not your API key.',
},
composite: {
description: 'Configure supported clients through the current Composite routing group.',
codexDescription: 'Configure Codex with API key authentication and the complete model catalog for this Composite group.',
codexConfigTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.',
codexNote: 'Export SUB2API_API_KEY before starting Codex. Model requests are routed by the selected catalog slug.',
},
routedCodex: {
description: 'Configure Codex with the complete model catalog for the current routed group.',
configTomlHint: 'Download the model catalog below, save both files under the Codex config directory, and restart Codex.',
note: 'Export SUB2API_API_KEY before starting Codex. The downloaded catalog contains model metadata only, not your API key.',
},
codexModelCatalog: {
title: 'Codex model catalog',
description: 'Fetch with this API key, then save the catalog at the path referenced by config.toml.',
fetch: 'Fetch catalog',
retry: 'Retry',
download: 'Download catalog',
modelsCount: '{count} models ready to download',
errorDescription: 'The catalog could not be fetched with this API key.',
},
opencode: {
title: 'OpenCode Example',
subtitle: 'opencode.json',
@@ -819,6 +819,7 @@ export default {
syncUpstreamModelsEmpty: '上游没有返回可同步的模型',
syncUpstreamModelsFailed: '同步上游模型失败',
syncUpstreamModelsError: '同步上游模型失败:{message}',
syncUpstreamModelsMetadataIncomplete: '模型 ID 已同步,但能力元数据不完整,能力信息未更新。',
clearAllModels: '清除所有模型',
customModelName: '自定义模型名称',
enterCustomModelName: '输入自定义模型名称',
+26
View File
@@ -192,6 +192,32 @@ export default {
codexNoteWindows:
'设置 $env:SUB2API_API_KEY,将 config.toml 保存到 %USERPROFILE%\\.codex。优先 env_key,勿提交密钥。'
},
deepseek: {
description: '通过当前 DeepSeek 分组配置 Claude Code、Codex 或 OpenCode。',
codexDescription: '使用 API Key 配置 Codex,并通过当前 DeepSeek 分组发送请求。',
codexConfigTomlHint: '下载下方模型目录,将两个文件保存到 Codex 配置目录后重启 Codex。',
codexNote: '启动 Codex 前先导出 SUB2API_API_KEY。下载的目录只包含模型元数据,不包含 API Key。'
},
composite: {
description: '通过当前 Composite 路由分组配置受支持的客户端。',
codexDescription: '使用 API Key 和当前 Composite 分组的完整模型目录配置 Codex。',
codexConfigTomlHint: '下载下方模型目录,将两个文件保存到 Codex 配置目录后重启 Codex。',
codexNote: '启动 Codex 前先导出 SUB2API_API_KEY;分组会根据目录中选中的模型路由请求。'
},
routedCodex: {
description: '使用当前路由分组的完整模型目录配置 Codex。',
configTomlHint: '下载下方模型目录,将两个文件保存到 Codex 配置目录后重启 Codex。',
note: '启动 Codex 前先导出 SUB2API_API_KEY。下载的目录只包含模型元数据,不包含 API Key。'
},
codexModelCatalog: {
title: 'Codex 模型目录',
description: '使用当前 API Key 获取目录,并保存到 config.toml 引用的路径。',
fetch: '获取目录',
retry: '重试',
download: '下载目录',
modelsCount: '已获取 {count} 个模型',
errorDescription: '无法使用当前 API Key 获取模型目录。'
},
opencode: {
title: 'OpenCode 配置示例',
subtitle: 'opencode.json',
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
findCodexCatalogModel,
formatCodexReasoningEffortTomlLine,
parseCodexCatalogModels,
selectCodexConfigReasoningEffort
} from '@/utils/codexCatalogConfig'
describe('codexCatalogConfig', () => {
it('parses catalog slugs and finds a model by id', () => {
const content = JSON.stringify({
models: [
{ slug: 'glm-5.3', default_reasoning_level: 'none', supported_reasoning_levels: [{ effort: 'none' }] },
{ slug: ' ', supported_reasoning_levels: [] }
]
})
expect(parseCodexCatalogModels(content).map((model) => model.slug)).toEqual(['glm-5.3'])
expect(findCodexCatalogModel(content, 'glm-5.3')?.slug).toBe('glm-5.3')
expect(findCodexCatalogModel(content, 'missing')).toBeUndefined()
})
it('omits effort when the descriptor only advertises none', () => {
expect(selectCodexConfigReasoningEffort({
slug: 'glm-5.3',
default_reasoning_level: 'none',
supported_reasoning_levels: [{ effort: 'none' }]
})).toBeNull()
expect(formatCodexReasoningEffortTomlLine(null)).toBe('')
})
it('does not emit an effort absent from supported_reasoning_levels', () => {
expect(selectCodexConfigReasoningEffort({
slug: 'glm-5.3',
default_reasoning_level: 'xhigh',
supported_reasoning_levels: [{ effort: 'none' }]
})).toBeNull()
})
it('uses the catalog default when it is a supported non-none effort', () => {
expect(selectCodexConfigReasoningEffort({
slug: 'gpt-5.5',
default_reasoning_level: 'medium',
supported_reasoning_levels: [
{ effort: 'low' },
{ effort: 'medium' },
{ effort: 'high' },
{ effort: 'xhigh' }
]
})).toBe('medium')
expect(formatCodexReasoningEffortTomlLine('medium')).toBe('model_reasoning_effort = "medium"\n')
})
it('falls back to the first usable supported effort when default is missing', () => {
expect(selectCodexConfigReasoningEffort({
slug: 'custom',
supported_reasoning_levels: [{ effort: 'none' }, { effort: 'high' }]
})).toBe('high')
})
})
+63
View File
@@ -0,0 +1,63 @@
export interface CodexCatalogReasoningLevel {
effort?: unknown
}
export interface CodexCatalogModel {
slug: string
default_reasoning_level?: unknown
supported_reasoning_levels?: CodexCatalogReasoningLevel[]
}
function trimEffort(value: unknown): string {
if (typeof value !== 'string') return ''
return value.trim()
}
export function parseCodexCatalogModels(content: string | null | undefined): CodexCatalogModel[] {
if (!content) return []
try {
const payload: unknown = JSON.parse(content)
if (typeof payload !== 'object' || payload === null || !('models' in payload)) return []
const models = (payload as { models?: unknown }).models
if (!Array.isArray(models)) return []
return models.flatMap((model) => {
if (typeof model !== 'object' || model === null || !('slug' in model)) return []
const slug = trimEffort((model as { slug?: unknown }).slug)
if (!slug) return []
return [{ ...(model as CodexCatalogModel), slug }]
})
} catch {
return []
}
}
export function findCodexCatalogModel(
content: string | null | undefined,
slug: string
): CodexCatalogModel | undefined {
const wanted = slug.trim()
if (!wanted) return undefined
return parseCodexCatalogModels(content).find((model) => model.slug === wanted)
}
export function selectCodexConfigReasoningEffort(
model: CodexCatalogModel | undefined
): string | null {
if (!model) return null
const efforts = (model.supported_reasoning_levels ?? []).flatMap((level) => {
const effort = trimEffort(level?.effort)
return effort ? [effort] : []
})
if (efforts.length === 0) return null
const defaultLevel = trimEffort(model.default_reasoning_level)
if (defaultLevel && efforts.includes(defaultLevel)) {
return defaultLevel === 'none' ? null : defaultLevel
}
return efforts.find((effort) => effort !== 'none') ?? null
}
export function formatCodexReasoningEffortTomlLine(effort: string | null): string {
if (!effort) return ''
return `model_reasoning_effort = "${effort}"\n`
}