mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:08:03 +08:00
fix(codex): harden routed catalog capability sync
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 引用的路径。',
|
||||
|
||||
Reference in New Issue
Block a user