fix: 修掉模型调用链路上的四处失效逻辑

- 超时:NewGateway 里 gw_timeout*time.Second 把已是 Duration 的值二次换算,
  int64 溢出后无论 OT_PROXY_TIMEOUT 填多少都等于没有超时,渠道 timeout_ms
  也从没被读过。现在非流式用 context deadline 约束整段时长,流式只约束首包
  (Transport.ResponseHeaderTimeout),正文不限时长,长回答不会被砍。
- 断连:上游请求改用 NewRequestWithContext 绑定客户端 context,客户端断开即
  取消上游并记 canceled,不再空转生成继续付费;PATCH 已写 200 头后不再往
  响应里追加错误体。
- 白名单:APIKey.AllowedModels 以前只存不用,现在 Auth 注入 context 并参与
  判定(用户黑名单 > 密钥白名单 > 用户白名单 > 全局)。
- 选路:模型已有渠道绑定时只用绑定渠道,都不可用直接 503 no_channel,不再
  把请求发给不认识它的渠道;新增运行期健康(连续 3 次网络错误/超时/429/5xx
  进 30s 冷却,成功清零),与 60s 一次的 /models 探测互补。
- 密钥 API:PATCH 回显更新后的完整对象;限额填 0 统一视为不限制(原来
  token 配额存 0 会让密钥第一次请求就被 429 打死,而请求配额存 0 又等于
  不限制,两个字段语义相反);前端编辑框总是提交白名单,清空能真的取消。

测试:新增 proxy 链路集成测试(超时生效、流式不被截断、断连取消并记账、
密钥白名单 403、连续 429 后候选剔除)与 channel 用例(绑定模型不退全局、
冷却阈值与到期恢复、成功清零)。
This commit is contained in:
Sakurasan committed 2026-10-16 19:15:51 +08:00
1 parent d911881499
commit e865f69a41
10 files changed
+659 -69

No files matched your search

+45 -8
View File
@@ -3,9 +3,11 @@ package proxy
import (
"bufio"
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -187,8 +189,17 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
idx := (startIdx + offset) % len(cands)
cand := cands[idx]
ch := cand.Channel
// 客户端已断开:上游请求会随 context 立刻失败,换渠道也没有意义
if c.Request.Context().Err() != nil {
g.finishUsage(c, ch, now(), store.UsageStatusCanceled, "client_disconnect")
return
}
if g.ch.InCooldown(ch.ID) {
continue // 本次请求期间已被并发请求判入运行期冷却
}
plan, err := prepareUpstream(ch, clientProto, body, cand.UpstreamModel)
if err != nil {
// 请求体转换失败与渠道健康无关,不换渠道计数
lastStatus, lastBody = http.StatusInternalServerError, []byte("conversion error: "+err.Error())
continue
}
@@ -196,9 +207,9 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
if !ok {
continue // 渠道满载,溢出到下一个
}
written, retry, st, b := g.proxyOne(c, ch, plan, stream, sink)
done, retry, st, b := g.proxyOne(c, ch, plan, stream, sink)
release()
if written {
if done {
return
}
if !retry {
@@ -208,6 +219,7 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
g.recordError(c, ch, nil, now(), "upstream_http_"+strconv.Itoa(st))
return
}
g.ch.ReportFailure(ch) // 网络错误/超时/429/5xx:累计运行期失败
lastStatus, lastBody = st, b
}
// 全部候选重试性失败
@@ -216,8 +228,9 @@ func (g *Gateway) doProxy(c *gin.Context, cands []channel.Candidate, clientProto
}
// proxyOne 对单个渠道执行一次代理。
// 返回:written=是否已写客户端响应;retry=是否可安全换渠道重试;status+respBody=失败信息。
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) (written bool, retry bool, status int, respBody []byte) {
// 返回:done=本次请求已终结(客户端响应已写出或已记账,停止故障转移);
// retry=是否可安全换渠道重试;status+respBody=失败信息。
func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan, stream bool, sink *usageSink) (done bool, retry bool, status int, respBody []byte) {
upKey, err := g.ch.UpstreamKey(ch)
if err != nil {
return false, true, http.StatusInternalServerError, []byte("failed to decrypt channel key")
@@ -235,7 +248,18 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
}
}
req, err := http.NewRequest(http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
// 上游请求绑定客户端 context:客户端断开即取消上游,不再空转生成、继续付费。
// 非流式再叠加渠道超时作为整段硬上限;流式只靠 Transport.ResponseHeaderTimeout
// 约束首包,正文时长不设限(长回答不能被超时砍断)。
limit := g.channelTimeout(ch)
ctx := c.Request.Context()
if !stream && limit > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, limit)
defer cancel()
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, upstreamURL(ch, plan.proto, plan.path), bytes.NewReader(upBody))
if err != nil {
return false, false, http.StatusInternalServerError, []byte("failed to build upstream request")
}
@@ -255,8 +279,13 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
}
start := time.Now()
resp, err := g.hc.Do(req)
resp, err := g.clientFor(limit).Do(req)
if err != nil {
// 客户端断开导致的取消:就地记账并终结本次请求(不计入渠道失败,也不换渠道)
if c.Request.Context().Err() != nil {
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
return true, false, 0, nil
}
return false, true, http.StatusBadGateway, []byte("upstream request failed: " + err.Error())
}
defer resp.Body.Close()
@@ -269,6 +298,7 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
}
return false, false, resp.StatusCode, errBody
}
g.ch.ReportSuccess(ch)
c.Header("Content-Type", resp.Header.Get("Content-Type"))
c.Status(http.StatusOK)
@@ -284,8 +314,15 @@ func (g *Gateway) proxyOne(c *gin.Context, ch *store.Channel, plan *upstreamPlan
func (g *Gateway) copyAndCapture(c *gin.Context, ch *store.Channel, r io.Reader, start time.Time, bodyConv func([]byte) ([]byte, error), sink *usageSink) {
data, err := io.ReadAll(r)
if err != nil {
apiError(c, http.StatusBadGateway, "upstream_error", "failed reading upstream response")
g.recordError(c, ch, nil, start, "read_error")
// 走到这里响应头已按 200 写出,无法再回错误体,只按中断原因记账
switch {
case c.Request.Context().Err() != nil:
g.finishUsage(c, ch, start, store.UsageStatusCanceled, "client_disconnect")
case errors.Is(err, context.DeadlineExceeded):
g.finishUsage(c, ch, start, store.UsageStatusError, "upstream_timeout")
default:
g.finishUsage(c, ch, start, store.UsageStatusError, "read_error")
}
return
}
if usageRaw := extractUsage(data); usageRaw != nil && sink != nil {