Compare commits

...

7 Commits

Author SHA1 Message Date
shaw
463ddea36f fix(frontend): 修复代理快捷添加弹窗的 i18n 解析错误
batchInputHint 中的 @ 符号需要使用 {'@'} 转义
2025-12-19 11:24:22 +08:00
shaw
e769f67699 fix(setup): 支持从配置文件读取 Setup Wizard 监听地址
Setup Wizard 之前硬编码使用 8080 端口,现在支持从 config.yaml 或
环境变量 (SERVER_HOST, SERVER_PORT) 读取监听地址,方便用户在端口
被占用时使用其他地址启动初始化向导。
2025-12-19 11:21:58 +08:00
shaw
52d2ae9708 feat(gateway): 添加 /v1/messages/count_tokens 端点
实现 Claude API 的 token 计数功能,支持 OAuth、SetupToken 和 ApiKey 三种账号类型。

特点:
- 校验订阅/余额(不扣费)
- 不计算用户和账号并发
- 不记录使用量
- 支持模型映射(ApiKey 账号)
- 支持 OAuth 账号的指纹管理和 401 重试
2025-12-19 11:12:41 +08:00
shaw
2e59998c51 fix: 代理表单字段保存时自动去除前后空格
前后端同时处理,防止因意外空格导致代理连接失败
2025-12-19 10:39:30 +08:00
shaw
32e58115cc fix(frontend): 修复代理快捷添加弹窗的 i18n 解析错误
转义 batchInputPlaceholder 中的 @ 符号,防止 Vue I18n 将其误解析为链接消息语法
2025-12-19 10:32:22 +08:00
shaw
ba27026399 docs: 调整源码编译步骤的顺序 2025-12-19 09:47:17 +08:00
shaw
c15b419c4c feat(backend): 添加 event_logging 接口直接返回200
将原本在nginx处理的遥测日志请求移至后端,
忽略Claude Code客户端发送的日志数据。
2025-12-19 09:39:57 +08:00
11 changed files with 366 additions and 53 deletions

View File

@@ -216,20 +216,19 @@ Build and run from source code for development or customization.
git clone https://github.com/Wei-Shaw/sub2api.git git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api cd sub2api
# 2. Build backend # 2. Build frontend
cd backend cd frontend
go build -o sub2api ./cmd/server
# 3. Build frontend
cd ../frontend
npm install npm install
npm run build npm run build
# 4. Copy frontend build to backend (for embedding) # 3. Copy frontend build to backend (for embedding)
cp -r dist ../backend/internal/web/ cp -r dist ../backend/internal/web/
# 5. Create configuration file # 4. Build backend (requires frontend dist to be present)
cd ../backend cd ../backend
go build -o sub2api ./cmd/server
# 5. Create configuration file
cp ../deploy/config.example.yaml ./config.yaml cp ../deploy/config.example.yaml ./config.yaml
# 6. Edit configuration # 6. Edit configuration

View File

@@ -216,20 +216,19 @@ docker-compose logs -f
git clone https://github.com/Wei-Shaw/sub2api.git git clone https://github.com/Wei-Shaw/sub2api.git
cd sub2api cd sub2api
# 2. 编译 # 2. 编译
cd backend cd frontend
go build -o sub2api ./cmd/server
# 3. 编译前端
cd ../frontend
npm install npm install
npm run build npm run build
# 4. 复制前端构建产物到后端(用于嵌入) # 3. 复制前端构建产物到后端(用于嵌入)
cp -r dist ../backend/internal/web/ cp -r dist ../backend/internal/web/
# 5. 创建配置文件 # 4. 编译后端(需要前端 dist 目录存在)
cd ../backend cd ../backend
go build -o sub2api ./cmd/server
# 5. 创建配置文件
cp ../deploy/config.example.yaml ./config.yaml cp ../deploy/config.example.yaml ./config.yaml
# 6. 编辑配置 # 6. 编辑配置

View File

