mirror of
https://gh-proxy.org/https://github.com/klinsmaya/CLIProxyAPI.git
synced 2026-11-09 18:06:50 +08:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user