diff --git a/internal/lua/adapters/opencodego.lua b/internal/lua/adapters/opencodego.lua index b3528ee..e936f69 100644 --- a/internal/lua/adapters/opencodego.lua +++ b/internal/lua/adapters/opencodego.lua @@ -29,6 +29,39 @@ local function rand_id(prefix, seed) return prefix .. string.sub(sha256_hex(seed), 1, 24) end +-- 会话指纹:取历史里**第一条 user 消息**。 +-- +-- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实 +-- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有 +-- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-* +-- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session +-- 是它自己的概念,通用客户端无从转发。 +-- +-- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**, +-- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。 +-- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。 +local function conversation_fingerprint(body) + if type(body) ~= "string" or body == "" then return "" end + local ok, req = pcall(json.decode, body) + if not ok or type(req) ~= "table" then return "" end + for _, m in ipairs(req.messages or {}) do + if type(m) == "table" and m.role == "user" then + local c = m.content + if type(c) == "string" then + if c ~= "" then return c end + elseif type(c) == "table" then + for _, part in ipairs(c) do + if type(part) == "table" and part.type == "text" and part.text then + return part.text + end + end + end + return "" + end + end + return "" +end + function adapter.build_headers(meta) local ts = tostring(meta.timestamp or "") local src = (meta.source and meta.source.name) or "" @@ -43,7 +76,7 @@ function adapter.build_headers(meta) -- 每请求换 session -> 永远 0 命中 -- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效, -- 上游也无法做会话亲和路由。 - ["x-opencode-session"] = rand_id("ses_", "session|llmsproxy|" .. src), + ["x-opencode-session"] = rand_id("ses_", "session|llmsproxy|" .. src .. "|" .. conversation_fingerprint(meta.body)), -- request id 仍每请求唯一(它只是请求标识,不参与缓存键) ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), } diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua index c836fb0..4a65149 100644 --- a/internal/lua/adapters/opencodezen.lua +++ b/internal/lua/adapters/opencodezen.lua @@ -30,6 +30,39 @@ local function rand_id(prefix, seed) return prefix .. string.sub(sha256_hex(seed), 1, 24) end +-- 会话指纹:取历史里**第一条 user 消息**。 +-- +-- 为什么不直接用客户端的会话 id:实测抓包(tcpdump 抓 127.0.0.1:8081 的真实 +-- agent 请求)确认通用 OpenAI 客户端**根本不发**任何会话标识 —— body 里没有 +-- user / session_id / conversation_id / metadata,请求头也只有 X-Stainless-* +-- (OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套 x-opencode-session +-- 是它自己的概念,通用客户端无从转发。 +-- +-- 退而求其次但要够用:历史被逐轮重放,**第一条 user 消息在整个会话里恒定**, +-- 用它做指纹即可得到"每会话一个 session",而不是"每源一个 session"。 +-- 拿不到时(无 user 消息)返回空串,调用方回退到按源稳定。 +local function conversation_fingerprint(body) + if type(body) ~= "string" or body == "" then return "" end + local ok, req = pcall(json.decode, body) + if not ok or type(req) ~= "table" then return "" end + for _, m in ipairs(req.messages or {}) do + if type(m) == "table" and m.role == "user" then + local c = m.content + if type(c) == "string" then + if c ~= "" then return c end + elseif type(c) == "table" then + for _, part in ipairs(c) do + if type(part) == "table" and part.type == "text" and part.text then + return part.text + end + end + end + return "" + end + end + return "" +end + function adapter.build_headers(meta) local ts = tostring(meta.timestamp or "") local src = (meta.source and meta.source.name) or "" @@ -44,7 +77,7 @@ function adapter.build_headers(meta) -- 每请求换 session -> 永远 0 命中 -- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效, -- 上游也无法做会话亲和路由。 - ["x-opencode-session"] = rand_id("ses_", "session|llmsproxy|" .. src), + ["x-opencode-session"] = rand_id("ses_", "session|llmsproxy|" .. src .. "|" .. conversation_fingerprint(meta.body)), -- request id 仍每请求唯一(它只是请求标识,不参与缓存键) ["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")), } diff --git a/internal/lua/toolcall_preservation_test.go b/internal/lua/toolcall_preservation_test.go index 33ae354..6cf5cf2 100644 --- a/internal/lua/toolcall_preservation_test.go +++ b/internal/lua/toolcall_preservation_test.go @@ -232,52 +232,72 @@ func TestOpenCodeGoVsZenReasoning(t *testing.T) { } } -// TestOpenCodeSessionIsStableForCache pins that the opencode client header set -// keeps x-opencode-session STABLE per source while x-opencode-request stays -// unique per call. +// TestOpenCodeSessionIsStableForCache pins the session-derivation contract that +// makes the upstream prefix cache usable. // -// Measured against the live OpenCode Go endpoint: the upstream prefix cache is -// session-scoped. Replaying the same 6032-token prompt hits 5888 cached tokens +// 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) made every request a new -// session, so the cache could never hit. +// 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}, + } + } - metaA := map[string]interface{}{ - "timestamp": 1000, - "body": `{"messages":[{"role":"user","content":"one"}]}`, - "source": map[string]interface{}{"name": "somesource", "meta": nil}, - } - metaB := map[string]interface{}{ - "timestamp": 2000, // different second, different body - "body": `{"messages":[{"role":"user","content":"two"}]}`, - "source": map[string]interface{}{"name": "somesource", "meta": nil}, - } - h1, err := vm.BuildHeaders(name, metaA) + // 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 headers A: %v", name, err) + t.Fatalf("%s: %v", name, err) } - h2, err := vm.BuildHeaders(name, metaB) + 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 headers B: %v", name, err) + 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 requests (%q vs %q) — "+ - "the upstream prefix cache is session-scoped, a rotating session kills every cache hit", - name, 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() } }