feat(api): add Codex client models support for OpenAI API

- Introduced Codex client models framework in `openai` package.
- Added JSON-based model definitions (`codex_client_models.json`) for Codex, including metadata, reasoning levels, and configuration options.
- Implemented handlers to load, clone, and build Codex client models with support for visibility overrides and metadata application.
- Enabled sorting and prioritization of models based on configuration or runtime criteria.
- Added utility functions for managing and validating model attributes.
This commit is contained in:
Luis Pater
2026-05-17 04:48:34 +08:00
parent 53d1fd6c5c
commit 088ab33df8
7 changed files with 1092 additions and 1 deletions
+37
View File
@@ -842,6 +842,15 @@ func (s *Server) watchKeepAlive() {
// otherwise it routes to OpenAI handler.
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
return func(c *gin.Context) {
if _, ok := c.Request.URL.Query()["client_version"]; ok {
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
s.handleHomeCodexClientModels(c)
return
}
openaiHandler.OpenAIModels(c)
return
}
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
s.handleHomeModels(c)
return
@@ -860,6 +869,34 @@ func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, cl
}
}
func (s *Server) handleHomeCodexClientModels(c *gin.Context) {
entries, ok := s.loadHomeModelEntries(c)
if !ok {
return
}
models := make([]map[string]any, 0, len(entries))
for _, entry := range entries {
model := map[string]any{
"id": entry.id,
"object": "model",
}
if entry.created > 0 {
model["created"] = entry.created
}
if entry.ownedBy != "" {
model["owned_by"] = entry.ownedBy
}
if entry.displayName != "" {
model["display_name"] = entry.displayName
model["description"] = entry.displayName
}
models = append(models, model)
}
c.JSON(http.StatusOK, openai.CodexClientModelsResponse(models))
}
func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc {
return func(c *gin.Context) {
if s != nil && s.cfg != nil && s.cfg.Home.Enabled {
+131
View File
@@ -14,6 +14,7 @@ import (
proxyconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
internallogging "github.com/router-for-me/CLIProxyAPI/v7/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v7/internal/redisqueue"
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v7/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config"
@@ -239,6 +240,136 @@ func TestAmpProviderModelRoutes(t *testing.T) {
}
}
func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) {
modelRegistry := registry.GetGlobalRegistry()
clientID := "test-client-version-catalog"
modelRegistry.RegisterClient(clientID, "openai", []*registry.ModelInfo{
{
ID: "gpt-5.5",
Object: "model",
Created: 1776902400,
OwnedBy: "openai",
Type: "openai",
DisplayName: "GPT 5.5",
Description: "Frontier model for complex coding, research, and real-world work.",
ContextLength: 272000,
Thinking: &registry.ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}},
},
{
ID: "custom-codex-model-test",
Object: "model",
OwnedBy: "test",
Type: "openai",
DisplayName: "Custom Codex Model",
Description: "Custom model from registry",
ContextLength: 123456,
Thinking: &registry.ThinkingSupport{Levels: []string{"low", "medium"}},
},
{ID: "grok-imagine-image-quality", Object: "model", OwnedBy: "xai", Type: "openai"},
{ID: "gpt-image-2", Object: "model", OwnedBy: "openai", Type: "openai"},
{ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", Type: "openai"},
{ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", Type: "openai"},
})
t.Cleanup(func() {
modelRegistry.UnregisterClient(clientID)
})
server := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/v1/models?client_version", nil)
req.Header.Set("Authorization", "Bearer test-key")
req.Header.Set("User-Agent", "claude-cli/1.0")
rr := httptest.NewRecorder()
server.engine.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d body=%s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Models []map[string]any `json:"models"`
Object string `json:"object"`
Data []any `json:"data"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to parse response JSON: %v; body=%s", err, rr.Body.String())
}
if resp.Object != "" || resp.Data != nil {
t.Fatalf("expected codex catalog format without object/data, got object=%q data=%v", resp.Object, resp.Data)
}
if len(resp.Models) == 0 {
t.Fatal("expected codex catalog models")
}
var gpt55 map[string]any
var custom map[string]any
for _, model := range resp.Models {
switch slug, _ := model["slug"].(string); slug {
case "gpt-5.5":
gpt55 = model
case "custom-codex-model-test":
custom = model
}
}
if gpt55 == nil {
t.Fatal("expected gpt-5.5 codex catalog entry")
}
if _, ok := gpt55["minimal_client_version"]; !ok {
t.Fatal("expected minimal_client_version in codex catalog")
}
serviceTiers, ok := gpt55["service_tiers"].([]any)
if !ok || len(serviceTiers) != 1 {
t.Fatalf("expected gpt-5.5 priority service tier, got %#v", gpt55["service_tiers"])
}
if custom == nil {
t.Fatal("expected custom model codex catalog entry")
}
if got, _ := custom["display_name"].(string); got != "Custom Codex Model" {
t.Fatalf("custom display_name = %q, want Custom Codex Model", got)
}
if got, _ := custom["description"].(string); got != "Custom model from registry" {
t.Fatalf("custom description = %q, want Custom model from registry", got)
}
if got, _ := custom["context_window"].(float64); got != 123456 {
t.Fatalf("custom context_window = %v, want 123456", custom["context_window"])
}
if custom["base_instructions"] != gpt55["base_instructions"] {
t.Fatal("expected custom model to use gpt-5.5 base_instructions fallback")
}
if _, ok := custom["available_in_plans"].([]any); !ok {
t.Fatalf("expected custom model to use gpt-5.5 available_in_plans fallback, got %#v", custom["available_in_plans"])
}
if got, _ := custom["prefer_websockets"].(bool); got {
t.Fatalf("custom prefer_websockets = %v, want false", custom["prefer_websockets"])
}
if _, ok := custom["apply_patch_tool_type"]; ok {
t.Fatal("expected custom model to omit apply_patch_tool_type")
}
hiddenModels := map[string]bool{
"grok-imagine-image-quality": false,
"gpt-image-2": false,
"grok-imagine-image": false,
"grok-imagine-video": false,
}
for _, model := range resp.Models {
slug, _ := model["slug"].(string)
if _, ok := hiddenModels[slug]; !ok {
continue
}
if visibility, _ := model["visibility"].(string); visibility != "hide" {
t.Fatalf("%s visibility = %q, want hide", slug, visibility)
}
hiddenModels[slug] = true
}
for slug, found := range hiddenModels {
if !found {
t.Fatalf("expected hidden model %s in codex catalog", slug)
}
}
}
func TestDefaultRequestLoggerFactory_UsesResolvedLogDirectory(t *testing.T) {
t.Setenv("WRITABLE_PATH", "")
t.Setenv("writable_path", "")