Files
sundynix-agentix/sundynix-dispatcher/internal/eino/integration_test.go
T
Blizzard 075d41f5b3 feat(llm): 本地模型接入 vLLM / Ollama + reasoning 思考过程入轨迹
vLLM 与 Ollama 都暴露 OpenAI 兼容 API(底层 go-openai 请求 {base}/chat/completions),
故统一走 openai 客户端,按 provider 归一化连接参数:
- Ollama/vLLM 的 OpenAI 端点固定在 /v1,BaseURL 漏写自动补全(否则打到 /chat/completions 404)
- 本地后端默认不校验 api_key → 缺省补占位(ollama→"ollama",vllm→"EMPTY";openai 客户端要求非空)
- 显式 key 一律尊重(vLLM --api-key 启动);在线 provider 原样不动(DeepSeek 两种都收)

reasoning_content 适配:ChatStream 增 onReasoning 回调,捕获 reasoning 模型
(本地 Qwen3 思考 / DeepSeek-R1 / QwQ、在线 deepseek-v4-pro)的思考分片。思考阶段分片
Content 为空本就不污染答案;runAgent 把思考累计后 surface 到 exec 轨迹「推理过程」事件。

控制台 ModelManager 加 ollama 选项;llm 包补 normalizeBaseURL / apiKeyOrPlaceholder 单测。
四模块全绿。live:经 admin 配 ollama qwen2.5:0.5b → dispatcher 热切(日志 base 自动 .../v1)
→ POST /v1/chat/completions 200 端到端出答案;切回 deepseek-v4-pro → exec 轨迹现「推理过程:思考26字…」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:10:18 +08:00

