Compare commits

..

16 Commits

Author SHA1 Message Date
Luis Pater 4874253d1e Merge pull request #1425 from router-for-me/auth
docker-image / docker_amd64 (push) Has been cancelled
docker-image / docker_arm64 (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
docker-image / docker_manifest (push) Has been cancelled
fix(cliproxy): update auth before model registration
2026-02-04 15:01:01 +08:00
Luis Pater b72250349f Merge pull request #1423 from router-for-me/watcher
feat(watcher): log auth field changes on reload
2026-02-04 15:00:38 +08:00
hkfires 116573311f fix(cliproxy): update auth before model registration 2026-02-04 14:03:15 +08:00
hkfires 4af712544d feat(watcher): log auth field changes on reload
Cache parsed auth contents and compute redacted diffs for prefix, proxy_url,
and disabled when auth files are added or updated.
2026-02-04 12:29:56 +08:00
Luis Pater 1548c567ab feat(pprof): add support for configurable pprof HTTP debug server
docker-image / docker_amd64 (push) Has been cancelled
docker-image / docker_arm64 (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
docker-image / docker_manifest (push) Has been cancelled
- Introduced a new `pprof` server to enable/debug HTTP profiling.
- Added configuration options for enabling/disabling and specifying the server address.
- Integrated pprof server lifecycle management with `Service`.

#1287
2026-02-04 02:39:26 +08:00
Luis Pater 5b23fc570c Merge pull request #1396 from Xm798/fix/log-dir-tilde-expansion
fix(logging): expand tilde in auth-dir path for log directory
2026-02-04 02:00:13 +08:00
Luis Pater 04e1c7a05a docs: reorganize and update README entries for CLIProxyAPI projects 2026-02-04 01:49:27 +08:00
Luis Pater 9181e72204 Merge pull request #1409 from wangdabaoqq/main
docs: Add a new client application - Lin Jun
2026-02-04 01:47:31 +08:00
宝宝宝 4939865f6d Add a new client application - Lin Jun 2026-02-03 23:55:24 +08:00
宝宝宝 3da7f7482e Add a new client application - Lin Jun 2026-02-03 23:36:34 +08:00
宝宝宝 9072b029b2 Add a new client application - Lin Jun 2026-02-03 23:35:53 +08:00
宝宝宝 c296cfb8c0 docs: Add a new client application - Lin Jun 2026-02-03 23:32:50 +08:00
Luis Pater 2707377fcb docs: add AICodeMirror sponsorship details to README files 2026-02-03 22:34:50 +08:00
Luis Pater 259f586ff7 Fixed: #1398
docker-image / docker_amd64 (push) Has been cancelled
docker-image / docker_arm64 (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
docker-image / docker_manifest (push) Has been cancelled
fix(translator): use model group caching for client signature validation
2026-02-03 22:04:52 +08:00
Luis Pater d885b81f23 Fixed: #1403
docker-image / docker_amd64 (push) Has been cancelled
docker-image / docker_arm64 (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
docker-image / docker_manifest (push) Has been cancelled
fix(translator): handle "input" field transformation for OpenAI responses
2026-02-03 21:49:30 +08:00
Cyrus a275db3fdb fix(logging): expand tilde in auth-dir and log resolution errors
- Use util.ResolveAuthDir to properly expand ~ to user home directory
- Fixes issue where logs were created in literal "~/.cli-proxy-api" folder
- Add warning log when auth-dir resolution fails for debugging

Bug introduced in 62e2b67 (refactor(logging): centralize log directory
resolution logic), where strings.TrimSpace was used instead of
util.ResolveAuthDir to process auth-dir path.
2026-02-03 00:02:54 +08:00
14 changed files with 347 additions and 19 deletions
+8
View File
@@ -30,6 +30,10 @@ Get 10% OFF GLM CODING PLANhttps://z.ai/subscribe?ic=8JVLJQFSKB
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td> <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
<td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td> <td>Thanks to Cubence for sponsoring this project! Cubence is a reliable and efficient API relay service provider, offering relay services for Claude Code, Codex, Gemini, and more. Cubence provides special discounts for our software users: register using <a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">this link</a> and enter the "CLIPROXYAPI" promo code during recharge to get 10% off.</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CLIProxyAPI users: register via <a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
</tbody> </tbody>
</table> </table>
@@ -142,6 +146,10 @@ A lightweight web admin panel for CLIProxyAPI with health checks, resource monit
A Windows tray application implemented using PowerShell scripts, without relying on any third-party libraries. The main features include: automatic creation of shortcuts, silent running, password management, channel switching (Main / Plus), and automatic downloading and updating. A Windows tray application implemented using PowerShell scripts, without relying on any third-party libraries. The main features include: automatic creation of shortcuts, silent running, password management, channel switching (Main / Plus), and automatic downloading and updating.
### [霖君](https://github.com/wangdabaoqq/LinJun)
霖君 is a cross-platform desktop application for managing AI programming assistants, supporting macOS, Windows, and Linux systems. Unified management of Claude Code, Gemini CLI, OpenAI Codex, Qwen Code, and other AI coding tools, with local proxy for multi-account quota tracking and one-click configuration.
> [!NOTE] > [!NOTE]
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list. > If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
+12 -4
View File
@@ -30,6 +30,10 @@ GLM CODING PLAN 是专为AI编码打造的订阅套餐,每月最低仅需20元
<td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td> <td width="180"><a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa"><img src="./assets/cubence.png" alt="Cubence" width="150"></a></td>
<td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td> <td>感谢 Cubence 对本项目的赞助!Cubence 是一家可靠高效的 API 中转服务商,提供 Claude Code、Codex、Gemini 等多种服务的中转。Cubence 为本软件用户提供了特别优惠:使用<a href="https://cubence.com/signup?code=CLIPROXYAPI&source=cpa">此链接</a>注册,并在充值时输入 "CLIPROXYAPI" 优惠码即可享受九折优惠。</td>
</tr> </tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=TJNAIF"><img src="./assets/aicodemirror.png" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定中转服务,支持企业级高并发、极速开票、7×24 专属技术支持。 Claude Code / Codex / Gemini 官方渠道低至 3.8 / 0.2 / 0.9 折,充值更有折上折!AICodeMirror 为 CLIProxyAPI 的用户提供了特别福利,通过<a href="https://www.aicodemirror.com/register?invitecode=TJNAIF">此链接</a>注册的用户,可享受首充8折,企业客户最高可享 7.5 折!</td>
</tr>
</tbody> </tbody>
</table> </table>
@@ -137,6 +141,14 @@ Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI
面向 CLIProxyAPI 的 Web 管理面板,提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示,支持一键安装与 systemd 服务。 面向 CLIProxyAPI 的 Web 管理面板,提供健康检查、资源监控、日志查看、自动更新、请求统计与定价展示,支持一键安装与 systemd 服务。
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
Windows 托盘应用,基于 PowerShell 脚本实现,不依赖任何第三方库。主要功能包括:自动创建快捷方式、静默运行、密码管理、通道切换(Main / Plus)以及自动下载与更新。
### [霖君](https://github.com/wangdabaoqq/LinJun)
霖君是一款用于管理AI编程助手的跨平台桌面应用,支持macOS、Windows、Linux系统。统一管理Claude Code、Gemini CLI、OpenAI Codex、Qwen Code等AI编程工具,本地代理实现多账户配额跟踪和一键配置。
> [!NOTE] > [!NOTE]
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。 > 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
@@ -148,10 +160,6 @@ Windows 桌面应用,基于 Tauri + React 构建,用于通过 CLIProxyAPI
基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。 基于 Next.js 的实现,灵感来自 CLIProxyAPI,易于安装使用;自研格式转换(OpenAI/Claude/Gemini/Ollama)、组合系统与自动回退、多账户管理(指数退避)、Next.js Web 控制台,并支持 Cursor、Claude Code、Cline、RooCode 等 CLI 工具,无需 API 密钥。
### [CLIProxyAPI Tray](https://github.com/kitephp/CLIProxyAPI_Tray)
Windows 托盘应用,基于 PowerShell 脚本实现,不依赖任何第三方库。主要功能包括:自动创建快捷方式、静默运行、密码管理、通道切换(Main / Plus)以及自动下载与更新。
> [!NOTE] > [!NOTE]
> 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。 > 如果你开发了 CLIProxyAPI 的移植或衍生项目,请提交 PR 将其添加到此列表中。
Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+5
View File
@@ -40,6 +40,11 @@ api-keys:
# Enable debug logging # Enable debug logging
debug: false debug: false
# Enable pprof HTTP debug server (host:port). Keep it bound to localhost for safety.
pprof:
enable: false
addr: "127.0.0.1:8316"
# When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency. # When true, disable high-overhead HTTP middleware features to reduce per-request memory usage under high concurrency.
commercial-mode: false commercial-mode: false
+22 -1
View File
@@ -18,7 +18,10 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center" const (
DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy-API-Management-Center"
DefaultPprofAddr = "127.0.0.1:8316"
)
// Config represents the application's configuration, loaded from a YAML file. // Config represents the application's configuration, loaded from a YAML file.
type Config struct { type Config struct {
@@ -41,6 +44,9 @@ type Config struct {
// Debug enables or disables debug-level logging and other debug features. // Debug enables or disables debug-level logging and other debug features.
Debug bool `yaml:"debug" json:"debug"` Debug bool `yaml:"debug" json:"debug"`
// Pprof config controls the optional pprof HTTP debug server.
Pprof PprofConfig `yaml:"pprof" json:"pprof"`
// CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage. // CommercialMode disables high-overhead HTTP middleware features to minimize per-request memory usage.
CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"` CommercialMode bool `yaml:"commercial-mode" json:"commercial-mode"`
@@ -121,6 +127,14 @@ type TLSConfig struct {
Key string `yaml:"key" json:"key"` Key string `yaml:"key" json:"key"`
} }
// PprofConfig holds pprof HTTP server settings.
type PprofConfig struct {
// Enable toggles the pprof HTTP debug server.
Enable bool `yaml:"enable" json:"enable"`
// Addr is the host:port address for the pprof HTTP server.
Addr string `yaml:"addr" json:"addr"`
}
// RemoteManagement holds management API configuration under 'remote-management'. // RemoteManagement holds management API configuration under 'remote-management'.
type RemoteManagement struct { type RemoteManagement struct {
// AllowRemote toggles remote (non-localhost) access to management API. // AllowRemote toggles remote (non-localhost) access to management API.
@@ -514,6 +528,8 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.ErrorLogsMaxFiles = 10 cfg.ErrorLogsMaxFiles = 10
cfg.UsageStatisticsEnabled = false cfg.UsageStatisticsEnabled = false
cfg.DisableCooling = false cfg.DisableCooling = false
cfg.Pprof.Enable = false
cfg.Pprof.Addr = DefaultPprofAddr
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
if err = yaml.Unmarshal(data, &cfg); err != nil { if err = yaml.Unmarshal(data, &cfg); err != nil {
@@ -556,6 +572,11 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository cfg.RemoteManagement.PanelGitHubRepository = DefaultPanelGitHubRepository
} }
cfg.Pprof.Addr = strings.TrimSpace(cfg.Pprof.Addr)
if cfg.Pprof.Addr == "" {
cfg.Pprof.Addr = DefaultPprofAddr
}
if cfg.LogsMaxTotalSizeMB < 0 { if cfg.LogsMaxTotalSizeMB < 0 {
cfg.LogsMaxTotalSizeMB = 0 cfg.LogsMaxTotalSizeMB = 0
} }
+4 -1
View File
@@ -131,7 +131,10 @@ func ResolveLogDirectory(cfg *config.Config) string {
return logDir return logDir
} }
if !isDirWritable(logDir) { if !isDirWritable(logDir) {
authDir := strings.TrimSpace(cfg.AuthDir) authDir, err := util.ResolveAuthDir(cfg.AuthDir)
if err != nil {
log.Warnf("Failed to resolve auth-dir %q for log directory: %v", cfg.AuthDir, err)
}
if authDir != "" { if authDir != "" {
logDir = filepath.Join(authDir, "logs") logDir = filepath.Join(authDir, "logs")
} }
@@ -115,7 +115,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if signatureResult.Exists() && signatureResult.String() != "" { if signatureResult.Exists() && signatureResult.String() != "" {
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2) arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
if len(arrayClientSignatures) == 2 { if len(arrayClientSignatures) == 2 {
if modelName == arrayClientSignatures[0] { if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
clientSignature = arrayClientSignatures[1] clientSignature = arrayClientSignatures[1]
} }
} }
@@ -11,6 +11,12 @@ import (
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte { func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON) rawJSON := bytes.Clone(inputRawJSON)
inputResult := gjson.GetBytes(rawJSON, "input")
if inputResult.Type == gjson.String {
input, _ := sjson.Set(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`, "0.content.0.text", inputResult.String())
rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", []byte(input))
}
rawJSON, _ = sjson.SetBytes(rawJSON, "stream", true) rawJSON, _ = sjson.SetBytes(rawJSON, "stream", true)
rawJSON, _ = sjson.SetBytes(rawJSON, "store", false) rawJSON, _ = sjson.SetBytes(rawJSON, "store", false)
rawJSON, _ = sjson.SetBytes(rawJSON, "parallel_tool_calls", true) rawJSON, _ = sjson.SetBytes(rawJSON, "parallel_tool_calls", true)
+35
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"os" "os"
@@ -15,6 +16,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util" "github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
@@ -72,6 +74,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
w.clientsMutex.Lock() w.clientsMutex.Lock()
w.lastAuthHashes = make(map[string]string) w.lastAuthHashes = make(map[string]string)
w.lastAuthContents = make(map[string]*coreauth.Auth)
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil { if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir) log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir)
} else if resolvedAuthDir != "" { } else if resolvedAuthDir != "" {
@@ -84,6 +87,11 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
sum := sha256.Sum256(data) sum := sha256.Sum256(data)
normalizedPath := w.normalizeAuthPath(path) normalizedPath := w.normalizeAuthPath(path)
w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:]) w.lastAuthHashes[normalizedPath] = hex.EncodeToString(sum[:])
// Parse and cache auth content for future diff comparisons
var auth coreauth.Auth
if errParse := json.Unmarshal(data, &auth); errParse == nil {
w.lastAuthContents[normalizedPath] = &auth
}
} }
} }
return nil return nil
@@ -127,6 +135,13 @@ func (w *Watcher) addOrUpdateClient(path string) {
curHash := hex.EncodeToString(sum[:]) curHash := hex.EncodeToString(sum[:])
normalized := w.normalizeAuthPath(path) normalized := w.normalizeAuthPath(path)
// Parse new auth content for diff comparison
var newAuth coreauth.Auth
if errParse := json.Unmarshal(data, &newAuth); errParse != nil {
log.Errorf("failed to parse auth file %s: %v", filepath.Base(path), errParse)
return
}
w.clientsMutex.Lock() w.clientsMutex.Lock()
cfg := w.config cfg := w.config
@@ -141,7 +156,26 @@ func (w *Watcher) addOrUpdateClient(path string) {
return return
} }
// Get old auth for diff comparison
var oldAuth *coreauth.Auth
if w.lastAuthContents != nil {
oldAuth = w.lastAuthContents[normalized]
}
// Compute and log field changes
if changes := diff.BuildAuthChangeDetails(oldAuth, &newAuth); len(changes) > 0 {
log.Debugf("auth field changes for %s:", filepath.Base(path))
for _, c := range changes {
log.Debugf(" %s", c)
}
}
// Update caches
w.lastAuthHashes[normalized] = curHash w.lastAuthHashes[normalized] = curHash
if w.lastAuthContents == nil {
w.lastAuthContents = make(map[string]*coreauth.Auth)
}
w.lastAuthContents[normalized] = &newAuth
w.clientsMutex.Unlock() // Unlock before the callback w.clientsMutex.Unlock() // Unlock before the callback
@@ -160,6 +194,7 @@ func (w *Watcher) removeClient(path string) {
cfg := w.config cfg := w.config
delete(w.lastAuthHashes, normalized) delete(w.lastAuthHashes, normalized)
delete(w.lastAuthContents, normalized)
w.clientsMutex.Unlock() // Release the lock before the callback w.clientsMutex.Unlock() // Release the lock before the callback
+44
View File
@@ -0,0 +1,44 @@
// auth_diff.go computes human-readable diffs for auth file field changes.
package diff
import (
"fmt"
"strings"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
// BuildAuthChangeDetails computes a redacted, human-readable list of auth field changes.
// Only prefix, proxy_url, and disabled fields are tracked; sensitive data is never printed.
func BuildAuthChangeDetails(oldAuth, newAuth *coreauth.Auth) []string {
changes := make([]string, 0, 3)
// Handle nil cases by using empty Auth as default
if oldAuth == nil {
oldAuth = &coreauth.Auth{}
}
if newAuth == nil {
return changes
}
// Compare prefix
oldPrefix := strings.TrimSpace(oldAuth.Prefix)
newPrefix := strings.TrimSpace(newAuth.Prefix)
if oldPrefix != newPrefix {
changes = append(changes, fmt.Sprintf("prefix: %s -> %s", oldPrefix, newPrefix))
}
// Compare proxy_url (redacted)
oldProxy := strings.TrimSpace(oldAuth.ProxyURL)
newProxy := strings.TrimSpace(newAuth.ProxyURL)
if oldProxy != newProxy {
changes = append(changes, fmt.Sprintf("proxy_url: %s -> %s", formatProxyURL(oldProxy), formatProxyURL(newProxy)))
}
// Compare disabled
if oldAuth.Disabled != newAuth.Disabled {
changes = append(changes, fmt.Sprintf("disabled: %t -> %t", oldAuth.Disabled, newAuth.Disabled))
}
return changes
}
+6
View File
@@ -27,6 +27,12 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.Debug != newCfg.Debug { if oldCfg.Debug != newCfg.Debug {
changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug)) changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug))
} }
if oldCfg.Pprof.Enable != newCfg.Pprof.Enable {
changes = append(changes, fmt.Sprintf("pprof.enable: %t -> %t", oldCfg.Pprof.Enable, newCfg.Pprof.Enable))
}
if strings.TrimSpace(oldCfg.Pprof.Addr) != strings.TrimSpace(newCfg.Pprof.Addr) {
changes = append(changes, fmt.Sprintf("pprof.addr: %s -> %s", strings.TrimSpace(oldCfg.Pprof.Addr), strings.TrimSpace(newCfg.Pprof.Addr)))
}
if oldCfg.LoggingToFile != newCfg.LoggingToFile { if oldCfg.LoggingToFile != newCfg.LoggingToFile {
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile)) changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
} }
+1
View File
@@ -38,6 +38,7 @@ type Watcher struct {
reloadCallback func(*config.Config) reloadCallback func(*config.Config)
watcher *fsnotify.Watcher watcher *fsnotify.Watcher
lastAuthHashes map[string]string lastAuthHashes map[string]string
lastAuthContents map[string]*coreauth.Auth
lastRemoveTimes map[string]time.Time lastRemoveTimes map[string]time.Time
lastConfigHash string lastConfigHash string
authQueue chan<- AuthUpdate authQueue chan<- AuthUpdate
+163
View File
@@ -0,0 +1,163 @@
package cliproxy
import (
"context"
"errors"
"net/http"
"net/http/pprof"
"strings"
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
log "github.com/sirupsen/logrus"
)
type pprofServer struct {
mu sync.Mutex
server *http.Server
addr string
enabled bool
}
func newPprofServer() *pprofServer {
return &pprofServer{}
}
func (s *Service) applyPprofConfig(cfg *config.Config) {
if s == nil || cfg == nil {
return
}
if s.pprofServer == nil {
s.pprofServer = newPprofServer()
}
s.pprofServer.Apply(cfg)
}
func (s *Service) shutdownPprof(ctx context.Context) error {
if s == nil || s.pprofServer == nil {
return nil
}
return s.pprofServer.Shutdown(ctx)
}
func (p *pprofServer) Apply(cfg *config.Config) {
if p == nil || cfg == nil {
return
}
addr := strings.TrimSpace(cfg.Pprof.Addr)
if addr == "" {
addr = config.DefaultPprofAddr
}
enabled := cfg.Pprof.Enable
p.mu.Lock()
currentServer := p.server
currentAddr := p.addr
p.addr = addr
p.enabled = enabled
if !enabled {
p.server = nil
p.mu.Unlock()
if currentServer != nil {
p.stopServer(currentServer, currentAddr, "disabled")
}
return
}
if currentServer != nil && currentAddr == addr {
p.mu.Unlock()
return
}
p.server = nil
p.mu.Unlock()
if currentServer != nil {
p.stopServer(currentServer, currentAddr, "restarted")
}
p.startServer(addr)
}
func (p *pprofServer) Shutdown(ctx context.Context) error {
if p == nil {
return nil
}
p.mu.Lock()
currentServer := p.server
currentAddr := p.addr
p.server = nil
p.enabled = false
p.mu.Unlock()
if currentServer == nil {
return nil
}
return p.stopServerWithContext(ctx, currentServer, currentAddr, "shutdown")
}
func (p *pprofServer) startServer(addr string) {
mux := newPprofMux()
server := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
p.mu.Lock()
if !p.enabled || p.addr != addr || p.server != nil {
p.mu.Unlock()
return
}
p.server = server
p.mu.Unlock()
log.Infof("pprof server starting on %s", addr)
go func() {
if errServe := server.ListenAndServe(); errServe != nil && !errors.Is(errServe, http.ErrServerClosed) {
log.Errorf("pprof server failed on %s: %v", addr, errServe)
p.mu.Lock()
if p.server == server {
p.server = nil
}
p.mu.Unlock()
}
}()
}
func (p *pprofServer) stopServer(server *http.Server, addr string, reason string) {
_ = p.stopServerWithContext(context.Background(), server, addr, reason)
}
func (p *pprofServer) stopServerWithContext(ctx context.Context, server *http.Server, addr string, reason string) error {
if server == nil {
return nil
}
stopCtx := ctx
if stopCtx == nil {
stopCtx = context.Background()
}
stopCtx, cancel := context.WithTimeout(stopCtx, 5*time.Second)
defer cancel()
if errStop := server.Shutdown(stopCtx); errStop != nil {
log.Errorf("pprof server stop failed on %s: %v", addr, errStop)
return errStop
}
log.Infof("pprof server stopped on %s (%s)", addr, reason)
return nil
}
func newPprofMux() *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("/debug/pprof/", pprof.Index)
mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
mux.Handle("/debug/pprof/allocs", pprof.Handler("allocs"))
mux.Handle("/debug/pprof/block", pprof.Handler("block"))
mux.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
mux.Handle("/debug/pprof/heap", pprof.Handler("heap"))
mux.Handle("/debug/pprof/mutex", pprof.Handler("mutex"))
mux.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
return mux
}
+40 -12
View File
@@ -57,6 +57,9 @@ type Service struct {
// server is the HTTP API server instance. // server is the HTTP API server instance.
server *api.Server server *api.Server
// pprofServer manages the optional pprof HTTP debug server.
pprofServer *pprofServer
// serverErr channel for server startup/shutdown errors. // serverErr channel for server startup/shutdown errors.
serverErr chan error serverErr chan error
@@ -270,27 +273,42 @@ func (s *Service) wsOnDisconnected(channelID string, reason error) {
} }
func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) { func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {
if s == nil || auth == nil || auth.ID == "" { if s == nil || s.coreManager == nil || auth == nil || auth.ID == "" {
return
}
if s.coreManager == nil {
return return
} }
auth = auth.Clone() auth = auth.Clone()
s.ensureExecutorsForAuth(auth) s.ensureExecutorsForAuth(auth)
s.registerModelsForAuth(auth)
if existing, ok := s.coreManager.GetByID(auth.ID); ok && existing != nil { // IMPORTANT: Update coreManager FIRST, before model registration.
// This ensures that configuration changes (proxy_url, prefix, etc.) take effect
// immediately for API calls, rather than waiting for model registration to complete.
// Model registration may involve network calls (e.g., FetchAntigravityModels) that
// could timeout if the new proxy_url is unreachable.
op := "register"
var err error
if existing, ok := s.coreManager.GetByID(auth.ID); ok {
auth.CreatedAt = existing.CreatedAt auth.CreatedAt = existing.CreatedAt
auth.LastRefreshedAt = existing.LastRefreshedAt auth.LastRefreshedAt = existing.LastRefreshedAt
auth.NextRefreshAfter = existing.NextRefreshAfter auth.NextRefreshAfter = existing.NextRefreshAfter
if _, err := s.coreManager.Update(ctx, auth); err != nil { op = "update"
log.Errorf("failed to update auth %s: %v", auth.ID, err) _, err = s.coreManager.Update(ctx, auth)
} else {
_, err = s.coreManager.Register(ctx, auth)
}
if err != nil {
log.Errorf("failed to %s auth %s: %v", op, auth.ID, err)
current, ok := s.coreManager.GetByID(auth.ID)
if !ok || current.Disabled {
GlobalModelRegistry().UnregisterClient(auth.ID)
return
} }
return auth = current
}
if _, err := s.coreManager.Register(ctx, auth); err != nil {
log.Errorf("failed to register auth %s: %v", auth.ID, err)
} }
// Register models after auth is updated in coreManager.
// This operation may block on network calls, but the auth configuration
// is already effective at this point.
s.registerModelsForAuth(auth)
} }
func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) { func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
@@ -501,6 +519,8 @@ func (s *Service) Run(ctx context.Context) error {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port) fmt.Printf("API server started successfully on: %s:%d\n", s.cfg.Host, s.cfg.Port)
s.applyPprofConfig(s.cfg)
if s.hooks.OnAfterStart != nil { if s.hooks.OnAfterStart != nil {
s.hooks.OnAfterStart(s) s.hooks.OnAfterStart(s)
} }
@@ -546,6 +566,7 @@ func (s *Service) Run(ctx context.Context) error {
} }
s.applyRetryConfig(newCfg) s.applyRetryConfig(newCfg)
s.applyPprofConfig(newCfg)
if s.server != nil { if s.server != nil {
s.server.UpdateClients(newCfg) s.server.UpdateClients(newCfg)
} }
@@ -639,6 +660,13 @@ func (s *Service) Shutdown(ctx context.Context) error {
s.authQueueStop = nil s.authQueueStop = nil
} }
if errShutdownPprof := s.shutdownPprof(ctx); errShutdownPprof != nil {
log.Errorf("failed to stop pprof server: %v", errShutdownPprof)
if shutdownErr == nil {
shutdownErr = errShutdownPprof
}
}
// no legacy clients to persist // no legacy clients to persist
if s.server != nil { if s.server != nil {