mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +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",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.4.2",
|
"version": "1.5.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.4.2",
|
"version": "1.5.3",
|
||||||
"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.4.2",
|
"version": "1.5.3",
|
||||||
"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",
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"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
|
// filterCandsByModels keeps only providers exposing at least one model of the
|
||||||
// scope (used for user keys with a restricted model scope). An "AUTO" scope
|
// scope (used for user keys with a restricted model scope). Scope entries with
|
||||||
// entry means the key is allowed to use any model. Scope entries with a
|
// a Source pinned to a specific upstream narrow the candidates to that source
|
||||||
// Source pinned to a specific upstream narrow the candidates to that source
|
|
||||||
// for the matching model.
|
// 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 {
|
func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope) []*provider.Provider {
|
||||||
for _, m := range allow {
|
for _, m := range allow {
|
||||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
if m.Model == "" {
|
||||||
return cands
|
return cands
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -152,20 +155,18 @@ func filterCandsByModels(cands []*provider.Provider, allow []config.ModelScope)
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// intersectModels restricts a model list to the scope (preserving order). An
|
// intersectModels restricts a model list to the scope (preserving order).
|
||||||
// "AUTO" scope entry grants every model.
|
// An "AUTO" scope entry only allows the AUTO routing mode; it does NOT grant
|
||||||
|
// every model.
|
||||||
func intersectModels(models []string, allow []config.ModelScope) []string {
|
func intersectModels(models []string, allow []config.ModelScope) []string {
|
||||||
allowed := make(map[string]bool, len(allow))
|
allowed := make(map[string]bool, len(allow))
|
||||||
any := false
|
|
||||||
for _, m := range allow {
|
for _, m := range allow {
|
||||||
if m.Model == "" || strings.EqualFold(m.Model, "AUTO") {
|
if m.Model == "" {
|
||||||
any = true
|
return models
|
||||||
break
|
}
|
||||||
|
if !strings.EqualFold(m.Model, "AUTO") {
|
||||||
|
allowed[m.Model] = true
|
||||||
}
|
}
|
||||||
allowed[m.Model] = true
|
|
||||||
}
|
|
||||||
if any {
|
|
||||||
return models
|
|
||||||
}
|
}
|
||||||
out := make([]string, 0, len(models))
|
out := make([]string, 0, len(models))
|
||||||
seen := map[string]bool{}
|
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
|
// checkModelScope validates the effective model against the key's model scope
|
||||||
// and token quota. Returns an error message when rejected. A scope entry with
|
// and token quota. Returns an error message when rejected.
|
||||||
// model "AUTO" grants all models; its quota caps the key's total tokens.
|
//
|
||||||
|
// 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 {
|
func (g *Gateway) checkModelScope(ctx context.Context, model string) string {
|
||||||
allow := g.allowedModels(ctx)
|
allow := g.allowedModels(ctx)
|
||||||
if allow == nil {
|
if allow == nil {
|
||||||
return ""
|
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 {
|
for _, sc := range allow {
|
||||||
if sc.Model != model {
|
if sc.Model != model {
|
||||||
continue
|
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
|
// source that actually serves the bare model (via Registry.EffectiveModel), so
|
||||||
// model ids that themselves contain separators (e.g. "deepseek-v4-flash-free")
|
// model ids that themselves contain separators (e.g. "deepseek-v4-flash-free")
|
||||||
// are never corrupted (P10-2).
|
// 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 {
|
func (g *Gateway) hasScopeModel(list []config.ModelScope, s string) bool {
|
||||||
if r := g.core.Registry(); r != nil {
|
if r := g.core.Registry(); r != nil {
|
||||||
s = r.EffectiveModel(s)
|
s = r.EffectiveModel(s)
|
||||||
}
|
}
|
||||||
for _, x := range list {
|
for _, x := range list {
|
||||||
if x.Model == s || (x.Model != "" && strings.EqualFold(x.Model, "AUTO")) {
|
if x.Model == s {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -517,21 +513,67 @@ func upstreamErrStatus(err error) int {
|
|||||||
return http.StatusBadGateway
|
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
|
// clientUpstreamErr collapses upstream failure details into a short message
|
||||||
// for the client: per-tier bodies (WAF HTML pages, quota payloads, ...) stay
|
// 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.
|
// 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
|
// 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.
|
// and are the actionable part); each is capped so HTML dumps can't leak.
|
||||||
|
//
|
||||||
|
// 超窗会被归一化成 overflowCanonical 前缀,见 overflowMarkers 的说明。
|
||||||
func clientUpstreamErr(err error) string {
|
func clientUpstreamErr(err error) string {
|
||||||
log.Printf("[gateway] upstream failure surfaced to client: %v", err)
|
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
|
var ce *scheduler.ChainErr
|
||||||
if errors.As(err, &ce) {
|
if errors.As(err, &ce) {
|
||||||
parts := make([]string, 0, len(ce.Tiers)+len(ce.Skipped))
|
parts := make([]string, 0, len(ce.Tiers)+len(ce.Skipped))
|
||||||
for _, t := range ce.Tiers {
|
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 {
|
for _, sk := range ce.Skipped {
|
||||||
parts = append(parts, types.OneLine(sk, 80))
|
parts = append(parts, types.OneLine(sk, 160))
|
||||||
}
|
}
|
||||||
msg := strings.Join(parts, "; ")
|
msg := strings.Join(parts, "; ")
|
||||||
if len(msg) > 300 {
|
if len(msg) > 300 {
|
||||||
|
|||||||
@ -670,8 +670,11 @@ func TestHasScopeModelWithSourcePrefix(t *testing.T) {
|
|||||||
if g.hasScopeModel(scope, "deepseek-v4-flash-free-extra") {
|
if g.hasScopeModel(scope, "deepseek-v4-flash-free-extra") {
|
||||||
t.Error("hasScopeModel returned true for unrelated model")
|
t.Error("hasScopeModel returned true for unrelated model")
|
||||||
}
|
}
|
||||||
if !g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:anything") {
|
if g.hasScopeModel([]config.ModelScope{{Model: "AUTO"}}, "zen:deepseek-v4-flash-free") {
|
||||||
t.Error("AUTO scope should allow any prefixed model")
|
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.endpoint = "/chat/completions"
|
||||||
adapter.headers = {}
|
adapter.headers = {}
|
||||||
|
|
||||||
-- AgentRouter 的 WAF 会按客户端指纹白名单校验,只放行官方客户端。
|
-- AgentRouter 的阿里云 WAF 按三重维度校验:出口 IP(必须海外)、
|
||||||
-- 这里通过 build_headers 注入官方客户端 User-Agent 以通过校验。
|
-- TLS 指纹(JS/Go HTTP 层与 curl 不同)、浏览器指纹 header 集。
|
||||||
-- 默认 QwenCode 指纹(实测可通);可在 source.meta.user_agent 覆盖成 Claude Code 指纹。
|
-- 纯 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)"
|
local default_ua = "QwenCode/0.2.0 (linux; x64)"
|
||||||
|
|
||||||
-- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams
|
-- AgentRouter fronts Claude models (claude-opus-4-8), and Claude upstreams
|
||||||
@ -65,6 +67,13 @@ function adapter.build_headers(meta)
|
|||||||
["Content-Type"] = "application/json",
|
["Content-Type"] = "application/json",
|
||||||
["Authorization"] = "Bearer " .. meta.api_key,
|
["Authorization"] = "Bearer " .. meta.api_key,
|
||||||
["User-Agent"] = ua,
|
["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
|
end
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,10 @@ adapter.headers = {}
|
|||||||
|
|
||||||
-- Gemini API: POST /v1/models/{model}:generateContent
|
-- Gemini API: POST /v1/models/{model}:generateContent
|
||||||
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
-- Auth: API key in query param ?key=XXX or Authorization: Bearer XXX
|
||||||
|
--
|
||||||
|
-- 请求方向必须把 OpenAI 的工具调用翻译成 Gemini 的 functionCall /
|
||||||
|
-- functionResponse,否则 agent 回放的历史里助手那一轮的调用会凭空消失,
|
||||||
|
-- 紧随其后的工具结果就成了「无来源」的孤立结果,模型只能反复重发同一个调用。
|
||||||
function adapter.transform_request(raw_body)
|
function adapter.transform_request(raw_body)
|
||||||
local ok, req = pcall(json.decode, raw_body)
|
local ok, req = pcall(json.decode, raw_body)
|
||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
@ -14,16 +18,23 @@ function adapter.transform_request(raw_body)
|
|||||||
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
-- 将 OpenAI 风格 content(字符串或 [{type:*}] 数组)拆成 Gemini parts
|
||||||
local function to_parts(content)
|
local function to_parts(content)
|
||||||
if type(content) == "string" then
|
if type(content) == "string" then
|
||||||
|
if content == "" then return {} end
|
||||||
return { { text = content } }
|
return { { text = content } }
|
||||||
end
|
end
|
||||||
local parts = {}
|
local parts = {}
|
||||||
for _, p in ipairs(content or {}) do
|
for _, p in ipairs(content or {}) do
|
||||||
if p.type == "text" then
|
if type(p) == "string" then
|
||||||
table.insert(parts, { text = p.text })
|
if p ~= "" then table.insert(parts, { text = p }) end
|
||||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
elseif type(p) == "table" then
|
||||||
local mt, b64 = string.match(p.image_url.url, "^data:([^,]+);base64,(.+)$")
|
if p.type == "text" then
|
||||||
if b64 then
|
if p.text ~= nil and p.text ~= "" then
|
||||||
table.insert(parts, { inline_data = { mime_type = mt or "image/png", data = b64 } })
|
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
|
end
|
||||||
end
|
end
|
||||||
@ -31,11 +42,59 @@ function adapter.transform_request(raw_body)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local contents = {}
|
local contents = {}
|
||||||
|
local system = ""
|
||||||
|
local call_names = {} -- tool_call_id -> 函数名(functionResponse 只认名字)
|
||||||
|
|
||||||
for _, m in ipairs(req.messages or {}) do
|
for _, m in ipairs(req.messages or {}) do
|
||||||
table.insert(contents, {
|
local role = m.role or "user"
|
||||||
role = (m.role == "assistant") and "model" or m.role,
|
|
||||||
parts = to_parts(m.content)
|
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
|
end
|
||||||
|
|
||||||
local gemini_req = {
|
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
|
if req.stream then
|
||||||
gemini_req.stream = true
|
gemini_req.stream = true
|
||||||
end
|
end
|
||||||
|
|||||||
@ -6,6 +6,10 @@ adapter.endpoint = "/api/chat"
|
|||||||
adapter.headers = {}
|
adapter.headers = {}
|
||||||
|
|
||||||
-- Ollama API 格式:{ model, messages, stream, options:{temperature,num_predict} }
|
-- 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)
|
function adapter.transform_request(raw_body)
|
||||||
local ok, req = pcall(json.decode, raw_body)
|
local ok, req = pcall(json.decode, raw_body)
|
||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
@ -22,6 +26,7 @@ function adapter.transform_request(raw_body)
|
|||||||
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
|
-- 转换 messages 格式(Ollama messages 支持 images base64 数组)
|
||||||
if req.messages then
|
if req.messages then
|
||||||
local msgs = {}
|
local msgs = {}
|
||||||
|
local call_names = {} -- tool_call_id -> 函数名
|
||||||
for _, m in ipairs(req.messages) do
|
for _, m in ipairs(req.messages) do
|
||||||
local text, images
|
local text, images
|
||||||
if type(m.content) == "string" then
|
if type(m.content) == "string" then
|
||||||
@ -30,22 +35,61 @@ function adapter.transform_request(raw_body)
|
|||||||
text = ""
|
text = ""
|
||||||
images = {}
|
images = {}
|
||||||
for _, p in ipairs(m.content or {}) do
|
for _, p in ipairs(m.content or {}) do
|
||||||
if p.type == "text" then
|
if type(p) == "table" then
|
||||||
text = text .. (p.text or "")
|
if p.type == "text" then
|
||||||
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
text = text .. (p.text or "")
|
||||||
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
elseif p.type == "image_url" and type(p.image_url) == "table" and p.image_url.url then
|
||||||
if b64 then table.insert(images, b64) end
|
local b64 = string.match(p.image_url.url, "^data:[^,]+;base64,(.+)$")
|
||||||
|
if b64 then table.insert(images, b64) end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #images == 0 then images = nil end
|
if #images == 0 then images = nil end
|
||||||
end
|
end
|
||||||
local msg = { role = m.role, content = text }
|
local msg = { role = m.role, content = text }
|
||||||
if images then msg.images = images end
|
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)
|
table.insert(msgs, msg)
|
||||||
end
|
end
|
||||||
ollama_req.messages = msgs
|
ollama_req.messages = msgs
|
||||||
end
|
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)
|
return json.encode(ollama_req)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -34,8 +34,10 @@ end
|
|||||||
|
|
||||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||||
-- (image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
|
-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息
|
||||||
-- 消息整条丢弃,避免上游 "unknown variant `image_url`, expected `text`"。
|
-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游
|
||||||
|
-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留,
|
||||||
|
-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。
|
||||||
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder:
|
||||||
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游
|
||||||
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。
|
||||||
@ -72,7 +74,24 @@ function adapter.transform_request(raw_body)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if #parts == 0 then
|
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
|
else
|
||||||
msg.content = parts
|
msg.content = parts
|
||||||
end
|
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) {
|
func TestBuildHeadersCustomHook(t *testing.T) {
|
||||||
vm := NewVM(freshAdapterDir(t))
|
vm := NewVM(freshAdapterDir(t))
|
||||||
if err := vm.Start(); err != nil {
|
if err := vm.Start(); err != nil {
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
@ -61,6 +62,39 @@ type ChatMessage struct {
|
|||||||
|
|
||||||
func StringContent(s string) json.RawMessage { b, _ := json.Marshal(s); return b }
|
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 {
|
type ToolCall struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
|||||||
@ -68,3 +68,52 @@ func TestTokenUsageMarshalCacheFields(t *testing.T) {
|
|||||||
t.Logf("DeepSeek JSON: %s", string(b))
|
t.Logf("DeepSeek JSON: %s", string(b))
|
||||||
t.Logf("OpenAI JSON: %s", string(b2))
|
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