248 lines
7.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"github.com/cloudwego/eino/components/model"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
"github.com/sundynix/sundynix-shared/contract"
)
// ---- 测试替身 ----
type fakeLLM struct {
ready bool
stream func(msgs []llm.ChatMessage) string // ChatStream 要回流的整段文本
chat func(msgs []llm.ChatMessage) (string, error) // Chat 返回
cm model.BaseChatModel // compose 路径用的 Eino 模型(可为 nil)
}
func (f *fakeLLM) Ready() bool { return f.ready }
func (f *fakeLLM) ChatStream(_ context.Context, msgs []llm.ChatMessage, onToken func(string), _ func(string)) error {
if f.stream != nil {
onToken(f.stream(msgs))
}
return nil
}
func (f *fakeLLM) StreamText(_ context.Context, text string, onToken func([]byte)) error {
onToken([]byte(text))
return nil
}
func (f *fakeLLM) Chat(_ context.Context, msgs []llm.ChatMessage) (string, error) {
if f.chat != nil {
return f.chat(msgs)
}
return "", nil
}
// ToolCallingModel:假模型不支持函数调用 → ReAct 路径会降级回普通对话。
func (f *fakeLLM) ToolCallingModel() model.ToolCallingChatModel { return nil }
// ChatModelcompose 路径用;nil 时 runComposeConversation 降级回 runAgent。
func (f *fakeLLM) ChatModel() model.BaseChatModel { return f.cm }
type fakeSink struct {
mu sync.Mutex
tokens []string
done bool
}
func (s *fakeSink) PublishToken(_ string, tok []byte) error {
s.mu.Lock()
s.tokens = append(s.tokens, string(tok))
s.mu.Unlock()
return nil
}
func (s *fakeSink) CompleteStream(_ string) error { s.done = true; return nil }
func (s *fakeSink) text() string {
s.mu.Lock()
defer s.mu.Unlock()
return strings.Join(s.tokens, "")
}
type fakeTools struct {
mu sync.Mutex
calls []string
fn func(call *contract.ToolCall) *contract.ToolResult
}
func (t *fakeTools) CallTool(_ context.Context, _ string, call *contract.ToolCall) (*contract.ToolResult, error) {
t.mu.Lock()
t.calls = append(t.calls, call.Tool)
t.mu.Unlock()
if t.fn != nil {
return t.fn(call), nil
}
return &contract.ToolResult{OK: true, Content: ""}, nil
}
func (t *fakeTools) called(tool string) bool {
t.mu.Lock()
defer t.mu.Unlock()
for _, c := range t.calls {
if c == tool {
return true
}
}
return false
}
type fakeExec struct {
mu sync.Mutex
events []contract.ExecEvent
}
func (e *fakeExec) PublishExec(_ string, data []byte) error {
var ev contract.ExecEvent
if json.Unmarshal(data, &ev) == nil {
e.mu.Lock()
e.events = append(e.events, ev)
e.mu.Unlock()
}
return nil
}
func (e *fakeExec) CompleteExec(_ string) error { return nil }
func newOrch(ll *fakeLLM, ft *fakeTools, fs *fakeSink, fe *fakeExec) *Orchestrator {
return &Orchestrator{pool: ll, breaker: harness.NewCircuitBreaker(), sink: fs, tools: ft, exec: fe}
}
func task(graph string) *contract.Task {
return &contract.Task{ID: "t1", Graph: json.RawMessage(graph), Meta: map[string]any{}}
}
// ---- 集成测试:runGraph ----
// 分支按 true/false 边标签精确选路(true 边故意列第二位)。
func TestRunGraph_BranchRouting(t *testing.T) {
g := `{"nodes":[
{"id":"i","kind":"input","config":{"text":"hi"}},
{"id":"b","kind":"branch","config":{"condition":"%s"}},
{"id":"a","kind":"agent","config":{"system":"AAA"}},
{"id":"c","kind":"agent","config":{"system":"BBB"}}
],"edges":[
{"source":"i","target":"b"},
{"source":"b","target":"c","sourceHandle":"false"},
{"source":"b","target":"a","sourceHandle":"true"}
]}`
// fakeLLM 回流"系统提示词",借此判断哪个 agent 跑了。
ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { return m[0].Content }}
run := func(cond string) string {
o := newOrch(ll, &fakeTools{}, &fakeSink{}, &fakeExec{})
ans, _, err := o.runGraph(context.Background(), task(strings.Replace(g, "%s", cond, 1)), o.tracer("t1"))
if err != nil {
t.Fatal(err)
}
return ans
}
if a := run("2>1"); !strings.Contains(a, "AAA") || strings.Contains(a, "BBB") {
t.Errorf("条件真应走 true 标签(AAA)got %q", a)
}
if a := run("1>2"); !strings.Contains(a, "BBB") || strings.Contains(a, "AAA") {
t.Errorf("条件假应走 false 标签(BBB)got %q", a)
}
}
// 工具节点产出注入 agent 上下文。
func TestRunGraph_ToolFeedsAgent(t *testing.T) {
g := `{"nodes":[
{"id":"i","kind":"input","config":{"text":"hi"}},
{"id":"t","kind":"tool","config":{"tool":"wiki_search"}},
{"id":"a","kind":"agent","config":{"system":"S"}}
],"edges":[{"source":"i","target":"t"},{"source":"t","target":"a"}]}`
ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string { return m[0].Content }}
ft := &fakeTools{fn: func(c *contract.ToolCall) *contract.ToolResult {
return &contract.ToolResult{OK: true, Content: "TOOLDATA"}
}}
o := newOrch(ll, ft, &fakeSink{}, &fakeExec{})
ans, _, err := o.runGraph(context.Background(), task(g), o.tracer("t1"))
if err != nil {
t.Fatal(err)
}
if !ft.called("wiki_search") {
t.Error("应调用 wiki_search 工具")
}
if !strings.Contains(ans, "TOOLDATA") {
t.Errorf("工具产出应注入 agent 上下文,got %q", ans)
}
}
// map 并行 fan-out:拆项 → 各章撰写 → 汇成多章成稿。
func TestRunGraph_MapFanout(t *testing.T) {
g := `{"nodes":[
{"id":"i","kind":"input","config":{"text":"猫"}},
{"id":"m","kind":"map","config":{"splitBy":"要点"}}
],"edges":[{"source":"i","target":"m"}]}`
ll := &fakeLLM{ready: true, chat: func(m []llm.ChatMessage) (string, error) {
u := m[len(m)-1].Content
if strings.Contains(u, "拆分") {
return `["第一章","第二章"]`, nil
}
return "正文XYZ", nil
}}
o := newOrch(ll, &fakeTools{}, &fakeSink{}, &fakeExec{})
ans, _, err := o.runGraph(context.Background(), task(g), o.tracer("t1"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(ans, "## 第一章") || !strings.Contains(ans, "## 第二章") || !strings.Contains(ans, "正文XYZ") {
t.Errorf("map 应产出多章成稿,got %q", ans)
}
}
// 输出护栏:发射的 token 中疑似密钥被脱敏。
func TestRunGraph_OutputRedaction(t *testing.T) {
g := `{"nodes":[{"id":"i","kind":"input","config":{"text":"hi"}},{"id":"a","kind":"agent","config":{"system":"S"}}],"edges":[{"source":"i","target":"a"}]}`
ll := &fakeLLM{ready: true, stream: func(m []llm.ChatMessage) string {
return "你的 key 是 sk-912cf85b16d04b22bcb95f4576423bfb 请保密"
}}
fs := &fakeSink{}
o := newOrch(ll, &fakeTools{}, fs, &fakeExec{})
if _, _, err := o.runGraph(context.Background(), task(g), o.tracer("t1")); err != nil {
t.Fatal(err)
}
out := fs.text()
if strings.Contains(out, "sk-912cf85b16d04b22bcb95f4576423bfb") {
t.Errorf("流式输出未脱敏密钥: %q", out)
}
if !strings.Contains(out, "[已脱敏]") {
t.Errorf("应出现脱敏标记: %q", out)
}
}
// ---- 集成测试:handleReport ----
func TestHandleReport_PlanWriteStore(t *testing.T) {
ll := &fakeLLM{ready: true, chat: func(m []llm.ChatMessage) (string, error) {
u := m[len(m)-1].Content
if strings.Contains(u, "大纲") {
return `{"title":"咖啡报告","sections":["提神","风险"]}`, nil
}
return "本章正文。", nil
}}
ft := &fakeTools{fn: func(c *contract.ToolCall) *contract.ToolResult {
return &contract.ToolResult{OK: true, Content: "/tmp/x.json"}
}}
fs := &fakeSink{}
o := newOrch(ll, ft, fs, &fakeExec{})
tk := &contract.Task{ID: "r1", Graph: json.RawMessage(`{}`), Meta: map[string]any{contract.MetaTopic: "咖啡"}}
if err := o.handleReport(context.Background(), tk, o.tracer("r1")); err != nil {
t.Fatal(err)
}
if !ft.called("report_store") {
t.Error("应调用 report_store 存源")
}
out := fs.text()
if !strings.Contains(out, "# 咖啡报告") || !strings.Contains(out, "## 提神") || !strings.Contains(out, "## 风险") {
t.Errorf("报告流应含标题与各章,got %q", out)
}
if !fs.done {
t.Error("报告结束应 CompleteStream")
}
}