feat(config): add support for disabling model list cloaking in Claude Code

- Introduced `DisableCloakingModelList` in `ClaudeCodeConfig` to control model ID cloaking in Anthropic model list responses.
- Updated relevant APIs and handlers to respect the new configuration.
- Added comprehensive tests for enabling/disabling cloaking behavior and config-driven hot reload scenarios.
- Extended example configuration and documentation to include the new setting.

Closes: #4473
This commit is contained in:
Luis Pater
2026-07-27 18:16:41 +08:00
parent 181242b1c0
commit 6914478562
12 changed files with 174 additions and 6 deletions

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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"

View File

@@ -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)
}
}

View File

@@ -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"])

View File

@@ -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)
}
})
}
}

View File

@@ -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").

View File

@@ -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))
}

View File

@@ -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")

View File

@@ -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.

View File

@@ -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

View File

@@ -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