mirror of
https://github.com/handsomezhuzhu/api-proxy.git
synced 2026-02-20 11:50:15 +00:00
兼容CDN api
This commit is contained in:
53
README.md
53
README.md
@@ -2,6 +2,32 @@
|
|||||||
|
|
||||||
这是一个轻量级的 API 代理服务,旨在统一和简化对各种 AI 服务 API 的访问。它使用 Go 语言编写,支持 Docker 部署。
|
这是一个轻量级的 API 代理服务,旨在统一和简化对各种 AI 服务 API 的访问。它使用 Go 语言编写,支持 Docker 部署。
|
||||||
|
|
||||||
|
## 🚀 阿里云 ESA (边缘安全加速) 配置指南 (非常重要)
|
||||||
|
|
||||||
|
如果你使用了阿里 ESA 加速本服务,**必须**在 ESA 控制台中进行以下设置,否则会出现 `Origin Time-out` (源站超时) 或 AI 回复卡顿(打字机效果失效)的问题。
|
||||||
|
|
||||||
|
### 1. 缓存配置 (Cache)
|
||||||
|
请进入 **站点管理** -> **缓存配置**,添加以下规则:
|
||||||
|
|
||||||
|
| 配置项 | 推荐设置 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **边缘缓存过期时间** <br> (Edge Cache TTL) | **不缓存** <br> (或设置为 0秒) | **核心设置**。必须禁止 CDN 节点缓存 AI 的接口响应,否则第二个用户会看到上一个用户的对话,或者直接报错。建议针对 API 目录(如 `/openai/*`)设置。 |
|
||||||
|
| **浏览器缓存过期时间** <br> (Browser Cache TTL) | **不缓存** | 禁止客户端浏览器缓存接口结果。 |
|
||||||
|
| **查询字符串** | **保留** (或 遵循源站) | 某些 AI API 使用 URL 参数传递版本号或签名,不可忽略。 |
|
||||||
|
|
||||||
|
### 2. 回源配置 (Origin) - 解决超时问题的关键
|
||||||
|
ESA 默认的连接超时时间较短(通常 30秒),而 AI 模型(特别是推理模型)可能需要 60秒+ 才能生成第一个字。
|
||||||
|
|
||||||
|
请进入 **站点管理** -> **回源配置**:
|
||||||
|
|
||||||
|
* **读超时时间 (Read Timeout)**: 修改为 **120秒** 或 **300秒**。
|
||||||
|
* *说明*: 如果不改这个,AI 思考超过 30秒时,ESA 会认为源站挂了,直接切断连接并报 `Origin Time-out`。
|
||||||
|
|
||||||
|
### 3. 开发模式 (Debug)
|
||||||
|
如果配置后仍然有问题,可以暂时开启 **“开发模式”**。这会强制所有请求绕过缓存节点直接回源,用于排查是否是缓存规则导致的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 功能特性
|
## 功能特性
|
||||||
|
|
||||||
- **多平台支持**: 代理了众多流行的 AI 和聊天服务 API。
|
- **多平台支持**: 代理了众多流行的 AI 和聊天服务 API。
|
||||||
@@ -111,8 +137,35 @@ go build -o api-proxy main.go
|
|||||||
|
|
||||||
`http://localhost:7890/claude/v1/messages`
|
`http://localhost:7890/claude/v1/messages`
|
||||||
|
|
||||||
|
## 🚀 阿里云 ESA (边缘安全加速) 配置指南 (非常重要)
|
||||||
|
|
||||||
|
如果你使用了阿里 ESA 加速本服务,**必须**在 ESA 控制台中进行以下设置,否则会出现 `Origin Time-out` (源站超时) 或 AI 回复卡顿(打字机效果失效)的问题。
|
||||||
|
|
||||||
|
### 1. 缓存配置 (Cache)
|
||||||
|
请进入 **站点管理** -> **缓存配置**,添加以下规则:
|
||||||
|
|
||||||
|
| 配置项 | 推荐设置 | 说明 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **边缘缓存过期时间** <br> (Edge Cache TTL) | **不缓存** <br> (或设置为 0秒) | **核心设置**。必须禁止 CDN 节点缓存 AI 的接口响应,否则第二个用户会看到上一个用户的对话,或者直接报错。建议针对 API 目录(如 `/openai/*`)设置。 |
|
||||||
|
| **浏览器缓存过期时间** <br> (Browser Cache TTL) | **不缓存** | 禁止客户端浏览器缓存接口结果。 |
|
||||||
|
| **查询字符串** | **保留** (或 遵循源站) | 某些 AI API 使用 URL 参数传递版本号或签名,不可忽略。 |
|
||||||
|
|
||||||
|
### 2. 回源配置 (Origin) - 解决超时问题的关键
|
||||||
|
ESA 默认的连接超时时间较短(通常 30秒),而 AI 模型(特别是推理模型)可能需要 60秒+ 才能生成第一个字。
|
||||||
|
|
||||||
|
请进入 **站点管理** -> **回源配置**:
|
||||||
|
|
||||||
|
* **读超时时间 (Read Timeout)**: 修改为 **120秒** 或 **300秒**。
|
||||||
|
* *说明*: 如果不改这个,AI 思考超过 30秒时,ESA 会认为源站挂了,直接切断连接并报 `Origin Time-out`。
|
||||||
|
|
||||||
|
### 3. 开发模式 (Debug)
|
||||||
|
如果配置后仍然有问题,可以暂时开启 **“开发模式”**。这会强制所有请求绕过缓存节点直接回源,用于排查是否是缓存规则导致的问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 注意事项
|
## 注意事项
|
||||||
|
|
||||||
- 本项目仅作为 API 请求的转发代理,请确保你拥有对应服务的有效 API Key。
|
- 本项目仅作为 API 请求的转发代理,请确保你拥有对应服务的有效 API Key。
|
||||||
- 代理会透传你的 Authorization 头(API Key)。
|
- 代理会透传你的 Authorization 头(API Key)。
|
||||||
- 首页 (`/`) 提供了一个简单的状态页面,列出所有可用路由。
|
- 首页 (`/`) 提供了一个简单的状态页面,列出所有可用路由。
|
||||||
|
- **隐私**: 本服务会自动过滤 `X-Forwarded-For` 和 `Host` 头,保护你的源站 IP。日志中会尝试解析 `Ali-Cdn-Real-Ip` 以记录真实访问者 IP。
|
||||||
|
|||||||
88
main.go
88
main.go
@@ -14,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version = "1.1.0"
|
Version = "1.2.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -23,10 +23,11 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Denied headers that should not be forwarded to the upstream API
|
// Denied headers that should not be forwarded to the upstream API
|
||||||
var deniedHeaderPrefixes = []string{"cf-", "forward", "cdn"}
|
// x-real-ip is denied to prevent leaking internal IP structures if behind multiple proxies
|
||||||
|
var deniedHeaderPrefixes = []string{"cf-", "forward", "cdn", "x-real-ip"}
|
||||||
var deniedExactHeaders = map[string]bool{
|
var deniedExactHeaders = map[string]bool{
|
||||||
"host": true,
|
"host": true,
|
||||||
"referer": true,
|
// "referer": true, // 有些 CDN 防盗链可能需要 referer
|
||||||
"connection": true,
|
"connection": true,
|
||||||
"keep-alive": true,
|
"keep-alive": true,
|
||||||
"proxy-authenticate": true,
|
"proxy-authenticate": true,
|
||||||
@@ -212,6 +213,16 @@ type MappingItem struct {
|
|||||||
Target string
|
Target string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type loggingResponseWriter struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
||||||
|
lrw.statusCode = code
|
||||||
|
lrw.ResponseWriter.WriteHeader(code)
|
||||||
|
}
|
||||||
|
|
||||||
var apiMapping = map[string]string{
|
var apiMapping = map[string]string{
|
||||||
// "/discord": "https://discord.com/api",
|
// "/discord": "https://discord.com/api",
|
||||||
// "/telegram": "https://api.telegram.org",
|
// "/telegram": "https://api.telegram.org",
|
||||||
@@ -231,8 +242,23 @@ var apiMapping = map[string]string{
|
|||||||
"/cerebras": "https://api.cerebras.ai",
|
"/cerebras": "https://api.cerebras.ai",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取请求的真实 IP,优先获取 CDN 传递的 Header
|
||||||
|
func getClientIP(r *http.Request) string {
|
||||||
|
// 阿里 CDN (ESA) 通常会把真实 IP 放在 X-Forwarded-For 的第一个
|
||||||
|
// 或者尝试获取 Ali-Cdn-Real-Ip
|
||||||
|
if ip := r.Header.Get("Ali-Cdn-Real-Ip"); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
return strings.TrimSpace(parts[0])
|
||||||
|
}
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
// Parse template
|
// Parse template
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
tpl, err = template.New("index").Parse(htmlTemplate)
|
tpl, err = template.New("index").Parse(htmlTemplate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,10 +306,28 @@ func init() {
|
|||||||
req.Header.Del("X-Forwarded-For")
|
req.Header.Del("X-Forwarded-For")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新增:ModifyResponse 强制禁用 CDN 缓存
|
||||||
|
proxy.ModifyResponse = func(res *http.Response) error {
|
||||||
|
// Nginx 和大部分 CDN 识别这个 Header 来禁用缓冲
|
||||||
|
res.Header.Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
// 确保如果是流式传输,Cache-Control 设置正确
|
||||||
|
// content-type 包含 event-stream 时,强制不缓存
|
||||||
|
if strings.Contains(res.Header.Get("Content-Type"), "event-stream") {
|
||||||
|
res.Header.Set("Cache-Control", "no-cache")
|
||||||
|
res.Header.Set("Connection", "keep-alive")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Optional: Custom error handler
|
// Optional: Custom error handler
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
log.Printf("Proxy error for %s: %v", r.URL.Path, err)
|
clientIP := getClientIP(r)
|
||||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
log.Printf("[ERROR] Client: %s | Target: %s | Error: %v", clientIP, targetURL.Host, err)
|
||||||
|
|
||||||
|
// 返回 JSON 格式错误,方便 AI 客户端解析
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
fmt.Fprintf(w, `{"error": {"message": "Proxy Connection Error: %v", "type": "proxy_error"}}`, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
proxyMap[path] = proxy
|
proxyMap[path] = proxy
|
||||||
@@ -291,17 +335,28 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func handler(w http.ResponseWriter, r *http.Request) {
|
func handler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
startTime := time.Now()
|
||||||
|
clientIP := getClientIP(r)
|
||||||
|
|
||||||
|
// 包装 Writer 记录状态码
|
||||||
|
lrw := &loggingResponseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||||
|
|
||||||
|
// 简单日志
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
log.Printf("[REQ] %s %s from %s", r.Method, r.URL.Path, clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Handle Home Page
|
// 1. Handle Home Page
|
||||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
renderHome(w)
|
renderHome(lrw)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle Robots.txt
|
// 2. Handle Robots.txt
|
||||||
if r.URL.Path == "/robots.txt" {
|
if r.URL.Path == "/robots.txt" {
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
lrw.Header().Set("Content-Type", "text/plain")
|
||||||
w.WriteHeader(http.StatusOK)
|
lrw.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, "User-agent: *\nDisallow: /")
|
fmt.Fprint(lrw, "User-agent: *\nDisallow: /")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +377,8 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if matchedPrefix == "" {
|
if matchedPrefix == "" {
|
||||||
http.Error(w, "Not Found", http.StatusNotFound)
|
http.Error(lrw, "Not Found", http.StatusNotFound)
|
||||||
|
log.Printf("[404] Path: %s | IP: %s", path, clientIP)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,7 +403,13 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
|||||||
// - Header copying (with hop-by-hop removal)
|
// - Header copying (with hop-by-hop removal)
|
||||||
// - Body copying
|
// - Body copying
|
||||||
// - Streaming responses
|
// - Streaming responses
|
||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(lrw, r)
|
||||||
|
|
||||||
|
// 只有非 200 或者耗时较长时才打印结束日志,避免日志刷屏
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
if lrw.statusCode != 200 || duration > 5*time.Second {
|
||||||
|
log.Printf("[RES] %d | %v | %s -> %s", lrw.statusCode, duration, path, apiMapping[matchedPrefix])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderHome(w http.ResponseWriter) {
|
func renderHome(w http.ResponseWriter) {
|
||||||
@@ -388,7 +450,7 @@ func main() {
|
|||||||
IdleTimeout: 60 * time.Second, // Keep-alive connection idle time
|
IdleTimeout: 60 * time.Second, // Keep-alive connection idle time
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Starting proxy server on " + server.Addr)
|
log.Printf("Starting proxy server on %s (Behind CDN mode)", server.Addr)
|
||||||
if err := server.ListenAndServe(); err != nil {
|
if err := server.ListenAndServe(); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user