Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ cli-proxy-api

# Configuration
config.yaml
config.test.yaml
.env

# Generated content
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/shadowsocks/go-shadowsocks2 v0.1.5 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -118,12 +118,16 @@ github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3/go.mod h1:HgjTstvQsPGkxUsCd2KWxErBblirPizecHcpD3ffK+s=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/shadowsocks/go-shadowsocks2 v0.1.5 h1:PDSQv9y2S85Fl7VBeOMF9StzeXZyK1HakRm86CUbr28=
github.com/shadowsocks/go-shadowsocks2 v0.1.5/go.mod h1:AGGpIoek4HRno4xzyFiAtLHkOpcoznZEkAccaI/rplM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
Expand Down Expand Up @@ -160,20 +164,27 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
Expand Down
277 changes: 277 additions & 0 deletions internal/api/handlers/management/auth_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -2383,3 +2385,278 @@ func (h *Handler) GetAuthStatus(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"status": "wait"})
}

// ModelHealth represents the health status of a model
type ModelHealth struct {
ModelID string `json:"model_id"`
DisplayName string `json:"display_name,omitempty"`
Status string `json:"status"` // "healthy", "unhealthy"
Message string `json:"message,omitempty"`
Latency int64 `json:"latency_ms,omitempty"`
}

// CheckAuthFileModelsHealth performs health checks on all models supported by an auth file
// Mimics Cherry Studio's implementation: sends actual generation request and aborts after first chunk
// Automatically uses proxy if configured in auth.ProxyURL
func (h *Handler) CheckAuthFileModelsHealth(c *gin.Context) {
if h.authManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
return
}

name := c.Query("name")
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}

// Parse optional query parameters (like Cherry Studio)
isConcurrent := c.DefaultQuery("concurrent", "false") == "true"
timeoutSeconds := 15
if ts := c.Query("timeout"); ts != "" {
if parsed, err := strconv.Atoi(ts); err == nil && parsed >= 5 && parsed <= 60 {
timeoutSeconds = parsed
}
}

// Parse optional model filter parameters
// - model: single model to check
// - models: comma-separated list of models to check
// If neither is specified, all models are checked
modelFilter := strings.TrimSpace(c.Query("model"))
modelsFilter := strings.TrimSpace(c.Query("models"))

// Find auth by name or ID
var targetAuth *coreauth.Auth
if auth, ok := h.authManager.GetByID(name); ok {
targetAuth = auth
} else {
auths := h.authManager.List()
for _, auth := range auths {
if auth.FileName == name || auth.ID == name {
targetAuth = auth
break
}
}
}

if targetAuth == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
return
}

// Get models from registry
reg := registry.GetGlobalRegistry()
models := reg.GetModelsForClient(targetAuth.ID)

// Apply model filter if specified
if modelFilter != "" || modelsFilter != "" {
filterSet := make(map[string]struct{})
if modelFilter != "" {
filterSet[strings.ToLower(modelFilter)] = struct{}{}
}
if modelsFilter != "" {
for _, m := range strings.Split(modelsFilter, ",") {
trimmed := strings.TrimSpace(m)
if trimmed != "" {
filterSet[strings.ToLower(trimmed)] = struct{}{}
}
}
}
if len(filterSet) > 0 {
filtered := make([]*registry.ModelInfo, 0)
for _, model := range models {
if _, ok := filterSet[strings.ToLower(model.ID)]; ok {
filtered = append(filtered, model)
}
}
models = filtered
}
}

if len(models) == 0 {
c.JSON(http.StatusOK, gin.H{
"auth_id": targetAuth.ID,
"status": "healthy",
"healthy_count": 0,
"unhealthy_count": 0,
"total_count": 0,
"models": []ModelHealth{},
})
return
}

// Prepare health check results
results := make([]ModelHealth, 0, len(models))
var wg sync.WaitGroup
var mu sync.Mutex

checkModel := func(model *registry.ModelInfo) {
defer wg.Done()

startTime := time.Now()
checkCtx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSeconds)*time.Second)
defer cancel()

// Build minimal OpenAI-format request for health check (mimicking Cherry Studio)
// This will be translated by the executor to the appropriate provider format
openAIRequest := map[string]interface{}{
"model": model.ID,
"messages": []map[string]interface{}{
{"role": "user", "content": "hi"},
{"role": "system", "content": "test"},
},
"stream": true,
"max_tokens": 1,
}

