feat(auth/models): implement Claude model ID prefix handling and plugin auth disabled state support

- Added `EnsureClaudeModelIDPrefix` and `ResolveClaudeModelIDPrefix` utilities for standardizing and decoding Claude model IDs.
- Updated handling of `claude-fable-5-dd-<reversed>` model IDs for request routing and response formatting.
- Incorporated logic to enforce disabled states for plugin virtual auths and their expanded children.
- Refactored code for applying and persisting disabled state metadata in plugin multi-auth scenarios.
- Implemented and extended unit tests to validate expected behavior.
This commit is contained in:
Luis Pater
2026-07-09 22:39:59 +08:00
parent 1204101ff2
commit ee71dc52b7
12 changed files with 506 additions and 15 deletions
+101 -12
View File
@@ -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 {
@@ -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")
+11 -1
View File
@@ -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",
+53 -1
View File
@@ -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-<reversed>, 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": [
+53
View File
@@ -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)
}
+48
View File
@@ -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)
}
})
}
}
+9
View File
@@ -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)
+31
View File
@@ -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")
+45
View File
@@ -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-<reversed> 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-<reversed> 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-<reversed>
// 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
@@ -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))
}
})
}
}
+9
View File
@@ -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
+36
View File
@@ -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")