diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 815f6e3b..fc9de129 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -637,29 +637,39 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin 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 - } - if entry.maxCompletionTokens > 0 { - model["max_completion_tokens"] = entry.maxCompletionTokens - } - models = append(models, model) + models = append(models, formatHomeCodexModel(entry)) } c.JSON(http.StatusOK, codexmodels.BuildResponseForClient(models, nil, s.cfg.Codex.OptimizeMultiAgentV2, clientVersion)) } +func formatHomeCodexModel(entry homeModelEntry) map[string]any { + 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 + } + if entry.contextLength > 0 { + model["context_length"] = entry.contextLength + } + if entry.maxCompletionTokens > 0 { + model["max_completion_tokens"] = entry.maxCompletionTokens + } + if entry.thinking != nil { + model["thinking"] = entry.thinking + } + return model +} + func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { return func(c *gin.Context) { if s != nil && s.cfg != nil && s.cfg.Home.Enabled { @@ -689,6 +699,7 @@ type homeModelEntry struct { displayName string contextLength int maxCompletionTokens int + thinking *registry.ThinkingSupport } func (s *Server) handleHomeModels(c *gin.Context) { @@ -984,6 +995,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { displayName, _ = model["displayName"].(string) displayName = strings.TrimSpace(displayName) } + thinking := homeModelThinkingSupport(model) out = append(out, homeModelEntry{ id: id, @@ -992,6 +1004,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { displayName: displayName, contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")), maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")), + thinking: thinking, }) } } @@ -1003,6 +1016,22 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { return out, nil } +func homeModelThinkingSupport(model map[string]any) *registry.ThinkingSupport { + raw, ok := model["thinking"] + if !ok || raw == nil { + return nil + } + data, errMarshal := json.Marshal(raw) + if errMarshal != nil { + return nil + } + var thinking registry.ThinkingSupport + if errUnmarshal := json.Unmarshal(data, &thinking); errUnmarshal != nil { + return nil + } + return &thinking +} + func homeModelInt64Value(model map[string]any, keys ...string) int64 { for _, key := range keys { switch value := model[key].(type) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 3b8eab9b..0e90743f 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -9,6 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "sync" "sync/atomic" @@ -2497,7 +2498,13 @@ func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { { "name": "models/gemini-3-pro", "inputTokenLimit": 1048576, - "outputTokenLimit": 65536 + "outputTokenLimit": 65536, + "thinking": { + "min": 128, + "max": 65535, + "dynamic_allowed": true, + "levels": ["low", "medium", "high"] + } } ] }`)) @@ -2523,6 +2530,17 @@ func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { if geminiEntry.contextLength != 1048576 || geminiEntry.maxCompletionTokens != 65536 { t.Fatalf("gemini token metadata = %d/%d, want 1048576/65536", geminiEntry.contextLength, geminiEntry.maxCompletionTokens) } + if geminiEntry.thinking == nil || !reflect.DeepEqual(geminiEntry.thinking.Levels, []string{"low", "medium", "high"}) { + t.Fatalf("gemini thinking metadata = %#v, want low/medium/high", geminiEntry.thinking) + } + + formatted := formatHomeCodexModel(geminiEntry) + if got := homeModelInt64Value(formatted, "context_length"); got != 1048576 { + t.Fatalf("formatted Gemini context_length = %d, want 1048576", got) + } + if got, ok := formatted["thinking"].(*registry.ThinkingSupport); !ok || !reflect.DeepEqual(got.Levels, []string{"low", "medium", "high"}) { + t.Fatalf("formatted Gemini thinking metadata = %#v, want low/medium/high", formatted["thinking"]) + } } func TestHomeModelsAuthStatus(t *testing.T) { diff --git a/internal/client/codex/models/models.go b/internal/client/codex/models/models.go index a21fc59d..7b807c67 100644 --- a/internal/client/codex/models/models.go +++ b/internal/client/codex/models/models.go @@ -251,6 +251,7 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st displayName := stringModelValue(model, "display_name") description := stringModelValue(model, "description") contextWindow := intModelValue(model, "context_length") + thinkingSupport := codexClientThinkingSupport(model) if info != nil { if info.DisplayName != "" { @@ -259,7 +260,7 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st if info.Description != "" { description = info.Description } - if info.ContextLength > 0 { + if contextWindow <= 0 && info.ContextLength > 0 { contextWindow = info.ContextLength } if info.Type == registry.OpenAIImageModelType { @@ -269,8 +270,11 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st } else { applyCodexClientInputModalitiesMetadata(entry, info.SupportedInputModalities) } - applyCodexClientThinkingMetadata(entry, info.Thinking, clientVersion) + if thinkingSupport == nil { + thinkingSupport = info.Thinking + } } + applyCodexClientThinkingMetadata(entry, thinkingSupport, clientVersion) if maxContextWindow := intModelValue(model, "max_context_length"); maxContextWindow > 0 { contextWindow = maxContextWindow @@ -308,6 +312,28 @@ func applyCodexClientModelMetadata(entry map[string]any, id string, model map[st } } +func codexClientThinkingSupport(model map[string]any) *registry.ThinkingSupport { + raw, ok := model["thinking"] + if !ok || raw == nil { + return nil + } + switch thinking := raw.(type) { + case *registry.ThinkingSupport: + return thinking + case registry.ThinkingSupport: + return &thinking + } + data, errMarshal := json.Marshal(raw) + if errMarshal != nil { + return nil + } + var thinking registry.ThinkingSupport + if errUnmarshal := json.Unmarshal(data, &thinking); errUnmarshal != nil { + return nil + } + return &thinking +} + 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-image-2.0", "grok-imagine-video", "grok-imagine-video-1.5", "grok-imagine-video-1.5-preview": diff --git a/internal/client/codex/models/models_test.go b/internal/client/codex/models/models_test.go index 13994c28..a9029b17 100644 --- a/internal/client/codex/models/models_test.go +++ b/internal/client/codex/models/models_test.go @@ -458,3 +458,38 @@ func TestCodexClientModelsResponseMapsMaxCompletionTokensToMaxTokens(t *testing. } } } + +func TestCodexClientModelsResponseUsesProvidedCapabilitiesForNewHomeModel(t *testing.T) { + const modelID = "gemini-new-home-model-test" + const wantContextWindow = 1048576 + + resp := BuildResponse([]map[string]any{{ + "id": modelID, + "context_length": wantContextWindow, + "thinking": ®istry.ThinkingSupport{ + Levels: []string{"low", "medium", "high"}, + }, + }}, nil, false) + models, ok := resp["models"].([]map[string]any) + if !ok || len(models) != 1 { + t.Fatalf("models = %#v, want one model", resp["models"]) + } + model := models[0] + if got := intModelValue(model, "context_window"); got != wantContextWindow { + t.Fatalf("context_window = %d, want %d", got, wantContextWindow) + } + if got := intModelValue(model, "max_context_window"); got != wantContextWindow { + t.Fatalf("max_context_window = %d, want %d", got, wantContextWindow) + } + + rawLevels, ok := model["supported_reasoning_levels"].([]any) + if !ok || len(rawLevels) != 3 { + t.Fatalf("supported_reasoning_levels = %#v, want low/medium/high", model["supported_reasoning_levels"]) + } + for index, want := range []string{"low", "medium", "high"} { + level, ok := rawLevels[index].(map[string]any) + if !ok || stringModelValue(level, "effort") != want { + t.Fatalf("supported_reasoning_levels[%d] = %#v, want %q", index, rawLevels[index], want) + } + } +}