mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-19 16:39:15 +00:00
两处都源于同一次排查:pi 到底有没有带会话标识、超窗为什么触发不了压缩。 ## 1) 客户端会话 id:pi 一直在发,只是被配置关掉了 之前结论是「通用客户端不发会话 id」——只对了一半。pi 有会话 id,且能发: pi-ai 的 createClient 在 compat.sendSessionAffinityHeaders 为真时,会把 平台会话 id(uuidv7,整个会话恒定)放到 x-session-affinity / x-client-request-id / session_id 上。该开关默认 false,而 llmsproxy 的 provider 配置里没开,所以此前一直收不到。 现在网关按优先级采纳:x-session-affinity → x-session-id → session_id → body 的 prompt_cache_key,并把值经 types.ChatRequest.ClientSession 传到 适配器 meta.client_session。适配器的会号种子优先级变为: 客户端会话 id > 首条 user 消息指纹 > 按源固定。 刻意不采纳 x-client-request-id:名字含 request,部分客户端每请求都换, 拿它当会话会让上游前缀缓存永不命中(pi 总会同时发 x-session-affinity,够用)。 实测:抓 127.0.0.1:8081 的真实 pi 请求,配置打开后收到 x-session-affinity = session_id = x-client-request-id = <子会话 uuid>。 上游缓存确为会话级隔离(同前缀、不同会号:A 冷→命中,B 首次仍为 0), 两个不同 header 值互不命中,反证网关确实采纳了客户端会话 id。 ## 2) 超窗消息必须「干净」,否则被同链的限流措辞反向封杀 pi 的 isContextOverflow 先查 NON_OVERFLOW_PATTERNS(/rate limit/、 /too many requests/、Bedrock 前缀),命中就直接判为「非超窗」——**即使 消息里已经有 context_length_exceeded**,pi 也不会压缩重试。 而 AUTO 链的失败消息天生是多 tier 原因的拼接,超窗 tier(gozen 400 maximum context length)常与配额/限流 tier(429 token plan exhausted、 cooling、no free slot)同时出现。此前把 tier 明细原样拼在归一化标记后面, 等于让一条限流 tier 的措辞反过来封杀超窗识别。 现在超窗走独立的干净消息: context_length_exceeded: context window is full; reduce the length of the messages (gozen/deepseek-v4.1-flash) 只留超窗措辞 + 超窗源名,不带任何其它 tier 的文本。 测试:TestOverflowMessageSurvivesRateLimitedSiblingTier 用 pi 的完整判定 顺序(先 NON_OVERFLOW 后 OVERFLOW)断言同链限流 tier 不再封杀超窗识别; TestClientSessionFromRequestHeaders / TestClientRequestIDIsNotUsedAsSession / TestOpenCodePrefersClientSessionID 覆盖会话采纳与优先级。
354 lines
14 KiB
Go
354 lines
14 KiB
Go
package lua
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// TestAdaptersPreserveToolCalls is the fleet-wide guard for the
|
||
// "orphaned tool result" class of bug: when an agent client replays a turn
|
||
// whose assistant message carries tool_calls, EVERY adapter must forward both
|
||
// the call and the attribution of its result. Dropping the call (or the
|
||
// tool_call_id / tool_name that ties the result to it) makes the model see a
|
||
// result for a call it never made and re-issue the same call forever.
|
||
//
|
||
// The input deliberately uses content:[] — the shape most agent clients emit
|
||
// for a tool-calling assistant turn with no text.
|
||
func TestAdaptersPreserveToolCalls(t *testing.T) {
|
||
vm := NewVM(freshAdapterDir(t))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer vm.Stop()
|
||
|
||
body := `{"model":"m","messages":[
|
||
{"role":"user","content":"read /tmp/x"},
|
||
{"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]},
|
||
{"role":"tool","tool_call_id":"call_abc","content":"hello world"},
|
||
{"role":"user","content":"summarize"}
|
||
]}`
|
||
const (
|
||
callID = "call_abc"
|
||
fnName = "read"
|
||
toolOut = "hello world"
|
||
lastUser = "summarize"
|
||
)
|
||
|
||
// Adapters that identify a tool result by the call id.
|
||
byID := []string{"openai", "deepseek", "github", "groq", "kimicode", "mistral",
|
||
"sensenova", "trae", "agentrouter", "opencode", "anthropic"}
|
||
|
||
for _, a := range byID {
|
||
out, err := vm.Transform(a, "transform_request", body)
|
||
if err != nil {
|
||
t.Errorf("%s: transform_request: %v", a, err)
|
||
continue
|
||
}
|
||
if !strings.Contains(out, callID) {
|
||
t.Errorf("%s dropped the tool call id %q: %s", a, callID, out)
|
||
}
|
||
if !strings.Contains(out, fnName) {
|
||
t.Errorf("%s dropped the function name %q: %s", a, fnName, out)
|
||
}
|
||
if !strings.Contains(out, toolOut) {
|
||
t.Errorf("%s dropped the tool result: %s", a, out)
|
||
}
|
||
if !strings.Contains(out, lastUser) {
|
||
t.Errorf("%s dropped the final user turn: %s", a, out)
|
||
}
|
||
}
|
||
|
||
// Ollama identifies the result with tool_name (its API has no tool_call_id
|
||
// on assistant turns) and takes arguments as an object, not a JSON string.
|
||
out, err := vm.Transform("ollama", "transform_request", body)
|
||
if err != nil {
|
||
t.Fatalf("ollama: %v", err)
|
||
}
|
||
for _, want := range []string{`"tool_calls"`, fnName, `"tool_name"`, toolOut} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("ollama missing %s: %s", want, out)
|
||
}
|
||
}
|
||
|
||
// Gemini identifies the result by function NAME and has no call id in its
|
||
// wire format; both directions must be present as functionCall /
|
||
// functionResponse parts, and the system role must move to
|
||
// systemInstruction rather than sitting in contents.
|
||
gBody := `{"model":"m","messages":[
|
||
{"role":"system","content":"be brief"},
|
||
{"role":"user","content":"read /tmp/x"},
|
||
{"role":"assistant","content":[],"tool_calls":[{"id":"call_abc","type":"function","function":{"name":"read","arguments":"{\"path\":\"/tmp/x\"}"}}]},
|
||
{"role":"tool","tool_call_id":"call_abc","content":"hello world"}
|
||
]}`
|
||
gout, err := vm.Transform("gemini", "transform_request", gBody)
|
||
if err != nil {
|
||
t.Fatalf("gemini: %v", err)
|
||
}
|
||
for _, want := range []string{`"functionCall"`, `"functionResponse"`, fnName, toolOut, `"systemInstruction"`} {
|
||
if !strings.Contains(gout, want) {
|
||
t.Errorf("gemini missing %s: %s", want, gout)
|
||
}
|
||
}
|
||
// No message may keep the OpenAI-only roles inside contents.
|
||
var greq struct {
|
||
Contents []struct {
|
||
Role string `json:"role"`
|
||
} `json:"contents"`
|
||
}
|
||
if err := json.Unmarshal([]byte(gout), &greq); err != nil {
|
||
t.Fatalf("gemini unmarshal: %v (%s)", err, gout)
|
||
}
|
||
for _, c := range greq.Contents {
|
||
if c.Role != "user" && c.Role != "model" {
|
||
t.Errorf("gemini contents carry an invalid role %q: %s", c.Role, gout)
|
||
}
|
||
}
|
||
|
||
// Negative control: a text-only turn must still be forwarded verbatim.
|
||
plain := `{"model":"m","messages":[{"role":"user","content":"just text"}]}`
|
||
for _, a := range append(byID, "ollama", "gemini") {
|
||
pout, err := vm.Transform(a, "transform_request", plain)
|
||
if err != nil {
|
||
t.Errorf("%s plain: %v", a, err)
|
||
continue
|
||
}
|
||
if !strings.Contains(pout, "just text") {
|
||
t.Errorf("%s dropped plain text: %s", a, pout)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestOpenCodeStreamOptionsOnlyWhenStreaming pins the fix for
|
||
// "stream_options should be set along with stream": OpenCode Go (and other
|
||
// strict OpenAI-compatible upstreams) reject a non-streaming request that
|
||
// carries stream_options, which broke every non-stream call through the
|
||
// opencode adapter.
|
||
func TestOpenCodeStreamOptionsOnlyWhenStreaming(t *testing.T) {
|
||
vm := NewVM(freshAdapterDir(t))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer vm.Stop()
|
||
|
||
// non-streaming: stream_options must be absent entirely
|
||
out, err := vm.Transform("opencode", "transform_request",
|
||
`{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(out, "stream_options") {
|
||
t.Fatalf("non-streaming request must not carry stream_options: %s", out)
|
||
}
|
||
|
||
// streaming: stream_options.include_usage must be set
|
||
sout, err := vm.Transform("opencode", "transform_request",
|
||
`{"model":"m","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !strings.Contains(sout, "stream_options") || !strings.Contains(sout, "include_usage") {
|
||
t.Fatalf("streaming request must carry stream_options.include_usage: %s", sout)
|
||
}
|
||
}
|
||
|
||
// TestOpenCodeGoVsZenReasoning pins the one behavioural difference that made a
|
||
// shared adapter wrong: OpenCode Go's thinking mode REQUIRES the assistant
|
||
// turn's reasoning_content to be echoed back ("The `reasoning_content` in the
|
||
// thinking mode must be passed back to the API"), while the Zen free pool must
|
||
// not receive it. Hence two purpose-built adapters.
|
||
func TestOpenCodeGoVsZenReasoning(t *testing.T) {
|
||
vm := NewVM(freshAdapterDir(t))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer vm.Stop()
|
||
|
||
body := `{"model":"m","messages":[
|
||
{"role":"user","content":"hi"},
|
||
{"role":"assistant","content":"","reasoning_content":"I should read the file.","tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{\"path\":\"/x\"}"}}]},
|
||
{"role":"tool","tool_call_id":"call_1","content":"r"}
|
||
]}`
|
||
|
||
// Go: reasoning_content must survive, and the tool call must stay paired.
|
||
gout, err := vm.Transform("opencodego", "transform_request", body)
|
||
if err != nil {
|
||
t.Fatalf("opencodego: %v", err)
|
||
}
|
||
if !strings.Contains(gout, "I should read the file.") {
|
||
t.Errorf("opencodego must pass reasoning_content back (thinking mode requires it): %s", gout)
|
||
}
|
||
if !strings.Contains(gout, "call_1") || !strings.Contains(gout, "tool_call_id") {
|
||
t.Errorf("opencodego must keep the tool call paired with its result: %s", gout)
|
||
}
|
||
|
||
// Zen: reasoning_content is stripped.
|
||
zout, err := vm.Transform("opencodezen", "transform_request", body)
|
||
if err != nil {
|
||
t.Fatalf("opencodezen: %v", err)
|
||
}
|
||
if strings.Contains(zout, "I should read the file.") {
|
||
t.Errorf("opencodezen must strip reasoning_content: %s", zout)
|
||
}
|
||
if !strings.Contains(zout, "call_1") {
|
||
t.Errorf("opencodezen must still keep the tool call: %s", zout)
|
||
}
|
||
|
||
// An assistant turn that carries tool_calls but no reasoning_content must
|
||
// get an empty one injected: Go validates that field on tool-calling turns
|
||
// and 400s when it is absent, while most clients never send it.
|
||
// Verified against the live endpoint that an empty string passes.
|
||
bare := `{"model":"m","messages":[
|
||
{"role":"user","content":"go"},
|
||
{"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"read","arguments":"{}"}}]},
|
||
{"role":"tool","tool_call_id":"c1","content":"r"}
|
||
]}`
|
||
bout, err := vm.Transform("opencodego", "transform_request", bare)
|
||
if err != nil {
|
||
t.Fatalf("opencodego bare: %v", err)
|
||
}
|
||
if !strings.Contains(bout, `"reasoning_content":""`) {
|
||
t.Errorf("opencodego must inject an empty reasoning_content on a tool-calling turn: %s", bout)
|
||
}
|
||
// A plain assistant turn (no tool calls) must NOT gain the field.
|
||
plainAsst := `{"model":"m","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"hello"}]}`
|
||
pout, err := vm.Transform("opencodego", "transform_request", plainAsst)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if strings.Contains(pout, "reasoning_content") {
|
||
t.Errorf("opencodego must not inject reasoning_content on a plain assistant turn: %s", pout)
|
||
}
|
||
|
||
// Both must still omit stream_options on non-streaming requests.
|
||
for _, a := range []string{"opencodego", "opencodezen"} {
|
||
out, err := vm.Transform(a, "transform_request", `{"model":"m","messages":[{"role":"user","content":"hi"}]}`)
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", a, err)
|
||
}
|
||
if strings.Contains(out, "stream_options") {
|
||
t.Errorf("%s must not send stream_options when not streaming: %s", a, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestOpenCodeSessionIsStableForCache pins the session-derivation contract that
|
||
// makes the upstream prefix cache usable.
|
||
//
|
||
// Measured against the live OpenCode Go endpoint: the prefix cache is
|
||
// SESSION-scoped. Replaying the same 6032-token prompt hits 5888 cached tokens
|
||
// when the session id is fixed, and 0 when it changes per request. Deriving the
|
||
// session from meta.timestamp (the previous behaviour) meant every request was a
|
||
// new session, so the cache could never hit.
|
||
//
|
||
// The session must be stable WITHIN one conversation and differ BETWEEN
|
||
// conversations. Since generic OpenAI clients send no session id at all
|
||
// (verified by capturing real agent traffic: no user / session_id /
|
||
// conversation_id / metadata in the body, no session header), the conversation
|
||
// is fingerprinted by its FIRST user message — history is replayed every turn,
|
||
// so that message is invariant for the life of the conversation.
|
||
func TestOpenCodeSessionIsStableForCache(t *testing.T) {
|
||
for _, name := range []string{"opencodego", "opencodezen"} {
|
||
vm := NewVM(freshAdapterDir(t))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
meta := func(ts int, body string) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"timestamp": ts,
|
||
"body": body,
|
||
"source": map[string]interface{}{"name": "somesource", "meta": nil},
|
||
}
|
||
}
|
||
|
||
// Same conversation, later turn: history grew, first user msg unchanged.
|
||
h1, err := vm.BuildHeaders(name, meta(1000,
|
||
`{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"}]}`))
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", name, err)
|
||
}
|
||
h2, err := vm.BuildHeaders(name, meta(2000,
|
||
`{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"},{"role":"user","content":"and tests"}]}`))
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", name, err)
|
||
}
|
||
// Different conversation.
|
||
h3, err := vm.BuildHeaders(name, meta(3000,
|
||
`{"messages":[{"role":"user","content":"an unrelated task"}]}`))
|
||
if err != nil {
|
||
t.Fatalf("%s: %v", name, err)
|
||
}
|
||
|
||
if h1["x-opencode-session"] == "" {
|
||
t.Fatalf("%s: session header must be set", name)
|
||
}
|
||
if h1["x-opencode-session"] != h2["x-opencode-session"] {
|
||
t.Errorf("%s: x-opencode-session must be STABLE across turns of one conversation "+
|
||
"(%q vs %q) — the prefix cache is session-scoped", name, h1["x-opencode-session"], h2["x-opencode-session"])
|
||
}
|
||
if h1["x-opencode-session"] == h3["x-opencode-session"] {
|
||
t.Errorf("%s: a different conversation must get a different session", name)
|
||
}
|
||
if h1["x-opencode-request"] == h2["x-opencode-request"] {
|
||
t.Errorf("%s: x-opencode-request must differ per request", name)
|
||
}
|
||
// No user message at all falls back to per-source stability.
|
||
h4, _ := vm.BuildHeaders(name, meta(4000, `{"messages":[{"role":"system","content":"s"}]}`))
|
||
h5, _ := vm.BuildHeaders(name, meta(5000, `{"messages":[{"role":"system","content":"s"}]}`))
|
||
if h4["x-opencode-session"] != h5["x-opencode-session"] {
|
||
t.Errorf("%s: sessionless requests must still be stable per source", name)
|
||
}
|
||
vm.Stop()
|
||
}
|
||
}
|
||
|
||
// 客户端自带的会话标识(pi 的 x-session-affinity)必须优先于会话指纹:
|
||
// 它是平台真实会话 id,历史压缩后也不会漂移,且天然按会话隔离。
|
||
func TestOpenCodePrefersClientSessionID(t *testing.T) {
|
||
for _, name := range []string{"opencodego", "opencodezen"} {
|
||
vm := NewVM(freshAdapterDir(t))
|
||
if err := vm.Start(); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
meta := func(body, cs string) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"timestamp": 1000,
|
||
"body": body,
|
||
"client_session": cs,
|
||
"source": map[string]interface{}{"name": "somesource", "meta": nil},
|
||
}
|
||
}
|
||
|
||
// 同一条会话:历史在长,但客户端会话 id 不变。
|
||
a, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-aaa"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"a totally different opener"}]}`, "cs-aaa"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if a["x-opencode-session"] != b["x-opencode-session"] {
|
||
t.Errorf("%s: 客户端会话 id 相同就必须同会话(内容无关): %q vs %q",
|
||
name, a["x-opencode-session"], b["x-opencode-session"])
|
||
}
|
||
|
||
// 不同会话。
|
||
c, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-bbb"))
|
||
if a["x-opencode-session"] == c["x-opencode-session"] {
|
||
t.Errorf("%s: 不同客户端会话必须不同会号", name)
|
||
}
|
||
|
||
// 客户端没给会话 id 时,回落到指纹(同内容 -> 同会号)。
|
||
d, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, ""))
|
||
e, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, ""))
|
||
if d["x-opencode-session"] != e["x-opencode-session"] {
|
||
t.Errorf("%s: 无客户端会号时应回落到稳定指纹", name)
|
||
}
|
||
if d["x-opencode-session"] == a["x-opencode-session"] {
|
||
t.Errorf("%s: 客户端会号与指纹两条路必须产出不同会号", name)
|
||
}
|
||
vm.Stop()
|
||
}
|
||
}
|