@@ -15,6 +15,7 @@ import (
"syscall" "syscall"
"time" "time"
"sub2api/internal/config"
"sub2api/internal/handler" "sub2api/internal/handler"
"sub2api/internal/middleware" "sub2api/internal/middleware"
"sub2api/internal/setup" "sub2api/internal/setup"
@@ -94,8 +95,10 @@ func runSetupServer() {
r.Use(web.ServeEmbeddedFrontend()) r.Use(web.ServeEmbeddedFrontend())
} }
addr := ":8080" // Get server address from config.yaml or environment variables (SERVER_HOST, SERVER_PORT)
log.Printf("Setup wizard available at http://localhost%s", addr) // This allows users to run setup on a different address if needed
addr := config.GetServerAddress()
log.Printf("Setup wizard available at http://%s", addr)
log.Println("Complete the setup wizard to configure Sub2API") log.Println("Complete the setup wizard to configure Sub2API")
if err := r.Run(addr); err != nil { if err := r.Run(addr); err != nil {

View File

@@ -203,3 +203,29 @@ func (c *Config) Validate() error {
} }
return nil return nil
} }
// GetServerAddress returns the server address (host:port) from config file or environment variable.
// This is a lightweight function that can be used before full config validation,
// such as during setup wizard startup.
// Priority: config.yaml > environment variables > defaults
func GetServerAddress() string {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("/etc/sub2api")
// Support SERVER_HOST and SERVER_PORT environment variables
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8080)
// Try to read config file (ignore errors if not found)
_ = v.ReadInConfig()
host := v.GetString("server.host")
port := v.GetInt("server.port")
return fmt.Sprintf("%s:%d", host, port)
}

View File

@@ -2,6 +2,7 @@ package admin
import ( import (
"strconv" "strconv"
"strings"
"sub2api/internal/pkg/response" "sub2api/internal/pkg/response"
"sub2api/internal/service" "sub2api/internal/service"
@@ -112,12 +113,12 @@ func (h *ProxyHandler) Create(c *gin.Context) {
} }
proxy, err := h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{ proxy, err := h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
Name: req.Name, Name: strings.TrimSpace(req.Name),
Protocol: req.Protocol, Protocol: strings.TrimSpace(req.Protocol),
Host: req.Host, Host: strings.TrimSpace(req.Host),
Port: req.Port, Port: req.Port,
Username: req.Username, Username: strings.TrimSpace(req.Username),
Password: req.Password, Password: strings.TrimSpace(req.Password),
}) })
if err != nil { if err != nil {
response.BadRequest(c, "Failed to create proxy: "+err.Error()) response.BadRequest(c, "Failed to create proxy: "+err.Error())
@@ -143,13 +144,13 @@ func (h *ProxyHandler) Update(c *gin.Context) {
} }
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{ proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
Name: req.Name, Name: strings.TrimSpace(req.Name),
Protocol: req.Protocol, Protocol: strings.TrimSpace(req.Protocol),
Host: req.Host, Host: strings.TrimSpace(req.Host),
Port: req.Port, Port: req.Port,
Username: req.Username, Username: strings.TrimSpace(req.Username),
Password: req.Password, Password: strings.TrimSpace(req.Password),
Status: req.Status, Status: strings.TrimSpace(req.Status),
}) })
if err != nil { if err != nil {
response.InternalError(c, "Failed to update proxy: "+err.Error()) response.InternalError(c, "Failed to update proxy: "+err.Error())
@@ -263,8 +264,14 @@ func (h *ProxyHandler) BatchCreate(c *gin.Context) {
skipped := 0 skipped := 0
for _, item := range req.Proxies { for _, item := range req.Proxies {
// Trim all string fields
host := strings.TrimSpace(item.Host)
protocol := strings.TrimSpace(item.Protocol)
username := strings.TrimSpace(item.Username)
password := strings.TrimSpace(item.Password)
// Check for duplicates (same host, port, username, password) // Check for duplicates (same host, port, username, password)
exists, err := h.adminService.CheckProxyExists(c.Request.Context(), item.Host, item.Port, item.Username, item.Password) exists, err := h.adminService.CheckProxyExists(c.Request.Context(), host, item.Port, username, password)
if err != nil { if err != nil {
response.InternalError(c, "Failed to check proxy existence: "+err.Error()) response.InternalError(c, "Failed to check proxy existence: "+err.Error())
return return
@@ -278,11 +285,11 @@ func (h *ProxyHandler) BatchCreate(c *gin.Context) {
// Create proxy with default name // Create proxy with default name
_, err = h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{ _, err = h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{
Name: "default", Name: "default",
Protocol: item.Protocol, Protocol: protocol,
Host: item.Host, Host: host,
Port: item.Port, Port: item.Port,
Username: item.Username, Username: username,
Password: item.Password, Password: password,
}) })
if err != nil { if err != nil {
// If creation fails due to duplicate, count as skipped // If creation fails due to duplicate, count as skipped

View File

@@ -443,3 +443,69 @@ func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, mess
}, },
}) })
} }
// CountTokens handles token counting endpoint
// POST /v1/messages/count_tokens
// 特点:校验订阅/余额,但不计算并发、不记录使用量
func (h *GatewayHandler) CountTokens(c *gin.Context) {
// 从context获取apiKey和userApiKeyAuth中间件已设置
apiKey, ok := middleware.GetApiKeyFromContext(c)
if !ok {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
user, ok := middleware.GetUserFromContext(c)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
// 读取请求体
body, err := io.ReadAll(c.Request.Body)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to read request body")
return
}
if len(body) == 0 {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return
}
// 解析请求获取模型名
var req struct {
Model string `json:"model"`
}
if err := json.Unmarshal(body, &req); err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to parse request body")
return
}
// 获取订阅信息可能为nil
subscription, _ := middleware.GetSubscriptionFromContext(c)
// 校验 billing eligibility订阅/余额)
// 【注意】不计算并发,但需要校验订阅/余额
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), user, apiKey, apiKey.Group, subscription); err != nil {
h.errorResponse(c, http.StatusForbidden, "billing_error", err.Error())
return
}
// 计算粘性会话 hash
sessionHash := h.gatewayService.GenerateSessionHash(body)
// 选择支持该模型的账号
account, err := h.gatewayService.SelectAccountForModel(c.Request.Context(), apiKey.GroupID, sessionHash, req.Model)
if err != nil {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error())
return
}
// 转发请求(不记录使用量)
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, body); err != nil {
log.Printf("Forward count_tokens request failed: %v", err)
// 错误响应已在 ForwardCountTokens 中处理
return
}
}

