From 195a3313224a7a232370e7aa7a4ed79cf6ad49d7 Mon Sep 17 00:00:00 2001 From: Sjeary <1286860778@qq.com> Date: Fri, 11 Sep 2026 21:56:25 -0700 Subject: [PATCH] fix(codex): preserve maximum context window in model catalogs --- .../openai_codex_context_window_test.go | 187 ++++++++++++++++++ .../service/openai_codex_model_metadata.go | 19 ++ backend/internal/service/upstream_models.go | 9 + frontend/src/api/admin/accounts.ts | 1 + 4 files changed, 216 insertions(+) create mode 100644 backend/internal/service/openai_codex_context_window_test.go diff --git a/backend/internal/service/openai_codex_context_window_test.go b/backend/internal/service/openai_codex_context_window_test.go new file mode 100644 index 0000000000..926d315362 --- /dev/null +++ b/backend/internal/service/openai_codex_context_window_test.go @@ -0,0 +1,187 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCodexContextWindowSyncRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + fields string + wantWindow int64 + wantMax int64 + }{ + {"distinct windows", `"context_window":272000,"max_context_window":872000`, 272000, 872000}, + {"equal windows", `"context_window":272000,"max_context_window":272000`, 272000, 272000}, + {"default only", `"context_window":272000`, 272000, 272000}, + {"maximum only", `"max_context_window":872000`, 872000, 872000}, + {"registry style limit", `"limit":{"context":128000}`, 128000, 128000}, + {"zero maximum", `"context_window":272000,"max_context_window":0`, 272000, 272000}, + {"negative maximum", `"context_window":272000,"max_context_window":-1`, 272000, 272000}, + {"maximum below default", `"context_window":272000,"max_context_window":128000`, 128000, 128000}, + } { + t.Run(tc.name, func(t *testing.T) { + account := codexContextWindowAccount(t, 1, `"context_window":64000`) + upstream := &httpUpstreamRecorder{resp: &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`{"models":[{ + "slug":"gpt-6-astra","reasoning":true, + "supported_reasoning_levels":[{"effort":"high"}], + "input_modalities":["text","image"],%s + }]}`, tc.fields))), + }} + repo := &upstreamModelMetadataRepoStub{} + syncer := &AccountTestService{accountRepo: repo, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig()} + catalog, err := syncer.SyncUpstreamModelCatalog(context.Background(), &account) + require.NoError(t, err) + require.Empty(t, catalog.Warnings) + require.Len(t, upstream.requests, 1) + require.Equal(t, account.ID, repo.accountID) + + // Reload the JSON representation persisted in account.extra, replacing + // the legacy snapshot so the test exercises resync as well as parsing. + persisted, err := json.Marshal(repo.updates) + require.NoError(t, err) + account.Extra = nil + require.NoError(t, json.Unmarshal(persisted, &account.Extra)) + model := codexContextWindowManifest(t, []Account{account}) + require.EqualValues(t, tc.wantWindow, model["context_window"]) + require.EqualValues(t, tc.wantMax, model["max_context_window"]) + }) + } +} + +func TestCodexContextWindowAccountIntersection(t *testing.T) { + for _, tc := range []struct { + name string + accounts []string + wantWindow int64 + wantMax int64 + }{ + { + "independent minima", + []string{`"context_window":272000,"max_context_window":872000`, `"context_window":300000,"max_context_window":512000`}, + 272000, 512000, + }, + { + "legacy snapshot caps maximum", + []string{`"context_window":272000,"max_context_window":872000`, `"context_window":300000`}, + 272000, 300000, + }, + { + "legacy snapshot alone", + []string{`"context_window":272000`}, + 272000, 272000, + }, + { + "invalid maximum uses known default", + []string{`"context_window":272000,"max_context_window":872000`, `"context_window":300000,"max_context_window":-1`}, + 272000, 300000, + }, + } { + t.Run(tc.name, func(t *testing.T) { + accounts := make([]Account, len(tc.accounts)) + for i, fields := range tc.accounts { + accounts[i] = codexContextWindowAccount(t, int64(i+1), fields) + } + for range 2 { + model := codexContextWindowManifest(t, accounts) + require.EqualValues(t, tc.wantWindow, model["context_window"]) + require.EqualValues(t, tc.wantMax, model["max_context_window"]) + for i, j := 0, len(accounts)-1; i < j; i, j = i+1, j-1 { + accounts[i], accounts[j] = accounts[j], accounts[i] + } + } + }) + } +} + +func TestCodexContextWindowRegistryEnrichment(t *testing.T) { + for _, tc := range []struct { + name string + fields string + wantWindow int64 + wantMax int64 + }{ + {"preserve upstream limits", `"context_window":272000,"max_context_window":872000`, 272000, 872000}, + {"upstream default remains ceiling", `"context_window":272000`, 272000, 272000}, + {"registry supplies missing limits", `"description":"Model without context metadata"`, 1050000, 1050000}, + } { + t.Run(tc.name, func(t *testing.T) { + account := codexContextWindowAccount(t, 1, `"context_window":64000`) + account.Type = AccountTypeAPIKey + account.Credentials["api_key"] = "test-key" + account.Credentials["base_url"] = "https://provider.example/v1" + upstream := &httpUpstreamRecorder{responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(fmt.Sprintf( + `{"data":[{"id":"gpt-6-astra",%s}]}`, tc.fields))), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"provider":{ + "id":"provider","api":"https://provider.example/v1","models":{ + "gpt-6-astra":{"id":"gpt-6-astra","reasoning":true, + "reasoning_options":[{"type":"effort","values":["high"]}], + "modalities":{"input":["text","image"]},"limit":{"context":1050000}} + } + }}`)), + }, + }} + syncer := &AccountTestService{ + accountRepo: &upstreamModelMetadataRepoStub{}, httpUpstream: upstream, cfg: upstreamModelSyncTestConfig(), + } + catalog, err := syncer.SyncUpstreamModelCatalog(context.Background(), &account) + require.NoError(t, err) + require.Empty(t, catalog.Warnings) + require.Len(t, upstream.requests, 2) + model := codexContextWindowManifest(t, []Account{account}) + require.EqualValues(t, tc.wantWindow, model["context_window"]) + require.EqualValues(t, tc.wantMax, model["max_context_window"]) + }) + } +} + +func codexContextWindowAccount(t *testing.T, id int64, fields string) Account { + t.Helper() + account := Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Credentials: map[string]any{ + "access_token": "test-token", + "model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}, + }, + } + err := json.Unmarshal([]byte(fmt.Sprintf(`{"upstream_model_metadata":{ + "source":"upstream","models":{"gpt-6-astra":{ + "id":"gpt-6-astra","reasoning":true, + "supported_reasoning_levels":["high"],"input_modalities":["text","image"],%s + }} + }}`, fields)), &account.Extra) + require.NoError(t, err) + return account +} + +func codexContextWindowManifest(t *testing.T, accounts []Account) map[string]any { + t.Helper() + const groupID int64 = 7042 + svc := &GatewayService{accountRepo: codexModelsVisibilityAccountRepo{byGroup: map[int64][]Account{ + groupID: accounts, + }}} + body, err := svc.BuildCodexModelsManifestForGroup( + context.Background(), &Group{ID: groupID, Platform: PlatformOpenAI}, "", []string{"gpt-6-astra"}, + ) + require.NoError(t, err) + models := decodeCodexManifestModels(t, body) + require.Len(t, models, 1) + return models[0] +} diff --git a/backend/internal/service/openai_codex_model_metadata.go b/backend/internal/service/openai_codex_model_metadata.go index 445b3b7899..abfe94ef37 100644 --- a/backend/internal/service/openai_codex_model_metadata.go +++ b/backend/internal/service/openai_codex_model_metadata.go @@ -350,6 +350,21 @@ func intersectUpstreamModelMetadata(modelID string, candidates []UpstreamModelMe if !contextKnown { result.ContextWindow = 0 } + for i, candidate := range candidates { + maxContextWindow := candidate.MaxContextWindow + if maxContextWindow <= 0 { + // Older snapshots only stored the default window. Keep that bound + // until a sync supplies the upstream's explicit maximum. + maxContextWindow = candidate.ContextWindow + } + if maxContextWindow <= 0 { + result.MaxContextWindow = 0 + break + } + if i == 0 || maxContextWindow < result.MaxContextWindow { + result.MaxContextWindow = maxContextWindow + } + } return result } @@ -405,6 +420,10 @@ func applyUpstreamModelMetadataToCodexDescriptor( descriptor.ContextWindow = metadata.ContextWindow descriptor.MaxContextWindow = metadata.ContextWindow } + if metadata.MaxContextWindow > 0 { + descriptor.MaxContextWindow = metadata.MaxContextWindow + descriptor.ContextWindow = min(descriptor.ContextWindow, metadata.MaxContextWindow) + } } func configuredCodexReasoningLevelDescription(level string) string { diff --git a/backend/internal/service/upstream_models.go b/backend/internal/service/upstream_models.go index 3f2795c4f5..23ae22fa5d 100644 --- a/backend/internal/service/upstream_models.go +++ b/backend/internal/service/upstream_models.go @@ -36,6 +36,7 @@ type UpstreamModelMetadata struct { SupportedReasoningLevels []string `json:"supported_reasoning_levels,omitempty"` InputModalities []string `json:"input_modalities,omitempty"` ContextWindow int64 `json:"context_window,omitempty"` + MaxContextWindow int64 `json:"max_context_window,omitempty"` MaxOutputTokens int64 `json:"max_output_tokens,omitempty"` CodexToolCapabilities map[string]json.RawMessage `json:"codex_tool_capabilities,omitempty"` } @@ -389,6 +390,7 @@ func upstreamModelMetadataIsUseful(metadata UpstreamModelMetadata) bool { len(metadata.InputModalities) > 0 || len(metadata.CodexToolCapabilities) > 0 || metadata.ContextWindow > 0 || + metadata.MaxContextWindow > 0 || metadata.MaxOutputTokens > 0 } @@ -472,6 +474,11 @@ func mergeUpstreamModelMetadata(primary, fallback UpstreamModelMetadata) (Upstre } if merged.ContextWindow <= 0 && fallback.ContextWindow > 0 { merged.ContextWindow = fallback.ContextWindow + // Keep the registry's context limits together. A direct upstream default + // without an explicit maximum remains the conservative ceiling. + if merged.MaxContextWindow <= 0 { + merged.MaxContextWindow = fallback.MaxContextWindow + } changed = true } if merged.MaxOutputTokens <= 0 && fallback.MaxOutputTokens > 0 { @@ -582,6 +589,7 @@ func upstreamMetadataFromModelsDevModel(modelID string, model modelsDevModel) Up SupportedReasoningLevels: levels, InputModalities: normalizeCodexInputModalities(model.Modalities.Input), ContextWindow: model.Limit.Context, + MaxContextWindow: model.Limit.Context, MaxOutputTokens: model.Limit.Output, } if len(levels) > 0 { @@ -1360,6 +1368,7 @@ func upstreamMetadataFromCapabilityEntry(modelID string, entry upstreamModelCapa SupportedReasoningLevels: levels, InputModalities: normalizeCodexInputModalities(modalities), ContextWindow: contextWindow, + MaxContextWindow: entry.MaxContextWindow, MaxOutputTokens: maxOutputTokens, } } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 9f4506182c..7e6e04280f 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -620,6 +620,7 @@ export interface UpstreamModelMetadata { supported_reasoning_levels?: string[] input_modalities?: string[] context_window?: number + max_context_window?: number max_output_tokens?: number }