Skip to content

Commit cdfd5d7

Browse files
committed
fix: review 跟进——流式报错回传真实状态码等修复
- 流式请求上游拒绝(400/429/5xx、busy、流内错误帧)在尚未写出任何内容时 按真实状态码回传 JSON 错误体,不再伪装成已提交的 200 SSE 流; 内容已输出后才失败的仍走 SSE error chunk(wrote 边界) - AuthError(上游会话/PAT 凭证被拒)映射 502 upstream_error, 不再落 500,也不透传 401 以免客户端误判自己的 key 失效 - 流内错误帧(InStreamError,导出)按帧内嵌状态码映射,无状态码则 502 - 上游 429 的 Retry-After 响应头捕获并透传(与 busy 路径同形) - 请求解码启用 UseNumber:verbatim 数字透传精度无损(2^53+1、 长小数不再经 float64 取整),numberJSON 的 json.Number 分支就此生效 - max_thinking_tokens 客户端显式值原样转发(覆盖 none 档合成的 0), 类型错误本地 400;不再静默丢弃 - GetCatalog 缓存时间戳改为 fetch 后取值,TTL 不再被取数耗时稀释 - 前端:copy() 判空+catch(非安全上下文不再静默失败); keys/token/settings 加载失败补 toast(与 stats/models 一致); settings 保存注释与实现对齐;后台标签页暂停统计轮询、回前台即刷 - 测试:流式上游拒绝状态码表 / 内容后失败保持 SSE 边界 / AuthError 502 / 生产解码路径 verbatim 精度 / max_thinking_tokens 透传 / admin+web 同 mux 联合装配 / 前端 API 路径与 admin 路由契约 - README 错误回传约定与参数表同步更新
1 parent 9025f30 commit cdfd5d7

15 files changed

Lines changed: 569 additions & 90 deletions

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ curl http://localhost:10081/v1/models \
171171
| 请求字段 | 处理 |
172172
| --- | --- |
173173
| `reasoning_effort` / `reasoning.effort` | 原样写入 `parameters.reasoning_effort`(顶层优先)。不校验档位、不映射 `minimal`、不因目录未声明而丢弃;模型不支持时由网关报错回传 |
174-
| `max_tokens` / `max_completion_tokens` | 任意 JSON 数値原样写入 `parameters.max_tokens`(含 0、负数、小数、超大值;`max_tokens` 优先)。未传时才用目录默认值 |
174+
| `max_tokens` / `max_completion_tokens` | 任意 JSON 数値原样写入 `parameters.max_tokens`(含 0、负数、小数、超大值与超出 float64 精度的整数;`max_tokens` 优先)。未传时才用目录默认值 |
175+
| `max_thinking_tokens` | 客户端显式传入时原样转发(即使同时携带 `reasoning_effort: "none"`,也以客户端值为准);仅 `none` 档且未传时才合成显式 `0`(官方客户端的天闭开关) |
175176
| `temperature` / `top_p` / `stop` / `seed` / `response_format` / `logit_bias` / `presence_penalty` / `frequency_penalty` / `n=1` | 原样写入 `parameters`。网关认不认由网关决定,错误回传 |
176177
| `n` ≠ 1 | 本地 `400`:响应通道结构上只能产生单个 choice,拒绝静默只返回一个 |
177178
| 类型错误(如 `"max_tokens": "8000"`、`"reasoning_effort": 3`) | 本地 `400 invalid_request_error` |
@@ -243,7 +244,7 @@ curl http://localhost:10081/v1/models \
243244

244245
**错误响应约定**:登录失败限流返回 `429`(附 `Retry-After`);创建重复 API Key 返回 `409`;端口配置越界(非 1–65535)返回 `400`;未鉴权或会话过期返回 `401`。
245246

