From a303fd869b03df43d8b70cd4eb5cff44ad526715 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Mon, 3 Aug 2026 03:01:43 +0800 Subject: [PATCH] feat(codex): support `max-context-length` overrides for configured models - Add optional `max-context-length` model configuration across supported provider model types and expose it via `GetMaxContextLength`. - Propagate the override into model metadata so Codex/client model catalog responses honor the configured value (`context_window`, `max_context_window`, and `max_context_length`). - Update example configuration with documented usage of the new option. Closes: #4728 --- config.example.yaml | 6 ++ internal/client/codex/models/models.go | 12 +++ internal/client/codex/models/models_test.go | 40 ++++++++ internal/config/config_types.go | 36 +++++--- internal/config/max_context_length_test.go | 86 +++++++++++++++++ internal/registry/model_registry.go | 6 ++ .../registry/model_registry_safety_test.go | 21 +++++ .../config_model_max_context_length_test.go | 92 +++++++++++++++++++ sdk/cliproxy/service_models.go | 13 ++- 9 files changed, 299 insertions(+), 13 deletions(-) create mode 100644 internal/config/max_context_length_test.go create mode 100644 sdk/cliproxy/config_model_max_context_length_test.go diff --git a/config.example.yaml b/config.example.yaml index de4ff9c0..4be7277b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -295,6 +295,7 @@ nonstream-keepalive-interval: 0 # - name: "gemini-2.5-flash" # upstream model name # alias: "gemini-flash" # client alias mapped to the upstream model # display-name: "Gemini Flash" # optional catalog display name +# max-context-length: 1048576 # optional: override Codex client context window metadata # thinking: # optional: exact thinking capability for this configured model # levels: ["high", "medium", "low", "none", "auto"] # excluded-models: @@ -320,6 +321,7 @@ nonstream-keepalive-interval: 0 # models: # - name: "gemini-2.5-flash" # upstream model name # alias: "native-gemini-flash" # client alias mapped to the upstream model +# max-context-length: 1048576 # optional: override Codex client context window metadata # thinking: # optional: exact thinking capability for this configured model # levels: ["high", "medium", "low", "none", "auto"] # excluded-models: @@ -341,6 +343,7 @@ nonstream-keepalive-interval: 0 # - name: "gpt-5-codex" # upstream model name # alias: "codex-latest" # client alias mapped to the upstream model # display-name: "Codex Latest" # optional catalog display name +# max-context-length: 1048576 # optional: override Codex client context window metadata # force-mapping: true # optional: rewrite response model fields back to the alias # thinking: # optional: exact thinking capability for this configured model # levels: ["xhigh", "high", "medium", "low"] @@ -367,6 +370,7 @@ nonstream-keepalive-interval: 0 # - name: "grok-4.5" # upstream model name # alias: "grok-latest" # client alias mapped to the upstream model # display-name: "Grok Latest" # optional catalog display name +# max-context-length: 1048576 # optional: override Codex client context window metadata # force-mapping: true # optional: rewrite response model fields back to the alias # thinking: # optional: exact thinking capability for this configured model # levels: ["xhigh", "high", "medium", "low"] @@ -390,6 +394,7 @@ nonstream-keepalive-interval: 0 # - name: "claude-3-5-sonnet-20241022" # upstream model name # alias: "claude-sonnet-latest" # client alias mapped to the upstream model # display-name: "Claude Sonnet" # optional catalog display name +# max-context-length: 1048576 # optional: override Codex client context window metadata # force-mapping: true # optional: rewrite response model fields back to the alias # thinking: # optional: exact thinking capability for this configured model # levels: ["max", "xhigh", "high", "medium", "low", "minimal", "none", "auto"] @@ -459,6 +464,7 @@ nonstream-keepalive-interval: 0 # - name: "moonshotai/kimi-k2:free" # The actual model name. # alias: "kimi-k2" # The alias used in the API. # display-name: "Kimi K2" # optional catalog display name +# max-context-length: 1048576 # optional: override Codex client context window metadata # image: false # optional: set true to allow this model on /v1/images/generations and /v1/images/edits (not chat/responses image input) # input-modalities: [text, image] # optional: declare /v1/chat/completions and /v1/responses multimodal input for Codex clients # output-modalities: [text] # optional: declare output modalities when known diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go index 289ca448..c1025015 100644 --- a/internal/client/codex/models/models.go +++ b/internal/client/codex/models/models.go @@ -60,6 +60,7 @@ func buildCodexClientModels(models []map[string]any, providersForModel Providers if template, ok := templates[id]; ok { entry := cloneCodexClientModelMap(template) applyCodexClientDisplayName(entry, model) + applyCodexClientMaxContextLengthOverride(entry, model) applyCodexClientSearchToolSupport(entry, id, true, providersForModel) sanitizeCodexClientReasoningMetadata(entry) applyCodexClientVisibilityOverride(entry, id) @@ -182,6 +183,13 @@ func applyCodexClientDisplayName(entry map[string]any, model map[string]any) { } } +func applyCodexClientMaxContextLengthOverride(entry map[string]any, model map[string]any) { + if maxContextLength := intModelValue(model, "max_context_length"); maxContextLength > 0 { + entry["context_window"] = maxContextLength + entry["max_context_window"] = maxContextLength + } +} + func applyCodexClientSearchToolSupport(entry map[string]any, id string, templateModel bool, providersForModel ProvidersForModelFunc) { supportsSearch, _ := entry["supports_search_tool"].(bool) if !supportsSearch { @@ -237,6 +245,10 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st applyCodexClientThinkingMetadata(entry, info.Thinking) } + if maxContextWindow := intModelValue(model, "max_context_length"); maxContextWindow > 0 { + contextWindow = maxContextWindow + } + if displayName == "" { displayName = id } diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go index c9c371d7..59917e16 100644 --- a/internal/client/codex/models/models_test.go +++ b/internal/client/codex/models/models_test.go @@ -308,3 +308,43 @@ func TestApplyCodexClientModelMetadataPreservesMultiAgentVersionWhenDisabled(t * t.Fatalf("enabled multi_agent_version = %#v, want v2", got) } } + +func TestCodexClientModelsResponseAppliesMaxContextLengthOverride(t *testing.T) { + const wantOverride = 1048576 + const wantDefault = 272000 + + resp := BuildResponse([]map[string]any{ + {"id": "deepseek-v4-flash", "max_context_length": wantOverride}, + {"id": "deepseek-v4-pro"}, + {"id": "gpt-5.5", "max_context_length": wantOverride}, + }, nil, false) + models, ok := resp["models"].([]map[string]any) + if !ok { + t.Fatalf("models type = %T, want []map[string]any", resp["models"]) + } + + bySlug := make(map[string]map[string]any, len(models)) + for _, model := range models { + bySlug[stringModelValue(model, "slug")] = model + } + + for _, testCase := range []struct { + slug string + want int + }{ + {slug: "deepseek-v4-flash", want: wantOverride}, + {slug: "deepseek-v4-pro", want: wantDefault}, + {slug: "gpt-5.5", want: wantOverride}, + } { + entry := bySlug[testCase.slug] + if entry == nil { + t.Fatalf("missing model %q", testCase.slug) + } + if got := intModelValue(entry, "context_window"); got != testCase.want { + t.Errorf("%s context_window = %d, want %d", testCase.slug, got, testCase.want) + } + if got := intModelValue(entry, "max_context_window"); got != testCase.want { + t.Errorf("%s max_context_window = %d, want %d", testCase.slug, got, testCase.want) + } + } +} diff --git a/internal/config/config_types.go b/internal/config/config_types.go index e6609e5b..3eb61141 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -376,6 +376,9 @@ type ClaudeModel struct { // DisplayName is the optional human-readable name shown in model catalogs. DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // MaxContextLength overrides the context window advertised to Codex clients. + MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` @@ -387,9 +390,9 @@ func (m ClaudeModel) GetName() string { return m.Name } func (m ClaudeModel) GetAlias() string { return m.Alias } -func (m ClaudeModel) GetDisplayName() string { return m.DisplayName } - -func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping } +func (m ClaudeModel) GetDisplayName() string { return m.DisplayName } +func (m ClaudeModel) GetMaxContextLength() int { return m.MaxContextLength } +func (m ClaudeModel) GetForceMapping() bool { return m.ForceMapping } func (m ClaudeModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } @@ -455,6 +458,9 @@ type CodexModel struct { // DisplayName is the optional human-readable name shown in model catalogs. DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // MaxContextLength overrides the context window advertised to Codex clients. + MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` @@ -466,9 +472,9 @@ func (m CodexModel) GetName() string { return m.Name } func (m CodexModel) GetAlias() string { return m.Alias } -func (m CodexModel) GetDisplayName() string { return m.DisplayName } - -func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } +func (m CodexModel) GetDisplayName() string { return m.DisplayName } +func (m CodexModel) GetMaxContextLength() int { return m.MaxContextLength } +func (m CodexModel) GetForceMapping() bool { return m.ForceMapping } func (m CodexModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } @@ -533,6 +539,9 @@ type GeminiModel struct { // DisplayName is the optional human-readable name shown in model catalogs. DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // MaxContextLength overrides the context window advertised to Codex clients. + MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` @@ -544,9 +553,9 @@ func (m GeminiModel) GetName() string { return m.Name } func (m GeminiModel) GetAlias() string { return m.Alias } -func (m GeminiModel) GetDisplayName() string { return m.DisplayName } - -func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping } +func (m GeminiModel) GetDisplayName() string { return m.DisplayName } +func (m GeminiModel) GetMaxContextLength() int { return m.MaxContextLength } +func (m GeminiModel) GetForceMapping() bool { return m.ForceMapping } func (m GeminiModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } @@ -610,6 +619,9 @@ type OpenAICompatibilityModel struct { // DisplayName is the optional human-readable name shown in model catalogs. DisplayName string `yaml:"display-name,omitempty" json:"display-name,omitempty"` + // MaxContextLength overrides the context window advertised to Codex clients. + MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"` + // ForceMapping rewrites upstream response model fields back to Alias. ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` @@ -632,8 +644,8 @@ func (m OpenAICompatibilityModel) GetName() string { return m.Name } func (m OpenAICompatibilityModel) GetAlias() string { return m.Alias } -func (m OpenAICompatibilityModel) GetDisplayName() string { return m.DisplayName } - -func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping } +func (m OpenAICompatibilityModel) GetDisplayName() string { return m.DisplayName } +func (m OpenAICompatibilityModel) GetMaxContextLength() int { return m.MaxContextLength } +func (m OpenAICompatibilityModel) GetForceMapping() bool { return m.ForceMapping } func (m OpenAICompatibilityModel) GetThinking() *registry.ThinkingSupport { return m.Thinking } diff --git a/internal/config/max_context_length_test.go b/internal/config/max_context_length_test.go new file mode 100644 index 00000000..16b40688 --- /dev/null +++ b/internal/config/max_context_length_test.go @@ -0,0 +1,86 @@ +package config + +import ( + "encoding/json" + "testing" + + "gopkg.in/yaml.v3" +) + +func TestMaxContextLengthConfigDecoding(t *testing.T) { + const want = 1048576 + const yamlConfig = `codex-api-key: + - models: + - name: codex-upstream + alias: codex-alias + max-context-length: 1048576 +claude-api-key: + - models: + - name: claude-upstream + alias: claude-alias + max-context-length: 1048576 +gemini-api-key: + - models: + - name: gemini-upstream + alias: gemini-alias + max-context-length: 1048576 +interactions-api-key: + - models: + - name: interactions-upstream + alias: interactions-alias + max-context-length: 1048576 +xai-api-key: + - models: + - name: xai-upstream + alias: xai-alias + max-context-length: 1048576 +openai-compatibility: + - models: + - name: compat-upstream + alias: compat-alias + max-context-length: 1048576 +` + const jsonConfig = `{"codex-api-key":[{"models":[{"name":"codex-upstream","alias":"codex-alias","max-context-length":1048576}]}],"claude-api-key":[{"models":[{"name":"claude-upstream","alias":"claude-alias","max-context-length":1048576}]}],"gemini-api-key":[{"models":[{"name":"gemini-upstream","alias":"gemini-alias","max-context-length":1048576}]}],"interactions-api-key":[{"models":[{"name":"interactions-upstream","alias":"interactions-alias","max-context-length":1048576}]}],"xai-api-key":[{"models":[{"name":"xai-upstream","alias":"xai-alias","max-context-length":1048576}]}],"openai-compatibility":[{"models":[{"name":"compat-upstream","alias":"compat-alias","max-context-length":1048576}]}]}` + + for _, testCase := range []struct { + name string + decode func(*Config) error + }{ + { + name: "YAML", + decode: func(cfg *Config) error { + return yaml.Unmarshal([]byte(yamlConfig), cfg) + }, + }, + { + name: "JSON", + decode: func(cfg *Config) error { + return json.Unmarshal([]byte(jsonConfig), cfg) + }, + }, + } { + t.Run(testCase.name, func(t *testing.T) { + var cfg Config + if errDecode := testCase.decode(&cfg); errDecode != nil { + t.Fatalf("decode config: %v", errDecode) + } + + models := []struct { + name string + got int + }{ + {name: "codex", got: cfg.CodexKey[0].Models[0].MaxContextLength}, + {name: "claude", got: cfg.ClaudeKey[0].Models[0].MaxContextLength}, + {name: "gemini", got: cfg.GeminiKey[0].Models[0].MaxContextLength}, + {name: "interactions", got: cfg.InteractionsKey[0].Models[0].MaxContextLength}, + {name: "xai", got: cfg.XAIKey[0].Models[0].MaxContextLength}, + {name: "openai compatibility", got: cfg.OpenAICompatibility[0].Models[0].MaxContextLength}, + } + for _, model := range models { + if model.got != want { + t.Errorf("%s max-context-length = %d, want %d", model.name, model.got, want) + } + } + }) + } +} diff --git a/internal/registry/model_registry.go b/internal/registry/model_registry.go index 1bc2715d..8f559d24 100644 --- a/internal/registry/model_registry.go +++ b/internal/registry/model_registry.go @@ -51,6 +51,9 @@ type ModelInfo struct { SupportedGenerationMethods []string `json:"supportedGenerationMethods,omitempty"` // ContextLength is the context window size ContextLength int `json:"context_length,omitempty"` + // MaxContextLength is an explicit per-model context window override from configuration. + // It is carried internally for Codex client model catalog generation. + MaxContextLength int `json:"-"` // MaxCompletionTokens is the maximum completion tokens MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // SupportedParameters lists supported parameters @@ -1187,6 +1190,9 @@ func (r *ModelRegistry) convertModelToMap(model *ModelInfo, handlerType string) if model.ContextLength > 0 { result["context_length"] = model.ContextLength } + if model.MaxContextLength > 0 { + result["max_context_length"] = model.MaxContextLength + } if model.MaxCompletionTokens > 0 { result["max_completion_tokens"] = model.MaxCompletionTokens } diff --git a/internal/registry/model_registry_safety_test.go b/internal/registry/model_registry_safety_test.go index e84671c5..1df76d1f 100644 --- a/internal/registry/model_registry_safety_test.go +++ b/internal/registry/model_registry_safety_test.go @@ -135,6 +135,27 @@ func TestGetAvailableModelsReturnsClonedSupportedParameters(t *testing.T) { } } +func TestGetAvailableModelsIncludesMaxContextLengthOverride(t *testing.T) { + r := newTestModelRegistry() + const want = 1048576 + r.RegisterClient("client-1", "openai", []*ModelInfo{{ + ID: "deepseek-v4-flash", + ContextLength: want, + MaxContextLength: want, + }}) + + models := r.GetAvailableModels("openai") + if len(models) != 1 { + t.Fatalf("models length = %d, want 1", len(models)) + } + if got := models[0]["context_length"]; got != want { + t.Fatalf("context_length = %#v, want %d", got, want) + } + if got := models[0]["max_context_length"]; got != want { + t.Fatalf("max_context_length = %#v, want %d", got, want) + } +} + func TestLookupModelInfoReturnsCloneForStaticDefinitions(t *testing.T) { first := LookupModelInfo("claude-sonnet-4-6") if first == nil || first.Thinking == nil || len(first.Thinking.Levels) == 0 { diff --git a/sdk/cliproxy/config_model_max_context_length_test.go b/sdk/cliproxy/config_model_max_context_length_test.go new file mode 100644 index 00000000..aff0edc5 --- /dev/null +++ b/sdk/cliproxy/config_model_max_context_length_test.go @@ -0,0 +1,92 @@ +package cliproxy + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/config" +) + +func TestBuildConfigModelsPropagatesMaxContextLength(t *testing.T) { + const want = 1048576 + + tests := []struct { + name string + got func() *ModelInfo + }{ + { + name: "codex", + got: func() *ModelInfo { + return buildCodexConfigModels(&config.CodexKey{ + Models: []config.CodexModel{{ + Name: "codex-upstream", Alias: "codex-alias", MaxContextLength: want, + }}, + })[0] + }, + }, + { + name: "claude", + got: func() *ModelInfo { + return buildClaudeConfigModels(&config.ClaudeKey{ + Models: []config.ClaudeModel{{ + Name: "claude-upstream", Alias: "claude-alias", MaxContextLength: want, + }}, + })[0] + }, + }, + { + name: "gemini", + got: func() *ModelInfo { + return buildGeminiConfigModels(&config.GeminiKey{ + Models: []config.GeminiModel{{ + Name: "gemini-upstream", Alias: "gemini-alias", MaxContextLength: want, + }}, + })[0] + }, + }, + { + name: "interactions", + got: func() *ModelInfo { + return buildGeminiConfigModels(&config.GeminiKey{ + Models: []config.GeminiModel{{ + Name: "interactions-upstream", Alias: "interactions-alias", MaxContextLength: want, + }}, + })[0] + }, + }, + { + name: "xai", + got: func() *ModelInfo { + return buildXAIConfigModels(&config.XAIKey{ + Models: []config.XAIModel{{ + Name: "xai-upstream", Alias: "xai-alias", MaxContextLength: want, + }}, + })[0] + }, + }, + { + name: "openai compatibility", + got: func() *ModelInfo { + return buildOpenAICompatibilityConfigModels(&config.OpenAICompatibility{ + Models: []config.OpenAICompatibilityModel{{ + Name: "compat-upstream", Alias: "compat-alias", MaxContextLength: want, + }}, + })[0] + }, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + model := testCase.got() + if model == nil { + t.Fatal("model = nil") + } + if model.ContextLength != want { + t.Errorf("context length = %d, want %d", model.ContextLength, want) + } + if model.MaxContextLength != want { + t.Errorf("max context length = %d, want %d", model.MaxContextLength, want) + } + }) + } +} diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index b0394dc3..8d07eed9 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -666,6 +666,10 @@ type modelEntry interface { GetThinking() *registry.ThinkingSupport } +type modelMaxContextLengthEntry interface { + GetMaxContextLength() int +} + func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, created int64, fallbackDisplayName string, userDefined bool) *ModelInfo { name := strings.TrimSpace(model.GetName()) alias := strings.TrimSpace(model.GetAlias()) @@ -682,7 +686,7 @@ func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, creat if displayName == "" { displayName = alias } - return &ModelInfo{ + info := &ModelInfo{ ID: alias, Object: "model", Created: created, @@ -691,6 +695,13 @@ func buildConfiguredModelInfo(model modelEntry, ownedBy, modelType string, creat DisplayName: displayName, UserDefined: userDefined, } + if maxContextModel, okMaxContext := any(model).(modelMaxContextLengthEntry); okMaxContext { + if maxContextLength := maxContextModel.GetMaxContextLength(); maxContextLength > 0 { + info.ContextLength = maxContextLength + info.MaxContextLength = maxContextLength + } + } + return info } func buildOpenAICompatibilityConfigModels(compat *config.OpenAICompatibility) []*ModelInfo { -- knotmirror 0.0.1