mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "modelrouter-gui",
|
"name": "modelrouter-gui",
|
||||||
"version": "1.4.2",
|
"version": "1.5.2",
|
||||||
"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.2",
|
||||||
"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",
|
||||||
|
|||||||
@ -115,13 +115,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 +154,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 +179,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 +221,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user