feat(llm): 连接测试打印网关原始响应(状态码+响应体)

点击后台「测试」时,把每次 HTTP 尝试的状态码与原始 wire body 打进
服务端日志,便于诊断 401/额度文案/空帧/返回 HTML 页面等情况——不再
只看到 UI 折叠后的 ok/err。

- llmrec.Capture 新增 Attempts(),按次返回 {状态码, 响应体}
- TestConnection 自建 Capture 挂到 ctx(不依赖 LLM 记录开关),
  经 quotaAwareTransport 填充后由 logTestConnection 输出,body 裁到 4K
This commit is contained in:
Autumn-27 committed 2026-08-31 09:45:01 -04:00
1 parent a2f7ac9747
commit 3ddb0cb808
2 files changed
+60

No files matched your search

+38
View File
@@ -12,6 +12,7 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@@ -356,6 +357,38 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil
} }
// logTestConnection prints the raw HTTP status code(s) and response body of a
// connection test to the server log, so "点击测试" leaves a diagnosable trail of
// exactly what the gateway returned — 401 bodies, quota text, empty frames — not
// just the collapsed ok/err the UI shows. Bodies are clipped to keep a chatty
// SSE stream from flooding the log.
func logTestConnection(c Config, capt *llmrec.Capture) {
attempts := capt.Attempts()
if len(attempts) == 0 {
log.Printf("[llm-test] %s / %s @ %s — 未发出任何 HTTP 请求(配置解析或建连即失败)",
c.Provider(), c.Model, c.BaseURL)
return
}
for i, a := range attempts {
log.Printf("[llm-test] %s / %s @ %s — 尝试 %d/%d HTTP %d\n响应体: %s",
c.Provider(), c.Model, c.BaseURL, i+1, len(attempts), a.Status, clipBody(a.Body))
}
}
// clipBody trims a wire body for logging. 4K is plenty to show an error JSON or
// the head of an SSE stream while bounding a runaway response.
func clipBody(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return "(空)"
}
const max = 4096
if len(s) > max {
return s[:max] + fmt.Sprintf("…(截断,共 %d 字节)", len(s))
}
return s
}
// TestConnection makes a minimal real completion to verify the provider/model/ // TestConnection makes a minimal real completion to verify the provider/model/
// endpoint/key actually work. Returns the round-trip latency and the model's // endpoint/key actually work. Returns the round-trip latency and the model's
// reply text. // reply text.
@@ -366,6 +399,11 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, string, error
} }
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() defer cancel()
// 抓取原始 wire 报文:连接测试最需要看到的就是网关到底回了什么(状态码+响应体),
// 而 norma 把响应解码成 StreamEvent 后这些就没了。quotaAwareTransport 会在
// context 里找到这个 Capture 并填入每次 HTTP 尝试的状态码与 body。
ctx, capt := llmrec.NewCapture(ctx)
defer logTestConnection(c, capt)
start := time.Now() start := time.Now()
// MaxTokens 要给足:推理模型(如 deepseek-v4-pro)在给出答案前会先产出一大段 // MaxTokens 要给足:推理模型(如 deepseek-v4-pro)在给出答案前会先产出一大段
// 思考(实测对一句 "ping" 也能烧 ~2900 token)。若只给 32,模型会一直卡在"思考阶段" // 思考(实测对一句 "ping" 也能烧 ~2900 token)。若只给 32,模型会一直卡在"思考阶段"
+22
View File
@@ -119,6 +119,28 @@ func (c *Capture) RawResponse() string {
return b.String() return b.String()
} }
// Attempt is one HTTP round trip's status code and response bytes.
type Attempt struct {
Status int
Body string
}
// Attempts returns every round trip in order. Unlike RawResponse — which drops
// the header line for a lone attempt so the bytes stay replayable — this always
// carries the status code, for callers that must report "HTTP 401 + body".
func (c *Capture) Attempts() []Attempt {
if c == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
out := make([]Attempt, 0, len(c.attempts))
for _, a := range c.attempts {
out = append(out, Attempt{Status: a.status, Body: a.body.String()})
}
return out
}
// teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex // teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex
// so a snapshot taken mid-stream never races the writer. // so a snapshot taken mid-stream never races the writer.
type teeBody struct { type teeBody struct {