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{