View File

@@ -36,6 +36,11 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
c.JSON(http.StatusOK, gin.H{"status": "ok"}) c.JSON(http.StatusOK, gin.H{"status": "ok"})
}) })
// Claude Code 遥测日志忽略直接返回200
r.POST("/api/event_logging/batch", func(c *gin.Context) {
c.Status(http.StatusOK)
})
// Setup status endpoint (always returns needs_setup: false in normal mode) // Setup status endpoint (always returns needs_setup: false in normal mode)
// This is used by the frontend to detect when the service has restarted after setup // This is used by the frontend to detect when the service has restarted after setup
r.GET("/setup/status", func(c *gin.Context) { r.GET("/setup/status", func(c *gin.Context) {
@@ -276,6 +281,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
gateway.Use(middleware.ApiKeyAuthWithSubscription(s.ApiKey, s.Subscription)) gateway.Use(middleware.ApiKeyAuthWithSubscription(s.ApiKey, s.Subscription))
{ {
gateway.POST("/messages", h.Gateway.Messages) gateway.POST("/messages", h.Gateway.Messages)
gateway.POST("/messages/count_tokens", h.Gateway.CountTokens)
gateway.GET("/models", h.Gateway.Models) gateway.GET("/models", h.Gateway.Models)
gateway.GET("/usage", h.Gateway.Usage) gateway.GET("/usage", h.Gateway.Usage)
} }

View File

@@ -27,10 +27,11 @@ import (
) )
const ( const (
claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true" claudeAPIURL = "https://api.anthropic.com/v1/messages?beta=true"
stickySessionPrefix = "sticky_session:" claudeAPICountTokensURL = "https://api.anthropic.com/v1/messages/count_tokens?beta=true"
stickySessionTTL = time.Hour // 粘性会话TTL stickySessionPrefix = "sticky_session:"
tokenRefreshBuffer = 5 * 60 // 提前5分钟刷新token stickySessionTTL = time.Hour // 粘性会话TTL
tokenRefreshBuffer = 5 * 60 // 提前5分钟刷新token
) )
// allowedHeaders 白名单headers参考CRS项目 // allowedHeaders 白名单headers参考CRS项目
@@ -1044,3 +1045,205 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
return nil return nil
} }
// ForwardCountTokens 转发 count_tokens 请求到上游 API
// 特点:不记录使用量、仅支持非流式响应
func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context, account *model.Account, body []byte) error {
// 应用模型映射(仅对 apikey 类型账号)
if account.Type == model.AccountTypeApiKey {
var req struct {
Model string `json:"model"`
}
if err := json.Unmarshal(body, &req); err == nil && req.Model != "" {
mappedModel := account.GetMappedModel(req.Model)
if mappedModel != req.Model {
body = s.replaceModelInBody(body, mappedModel)
log.Printf("CountTokens model mapping applied: %s -> %s (account: %s)", req.Model, mappedModel, account.Name)
}
}
}
// 获取凭证
token, tokenType, err := s.GetAccessToken(ctx, account)
if err != nil {
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to get access token")
return err
}
// 构建上游请求
upstreamResult, err := s.buildCountTokensRequest(ctx, c, account, body, token, tokenType)
if err != nil {
s.countTokensError(c, http.StatusInternalServerError, "api_error", "Failed to build request")
return err
}
// 选择 HTTP client
httpClient := s.httpClient
if upstreamResult.Client != nil {
httpClient = upstreamResult.Client
}
// 发送请求
resp, err := httpClient.Do(upstreamResult.Request)
if err != nil {
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Request failed")
return fmt.Errorf("upstream request failed: %w", err)
}
defer resp.Body.Close()
// 处理 401 错误:刷新 token 重试(仅 OAuth
if resp.StatusCode == http.StatusUnauthorized && tokenType == "oauth" {
resp.Body.Close()
token, tokenType, err = s.forceRefreshToken(ctx, account)
if err != nil {
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Token refresh failed")
return fmt.Errorf("token refresh failed: %w", err)
}
upstreamResult, err = s.buildCountTokensRequest(ctx, c, account, body, token, tokenType)
if err != nil {
return err
}
httpClient = s.httpClient
if upstreamResult.Client != nil {
httpClient = upstreamResult.Client
}
resp, err = httpClient.Do(upstreamResult.Request)
if err != nil {
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Retry failed")
return fmt.Errorf("retry request failed: %w", err)
}
defer resp.Body.Close()
}
// 读取响应体
respBody, err := io.ReadAll(resp.Body)
if err != nil {
s.countTokensError(c, http.StatusBadGateway, "upstream_error", "Failed to read response")
return err
}
// 处理错误响应
if resp.StatusCode >= 400 {
// 标记账号状态429/529等
s.rateLimitService.HandleUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
// 返回简化的错误响应
errMsg := "Upstream request failed"
switch resp.StatusCode {
case 429:
errMsg = "Rate limit exceeded"
case 529:
errMsg = "Service overloaded"
}
s.countTokensError(c, resp.StatusCode, "upstream_error", errMsg)
return fmt.Errorf("upstream error: %d", resp.StatusCode)
}
// 透传成功响应
c.Data(resp.StatusCode, "application/json", respBody)
return nil
}
// buildCountTokensRequest 构建 count_tokens 上游请求
func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Context, account *model.Account, body []byte, token, tokenType string) (*buildUpstreamRequestResult, error) {
// 确定目标 URL
targetURL := claudeAPICountTokensURL
if account.Type == model.AccountTypeApiKey {
baseURL := account.GetBaseURL()
targetURL = baseURL + "/v1/messages/count_tokens"
}
// OAuth 账号:应用统一指纹和重写 userID
if account.IsOAuth() && s.identityService != nil {
fp, err := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if err == nil {
accountUUID := account.GetExtraString("account_uuid")
if accountUUID != "" && fp.ClientID != "" {
if newBody, err := s.identityService.RewriteUserID(body, account.ID, accountUUID, fp.ClientID); err == nil && len(newBody) > 0 {
body = newBody
}
}
}
}
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
// 设置认证头
if tokenType == "oauth" {
req.Header.Set("Authorization", "Bearer "+token)
} else {
req.Header.Set("x-api-key", token)
}
// 白名单透传 headers
for key, values := range c.Request.Header {
lowerKey := strings.ToLower(key)
if allowedHeaders[lowerKey] {
for _, v := range values {
req.Header.Add(key, v)
}
}
}
// OAuth 账号:应用指纹到请求头
if account.IsOAuth() && s.identityService != nil {
fp, _ := s.identityService.GetOrCreateFingerprint(ctx, account.ID, c.Request.Header)
if fp != nil {
s.identityService.ApplyFingerprint(req, fp)
}
}
// 确保必要的 headers 存在
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
if req.Header.Get("anthropic-version") == "" {
req.Header.Set("anthropic-version", "2023-06-01")
}
// OAuth 账号:处理 anthropic-beta header
if tokenType == "oauth" {
req.Header.Set("anthropic-beta", s.getBetaHeader(body, c.GetHeader("anthropic-beta")))
}
// 配置代理
var customClient *http.Client
if account.ProxyID != nil && account.Proxy != nil {
proxyURL := account.Proxy.URL()
if proxyURL != "" {
if parsedURL, err := url.Parse(proxyURL); err == nil {
responseHeaderTimeout := time.Duration(s.cfg.Gateway.ResponseHeaderTimeout) * time.Second
if responseHeaderTimeout == 0 {
responseHeaderTimeout = 300 * time.Second
}
transport := &http.Transport{
Proxy: http.ProxyURL(parsedURL),
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
ResponseHeaderTimeout: responseHeaderTimeout,
}
customClient = &http.Client{Transport: transport}
}
}
}
return &buildUpstreamRequestResult{
Request: req,
Client: customClient,
}, nil
}
// countTokensError 返回 count_tokens 错误响应
func (s *GatewayService) countTokensError(c *gin.Context, status int, errType, message string) {
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{
"type": errType,
"message": message,
},
})
}