246-
**上游错误回传**:网关对请求本身的拒绝(如不支持的档位、`max_tokens` 范围校验失败)按真实状态码回传——4xx 请求级拒绝 → `400 invalid_request_error` + 网关原始 message;429 → `429 rate_limit_error`;5xx → `502 upstream_error`。参数类型错误(字符串数值等)与 `n`≠1 在本地返回 `400`。流中错误以 SSE error chunk 形式携带真实原因。
247+
**上游错误回传**:网关对请求本身的拒绝(如不支持的档位、`max_tokens` 范围校验失败)按真实状态码回传——4xx 请求级拒绝 → `400 invalid_request_error` + 网关原始 message;429 → `429 rate_limit_error`(上游携带 `Retry-After` 时一并透传);5xx → `502 upstream_error`。流式与非流式一视同仁:只要尚未向客户端写出任何内容,拒绝就以真实状态码的 JSON 错误体返回;内容已开始输出后才失败的,则以 SSE error chunk 携带真实原因(此时状态码只能保持 200)。网关在流内报错(HTTP 200 但错误帧)时,若尚无内容,同样按错误帧内嵌的状态码映射(无状态码则 `502`)。上游会话/PAT 凭证被拒(网关 401/403)回传 `502 upstream_error`(上游凭证问题不是客户端的 API key 问题,不回传 401 以免误导)。参数类型错误(字符串数值等)与 `n`≠1 在本地返回 `400`。
247248

248249
## 额度与计费周期
249250

‎auth/auth.go‎

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -398,15 +398,31 @@ func (e *BusyError) Error() string {
398398
// message instead of collapsing everything into an opaque 500. Typical
399399
// sources: parameter rejections (400, e.g. an unsupported max_tokens range
400400
// or reasoning tier) and gateway faults (5xx).
401+
//
402+
// RetryAfter carries the gateway's numeric Retry-After header when the
403+
// rejection came with one (429); 0 means absent/unparseable, and the client
404+
// response then simply omits the header.
401405
type UpstreamError struct {
402406
StatusCode int
403407
Body string
408+
RetryAfter time.Duration
404409
}
405410

406411
func (e *UpstreamError) Error() string {
407412
return fmt.Sprintf("gateway HTTP %d: %s", e.StatusCode, e.Body)
408413
}
409414

415+
// retryAfterHeader parses a numeric Retry-After response header into a
416+
// duration. The HTTP-date form and unparsable/non-positive values yield 0;
417+
// only 429 rejections are expected to carry the header at all.
418+
func retryAfterHeader(h string) time.Duration {
419+
secs, err := strconv.Atoi(strings.TrimSpace(h))
420+
if err != nil || secs <= 0 {
421+
return 0
422+
}
423+
return time.Duration(secs) * time.Second
424+
}
425+
410426
// busyMeta extracts retryAfterSeconds from the nested JSON message carried
411427
// inside a 10605 payload, if present.
412428
func busyMeta(msg string) time.Duration {
@@ -645,7 +661,7 @@ func postEncoded(ctx context.Context, urlStr string, obj interface{}, machineID,
645661
}
646662
return nil, &AuthError{StatusCode: resp.StatusCode, Detail: string(detail)}
647663
}
648-
return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(detail))}
664+
return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(detail)), RetryAfter: retryAfterHeader(resp.Header.Get("Retry-After"))}
649665
}
650666

651667
var result map[string]interface{}
@@ -991,7 +1007,7 @@ func call(ctx context.Context, sess *SessionContext, method, fullURL string, jso
9911007
}
9921008
return nil, &AuthError{StatusCode: resp.StatusCode, Detail: string(detail)}
9931009
}
994-
return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(detail))}
1010+
return nil, &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(detail)), RetryAfter: retryAfterHeader(resp.Header.Get("Retry-After"))}
9951011
}
9961012

9971013
var result map[string]interface{}
@@ -1105,20 +1121,23 @@ func isErrorEvent(line string) bool {
11051121
return strings.HasPrefix(line, "event:") && strings.TrimSpace(line[len("event:"):]) == "error"
11061122
}
11071123

