mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 091c9c961f | |||
| 39b48e556e |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"license": "Proprietary",
|
"license": "Proprietary",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.5.6",
|
"version": "1.5.7",
|
||||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||||
"author": "ModelRouter",
|
"author": "ModelRouter",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
|
|||||||
@ -29,6 +29,39 @@ local function rand_id(prefix, seed)
|
|||||||
return prefix .. string.sub(sha256_hex(seed), 1, 24)
|
return prefix .. string.sub(sha256_hex(seed), 1, 24)
|
||||||
end
|
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)
|
function adapter.build_headers(meta)
|
||||||
local ts = tostring(meta.timestamp or "")
|
local ts = tostring(meta.timestamp or "")
|
||||||
local src = (meta.source and meta.source.name) or ""
|
local src = (meta.source and meta.source.name) or ""
|
||||||
@ -43,7 +76,7 @@ function adapter.build_headers(meta)
|
|||||||
-- 每请求换 session -> 永远 0 命中
|
-- 每请求换 session -> 永远 0 命中
|
||||||
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
|
-- 原先用 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 仍每请求唯一(它只是请求标识,不参与缓存键)
|
-- request id 仍每请求唯一(它只是请求标识,不参与缓存键)
|
||||||
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,6 +30,39 @@ local function rand_id(prefix, seed)
|
|||||||
return prefix .. string.sub(sha256_hex(seed), 1, 24)
|
return prefix .. string.sub(sha256_hex(seed), 1, 24)
|
||||||
end
|
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)
|
function adapter.build_headers(meta)
|
||||||
local ts = tostring(meta.timestamp or "")
|
local ts = tostring(meta.timestamp or "")
|
||||||
local src = (meta.source and meta.source.name) or ""
|
local src = (meta.source and meta.source.name) or ""
|
||||||
@ -44,7 +77,7 @@ function adapter.build_headers(meta)
|
|||||||
-- 每请求换 session -> 永远 0 命中
|
-- 每请求换 session -> 永远 0 命中
|
||||||
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
|
-- 原先用 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 仍每请求唯一(它只是请求标识,不参与缓存键)
|
-- request id 仍每请求唯一(它只是请求标识,不参与缓存键)
|
||||||
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -232,52 +232,72 @@ func TestOpenCodeGoVsZenReasoning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenCodeSessionIsStableForCache pins that the opencode client header set
|
// TestOpenCodeSessionIsStableForCache pins the session-derivation contract that
|
||||||
// keeps x-opencode-session STABLE per source while x-opencode-request stays
|
// makes the upstream prefix cache usable.
|
||||||
// unique per call.
|
|
||||||
//
|
//
|
||||||
// Measured against the live OpenCode Go endpoint: the upstream prefix cache is
|
// Measured against the live OpenCode Go endpoint: the prefix cache is
|
||||||
// session-scoped. Replaying the same 6032-token prompt hits 5888 cached tokens
|
// 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
|
// 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 from meta.timestamp (the previous behaviour) meant every request was a
|
||||||
// session, so the cache could never hit.
|
// 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) {
|
func TestOpenCodeSessionIsStableForCache(t *testing.T) {
|
||||||
for _, name := range []string{"opencodego", "opencodezen"} {
|
for _, name := range []string{"opencodego", "opencodezen"} {
|
||||||
vm := NewVM(freshAdapterDir(t))
|
vm := NewVM(freshAdapterDir(t))
|
||||||
if err := vm.Start(); err != nil {
|
if err := vm.Start(); err != nil {
|
||||||
t.Fatal(err)
|
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{}{
|
// Same conversation, later turn: history grew, first user msg unchanged.
|
||||||
"timestamp": 1000,
|
h1, err := vm.BuildHeaders(name, meta(1000,
|
||||||
"body": `{"messages":[{"role":"user","content":"one"}]}`,
|
`{"messages":[{"role":"user","content":"fix the bug"},{"role":"assistant","content":"looking"}]}`))
|
||||||
"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)
|
|
||||||
if err != nil {
|
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 {
|
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"] == "" {
|
if h1["x-opencode-session"] == "" {
|
||||||
t.Fatalf("%s: session header must be set", name)
|
t.Fatalf("%s: session header must be set", name)
|
||||||
}
|
}
|
||||||
if h1["x-opencode-session"] != h2["x-opencode-session"] {
|
if h1["x-opencode-session"] != h2["x-opencode-session"] {
|
||||||
t.Errorf("%s: x-opencode-session must be stable across requests (%q vs %q) — "+
|
t.Errorf("%s: x-opencode-session must be STABLE across turns of one conversation "+
|
||||||
"the upstream prefix cache is session-scoped, a rotating session kills every cache hit",
|
"(%q vs %q) — the prefix cache is session-scoped", name, h1["x-opencode-session"], h2["x-opencode-session"])
|
||||||
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"] {
|
if h1["x-opencode-request"] == h2["x-opencode-request"] {
|
||||||
t.Errorf("%s: x-opencode-request must differ per request", name)
|
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()
|
vm.Stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user