mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-08 01:00:21 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
463ddea36f | ||
|
|
e769f67699 | ||
|
|
52d2ae9708 | ||
|
|
2e59998c51 | ||
|
|
32e58115cc | ||
|
|
ba27026399 | ||
|
|
c15b419c4c |
15
README.md
15
README.md
@@ -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
|
||||||
|
|||||||
15
README_CN.md
15
README_CN.md
@@ -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. 编辑配置
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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和user(ApiKeyAuth中间件已设置)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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} 个',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user