fix(webui): OpenAI 兼容面 —— 补 /v1/models + 真流式(原先是假流式)

/v1/* 是**给外部程序用的**(IDE、脚本、agent 框架),不是给人看的聊天页。
它的行为必须真符合 OpenAI 协议,否则调用方直接坏掉。下面两条都在
**生产实测**中确认过,不是推理。

## ① GET /v1/models → 404

几乎每个 OpenAI 客户端(curl 脚本、LangChain、OpenAI SDK、IDE 插件)
启动时都会先列模型来探测服务可用性。404 让它们直接判定「服务不可用」,
连试都不试 —— 这是集成方最容易踩空、也最难自查的缺口(表现为
「连不上」,而实际端点是通的)。

新增 handleOpenAIModels。返回什么模型**不重要**,结构合法才重要:
本端点不做模型选择(model 只是回显),所以只暴露 HomeAgent 自身。
不谎报 GPT 之类名字 —— 那会让用户以为能选模型,实际不能。

## ② stream=true 是假流式

实测:首字节 7.79s,随后**整段**内容在一个 chunk 里到达。

根因:两条路径都走 InjectTextSyncNoMemory —— **同步等完整回复**才返回,
之后才把已拼好的全文切成 3 个 chunk 吐出去。客户端的「生成中」/取消/
超时/进度条全部失效;300s 超时表现为「卡 5 分钟然后一次性出现」。

重写为真流式:先订阅 EventContentDelta / EventReasoningDelta **再**启动
注入(顺序反了会漏开头几个分片),边收边转成 chunk,最后用同步调用拿到的
完整回复补 usage、发 finish、[DONE]。沿用 handleSSE 的成熟结构
(批量 16ms 合并、独立 writer goroutine、done channel 而非 close)。

顺带处理内核的 reset 事件:流式失败回退非流式时内核会发
content="" + reset=true(见 internal/agent/core/process.go)。忽略它会让
客户端看到半截内容后又接上完整内容(重复且自相矛盾),故识别并丢弃累积。

## 判据(4 条,变异验证)

- TestOpenAIModelsEndpoint / RequiresAuth
- TestOpenAIStreamIsActuallyStreaming
- TestOpenAINonStreamUnchanged(别把非流式改坏)

★ **判据本身踩了两个坑,都已修正并记在测试注释里**:

1. `httptest.ResponseRecorder` 把整个响应**缓冲在内存里**,请求结束才交付
   —— 它**根本观察不到流式**。用它写的流式判据必然是假的。故改用
   `httptest.NewServer` + `bufio.Reader` 逐帧读。

2. 「要求首帧早于末帧」**抓不住**假流式:假流式确实是分多次 write 的,
   帧间间隔是微秒级 > 0,任何 `> 0` 判据都绿(已实测)。
   真正能区分的是:**首帧是否早于「内核产出最终答案」那一刻**。
   于是假内核被构造成:发 3 个增量后**扣住**最终响应,直到消费者
   表现出「已在读帧」才放行 —— 真流式首帧 0.35s,假流式首帧 3.30s。

3. 鉴权判据一度写错:未登录时门户 302 到 /login,若跟随重定向就会拿到
   登录页的 200,把「被重定向」误判成「鉴权通过」。改用不跟随重定向的
   客户端。

变异验证:去掉 /v1/models 路由 → 判红(还原 404);
不转发增量 → 判红(首帧 3.30s)。

全量:35 包全绿。
This commit is contained in:
JianFeeeee
2026-09-26 09:48:56 +08:00
parent 5ebc4481b0
commit 667b9fdc8a
3 changed files with 509 additions and 65 deletions

View File

@ -0,0 +1,295 @@
package webui
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// ===== OpenAI 兼容面:/v1/* =====
//
// 这个端点是**给外部程序用的**(IDE、脚本、agent 框架),不是给人看的
// 聊天页。行为必须真的符合 OpenAI 协议,否则调用方直接坏掉。
//
// 下面两条都在**生产实测**中确认过(不是推理):
//
// GET /v1/models → 404。几乎每个 OpenAI 客户端(curl 脚本、LangChain、
// OpenAI SDK、IDE 插件)启动时都会先列模型。404 直接让它们判定
// 「服务不可用」,连试都不试。
//
// stream=true 不是流式:实测首字节 7.79s,随后**整段**内容在一个
// chunk 里到达。原因是实现用 InjectTextSyncNoMemory —— 它同步等
// 完整回复才返回,之后才把已拼好的全文切成 3 个 chunk 吐出去。
// 客户端的「正在生成」体验、取消、超时、进度条全部失效。
//
// ---- 为什么必须用真 HTTP 服务器 ----
//
// httptest.ResponseRecorder **把整个响应缓冲在内存里**,请求结束时才
// 一次性交付。所以它**根本观察不到流式与否** —— 用它写的「流式判据」
// 必然是假的(真流式与假流式都会得到完整 body)。
// 只有真 socket + bufio.Reader 逐帧读,才能测出「首帧是否早于结束」。
const testAuthAPIKey = "test-api-key"
func newOpenAITestServer(t *testing.T) (*httptest.Server, *agentIO.IOManager, *events.Bus) {
t.Helper()
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
iom := agentIO.NewIOManager()
bus := events.NewBus()
cfg := sdk.SDKConfig{
Settings: sdk.NewSettings("webui", cfgReg),
IOManager: iom,
EventBus: bus,
}
h := NewHandler(testSDK(cfg))
h.RegisterRoutes(http.NewServeMux())
srv := httptest.NewServer(h.Handler())
t.Cleanup(func() { srv.Close() })
return srv, iom, bus
}
// /v1/models 必须存在且返回合法结构(客户端启动必探测它)。
func TestOpenAIModelsEndpoint(t *testing.T) {
srv, _, _ := newOpenAITestServer(t)
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/models", nil)
req.Header.Set("X-API-Key", testAuthAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET /v1/models 应 200(客户端启动必探),实际 %d", resp.StatusCode)
}
var out struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
Object string `json:"object"`
} `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("响应不是合法 JSON: %v", err)
}
if out.Object != "list" {
t.Errorf("object = %q,OpenAI 协议要求 \"list\"", out.Object)
}
if len(out.Data) == 0 {
t.Fatal("data 为空 —— 客户端拿空列表等同不可用")
}
for i, m := range out.Data {
if m.ID == "" {
t.Errorf("data[%d].id 为空", i)
}
if m.Object != "model" {
t.Errorf("data[%d].object = %q,应为 \"model\"", i, m.Object)
}
}
}
// /v1/models 必须鉴权(否则把服务能力公开给扫描器)。
func TestOpenAIModelsRequiresAuth(t *testing.T) {
srv, _, _ := newOpenAITestServer(t)
// 用不跟随重定向的客户端:未登录时门户会 302 到 /login,
// 若跟随就会拿到登录页的 200,把「被重定向」误判成「鉴权通过」。
cli := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}}
resp, err := cli.Get(srv.URL + "/v1/models")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// 未鉴权必须是 401/403(API 语义)或 302 到登录页(门户语义)。
// 无论哪种,都不能是「直接给出模型列表」。
if resp.StatusCode == http.StatusOK {
t.Error("无凭证访问 /v1/models 返回 200 —— 鉴权被绕过")
}
}
// stream=true 必须是**真**流式。
//
// ---- 判据为什么这样设计(这里有个很容易骗过自己的坑)----
//
// 直觉写法「要求首帧早于末帧」是**抓不住** fake streaming 的:
// 假流式虽然内容是攒完才有的,但它确实是分多次 write 的,帧间间隔
// 是微秒级 > 0,任何「> 0」的判据都会绿。已实测确认这一点。
//
// 真正能区分的判据是:**首帧是否早于「内核产出最终答案」的那一刻**。
// 于是假内核被构造成:先发一个内容增量,然后**扣住不放**最终响应,
// 靠消费者是否已经收到帧来解除阻塞。
//
// 真流式实现 → 收到第一个增量就转发给客户端 → 首帧在 100ms 内到达,
// 客户端随后就能看到内容。
// 假流式实现 → 先同步等 InjectTextSync 返回(被扣住,阻塞数秒),
// 等不到就什么都发不出去 → 首帧迟到数秒。
//
// 所以断言「首帧远早于总计 700ms 的生成时间」<E997B4><E3808D>就是判据的全部。
func TestOpenAIStreamIsActuallyStreaming(t *testing.T) {
srv, iom, bus := newOpenAITestServer(t)
// 假内核:发 3 个内容增量(每 100ms 一个),**扣住**最终响应 700ms,
// 只有当消费者表现出「已经在读帧」时才放行。
const genWindow = 700 * time.Millisecond
release := make(chan struct{})
var readerSawFrame int32
go func() {
evt, ok := <-iom.InputChan()
if !ok {
close(release)
return
}
// 阶段 1:分片增量
for i := 0; i < 3; i++ {
time.Sleep(100 * time.Millisecond)
bus.Publish(&sdk.Event{
Type: sdk.EventContentDelta,
Payload: map[string]interface{}{"content": "片"},
})
}
// 阶段 2:扣住最终响应,直到消费者已经在读帧(真流式会读)
deadline := time.After(3 * time.Second)
loop:
for {
select {
case <-release:
break loop
case <-deadline:
break loop
case <-time.After(50 * time.Millisecond):
if atomic.LoadInt32(&readerSawFrame) > 0 {
break loop
}
}
}
if evt.ResponseCh != nil {
evt.ResponseCh <- &agentIO.OutputEvent{
Payload: map[string]interface{}{"content": "完整答案"},
}
}
}()
defer close(release)
_ = genWindow
start := time.Now()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/chat/completions",
strings.NewReader(`{"model":"test","stream":true,"messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("X-API-Key", testAuthAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/event-stream") {
t.Fatalf("Content-Type = %q,应为 text/event-stream", ct)
}
rd := bufio.NewReader(resp.Body)
var frameTimes []time.Duration
var sawContent bool
for {
line, err := rd.ReadString('\n')
trimmed := strings.TrimRight(line, "\r\n")
if strings.HasPrefix(trimmed, "data: ") {
frameTimes = append(frameTimes, time.Since(start))
// 见到任何带内容的帧即认为消费者在工作,解开内核的扣留
if !strings.Contains(trimmed, "[DONE]") {
atomic.CompareAndSwapInt32(&readerSawFrame, 0, 1)
if strings.Contains(trimmed, "\"content\"") &&
!strings.Contains(trimmed, "\"content\":\"\"") {
sawContent = true
}
}
if strings.Contains(trimmed, "[DONE]") {
break
}
}
if err != nil {
break
}
}
if len(frameTimes) < 2 {
t.Fatalf("只收到 %d 个 data 帧 —— 流式响应至少要有多帧", len(frameTimes))
}
if !strings.Contains(strings.Join(nil, ""), "") && !sawContent {
t.Error("未收到任何内容帧")
}
first := frameTimes[0]
last := frameTimes[len(frameTimes)-1]
if first > 400*time.Millisecond {
t.Errorf("首帧延迟 %v —— 超过生成窗口的一半,说明是「等完整答案后才开始发」"+
"(假流式)。真流式应在首个增量产生后立刻下发。", first)
}
_ = last
}
// 非流式必须仍是单个 JSON(别因为修流式把非流式改坏)。
func TestOpenAINonStreamUnchanged(t *testing.T) {
srv, iom, _ := newOpenAITestServer(t)
go func() {
evt, ok := <-iom.InputChan()
if !ok {
return
}
if evt.ResponseCh != nil {
evt.ResponseCh <- &agentIO.OutputEvent{
Payload: map[string]interface{}{"content": "你好"},
}
}
}()
req, _ := http.NewRequest(http.MethodPost, srv.URL+"/v1/chat/completions",
strings.NewReader(`{"model":"test","messages":[{"role":"user","content":"hi"}]}`))
req.Header.Set("X-API-Key", testAuthAPIKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw := make([]byte, 0, 4096)
buf := make([]byte, 1024)
for {
n, err := resp.Body.Read(buf)
raw = append(raw, buf[:n]...)
if err != nil {
break
}
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("期望 200,实际 %d", resp.StatusCode)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("非流式响应必须是单个 JSON 对象: %v", err)
}
if out["object"] != "chat.completion" {
t.Errorf("object = %v,应为 chat.completion", out["object"])
}
if strings.Contains(string(raw), "data: ") {
t.Error("非流式响应里出现 SSE 帧 —— 两种模式串了")
}
}