Files
Cyrene_v2/internal/runtime/model/openai_test.go
T
Aska 2248ddce15
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(web): 用户侧体验补齐——思维链/工具卡数据源 + 图片附件 + 语音上行 + home/vision/voice 真表现
- 打通 thinking/tool_call 运行时数据源:openai 发 thinking_*(reasoning_content)/tool_call_*
  (delta.tool_calls 累积重新组装 Args 非 JSON 串);anthropic_stream 发 thinking_*(content_block
  thinking start/delta/stop)。ws_bridge 已映射 thinking.*/tool_call.*,前端渲染活。
- 前端消息模型 + 附件:ChatMessage 加 image?;ChatInput 加附件按钮(file→base64)、预览/移除、
  image-aware onSend(text,image);VisionMessageBody 升级图像卡+识别 chip;VoiceMessageBody
  升级通话控制卡(live2D预留/波形/静音/挂断);HomeMessageBody 读真实 deviceUpdates(去掉
  sampleDevices 猜)。
- 激活 voice_input:ChatShell onVoice 不再 no-op → sendUp voice_input + 切 voice mode。
- addTool 改按工具名 upsert(修复 tool_call.start/end 重复卡);ws_mode_switch 应用 agent 驱动
  mode;addUserMessage/beginAssistant 带 image/mode;chatStream/useChat.send 带 image。
- 新增 image/phone 图标。

已知限制(标注):图片仅前端收集(后端 chatRequest 无 image 字段未接真推理);流式工具卡无真实
工具名(StreamEvent 只带 ID,回退 tool_<id前缀>);mode 由 agent 驱动但后端未逐消息下发 mode
(mode_switch 回显+默认当前 mode 兜底);voice 为 UI+voice_input 上行(真实通话后置)。
go build/vet/test + 前端 tsc 绿。
2026-09-06 17:43:03 +08:00

355 lines
12 KiB
Go