View File

@@ -817,8 +817,8 @@ export default {
standardAdd: 'Standard Add', standardAdd: 'Standard Add',
batchAdd: 'Quick Add', batchAdd: 'Quick Add',
batchInput: 'Proxy List', batchInput: 'Proxy List',
batchInputPlaceholder: 'Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443', batchInputPlaceholder: "Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
batchInputHint: 'Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port', batchInputHint: "Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
parsedCount: '{count} valid', parsedCount: '{count} valid',
invalidCount: '{count} invalid', invalidCount: '{count} invalid',
duplicateCount: '{count} duplicate', duplicateCount: '{count} duplicate',

View File

@@ -942,8 +942,8 @@ export default {
standardAdd: '标准添加', standardAdd: '标准添加',
batchAdd: '快捷添加', batchAdd: '快捷添加',
batchInput: '代理列表', batchInput: '代理列表',
batchInputPlaceholder: '每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443', batchInputPlaceholder: "每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
batchInputHint: '支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口', batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
parsedCount: '有效 {count} 个', parsedCount: '有效 {count} 个',
invalidCount: '无效 {count} 个', invalidCount: '无效 {count} 个',
duplicateCount: '重复 {count} 个', duplicateCount: '重复 {count} 个',

View File

@@ -647,10 +647,10 @@ const parseProxyUrl = (line: string): {
return { return {
protocol: protocol.toLowerCase() as ProxyProtocol, protocol: protocol.toLowerCase() as ProxyProtocol,
host, host: host.trim(),
port: portNum, port: portNum,
username: username || '', username: username?.trim() || '',
password: password || '' password: password?.trim() || ''
} }
} }
@@ -714,9 +714,12 @@ const handleCreateProxy = async () => {
submitting.value = true submitting.value = true
try { try {
await adminAPI.proxies.create({ await adminAPI.proxies.create({
...createForm, name: createForm.name.trim(),
username: createForm.username || null, protocol: createForm.protocol,
password: createForm.password || null host: createForm.host.trim(),
port: createForm.port,
username: createForm.username.trim() || null,
password: createForm.password.trim() || null
}) })
appStore.showSuccess(t('admin.proxies.proxyCreated')) appStore.showSuccess(t('admin.proxies.proxyCreated'))
closeCreateModal() closeCreateModal()
@@ -752,17 +755,18 @@ const handleUpdateProxy = async () => {
submitting.value = true submitting.value = true
try { try {
const updateData: any = { const updateData: any = {
name: editForm.name, name: editForm.name.trim(),
protocol: editForm.protocol, protocol: editForm.protocol,
host: editForm.host, host: editForm.host.trim(),
port: editForm.port, port: editForm.port,
username: editForm.username || null, username: editForm.username.trim() || null,
status: editForm.status status: editForm.status
} }
// Only include password if it was changed // Only include password if it was changed
if (editForm.password) { const trimmedPassword = editForm.password.trim()
updateData.password = editForm.password if (trimmedPassword) {
updateData.password = trimmedPassword
} }
await adminAPI.proxies.update(editingProxy.value.id, updateData) await adminAPI.proxies.update(editingProxy.value.id, updateData)