1108-
// inStreamError describes a gateway failure delivered inside the SSE stream
1124+
// InStreamError describes a gateway failure delivered inside the SSE stream
11091125
// (HTTP status is still 200). Kept when seen so the EOF-without-[DONE]
11101126
// fallback reports the real cause instead of a misleading "connection
1111-
// truncated".
1112-
type inStreamError struct {
1113-
status int
1114-
detail string
1127+
// truncated". Status carries the status embedded in the failure frame
1128+
// (statusCodeValue or msgCode); 0 means unknown. Exported so the chat
1129+
// handler can map the client response to the same status instead of a
1130+
// generic 500 when the stream failed before any content was delivered.
1131+
type InStreamError struct {
1132+
Status int
1133+
Detail string
11151134
}
11161135

1117-
func (e *inStreamError) Error() string {
1118-
if e.status > 0 {
1119-
return fmt.Sprintf("gateway in-stream error (HTTP %d): %s", e.status, e.detail)
1136+
func (e *InStreamError) Error() string {
1137+
if e.Status > 0 {
1138+
return fmt.Sprintf("gateway in-stream error (HTTP %d): %s", e.Status, e.Detail)
11201139
}
1121-
return fmt.Sprintf("gateway in-stream error: %s", e.detail)
1140+
return fmt.Sprintf("gateway in-stream error: %s", e.Detail)
11221141
}
11231142

11241143
// detectInStreamGatewayError extracts a terminal gateway/provider failure
@@ -1133,7 +1152,7 @@ func (e *inStreamError) Error() string {
11331152
// {"success":false,"msgCode":500,"message":"Internal Server Error"}
11341153
//
11351154
// A 200 OK envelope with normal chat/usage/[DONE] bodies returns nil.
1136-
func detectInStreamGatewayError(line string) *inStreamError {
1155+
func detectInStreamGatewayError(line string) *InStreamError {
11371156
if !strings.HasPrefix(line, "data:") {
11381157
return nil
11391158
}
@@ -1151,7 +1170,7 @@ func detectInStreamGatewayError(line string) *inStreamError {
11511170
if detail == "" {
11521171
detail = env.StatusCode
11531172
}
1154-
return &inStreamError{status: env.StatusValue, detail: trimJSONMessage(detail)}
1173+
return &InStreamError{Status: env.StatusValue, Detail: trimJSONMessage(detail)}
11551174
}
11561175
if env.Body != "" || env.StatusCode != "" {
11571176
// A well-formed envelope with a 2xx status is a normal frame.
@@ -1176,7 +1195,7 @@ func detectInStreamGatewayError(line string) *inStreamError {
11761195
if detail == "" {
11771196
detail = fmt.Sprintf("msgCode %d", bare.MsgCode)
11781197
}
1179-
return &inStreamError{status: bare.MsgCode, detail: detail}
1198+
return &InStreamError{Status: bare.MsgCode, Detail: detail}
11801199
}
11811200
return nil
11821201
}
@@ -1234,7 +1253,7 @@ func OpenStreamLines(ctx context.Context, sess *SessionContext, fullURL string,
12341253
}
12351254
return &AuthError{StatusCode: resp.StatusCode, Detail: string(errBody)}
12361255
}
1237-
return &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(errBody))}
1256+
return &UpstreamError{StatusCode: resp.StatusCode, Body: string(truncateErrorBody(errBody)), RetryAfter: retryAfterHeader(resp.Header.Get("Retry-After"))}
12381257
}
12391258

12401259
// Cut off silent streams: long generations are fine (lines keep
@@ -1264,7 +1283,7 @@ func OpenStreamLines(ctx context.Context, sess *SessionContext, fullURL string,
12641283
// success would silently truncate the answer and still emit our own
12651284
// [DONE] to the client.
12661285
sawDone := false
1267-
var gwErr *inStreamError
1286+
var gwErr *InStreamError
12681287

12691288
scanner := bufio.NewScanner(resp.Body)
12701289
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
@@ -1309,7 +1328,7 @@ func OpenStreamLines(ctx context.Context, sess *SessionContext, fullURL string,
13091328
// sure the EOF fallback reports the real cause (from the error
13101329
// frames seen before this event) instead of "truncated".
13111330
if gwErr == nil {
1312-
gwErr = &inStreamError{detail: "upstream stream failed (event:error)"}
1331+
gwErr = &InStreamError{Detail: "upstream stream failed (event:error)"}
13131332
}
13141333
}
13151334
if e := detectInStreamGatewayError(line); e != nil && gwErr == nil {

‎auth/auth_test.go‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,11 @@ func TestDetectInStreamGatewayErrorEnvelope429(t *testing.T) {
131131
if e == nil {
132132
t.Fatal("429 envelope must be detected as in-stream error")
133133
}
134-
if e.status != 429 {
135-
t.Errorf("status = %d, want 429", e.status)
134+
if e.Status != 429 {
135+
t.Errorf("status = %d, want 429", e.Status)
136136
}
137-
if e.detail != "provider_error: All backends failed" {
138-
t.Errorf("detail = %q, want provider_error message", e.detail)
137+
if e.Detail != "provider_error: All backends failed" {
138+
t.Errorf("detail = %q, want provider_error message", e.Detail)
139139
}
140140
}
141141

@@ -161,8 +161,8 @@ func TestDetectInStreamGatewayErrorBareFailure(t *testing.T) {
161161
if e == nil {
162162
t.Fatal("bare success:false frame must be detected")
163163
}
164-
if e.detail != "Internal Server Error" {
165-
t.Errorf("detail = %q", e.detail)
164+
if e.Detail != "Internal Server Error" {
165+
t.Errorf("detail = %q", e.Detail)
166166
}
167167
}
168168

‎bridge/bridge_test.go‎

Lines changed: 51 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -773,28 +773,27 @@ func TestHandleStreamProviderErrorSurfacesRealCause(t *testing.T) {
773773
}}
774774
b := newFakeBridge(t, gw)
775775

776+
// The failure frames carry no content, so nothing is committed to the
777+
// client and the rejection must surface as a real HTTP status carrying the
778+
// embedded 429 — not a 200 SSE stream whose success every SDK would read.
779+
handler := MakeChatHandler(func(string) *OpenAiBridge { return b }, nil)
780+
req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(
781+
`{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}],"stream":true}`))
782+
req.Header.Set("Authorization", "Bearer sk-test")
776783
w := httptest.NewRecorder()
777-
err := b.HandleChat(context.Background(), w, map[string]interface{}{
778-
"model": "Fake-Model",
779-
"messages": []interface{}{map[string]interface{}{"role": "user", "content": "hi"}},
780-
"stream": true,
781-
}, nil)
782-
if err == nil {
783-
t.Fatal("provider outage stream must fail")
784-
}
785-
var repErr *StreamReportedError
786-
if !errors.As(err, &repErr) {
787-
t.Fatalf("expected StreamReportedError, got %T: %v", err, err)
784+
handler(w, req)
785+
786+
if w.Code != http.StatusTooManyRequests {
787+
t.Fatalf("status = %d, want 429 (embedded statusCodeValue)\n%s", w.Code, w.Body.String())
788788
}
789-
body := w.Body.String()
790-
if !strings.Contains(body, "HTTP 429") || !strings.Contains(body, "provider_error: All backends failed") {
791-
t.Errorf("error chunk must surface the real gateway cause, got:\n%s", body)
789+
if !strings.Contains(w.Body.String(), "provider_error: All backends failed") {
790+
t.Errorf("error body must surface the real gateway cause, got:\n%s", w.Body.String())
792791
}
793-
if strings.Contains(body, "connection truncated") {
794-
t.Errorf("misleading truncated message must be gone, got:\n%s", body)
792+
if strings.Contains(w.Body.String(), "connection truncated") {
793+
t.Errorf("misleading truncated message must be gone, got:\n%s", w.Body.String())
795794
}
796-
if strings.HasSuffix(strings.TrimSpace(body), "data: [DONE]") {
797-
t.Error("failed stream must not terminate with [DONE]")
795+
if strings.Contains(w.Header().Get("Content-Type"), "text/event-stream") {
796+
t.Errorf("no content was delivered; the response must be a JSON error, not SSE")
798797
}
799798
}
800799

@@ -1141,18 +1140,22 @@ func TestMakeChatHandlerMapsBusyTo429(t *testing.T) {
11411140
t.Errorf("Retry-After header missing/invalid: %q", ra)
11421141
}
11431142

1144-
// Streaming path: the error chunk carries the busy message and no second
1145-
// HTTP-level response is attempted.
1143+
// Streaming path: with no content delivered yet the response is still
1144+
// uncommitted, so the busy rejection must surface as a real 429 (with
1145+
// Retry-After) instead of an already-committed 200 SSE stream.
11461146
w2 := httptest.NewRecorder()
11471147
body := `{"model":"Fake-Model","messages":[{"role":"user","content":"hi"}],"stream":true}`
11481148
req2 := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body))
11491149
req2.Header.Set("Authorization", "Bearer sk-test")
11501150
handler(w2, req2)
1151-
if w2.Code != 200 {
1152-
t.Errorf("streaming status = %d, want 200 (SSE headers already sent)", w2.Code)
1151+
if w2.Code != http.StatusTooManyRequests {
1152+
t.Errorf("streaming status = %d, want 429 (no content committed yet)\n%s", w2.Code, w2.Body.String())
1153+
}
1154+
if ra := w2.Header().Get("Retry-After"); ra == "" || ra == "0" {
1155+
t.Errorf("streaming Retry-After header missing/invalid: %q", ra)
11531156
}
1154-
if !strings.Contains(w2.Body.String(), "10605") {
1155-
t.Errorf("stream body should carry the busy code:\n%s", w2.Body.String())
1157+
if !strings.Contains(w2.Body.String(), "upstream busy") {
1158+
t.Errorf("stream body should carry the busy message:\n%s", w2.Body.String())
11561159
}
11571160
}
11581161

@@ -1830,8 +1833,9 @@ func TestFmtResetTime(t *testing.T) {
18301833
// rejectingGateway serves the auth endpoints but rejects every chat request
18311834
// with a fixed status and body, for pass-through error mapping tests.
18321835
type rejectingGateway struct {
1833-
status int
1834-
body string
1836+
status int
1837+
body string
1838+
retryAfter string // optional Retry-After header served with the rejection
18351839
}
18361840

18371841
func newRejectingBridge(t *testing.T, gw *rejectingGateway) *OpenAiBridge {
@@ -1847,6 +1851,9 @@ func newRejectingBridge(t *testing.T, gw *rejectingGateway) *OpenAiBridge {
18471851
})
18481852
mux.HandleFunc("/algo/api/v2/service/pro/sse/agent_chat_generation", func(w http.ResponseWriter, r *http.Request) {
18491853
w.Header().Set("Content-Type", "application/json")
1854+
if gw.retryAfter != "" {
1855+
w.Header().Set("Retry-After", gw.retryAfter)
1856+
}
18501857
w.WriteHeader(gw.status)
18511858
io.WriteString(w, gw.body)
18521859
})
@@ -1860,31 +1867,30 @@ func newRejectingBridge(t *testing.T, gw *rejectingGateway) *OpenAiBridge {
18601867
// real status and message instead of being masked as a bridge 500.
18611868
func TestMakeChatHandlerMapsUpstreamErrors(t *testing.T) {
18621869
cases := []struct {
1863-
name string
1864-
status int
1865-
body string
1866-
wantCode int
1867-
wantInBody string
1870+
name string
1871+
status int
1872+
body string
1873+
retryAfter string // Retry-After header served by the gateway, "" for none
1874+
wantCode int
1875+
wantInBody string
1876+
wantRetryAfter string
18681877
}{
18691878
{"400 parameter rejection passes through",
1870-
400, `{"code":"invalid_parameter_error","message":"Range of max_tokens validation failed"}`,
1871-
400, "Range of max_tokens"},
1879+
400, `{"code":"invalid_parameter_error","message":"Range of max_tokens validation failed"}`, "", 400, "Range of max_tokens", ""},
18721880
{"404 unknown model",
1873-
404, `{"code":"not_found","message":"model not found"}`,
1874-
400, "model not found"},
1881+
404, `{"code":"not_found","message":"model not found"}`, "", 400, "model not found", ""},
18751882
{"429 rate limit",
1876-
429, `{"code":"too_many_requests","message":"slow down"}`,
1877-
429, "slow down"},
1883+
429, `{"code":"too_many_requests","message":"slow down"}`, "", 429, "slow down", ""},
1884+
{"429 rate limit with Retry-After passes it through",
1885+
429, `{"code":"too_many_requests","message":"slow down"}`, "7", 429, "slow down", "7"},
18781886
{"500 gateway fault maps to 502",
1879-
500, `{"code":"internal_error","message":"boom"}`,
1880-
502, "boom"},
1887+
500, `{"code":"internal_error","message":"boom"}`, "", 502, "boom", ""},
18811888
{"503 gateway unavailable maps to 502",
1882-
503, "service unavailable",
1883-
502, "service unavailable"},
1889+
503, "service unavailable", "", 502, "service unavailable", ""},
18841890
}
18851891
for _, tc := range cases {
18861892
t.Run(tc.name, func(t *testing.T) {
1887-
gw := &rejectingGateway{status: tc.status, body: tc.body}
1893+
gw := &rejectingGateway{status: tc.status, body: tc.body, retryAfter: tc.retryAfter}
18881894
b := newRejectingBridge(t, gw)
18891895
handler := MakeChatHandler(func(string) *OpenAiBridge { return b }, nil)
18901896

@@ -1900,6 +1906,9 @@ func TestMakeChatHandlerMapsUpstreamErrors(t *testing.T) {
19001906
if !strings.Contains(w.Body.String(), tc.wantInBody) {
19011907
t.Errorf("body must carry the upstream message %q:\n%s", tc.wantInBody, w.Body.String())
19021908
}
1909+
if got := w.Header().Get("Retry-After"); got != tc.wantRetryAfter {
1910+
t.Errorf("Retry-After = %q, want %q", got, tc.wantRetryAfter)
1911+
}
19031912
})
19041913
}
19051914
}

0 commit comments

Comments
 (0)