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:
1 parent
a2f7ac9747
commit
3ddb0cb808
2 files changed
+60
No files matched your search
@@ -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,模型会一直卡在"思考阶段"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user