package model
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
const okCompletion = `{
"id": "cmpl-1",
"model": "gpt-test",
"choices": [
{"index": 0, "message": {"role": "assistant", "content": "Hello!"}, "finish_reason": "stop"}
],
"usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}
}`
// capturedRequest records what an OpenAIChatAdapter sent to a stub server.
type capturedRequest struct {
method string
path string
contentType string
body map[string]any
}
func newCompletionServer(t *testing.T, status int, contentType, body string, ch chan<- capturedRequest) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var reqBody map[string]any
if ch != nil {
_ = json.NewDecoder(r.Body).Decode(&reqBody)
}
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(status)
if body != "" {
_, _ = io.WriteString(w, body)
}
if ch != nil {
ch <- capturedRequest{
method: r.Method,
path: r.URL.Path,
contentType: r.Header.Get("Content-Type"),
body: reqBody,
}
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestOpenAIChat_Chat_NonStreaming(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
recs := make([]CallRecord, 0, 1)
temp := 1.0
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, APIKey: "sk-test", Model: "gpt-test"},
WithRecorder(func(cr CallRecord) { recs = append(recs, cr) }))
ctx := context.Background()
resp, err := ad.Chat(ctx, Request{
Messages: []Message{TextMessage(RoleUser, "hi")},
Model: "gpt-test",
Temperature: &temp,
MaxTokens: 64,
Purpose: "chat",
}, Options{})
if err != nil {
t.Fatalf("Chat error: %v", err)
}
req := <-ch
if req.method != http.MethodPost {
t.Errorf("method = %s, want POST", req.method)
}
if req.path != "/chat/completions" {
t.Errorf("path = %s, want /chat/completions", req.path)
}
if req.contentType != "application/json" {
t.Errorf("content-type = %s, want application/json", req.contentType)
}
if req.body["model"] != "gpt-test" {
t.Errorf("body.model = %v, want gpt-test", req.body["model"])
}
if req.body["stream"] != nil && req.body["stream"] != false {
t.Errorf("body.stream = %v, want omitted(false)", req.body["stream"])
}
if req.body["temperature"] != 1.0 {
t.Errorf("body.temperature = %v, want 1.0", req.body["temperature"])
}
if req.body["max_tokens"] != float64(64) {
t.Errorf("body.max_tokens = %v, want 64", req.body["max_tokens"])
}
msgs, _ := req.body["messages"].([]any)
if len(msgs) != 1 {
t.Fatalf("body.messages = %v, want 1 message", req.body["messages"])
}
if resp.Model != "gpt-test" {
t.Errorf("resp.Model = %q, want gpt-test", resp.Model)
}
if resp.Content != "Hello!" {
t.Errorf("resp.Content = %q, want Hello!", resp.Content)
}
if resp.TotalTokens != 7 || resp.PromptTokens != 5 || resp.CompletionTokens != 2 {
t.Errorf("resp usage = (%d,%d,%d), want (5,2,7)", resp.PromptTokens, resp.CompletionTokens, resp.TotalTokens)
}
if resp.Message.Role != RoleAssistant || len(resp.Message.Content) != 1 || resp.Message.Content[0].Text != "Hello!" {
t.Errorf("resp.Message = %+v", resp.Message)
}
if len(recs) != 1 {
t.Fatalf("records = %d, want 1", len(recs))
}
if recs[0].Model != "gpt-test" || recs[0].Purpose != "chat" || recs[0].Tokens != 7 || recs[0].TookMs < 0 {
t.Errorf("record = %+v, want model=gpt-test purpose=chat tokens=7 took_ms>=0", recs[0])
}
}
func TestOpenAIChat_Chat_ImageContent(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
req := Request{Messages: []Message{ImageMessage(RoleUser, "what is this", "data:image/png;base64,abc")}}
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
msgs, _ := got.body["messages"].([]any)
part0, _ := msgs[0].(map[string]any)
content, _ := part0["content"].([]any)
if len(content) != 2 {
t.Fatalf("content parts = %d, want 2 (text+image)", len(content))
}
found := false
for _, c := range content {
cp, _ := c.(map[string]any)
if cp["type"] != "image_url" {
continue
}
found = true
iu, _ := cp["image_url"].(map[string]any)
if iu["url"] != "data:image/png;base64,abc" {
t.Fatalf("image url = %v, want data:image/png;base64,abc", iu["url"])
}
}
if !found {
t.Fatalf("no image_url part found in %v", content)
}
}
func TestOpenAIChat_Chat_ToolsPlaceholder(t *testing.T) {
t.Parallel()
ch := make(chan capturedRequest, 1)
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, ch)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
req := Request{Messages: []Message{TextMessage(RoleUser, "hi")},
Tools: []ToolSpec{{Name: "lookup", Description: "look it up", Parameters: map[string]any{"type": "object"}}}}
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
tools, ok := got.body["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("body.tools = %v, want 1 entry", got.body["tools"])
}
}
func TestOpenAIChat_ChatStream_SSE(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","content":"Hel"}}]}`,
``,
`data: {"choices":[{"delta":{"content":"lo"}}]}`,
``,
`data: {"model":"gpt-test","choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
``,
`data: [DONE]`,
``,
}, "\n")
srv := newCompletionServer(t, http.StatusOK, "text/event-stream", sse, nil)
recs := make([]CallRecord, 0, 1)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"},
WithRecorder(func(cr CallRecord) { recs = append(recs, cr) }))
var events []StreamEvent
resp, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Model: "gpt-test", Purpose: "chat"},
func(ev StreamEvent) { events = append(events, ev) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if resp.Content != "Hello" {
t.Errorf("resp.Content = %q, want Hello", resp.Content)
}
if resp.Model != "gpt-test" {
t.Errorf("resp.Model = %q, want gpt-test", resp.Model)
}
if resp.TotalTokens != 7 {
t.Errorf("resp.TotalTokens = %d, want 7", resp.TotalTokens)
}
wantEvents := []StreamEvent{
{Kind: StreamKindTextStart},
{Kind: StreamKindTextDelta, Delta: "Hel"},
{Kind: StreamKindTextDelta, Delta: "lo"},
{Kind: StreamKindTextEnd},
}
if !reflect.DeepEqual(events, wantEvents) {
t.Errorf("events = %+v, want %+v", events, wantEvents)
}
if len(recs) != 1 || recs[0].Tokens != 7 || recs[0].Purpose != "chat" {
t.Errorf("records = %+v, want 1 record tokens=7 purpose=chat", recs)
}
}
func TestOpenAIChat_Chat_UpstreamError(t *testing.T) {
t.Parallel()
srv := newCompletionServer(t, http.StatusInternalServerError, "application/json", `{"error":"boom"}`, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "m"})
_, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{})
if err == nil {
t.Fatal("want error, got nil")
}
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
}
func TestOpenAIChat_Chat_NoBaseURL(t *testing.T) {
t.Parallel()
ad := NewOpenAIChat(config.LLMConfig{})
if _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
}
func TestOpenAIChat_Chat_NoModel(t *testing.T) {
t.Parallel()
srv := newCompletionServer(t, http.StatusOK, "application/json", okCompletion, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL})
if _, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrNoModel) {
t.Fatalf("err = %v, want ErrNoModel", err)
}
}
func TestOpenAIChat_ChatStream_ReasoningAndText(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","reasoning_content":"Let me th"}}]}`,
``,
`data: {"choices":[{"delta":{"reasoning_content":"ink about it"}}]}`,
``,
`data: {"choices":[{"delta":{"content":"Hel"}}]}`,
``,
`data: {"choices":[{"delta":{"content":"lo"}}]}`,
``,
`data: {"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`,
``,
`data: [DONE]`,
``,
}, "\n")
srv := newCompletionServer(t, http.StatusOK, "text/event-stream", sse, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
var events []StreamEvent
resp, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Model: "gpt-test"},
func(ev StreamEvent) { events = append(events, ev) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if resp.Content != "Hello" {
t.Errorf("resp.Content = %q, want Hello (reasoning must not leak into body content)", resp.Content)
}
wantEvents := []StreamEvent{
{Kind: StreamKindThinkingStart},
{Kind: StreamKindThinkingDelta, Delta: "Let me th"},
{Kind: StreamKindThinkingDelta, Delta: "ink about it"},
{Kind: StreamKindThinkingEnd},
{Kind: StreamKindTextStart},
{Kind: StreamKindTextDelta, Delta: "Hel"},
{Kind: StreamKindTextDelta, Delta: "lo"},
{Kind: StreamKindTextEnd},
}
if !reflect.DeepEqual(events, wantEvents) {
t.Errorf("events = %+v, want %+v", events, wantEvents)
}
}
func TestOpenAIChat_ChatStream_ToolCall(t *testing.T) {
t.Parallel()
sse := strings.Join([]string{
`data: {"model":"gpt-test","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call_1","function":{"name":"lookup","arguments":"{\"loc"}}]}}]}`,
``,
`data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ation\":\"Paris\"}"}}]}}]}`,
``,
`data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`,
``,
`data: {"choices":[],"usage":{"prompt_tokens":5,"completion_tokens":6,"total_tokens":11}}`,
``,
`data: [DONE]`,
``,
}, "\n")
srv := newCompletionServer(t, http.StatusOK, "text/event-stream", sse, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "gpt-test"})
var events []StreamEvent
resp, err := ad.ChatStream(context.Background(),
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Model: "gpt-test"},
func(ev StreamEvent) { events = append(events, ev) })
if err != nil {
t.Fatalf("ChatStream error: %v", err)
}
if len(resp.ToolCalls) != 1 {
t.Fatalf("resp.ToolCalls = %+v, want 1", resp.ToolCalls)
}
tc := resp.ToolCalls[0]
if tc.Name != "lookup" {
t.Errorf("tool call name = %q, want lookup", tc.Name)
}
if tc.Args["location"] != "Paris" {
t.Errorf("tool call args.location = %v, want Paris (reassembled from argument fragments)", tc.Args["location"])
}
if resp.Content != "" {
t.Errorf("resp.Content = %q, want empty (tool-only response)", resp.Content)
}
wantEvents := []StreamEvent{
{Kind: StreamKindToolCallStart, Index: 0, ID: "call_1"},
{Kind: StreamKindToolCallDelta, Index: 0, ID: "call_1", Delta: `{"loc`},
{Kind: StreamKindToolCallDelta, Index: 0, ID: "call_1", Delta: `ation":"Paris"}`},
{Kind: StreamKindToolCallEnd, Index: 0, ID: "call_1"},
}
if !reflect.DeepEqual(events, wantEvents) {
t.Errorf("events = %+v, want %+v", events, wantEvents)
}
}