Files
Cyrene_v2/internal/runtime/model/openai_test.go
T
AskaEth 66dda084cd fix(runtime): 上游错误体统一走 trace.UpstreamError,正文不进 error
M41 的「LLM / embedding / 视觉 上游错误体」一族(M38 #5 #6 #7 #8 #9 #14):
非 2xx 时把响应体截断后拼进 error,而 error 会被 chat run / loop step /
retrieve_fallback 打日志,等于把她的提问、记忆正文、要念的台词写进日志。

- model:openai/anthropic 两处 upstreamError 收口到 upstream.go 的
  providerUpstreamError;anthropic 流内 error 事件的 evt.Error.Message
  (原本无上限)整段丢弃,只保留令牌形状的 type
- embedding:readErrorBody 删除,改走 helper(input 就是她的记忆正文)
- vision:错误路径不再沿用取图的 50 MiB 上限,改为 4 KiB 有界读且不拼原文
- voice:classifyDashscopeStatus 只解 code/request_id,正文只留在内存里做
  分类判据;错误体读取同样改为 4 KiB 有界
- 测试:五个上游各一条「日志/error 里搜不到哨兵正文」用例,且能变异打红
2026-10-26 13:44:32 +08:00

466 lines
16 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 model
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
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 记录 OpenAIChatAdapter 发往 stub 服务器的内容。
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)
}
}
// TestOpenAIChat_Chat_UpstreamErrorDropsPromptEcho 覆盖 M41 的一家 provider:
// 4xx 的错误体里回显了她的提问,**提问不能出现在 error 文本里**。
//
// 为什么断言的是「文本里搜不到」而不是「error 非空」:旧实现把响应体截断
// 512 字节后拼进 error,error 又被 chat run / loop step 打日志 —— 截断只是把
// 「整段泄漏」变成「前 512 字节泄漏」。只有「搜不到原文」这个断言能把实现
// 变异回旧写法时打红。
func TestOpenAIChat_Chat_UpstreamErrorDropsPromptEcho(t *testing.T) {
t.Parallel()
const echoed = "SENTINEL-OPENAI-PROMPT-9f3a"
body := `{"error":{"code":"invalid_request_error","type":"invalid_request_error",` +
`"param":"messages","message":"Invalid value for 'messages[0].content': '` + echoed + `'"}}`
srv := newCompletionServer(t, http.StatusUnprocessableEntity, "application/json", body, nil)
ad := NewOpenAIChat(config.LLMConfig{BaseURL: srv.URL, Model: "m"})
_, err := ad.Chat(context.Background(), Request{Messages: []Message{TextMessage(RoleUser, echoed)}}, Options{})
if err == nil {
t.Fatal("want error, got nil")
}
if !errors.Is(err, ErrUpstream) || !errors.Is(err, trace.ErrUpstream) {
t.Fatalf("err = %v, want both ErrUpstream and trace.ErrUpstream", err)
}
if strings.Contains(err.Error(), echoed) {
t.Errorf("error text echoes the prompt: %v", err)
}
if !strings.Contains(err.Error(), "422") {
t.Errorf("err = %v, want the status kept for triage", err)
}
if !strings.Contains(err.Error(), "invalid_request_error") {
t.Errorf("err = %v, want the error.code kept for triage", 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)
}
}
func TestOpenAIChat_Chat_ToolRoundTrip(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, "what's the weather in Paris?"),
AssistantToolCallMessage([]ToolCall{{ID: "call_1", Name: "weather", Args: map[string]any{"city": "Paris"}}}),
ToolResultMessage("call_1", `{"temp":22}`, true),
}, Model: "gpt-test"}
if _, err := ad.Chat(context.Background(), req, Options{}); err != nil {
t.Fatalf("Chat error: %v", err)
}
got := <-ch
msgs, _ := got.body["messages"].([]any)
if len(msgs) != 3 {
t.Fatalf("body.messages = %v, want 3 messages", got.body["messages"])
}
// 助手回合必须携带 tool_calls(好让 tool 消息能被
// 提供商关联)——这一点此前被完全丢弃。
assistant, _ := msgs[1].(map[string]any)
if assistant["role"] != "assistant" {
t.Errorf("assistant role = %v, want assistant", assistant["role"])
}
tcs, _ := assistant["tool_calls"].([]any)
if len(tcs) != 1 {
t.Fatalf("assistant tool_calls = %v, want 1 entry", assistant["tool_calls"])
}
tc := tcs[0].(map[string]any)
if tc["id"] != "call_1" || tc["type"] != "function" {
t.Errorf("tool_call = %v, want id=call_1 type=function", tc)
}
fn, _ := tc["function"].(map[string]any)
if fn["name"] != "weather" || fn["arguments"] != `{"city":"Paris"}` {
t.Errorf("function = %#v, want name=weather arguments={\"city\":\"Paris\"}", fn)
}
// tool 消息必须携带 tool_call_id(审计指出的缺口)与结果。
tool, _ := msgs[2].(map[string]any)
if tool["role"] != "tool" {
t.Errorf("tool role = %v, want tool", tool["role"])
}
if tool["tool_call_id"] != "call_1" {
t.Errorf("tool_call_id = %v, want call_1", tool["tool_call_id"])
}
if tool["content"] != `{"temp":22}` {
t.Errorf("tool content = %v, want the raw result", tool["content"])
}
}
func TestOpenAIChat_Build_ToolMessageLegacy(t *testing.T) {
t.Parallel()
// loop 当前的形态:一个携带纯文本片段与
// Message.ToolCallID 的 RoleTool 消息。buildMessages 必须发出带 tool_call_id 的 tool 消息。
m := Message{Role: RoleTool, ToolCallID: "call_9",
Content: []Content{{Type: ContentTypeText, Text: "tool weather result: 22"}}}
msgs, err := buildMessages([]Message{m})
if err != nil {
t.Fatalf("buildMessages error: %v", err)
}
if len(msgs) != 1 {
t.Fatalf("messages = %d, want 1", len(msgs))
}
if msgs[0].Role != "tool" {
t.Errorf("role = %q, want tool", msgs[0].Role)
}
if msgs[0].ToolCallID != "call_9" {
t.Errorf("tool_call_id = %q, want call_9", msgs[0].ToolCallID)
}
if msgs[0].Content != "tool weather result: 22" {
t.Errorf("content = %v, want the text", msgs[0].Content)
}
}