From 71d591296b939043ce5f47757ca9c914146029f5 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Fri, 24 Jul 2026 17:24:22 +0000 Subject: [PATCH] feat(models): add Codex client model catalog and response builder - Introduced a new `models` package for organizing Codex client model templates and building responses. - Migrated Codex response handling to `codexmodels.BuildResponse`. - Added comprehensive tests for model metadata, reasoning levels, and input modalities handling. --- internal/api/server.go | 3 ++- internal/client/codex/models/models.go | 476 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ internal/client/codex/models/models_test.go | 310 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ sdk/api/handlers/openai/codex_client_models.go | 478 ++++++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- sdk/api/handlers/openai/codex_client_models_test.go | 306 ++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- 5 file(s) changed, 796 insertion(s)(+), 777 deletion(s)(-) diff --git a/internal/api/server.go b/internal/api/server.go --- a/internal/api/server.go +++ b/internal/api/server.go @@ -28,6 +28,7 @@ managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" "github.com/router-for-me/CLIProxyAPI/v7/internal/api/middleware" "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" + codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models" "github.com/router-for-me/CLIProxyAPI/v7/internal/config" "github.com/router-for-me/CLIProxyAPI/v7/internal/home" "github.com/router-for-me/CLIProxyAPI/v7/internal/logging" @@ -1367,7 +1368,7 @@ models = append(models, model) } - c.JSON(http.StatusOK, openai.CodexClientModelsResponseWithMultiAgentV2(models, s.cfg.Codex.OptimizeMultiAgentV2)) + c.JSON(http.StatusOK, codexmodels.BuildResponse(models, nil, s.cfg.Codex.OptimizeMultiAgentV2)) } func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go new file mode 100644 --- /dev/null +++ b/internal/client/codex/models/models.go @@ -0,0 +1,476 @@ +// Package models builds model catalogs for official Codex clients. +package models + +import ( + "encoding/json" + "sort" + "strings" + "sync" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +type codexClientModelsPayload struct { + Models []map[string]any `json:"models"` +} + +// ProvidersForModelFunc returns the providers registered for a model. +type ProvidersForModelFunc func(string) []string + +var ( + codexClientModelTemplatesMu sync.Mutex + codexClientModelTemplatesLoaded bool + codexClientModelTemplatesRevision uint64 + codexClientModelTemplates map[string]map[string]any + codexClientDefaultTemplate map[string]any + codexClientModelTemplatesErr error +) + +var codexClientAllowedReasoningLevels = map[string]struct{}{ + "none": {}, + "low": {}, + "medium": {}, + "high": {}, + "xhigh": {}, + "max": {}, + "ultra": {}, +} + +// BuildResponse builds a Codex client model response from available models. +func BuildResponse(availableModels []map[string]any, providersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool) map[string]any { + return map[string]any{ + "models": buildCodexClientModels(availableModels, providersForModel, optimizeMultiAgentV2), + } +} + +func buildCodexClientModels(models []map[string]any, providersForModel ProvidersForModelFunc, optimizeMultiAgentV2 bool) []map[string]any { + templates, defaultTemplate, err := loadCodexClientModelTemplates() + if err != nil || defaultTemplate == nil { + return nil + } + + result := make([]map[string]any, 0, len(models)) + for _, model := range models { + id := strings.TrimSpace(stringModelValue(model, "id")) + if id == "" { + continue + } + + if template, ok := templates[id]; ok { + entry := cloneCodexClientModelMap(template) + applyCodexClientDisplayName(entry, model) + applyCodexClientSearchToolSupport(entry, id, true, providersForModel) + sanitizeCodexClientReasoningMetadata(entry) + applyCodexClientVisibilityOverride(entry, id) + result = append(result, entry) + continue + } + + entry := cloneCodexClientModelMap(defaultTemplate) + applyCodexClientModelMetadata(entry, id, model, optimizeMultiAgentV2) + applyCodexClientSearchToolSupport(entry, id, false, providersForModel) + sanitizeCodexClientReasoningMetadata(entry) + applyCodexClientVisibilityOverride(entry, id) + result = append(result, entry) + } + + applyCodexClientNonTemplatePriorities(result, templates) + + sort.SliceStable(result, func(i, j int) bool { + return codexClientModelPriority(result[i]) < codexClientModelPriority(result[j]) + }) + + return result +} + +func maxCodexClientTemplatePriority(templates map[string]map[string]any) int { + maxPriority := 0 + for _, template := range templates { + priority := codexClientModelPriority(template) + if priority > maxPriority { + maxPriority = priority + } + } + return maxPriority +} + +func applyCodexClientNonTemplatePriorities(result []map[string]any, templates map[string]map[string]any) { + if len(result) == 0 { + return + } + + basePriority := maxCodexClientTemplatePriority(templates) + type nonTemplateEntry struct { + index int + displayName string + slug string + } + + pending := make([]nonTemplateEntry, 0) + for index, entry := range result { + slug := stringModelValue(entry, "slug") + if _, ok := templates[slug]; ok { + continue + } + displayName := stringModelValue(entry, "display_name") + if displayName == "" { + displayName = slug + } + pending = append(pending, nonTemplateEntry{ + index: index, + displayName: displayName, + slug: slug, + }) + } + + sort.SliceStable(pending, func(i, j int) bool { + left := strings.ToLower(pending[i].displayName) + right := strings.ToLower(pending[j].displayName) + if left == right { + return pending[i].slug < pending[j].slug + } + return left < right + }) + + for rank, entry := range pending { + result[entry.index]["priority"] = basePriority + 100*(rank+1) + } +} + +func loadCodexClientModelTemplates() (map[string]map[string]any, map[string]any, error) { + raw, revision := registry.GetCodexClientModelsSnapshot() + return loadCodexClientModelTemplatesSnapshot(raw, revision) +} + +func loadCodexClientModelTemplatesSnapshot(raw []byte, revision uint64) (map[string]map[string]any, map[string]any, error) { + codexClientModelTemplatesMu.Lock() + defer codexClientModelTemplatesMu.Unlock() + if codexClientModelTemplatesLoaded && codexClientModelTemplatesRevision == revision { + return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr + } + + var payload codexClientModelsPayload + err := json.Unmarshal(raw, &payload) + var templates map[string]map[string]any + var defaultTemplate map[string]any + if err == nil { + templates = make(map[string]map[string]any, len(payload.Models)) + for _, model := range payload.Models { + slug := strings.TrimSpace(stringModelValue(model, "slug")) + if slug == "" { + continue + } + templates[slug] = cloneCodexClientModelMap(model) + if slug == "gpt-5.5" { + defaultTemplate = cloneCodexClientModelMap(model) + } + } + } + + codexClientModelTemplatesLoaded = true + codexClientModelTemplatesRevision = revision + codexClientModelTemplates = templates + codexClientDefaultTemplate = defaultTemplate + codexClientModelTemplatesErr = err + return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr +} + +func applyCodexClientDisplayName(entry map[string]any, model map[string]any) { + if displayName := stringModelValue(model, "display_name"); displayName != "" { + entry["display_name"] = displayName + } +} + +func applyCodexClientSearchToolSupport(entry map[string]any, id string, templateModel bool, providersForModel ProvidersForModelFunc) { + supportsSearch, _ := entry["supports_search_tool"].(bool) + if !supportsSearch { + return + } + + if !templateModel { + entry["supports_search_tool"] = false + return + } + + if providersForModel == nil { + return + } + + providers := providersForModel(id) + if len(providers) == 0 { + entry["supports_search_tool"] = false + return + } + for _, provider := range providers { + if !strings.EqualFold(strings.TrimSpace(provider), "codex") { + entry["supports_search_tool"] = false + return + } + } +} + +func applyCodexClientModelMetadata(entry map[string]any, id string, model map[string]any, optimizeMultiAgentV2 bool) { + info := registry.LookupModelInfo(id) + + displayName := stringModelValue(model, "display_name") + description := stringModelValue(model, "description") + contextWindow := intModelValue(model, "context_length") + + if info != nil { + if info.DisplayName != "" { + displayName = info.DisplayName + } + if info.Description != "" { + description = info.Description + } + if info.ContextLength > 0 { + contextWindow = info.ContextLength + } + if info.Type == registry.OpenAIImageModelType { + entry["visibility"] = "hide" + delete(entry, "input_modalities") + delete(entry, "supports_image_detail_original") + } else { + applyCodexClientInputModalitiesMetadata(entry, info.SupportedInputModalities) + } + applyCodexClientThinkingMetadata(entry, info.Thinking) + } + + if displayName == "" { + displayName = id + } + if description == "" { + description = id + } + + entry["slug"] = id + entry["display_name"] = displayName + entry["description"] = description + entry["prefer_websockets"] = false + if optimizeMultiAgentV2 { + entry["multi_agent_version"] = "v2" + } + entry["service_tiers"] = []any{} + delete(entry, "apply_patch_tool_type") + delete(entry, "upgrade") + delete(entry, "availability_nux") + + if contextWindow > 0 { + entry["context_window"] = contextWindow + entry["max_context_window"] = contextWindow + } + + if baseInstructions := stringModelValue(model, "base_instructions"); baseInstructions != "" { + entry["base_instructions"] = baseInstructions + } + if plans, ok := model["available_in_plans"]; ok { + entry["available_in_plans"] = cloneCodexClientModelValue(plans) + } +} + +func applyCodexClientVisibilityOverride(entry map[string]any, id string) { + switch strings.TrimSpace(id) { + case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "grok-imagine-image", "grok-imagine-video", "grok-imagine-video-1.5-preview": + entry["visibility"] = "hide" + } +} + +func applyCodexClientInputModalitiesMetadata(entry map[string]any, modalities []string) { + if len(modalities) == 0 { + return + } + // Codex client only accepts text/image input modalities. + codexModalities := make([]any, 0, 2) + seen := make(map[string]struct{}, 2) + supportsImage := false + for _, raw := range modalities { + switch modality := strings.ToLower(strings.TrimSpace(raw)); modality { + case "text", "image": + if _, ok := seen[modality]; ok { + continue + } + seen[modality] = struct{}{} + codexModalities = append(codexModalities, modality) + if modality == "image" { + supportsImage = true + } + } + } + if len(codexModalities) == 0 { + return + } + entry["input_modalities"] = codexModalities + if supportsImage { + entry["supports_image_detail_original"] = true + } else { + delete(entry, "supports_image_detail_original") + } +} + +func applyCodexClientThinkingMetadata(entry map[string]any, thinking *registry.ThinkingSupport) { + if thinking == nil || len(thinking.Levels) == 0 { + return + } + + levels := make([]any, 0, len(thinking.Levels)) + defaultLevel := "" + firstLevel := "" + for _, rawLevel := range thinking.Levels { + level := normalizeCodexClientReasoningLevel(rawLevel) + if level == "" { + continue + } + if firstLevel == "" { + firstLevel = level + } + if (defaultLevel == "" && level != "none") || level == "medium" { + defaultLevel = level + } + levels = append(levels, map[string]any{ + "effort": level, + "description": codexClientReasoningDescription(level), + }) + } + if len(levels) == 0 { + return + } + if defaultLevel == "" { + defaultLevel = firstLevel + } + + entry["supported_reasoning_levels"] = levels + entry["default_reasoning_level"] = defaultLevel +} + +func sanitizeCodexClientReasoningMetadata(entry map[string]any) { + rawLevels, ok := entry["supported_reasoning_levels"].([]any) + if !ok { + return + } + + levels := make([]any, 0, len(rawLevels)) + allowedDefaults := make(map[string]struct{}, len(rawLevels)) + for _, rawLevelEntry := range rawLevels { + levelEntry, ok := rawLevelEntry.(map[string]any) + if !ok { + continue + } + level := normalizeCodexClientReasoningLevel(stringModelValue(levelEntry, "effort")) + if level == "" { + continue + } + clonedEntry := cloneCodexClientModelMap(levelEntry) + clonedEntry["effort"] = level + levels = append(levels, clonedEntry) + allowedDefaults[level] = struct{}{} + } + + if len(levels) == 0 { + delete(entry, "supported_reasoning_levels") + delete(entry, "default_reasoning_level") + return + } + + defaultLevel := normalizeCodexClientReasoningLevel(stringModelValue(entry, "default_reasoning_level")) + if _, ok := allowedDefaults[defaultLevel]; !ok { + defaultLevel = stringModelValue(levels[0].(map[string]any), "effort") + } + + entry["supported_reasoning_levels"] = levels + entry["default_reasoning_level"] = defaultLevel +} + +func normalizeCodexClientReasoningLevel(rawLevel string) string { + level := strings.ToLower(strings.TrimSpace(rawLevel)) + if _, ok := codexClientAllowedReasoningLevels[level]; !ok { + return "" + } + return level +} + +func codexClientReasoningDescription(level string) string { + switch level { + case "none": + return "No reasoning" + case "low": + return "Fast responses with lighter reasoning" + case "medium": + return "Balances speed and reasoning depth for everyday tasks" + case "high": + return "Greater reasoning depth for complex problems" + case "xhigh": + return "Extra high reasoning depth for complex problems" + case "max": + return "Maximum available reasoning depth for complex problems" + default: + return level + } +} + +func codexClientModelPriority(model map[string]any) int { + if priority, ok := model["priority"].(int); ok { + return priority + } + if priority, ok := model["priority"].(float64); ok { + return int(priority) + } + return 100 +} + +func stringModelValue(model map[string]any, key string) string { + if model == nil { + return "" + } + value, ok := model[key] + if !ok { + return "" + } + if s, ok := value.(string); ok { + return strings.TrimSpace(s) + } + return "" +} + +func intModelValue(model map[string]any, key string) int { + if model == nil { + return 0 + } + switch value := model[key].(type) { + case int: + return value + case int64: + return int(value) + case float64: + return int(value) + default: + return 0 + } +} + +func cloneCodexClientModelMap(model map[string]any) map[string]any { + if model == nil { + return nil + } + cloned := make(map[string]any, len(model)) + for key, value := range model { + cloned[key] = cloneCodexClientModelValue(value) + } + return cloned +} + +func cloneCodexClientModelValue(value any) any { + switch typed := value.(type) { + case map[string]any: + return cloneCodexClientModelMap(typed) + case []any: + cloned := make([]any, len(typed)) + for i, entry := range typed { + cloned[i] = cloneCodexClientModelValue(entry) + } + return cloned + case []string: + return append([]string(nil), typed...) + default: + return value + } +} diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go new file mode 100644 --- /dev/null +++ b/internal/client/codex/models/models_test.go @@ -0,0 +1,310 @@ +package models + +import ( + "testing" + + "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" +) + +func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) { + modelID := "mimo-v2.5-pro-codex-test" + textOnlyModelID := "mimo-text-only-codex-test" + modelRegistry := registry.GetGlobalRegistry() + modelRegistry.RegisterClient("codex-input-modalities-test", "openai-compatibility", []*registry.ModelInfo{ + { + ID: modelID, + Object: "model", + OwnedBy: "mimo", + Type: "openai-compatibility", + DisplayName: modelID, + SupportedInputModalities: []string{"text", "image"}, + }, + { + ID: textOnlyModelID, + Object: "model", + OwnedBy: "mimo", + Type: "openai-compatibility", + DisplayName: textOnlyModelID, + SupportedInputModalities: []string{"text"}, + }, + { + ID: "mimo-mixed-modalities-codex-test", + Object: "model", + OwnedBy: "mimo", + Type: "openai-compatibility", + DisplayName: "mimo-mixed-modalities-codex-test", + SupportedInputModalities: []string{"text", "image", "audio", "video", "TEXT", "IMAGE"}, + }, + { + ID: "compat-image-only-codex-test", + Object: "model", + OwnedBy: "mimo", + Type: registry.OpenAIImageModelType, + }, + }) + t.Cleanup(func() { + modelRegistry.UnregisterClient("codex-input-modalities-test") + }) + + openaiModels := modelRegistry.GetAvailableModels("openai") + resp := BuildResponse(openaiModels, nil, false) + models, ok := resp["models"].([]map[string]any) + if !ok { + t.Fatalf("models type = %T, want []map[string]any", resp["models"]) + } + + var visionEntry map[string]any + var textOnlyEntry map[string]any + var mixedEntry map[string]any + var imageEntry map[string]any + for _, entry := range models { + slug := stringModelValue(entry, "slug") + switch slug { + case modelID: + visionEntry = entry + case textOnlyModelID: + textOnlyEntry = entry + case "mimo-mixed-modalities-codex-test": + mixedEntry = entry + case "compat-image-only-codex-test": + imageEntry = entry + } + } + if visionEntry == nil { + t.Fatalf("expected codex entry for %q", modelID) + } + modalities, ok := visionEntry["input_modalities"].([]any) + if !ok || len(modalities) != 2 { + t.Fatalf("input_modalities = %#v, want [text image]", visionEntry["input_modalities"]) + } + if got, _ := modalities[0].(string); got != "text" { + t.Fatalf("input_modalities[0] = %q, want text", got) + } + if got, _ := modalities[1].(string); got != "image" { + t.Fatalf("input_modalities[1] = %q, want image", got) + } + if got, ok := visionEntry["supports_image_detail_original"].(bool); !ok || !got { + t.Fatalf("supports_image_detail_original = %#v, want true", visionEntry["supports_image_detail_original"]) + } + + if textOnlyEntry == nil { + t.Fatalf("expected codex entry for %q", textOnlyModelID) + } + textOnlyModalities, ok := textOnlyEntry["input_modalities"].([]any) + if !ok || len(textOnlyModalities) != 1 { + t.Fatalf("text-only input_modalities = %#v, want [text]", textOnlyEntry["input_modalities"]) + } + if got, _ := textOnlyModalities[0].(string); got != "text" { + t.Fatalf("text-only input_modalities[0] = %q, want text", got) + } + if _, exists := textOnlyEntry["supports_image_detail_original"]; exists { + t.Fatalf("text-only model should not expose supports_image_detail_original: %#v", textOnlyEntry["supports_image_detail_original"]) + } + + if mixedEntry == nil { + t.Fatal("expected codex entry for mixed-modalities model") + } + mixedModalities, ok := mixedEntry["input_modalities"].([]any) + if !ok || len(mixedModalities) != 2 { + t.Fatalf("mixed input_modalities = %#v, want [text image]", mixedEntry["input_modalities"]) + } + if got, _ := mixedModalities[0].(string); got != "text" { + t.Fatalf("mixed input_modalities[0] = %q, want text", got) + } + if got, _ := mixedModalities[1].(string); got != "image" { + t.Fatalf("mixed input_modalities[1] = %q, want image", got) + } + if got, ok := mixedEntry["supports_image_detail_original"].(bool); !ok || !got { + t.Fatalf("mixed supports_image_detail_original = %#v, want true", mixedEntry["supports_image_detail_original"]) + } + + if imageEntry == nil { + t.Fatal("expected codex entry for image-only compat model") + } + if got, _ := imageEntry["visibility"].(string); got != "hide" { + t.Fatalf("image model visibility = %q, want hide", got) + } + if _, exists := imageEntry["input_modalities"]; exists { + t.Fatalf("image endpoint model should not expose input_modalities from registry: %#v", imageEntry["input_modalities"]) + } +} + +func TestCodexClientModelsResponse_AppliesDisplayNameToTemplateModel(t *testing.T) { + resp := BuildResponse([]map[string]any{{ + "id": "gpt-5.5", + "display_name": "Configured Codex Name", + }}, nil, false) + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("models = %#v, want one model", resp["models"]) + } + if got := stringModelValue(models[0], "display_name"); got != "Configured Codex Name" { + t.Fatalf("display_name = %q, want Configured Codex Name", got) + } +} + +func TestCodexClientModelsResponse_DisablesSearchToolForSynthesizedModels(t *testing.T) { + resp := BuildResponse([]map[string]any{ + {"id": "custom-openai-compatible-model"}, + {"id": "gpt-5.5"}, + }, 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 + } + + custom := bySlug["custom-openai-compatible-model"] + if custom == nil { + t.Fatal("expected synthesized custom model entry") + } + if got, ok := custom["supports_search_tool"].(bool); !ok || got { + t.Fatalf("custom supports_search_tool = %#v, want false", custom["supports_search_tool"]) + } + + official := bySlug["gpt-5.5"] + if official == nil { + t.Fatal("expected official template model entry") + } + if got, ok := official["supports_search_tool"].(bool); !ok || !got { + t.Fatalf("official supports_search_tool = %#v, want true", official["supports_search_tool"]) + } +} + +func TestCodexClientModelsResponse_RequiresTemplateAndCodexProvidersForSearchTool(t *testing.T) { + providers := map[string][]string{ + "new-codex-model": {"codex"}, + "gpt-5.5": {"openai-compatible-deepseek"}, + "gpt-5.4": {"codex", "xai"}, + "gpt-5.6-sol": {"codex"}, + } + resp := BuildResponse([]map[string]any{ + {"id": "new-codex-model"}, + {"id": "gpt-5.5"}, + {"id": "gpt-5.4"}, + {"id": "gpt-5.6-sol"}, + }, func(id string) []string { + return providers[id] + }, 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 + } + + if got, ok := bySlug["gpt-5.6-sol"]["supports_search_tool"].(bool); !ok || !got { + t.Errorf("gpt-5.6-sol supports_search_tool = %#v, want true", bySlug["gpt-5.6-sol"]["supports_search_tool"]) + } + for _, slug := range []string{"new-codex-model", "gpt-5.5", "gpt-5.4"} { + if got, ok := bySlug[slug]["supports_search_tool"].(bool); !ok || got { + t.Errorf("%s supports_search_tool = %#v, want false", slug, bySlug[slug]["supports_search_tool"]) + } + } +} + +func TestCodexClientModelsResponse_PreservesUltraReasoningEffort(t *testing.T) { + resp := BuildResponse([]map[string]any{{"id": "gpt-5.6-sol"}}, nil, false) + models, ok := resp["models"].([]map[string]any) + if !ok { + t.Fatalf("models type = %T, want []map[string]any", resp["models"]) + } + + var sol map[string]any + for _, entry := range models { + if stringModelValue(entry, "slug") == "gpt-5.6-sol" { + sol = entry + break + } + } + if sol == nil { + t.Fatal("expected codex client entry for gpt-5.6-sol") + } + + levels, ok := sol["supported_reasoning_levels"].([]any) + if !ok { + t.Fatalf("supported_reasoning_levels = %T, want []any", sol["supported_reasoning_levels"]) + } + for _, rawLevel := range levels { + level, ok := rawLevel.(map[string]any) + if ok && stringModelValue(level, "effort") == "ultra" { + return + } + } + + t.Fatalf("supported_reasoning_levels = %#v, want ultra", levels) +} + +func TestLoadCodexClientModelTemplatesRefreshesOnRevision(t *testing.T) { + codexClientModelTemplatesMu.Lock() + previousLoaded := codexClientModelTemplatesLoaded + previousRevision := codexClientModelTemplatesRevision + previousTemplates := codexClientModelTemplates + previousDefault := codexClientDefaultTemplate + previousErr := codexClientModelTemplatesErr + codexClientModelTemplatesLoaded = false + codexClientModelTemplatesMu.Unlock() + t.Cleanup(func() { + codexClientModelTemplatesMu.Lock() + codexClientModelTemplatesLoaded = previousLoaded + codexClientModelTemplatesRevision = previousRevision + codexClientModelTemplates = previousTemplates + codexClientDefaultTemplate = previousDefault + codexClientModelTemplatesErr = previousErr + codexClientModelTemplatesMu.Unlock() + }) + + first := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"First"}]}`) + templates, defaultTemplate, err := loadCodexClientModelTemplatesSnapshot(first, 100) + if err != nil { + t.Fatalf("load first snapshot: %v", err) + } + if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "First" { + t.Fatalf("first display_name = %q, want First", got) + } + if got := stringModelValue(defaultTemplate, "display_name"); got != "First" { + t.Fatalf("first default display_name = %q, want First", got) + } + + second := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"Second"}]}`) + templates, defaultTemplate, err = loadCodexClientModelTemplatesSnapshot(second, 101) + if err != nil { + t.Fatalf("load second snapshot: %v", err) + } + if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { + t.Fatalf("second display_name = %q, want Second", got) + } + if got := stringModelValue(defaultTemplate, "display_name"); got != "Second" { + t.Fatalf("second default display_name = %q, want Second", got) + } + + templates, _, err = loadCodexClientModelTemplatesSnapshot(first, 101) + if err != nil { + t.Fatalf("reload cached revision: %v", err) + } + if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { + t.Fatalf("cached display_name = %q, want Second", got) + } +} + +func TestApplyCodexClientModelMetadataPreservesMultiAgentVersionWhenDisabled(t *testing.T) { + entry := map[string]any{"multi_agent_version": "v1"} + model := map[string]any{"id": "custom-model"} + + applyCodexClientModelMetadata(entry, "custom-model", model, false) + if got := entry["multi_agent_version"]; got != "v1" { + t.Fatalf("disabled multi_agent_version = %#v, want preserved v1", got) + } + + applyCodexClientModelMetadata(entry, "custom-model", model, true) + if got := entry["multi_agent_version"]; got != "v2" { + t.Fatalf("enabled multi_agent_version = %#v, want v2", got) + } +} diff --git a/sdk/api/handlers/openai/codex_client_models.go b/sdk/api/handlers/openai/codex_client_models.go --- a/sdk/api/handlers/openai/codex_client_models.go +++ b/sdk/api/handlers/openai/codex_client_models.go @@ -1,488 +1,22 @@ package openai import ( - "encoding/json" - "sort" - "strings" - "sync" - + codexmodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/codex/models" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" ) -type codexClientModelsPayload struct { - Models []map[string]any `json:"models"` -} - -type codexClientModelProvidersFunc func(string) []string - -var ( - codexClientModelTemplatesMu sync.Mutex - codexClientModelTemplatesLoaded bool - codexClientModelTemplatesRevision uint64 - codexClientModelTemplates map[string]map[string]any - codexClientDefaultTemplate map[string]any - codexClientModelTemplatesErr error -) - -var codexClientAllowedReasoningLevels = map[string]struct{}{ - "none": {}, - "low": {}, - "medium": {}, - "high": {}, - "xhigh": {}, - "max": {}, - "ultra": {}, -} - func (h *OpenAIAPIHandler) codexClientModelsResponse() map[string]any { optimizeMultiAgentV2 := h != nil && h.Cfg != nil && h.Cfg.CodexOptimizeMultiAgentV2 - return codexClientModelsResponse(h.Models(), registry.GetGlobalRegistry().GetModelProviders, optimizeMultiAgentV2) + return codexmodels.BuildResponse(h.Models(), registry.GetGlobalRegistry().GetModelProviders, optimizeMultiAgentV2) } +// CodexClientModelsResponse builds a Codex client model response. func CodexClientModelsResponse(models []map[string]any) map[string]any { - return codexClientModelsResponse(models, nil, false) + return codexmodels.BuildResponse(models, nil, false) } -// CodexClientModelsResponseWithMultiAgentV2 builds the Codex client model response +// CodexClientModelsResponseWithMultiAgentV2 builds a Codex client model response // and advertises multi-agent v2 for synthesized models when enabled. func CodexClientModelsResponseWithMultiAgentV2(models []map[string]any, enabled bool) map[string]any { - return codexClientModelsResponse(models, nil, enabled) -} - -func codexClientModelsResponse(models []map[string]any, providersForModel codexClientModelProvidersFunc, optimizeMultiAgentV2 bool) map[string]any { - return map[string]any{ - "models": buildCodexClientModels(models, providersForModel, optimizeMultiAgentV2), - } -} - -func buildCodexClientModels(models []map[string]any, providersForModel codexClientModelProvidersFunc, optimizeMultiAgentV2 bool) []map[string]any { - templates, defaultTemplate, err := loadCodexClientModelTemplates() - if err != nil || defaultTemplate == nil { - return nil - } - - result := make([]map[string]any, 0, len(models)) - for _, model := range models { - id := strings.TrimSpace(stringModelValue(model, "id")) - if id == "" { - continue - } - - if template, ok := templates[id]; ok { - entry := cloneCodexClientModelMap(template) - applyCodexClientDisplayName(entry, model) - applyCodexClientSearchToolSupport(entry, id, true, providersForModel) - sanitizeCodexClientReasoningMetadata(entry) - applyCodexClientVisibilityOverride(entry, id) - result = append(result, entry) - continue - } - - entry := cloneCodexClientModelMap(defaultTemplate) - applyCodexClientModelMetadata(entry, id, model, optimizeMultiAgentV2) - applyCodexClientSearchToolSupport(entry, id, false, providersForModel) - sanitizeCodexClientReasoningMetadata(entry) - applyCodexClientVisibilityOverride(entry, id) - result = append(result, entry) - } - - applyCodexClientNonTemplatePriorities(result, templates) - - sort.SliceStable(result, func(i, j int) bool { - return codexClientModelPriority(result[i]) < codexClientModelPriority(result[j]) - }) - - return result -} - -func maxCodexClientTemplatePriority(templates map[string]map[string]any) int { - maxPriority := 0 - for _, template := range templates { - priority := codexClientModelPriority(template) - if priority > maxPriority { - maxPriority = priority - } - } - return maxPriority -} - -func applyCodexClientNonTemplatePriorities(result []map[string]any, templates map[string]map[string]any) { - if len(result) == 0 { - return - } - - basePriority := maxCodexClientTemplatePriority(templates) - type nonTemplateEntry struct { - index int - displayName string - slug string - } - - pending := make([]nonTemplateEntry, 0) - for index, entry := range result { - slug := stringModelValue(entry, "slug") - if _, ok := templates[slug]; ok { - continue - } - displayName := stringModelValue(entry, "display_name") - if displayName == "" { - displayName = slug - } - pending = append(pending, nonTemplateEntry{ - index: index, - displayName: displayName, - slug: slug, - }) - } - - sort.SliceStable(pending, func(i, j int) bool { - left := strings.ToLower(pending[i].displayName) - right := strings.ToLower(pending[j].displayName) - if left == right { - return pending[i].slug < pending[j].slug - } - return left < right - }) - - for rank, entry := range pending { - result[entry.index]["priority"] = basePriority + 100*(rank+1) - } -} - -func loadCodexClientModelTemplates() (map[string]map[string]any, map[string]any, error) { - raw, revision := registry.GetCodexClientModelsSnapshot() - return loadCodexClientModelTemplatesSnapshot(raw, revision) -} - -func loadCodexClientModelTemplatesSnapshot(raw []byte, revision uint64) (map[string]map[string]any, map[string]any, error) { - codexClientModelTemplatesMu.Lock() - defer codexClientModelTemplatesMu.Unlock() - if codexClientModelTemplatesLoaded && codexClientModelTemplatesRevision == revision { - return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr - } - - var payload codexClientModelsPayload - err := json.Unmarshal(raw, &payload) - var templates map[string]map[string]any - var defaultTemplate map[string]any - if err == nil { - templates = make(map[string]map[string]any, len(payload.Models)) - for _, model := range payload.Models { - slug := strings.TrimSpace(stringModelValue(model, "slug")) - if slug == "" { - continue - } - templates[slug] = cloneCodexClientModelMap(model) - if slug == "gpt-5.5" { - defaultTemplate = cloneCodexClientModelMap(model) - } - } - } - - codexClientModelTemplatesLoaded = true - codexClientModelTemplatesRevision = revision - codexClientModelTemplates = templates - codexClientDefaultTemplate = defaultTemplate - codexClientModelTemplatesErr = err - return codexClientModelTemplates, codexClientDefaultTemplate, codexClientModelTemplatesErr -} - -func applyCodexClientDisplayName(entry map[string]any, model map[string]any) { - if displayName := stringModelValue(model, "display_name"); displayName != "" { - entry["display_name"] = displayName - } -} - -func applyCodexClientSearchToolSupport(entry map[string]any, id string, templateModel bool, providersForModel codexClientModelProvidersFunc) { - supportsSearch, _ := entry["supports_search_tool"].(bool) - if !supportsSearch { - return - } - - if !templateModel { - entry["supports_search_tool"] = false - return - } - - if providersForModel == nil { - return - } - - providers := providersForModel(id) - if len(providers) == 0 { - entry["supports_search_tool"] = false - return - } - for _, provider := range providers { - if !strings.EqualFold(strings.TrimSpace(provider), "codex") { - entry["supports_search_tool"] = false - return - } - } -} - -func applyCodexClientModelMetadata(entry map[string]any, id string, model map[string]any, optimizeMultiAgentV2 bool) { - info := registry.LookupModelInfo(id) - - displayName := stringModelValue(model, "display_name") - description := stringModelValue(model, "description") - contextWindow := intModelValue(model, "context_length") - - if info != nil { - if info.DisplayName != "" { - displayName = info.DisplayName - } - if info.Description != "" { - description = info.Description - } - if info.ContextLength > 0 { - contextWindow = info.ContextLength - } - if info.Type == registry.OpenAIImageModelType { - entry["visibility"] = "hide" - delete(entry, "input_modalities") - delete(entry, "supports_image_detail_original") - } else { - applyCodexClientInputModalitiesMetadata(entry, info.SupportedInputModalities) - } - applyCodexClientThinkingMetadata(entry, info.Thinking) - } - - if displayName == "" { - displayName = id - } - if description == "" { - description = id - } - - entry["slug"] = id - entry["display_name"] = displayName - entry["description"] = description - entry["prefer_websockets"] = false - if optimizeMultiAgentV2 { - entry["multi_agent_version"] = "v2" - } - entry["service_tiers"] = []any{} - delete(entry, "apply_patch_tool_type") - delete(entry, "upgrade") - delete(entry, "availability_nux") - - if contextWindow > 0 { - entry["context_window"] = contextWindow - entry["max_context_window"] = contextWindow - } - - if baseInstructions := stringModelValue(model, "base_instructions"); baseInstructions != "" { - entry["base_instructions"] = baseInstructions - } - if plans, ok := model["available_in_plans"]; ok { - entry["available_in_plans"] = cloneCodexClientModelValue(plans) - } -} - -func applyCodexClientVisibilityOverride(entry map[string]any, id string) { - switch strings.TrimSpace(id) { - case "grok-imagine-image-quality", "gpt-image-1.5", "gpt-image-2", "grok-imagine-image", "grok-imagine-video", "grok-imagine-video-1.5-preview": - entry["visibility"] = "hide" - } -} - -func applyCodexClientInputModalitiesMetadata(entry map[string]any, modalities []string) { - if len(modalities) == 0 { - return - } - // Codex client only accepts text/image input modalities. - codexModalities := make([]any, 0, 2) - seen := make(map[string]struct{}, 2) - supportsImage := false - for _, raw := range modalities { - switch modality := strings.ToLower(strings.TrimSpace(raw)); modality { - case "text", "image": - if _, ok := seen[modality]; ok { - continue - } - seen[modality] = struct{}{} - codexModalities = append(codexModalities, modality) - if modality == "image" { - supportsImage = true - } - } - } - if len(codexModalities) == 0 { - return - } - entry["input_modalities"] = codexModalities - if supportsImage { - entry["supports_image_detail_original"] = true - } else { - delete(entry, "supports_image_detail_original") - } -} - -func applyCodexClientThinkingMetadata(entry map[string]any, thinking *registry.ThinkingSupport) { - if thinking == nil || len(thinking.Levels) == 0 { - return - } - - levels := make([]any, 0, len(thinking.Levels)) - defaultLevel := "" - firstLevel := "" - for _, rawLevel := range thinking.Levels { - level := normalizeCodexClientReasoningLevel(rawLevel) - if level == "" { - continue - } - if firstLevel == "" { - firstLevel = level - } - if (defaultLevel == "" && level != "none") || level == "medium" { - defaultLevel = level - } - levels = append(levels, map[string]any{ - "effort": level, - "description": codexClientReasoningDescription(level), - }) - } - if len(levels) == 0 { - return - } - if defaultLevel == "" { - defaultLevel = firstLevel - } - - entry["supported_reasoning_levels"] = levels - entry["default_reasoning_level"] = defaultLevel -} - -func sanitizeCodexClientReasoningMetadata(entry map[string]any) { - rawLevels, ok := entry["supported_reasoning_levels"].([]any) - if !ok { - return - } - - levels := make([]any, 0, len(rawLevels)) - allowedDefaults := make(map[string]struct{}, len(rawLevels)) - for _, rawLevelEntry := range rawLevels { - levelEntry, ok := rawLevelEntry.(map[string]any) - if !ok { - continue - } - level := normalizeCodexClientReasoningLevel(stringModelValue(levelEntry, "effort")) - if level == "" { - continue - } - clonedEntry := cloneCodexClientModelMap(levelEntry) - clonedEntry["effort"] = level - levels = append(levels, clonedEntry) - allowedDefaults[level] = struct{}{} - } - - if len(levels) == 0 { - delete(entry, "supported_reasoning_levels") - delete(entry, "default_reasoning_level") - return - } - - defaultLevel := normalizeCodexClientReasoningLevel(stringModelValue(entry, "default_reasoning_level")) - if _, ok := allowedDefaults[defaultLevel]; !ok { - defaultLevel = stringModelValue(levels[0].(map[string]any), "effort") - } - - entry["supported_reasoning_levels"] = levels - entry["default_reasoning_level"] = defaultLevel -} - -func normalizeCodexClientReasoningLevel(rawLevel string) string { - level := strings.ToLower(strings.TrimSpace(rawLevel)) - if _, ok := codexClientAllowedReasoningLevels[level]; !ok { - return "" - } - return level -} - -func codexClientReasoningDescription(level string) string { - switch level { - case "none": - return "No reasoning" - case "low": - return "Fast responses with lighter reasoning" - case "medium": - return "Balances speed and reasoning depth for everyday tasks" - case "high": - return "Greater reasoning depth for complex problems" - case "xhigh": - return "Extra high reasoning depth for complex problems" - case "max": - return "Maximum available reasoning depth for complex problems" - default: - return level - } -} - -func codexClientModelPriority(model map[string]any) int { - if priority, ok := model["priority"].(int); ok { - return priority - } - if priority, ok := model["priority"].(float64); ok { - return int(priority) - } - return 100 -} - -func stringModelValue(model map[string]any, key string) string { - if model == nil { - return "" - } - value, ok := model[key] - if !ok { - return "" - } - if s, ok := value.(string); ok { - return strings.TrimSpace(s) - } - return "" -} - -func intModelValue(model map[string]any, key string) int { - if model == nil { - return 0 - } - switch value := model[key].(type) { - case int: - return value - case int64: - return int(value) - case float64: - return int(value) - default: - return 0 - } -} - -func cloneCodexClientModelMap(model map[string]any) map[string]any { - if model == nil { - return nil - } - cloned := make(map[string]any, len(model)) - for key, value := range model { - cloned[key] = cloneCodexClientModelValue(value) - } - return cloned -} - -func cloneCodexClientModelValue(value any) any { - switch typed := value.(type) { - case map[string]any: - return cloneCodexClientModelMap(typed) - case []any: - cloned := make([]any, len(typed)) - for i, entry := range typed { - cloned[i] = cloneCodexClientModelValue(entry) - } - return cloned - case []string: - return append([]string(nil), typed...) - default: - return value - } + return codexmodels.BuildResponse(models, nil, enabled) } diff --git a/sdk/api/handlers/openai/codex_client_models_test.go b/sdk/api/handlers/openai/codex_client_models_test.go --- a/sdk/api/handlers/openai/codex_client_models_test.go +++ b/sdk/api/handlers/openai/codex_client_models_test.go @@ -8,309 +8,6 @@ "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" ) -func TestCodexClientModelsResponse_InputModalitiesFromRegistry(t *testing.T) { - modelID := "mimo-v2.5-pro-codex-test" - textOnlyModelID := "mimo-text-only-codex-test" - modelRegistry := registry.GetGlobalRegistry() - modelRegistry.RegisterClient("codex-input-modalities-test", "openai-compatibility", []*registry.ModelInfo{ - { - ID: modelID, - Object: "model", - OwnedBy: "mimo", - Type: "openai-compatibility", - DisplayName: modelID, - SupportedInputModalities: []string{"text", "image"}, - }, - { - ID: textOnlyModelID, - Object: "model", - OwnedBy: "mimo", - Type: "openai-compatibility", - DisplayName: textOnlyModelID, - SupportedInputModalities: []string{"text"}, - }, - { - ID: "mimo-mixed-modalities-codex-test", - Object: "model", - OwnedBy: "mimo", - Type: "openai-compatibility", - DisplayName: "mimo-mixed-modalities-codex-test", - SupportedInputModalities: []string{"text", "image", "audio", "video", "TEXT", "IMAGE"}, - }, - { - ID: "compat-image-only-codex-test", - Object: "model", - OwnedBy: "mimo", - Type: registry.OpenAIImageModelType, - }, - }) - t.Cleanup(func() { - modelRegistry.UnregisterClient("codex-input-modalities-test") - }) - - openaiModels := modelRegistry.GetAvailableModels("openai") - resp := CodexClientModelsResponse(openaiModels) - models, ok := resp["models"].([]map[string]any) - if !ok { - t.Fatalf("models type = %T, want []map[string]any", resp["models"]) - } - - var visionEntry map[string]any - var textOnlyEntry map[string]any - var mixedEntry map[string]any - var imageEntry map[string]any - for _, entry := range models { - slug := stringModelValue(entry, "slug") - switch slug { - case modelID: - visionEntry = entry - case textOnlyModelID: - textOnlyEntry = entry - case "mimo-mixed-modalities-codex-test": - mixedEntry = entry - case "compat-image-only-codex-test": - imageEntry = entry - } - } - if visionEntry == nil { - t.Fatalf("expected codex entry for %q", modelID) - } - modalities, ok := visionEntry["input_modalities"].([]any) - if !ok || len(modalities) != 2 { - t.Fatalf("input_modalities = %#v, want [text image]", visionEntry["input_modalities"]) - } - if got, _ := modalities[0].(string); got != "text" { - t.Fatalf("input_modalities[0] = %q, want text", got) - } - if got, _ := modalities[1].(string); got != "image" { - t.Fatalf("input_modalities[1] = %q, want image", got) - } - if got, ok := visionEntry["supports_image_detail_original"].(bool); !ok || !got { - t.Fatalf("supports_image_detail_original = %#v, want true", visionEntry["supports_image_detail_original"]) - } - - if textOnlyEntry == nil { - t.Fatalf("expected codex entry for %q", textOnlyModelID) - } - textOnlyModalities, ok := textOnlyEntry["input_modalities"].([]any) - if !ok || len(textOnlyModalities) != 1 { - t.Fatalf("text-only input_modalities = %#v, want [text]", textOnlyEntry["input_modalities"]) - } - if got, _ := textOnlyModalities[0].(string); got != "text" { - t.Fatalf("text-only input_modalities[0] = %q, want text", got) - } - if _, exists := textOnlyEntry["supports_image_detail_original"]; exists { - t.Fatalf("text-only model should not expose supports_image_detail_original: %#v", textOnlyEntry["supports_image_detail_original"]) - } - - if mixedEntry == nil { - t.Fatal("expected codex entry for mixed-modalities model") - } - mixedModalities, ok := mixedEntry["input_modalities"].([]any) - if !ok || len(mixedModalities) != 2 { - t.Fatalf("mixed input_modalities = %#v, want [text image]", mixedEntry["input_modalities"]) - } - if got, _ := mixedModalities[0].(string); got != "text" { - t.Fatalf("mixed input_modalities[0] = %q, want text", got) - } - if got, _ := mixedModalities[1].(string); got != "image" { - t.Fatalf("mixed input_modalities[1] = %q, want image", got) - } - if got, ok := mixedEntry["supports_image_detail_original"].(bool); !ok || !got { - t.Fatalf("mixed supports_image_detail_original = %#v, want true", mixedEntry["supports_image_detail_original"]) - } - - if imageEntry == nil { - t.Fatal("expected codex entry for image-only compat model") - } - if got, _ := imageEntry["visibility"].(string); got != "hide" { - t.Fatalf("image model visibility = %q, want hide", got) - } - if _, exists := imageEntry["input_modalities"]; exists { - t.Fatalf("image endpoint model should not expose input_modalities from registry: %#v", imageEntry["input_modalities"]) - } -} - -func TestCodexClientModelsResponse_AppliesDisplayNameToTemplateModel(t *testing.T) { - resp := CodexClientModelsResponse([]map[string]any{{ - "id": "gpt-5.5", - "display_name": "Configured Codex Name", - }}) - models, ok := resp["models"].([]map[string]any) - if !ok || len(models) != 1 { - t.Fatalf("models = %#v, want one model", resp["models"]) - } - if got := stringModelValue(models[0], "display_name"); got != "Configured Codex Name" { - t.Fatalf("display_name = %q, want Configured Codex Name", got) - } -} - -func TestCodexClientModelsResponse_DisablesSearchToolForSynthesizedModels(t *testing.T) { - resp := CodexClientModelsResponse([]map[string]any{ - {"id": "custom-openai-compatible-model"}, - {"id": "gpt-5.5"}, - }) - 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 - } - - custom := bySlug["custom-openai-compatible-model"] - if custom == nil { - t.Fatal("expected synthesized custom model entry") - } - if got, ok := custom["supports_search_tool"].(bool); !ok || got { - t.Fatalf("custom supports_search_tool = %#v, want false", custom["supports_search_tool"]) - } - - official := bySlug["gpt-5.5"] - if official == nil { - t.Fatal("expected official template model entry") - } - if got, ok := official["supports_search_tool"].(bool); !ok || !got { - t.Fatalf("official supports_search_tool = %#v, want true", official["supports_search_tool"]) - } -} - -func TestCodexClientModelsResponse_RequiresTemplateAndCodexProvidersForSearchTool(t *testing.T) { - providers := map[string][]string{ - "new-codex-model": {"codex"}, - "gpt-5.5": {"openai-compatible-deepseek"}, - "gpt-5.4": {"codex", "xai"}, - "gpt-5.6-sol": {"codex"}, - } - resp := codexClientModelsResponse([]map[string]any{ - {"id": "new-codex-model"}, - {"id": "gpt-5.5"}, - {"id": "gpt-5.4"}, - {"id": "gpt-5.6-sol"}, - }, func(id string) []string { - return providers[id] - }, 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 - } - - if got, ok := bySlug["gpt-5.6-sol"]["supports_search_tool"].(bool); !ok || !got { - t.Errorf("gpt-5.6-sol supports_search_tool = %#v, want true", bySlug["gpt-5.6-sol"]["supports_search_tool"]) - } - for _, slug := range []string{"new-codex-model", "gpt-5.5", "gpt-5.4"} { - if got, ok := bySlug[slug]["supports_search_tool"].(bool); !ok || got { - t.Errorf("%s supports_search_tool = %#v, want false", slug, bySlug[slug]["supports_search_tool"]) - } - } -} - -func TestCodexClientModelsResponse_PreservesUltraReasoningEffort(t *testing.T) { - resp := CodexClientModelsResponse([]map[string]any{{"id": "gpt-5.6-sol"}}) - models, ok := resp["models"].([]map[string]any) - if !ok { - t.Fatalf("models type = %T, want []map[string]any", resp["models"]) - } - - var sol map[string]any - for _, entry := range models { - if stringModelValue(entry, "slug") == "gpt-5.6-sol" { - sol = entry - break - } - } - if sol == nil { - t.Fatal("expected codex client entry for gpt-5.6-sol") - } - - levels, ok := sol["supported_reasoning_levels"].([]any) - if !ok { - t.Fatalf("supported_reasoning_levels = %T, want []any", sol["supported_reasoning_levels"]) - } - for _, rawLevel := range levels { - level, ok := rawLevel.(map[string]any) - if ok && stringModelValue(level, "effort") == "ultra" { - return - } - } - - t.Fatalf("supported_reasoning_levels = %#v, want ultra", levels) -} - -func TestLoadCodexClientModelTemplatesRefreshesOnRevision(t *testing.T) { - codexClientModelTemplatesMu.Lock() - previousLoaded := codexClientModelTemplatesLoaded - previousRevision := codexClientModelTemplatesRevision - previousTemplates := codexClientModelTemplates - previousDefault := codexClientDefaultTemplate - previousErr := codexClientModelTemplatesErr - codexClientModelTemplatesLoaded = false - codexClientModelTemplatesMu.Unlock() - t.Cleanup(func() { - codexClientModelTemplatesMu.Lock() - codexClientModelTemplatesLoaded = previousLoaded - codexClientModelTemplatesRevision = previousRevision - codexClientModelTemplates = previousTemplates - codexClientDefaultTemplate = previousDefault - codexClientModelTemplatesErr = previousErr - codexClientModelTemplatesMu.Unlock() - }) - - first := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"First"}]}`) - templates, defaultTemplate, err := loadCodexClientModelTemplatesSnapshot(first, 100) - if err != nil { - t.Fatalf("load first snapshot: %v", err) - } - if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "First" { - t.Fatalf("first display_name = %q, want First", got) - } - if got := stringModelValue(defaultTemplate, "display_name"); got != "First" { - t.Fatalf("first default display_name = %q, want First", got) - } - - second := []byte(`{"models":[{"slug":"gpt-5.5","display_name":"Second"}]}`) - templates, defaultTemplate, err = loadCodexClientModelTemplatesSnapshot(second, 101) - if err != nil { - t.Fatalf("load second snapshot: %v", err) - } - if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { - t.Fatalf("second display_name = %q, want Second", got) - } - if got := stringModelValue(defaultTemplate, "display_name"); got != "Second" { - t.Fatalf("second default display_name = %q, want Second", got) - } - - templates, _, err = loadCodexClientModelTemplatesSnapshot(first, 101) - if err != nil { - t.Fatalf("reload cached revision: %v", err) - } - if got := stringModelValue(templates["gpt-5.5"], "display_name"); got != "Second" { - t.Fatalf("cached display_name = %q, want Second", got) - } -} - -func TestApplyCodexClientModelMetadataPreservesMultiAgentVersionWhenDisabled(t *testing.T) { - entry := map[string]any{"multi_agent_version": "v1"} - model := map[string]any{"id": "custom-model"} - - applyCodexClientModelMetadata(entry, "custom-model", model, false) - if got := entry["multi_agent_version"]; got != "v1" { - t.Fatalf("disabled multi_agent_version = %#v, want preserved v1", got) - } - - applyCodexClientModelMetadata(entry, "custom-model", model, true) - if got := entry["multi_agent_version"]; got != "v2" { - t.Fatalf("enabled multi_agent_version = %#v, want v2", got) - } -} - func TestCodexClientModelsResponseMultiAgentV2FollowsConfig(t *testing.T) { modelID := "codex-client-multi-agent-v2-test" clientID := "codex-client-multi-agent-v2-test-client" @@ -338,7 +35,8 @@ } var entry map[string]any for _, model := range models { - if stringModelValue(model, "slug") == modelID { + slug, _ := model["slug"].(string) + if slug == modelID { entry = model break } -- tangled.sh