mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 17:57:23 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12af22fda6 | |||
| 9114468753 | |||
| 611e975456 | |||
| 6733534282 | |||
| 499f0cac2f | |||
| 7fb8f96b82 | |||
| 6a34cd6f86 |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.3",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.3",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@ -115,13 +116,15 @@ func (g *Gateway) resolveCands(ctx context.Context, req *chatRequest) ([]*provid
|
||||
}
|
||||
|
||||
// filterCandsByModels keeps only providers exposing at least one model of the
|
||||
// scope (used for user keys with a restricted model scope). An "AUTO" scope
|
||||
// entry means the key is allowed to use any model. Scope entries with a
|
||||
// Source pinned to a specific upstream narrow the candidates to that source
|
||||
// scope (used for user keys with a restricted model scope). Scope entries with
|
||||
// a Source pinned to a specific upstream narrow the candidates to that source
|
||||
// for the matching model.
|
||||
//
|
||||
// An "AUTO" scope entry only allows the AUTO routing mode; it does NOT grant
|
||||
// access to specific models.
|
||||
func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider {
|
||||
for _, m := range allow {
|
||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
||||
if m.Model == "" {
|
||||
return cands
|
||||
}
|
||||
}
|
||||
@ -152,20 +155,18 @@ func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope)
|
||||
return out
|
||||
}
|
||||
|
||||
// intersectModels restricts a model list to the scope (preserving order). An
|
||||
// "AUTO" scope entry grants every model.
|
||||
// intersectModels restricts a model list to the scope (preserving order).
|
||||
// An "AUTO" scope entry only allows the AUTO routing mode; it does NOT grant
|
||||
// every model.
|
||||
func intersectModels(models []string, allow []config.ModelScope) []string {
|
||||
allowed := make(map[string]bool, len(allow))
|
||||
any := false
|
||||
for _, m := range allow {
|
||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
||||
any = true
|
||||
break
|
||||
if m.Model == "" {
|
||||
return models
|
||||
}
|
||||
if !strings.EqualFold(m.Model, "AUTO") {
|
||||
allowed[m.Model] = true
|
||||
}
|
||||
allowed[m.Model] = true
|
||||
}
|
||||
if any {
|
||||
return models
|
||||
}
|
||||
out := make([]string, 0, len(models))
|
||||
seen := map[string]bool{}
|
||||
@ -179,24 +180,16 @@ func intersectModels(models []string, allow []config.ModelScope) []string {
|
||||
}
|
||||
|
||||
// checkModelScope validates the effective model against the key's model scope
|
||||
// and token quota. Returns an error message when rejected. A scope entry with
|
||||
// model "AUTO" grants all models; its quota caps the key's total tokens.
|
||||
// and token quota. Returns an error message when rejected.
|
||||
//
|
||||
// A scope entry with model "AUTO" only allows requests where the effective
|
||||
// model is AUTO (the routing mode). It does NOT grant access to specific model
|
||||
// ids — that requires an explicit scope entry for the model.
|
||||
func (g *Gateway) checkModelScope(ctx context.Context, model string) string {
|
||||
allow := g.allowedModels(ctx)
|
||||
if allow == nil {
|
||||
return ""
|
||||
}
|
||||
for _, sc := range allow {
|
||||
if sc.Model != "" && strings.EqualFold(sc.Model, "AUTO") {
|
||||
if sc.TokenQuota > 0 {
|
||||
used := g.scopeTokens(ctx, sc)
|
||||
if used >= sc.TokenQuota {
|
||||
return fmt.Sprintf("token quota exceeded (%d/%d)", used, sc.TokenQuota)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, sc := range allow {
|
||||
if sc.Model != model {
|
||||
continue
|
||||
@ -229,12 +222,15 @@ func (g *Gateway) scopeTokens(ctx context.Context, sc config.ModelScope) int64 {
|
||||
// source that actually serves the bare model (via Registry.EffectiveModel), so
|
||||
// model ids that themselves contain separators (e.g. "deepseek-v4-flash-free")
|
||||
// are never corrupted (P10-2).
|
||||
//
|
||||
// A scope entry with model "AUTO" only matches the literal AUTO routing mode;
|
||||
// it does NOT grant access to specific model ids.
|
||||
func (g *Gateway) hasScopeModel(list []config.ModelScope, s string) bool {
|
||||
if r := g.core.Registry(); r != nil {
|
||||
s = r.EffectiveModel(s)
|
||||
}
|
||||
for _, x := range list {
|
||||
if x.Model == s || (x.Model != "" && strings.EqualFold(x.Model, "AUTO")) {
|
||||
if x.Model == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@ -517,21 +513,67 @@ func upstreamErrStatus(err error) int {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
// overflowMarkers 匹配上游「上下文超窗」类措辞。
|
||||
//
|
||||
// 上游写法五花八门,且**不在 pi 客户端的识别列表里**。pi 靠
|
||||
// @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 判断超窗并据此触发压缩重试,
|
||||
// 而 justworker 返回的是「请精简对话历史…(Context window is full…)」——
|
||||
// 与那 25 条正则一条都不匹配,于是 pi 既不压缩也不重试,只把它当成一条
|
||||
// 普通上游错误。这里把可识别的超窗措辞归一化成 pi 一定认得的标记。
|
||||
var overflowMarkers = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)context[ _-]?window is full`),
|
||||
regexp.MustCompile(`(?i)context[_ ]length[_ ]exceeded`),
|
||||
regexp.MustCompile(`(?i)exceeds? the context window`),
|
||||
regexp.MustCompile(`(?i)maximum context length`),
|
||||
regexp.MustCompile(`(?i)reduce the length of the messages`),
|
||||
regexp.MustCompile(`(?i)too many tokens`),
|
||||
regexp.MustCompile(`(?i)token limit exceeded`),
|
||||
regexp.MustCompile(`请精简对话历史`),
|
||||
regexp.MustCompile(`上下文(长度)?超(出|限)`),
|
||||
regexp.MustCompile(`对话历史过长`),
|
||||
}
|
||||
|
||||
// overflowCanonical 命中 pi 的 /context[_ ]length[_ ]exceeded/i。
|
||||
const overflowCanonical = "context_length_exceeded"
|
||||
|
||||
// looksLikeOverflow 判断错误文本是否属于上下文超窗。
|
||||
func looksLikeOverflow(s string) bool {
|
||||
for _, re := range overflowMarkers {
|
||||
if re.MatchString(s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clientUpstreamErr collapses upstream failure details into a short message
|
||||
// for the client: per-tier bodies (WAF HTML pages, quota payloads, ...) stay
|
||||
// in rec.Err / the stats API and the server log instead of the response.
|
||||
// Per-tier one-line reasons are kept (quota/cooling skips carry no error body
|
||||
// and are the actionable part); each is capped so HTML dumps can't leak.
|
||||
//
|
||||
// 超窗会被归一化成 overflowCanonical 前缀,见 overflowMarkers 的说明。
|
||||
func clientUpstreamErr(err error) string {
|
||||
log.Printf("[gateway] upstream failure surfaced to client: %v", err)
|
||||
msg := upstreamErrSummary(err)
|
||||
if looksLikeOverflow(err.Error()) || looksLikeOverflow(msg) {
|
||||
return fmt.Sprintf("%s: %s", overflowCanonical, msg)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// upstreamErrSummary 把上游失败压成一行短消息。
|
||||
func upstreamErrSummary(err error) string {
|
||||
var ce *scheduler.ChainErr
|
||||
if errors.As(err, &ce) {
|
||||
parts := make([]string, 0, len(ce.Tiers)+len(ce.Skipped))
|
||||
for _, t := range ce.Tiers {
|
||||
parts = append(parts, types.OneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 80))
|
||||
// 160 而不是 80:短诊断词("Context window is full")常落在尾部,
|
||||
// 80 字节按字节截断正好会把它切掉,超窗就再也认不出来。
|
||||
parts = append(parts, types.OneLine(fmt.Sprintf("%s/%s: %v", t.Source, t.Model, t.Err), 160))
|
||||
}
|
||||
for _, sk := range ce.Skipped {
|
||||
parts = append(parts, types.OneLine(sk, 80))
|
||||
parts = append(parts, types.OneLine(sk, 160))
|
||||
}
|
||||
msg := strings.Join(parts, "; ")
|
||||
if len(msg) > 300 {
|
||||
|
||||
@ -670,8 +670,11 @@ func TestHasScopeModelWithSourcePrefix(t *testing.T) {
|
||||
if g.hasScopeModel(scope, "deepseek-v4-flash-free-extra") {
|
||||
t.Error("hasScopeModel returned true for unrelated model")
|
||||
}
|
||||
if !g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:anything") {
|
||||
t.Error("AUTO scope should allow any prefixed model")
|
||||
if g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:deepseek-v4-flash-free") {
|
||||
t.Error("AUTO scope must NOT grant specific model ids even with prefix")
|
||||
}
|
||||
if g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "deepseek-v4-pro") {
|
||||
t.Error("AUTO scope must NOT grant specific model ids")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
internal/gateway/overflow_err_test.go
Normal file
96
internal/gateway/overflow_err_test.go
Normal file
@ -0,0 +1,96 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/scheduler"
|
||||
"llmsproxy/internal/types"
|
||||
)
|
||||
|
||||
// piOverflowRe 是 @earendil-works/pi-ai 的 OVERFLOW_PATTERNS 中对应的一条。
|
||||
// 归一化标记必须命中它,否则改了等于没改:客户端仍不会压缩重试。
|
||||
var piOverflowRe = regexp.MustCompile(`context[_ ]length[_ ]exceeded`)
|
||||
|
||||
func TestOverflowCanonicalMatchesPiPattern(t *testing.T) {
|
||||
if !piOverflowRe.MatchString(overflowCanonical) {
|
||||
t.Fatalf("归一化标记 %q 不匹配 pi 的超窗正则,客户端仍不会压缩重试", overflowCanonical)
|
||||
}
|
||||
}
|
||||
|
||||
// justworker 的真实措辞:与 pi 现有 25 条正则一条都不匹配,
|
||||
// 所以必须在网关侧归一化,否则超窗对客户端完全不可见。
|
||||
func TestJustworkerOverflowIsNormalized(t *testing.T) {
|
||||
raw := "请精简对话历史…(Context window is full, please reduce the conversation history)…"
|
||||
|
||||
// 前提校验:原始措辞确实不被 pi 认出(否则本用例失去意义)。
|
||||
if piOverflowRe.MatchString(raw) {
|
||||
t.Fatalf("前提不成立:原始措辞本就匹配 pi 正则: %q", raw)
|
||||
}
|
||||
if !looksLikeOverflow(raw) {
|
||||
t.Fatalf("网关应识别出这是超窗: %q", raw)
|
||||
}
|
||||
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "justworker", Model: "claude-opus-5", Err: errors.New(raw),
|
||||
}}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("归一化后仍不匹配 pi 正则,客户端不会压缩重试: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 直连路径(非 AUTO 链)也要归一化——它走的是同一个入口。
|
||||
func TestDirectPathOverflowIsNormalized(t *testing.T) {
|
||||
got := clientUpstreamErr(errors.New("upstream 400: Context window is full"))
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("直连超窗未被归一化: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 非超窗错误不能被加上超窗标记,否则会把普通失败误报成需要压缩。
|
||||
func TestNonOverflowNotNormalized(t *testing.T) {
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "x", Model: "y", Err: errors.New("502 Bad Gateway: upstream connect error"),
|
||||
}}}
|
||||
got := clientUpstreamErr(err)
|
||||
if strings.Contains(got, overflowCanonical) {
|
||||
t.Fatalf("普通上游失败被误标为超窗: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "502 Bad Gateway") {
|
||||
t.Fatalf("普通失败的诊断信息应保留: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:短诊断词常落在错误尾部,80 字节截断会正好把它切掉,
|
||||
// 超窗就永远认不出来。宽度放宽后必须保住。
|
||||
func TestOverflowPhraseSurvivesTruncationWidth(t *testing.T) {
|
||||
prefix := strings.Repeat("x", 100)
|
||||
// OneLine 的长度限制作用于整条 "source/model: err",不是只算 err 本身。
|
||||
full := "justworker/claude-opus-5: " + prefix + " Context window is full"
|
||||
|
||||
// 前提:80 字节下短语确实会被切掉(这正是修复前的行为)。
|
||||
if got := types.OneLine(full, 80); strings.Contains(got, "Context window is full") {
|
||||
t.Fatalf("前提不成立:80 字节下短语居然还在: %q", got)
|
||||
}
|
||||
// 160 字节下必须保住,否则超窗识别会被截断悄悄破坏。
|
||||
if got := types.OneLine(full, 160); !strings.Contains(got, "Context window is full") {
|
||||
t.Fatalf("160 字节应保住尾部短诊断词: %q", got)
|
||||
}
|
||||
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{{
|
||||
Tier: 1, Source: "justworker", Model: "claude-opus-5",
|
||||
Err: errors.New(prefix + " Context window is full"),
|
||||
}}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !strings.Contains(strings.ToLower(got), "context window is full") {
|
||||
t.Fatalf("宽度 160 应保住尾部短诊断词,实际: %q", got)
|
||||
}
|
||||
if !piOverflowRe.MatchString(got) {
|
||||
t.Fatalf("归一化标记丢失: %q", got)
|
||||
}
|
||||
}
|
||||
@ -5,10 +5,12 @@ adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- AgentRouter 的 WAF 会按客户端指纹白名单校验,只放行官方客户端。
|
||||
-- 这里通过 build_headers 注入官方客户端 User-Agent 以通过校验。
|
||||
-- 默认 QwenCode 指纹(实测可通);可在 source.meta.user_agent 覆盖成 Claude Code 指纹。
|
||||
|
||||
-- AgentRouter 的阿里云 WAF 按三重维度校验:出口 IP(必须海外)、
|
||||
-- TLS 指纹(JS/Go HTTP 层与 curl 不同)、浏览器指纹 header 集。
|
||||
-- 纯 UA 已不够:WAF 拦 12 种 agent CLI UA 中的 10 种(全 405 HTML),
|
||||
-- 只有同时带 Origin: qwen.ai + Referer + X-Requested-With 才放行,
|
||||
-- 且同 IP 短时间连续 3+ 次请求会被速率封禁,靠 adapter 侧低频调用 +
|
||||
-- llmsproxy 的 prefFailStep 冷却自然限制,来源无法控制速率。
|
||||
local default_ua = "QwenCode/0.2.0 (linux; x64)"
|
||||
|
||||
-- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams
|
||||
@ -65,6 +67,13 @@ function adapter.build_headers(meta)
|
||||
["Content-Type"] = "application/json",
|
||||
["Authorization"] = "Bearer " .. meta.api_key,
|
||||
["User-Agent"] = ua,
|
||||
-- QwenCode client fingerprint: WAF rejects bare UA without these.
|
||||
["Accept"] = "application/json, text/plain, */*",
|
||||
["Accept-Language"] = "zh-CN,zh;q=0.9",
|
||||
["Origin"] = "https://qwen.ai",
|
||||
["Referer"] = "https://qwen.ai/",
|
||||
["X-Requested-With"] = "XMLHttpRequest",
|
||||
["Connection"] = "keep-alive",
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@ -7,6 +7,10 @@ adapter.headers = {}
|
||||
|
||||
-- Gemini API: POST /v1/models/{model}:generateContent
|
||||
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
||||
--
|
||||
-- 请求方向必须把 OpenAI 的工具调用翻译成 Gemini 的 functionCall /
|
||||
-- functionResponse,否则 agent 回放的历史里助手那一轮的调用会凭空消失,
|
||||
-- 紧随其后的工具结果就成了「无来源」的孤立结果,模型只能反复重发同一个调用。
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
@ -14,16 +18,23 @@ function adapter.transform_request(raw_body)
|
||||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
||||
local function to_parts(content)
|
||||
if type(content) == "string" then
|
||||
if content == "" then return {} end
|
||||
return { { text = content } }
|
||||
end
|
||||
local parts = {}
|
||||
for _, p in ipairs(content or {}) do
|
||||
if p.type == "text" then
|
||||
table.insert(parts, { text = p.text })
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||||
if b64 then
|
||||
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
||||
if type(p) == "string" then
|
||||
if p ~= "" then table.insert(parts, { text = p }) end
|
||||
elseif type(p) == "table" then
|
||||
if p.type == "text" then
|
||||
if p.text ~= nil and p.text ~= "" then
|
||||
table.insert(parts, { text = p.text })
|
||||
end
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
||||
if b64 then
|
||||
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -31,11 +42,59 @@ function adapter.transform_request(raw_body)
|
||||
end
|
||||
|
||||
local contents = {}
|
||||
local system = ""
|
||||
local call_names = {} -- tool_call_id -> 函数名(functionResponse 只认名字)
|
||||
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
table.insert(contents, {
|
||||
role = (m.role == "assistant") and "model" or m.role,
|
||||
parts = to_parts(m.content)
|
||||
})
|
||||
local role = m.role or "user"
|
||||
|
||||
if role == "system" then
|
||||
if type(m.content) == "string" then
|
||||
system = system .. m.content .. "\n"
|
||||
end
|
||||
|
||||
elseif role == "assistant" then
|
||||
local parts = to_parts(m.content)
|
||||
if type(m.tool_calls) == "table" then
|
||||
for _, tc in ipairs(m.tool_calls) do
|
||||
if type(tc) == "table" then
|
||||
local fn = tc["function"] or {}
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" and args ~= "" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
if tc.id ~= nil then call_names[tc.id] = fn.name or "" end
|
||||
table.insert(parts, {
|
||||
functionCall = { name = fn.name or "", args = args }
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Gemini 不接受空的 parts 数组;无可发送内容的轮次直接跳过
|
||||
if #parts > 0 then
|
||||
table.insert(contents, { role = "model", parts = parts })
|
||||
end
|
||||
|
||||
elseif role == "tool" then
|
||||
local name = call_names[m.tool_call_id] or m.name or ""
|
||||
local text = type(m.content) == "string" and m.content or ""
|
||||
table.insert(contents, {
|
||||
role = "user",
|
||||
parts = { { functionResponse = {
|
||||
name = name,
|
||||
response = { content = text },
|
||||
} } }
|
||||
})
|
||||
|
||||
else
|
||||
local parts = to_parts(m.content)
|
||||
if #parts > 0 then
|
||||
table.insert(contents, { role = "user", parts = parts })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local gemini_req = {
|
||||
@ -46,6 +105,30 @@ function adapter.transform_request(raw_body)
|
||||
}
|
||||
}
|
||||
|
||||
if system ~= "" then
|
||||
gemini_req.systemInstruction = { parts = { { text = system } } }
|
||||
end
|
||||
|
||||
-- tools -> functionDeclarations
|
||||
if type(req.tools) == "table" then
|
||||
local decls = {}
|
||||
for _, t in ipairs(req.tools) do
|
||||
if type(t) == "table" and type(t["function"]) == "table" then
|
||||
local fn = t["function"]
|
||||
local params = fn.parameters
|
||||
if type(params) ~= "table" then params = { type = "object", properties = {} } end
|
||||
table.insert(decls, {
|
||||
name = fn.name or "",
|
||||
description = fn.description or "",
|
||||
parameters = params,
|
||||
})
|
||||
end
|
||||
end
|
||||
if #decls > 0 then
|
||||
gemini_req.tools = { { functionDeclarations = decls } }
|
||||
end
|
||||
end
|
||||
|
||||
if req.stream then
|
||||
gemini_req.stream = true
|
||||
end
|
||||
|
||||
@ -6,6 +6,10 @@ adapter.endpoint = "/api/chat"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
|
||||
-- 工具调用必须一并翻译:Ollama 的 assistant 消息用 tool_calls(arguments 是对象
|
||||
-- 而非 JSON 字符串),工具结果用 tool 角色 + tool_name。此前这里只复制了
|
||||
-- role/content,助手那轮的调用和 tool 消息的归属全部丢失,模型看到无来源的
|
||||
-- 工具结果就只能反复重发同一个调用。
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
@ -22,6 +26,7 @@ function adapter.transform_request(raw_body)
|
||||
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
|
||||
if req.messages then
|
||||
local msgs = {}
|
||||
local call_names = {} -- tool_call_id -> 函数名
|
||||
for _, m in ipairs(req.messages) do
|
||||
local text, images
|
||||
if type(m.content) == "string" then
|
||||
@ -30,22 +35,61 @@ function adapter.transform_request(raw_body)
|
||||
text = ""
|
||||
images = {}
|
||||
for _, p in ipairs(m.content or {}) do
|
||||
if p.type == "text" then
|
||||
text = text .. (p.text or "")
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
||||
if b64 then table.insert(images, b64) end
|
||||
if type(p) == "table" then
|
||||
if p.type == "text" then
|
||||
text = text .. (p.text or "")
|
||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
||||
if b64 then table.insert(images, b64) end
|
||||
end
|
||||
end
|
||||
end
|
||||
if #images == 0 then images = nil end
|
||||
end
|
||||
local msg = { role = m.role, content = text }
|
||||
if images then msg.images = images end
|
||||
|
||||
-- 助手轮的工具调用:arguments 转成对象
|
||||
if type(m.tool_calls) == "table" and #m.tool_calls > 0 then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(m.tool_calls) do
|
||||
if type(tc) == "table" then
|
||||
local fn = tc["function"] or {}
|
||||
local args = fn.arguments
|
||||
if type(args) == "string" and args ~= "" then
|
||||
local aok, decoded = pcall(json.decode, args)
|
||||
args = aok and decoded or {}
|
||||
elseif type(args) ~= "table" then
|
||||
args = {}
|
||||
end
|
||||
if tc.id ~= nil then call_names[tc.id] = fn.name or "" end
|
||||
table.insert(tcs, {
|
||||
["function"] = { name = fn.name or "", arguments = args }
|
||||
})
|
||||
end
|
||||
end
|
||||
if #tcs > 0 then msg.tool_calls = tcs end
|
||||
end
|
||||
|
||||
-- 工具结果:Ollama 用 tool_name 标识归属(不认 tool_call_id)
|
||||
if m.role == "tool" then
|
||||
local name = call_names[m.tool_call_id] or m.name or ""
|
||||
if m.tool_call_id ~= nil then
|
||||
msg.tool_call_id = m.tool_call_id
|
||||
end
|
||||
if name ~= "" then msg.tool_name = name end
|
||||
end
|
||||
|
||||
table.insert(msgs, msg)
|
||||
end
|
||||
ollama_req.messages = msgs
|
||||
end
|
||||
|
||||
-- tools 透传(Ollama 的 shape 与 OpenAI 一致)
|
||||
if type(req.tools) == "table" and #req.tools > 0 then
|
||||
ollama_req.tools = req.tools
|
||||
end
|
||||
|
||||
return json.encode(ollama_req)
|
||||
end
|
||||
|
||||
|
||||
@ -34,8 +34,10 @@ end
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||
-- (image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
|
||||
-- 消息整条丢弃,避免上游 "unknown variant `image_url`, expected `text`"。
|
||||
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||
@ -72,7 +74,24 @@ function adapter.transform_request(raw_body)
|
||||
end
|
||||
end
|
||||
if #parts == 0 then
|
||||
drop = true
|
||||
-- Content collapsed to nothing after stripping unsupported
|
||||
-- parts. A message that still carries a tool call must
|
||||
-- NEVER be dropped: the very next message is its tool
|
||||
-- result, and dropping the call orphans that result. The
|
||||
-- model then sees a result for a call it never made and
|
||||
-- re-issues the same tool call on every turn (observed as
|
||||
-- an infinite "repeated tool call" loop).
|
||||
--
|
||||
-- This is the common shape for agent clients: an assistant
|
||||
-- turn whose content is only [thinking, toolCall] serialises
|
||||
-- to content:[] with tool_calls — exactly the case that used
|
||||
-- to vanish here. Emit "" instead, which zen accepts.
|
||||
if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0)
|
||||
or msg.tool_call_id ~= nil then
|
||||
msg.content = ""
|
||||
else
|
||||
drop = true
|
||||
end
|
||||
else
|
||||
msg.content = parts
|
||||
end
|
||||
|
||||
120
internal/lua/toolcall_preservation_test.go
Normal file
120
internal/lua/toolcall_preservation_test.go
Normal file
@ -0,0 +1,120 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -221,6 +221,70 @@ func TestOpenCodeStripsMultiModalParts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenCodeKeepsToolCallWithEmptyContent pins the fix for an infinite
|
||||
// "repeated tool call" loop: an assistant turn whose content serialises to an
|
||||
// empty ARRAY while carrying tool_calls must survive the multimodal strip.
|
||||
// Dropping it orphans the following tool result, so the model never sees the
|
||||
// call it already made and re-issues it on every turn.
|
||||
func TestOpenCodeKeepsToolCallWithEmptyContent(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
|
||||
body := `{"model":"x","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"},
|
||||
{"role":"user","content":"now summarize"}
|
||||
]}`
|
||||
out, err := vm.Transform("opencode", "transform_request", body)
|
||||
if err != nil {
|
||||
t.Fatalf("transform_request: %v", err)
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"tool_calls"`
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &req); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, out)
|
||||
}
|
||||
if len(req.Messages) != 4 {
|
||||
t.Fatalf("expected 4 messages (tool call must not be dropped), got %d: %s", len(req.Messages), out)
|
||||
}
|
||||
asst := req.Messages[1]
|
||||
if asst.Role != "assistant" || len(asst.ToolCalls) != 1 || asst.ToolCalls[0].ID != "call_abc" {
|
||||
t.Fatalf("assistant tool_calls lost: %s", out)
|
||||
}
|
||||
if string(asst.Content) != `""` {
|
||||
t.Fatalf("empty content must be rewritten to \"\", got %s", asst.Content)
|
||||
}
|
||||
if req.Messages[2].Role != "tool" || req.Messages[2].ToolCallID != "call_abc" {
|
||||
t.Fatalf("tool result must stay paired with its call: %s", out)
|
||||
}
|
||||
|
||||
// Negative control: a message with neither text nor tool calls is still dropped.
|
||||
only := `{"model":"x","messages":[
|
||||
{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://ex.com/a.png"}}]},
|
||||
{"role":"user","content":"keep me"}
|
||||
]}`
|
||||
out2, err := vm.Transform("opencode", "transform_request", only)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Count(out2, `"role"`); got != 1 {
|
||||
t.Fatalf("image-only message without tool calls must still be dropped, got %d: %s", got, out2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
@ -61,6 +62,39 @@ type ChatMessage struct {
|
||||
|
||||
func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b }
|
||||
|
||||
// isEmptyJSONArray reports whether raw is the literal empty JSON array [].
|
||||
func isEmptyJSONArray(raw json.RawMessage) bool {
|
||||
t := bytes.TrimSpace(raw)
|
||||
return len(t) == 2 && t[0] == '[' && t[1] == ']'
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes a chat message, normalising an empty-array content
|
||||
// ("content":[]) to an empty string and dropping an empty tool_calls array.
|
||||
//
|
||||
// Why this exists: Lua adapters json.decode the request and re-encode it, and
|
||||
// an empty Lua table is indistinguishable from an empty JSON array — the
|
||||
// encoder emits {} for both. Agent clients serialise an assistant turn that
|
||||
// carries tool_calls and no text as content:[], so every pass-through adapter
|
||||
// turned it into content:{} — a shape that is not valid OpenAI (content is
|
||||
// string | array of parts | null) and that real upstreams reject with
|
||||
// "400 invalid arguments". Normalising at the decode boundary fixes every
|
||||
// adapter at once, including ones added later.
|
||||
func (m *ChatMessage) UnmarshalJSON(b []byte) error {
|
||||
type alias ChatMessage
|
||||
var a alias
|
||||
if err := json.Unmarshal(b, &a); err != nil {
|
||||
return err
|
||||
}
|
||||
*m = ChatMessage(a)
|
||||
if isEmptyJSONArray(m.Content) {
|
||||
m.Content = StringContent("")
|
||||
}
|
||||
if isEmptyJSONArray(m.ToolCalls) {
|
||||
m.ToolCalls = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
|
||||
@ -68,3 +68,52 @@ func TestTokenUsageMarshalCacheFields(t *testing.T) {
|
||||
t.Logf("DeepSeek JSON: %s", string(b))
|
||||
t.Logf("OpenAI JSON: %s", string(b2))
|
||||
}
|
||||
|
||||
// TestChatMessageNormalizesEmptyArrayContent pins the fix for the malformed
|
||||
// content:{} that pass-through adapters produced for an assistant turn carrying
|
||||
// tool_calls with no text. Lua adapters cannot tell an empty JSON array from an
|
||||
// empty object, so the decode boundary normalises it for every adapter at once.
|
||||
func TestChatMessageNormalizesEmptyArrayContent(t *testing.T) {
|
||||
var msg ChatMessage
|
||||
raw := `{"role":"assistant","content":[],"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read","arguments":"{}"}}]}`
|
||||
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(msg.Content) != `""` {
|
||||
t.Fatalf("empty-array content must normalise to \"\", got %s", msg.Content)
|
||||
}
|
||||
if len(msg.ToolCalls) == 0 {
|
||||
t.Fatal("tool_calls must survive normalisation")
|
||||
}
|
||||
|
||||
// A non-empty content array must be left byte-identical (multimodal path).
|
||||
var mm ChatMessage
|
||||
multi := `{"role":"user","content":[{"type":"text","text":"hi"}]}`
|
||||
if err := json.Unmarshal([]byte(multi), &mm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(mm.Content) != `[{"type":"text","text":"hi"}]` {
|
||||
t.Fatalf("multimodal content must pass through untouched, got %s", mm.Content)
|
||||
}
|
||||
|
||||
// A plain string content is untouched.
|
||||
var sm ChatMessage
|
||||
if err := json.Unmarshal([]byte(`{"role":"user","content":"plain"}`), &sm); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(sm.Content) != `"plain"` {
|
||||
t.Fatalf("string content must pass through untouched, got %s", sm.Content)
|
||||
}
|
||||
|
||||
// An omitted content stays omitted (omitempty semantics preserved).
|
||||
var om ChatMessage
|
||||
if err := json.Unmarshal([]byte(`{"role":"assistant","tool_calls":[]}`), &om); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if om.Content != nil {
|
||||
t.Fatalf("absent content must stay absent, got %s", om.Content)
|
||||
}
|
||||
if om.ToolCalls != nil {
|
||||
t.Fatalf("empty tool_calls array must be dropped, got %s", om.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user