Files
Sakurasan d5dac5a6fc feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、
builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、
编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。

产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt)
留在 AutoGo,宿主接入方式见 MIGRATION.md。
2026-10-26 00:54:46 +08:00

173 lines
5.6 KiB
Go
Raw Permalink 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 llm
import (
"context"
"encoding/json"
"strings"
"testing"
)
// 三家 provider 的 token 记账必须归一到同一口径(见 Usage 注释):
// InputTokens 含缓存段。Anthropic 尤其容易写错——它把命中/写入单列,
// 照抄 input_tokens 就会在缓存命中时把 10 万上下文记成 5 千。
func TestAnthropicUsageIncludesCache(t *testing.T) {
sse := strings.Join([]string{
`data: {"type":"message_start","message":{"usage":{"input_tokens":120,"cache_read_input_tokens":90000,"cache_creation_input_tokens":500}}}`,
"",
`data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
"",
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`,
"",
`data: {"type":"content_block_stop","index":0}`,
"",
// message_delta 常常不回缓存明细:必须沿用 message_start 学到的数
`data: {"type":"message_delta","usage":{"output_tokens":7}}`,
"",
`data: {"type":"message_stop"}`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &AnthropicProvider{BaseURL: srv.URL, APIKey: "k", Model: "claude-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
if u.InputTokens != 90620 {
t.Errorf("InputTokens = %d, want 90620(120 + cache_read 90000 + cache_write 500)", u.InputTokens)
}
if u.CacheReadTokens != 90000 || u.CacheCreationTokens != 500 {
t.Errorf("缓存明细 = %d/%d, want 90000/500", u.CacheReadTokens, u.CacheCreationTokens)
}
if u.OutputTokens != 7 {
t.Errorf("OutputTokens = %d, want 7", u.OutputTokens)
}
if msg.Content != "ok" {
t.Errorf("Content = %q", msg.Content)
}
}
func TestOpenAIUsageDetails(t *testing.T) {
sse := strings.Join([]string{
`data: {"choices":[{"delta":{"content":"hi"}}]}`,
"",
`data: {"choices":[{"finish_reason":"stop"}],"usage":{"prompt_tokens":1000,"completion_tokens":20,` +
`"prompt_tokens_details":{"cached_tokens":950},"completion_tokens_details":{"reasoning_tokens":8}}}`,
"",
`data: [DONE]`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "gpt-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
// prompt_tokens 本身已含缓存:总量不许二次相加。
if u.InputTokens != 1000 || u.CacheReadTokens != 950 || u.ReasoningTokens != 8 || u.OutputTokens != 20 {
t.Errorf("usage 归一错误:%+v", u)
}
}
func TestResponsesUsageDetails(t *testing.T) {
sse := strings.Join([]string{
`data: {"type":"response.output_text.delta","delta":"hi"}`,
"",
`data: {"type":"response.completed","response":{"status":"completed","usage":` +
`{"input_tokens":800,"output_tokens":15,"input_tokens_details":{"cached_tokens":700},` +
`"output_tokens_details":{"reasoning_tokens":5}}}}`,
"",
}, "\n")
var got map[string]any
srv := captureServer(&got, sse)
defer srv.Close()
p := &ResponsesProvider{BaseURL: srv.URL, APIKey: "k", Model: "rsp-x"}
msg, err := p.Stream(context.Background(), "sys",
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
if err != nil {
t.Fatal(err)
}
u := msg.Usage
if u == nil {
t.Fatal("没有 usage")
}
if u.InputTokens != 800 || u.CacheReadTokens != 700 || u.ReasoningTokens != 5 || u.OutputTokens != 15 {
t.Errorf("usage 归一错误:%+v", u)
}
}
// TestProviderIdentity:三家都自报协议与当前模型(会话落盘的归因戳)。
// 换了模型(SetModel)之后 Identity 必须跟着变,否则戳是假的。
func TestProviderIdentity(t *testing.T) {
var cases = []struct {
p Provider
wantFmt string
wantAttr string
}{
{&OpenAIProvider{Model: "gpt-a"}, FormatChatCompletions, "gpt-a"},
{&AnthropicProvider{Model: "claude-a"}, FormatAnthropic, "claude-a"},
{&ResponsesProvider{Model: "rsp-a"}, FormatResponses, "rsp-a"},
}
for _, c := range cases {
id, ok := c.p.(Identifier)
if !ok {
t.Fatalf("%T 没实现 Identifier", c.p)
}
f, m := id.Identity()
if f != c.wantFmt || m != c.wantAttr {
t.Errorf("%T Identity = %q/%q, want %q/%q", c.p, f, m, c.wantFmt, c.wantAttr)
}
if sw, ok := c.p.(ModelSwitcher); !ok {
t.Errorf("%T 应可切模型", c.p)
} else {
sw.SetModel("后到")
if _, m := id.Identity(); m != "后到" {
t.Errorf("SetModel 后 Identity 仍报 %q", m)
}
}
}
}
// TestUsageSerializesIntoTranscript:新增明细字段要能落进会话文件,
// 同时不给旧消息添噪声(omitempty)。
func TestUsageSerializesIntoTranscript(t *testing.T) {
old := Message{Role: RoleAssistant, Usage: &Usage{InputTokens: 5, OutputTokens: 2}}
b, err := json.Marshal(old)
if err != nil {
t.Fatal(err)
}
for _, noise := range []string{"cache_read_tokens", "reasoning_tokens", "format", "model"} {
if strings.Contains(string(b), noise) {
t.Errorf("零值不该序列化出 %q:%s", noise, b)
}
}
withCache := Message{Role: RoleAssistant, Format: FormatAnthropic, Model: "claude-x",
Usage: &Usage{InputTokens: 90620, OutputTokens: 7, CacheReadTokens: 90000}}
b2, err := json.Marshal(withCache)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"cache_read_tokens", "90000", `"format":"anthropic"`, `"model":"claude-x"`} {
if !strings.Contains(string(b2), want) {
t.Errorf("序列化缺 %q:%s", want, b2)
}
}
}