fix(codex): harden routed catalog capability sync

This commit is contained in:
Long Li
2026-08-26 12:46:31 +09:00
parent 195b219707
commit 2abce65031
13 changed files with 677 additions and 42 deletions
@@ -2802,22 +2802,28 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
// 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,
},
}
@@ -74,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
}
@@ -393,6 +394,59 @@ func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T)
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(),
@@ -94,10 +94,12 @@ func codexProviderQualifiedModelID(modelID string) string {
// CodexModelsManifest carries the client representation plus caching metadata.
type CodexModelsManifest struct {
Body []byte
ETag string
upstreamETag string
NotModified bool
Body []byte
ETag string
upstreamETag string
upstreamSourceBody []byte
convertedFromOpenAIModelList bool
NotModified bool
}
// BuildGroupConfiguredCodexModelsManifest builds a Codex catalog exclusively
@@ -117,7 +119,7 @@ func (s *OpenAIGatewayService) BuildGroupConfiguredCodexModelsManifest(
if err != nil {
return nil, false, fmt.Errorf("load group configured Codex models: %w", err)
}
configuredModels := openAIConfiguredCodexModelIDs(visible)
configuredModels := openAIConfiguredCodexModelIDsForGroup(visible, group)
if len(configuredModels) == 0 {
return nil, false, nil
}
@@ -169,7 +171,7 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
return nil
}
configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group.ID)
configuredModels, err := s.groupConfiguredCodexModelIDs(ctx, group)
if err != nil {
return fmt.Errorf("load group configured Codex models: %w", err)
}
@@ -193,12 +195,15 @@ func (s *OpenAIGatewayService) MergeGroupConfiguredCodexModels(
return nil
}
func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, groupID int64) ([]string, error) {
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
func (s *OpenAIGatewayService) groupConfiguredCodexModelIDs(ctx context.Context, group *Group) ([]string, error) {
if group == nil {
return nil, nil
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, group.ID)
if err != nil {
return nil, err
}
return openAIConfiguredCodexModelIDs(accounts), nil
return openAIConfiguredCodexModelIDsForGroup(accounts, group), nil
}
// loadCodexGroupCatalogAccounts separates picker membership from capability
@@ -248,6 +253,41 @@ func openAIConfiguredCodexModelIDs(accounts []Account) []string {
return models
}
func openAIConfiguredCodexModelIDsForGroup(accounts []Account, group *Group) []string {
models := openAIConfiguredCodexModelIDs(accounts)
if group == nil || !group.CustomModelsListEnabled() {
return models
}
seen := make(map[string]struct{}, len(models)+len(group.ModelsListConfig.Models))
for _, modelID := range models {
seen[modelID] = struct{}{}
}
for _, selectedModel := range group.ModelsListConfig.Models {
selectedModel = strings.TrimSpace(selectedModel)
if selectedModel == "" || strings.Contains(selectedModel, "*") {
continue
}
for i := range accounts {
account := &accounts[i]
if account.Platform != PlatformOpenAI {
continue
}
mappedModel, matched := account.ResolveMappedModel(selectedModel)
if !matched || strings.TrimSpace(mappedModel) == "" {
continue
}
if _, exists := seen[selectedModel]; !exists {
seen[selectedModel] = struct{}{}
models = append(models, selectedModel)
}
break
}
}
sort.Strings(models)
return models
}
const (
configuredCodexModelPriority = 50
configuredCodexCustomDescription = "Custom model routed through Sub2API."
@@ -1342,6 +1382,10 @@ func (c *codexModelsManifestCache) set(key string, manifest *CodexModelsManifest
if manifest == nil || len(manifest.Body) > codexModelsManifestCacheBodyLimit {
return
}
remainingBodyBudget := codexModelsManifestCacheBodyLimit - len(manifest.Body)
if len(manifest.upstreamSourceBody) > remainingBodyBudget {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.entries == nil {
@@ -1669,8 +1713,11 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
}
}
upstreamBody := body
convertedFromOpenAIModelList := false
if request.useAPIKeyUpstream {
body = convertOpenAIModelListToCodexManifest(body)
convertedBody := convertOpenAIModelListToCodexManifest(body)
convertedFromOpenAIModelList = !bytes.Equal(convertedBody, body)
body = convertedBody
}
if err := validateCodexModelsManifestEnvelope(body); err != nil {
return nil, &codexModelsManifestUpstreamError{
@@ -1714,7 +1761,12 @@ func (s *OpenAIGatewayService) fetchCodexModelsManifestUpstream(ctx context.Cont
}
}
etag := resp.Header.Get("ETag")
manifest := &CodexModelsManifest{Body: body, ETag: etag}
manifest := &CodexModelsManifest{
Body: body,
ETag: etag,
upstreamSourceBody: append([]byte(nil), upstreamBody...),
convertedFromOpenAIModelList: convertedFromOpenAIModelList,
}
if request.useAPIKeyUpstream {
manifest.upstreamETag = etag
if !bytes.Equal(body, upstreamBody) {
@@ -1848,21 +1900,140 @@ func (s *OpenAIGatewayService) CompleteAPIKeyCodexModelsManifestForClient(manife
if manifest == nil || account == nil || !account.IsOpenAIApiKey() || manifest.NotModified || len(manifest.Body) == 0 {
return nil
}
body, err := completeAPIKeyCodexModelsManifestMetadata(
manifest.Body,
body := manifest.Body
if len(manifest.upstreamSourceBody) > 0 {
body = append([]byte(nil), manifest.upstreamSourceBody...)
if manifest.convertedFromOpenAIModelList {
body = convertOpenAIModelListToCodexManifest(body)
}
}
var err error
body, err = applySyncedAPIKeyCodexModelMetadata(body, account, manifest.convertedFromOpenAIModelList)
if err != nil {
return err
}
body, err = completeAPIKeyCodexModelsManifestMetadata(
body,
true,
isOfficialOpenAIModelsBaseURL(account.GetOpenAIBaseURL()),
)
if err != nil {
return err
}
if !bytes.Equal(body, manifest.Body) {
manifest.Body = body
manifest.ETag = codexModelsManifestBodyETag(body)
body, err = adjustAPIKeyCodexModelsManifest(body)
if err != nil {
return err
}
manifest.Body = body
manifest.ETag = codexModelsManifestBodyETag(manifest.Body)
return nil
}
func applySyncedAPIKeyCodexModelMetadata(body []byte, account *Account, overwriteLocalDefaults bool) ([]byte, error) {
snapshot := account.GetUpstreamModelMetadataSnapshot()
if snapshot == nil || len(snapshot.Models) == 0 {
return body, nil
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("decode JSON object: %w", err)
}
var models []json.RawMessage
if err := json.Unmarshal(envelope["models"], &models); err != nil {
return nil, fmt.Errorf("decode top-level models array: %w", err)
}
changed := false
for i, rawModel := range models {
var model map[string]json.RawMessage
if err := json.Unmarshal(rawModel, &model); err != nil || model == nil {
continue
}
var slug string
if err := json.Unmarshal(model["slug"], &slug); err != nil {
continue
}
slug = strings.TrimSpace(slug)
metadata, ok := snapshot.Models[slug]
if !ok {
continue
}
descriptor := newConfiguredCodexModelDescriptor(slug)
applyUpstreamModelMetadataToCodexDescriptor(
&descriptor,
codexModelMetadataOverride{UpstreamModelMetadata: metadata},
)
descriptorBody, err := json.Marshal(descriptor)
if err != nil {
return nil, fmt.Errorf("encode synced model %q: %w", slug, err)
}
var syncedFields map[string]json.RawMessage
if err := json.Unmarshal(descriptorBody, &syncedFields); err != nil {
return nil, fmt.Errorf("decode synced model %q: %w", slug, err)
}
fields := make([]string, 0, 7)
if strings.TrimSpace(metadata.DisplayName) != "" {
fields = append(fields, "display_name")
}
if strings.TrimSpace(metadata.Description) != "" {
fields = append(fields, "description")
}
if metadata.Reasoning != nil {
fields = append(fields, "default_reasoning_level", "supported_reasoning_levels")
}
if len(normalizeCodexInputModalities(metadata.InputModalities)) > 0 {
fields = append(fields, "input_modalities")
}
if metadata.ContextWindow > 0 {
fields = append(fields, "context_window", "max_context_window")
}
modelChanged := false
for _, field := range fields {
value, exists := syncedFields[field]
if !exists {
continue
}
current, currentExists := model[field]
current = bytes.TrimSpace(current)
if !overwriteLocalDefaults && currentExists && len(current) > 0 && !bytes.Equal(current, []byte("null")) {
continue
}
if bytes.Equal(current, bytes.TrimSpace(value)) {
continue
}
model[field] = value
modelChanged = true
}
if !modelChanged {
continue
}
encoded, err := json.Marshal(model)
if err != nil {
return nil, fmt.Errorf("encode model %q with synced metadata: %w", slug, err)
}
models[i] = encoded
changed = true
}
if !changed {
return body, nil
}
encodedModels, err := json.Marshal(models)
if err != nil {
return nil, fmt.Errorf("encode models with synced metadata: %w", err)
}
envelope["models"] = encodedModels
updated, err := json.Marshal(envelope)
if err != nil {
return nil, fmt.Errorf("encode manifest with synced metadata: %w", err)
}
return updated, nil
}
func completeAPIKeyCodexModelsManifestMetadata(body []byte, completeAll, officialOpenAI bool) ([]byte, error) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
@@ -2059,6 +2230,9 @@ func cloneCodexModelsManifest(manifest *CodexModelsManifest) *CodexModelsManifes
if manifest.Body != nil {
cloned.Body = append([]byte(nil), manifest.Body...)
}
if manifest.upstreamSourceBody != nil {
cloned.upstreamSourceBody = append([]byte(nil), manifest.upstreamSourceBody...)
}
return &cloned
}
@@ -971,6 +971,38 @@ func TestBuildGroupConfiguredCodexModelsManifestUsesAdministratorConfiguration(t
require.Equal(t, manifest.ETag, notModified.ETag)
}
// Scenario: OpenAI 通配映射展开组内精确选择,但不发布通配符 slug。
func TestBuildGroupConfiguredCodexModelsManifestExpandsSelectedModelCoveredByWildcardMapping(t *testing.T) {
t.Parallel()
const groupID int64 = 80
svc := &OpenAIGatewayService{accountRepo: codexModelsVisibilityAccountRepo{
byGroup: map[int64][]Account{
groupID: {{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"model_mapping": map[string]any{"gpt-*": "gpt-5.6-sol"},
},
}},
},
}}
group := &Group{
ID: groupID,
Platform: PlatformOpenAI,
ModelsListConfig: GroupModelsListConfig{
Enabled: true,
Models: []string{"gpt-5.6"},
},
}
manifest, configured, err := svc.BuildGroupConfiguredCodexModelsManifest(context.Background(), group, "")
require.NoError(t, err)
require.True(t, configured)
require.Equal(t, []string{"gpt-5.6"}, codexManifestModelSlugs(t, manifest.Body))
require.NotContains(t, string(manifest.Body), "gpt-*")
}
// Scenario: OpenAI 配置目录对暂时不可调度账号取能力交集,且不发布其独有模型。
func TestBuildGroupConfiguredCodexModelsManifestIntersectsUnschedulableMappedAccounts(t *testing.T) {
t.Parallel()
@@ -1630,6 +1662,41 @@ func TestFetchCodexModelsManifestAPIKeyCustomUpstream(t *testing.T) {
require.Equal(t, `W/"api-key-manifest"`, manifest.upstreamETag)
}
// Scenario: 完整上游清单没有 ETag 时,最终正文仍生成强 ETag 并支持 304。
func TestFetchCodexModelsManifestAPIKeyCompleteBodyWithoutUpstreamETagUsesFinalBodyETag(t *testing.T) {
completeBody, err := BuildCodexModelsManifest([]string{"custom-complete-model"})
require.NoError(t, err)
var calls atomic.Int32
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(bytes.NewReader(completeBody)),
}, nil
}}
svc := newCodexModelsAPIKeyTestService(upstream)
svc.accountRepo = codexModelsVisibilityAccountRepo{}
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
group := &Group{ID: 82, Platform: PlatformOpenAI}
first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
require.NoError(t, err)
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, first, ""))
require.Equal(t, codexModelsManifestBodyETag(first.Body), first.ETag)
require.NotEmpty(t, first.ETag)
second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
require.NoError(t, err)
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
require.NoError(t, svc.MergeGroupConfiguredCodexModels(context.Background(), group, second, first.ETag))
require.True(t, second.NotModified)
require.Empty(t, second.Body)
require.Equal(t, int32(1), calls.Load())
}
func TestFetchCodexModelsManifestAPIKeyConvertsStandardOpenAIModelList(t *testing.T) {
upstreamBody := `{"object":"list","data":[{"id":"gpt-5.6","object":"model"},{"id":" ","object":"model"},{"id":"gpt-5.6-codex","object":"model"}]}`
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
@@ -1702,6 +1769,134 @@ func TestCompleteAPIKeyCodexModelsManifestForClientPreservesProviderMetadata(t *
require.Equal(t, map[string]any{"source": "upstream"}, envelope["metadata"])
}
// Scenario: 标准 /models 型号列表优先使用已同步账号能力,再使用本地 descriptor 兜底。
func TestCompleteAPIKeyCodexModelsManifestForClientUsesSyncedMetadataForConvertedModelList(t *testing.T) {
t.Parallel()
reasoning := true
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
"future-reasoner": {
ID: "future-reasoner", DisplayName: "Future Reasoner", Description: "Synced upstream capability",
Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
SupportedReasoningLevels: []string{"low", "high", "ultra"},
InputModalities: []string{"text", "image"},
ContextWindow: 999_000,
},
}})
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"object":"list","data":[{"id":"future-reasoner","object":"model"}]}`)),
}, nil
}}
svc := newCodexModelsAPIKeyTestService(upstream)
manifest, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
require.NoError(t, err)
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
models := decodeCodexManifestModels(t, manifest.Body)
require.Len(t, models, 1)
require.Equal(t, "Future Reasoner", models[0]["display_name"])
require.Equal(t, "Synced upstream capability", models[0]["description"])
require.Equal(t, "ultra", models[0]["default_reasoning_level"])
require.Equal(t, []string{"low", "high", "ultra"}, effortsFromManifestModel(t, models[0]))
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
require.EqualValues(t, 999_000, models[0]["context_window"])
require.EqualValues(t, 999_000, models[0]["max_context_window"])
}
func TestCompleteAPIKeyCodexModelsManifestForClientFillsMissingProviderFieldsWithoutOverwritingExplicitMetadata(t *testing.T) {
t.Parallel()
reasoning := true
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
"provider-model": {
ID: "provider-model", DisplayName: "Synced Display", Description: "Synced description",
Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
SupportedReasoningLevels: []string{"high", "ultra"},
InputModalities: []string{"text", "image"},
ContextWindow: 999_000,
},
}})
manifest := &CodexModelsManifest{Body: []byte(`{"models":[{
"slug":"provider-model",
"description":"Provider supplied",
"context_window":64000,
"max_context_window":64000
}]}`)}
svc := &OpenAIGatewayService{}
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(manifest, account))
models := decodeCodexManifestModels(t, manifest.Body)
require.Len(t, models, 1)
require.Equal(t, "Synced Display", models[0]["display_name"])
require.Equal(t, "Provider supplied", models[0]["description"])
require.Equal(t, "ultra", models[0]["default_reasoning_level"])
require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, models[0]))
require.Equal(t, []any{"text", "image"}, models[0]["input_modalities"])
require.EqualValues(t, 64_000, models[0]["context_window"])
require.EqualValues(t, 64_000, models[0]["max_context_window"])
}
// Scenario: 原生 manifest 的缺失字段在命中缓存后仍使用账号当前同步快照,而不是缓存中的本地默认值。
func TestCompleteAPIKeyCodexModelsManifestForClientUsesCurrentSnapshotForCachedNativeManifest(t *testing.T) {
t.Parallel()
reasoning := true
account := newCodexModelsAPIKeyTestAccount("https://upstream.example/v1")
setSnapshot := func(displayName string, contextWindow int64) {
account.SetUpstreamModelMetadataSnapshot(UpstreamModelMetadataSnapshot{Models: map[string]UpstreamModelMetadata{
"deepseek-v4-pro": {
ID: "deepseek-v4-pro", DisplayName: displayName,
Reasoning: &reasoning, DefaultReasoningLevel: "ultra",
SupportedReasoningLevels: []string{"high", "ultra"},
InputModalities: []string{"text", "image"},
ContextWindow: contextWindow,
},
}})
}
setSnapshot("Synced DeepSeek", 256_000)
var calls atomic.Int32
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
calls.Add(1)
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"models":[{
"slug":"deepseek-v4-pro",
"description":"Provider supplied"
}]}`)),
}, nil
}}
svc := newCodexModelsAPIKeyTestService(upstream)
first, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
require.NoError(t, err)
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(first, account))
firstModel := decodeCodexManifestModels(t, first.Body)[0]
require.Equal(t, "Synced DeepSeek", firstModel["display_name"])
require.Equal(t, "Provider supplied", firstModel["description"])
require.Equal(t, "ultra", firstModel["default_reasoning_level"])
require.Equal(t, []string{"high", "ultra"}, effortsFromManifestModel(t, firstModel))
require.Equal(t, []any{"text", "image"}, firstModel["input_modalities"])
require.EqualValues(t, 256_000, firstModel["context_window"])
setSnapshot("Refreshed DeepSeek", 512_000)
second, err := svc.FetchCodexModelsManifest(context.Background(), account, "0.150.0", "")
require.NoError(t, err)
require.NoError(t, svc.CompleteAPIKeyCodexModelsManifestForClient(second, account))
secondModel := decodeCodexManifestModels(t, second.Body)[0]
require.Equal(t, "Refreshed DeepSeek", secondModel["display_name"])
require.Equal(t, "Provider supplied", secondModel["description"])
require.EqualValues(t, 512_000, secondModel["context_window"])
require.Equal(t, int32(1), calls.Load(), "second response should use the cached upstream source body")
}
func TestCompleteAPIKeyCodexModelsManifestForClientMarksOnlyOfficialVisionGPTImageInput(t *testing.T) {
t.Parallel()
@@ -2288,7 +2483,10 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.
upstream := &codexModelsHTTPUpstreamStub{do: func(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
calls.Add(1)
body := `{"models":[]}`
if strings.Contains(req.URL.Host, "large") {
switch {
case strings.Contains(req.URL.Host, "large-source"):
body = `{"object":"list","data":[{"id":"model-a","padding":"` + strings.Repeat("x", 1<<20) + `"}]}`
case strings.Contains(req.URL.Host, "large"):
body = `{"models":[],"padding":"` + strings.Repeat("x", (1<<20)+1) + `"}`
}
return &http.Response{
@@ -2312,8 +2510,12 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.
large.ID = 3
fetch(large)
fetch(large)
if got := calls.Load(); got != 3 {
t.Fatalf("body-size bounded cache calls: got %d, want 3", got)
largeSource := newCodexModelsAPIKeyTestAccount("https://large-source.example")
largeSource.ID = 4
fetch(largeSource)
fetch(largeSource)
if got := calls.Load(); got != 5 {
t.Fatalf("body-size bounded cache calls: got %d, want 5", got)
}
for i := int64(10); i < 75; i++ {
@@ -2324,14 +2526,14 @@ func TestFetchCodexModelsManifestAPIKeyCacheBoundsEntriesAndBodySize(t *testing.
last := newCodexModelsAPIKeyTestAccount("https://bounded.example")
last.ID = 74
fetch(last)
if got := calls.Load(); got != 68 {
t.Fatalf("most recent cache entry was not retained: calls=%d, want 68", got)
if got := calls.Load(); got != 70 {
t.Fatalf("most recent cache entry was not retained: calls=%d, want 70", got)
}
first := newCodexModelsAPIKeyTestAccount("https://bounded.example")
first.ID = 10
fetch(first)
if got := calls.Load(); got != 69 {
t.Errorf("oldest cache entry was not evicted: calls=%d, want 69", got)
if got := calls.Load(); got != 71 {
t.Errorf("oldest cache entry was not evicted: calls=%d, want 71", got)
}
}
+1 -1
View File
@@ -1241,7 +1241,7 @@ func normalizeReasoningLevel(level string) string {
return "none"
case "extra-high", "extra_high":
return "xhigh"
case "none", "minimal", "low", "medium", "high", "xhigh", "max":
case "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra":
return level
default:
return ""
@@ -617,7 +617,7 @@ func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) {
"display_name":"Upstream Display",
"description":"Upstream description",
"default_reasoning_level":"high",
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"}],
"supported_reasoning_levels":[{"effort":"low"},{"effort":"high"},{"effort":"ultra"}],
"input_modalities":["text","image"],
"context_window":256000
}]}`)),
@@ -634,11 +634,59 @@ func TestSyncUpstreamModelCatalogPrefersDirectUpstreamMetadata(t *testing.T) {
metadata := catalog.Metadata["custom-thinking-model"]
require.Equal(t, "Upstream Display", metadata.DisplayName)
require.Equal(t, "high", metadata.DefaultReasoningLevel)
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
require.Equal(t, []string{"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{
+1
View File
@@ -577,6 +577,7 @@ export interface SyncUpstreamPreviewParams {
type: string
base_url?: string
api_key: string
model_mapping?: Record<string, string>
}
/**
@@ -4087,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 } : {})
}
})
@@ -4987,7 +4993,14 @@ const submitCreateAccount = async (payload: CreateAccountRequest) => {
submitting.value = true
try {
const account = await adminAPI.accounts.create(withAntigravityConfirmFlag(payload))
if (upstreamModelsPreviewed.value) {
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')) {
@@ -126,7 +126,11 @@ const ModelWhitelistSelectorStub = defineComponent({
syncCredentials: Object,
},
emits: ['update:modelValue', 'upstream-synced'],
template: '<button type="button" data-testid="model-whitelist-selector" @click="$emit(\'upstream-synced\')">models</button>',
template: `<button
type="button"
data-testid="model-whitelist-selector"
@click="$emit('update:modelValue', ['public-glm']); $emit('upstream-synced')"
>models</button>`,
})
function mountModal(groups: any[] = []) {
@@ -257,6 +261,38 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
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'],
+59 -7
View File
@@ -315,8 +315,8 @@ let codexModelManifestRequestID = 0
const showCodexModelCatalog = computed(() =>
props.show &&
((props.platform === 'openai' && (activeClientTab.value === 'codex' || activeClientTab.value === 'codex-ws')) ||
((props.platform === 'grok' || props.platform === 'deepseek' || props.platform === 'composite') && activeClientTab.value === 'codex'))
(activeClientTab.value === 'codex' ||
(props.platform === 'openai' && activeClientTab.value === 'codex-ws'))
)
const codexModelCatalogPath = computed(() => {
@@ -451,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':
@@ -476,6 +478,7 @@ const clientTabs = computed((): TabConfig[] => {
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 }
]
}
@@ -510,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') {
@@ -542,6 +552,13 @@ const platformDescription = computed(() => {
})
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') {
@@ -715,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)]
}
@@ -740,6 +763,9 @@ const currentFiles = computed((): FileConfig[] => {
}
return generateAnthropicFiles(baseRoot, apiKey)
default:
if (activeClientTab.value === 'codex' && props.platform) {
return generateRoutedCodexFiles(apiBase, apiKey, props.platform)
}
return generateAnthropicFiles(baseUrl, apiKey)
}
})
@@ -1176,13 +1202,35 @@ supports_websockets = false
function generateRoutedCodexFiles(
baseUrl: string,
apiKey: string,
platform: 'deepseek' | 'composite'
platform: GroupPlatform
): FileConfig[] {
const isWindows = activeTab.value === 'windows'
const configDir = isWindows ? '%userprofile%\\.codex' : '~/.codex'
const preferredModel = platform === 'deepseek' ? 'deepseek-v4-pro' : 'gpt-5.5'
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 label = platform === 'deepseek' ? 'DeepSeek' : 'Composite'
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}"`
@@ -1195,7 +1243,7 @@ disable_response_storage = true
model_catalog_json = "${escapeTomlBasicString(codexModelCatalogPath.value)}"
[model_providers.sub2api]
name = "OpenAI"
name = "Sub2API ${label}"
base_url = "${baseUrl}"
env_key = "SUB2API_API_KEY"
wire_api = "responses"
@@ -1207,7 +1255,11 @@ supports_websockets = false`
{
path: joinConfigPath(configDir, 'config.toml', isWindows),
content: configContent,
hint: t(`keys.useKeyModal.${platform}.codexConfigTomlHint`)
hint: t(
platform === 'deepseek' || platform === 'composite'
? `keys.useKeyModal.${platform}.codexConfigTomlHint`
: 'keys.useKeyModal.routedCodex.configTomlHint'
)
}
]
}
@@ -714,6 +714,45 @@ describe('UseKeyModal', () => {
)
})
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({
@@ -200,6 +200,11 @@ export default {
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.',
@@ -204,6 +204,11 @@ export default {
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 引用的路径。',