From 3a436e116aa4dee08818b5e4143725d3f464c5e4 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 28 Dec 2025 03:06:51 +0800 Subject: [PATCH] feat(cliproxy): implement model aliasing and hashing for Codex configurations, enhance request routing logic, and normalize Codex model entries --- config.example.yaml | 3 + .../api/handlers/management/config_lists.go | 49 ++++++-- internal/config/config.go | 12 ++ internal/runtime/executor/codex_executor.go | 116 +++++++++++++++++- internal/watcher/diff/model_hash.go | 15 +++ internal/watcher/diff/model_hash_test.go | 35 ++++++ internal/watcher/synthesizer/config.go | 3 + sdk/cliproxy/service.go | 41 +++++++ 8 files changed, 262 insertions(+), 12 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index f0af19dc..85e00650 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -104,6 +104,9 @@ ws-auth: false # headers: # X-Custom-Header: "custom-value" # proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# models: +# - name: "gpt-5-codex" # upstream model name +# alias: "codex-latest" # client alias mapped to the upstream model # excluded-models: # - "gpt-5.1" # exclude specific models (exact match) # - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 7e42b64b..cc99ce3a 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -597,11 +597,7 @@ func (h *Handler) PutCodexKeys(c *gin.Context) { filtered := make([]config.CodexKey, 0, len(arr)) for i := range arr { entry := arr[i] - entry.APIKey = strings.TrimSpace(entry.APIKey) - entry.BaseURL = strings.TrimSpace(entry.BaseURL) - entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) - entry.Headers = config.NormalizeHeaders(entry.Headers) - entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) + normalizeCodexKey(&entry) if entry.BaseURL == "" { continue } @@ -613,12 +609,13 @@ func (h *Handler) PutCodexKeys(c *gin.Context) { } func (h *Handler) PatchCodexKey(c *gin.Context) { type codexKeyPatch struct { - APIKey *string `json:"api-key"` - Prefix *string `json:"prefix"` - BaseURL *string `json:"base-url"` - ProxyURL *string `json:"proxy-url"` - Headers *map[string]string `json:"headers"` - ExcludedModels *[]string `json:"excluded-models"` + APIKey *string `json:"api-key"` + Prefix *string `json:"prefix"` + BaseURL *string `json:"base-url"` + ProxyURL *string `json:"proxy-url"` + Models *[]config.CodexModel `json:"models"` + Headers *map[string]string `json:"headers"` + ExcludedModels *[]string `json:"excluded-models"` } var body struct { Index *int `json:"index"` @@ -667,12 +664,16 @@ func (h *Handler) PatchCodexKey(c *gin.Context) { if body.Value.ProxyURL != nil { entry.ProxyURL = strings.TrimSpace(*body.Value.ProxyURL) } + if body.Value.Models != nil { + entry.Models = append([]config.CodexModel(nil), (*body.Value.Models)...) + } if body.Value.Headers != nil { entry.Headers = config.NormalizeHeaders(*body.Value.Headers) } if body.Value.ExcludedModels != nil { entry.ExcludedModels = config.NormalizeExcludedModels(*body.Value.ExcludedModels) } + normalizeCodexKey(&entry) h.cfg.CodexKey[targetIndex] = entry h.cfg.SanitizeCodexKeys() h.persist(c) @@ -762,6 +763,32 @@ func normalizeClaudeKey(entry *config.ClaudeKey) { entry.Models = normalized } +func normalizeCodexKey(entry *config.CodexKey) { + if entry == nil { + return + } + entry.APIKey = strings.TrimSpace(entry.APIKey) + entry.Prefix = strings.TrimSpace(entry.Prefix) + entry.BaseURL = strings.TrimSpace(entry.BaseURL) + entry.ProxyURL = strings.TrimSpace(entry.ProxyURL) + entry.Headers = config.NormalizeHeaders(entry.Headers) + entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels) + if len(entry.Models) == 0 { + return + } + normalized := make([]config.CodexModel, 0, len(entry.Models)) + for i := range entry.Models { + model := entry.Models[i] + model.Name = strings.TrimSpace(model.Name) + model.Alias = strings.TrimSpace(model.Alias) + if model.Name == "" && model.Alias == "" { + continue + } + normalized = append(normalized, model) + } + entry.Models = normalized +} + // GetAmpCode returns the complete ampcode configuration. func (h *Handler) GetAmpCode(c *gin.Context) { if h == nil || h.cfg == nil { diff --git a/internal/config/config.go b/internal/config/config.go index 6b4b161d..dea56dff 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -253,6 +253,9 @@ type CodexKey struct { // ProxyURL overrides the global proxy setting for this API key if provided. ProxyURL string `yaml:"proxy-url" json:"proxy-url"` + // Models defines upstream model names and aliases for request routing. + Models []CodexModel `yaml:"models" json:"models"` + // Headers optionally adds extra HTTP headers for requests sent with this key. Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` @@ -260,6 +263,15 @@ type CodexKey struct { ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"` } +// CodexModel describes a mapping between an alias and the actual upstream model name. +type CodexModel struct { + // Name is the upstream model identifier used when issuing requests. + Name string `yaml:"name" json:"name"` + + // Alias is the client-facing model name that maps to Name. + Alias string `yaml:"alias" json:"alias"` +} + // GeminiKey represents the configuration for a Gemini API key, // including optional overrides for upstream base URL, proxy routing, and headers. type GeminiKey struct { diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go index c3e14701..310988c1 100644 --- a/internal/runtime/executor/codex_executor.go +++ b/internal/runtime/executor/codex_executor.go @@ -50,6 +50,16 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re defer reporter.trackFailure(ctx, &err) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) + if upstreamModel == "" { + upstreamModel = req.Model + } + if modelOverride := e.resolveUpstreamModel(upstreamModel, auth); modelOverride != "" { + upstreamModel = modelOverride + } else if !strings.EqualFold(upstreamModel, req.Model) { + if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + upstreamModel = modelOverride + } + } from := opts.SourceFormat to := sdktranslator.FromString("codex") @@ -147,6 +157,16 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au defer reporter.trackFailure(ctx, &err) upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) + if upstreamModel == "" { + upstreamModel = req.Model + } + if modelOverride := e.resolveUpstreamModel(upstreamModel, auth); modelOverride != "" { + upstreamModel = modelOverride + } else if !strings.EqualFold(upstreamModel, req.Model) { + if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + upstreamModel = modelOverride + } + } from := opts.SourceFormat to := sdktranslator.FromString("codex") @@ -247,12 +267,22 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata) + if upstreamModel == "" { + upstreamModel = req.Model + } + if modelOverride := e.resolveUpstreamModel(upstreamModel, auth); modelOverride != "" { + upstreamModel = modelOverride + } else if !strings.EqualFold(upstreamModel, req.Model) { + if modelOverride := e.resolveUpstreamModel(req.Model, auth); modelOverride != "" { + upstreamModel = modelOverride + } + } from := opts.SourceFormat to := sdktranslator.FromString("codex") body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) - modelForCounting := req.Model + modelForCounting := upstreamModel body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false) body, _ = sjson.SetBytes(body, "model", upstreamModel) @@ -520,3 +550,87 @@ func codexCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) { } return } + +func (e *CodexExecutor) resolveUpstreamModel(alias string, auth *cliproxyauth.Auth) string { + trimmed := strings.TrimSpace(alias) + if trimmed == "" { + return "" + } + + entry := e.resolveCodexConfig(auth) + if entry == nil { + return "" + } + + normalizedModel, metadata := util.NormalizeThinkingModel(trimmed) + + // Candidate names to match against configured aliases/names. + candidates := []string{strings.TrimSpace(normalizedModel)} + if !strings.EqualFold(normalizedModel, trimmed) { + candidates = append(candidates, trimmed) + } + if original := util.ResolveOriginalModel(normalizedModel, metadata); original != "" && !strings.EqualFold(original, normalizedModel) { + candidates = append(candidates, original) + } + + for i := range entry.Models { + model := entry.Models[i] + name := strings.TrimSpace(model.Name) + modelAlias := strings.TrimSpace(model.Alias) + + for _, candidate := range candidates { + if candidate == "" { + continue + } + if modelAlias != "" && strings.EqualFold(modelAlias, candidate) { + if name != "" { + return name + } + return candidate + } + if name != "" && strings.EqualFold(name, candidate) { + return name + } + } + } + return "" +} + +func (e *CodexExecutor) resolveCodexConfig(auth *cliproxyauth.Auth) *config.CodexKey { + if auth == nil || e.cfg == nil { + return nil + } + var attrKey, attrBase string + if auth.Attributes != nil { + attrKey = strings.TrimSpace(auth.Attributes["api_key"]) + attrBase = strings.TrimSpace(auth.Attributes["base_url"]) + } + for i := range e.cfg.CodexKey { + entry := &e.cfg.CodexKey[i] + cfgKey := strings.TrimSpace(entry.APIKey) + cfgBase := strings.TrimSpace(entry.BaseURL) + if attrKey != "" && attrBase != "" { + if strings.EqualFold(cfgKey, attrKey) && strings.EqualFold(cfgBase, attrBase) { + return entry + } + continue + } + if attrKey != "" && strings.EqualFold(cfgKey, attrKey) { + if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) { + return entry + } + } + if attrKey != "" { + for i := range e.cfg.CodexKey { + entry := &e.cfg.CodexKey[i] + if strings.EqualFold(strings.TrimSpace(entry.APIKey), attrKey) { + return entry + } + } + } + return nil +} diff --git a/internal/watcher/diff/model_hash.go b/internal/watcher/diff/model_hash.go index a8b1aba6..a224bdca 100644 --- a/internal/watcher/diff/model_hash.go +++ b/internal/watcher/diff/model_hash.go @@ -56,6 +56,21 @@ func ComputeClaudeModelsHash(models []config.ClaudeModel) string { return hashJoined(keys) } +// ComputeCodexModelsHash returns a stable hash for Codex model aliases. +func ComputeCodexModelsHash(models []config.CodexModel) string { + keys := normalizeModelPairs(func(out func(key string)) { + for _, model := range models { + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if name == "" && alias == "" { + continue + } + out(strings.ToLower(name) + "|" + strings.ToLower(alias)) + } + }) + return hashJoined(keys) +} + // ComputeExcludedModelsHash returns a normalized hash for excluded model lists. func ComputeExcludedModelsHash(excluded []string) string { if len(excluded) == 0 { diff --git a/internal/watcher/diff/model_hash_test.go b/internal/watcher/diff/model_hash_test.go index a7046080..db06ebd1 100644 --- a/internal/watcher/diff/model_hash_test.go +++ b/internal/watcher/diff/model_hash_test.go @@ -81,6 +81,15 @@ func TestComputeClaudeModelsHash_Empty(t *testing.T) { } } +func TestComputeCodexModelsHash_Empty(t *testing.T) { + if got := ComputeCodexModelsHash(nil); got != "" { + t.Fatalf("expected empty hash for nil models, got %q", got) + } + if got := ComputeCodexModelsHash([]config.CodexModel{}); got != "" { + t.Fatalf("expected empty hash for empty slice, got %q", got) + } +} + func TestComputeClaudeModelsHash_IgnoresBlankAndDedup(t *testing.T) { a := []config.ClaudeModel{ {Name: "m1", Alias: "a1"}, @@ -95,6 +104,20 @@ func TestComputeClaudeModelsHash_IgnoresBlankAndDedup(t *testing.T) { } } +func TestComputeCodexModelsHash_IgnoresBlankAndDedup(t *testing.T) { + a := []config.CodexModel{ + {Name: "m1", Alias: "a1"}, + {Name: " "}, + {Name: "M1", Alias: "A1"}, + } + b := []config.CodexModel{ + {Name: "m1", Alias: "a1"}, + } + if h1, h2 := ComputeCodexModelsHash(a), ComputeCodexModelsHash(b); h1 == "" || h1 != h2 { + t.Fatalf("expected same hash ignoring blanks/dupes, got %q / %q", h1, h2) + } +} + func TestComputeExcludedModelsHash_Normalizes(t *testing.T) { hash1 := ComputeExcludedModelsHash([]string{" A ", "b", "a"}) hash2 := ComputeExcludedModelsHash([]string{"a", " b", "A"}) @@ -157,3 +180,15 @@ func TestComputeClaudeModelsHash_Deterministic(t *testing.T) { t.Fatalf("expected different hash when models change, got %s", h3) } } + +func TestComputeCodexModelsHash_Deterministic(t *testing.T) { + models := []config.CodexModel{{Name: "a", Alias: "A"}, {Name: "b"}} + h1 := ComputeCodexModelsHash(models) + h2 := ComputeCodexModelsHash(models) + if h1 == "" || h1 != h2 { + t.Fatalf("expected deterministic hash, got %s / %s", h1, h2) + } + if h3 := ComputeCodexModelsHash([]config.CodexModel{{Name: "a"}}); h3 == h1 { + t.Fatalf("expected different hash when models change, got %s", h3) + } +} diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 4b19f2f3..e7c845a1 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -147,6 +147,9 @@ func (s *ConfigSynthesizer) synthesizeCodexKeys(ctx *SynthesisContext) []*coreau if ck.BaseURL != "" { attrs["base_url"] = ck.BaseURL } + if hash := diff.ComputeCodexModelsHash(ck.Models); hash != "" { + attrs["models_hash"] = hash + } addConfigHeadersToAttrs(ck.Headers, attrs) proxyURL := strings.TrimSpace(ck.ProxyURL) a := &coreauth.Auth{ diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index a699ca61..6e81e401 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -741,6 +741,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { case "codex": models = registry.GetOpenAIModels() if entry := s.resolveConfigCodexKey(a); entry != nil { + if len(entry.Models) > 0 { + models = buildCodexConfigModels(entry) + } if authKind == "apikey" { excluded = entry.ExcludedModels } @@ -1179,3 +1182,41 @@ func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo { } return out } + +func buildCodexConfigModels(entry *config.CodexKey) []*ModelInfo { + if entry == nil || len(entry.Models) == 0 { + return nil + } + now := time.Now().Unix() + out := make([]*ModelInfo, 0, len(entry.Models)) + seen := make(map[string]struct{}, len(entry.Models)) + for i := range entry.Models { + model := entry.Models[i] + name := strings.TrimSpace(model.Name) + alias := strings.TrimSpace(model.Alias) + if alias == "" { + alias = name + } + if alias == "" { + continue + } + key := strings.ToLower(alias) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + display := name + if display == "" { + display = alias + } + out = append(out, &ModelInfo{ + ID: alias, + Object: "model", + Created: now, + OwnedBy: "openai", + Type: "openai", + DisplayName: display, + }) + } + return out +}