mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-08-31 01:13:06 +08:00
Merge pull request #5926 from baryon/contrib/routed-codex-model-catalog
feat(gateway): generate complete routed Codex model catalogs
This commit is contained in:
@@ -2774,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2793,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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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, ®istry); err != nil {
|
||||
return nil, fmt.Errorf("parse model metadata registry: %w", err)
|
||||
}
|
||||
if len(registry) == 0 {
|
||||
return nil, fmt.Errorf("model metadata registry is empty")
|
||||
}
|
||||
|
||||
s.modelMetadataRegistryMu.Lock()
|
||||
s.modelMetadataRegistry = registry
|
||||
s.modelMetadataRegistryAt = now
|
||||
s.modelMetadataRegistryMu.Unlock()
|
||||
return registry, nil
|
||||
}
|
||||
|
||||
func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) UpstreamModelMetadata {
|
||||
levels := reasoningLevelsFromModelsDevOptions(model.ReasoningOptions)
|
||||
reasoning := model.Reasoning
|
||||
if reasoning == nil && len(levels) > 0 {
|
||||
inferred := true
|
||||
reasoning = &inferred
|
||||
}
|
||||
metadata := UpstreamModelMetadata{
|
||||
ID: strings.TrimSpace(modelID),
|
||||
DisplayName: strings.TrimSpace(model.Name),
|
||||
Description: strings.TrimSpace(model.Description),
|
||||
Reasoning: reasoning,
|
||||
SupportedReasoningLevels: levels,
|
||||
InputModalities: normalizeCodexInputModalities(model.Modalities.Input),
|
||||
ContextWindow: model.Limit.Context,
|
||||
MaxOutputTokens: model.Limit.Output,
|
||||
}
|
||||
if len(levels) > 0 {
|
||||
metadata.DefaultReasoningLevel = levels[0]
|
||||
}
|
||||
if strings.TrimSpace(model.ID) != "" {
|
||||
metadata.ID = strings.TrimSpace(model.ID)
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func reasoningLevelsFromModelsDevOptions(options []modelsDevReasoningOption) []string {
|
||||
levels := make([]string, 0)
|
||||
for _, option := range options {
|
||||
if !strings.EqualFold(strings.TrimSpace(option.Type), "effort") {
|
||||
continue
|
||||
}
|
||||
for _, value := range option.Values {
|
||||
if value == nil {
|
||||
levels = append(levels, "none")
|
||||
continue
|
||||
}
|
||||
if effort, ok := value.(string); ok {
|
||||
levels = append(levels, effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
return normalizeReasoningLevels(levels)
|
||||
}
|
||||
|
||||
func upstreamModelRegistryBaseURL(account *Account) string {
|
||||
if account == nil {
|
||||
return ""
|
||||
}
|
||||
switch {
|
||||
case account.IsOpenAI() || account.IsCNProvider():
|
||||
return account.GetOpenAIFormatBaseURL()
|
||||
case account.IsGrok():
|
||||
return account.GetGrokBaseURL()
|
||||
case account.IsGemini():
|
||||
return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
|
||||
case account.IsAnthropic():
|
||||
return account.GetBaseURL()
|
||||
case account.Platform == PlatformAntigravity:
|
||||
return account.GetGeminiBaseURL(geminicli.AIStudioBaseURL)
|
||||
default:
|
||||
return strings.TrimSpace(account.GetCredential("base_url"))
|
||||
}
|
||||
}
|
||||
|
||||
func matchModelsDevProvider(registry map[string]modelsDevProvider, accountBaseURL string) (modelsDevProvider, bool) {
|
||||
accountBaseURL = normalizeModelRegistryBaseURL(accountBaseURL)
|
||||
if accountBaseURL == "" {
|
||||
return modelsDevProvider{}, false
|
||||
}
|
||||
var best modelsDevProvider
|
||||
bestScore := -1
|
||||
for _, provider := range registry {
|
||||
providerBaseURL := normalizeModelRegistryBaseURL(provider.API)
|
||||
if providerBaseURL == "" {
|
||||
continue
|
||||
}
|
||||
if accountBaseURL != providerBaseURL &&
|
||||
!strings.HasPrefix(accountBaseURL, providerBaseURL+"/") &&
|
||||
!strings.HasPrefix(providerBaseURL, accountBaseURL+"/") {
|
||||
continue
|
||||
}
|
||||
if len(providerBaseURL) > bestScore {
|
||||
best = provider
|
||||
bestScore = len(providerBaseURL)
|
||||
}
|
||||
}
|
||||
return best, bestScore >= 0
|
||||
}
|
||||
|
||||
func normalizeModelRegistryBaseURL(raw string) string {
|
||||
parsed, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
if strings.HasSuffix(strings.ToLower(path), "/models") {
|
||||
path = strings.TrimRight(path[:len(path)-len("/models")], "/")
|
||||
}
|
||||
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host) + path
|
||||
}
|
||||
|
||||
func (s *AccountTestService) fetchUpstreamModelList(ctx context.Context, account *Account) ([]string, []byte, error) {
|
||||
if s == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Account test service is not configured", nil)
|
||||
}
|
||||
if account == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Account is required", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Account is required", nil)
|
||||
}
|
||||
|
||||
if account.Platform == PlatformAntigravity && account.Type != AccountTypeAPIKey {
|
||||
return s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
models, err := s.fetchAntigravityOAuthUpstreamModels(ctx, account)
|
||||
return models, nil, err
|
||||
}
|
||||
|
||||
if s.httpUpstream == nil {
|
||||
return nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
|
||||
return nil, nil, newUpstreamModelSyncConfigError("Upstream HTTP client is not configured", nil)
|
||||
}
|
||||
|
||||
req, err := s.buildUpstreamModelsRequest(ctx, account)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
proxyURL := upstreamModelsProxyURL(account)
|
||||
resp, err := s.doUpstreamModelsRequest(req, proxyURL, account)
|
||||
if err != nil {
|
||||
return nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err)
|
||||
return nil, nil, newUpstreamModelSyncUpstreamError("Failed to request upstream model list", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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']]])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '输入自定义模型名称',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user