Files
DeepSeek-Reasonix/internal/agent/cachehit_e2e_test.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

686 lines
21 KiB
Go

package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/provider/openai"
"reasonix/internal/tool"
)
// echoTool is a trivial read-only tool used to drive a multi-step tool loop:
// each call appends an assistant(tool_call) + tool(result) pair to the history,
// growing the request prefix the way a real multi-turn session does.
type echoTool struct{}
func (echoTool) Name() string { return "echo" }
func (echoTool) Description() string { return "echo back the given text" }
func (echoTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"text":{"type":"string"}},"required":["text"]}`)
}
func (echoTool) ReadOnly() bool { return true }
func (echoTool) Execute(_ context.Context, args json.RawMessage) (string, error) {
var a struct {
Text string `json:"text"`
}
_ = json.Unmarshal(args, &a)
return "echoed: " + a.Text, nil
}
// collectSink captures the per-turn Usage events plus any compaction notices the
// agent emits, so the test can replay exactly what the status line would show.
type collectSink struct {
usages []*provider.Usage
notices []string
}
func (s *collectSink) Emit(e event.Event) {
switch e.Kind {
case event.Usage:
if e.Usage != nil {
s.usages = append(s.usages, e.Usage)
}
case event.Notice:
s.notices = append(s.notices, e.Text)
}
}
// --- a mock DeepSeek endpoint that derives cache-hit tokens from the byte-
// identical message prefix it shares with the previous *conversation* request.
// The reported hit rate is therefore a direct measurement of how stable the
// client keeps its request prefix turn over turn. ---
type mockDeepSeek struct {
t *testing.T
prevMessages []json.RawMessage // last conversation request's messages
reqChars []int // total prompt chars per conversation request
hitChars []int // cached prefix chars per conversation request
withTools bool // advertise the echo tool (and emit tool calls)
reasoning string // chain-of-thought echoed every turn (round-tripped)
toolRounds int // remaining tool-call rounds before a final answer
}
func (m *mockDeepSeek) handler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
// Compaction issues a tool-less summarize request whose system prompt is the
// summarizer prompt — answer it with a short summary and DON'T let it pollute
// the conversation-prefix bookkeeping.
if isSummarizeRequest(body) {
writeSSE(w, m.t,
streamChunk(deltaText("- goal: keep going\n- decisions: none\n- pending: continue")),
finishChunk("stop"),
usageChunk(100, 40, 0, 100),
)
return
}
msgs := decodeMessages(body)
common := commonPrefixMsgs(m.prevMessages, msgs)
hitChars := charsOf(msgs[:common])
totalChars := charsOf(msgs)
m.prevMessages = msgs
m.reqChars = append(m.reqChars, totalChars)
m.hitChars = append(m.hitChars, hitChars)
promptTok := totalChars / 4
hitTok := hitChars / 4
missTok := promptTok - hitTok
emitTool := m.withTools && m.toolRounds > 0
if emitTool {
m.toolRounds--
}
chunks := []sseResp{streamChunk(deltaReasoning(m.reasoning))}
if emitTool {
idx := len(m.reqChars)
chunks = append(chunks,
streamChunk(deltaToolCall(idx, "echo", fmt.Sprintf(`{"text":"round-%d"}`, idx))),
finishChunk("tool_calls"))
} else {
chunks = append(chunks,
streamChunk(deltaText("Done.")),
finishChunk("stop"))
}
chunks = append(chunks, usageChunk(promptTok, 50, hitTok, missTok))
writeSSE(w, m.t, chunks...)
}
func (m *mockDeepSeek) tools() *tool.Registry {
reg := tool.NewRegistry()
if m.withTools {
reg.Add(echoTool{})
}
return reg
}
// hitRate is the status-line formula: hit / (hit+miss), falling back to prompt.
func hitRate(u *provider.Usage) int {
denom := u.CacheHitTokens + u.CacheMissTokens
if denom == 0 {
denom = u.PromptTokens
}
if denom == 0 {
return 0
}
return u.CacheHitTokens * 100 / denom
}
const systemPrompt = "You are reasonix, a coding agent. Be concise and follow project conventions. " +
"This system prompt is the cacheable head of every request and must never change between turns."
// longReasoning stands in for a deepseek-reasoner chain-of-thought that the agent
// round-trips onto the assistant turn (agent.go round-trips ReasoningContent).
const longReasoning = "Let me reason about this carefully. I will weigh the constraints, " +
"enumerate the candidate approaches, reject the ones that violate a requirement, and then " +
"commit to the most defensible option, double-checking it against the original goal before answering."
// TestCacheHitPrefixStable proves the standard path keeps a byte-stable prefix:
// every request re-sends the full prior history untouched, and the displayed
// hit% equals hit/prompt%. This rules out "something is breaking the cache" and
// "the display math is wrong" for the no-compaction path.
func TestCacheHitPrefixStable(t *testing.T) {
mock := &mockDeepSeek{t: t, withTools: true, reasoning: longReasoning, toolRounds: 2}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 0 /*no compaction*/, 0)
if err := a.Run(context.Background(), "echo a couple things then finish"); err != nil {
t.Fatalf("Run: %v", err)
}
// Reconstruct the requests to check prefix stability. Replay equality is
// already encoded in hitChars==full-previous-prefix, but assert it directly.
for i := 1; i < len(mock.reqChars); i++ {
// On request i the cached prefix should be the ENTIRE request i-1.
if mock.hitChars[i] != mock.reqChars[i-1] {
t.Errorf("PREFIX BROKEN at req %d: cached %d chars but the full prior request was %d chars",
i, mock.hitChars[i], mock.reqChars[i-1])
}
}
t.Logf("prefix STABLE across %d requests — nothing in the client breaks the cache", len(mock.reqChars))
t.Logf("==== reported usage (what the status line renders) ====")
for i, u := range sink.usages {
want := -1
if u.PromptTokens > 0 {
want = 100 * u.CacheHitTokens / u.PromptTokens
}
t.Logf("turn %d: prompt=%d hit=%d miss=%d → 'cache %d%%' (hit/prompt=%d%%) | %s",
i, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens, hitRate(u), want,
strings.TrimSpace(FormatUsageLine(u, nil, nil)))
if u.CacheHitTokens+u.CacheMissTokens != u.PromptTokens {
t.Errorf("display denominator mismatch: hit+miss=%d != prompt=%d (status%% would read wrong)",
u.CacheHitTokens+u.CacheMissTokens, u.PromptTokens)
}
}
}
// TestCacheHitClimbsWithoutCompaction runs a long multi-turn conversation with
// compaction DISABLED and prints the hit-rate curve. With a stable prefix the
// rate should climb past 90% as history dwarfs each turn's fresh tail.
func TestCacheHitClimbsWithoutCompaction(t *testing.T) {
mock := &mockDeepSeek{t: t, reasoning: longReasoning}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 0 /*no compaction*/, 0)
const turns = 14
for i := range turns {
userMsg := "Turn " + fmt.Sprint(i) + ": " + strings.Repeat("please consider this requirement. ", 6)
if err := a.Run(context.Background(), userMsg); err != nil {
t.Fatalf("Run %d: %v", i, err)
}
}
t.Logf("==== hit-rate curve, NO compaction (%d turns) ====", turns)
peak := 0
for i, u := range sink.usages {
r := hitRate(u)
if r > peak {
peak = r
}
t.Logf("turn %2d: prompt=%5d hit=%5d miss=%4d → cache %d%%", i, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens, r)
}
t.Logf("peak hit rate without compaction: %d%%", peak)
if peak < 90 {
t.Logf("NOTE: even with a perfectly stable prefix the rate plateaus below 90%% — "+
"each turn's fresh tail (incl. %d-char round-tripped reasoning) is too large a share", len(longReasoning))
}
}
// TestCacheHitSurvivesTooSmallWindow drives a long tool-loop against a window so
// small a single turn can't be summarized under it — the misconfigured regime
// that used to make compaction rewrite the prefix every step, cratering the
// cache turn after turn. The stuck guard now detects that compaction can't make
// progress, pauses it (with a notice), and lets the prefix grow append-only — so
// the hit rate recovers and stays high instead of collapsing repeatedly.
func TestCacheHitSurvivesTooSmallWindow(t *testing.T) {
mock := &mockDeepSeek{t: t, withTools: true, reasoning: longReasoning, toolRounds: 30}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 900 /*window tok*/, 4 /*recentKeep*/)
if err := a.Run(context.Background(), strings.Repeat("please consider this requirement. ", 6)); err != nil {
t.Fatalf("Run: %v", err)
}
t.Logf("==== hit-rate curve, too-small window (900 tok) ====")
collapses := 0
for i, u := range sink.usages {
r := hitRate(u)
marker := ""
if i > 0 && r+20 < hitRate(sink.usages[i-1]) {
marker = " <<< collapse"
collapses++
}
t.Logf("step %2d: prompt=%5d hit=%5d miss=%4d → cache %3d%%%s", i, u.PromptTokens, u.CacheHitTokens, u.CacheMissTokens, r, marker)
}
paused := false
for _, n := range sink.notices {
t.Logf("notice: %s", n)
if strings.Contains(n, "Automatic context cleanup paused") {
paused = true
}
}
// The guard caps the damage: a couple of compactions at most, not one per step.
if collapses > 2 {
t.Errorf("compaction cratered the cache %d times; the stuck guard should cap it at ≤2", collapses)
}
if !paused {
t.Errorf("expected an auto-compaction-paused notice for the too-small window")
}
// Once paused, the prefix grows append-only again, so the tail of the run
// recovers to a high, stable hit rate instead of collapsing every step.
if n := len(sink.usages); n >= 6 {
if tail := tailAverage(usageRates(sink.usages), 5); tail < 85 {
t.Errorf("tail hit rate after the guard kicked in = %d%%, want ≥85%%", tail)
}
}
}
// TestReasoningRoundTripCost contrasts the hit-rate curve WITH vs WITHOUT the
// reasoning_content round-trip (agent.go re-sends the assistant chain-of-thought
// every turn). It quantifies how much that round-tripped CoT — assuming DeepSeek
// counts it as uncached prompt — drags the hit rate down at each turn.
func TestReasoningRoundTripCost(t *testing.T) {
curve := func(reasoning string) []int {
mock := &mockDeepSeek{t: t, reasoning: reasoning}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 0, 0)
const turns = 12
for i := range turns {
if err := a.Run(context.Background(), strings.Repeat("please consider this requirement. ", 6)); err != nil {
t.Fatalf("Run %d: %v", i, err)
}
}
out := make([]int, len(sink.usages))
for i, u := range sink.usages {
out[i] = hitRate(u)
}
return out
}
withCoT := curve(longReasoning)
without := curve("")
t.Logf("==== reasoning round-trip: hit-rate cost per turn ====")
t.Logf("turn | with reasoning round-trip | without (stripped) | delta")
firstCross := func(c []int) int {
for i, r := range c {
if r >= 90 {
return i
}
}
return -1
}
for i := range withCoT {
t.Logf(" %2d | %3d%% | %3d%% | +%d pts",
i, withCoT[i], without[i], without[i]-withCoT[i])
}
t.Logf("turns needed to reach 90%%: with round-trip = %d, stripped = %d", firstCross(withCoT), firstCross(without))
}
// TestSessionAggregateCacheRate verifies the session-aggregate hit-rate the
// status line now shows: Agent.SessionCache() accumulates every turn's hit/miss
// (so it equals the sum of the per-turn usages), and the aggregate rate is the
// steadier, higher number compared to the volatile single-turn rate.
func TestSessionAggregateCacheRate(t *testing.T) {
mock := &mockDeepSeek{t: t, reasoning: longReasoning}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 0, 0)
const turns = 8
for i := range turns {
if err := a.Run(context.Background(), strings.Repeat("please consider this requirement. ", 6)); err != nil {
t.Fatalf("Run %d: %v", i, err)
}
}
// The agent's cumulative counters must equal the sum of the per-turn usages.
var sumHit, sumMiss int
for _, u := range sink.usages {
sumHit += u.CacheHitTokens
sumMiss += u.CacheMissTokens
}
hit, miss := a.SessionCache()
if hit != sumHit || miss != sumMiss {
t.Errorf("SessionCache()=%d/%d but per-turn sums are %d/%d", hit, miss, sumHit, sumMiss)
}
agg := 100 * hit / (hit + miss)
last := sink.usages[len(sink.usages)-1]
single := 100 * last.CacheHitTokens / last.PromptTokens
t.Logf("after %d turns: aggregate(session) = %d%% vs single(last turn) = %d%%", turns, agg, single)
if agg <= 0 || agg > 100 {
t.Errorf("aggregate rate out of range: %d%%", agg)
}
}
func TestSetSessionResetsSessionCache(t *testing.T) {
mock := &mockDeepSeek{t: t, reasoning: longReasoning}
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, _ := newAgent(t, srv.URL, mock.tools(), 0, 0)
if err := a.Run(context.Background(), strings.Repeat("please consider this requirement. ", 6)); err != nil {
t.Fatalf("Run: %v", err)
}
hit, miss := a.SessionCache()
if hit+miss == 0 {
t.Fatalf("SessionCache()=%d/%d before reset, want telemetry to record the turn", hit, miss)
}
a.SetSession(NewSession("system"))
hit, miss = a.SessionCache()
if hit != 0 || miss != 0 {
t.Fatalf("SessionCache()=%d/%d after SetSession, want reset", hit, miss)
}
}
func TestReleaseCacheHitGuard(t *testing.T) {
if os.Getenv("REASONIX_RELEASE_CACHE_GUARD") == "" {
t.Skip("set REASONIX_RELEASE_CACHE_GUARD=1 to run the release cache guard")
}
threshold := envInt("REASONIX_CACHE_GUARD_THRESHOLD", 90)
maxLowCases := envInt("REASONIX_CACHE_GUARD_MAX_LOW_CASES", 1)
cases := []struct {
name string
run func(*testing.T) []int
}{
{
name: "plain-dialogue",
run: func(t *testing.T) []int {
return cacheCurve(t, &mockDeepSeek{t: t, reasoning: longReasoning}, 14)
},
},
{
name: "plain-dialogue-no-reasoning",
run: func(t *testing.T) []int {
return cacheCurve(t, &mockDeepSeek{t: t}, 14)
},
},
{
name: "long-dialogue",
run: func(t *testing.T) []int {
return cacheCurveWithMessages(t, &mockDeepSeek{t: t, reasoning: longReasoning}, repeatedMessages(18, 18))
},
},
{
name: "mixed-message-sizes",
run: func(t *testing.T) []int {
msgs := make([]string, 0, 20)
for i := range 20 {
repeats := 4
if i%3 == 2 {
repeats = 20
}
msgs = append(msgs, fmt.Sprintf("Turn %d: ", i)+strings.Repeat("preserve the request prefix while handling varied input. ", repeats))
}
return cacheCurveWithMessages(t, &mockDeepSeek{t: t, reasoning: longReasoning}, msgs)
},
},
{
name: "tool-loop",
run: func(t *testing.T) []int {
return toolLoopCurve(t, &mockDeepSeek{t: t, withTools: true, reasoning: longReasoning, toolRounds: 14})
},
},
{
name: "tool-loop-no-reasoning",
run: func(t *testing.T) []int {
return toolLoopCurve(t, &mockDeepSeek{t: t, withTools: true, toolRounds: 14})
},
},
{
name: "long-tool-loop",
run: func(t *testing.T) []int {
return toolLoopCurve(t, &mockDeepSeek{t: t, withTools: true, reasoning: longReasoning, toolRounds: 24})
},
},
{
name: "long-tool-loop-no-reasoning",
run: func(t *testing.T) []int {
return toolLoopCurve(t, &mockDeepSeek{t: t, withTools: true, toolRounds: 24})
},
},
}
type result struct {
name string
rate int
all []int
}
var lows []result
for _, c := range cases {
rates := c.run(t)
rate := tailAverage(rates, 3)
status := "pass"
if rate < threshold {
status = "low"
lows = append(lows, result{name: c.name, rate: rate, all: rates})
}
t.Logf("CACHE_GUARD_RESULT: case=%s tail_avg=%d threshold=%d status=%s rates=%v",
c.name, rate, threshold, status, rates)
}
if len(lows) > maxLowCases {
var parts []string
for _, low := range lows {
parts = append(parts, fmt.Sprintf("%s=%d%%", low.name, low.rate))
}
msg := fmt.Sprintf("%d cache guard cases are below %d%%: %s", len(lows), threshold, strings.Join(parts, ", "))
t.Logf("CACHE_GUARD_WARNING: %s", msg)
if os.Getenv("REASONIX_CACHE_GUARD_STRICT") != "" {
t.Fatal(msg)
}
}
}
func cacheCurve(t *testing.T, mock *mockDeepSeek, turns int) []int {
return cacheCurveWithMessages(t, mock, repeatedMessages(turns, 6))
}
func repeatedMessages(turns, repeats int) []string {
msgs := make([]string, 0, turns)
for i := range turns {
msgs = append(msgs, "Turn "+fmt.Sprint(i)+": "+strings.Repeat("please consider this requirement. ", repeats))
}
return msgs
}
func cacheCurveWithMessages(t *testing.T, mock *mockDeepSeek, messages []string) []int {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 0, 0)
for i, userMsg := range messages {
if err := a.Run(context.Background(), userMsg); err != nil {
t.Fatalf("Run %d: %v", i, err)
}
}
return usageRates(sink.usages)
}
func toolLoopCurve(t *testing.T, mock *mockDeepSeek) []int {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(mock.handler))
defer srv.Close()
a, sink := newAgent(t, srv.URL, mock.tools(), 0, 0)
if err := a.Run(context.Background(), strings.Repeat("please consider this requirement. ", 6)); err != nil {
t.Fatalf("Run: %v", err)
}
return usageRates(sink.usages)
}
func usageRates(usages []*provider.Usage) []int {
out := make([]int, len(usages))
for i, u := range usages {
out[i] = hitRate(u)
}
return out
}
func tailAverage(xs []int, n int) int {
if len(xs) == 0 {
return 0
}
if n > len(xs) {
n = len(xs)
}
sum := 0
for _, x := range xs[len(xs)-n:] {
sum += x
}
return sum / n
}
func envInt(name string, fallback int) int {
raw := os.Getenv(name)
if raw == "" {
return fallback
}
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return fallback
}
return n
}
// newAgent wires a real openai.Provider at url into a real Agent.
func newAgent(t *testing.T, url string, reg *tool.Registry, contextWindow, recentKeep int) (*Agent, *collectSink) {
t.Helper()
prov, err := openai.New(provider.Config{
Name: "deepseek",
BaseURL: url,
Model: "deepseek-reasoner",
APIKey: "test",
Extra: map[string]any{"api_key_env": "DEEPSEEK_API_KEY"},
})
if err != nil {
t.Fatalf("provider New: %v", err)
}
sink := &collectSink{}
a := New(prov, reg, NewSession(systemPrompt), Options{
Temperature: 0,
ContextWindow: contextWindow,
RecentKeep: recentKeep,
}, sink)
return a, sink
}
// request inspection helpers
func decodeMessages(body []byte) []json.RawMessage {
var req struct {
Messages []json.RawMessage `json:"messages"`
}
_ = json.Unmarshal(body, &req)
return req.Messages
}
func isSummarizeRequest(body []byte) bool {
msgs := decodeMessages(body)
if len(msgs) == 0 {
return false
}
var m struct {
Role string `json:"role"`
Content string `json:"content"`
}
_ = json.Unmarshal(msgs[0], &m)
return m.Role == "system" && strings.Contains(m.Content, "compacting the earlier part")
}
func commonPrefixMsgs(a, b []json.RawMessage) int {
n := 0
for n < len(a) && n < len(b) && bytes.Equal(a[n], b[n]) {
n++
}
return n
}
func charsOf(msgs []json.RawMessage) int {
total := 0
for _, m := range msgs {
total += len(m)
}
return total
}
// SSE chunk builders matching the streamResponse shape the provider parses
type sseDelta struct {
Content string `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []sseToolCall `json:"tool_calls,omitempty"`
}
type sseToolCall struct {
Index int `json:"index"`
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
type sseChoice struct {
Delta sseDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
type sseResp struct {
Choices []sseChoice `json:"choices"`
Usage *sseUsage `json:"usage,omitempty"`
}
type sseUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens"`
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens"`
}
func deltaReasoning(s string) sseDelta { return sseDelta{ReasoningContent: s} }
func deltaText(s string) sseDelta { return sseDelta{Content: s} }
func deltaToolCall(idx int, name, args string) sseDelta {
tc := sseToolCall{Index: idx, ID: fmt.Sprintf("call_%d", idx), Type: "function"}
tc.Function.Name = name
tc.Function.Arguments = args
return sseDelta{ToolCalls: []sseToolCall{tc}}
}
func streamChunk(d sseDelta) sseResp { return sseResp{Choices: []sseChoice{{Delta: d}}} }
func finishChunk(reason string) sseResp {
return sseResp{Choices: []sseChoice{{FinishReason: &reason}}}
}
func usageChunk(prompt, completion, hit, miss int) sseResp {
return sseResp{Usage: &sseUsage{
PromptTokens: prompt,
CompletionTokens: completion,
TotalTokens: prompt + completion,
PromptCacheHitTokens: hit,
PromptCacheMissTokens: miss,
}}
}
func writeSSE(w http.ResponseWriter, t *testing.T, chunks ...sseResp) {
t.Helper()
w.Header().Set("Content-Type", "text/event-stream")
f, ok := w.(http.Flusher)
if !ok {
t.Fatal("ResponseWriter is not a Flusher")
}
for _, c := range chunks {
b, _ := json.Marshal(c)
fmt.Fprintf(w, "data: %s\n\n", b)
f.Flush()
}
fmt.Fprint(w, "data: [DONE]\n\n")
f.Flush()
}