feat(govai): 0617 优化首批 — 安全/私有化/深度研究/服务层/可观测性
借鉴 odysseus 的能力设计,全程净室实现、零 AGPL 代码、不引入 AGPL 依赖。
T1 提示注入防护: pkg/promptguard 包裹外部/知识库内容为不可信数据,buildMessages 移出 system 指令区。 T2 安全 CI: .github/workflows(ci+security: govulncheck/gitleaks/actionlint/hadolint/trivy)+dependabot+.hadolint.yaml;go.mod 加 toolchain go1.25.11 修复 20 个 stdlib CVE。 T3 管理员 2FA: 迁移 000016 + RFC6238 TOTP/备份码(pkg/auth, 零依赖) + 登录流程集成(后端)。 T4 本地模型: LLM/embedding 支持本地 vLLM/Ollama(OpenAI 兼容, 鉴权头条件发送, NoAuth) + docs/local-deploy.md。 T6 深度研究: 迁移 000017 + Python research-worker(净室多步流水线, 检索避开 SearXNG) + Go research 服务/handler/路由。 T7 service 层: 新增 internal/service/{research,twofa}, 2FA 业务逻辑从胖 handler 下沉, 接口注入可单测。 T10 缓存/可观测性: internal/cache(Redis+内存, 优雅降级) 接入 store 热点列表; Prometheus 指标+/metrics; docs/openapi.yaml。 验证: go build/vet/test ./... 全绿(8 包); research-worker 12 单测过; 真实 PG 应用迁移并烟测。
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 用一个 OpenAI 兼容的 mock 服务模拟本地 vLLM/Ollama,验证:
|
||||
// 1) 本地 provider 的流式响应能被 TransformOpenAIStream 正确解析;
|
||||
// 2) 未配置密钥时不发送 Authorization 头(本地无鉴权端点)。
|
||||
func TestLocalProvider_StreamingAndNoAuthHeader(t *testing.T) {
|
||||
var gotAuth string
|
||||
var sawAuthHeader bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
_, sawAuthHeader = r.Header["Authorization"]
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
flusher, _ := w.(http.Flusher)
|
||||
for _, chunk := range []string{
|
||||
`{"id":"cmpl-1","model":"local-model","choices":[{"delta":{"content":"你好"}}]}`,
|
||||
`{"id":"cmpl-1","model":"local-model","choices":[{"delta":{"content":",世界"}}]}`,
|
||||
} {
|
||||
fmt.Fprintf(w, "data: %s\n\n", chunk)
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
mgr := NewManager()
|
||||
// 密钥留空,模拟本地无鉴权端点
|
||||
mgr.Register("local", NewOpenAIProvider("", srv.URL, "local-model"))
|
||||
|
||||
body, err := mgr.ChatStream(context.Background(), "local", &ChatRequest{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ChatStream 出错: %v", err)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
var ended bool
|
||||
if err := TransformOpenAIStream(body, func(ev StreamEvent) {
|
||||
if ev.Answer != "" {
|
||||
sb.WriteString(ev.Answer)
|
||||
}
|
||||
if ev.Event == "message_end" {
|
||||
ended = true
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatalf("解析流出错: %v", err)
|
||||
}
|
||||
|
||||
if sb.String() != "你好,世界" {
|
||||
t.Fatalf("流式拼接结果不符: %q", sb.String())
|
||||
}
|
||||
if !ended {
|
||||
t.Fatal("未收到 message_end 事件")
|
||||
}
|
||||
if sawAuthHeader || gotAuth != "" {
|
||||
t.Fatalf("空密钥时不应发送 Authorization 头,实际: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// 配置了密钥时应发送 Authorization 头(云端/带鉴权的本地服务)。
|
||||
func TestOpenAIProvider_SendsAuthHeaderWhenKeySet(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"id":"1","model":"m","choices":[{"message":{"content":"ok"}}],"usage":{"total_tokens":3}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := NewOpenAIProvider("test-key", srv.URL, "m")
|
||||
resp, err := p.ChatCompletion(context.Background(), &ChatRequest{
|
||||
Messages: []Message{{Role: RoleUser, Content: "hi"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ChatCompletion 出错: %v", err)
|
||||
}
|
||||
if resp.Content != "ok" {
|
||||
t.Fatalf("响应内容不符: %q", resp.Content)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Fatalf("应发送 Bearer 密钥头,实际: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// 未注册的 provider 名应回退到 fallback。
|
||||
func TestManager_FallbackResolution(t *testing.T) {
|
||||
mgr := NewManager()
|
||||
mgr.Register("local", NewOpenAIProvider("", "http://localhost:9", "m"))
|
||||
mgr.SetFallback("local")
|
||||
|
||||
if _, err := mgr.GetProvider("does-not-exist"); err != nil {
|
||||
t.Fatalf("未知 provider 应回退到 fallback,却报错: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,9 @@ func (p *OpenAIProvider) ChatCompletion(ctx context.Context, req *ChatRequest) (
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
if p.apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(httpReq)
|
||||
@@ -141,7 +143,9 @@ func (p *OpenAIProvider) ChatStream(ctx context.Context, req *ChatRequest) (io.R
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
if p.apiKey != "" {
|
||||
httpReq.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.httpClient.Do(httpReq)
|
||||
|
||||
Reference in New Issue
Block a user