requestJSON, err := json.Marshal(openAIRequest)
if err != nil {
mu.Lock()
results = append(results, ModelHealth{
ModelID: model.ID,
DisplayName: model.DisplayName,
Status: "unhealthy",
Message: fmt.Sprintf("failed to build request: %v", err),
})
mu.Unlock()
return
}

// Build executor request
req := cliproxyexecutor.Request{
Model: model.ID,
Payload: requestJSON,
Format: sdktranslator.FormatOpenAI,
}

opts := cliproxyexecutor.Options{
Stream: true,
SourceFormat: sdktranslator.FormatOpenAI,
OriginalRequest: requestJSON,
}

// Execute stream directly with the specific auth (not load-balanced)
// This ensures we're testing this exact auth file, not any random auth of the same provider
stream, err := h.authManager.ExecuteStreamWithAuth(checkCtx, targetAuth, req, opts)
if err != nil {
mu.Lock()
results = append(results, ModelHealth{
ModelID: model.ID,
DisplayName: model.DisplayName,
Status: "unhealthy",
Message: err.Error(),
})
mu.Unlock()
return
}

// Wait for first chunk or timeout
select {
case chunk, ok := <-stream:
if ok {
// Check for error in chunk
if chunk.Err != nil {
mu.Lock()
results = append(results, ModelHealth{
ModelID: model.ID,
DisplayName: model.DisplayName,
Status: "unhealthy",
Message: chunk.Err.Error(),
})
mu.Unlock()
cancel()
// Drain remaining chunks
go func() {
for range stream {
}
}()
Comment on lines +2569 to +2573
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

在处理失败的健康检查时,在调用 cancel() 之后,没有必要再启动一个 goroutine 来排空流。cancel() 会导致 ExecuteStreamWithAuth 中的上下文被取消,这通常会使流的读取端(即 <-stream)立即出错或关闭。因此,select 语句中的 case chunk, ok := <-stream: 将会处理这个关闭或错误,而不需要额外的 goroutine 来排空。

return
}

// Got first chunk - model is healthy
latency := time.Since(startTime).Milliseconds()
cancel() // Cancel immediately after first chunk
// Drain remaining chunks in background
go func() {
for range stream {
}
}()

mu.Lock()
results = append(results, ModelHealth{
ModelID: model.ID,
DisplayName: model.DisplayName,
Status: "healthy",
Latency: latency,
})
mu.Unlock()
} else {
// Stream closed without data
mu.Lock()
results = append(results, ModelHealth{
ModelID: model.ID,
DisplayName: model.DisplayName,
Status: "unhealthy",
Message: "stream closed without data",
})
mu.Unlock()
}
case <-checkCtx.Done():
// Timeout
mu.Lock()
results = append(results, ModelHealth{
ModelID: model.ID,
DisplayName: model.DisplayName,
Status: "unhealthy",
Message: "health check timeout",
})
mu.Unlock()
}
}

// Execute health checks
if isConcurrent {
// Concurrent execution
for _, model := range models {
wg.Add(1)
go checkModel(model)
}
} else {
// Sequential execution
for _, model := range models {
wg.Add(1)
checkModel(model)
}
}
Comment on lines +2619 to +2631
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

并发和顺序执行的逻辑几乎完全相同,唯一的区别在于是否使用 go 关键字。这种重复可以通过将循环提取出来并有条件地决定是并发还是同步调用 checkModel 来避免,从而使代码更简洁。

	for _, model := range models {
		wg.Add(1)
		if isConcurrent {
			go checkModel(model)
		} else {
			checkModel(model)
		}
	}


wg.Wait()

// Count results
healthyCount := 0
unhealthyCount := 0
for _, result := range results {
if result.Status == "healthy" {
healthyCount++
} else {
unhealthyCount++
}
}

// Determine overall status
overallStatus := "healthy"
if unhealthyCount > 0 && healthyCount == 0 {
overallStatus = "unhealthy"
} else if unhealthyCount > 0 {
overallStatus = "partial"
}

c.JSON(http.StatusOK, gin.H{
"auth_id": targetAuth.ID,
"status": overallStatus,
"healthy_count": healthyCount,
"unhealthy_count": unhealthyCount,
"total_count": len(results),
"models": results,
})
}
Loading