mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-12 04:07:25 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 091c9c961f | |||
| 39b48e556e | |||
| 7df413453b | |||
| 791d198f47 |
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.5",
|
"version": "1.5.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.5.5",
|
"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.5",
|
"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,14 +29,55 @@ 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 ""
|
||||||
return {
|
return {
|
||||||
["User-Agent"] = adapter.headers["User-Agent"],
|
["User-Agent"] = adapter.headers["User-Agent"],
|
||||||
["x-opencode-client"] = "cli",
|
["x-opencode-client"] = "cli",
|
||||||
-- project 固定:同网关实例共享一个工作区身份
|
-- project 固定:同网关实例共享一个工作区身份
|
||||||
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. (meta.source and meta.source.name or "")), 1, 32),
|
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32),
|
||||||
["x-opencode-session"] = rand_id("ses_", "session|" .. ts),
|
-- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session
|
||||||
|
-- 内才复用。实测(同一段 6032 token 提示词):
|
||||||
|
-- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888
|
||||||
|
-- 每请求换 session -> 永远 0 命中
|
||||||
|
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
|
||||||
|
-- 上游也无法做会话亲和路由。
|
||||||
|
["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 "")),
|
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -30,14 +30,55 @@ 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 ""
|
||||||
return {
|
return {
|
||||||
["User-Agent"] = adapter.headers["User-Agent"],
|
["User-Agent"] = adapter.headers["User-Agent"],
|
||||||
["x-opencode-client"] = "cli",
|
["x-opencode-client"] = "cli",
|
||||||
-- project 固定:同网关实例共享一个工作区身份
|
-- project 固定:同网关实例共享一个工作区身份
|
||||||
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. (meta.source and meta.source.name or "")), 1, 32),
|
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. src), 1, 32),
|
||||||
["x-opencode-session"] = rand_id("ses_", "session|" .. ts),
|
-- session 必须**按源稳定**,不能每请求换:上游的前缀缓存在同一 session
|
||||||
|
-- 内才复用。实测(同一段 6032 token 提示词):
|
||||||
|
-- 固定 session -> 第 2 次命中 5888/6032,cached_tokens=5888
|
||||||
|
-- 每请求换 session -> 永远 0 命中
|
||||||
|
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
|
||||||
|
-- 上游也无法做会话亲和路由。
|
||||||
|
["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 "")),
|
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|||||||
@ -231,3 +231,73 @@ func TestOpenCodeGoVsZenReasoning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user