mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 14:39:26 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
34
internal/config/claude_code_test.go
Normal file
34
internal/config/claude_code_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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").
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user