diff --git a/internal/api/handlers/management/auth_files.go b/internal/api/handlers/management/auth_files.go index a960b586..5c044e0c 100644 --- a/internal/api/handlers/management/auth_files.go +++ b/internal/api/handlers/management/auth_files.go @@ -1281,7 +1281,21 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { return } if coreauth.IsPluginVirtualAuth(targetAuth) { - c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) + // Allow status changes only when targeting the source auth file name, matching delete semantics. + // Expanded virtual project auths still cannot be modified independently. + if !isPluginVirtualSourceDelete(name, targetAuth) { + c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()}) + return + } + if errPatch := h.patchPluginVirtualSourceStatus(ctx, targetAuth, *req.Disabled); errPatch != nil { + status := http.StatusInternalServerError + if errors.Is(errPatch, errAuthFileNotFound) || os.IsNotExist(errPatch) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": errPatch.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) return } @@ -1316,17 +1330,7 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { return } - // Update disabled state - targetAuth.Disabled = *req.Disabled - if *req.Disabled { - targetAuth.Status = coreauth.StatusDisabled - targetAuth.StatusMessage = "disabled via management API" - } else { - targetAuth.Status = coreauth.StatusActive - targetAuth.StatusMessage = "" - } - targetAuth.UpdatedAt = time.Now() - + applyAuthDisabledState(targetAuth, *req.Disabled) if _, err := h.authManager.Update(ctx, targetAuth); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)}) return @@ -1335,6 +1339,91 @@ func (h *Handler) PatchAuthFileStatus(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled}) } +// patchPluginVirtualSourceStatus toggles disabled on a plugin multi-auth source file and all +// runtime auths expanded from it. Virtual project children cannot be toggled independently. +func (h *Handler) patchPluginVirtualSourceStatus(ctx context.Context, targetAuth *coreauth.Auth, disabled bool) error { + if h == nil || h.authManager == nil || targetAuth == nil { + return fmt.Errorf("core auth manager unavailable") + } + sourcePath := strings.TrimSpace(authAttribute(targetAuth, coreauth.AttributeVirtualSource)) + if sourcePath == "" { + sourcePath = strings.TrimSpace(authAttribute(targetAuth, "path")) + } + if sourcePath == "" { + return errPluginVirtualAuth + } + if errWrite := setSourceAuthFileDisabled(sourcePath, disabled); errWrite != nil { + if os.IsNotExist(errWrite) { + return errAuthFileNotFound + } + return fmt.Errorf("failed to update source auth file: %w", errWrite) + } + now := time.Now() + for _, auth := range h.authManager.List() { + if auth == nil { + continue + } + if !sameAuthFilePath(authAttribute(auth, "path"), sourcePath) && + !sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), sourcePath) { + continue + } + applyAuthDisabledState(auth, disabled) + auth.UpdatedAt = now + if _, errUpdate := h.authManager.Update(ctx, auth); errUpdate != nil { + return fmt.Errorf("failed to update auth %s: %w", auth.ID, errUpdate) + } + } + return nil +} + +func setSourceAuthFileDisabled(path string, disabled bool) error { + path = strings.TrimSpace(path) + if path == "" { + return fmt.Errorf("source auth path is empty") + } + data, errRead := os.ReadFile(path) + if errRead != nil { + return errRead + } + metadata := make(map[string]any) + if len(bytes.TrimSpace(data)) > 0 { + if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil { + return fmt.Errorf("invalid auth file: %w", errUnmarshal) + } + } + if metadata == nil { + metadata = make(map[string]any) + } + metadata["disabled"] = disabled + raw, errMarshal := json.Marshal(metadata) + if errMarshal != nil { + return fmt.Errorf("marshal auth file: %w", errMarshal) + } + if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil { + return errWrite + } + return nil +} + +func applyAuthDisabledState(auth *coreauth.Auth, disabled bool) { + if auth == nil { + return + } + auth.Disabled = disabled + if disabled { + auth.Status = coreauth.StatusDisabled + auth.StatusMessage = "disabled via management API" + } else { + auth.Status = coreauth.StatusActive + auth.StatusMessage = "" + } + auth.UpdatedAt = time.Now() + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = disabled +} + // PatchAuthFileFields updates arbitrary metadata fields of an auth file. func (h *Handler) PatchAuthFileFields(c *gin.Context) { if h.authManager == nil { diff --git a/internal/api/handlers/management/auth_files_plugin_oauth_test.go b/internal/api/handlers/management/auth_files_plugin_oauth_test.go index 29acc762..452500fe 100644 --- a/internal/api/handlers/management/auth_files_plugin_oauth_test.go +++ b/internal/api/handlers/management/auth_files_plugin_oauth_test.go @@ -83,7 +83,7 @@ func TestSavePluginLoginRecordsRollsBackSavedAuthsOnFailure(t *testing.T) { } } -func TestPatchPluginVirtualAuthStatusReturnsConflict(t *testing.T) { +func TestPatchPluginVirtualAuthStatusReturnsConflictForVirtualChild(t *testing.T) { manager := coreauth.NewManager(nil, nil, nil) auth := pluginVirtualAuthForTest(t.TempDir(), "source.json", "auth-1") if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { @@ -104,6 +104,52 @@ func TestPatchPluginVirtualAuthStatusReturnsConflict(t *testing.T) { } } +func TestPatchPluginVirtualSourceStatusDisablesAllExpandedAuths(t *testing.T) { + authDir := t.TempDir() + fileName := "source.json" + filePath := filepath.Join(authDir, fileName) + if errWrite := os.WriteFile(filePath, []byte(`{"type":"gemini-cli","disabled":false}`), 0o600); errWrite != nil { + t.Fatalf("write source auth file: %v", errWrite) + } + + manager := coreauth.NewManager(nil, nil, nil) + for _, id := range []string{"source.json", "virtual-project-a"} { + auth := pluginVirtualAuthForTest(authDir, fileName, id) + if _, errRegister := manager.Register(context.Background(), auth); errRegister != nil { + t.Fatalf("register virtual auth %s: %v", id, errRegister) + } + } + + h := NewHandlerWithoutConfigFilePath(&config.Config{AuthDir: authDir}, manager) + rec := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(rec) + req := httptest.NewRequest(http.MethodPatch, "/v0/management/auth-files/status", strings.NewReader(`{"name":"source.json","disabled":true}`)) + req.Header.Set("Content-Type", "application/json") + ctx.Request = req + + h.PatchAuthFileStatus(ctx) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + raw, errRead := os.ReadFile(filePath) + if errRead != nil { + t.Fatalf("read source auth file: %v", errRead) + } + if !strings.Contains(string(raw), `"disabled":true`) { + t.Fatalf("source auth file = %s, want disabled:true", string(raw)) + } + for _, id := range []string{"source.json", "virtual-project-a"} { + auth, ok := manager.GetByID(id) + if !ok || auth == nil { + t.Fatalf("expected auth %s to remain registered", id) + } + if !auth.Disabled || auth.Status != coreauth.StatusDisabled { + t.Fatalf("auth %s disabled/status = %v/%s, want disabled", id, auth.Disabled, auth.Status) + } + } +} + func TestPatchPluginVirtualAuthFieldsReturnsConflict(t *testing.T) { manager := coreauth.NewManager(nil, nil, nil) auth := pluginVirtualAuthForTest(t.TempDir(), "source.json", "auth-1") diff --git a/internal/api/server.go b/internal/api/server.go index 35eacd34..f7bce5d9 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1196,6 +1196,16 @@ func formatHomeClaudeModels(entries []homeModelEntry) []map[string]any { for _, entry := range entries { out = append(out, formatHomeClaudeModel(entry)) } + sort.SliceStable(out, func(i, j int) bool { + di, _ := out[i]["display_name"].(string) + dj, _ := out[j]["display_name"].(string) + if di != dj { + return di < dj + } + idi, _ := out[i]["id"].(string) + idj, _ := out[j]["id"].(string) + return idi < idj + }) return out } @@ -1213,7 +1223,7 @@ func formatHomeClaudeModel(entry homeModelEntry) map[string]any { maxOutput = registry.DefaultClaudeMaxOutputTokens } model := map[string]any{ - "id": entry.id, + "id": util.EnsureClaudeModelIDPrefix(entry.id), "object": "model", "owned_by": entry.ownedBy, "type": "model", diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 0bb8b5a1..d7fba41a 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -483,6 +483,12 @@ func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) { ContextLength: 200000, MaxCompletionTokens: 64000, }, + { + ID: "gpt-4o", + Object: "model", + OwnedBy: "openai", + Type: "openai", + }, }) t.Cleanup(func() { modelRegistry.UnregisterClient(clientID) @@ -519,14 +525,24 @@ func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) { } var claudeModel map[string]any + var rewrittenModel map[string]any for _, m := range resp.Data { - if id, _ := m["id"].(string); id == "claude-sonnet-4-6" { + id, _ := m["id"].(string) + switch id { + case "claude-sonnet-4-6": claudeModel = m + case "claude-fable-5-dd-o4-tpg": + rewrittenModel = m + case "gpt-4o", "claude-gpt-4o": + t.Fatalf("expected non-claude model id to be rewritten as claude-fable-5-dd-, got %q", id) } } if claudeModel == nil { t.Fatalf("expected claude-sonnet-4-6 in response, got %s", rr.Body.String()) } + if rewrittenModel == nil { + t.Fatalf("expected claude-fable-5-dd-o4-tpg in response, got %s", rr.Body.String()) + } for _, field := range []string{"max_input_tokens", "max_tokens", "display_name"} { if _, ok := claudeModel[field]; !ok { t.Fatalf("expected Claude model to include %q, got %v", field, claudeModel) @@ -556,10 +572,20 @@ func TestModelsDispatchByAnthropicVersionHeader(t *testing.T) { if resp.Object != "list" { t.Fatalf("expected OpenAI format (object=list), got %s", rr.Body.String()) } + foundRawGPT := false for _, m := range resp.Data { if _, ok := m["max_input_tokens"]; ok { t.Fatalf("did not expect max_input_tokens in OpenAI format, got %v", m) } + if id, _ := m["id"].(string); id == "gpt-4o" { + foundRawGPT = true + } + if id, _ := m["id"].(string); id == "claude-gpt-4o" || id == "claude-fable-5-dd-o4-tpg" { + t.Fatalf("did not expect Anthropic id rewrite on OpenAI format models, got %v", m) + } + } + if !foundRawGPT { + t.Fatalf("expected raw gpt-4o in OpenAI format response, got %s", rr.Body.String()) } }) } @@ -870,6 +896,14 @@ func TestFormatHomeClaudeModelIncludesAnthropicSchemaFields(t *testing.T) { if got := withDefaults["display_name"]; got != "claude-no-limits" { t.Fatalf("display_name fallback = %v, want claude-no-limits", got) } + + prefixed := formatHomeClaudeModel(homeModelEntry{id: "gpt-4o", displayName: "GPT-4o"}) + if got := prefixed["id"]; got != "claude-fable-5-dd-o4-tpg" { + t.Fatalf("id = %v, want claude-fable-5-dd-o4-tpg", got) + } + if got := prefixed["display_name"]; got != "GPT-4o" { + t.Fatalf("display_name = %v, want GPT-4o", got) + } if got := withDefaults["max_input_tokens"]; got != registry.DefaultClaudeMaxInputTokens { t.Fatalf("max_input_tokens fallback = %v, want %d", got, registry.DefaultClaudeMaxInputTokens) } @@ -881,6 +915,24 @@ func TestFormatHomeClaudeModelIncludesAnthropicSchemaFields(t *testing.T) { } } +func TestFormatHomeClaudeModelsSortsByDisplayName(t *testing.T) { + out := formatHomeClaudeModels([]homeModelEntry{ + {id: "claude-z", displayName: "Zebra"}, + {id: "gpt-4o", displayName: "Alpha"}, + {id: "claude-b", displayName: "Beta"}, + }) + if len(out) != 3 { + t.Fatalf("len(out) = %d, want 3", len(out)) + } + wantNames := []string{"Alpha", "Beta", "Zebra"} + for i, want := range wantNames { + got, _ := out[i]["display_name"].(string) + if got != want { + t.Fatalf("out[%d].display_name = %q, want %q", i, got, want) + } + } +} + func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { entries, errDecode := decodeHomeModels([]byte(`{ "claude": [ diff --git a/internal/util/claude_model.go b/internal/util/claude_model.go index 1534f02c..ff3ef892 100644 --- a/internal/util/claude_model.go +++ b/internal/util/claude_model.go @@ -8,3 +8,56 @@ func IsClaudeThinkingModel(model string) bool { lower := strings.ToLower(model) return strings.Contains(lower, "claude") && strings.Contains(lower, "thinking") } + +const claudeDDModelPrefix = "claude-fable-5-dd-" + +// EnsureClaudeModelIDPrefix rewrites model IDs for Anthropic /models listings. +// IDs that already start with "claude-" are returned unchanged; all other IDs +// become "claude-fable-5-dd-" plus the original ID with its characters reversed. +func EnsureClaudeModelIDPrefix(id string) string { + if id == "" { + return id + } + if strings.HasPrefix(id, "claude-") { + return id + } + return claudeDDModelPrefix + reverseModelID(id) +} + +// ResolveClaudeModelIDPrefix reverses EnsureClaudeModelIDPrefix for request routing. +// IDs that start with "claude-fable-5-dd-" are decoded by stripping the prefix and reversing +// the remainder. Optional thinking suffixes in model(value) form are preserved. +func ResolveClaudeModelIDPrefix(id string) string { + if id == "" { + return id + } + base, suffix, hasSuffix := splitModelThinkingSuffix(id) + if !strings.HasPrefix(base, claudeDDModelPrefix) { + return id + } + encoded := base[len(claudeDDModelPrefix):] + if encoded == "" { + return id + } + resolved := reverseModelID(encoded) + if hasSuffix { + return resolved + "(" + suffix + ")" + } + return resolved +} + +func splitModelThinkingSuffix(model string) (base, suffix string, hasSuffix bool) { + lastOpen := strings.LastIndex(model, "(") + if lastOpen == -1 || !strings.HasSuffix(model, ")") { + return model, "", false + } + return model[:lastOpen], model[lastOpen+1 : len(model)-1], true +} + +func reverseModelID(id string) string { + runes := []rune(id) + for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { + runes[i], runes[j] = runes[j], runes[i] + } + return string(runes) +} diff --git a/internal/util/claude_model_test.go b/internal/util/claude_model_test.go index d20c337d..8fb29c37 100644 --- a/internal/util/claude_model_test.go +++ b/internal/util/claude_model_test.go @@ -40,3 +40,51 @@ func TestIsClaudeThinkingModel(t *testing.T) { }) } } + +func TestEnsureClaudeModelIDPrefix(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"empty", "", ""}, + {"already has claude prefix", "claude-sonnet-4-6", "claude-sonnet-4-6"}, + {"contains claude mid-string is reversed", "my-claude-custom", "claude-fable-5-dd-motsuc-edualc-ym"}, + {"uppercase Claude prefix is reversed", "Claude-Opus-4", "claude-fable-5-dd-4-supO-edualC"}, + {"gpt model is reversed", "gpt-4o", "claude-fable-5-dd-o4-tpg"}, + {"gemini model is reversed", "gemini-2.5-pro", "claude-fable-5-dd-orp-5.2-inimeg"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EnsureClaudeModelIDPrefix(tt.id); got != tt.want { + t.Fatalf("EnsureClaudeModelIDPrefix(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} + +func TestResolveClaudeModelIDPrefix(t *testing.T) { + tests := []struct { + name string + id string + want string + }{ + {"empty", "", ""}, + {"plain claude id unchanged", "claude-sonnet-4-6", "claude-sonnet-4-6"}, + {"non encoded id unchanged", "gpt-4o", "gpt-4o"}, + {"encoded gpt model", "claude-fable-5-dd-o4-tpg", "gpt-4o"}, + {"encoded gemini model", "claude-fable-5-dd-orp-5.2-inimeg", "gemini-2.5-pro"}, + {"empty encoded body unchanged", "claude-fable-5-dd-", "claude-fable-5-dd-"}, + {"preserves thinking suffix", "claude-fable-5-dd-o4-tpg(high)", "gpt-4o(high)"}, + {"round trip", EnsureClaudeModelIDPrefix("custom-model-x"), "custom-model-x"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ResolveClaudeModelIDPrefix(tt.id); got != tt.want { + t.Fatalf("ResolveClaudeModelIDPrefix(%q) = %q, want %q", tt.id, got, tt.want) + } + }) + } +} diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 44abdded..2b19759c 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -94,6 +94,7 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] } perAccountExcluded := extractExcludedModelsFromMetadata(metadata) perAccountModelAliases := extractOAuthModelAliasesFromMetadata(metadata) + disabled, _ := metadata["disabled"].(bool) for index, auth := range auths { if auth == nil { continue @@ -109,6 +110,14 @@ func synthesizeFileAuths(ctx *SynthesisContext, fullPath string, data []byte) [] auth.Attributes[coreauth.AttributePath] = fullPath auth.Attributes[coreauth.AttributeSource] = fullPath auth.Attributes[coreauth.AttributeSourceBackend] = coreauth.AuthSourceFile + if disabled { + auth.Disabled = true + auth.Status = coreauth.StatusDisabled + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = true + } coreauth.SetOAuthModelAliasesAttribute(auth, perAccountModelAliases) ApplyAuthExcludedModelsMeta(auth, cfg, perAccountExcluded, "oauth") coreauth.ApplyCustomHeadersFromMetadata(auth) diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index b5238554..caac1c13 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -231,6 +231,37 @@ func TestSynthesizeAuthFileExpandsPluginMultiAuths(t *testing.T) { } } +func TestSynthesizeAuthFileAppliesSourceDisabledToPluginMultiAuths(t *testing.T) { + tempDir := t.TempDir() + fullPath := filepath.Join(tempDir, "geminicli.json") + raw := []byte(`{"type":"gemini-cli","disabled":true}`) + + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Date(2026, 6, 21, 0, 0, 0, 0, time.UTC), + PluginAuthParser: multiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*coreauth.Auth, bool, error) { + return []*coreauth.Auth{ + {ID: "geminicli.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli"}}, + {ID: "geminicli-project-a.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}}, + }, true, nil + }), + } + + auths := SynthesizeAuthFile(ctx, fullPath, raw) + if len(auths) != 2 { + t.Fatalf("SynthesizeAuthFile() len = %d, want two plugin auths", len(auths)) + } + for _, auth := range auths { + if !auth.Disabled || auth.Status != coreauth.StatusDisabled { + t.Fatalf("auth %s disabled/status = %v/%s, want disabled", auth.ID, auth.Disabled, auth.Status) + } + if got, _ := auth.Metadata["disabled"].(bool); !got { + t.Fatalf("auth %s metadata disabled = %#v, want true", auth.ID, auth.Metadata["disabled"]) + } + } +} + func TestSynthesizeAuthFilePluginHandledEmptySuppressesBuiltin(t *testing.T) { tempDir := t.TempDir() fullPath := filepath.Join(tempDir, "codex.json") diff --git a/sdk/api/handlers/claude/code_handlers.go b/sdk/api/handlers/claude/code_handlers.go index 4724a727..e9bdd600 100644 --- a/sdk/api/handlers/claude/code_handlers.go +++ b/sdk/api/handlers/claude/code_handlers.go @@ -14,6 +14,7 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "time" @@ -21,9 +22,11 @@ import ( . "github.com/router-for-me/CLIProxyAPI/v7/internal/constant" "github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces" "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/sdk/api/handlers" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" + "github.com/tidwall/sjson" ) // ClaudeCodeAPIHandler contains the handlers for Claude API endpoints. @@ -78,6 +81,9 @@ func (h *ClaudeCodeAPIHandler) ClaudeMessages(c *gin.Context) { return } + // Decode claude-fable-5-dd- model IDs back to the real model name for routing. + rawJSON = rewriteClaudeDDModelInBody(rawJSON) + // Check if the client requested a streaming response. streamResult := gjson.GetBytes(rawJSON, "stream") if !streamResult.Exists() || streamResult.Type == gjson.False { @@ -107,6 +113,9 @@ func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) { return } + // Decode claude-fable-5-dd- model IDs back to the real model name for routing. + rawJSON = rewriteClaudeDDModelInBody(rawJSON) + c.Header("Content-Type", "application/json") alt := h.GetAlt(c) @@ -125,6 +134,21 @@ func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) { cliCancel() } +// rewriteClaudeDDModelInBody decodes model IDs of the form claude-fable-5-dd- +// back into the original model name used for routing and upstream requests. +func rewriteClaudeDDModelInBody(rawJSON []byte) []byte { + modelName := gjson.GetBytes(rawJSON, "model").String() + resolved := util.ResolveClaudeModelIDPrefix(modelName) + if resolved == modelName { + return rawJSON + } + updated, errSet := sjson.SetBytes(rawJSON, "model", resolved) + if errSet != nil { + return rawJSON + } + return updated +} + // ClaudeModels handles the Claude models listing endpoint. // It returns a JSON response containing available Claude models and their specifications. // @@ -132,6 +156,12 @@ func (h *ClaudeCodeAPIHandler) ClaudeCountTokens(c *gin.Context) { // - c: The Gin context for the request. func (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) { models := h.Models() + for i := range models { + if id, ok := models[i]["id"].(string); ok { + models[i]["id"] = util.EnsureClaudeModelIDPrefix(id) + } + } + sortClaudeModelsByDisplayName(models) firstID := "" lastID := "" if len(models) > 0 { @@ -151,6 +181,21 @@ func (h *ClaudeCodeAPIHandler) ClaudeModels(c *gin.Context) { }) } +// sortClaudeModelsByDisplayName sorts models by display_name ascending. +// When display_name is equal or missing, id is used as a stable tie-breaker. +func sortClaudeModelsByDisplayName(models []map[string]any) { + sort.SliceStable(models, func(i, j int) bool { + di, _ := models[i]["display_name"].(string) + dj, _ := models[j]["display_name"].(string) + if di != dj { + return di < dj + } + idi, _ := models[i]["id"].(string) + idj, _ := models[j]["id"].(string) + return idi < idj + }) +} + // handleNonStreamingResponse handles non-streaming content generation requests for Claude models. // This function processes the request synchronously and returns the complete generated // response in a single API call. It supports various generation parameters and diff --git a/sdk/api/handlers/claude/code_handlers_model_test.go b/sdk/api/handlers/claude/code_handlers_model_test.go new file mode 100644 index 00000000..fe1fe2cd --- /dev/null +++ b/sdk/api/handlers/claude/code_handlers_model_test.go @@ -0,0 +1,63 @@ +package claude + +import ( + "testing" + + "github.com/tidwall/gjson" +) + +func TestSortClaudeModelsByDisplayName(t *testing.T) { + models := []map[string]any{ + {"id": "claude-fable-5-dd-b", "display_name": "Zebra"}, + {"id": "claude-a", "display_name": "Alpha"}, + {"id": "claude-c", "display_name": "Alpha"}, + {"id": "claude-fable-5-dd-d", "display_name": "Beta"}, + } + sortClaudeModelsByDisplayName(models) + + wantIDs := []string{"claude-a", "claude-c", "claude-fable-5-dd-d", "claude-fable-5-dd-b"} + for i, want := range wantIDs { + got, _ := models[i]["id"].(string) + if got != want { + t.Fatalf("models[%d].id = %q, want %q", i, got, want) + } + } +} + +func TestRewriteClaudeDDModelInBody(t *testing.T) { + tests := []struct { + name string + body string + wantModel string + }{ + { + name: "encoded model is decoded", + body: `{"model":"claude-fable-5-dd-o4-tpg","messages":[]}`, + wantModel: "gpt-4o", + }, + { + name: "plain claude model unchanged", + body: `{"model":"claude-sonnet-4-6","messages":[]}`, + wantModel: "claude-sonnet-4-6", + }, + { + name: "encoded model with thinking suffix", + body: `{"model":"claude-fable-5-dd-o4-tpg(high)","stream":true}`, + wantModel: "gpt-4o(high)", + }, + { + name: "missing model field unchanged", + body: `{"messages":[]}`, + wantModel: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := rewriteClaudeDDModelInBody([]byte(tt.body)) + if model := gjson.GetBytes(got, "model").String(); model != tt.wantModel { + t.Fatalf("model = %q, want %q; body=%s", model, tt.wantModel, string(got)) + } + }) + } +} diff --git a/sdk/auth/filestore.go b/sdk/auth/filestore.go index 9e17589d..3f0f608c 100644 --- a/sdk/auth/filestore.go +++ b/sdk/auth/filestore.go @@ -254,6 +254,7 @@ func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Au if len(auths) == 0 { return nil, nil } + disabled, _ := metadata["disabled"].(bool) for index, auth := range auths { if auth == nil { continue @@ -269,6 +270,14 @@ func (s *FileTokenStore) readAuthFiles(path, baseDir string) ([]*cliproxyauth.Au auth.Attributes[cliproxyauth.AttributePath] = path auth.Attributes[cliproxyauth.AttributeSource] = path auth.Attributes[cliproxyauth.AttributeSourceBackend] = cliproxyauth.AuthSourceFile + if disabled { + auth.Disabled = true + auth.Status = cliproxyauth.StatusDisabled + if auth.Metadata == nil { + auth.Metadata = make(map[string]any) + } + auth.Metadata["disabled"] = true + } cliproxyauth.ApplyCustomHeadersFromMetadata(auth) } return auths, nil diff --git a/sdk/auth/filestore_test.go b/sdk/auth/filestore_test.go index 32164bed..fe552ad2 100644 --- a/sdk/auth/filestore_test.go +++ b/sdk/auth/filestore_test.go @@ -158,6 +158,42 @@ func TestFileTokenStoreListExpandsPluginMultiAuths(t *testing.T) { } } +func TestFileTokenStoreListAppliesSourceDisabledToPluginMultiAuths(t *testing.T) { + baseDir := t.TempDir() + path := filepath.Join(baseDir, "geminicli.json") + if errWrite := os.WriteFile(path, []byte(`{"type":"gemini-cli","disabled":true}`), 0o600); errWrite != nil { + t.Fatalf("write auth file: %v", errWrite) + } + + RegisterPluginAuthParser(fileStoreMultiAuthParserFunc(func(context.Context, pluginapi.AuthParseRequest) ([]*cliproxyauth.Auth, bool, error) { + return []*cliproxyauth.Auth{ + {ID: "geminicli.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli"}}, + {ID: "geminicli-project-a.json", Provider: "gemini-cli", Metadata: map[string]any{"type": "gemini-cli", "project_id": "project-a"}}, + }, true, nil + })) + t.Cleanup(func() { + RegisterPluginAuthParser(nil) + }) + + store := NewFileTokenStore() + store.SetBaseDir(baseDir) + auths, errList := store.List(context.Background()) + if errList != nil { + t.Fatalf("List() error = %v", errList) + } + if len(auths) != 2 { + t.Fatalf("List() len = %d, want two plugin auths", len(auths)) + } + for _, auth := range auths { + if !auth.Disabled || auth.Status != cliproxyauth.StatusDisabled { + t.Fatalf("auth %s disabled/status = %v/%s, want disabled", auth.ID, auth.Disabled, auth.Status) + } + if got, _ := auth.Metadata["disabled"].(bool); !got { + t.Fatalf("auth %s metadata disabled = %#v, want true", auth.ID, auth.Metadata["disabled"]) + } + } +} + func TestFileTokenStoreListPluginHandledEmptySuppressesBuiltin(t *testing.T) { baseDir := t.TempDir() path := filepath.Join(baseDir, "codex.json")