diff --git a/config.example.yaml b/config.example.yaml index 0db308f97..83959ea7f 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -169,6 +169,11 @@ transient-error-cooldown-seconds: 0 # "auto" behavior (cloak only non-Claude-Code clients). disable-claude-cloak-mode: false +# Claude Code compatibility settings. +claude-code: + # When true, return original model IDs in Anthropic model list responses instead of cloaked IDs. + disable-cloaking-model-list: false + # disable-image-generation supports: false (default), true, "chat", or "passthrough". # - true: disable image_generation everywhere (also returns 404 for /v1/images/generations and /v1/images/edits). # - "chat": disable image_generation injection on non-images endpoints, but keep /v1/images/generations and /v1/images/edits enabled. diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index e79015807..98127711b 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -571,7 +571,8 @@ func (s *Server) handleHomeModels(c *gin.Context) { isClaude := isAnthropicModelsRequest(c) if isClaude { - c.JSON(http.StatusOK, claudemodels.BuildResponse(formatHomeClaudeModels(entries))) + disableCloaking := s.cfg != nil && s.cfg.ClaudeCode.DisableCloakingModelList + c.JSON(http.StatusOK, claudemodels.BuildResponse(formatHomeClaudeModels(entries), disableCloaking)) return } diff --git a/internal/api/server_test.go b/internal/api/server_test.go index cd33f7dee..bdcb91b69 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -17,6 +17,7 @@ import ( gin "github.com/gin-gonic/gin" managementHandlers "github.com/router-for-me/CLIProxyAPI/v7/internal/api/handlers/management" + claudemodels "github.com/router-for-me/CLIProxyAPI/v7/internal/client/claude/models" 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/pluginhost" @@ -1434,6 +1435,56 @@ func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) { }) } +func TestClaudeModelListCloakingConfigHotReload(t *testing.T) { + modelRegistry := registry.GetGlobalRegistry() + clientID := "test-claude-model-list-cloaking-hot-reload" + const modelID = "gpt-model-list-hot-reload" + modelRegistry.RegisterClient(clientID, "claude", []*registry.ModelInfo{{ + ID: modelID, Object: "model", OwnedBy: "test", Type: "openai", + }}) + t.Cleanup(func() { + modelRegistry.UnregisterClient(clientID) + }) + + server := newTestServer(t) + assertModelID := func(want string) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/v1/models", nil) + req.Header.Set("Authorization", "Bearer test-key") + req.Header.Set("Anthropic-Version", "2023-06-01") + + recorder := httptest.NewRecorder() + server.engine.ServeHTTP(recorder, req) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", recorder.Code, http.StatusOK, recorder.Body.String()) + } + + var response struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode response: %v", errUnmarshal) + } + for _, model := range response.Data { + if model.ID == want { + return + } + } + t.Fatalf("model %q not found in response: %s", want, recorder.Body.String()) + } + + assertModelID(claudemodels.EnsureClaudeModelIDPrefix(modelID)) + + updatedCfg := *server.cfg + updatedCfg.SDKConfig = server.cfg.SDKConfig + updatedCfg.ClaudeCode.DisableCloakingModelList = true + server.UpdateClients(&updatedCfg) + + assertModelID(modelID) +} + func TestModelsWithClientVersionReturnsCodexCatalog(t *testing.T) { modelRegistry := registry.GetGlobalRegistry() clientID := "test-client-version-catalog" diff --git a/internal/client/claude/models/models.go b/internal/client/claude/models/models.go index 565ad2c14..60e09baf5 100644 --- a/internal/client/claude/models/models.go +++ b/internal/client/claude/models/models.go @@ -9,11 +9,11 @@ import ( const claudeDDModelPrefix = "claude-fable-5-dd-" // BuildResponse builds an Anthropic model response from available models. -func BuildResponse(availableModels []map[string]any) map[string]any { +func BuildResponse(availableModels []map[string]any, disableCloaking bool) map[string]any { models := make([]map[string]any, len(availableModels)) for i, model := range availableModels { models[i] = cloneModel(model) - if id, ok := models[i]["id"].(string); ok { + if id, ok := models[i]["id"].(string); ok && !disableCloaking { models[i]["id"] = EnsureClaudeModelIDPrefix(id) } } diff --git a/internal/client/claude/models/models_test.go b/internal/client/claude/models/models_test.go index 45eecbdf6..d251b6028 100644 --- a/internal/client/claude/models/models_test.go +++ b/internal/client/claude/models/models_test.go @@ -10,7 +10,7 @@ func TestBuildResponse(t *testing.T) { {"id": "claude-b", "display_name": "Beta"}, } - response := BuildResponse(availableModels) + response := BuildResponse(availableModels, false) models, ok := response["data"].([]map[string]any) if !ok { t.Fatalf("data type = %T, want []map[string]any", response["data"]) @@ -51,8 +51,32 @@ func TestBuildResponse(t *testing.T) { } } +func TestBuildResponseWithCloakingDisabled(t *testing.T) { + availableModels := []map[string]any{ + {"id": "gpt-4o", "display_name": "GPT-4o"}, + } + + response := BuildResponse(availableModels, true) + models, ok := response["data"].([]map[string]any) + if !ok { + t.Fatalf("data type = %T, want []map[string]any", response["data"]) + } + if len(models) != 1 { + t.Fatalf("len(data) = %d, want 1", len(models)) + } + if got := models[0]["id"]; got != "gpt-4o" { + t.Fatalf("data[0].id = %v, want gpt-4o", got) + } + if got := response["first_id"]; got != "gpt-4o" { + t.Fatalf("first_id = %v, want gpt-4o", got) + } + if got := response["last_id"]; got != "gpt-4o" { + t.Fatalf("last_id = %v, want gpt-4o", got) + } +} + func TestBuildResponseEmpty(t *testing.T) { - response := BuildResponse(nil) + response := BuildResponse(nil, false) models, ok := response["data"].([]map[string]any) if !ok { t.Fatalf("data type = %T, want []map[string]any", response["data"]) diff --git a/internal/config/claude_code_test.go b/internal/config/claude_code_test.go new file mode 100644 index 000000000..eb5bd9daa --- /dev/null +++ b/internal/config/claude_code_test.go @@ -0,0 +1,34 @@ +package config + +import "testing" + +func TestParseConfigBytesClaudeCodeModelListCloaking(t *testing.T) { + tests := []struct { + name string + yaml string + want bool + }{ + { + name: "defaults to enabled cloaking", + yaml: "port: 8317\n", + want: false, + }, + { + name: "disables model list cloaking", + yaml: "claude-code:\n disable-cloaking-model-list: true\n", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, errParse := ParseConfigBytes([]byte(tt.yaml)) + if errParse != nil { + t.Fatalf("ParseConfigBytes() error = %v", errParse) + } + if got := cfg.ClaudeCode.DisableCloakingModelList; got != tt.want { + t.Fatalf("DisableCloakingModelList = %t, want %t", got, tt.want) + } + }) + } +} diff --git a/internal/config/sdk_config.go b/internal/config/sdk_config.go index e24888bf8..c7a53ffb3 100644 --- a/internal/config/sdk_config.go +++ b/internal/config/sdk_config.go @@ -45,6 +45,9 @@ type SDKConfig struct { // CodexOptimizeMultiAgentV2 mirrors the provider-wide runtime setting for API handlers. CodexOptimizeMultiAgentV2 bool `yaml:"-" json:"-"` + // ClaudeCode configures Claude Code compatibility behavior. + ClaudeCode ClaudeCodeConfig `yaml:"claude-code" json:"claude-code"` + // APIKeys is a list of keys for authenticating clients to this proxy server. APIKeys []string `yaml:"api-keys" json:"api-keys"` @@ -60,6 +63,12 @@ type SDKConfig struct { NonStreamKeepAliveInterval int `yaml:"nonstream-keepalive-interval,omitempty" json:"nonstream-keepalive-interval,omitempty"` } +// ClaudeCodeConfig configures Claude Code compatibility behavior. +type ClaudeCodeConfig struct { + // DisableCloakingModelList disables model ID cloaking in Anthropic model list responses. + DisableCloakingModelList bool `yaml:"disable-cloaking-model-list" json:"disable-cloaking-model-list"` +} + // StreamingConfig holds server streaming behavior configuration. type StreamingConfig struct { // KeepAliveSeconds controls how often the server emits SSE heartbeats (": keep-alive\n\n"). diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index cfa1c2f42..e89eeed91 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -54,6 +54,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if oldCfg.DisableClaudeCloakMode != newCfg.DisableClaudeCloakMode { changes = append(changes, fmt.Sprintf("disable-claude-cloak-mode: %t -> %t", oldCfg.DisableClaudeCloakMode, newCfg.DisableClaudeCloakMode)) } + if oldCfg.ClaudeCode.DisableCloakingModelList != newCfg.ClaudeCode.DisableCloakingModelList { + changes = append(changes, fmt.Sprintf("claude-code.disable-cloaking-model-list: %t -> %t", oldCfg.ClaudeCode.DisableCloakingModelList, newCfg.ClaudeCode.DisableCloakingModelList)) + } if oldCfg.DisableImageGeneration != newCfg.DisableImageGeneration { changes = append(changes, fmt.Sprintf("disable-image-generation: %v -> %v", oldCfg.DisableImageGeneration, newCfg.DisableImageGeneration)) } diff --git a/internal/watcher/diff/config_diff_test.go b/internal/watcher/diff/config_diff_test.go index 20b563923..4a365b2a8 100644 --- a/internal/watcher/diff/config_diff_test.go +++ b/internal/watcher/diff/config_diff_test.go @@ -374,6 +374,9 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { ForceModelPrefix: true, NonStreamKeepAliveInterval: 5, DisableImageGeneration: config.DisableImageGenerationAll, + ClaudeCode: sdkconfig.ClaudeCodeConfig{ + DisableCloakingModelList: true, + }, }, } @@ -385,6 +388,7 @@ func TestBuildConfigChangeDetails_FlagsAndKeys(t *testing.T) { expectContains(t, details, "save-cooldown-status: false -> true") expectContains(t, details, "transient-error-cooldown-seconds: 0 -> -1") expectContains(t, details, "disable-image-generation: false -> true") + expectContains(t, details, "claude-code.disable-cloaking-model-list: false -> true") expectContains(t, details, "request-log: false -> true") expectContains(t, details, "request-retry: 1 -> 2") expectContains(t, details, "max-retry-credentials: 1 -> 3") diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index 5d3fc4b35..8fd42a6ff 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -154,7 +154,8 @@ func rewriteClaudeDDModelInBody(rawJSON []byte) []byte { // Parameters: // - c: The Gin context for the request. func (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) { - c.JSON(http.StatusOK, claudemodels.BuildResponse(h.Models())) + disableCloaking := h.Cfg != nil && h.Cfg.ClaudeCode.DisableCloakingModelList + c.JSON(http.StatusOK, claudemodels.BuildResponse(h.Models(), disableCloaking)) } // handleNonStreamingResponse handles non-streaming content generation requests for Claude models. diff --git a/sdk/api/handlers/claude/code_handlers_model_test.go b/sdk/api/handlers/claude/code_handlers_model_test.go index 9a6e8ef5c..6571a4844 100644 --- a/sdk/api/handlers/claude/code_handlers_model_test.go +++ b/sdk/api/handlers/claude/code_handlers_model_test.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" + sdkconfig "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" "github.com/tidwall/gjson" ) @@ -46,6 +47,40 @@ func TestClaudeModelsResponseUsesConfiguredDisplayName(t *testing.T) { t.Fatalf("model %q not found in response", modelID) } +func TestClaudeModelsResponseDisablesModelListCloaking(t *testing.T) { + const clientID = "claude-disable-model-list-cloaking-test" + const modelID = "gpt-disable-model-list-cloaking-test" + registryRef := registry.GetGlobalRegistry() + registryRef.RegisterClient(clientID, "claude", []*registry.ModelInfo{{ + ID: modelID, Object: "model", OwnedBy: "test", + }}) + t.Cleanup(func() { + registryRef.UnregisterClient(clientID) + }) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + baseHandler := &handlers.BaseAPIHandler{Cfg: &sdkconfig.SDKConfig{ + ClaudeCode: sdkconfig.ClaudeCodeConfig{DisableCloakingModelList: true}, + }} + NewClaudeCodeAPIHandler(baseHandler).ClaudeModels(ctx) + + var response struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if errUnmarshal := json.Unmarshal(recorder.Body.Bytes(), &response); errUnmarshal != nil { + t.Fatalf("decode response: %v", errUnmarshal) + } + for _, model := range response.Data { + if model.ID == modelID { + return + } + } + t.Fatalf("uncloaked model %q not found in response", modelID) +} + func TestRewriteClaudeDDModelInBody(t *testing.T) { tests := []struct { name string diff --git a/sdk/config/config.go b/sdk/config/config.go index c7ec3c5b9..73ed34230 100644 --- a/sdk/config/config.go +++ b/sdk/config/config.go @@ -11,6 +11,7 @@ type SDKConfig = internalconfig.SDKConfig type Config = internalconfig.Config type StreamingConfig = internalconfig.StreamingConfig +type ClaudeCodeConfig = internalconfig.ClaudeCodeConfig type TLSConfig = internalconfig.TLSConfig type RemoteManagement = internalconfig.RemoteManagement type OAuthModelAlias = internalconfig.OAuthModelAlias