-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
feat(auth): add health check endpoint for auth file models #1208
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
myuan19
wants to merge
8
commits into
router-for-me:main
Choose a base branch
from
myuan19:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+6,688
−18
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
a88187e
feat(auth): add health check endpoint for auth file models
yuanyuan19 bab32a9
feat(management): add provider listing and health check endpoints
yuanyuan19 ac26874
feat(unified-routing): 实现智能路由模块,包含配置、健康检查和指标监控功能。
yuanyuan19 5332cf0
fix(unified-routing): 获取认证文件时过滤掉Disabled的文件。
yuanyuan19 b0c7c63
feat(unified-routing): 让智能路由复用全局的日志目录解析模块
yuanyuan19 56cb2fe
feat(unified-routing): 实现流式请求的智能故障转移;修复SSE格式不对导致的cilent收不到chunk的问题
yuanyuan19 60ba084
feat(unified-routing): 智能路由支持所有的非智能路由接口
yuanyuan19 3c09a6e
feat(proxy): 添加对shadowsocks 协议代理的支持
yuanyuan19 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ cli-proxy-api | |
|
|
||
| # Configuration | ||
| config.yaml | ||
| config.test.yaml | ||
| .env | ||
|
|
||
| # Generated content | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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 { | ||
| } | ||
| }() | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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, | ||
| }) | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在处理失败的健康检查时,在调用
cancel()之后,没有必要再启动一个 goroutine 来排空流。cancel()会导致ExecuteStreamWithAuth中的上下文被取消,这通常会使流的读取端(即<-stream)立即出错或关闭。因此,select语句中的case chunk, ok := <-stream:将会处理这个关闭或错误,而不需要额外的 goroutine 来排空。