mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5923b3797 |
@ -55,3 +55,31 @@ func TestBuddyCatalogFilterDropsNonChatModels(t *testing.T) {
|
||||
}
|
||||
t.Log("✓ buddy 目录过滤四类规则齐备,且未误伤 default-model 类别名")
|
||||
}
|
||||
|
||||
// ★ 已实测**不可调用**的模型必须也从目录里排除 —— 包括 agentRef 例外路径。
|
||||
//
|
||||
// 真实缺陷:`hy4-preview-x` 被服务端 agents 引用,于是 agentRef 例外把它放回
|
||||
// 目录;而它对上游**恒回 11102**(`model service info not found`,加/不加
|
||||
// reasoning_effort 都一样 ⇒ 与 effort 无关,是后端未开放)。
|
||||
// 用户选中就 400,错误文案指向模型 ⇒ 极易误判"模型坏了"。
|
||||
//
|
||||
// 参考用**白名单重建**(product.ts 的兜底表里没有它)达到同样效果。
|
||||
func TestBuddyCatalogDropsKnownUncallable(t *testing.T) {
|
||||
src, err := readBundledChannels(t)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(src)
|
||||
|
||||
if !strings.Contains(s, "local NOT_CALLABLE = {") {
|
||||
t.Fatal("必须有已实测不可调用的排除表(等价于参考的白名单)")
|
||||
}
|
||||
if !strings.Contains(s, `["hy4-preview-x"] = true`) {
|
||||
t.Error("hy4-preview-x 实测恒 11102,必须排除")
|
||||
}
|
||||
// 闸门必须生效在**所有**入口 —— 尤其是 agentRef 例外之后
|
||||
if !strings.Contains(s, "if NOT_CALLABLE[id] then return false end") {
|
||||
t.Error("NOT_CALLABLE 闸门必须在 buddyIsChatModel 内(覆盖 models 与 agentRef 两条路径)")
|
||||
}
|
||||
t.Log("✓ 不可调用模型已排除,且 agentRef 例外也过闸")
|
||||
}
|
||||
|
||||
116
internal/lua/adapter_buddy_test.go
Normal file
116
internal/lua/adapter_buddy_test.go
Normal file
@ -0,0 +1,116 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// loadBuddyAdapter 装载内置 buddy 适配器。
|
||||
func loadBuddyAdapter(t *testing.T) *VM {
|
||||
t.Helper()
|
||||
src, err := bundledAdapters.ReadFile("adapters/buddy.lua")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vm := NewVM(t.TempDir())
|
||||
t.Cleanup(vm.Stop)
|
||||
if err := vm.LoadAdapterSource("buddy", string(src)); err != nil {
|
||||
t.Fatalf("装载失败: %v", err)
|
||||
}
|
||||
return vm
|
||||
}
|
||||
|
||||
// ★ buddy 适配器:两个厂商要求,各有实测依据。
|
||||
//
|
||||
// ① glm-5.1 **必须**带非空 reasoning_effort(否则上游 400 + 11150)
|
||||
// ② 流式请求必须带 Accept: text/event-stream(否则只回空帧、completion_tokens=0)
|
||||
func TestBuddyAdapterEffortAndHeaders(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
|
||||
// ① glm-5.1 → 补 medium
|
||||
out, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &b); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if b["reasoning_effort"] != "medium" {
|
||||
t.Errorf("glm-5.1 必须补 reasoning_effort=medium(否则上游 11150),实得 %v", b["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ② 其它模型**不补**(避免把单模型要求扩成全平台行为)
|
||||
out2, err := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"hy3","messages":[{"role":"user","content":"hi"}],"max_tokens":100,"stream":true}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b2 map[string]interface{}
|
||||
json.Unmarshal([]byte(out2), &b2)
|
||||
if b2["reasoning_effort"] != nil {
|
||||
t.Errorf("hy3 不该被补 reasoning_effort(实测不需要),实得 %v", b2["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ③ 调用方已给档位时**尊重它**
|
||||
out3, _ := vm.Transform("buddy", "transform_request",
|
||||
`{"model":"glm-5.1","messages":[{"role":"user","content":"hi"}],"reasoning_effort":"high","stream":true}`)
|
||||
var b3 map[string]interface{}
|
||||
json.Unmarshal([]byte(out3), &b3)
|
||||
if b3["reasoning_effort"] != "high" {
|
||||
t.Errorf("调用方给的档位必须保留,实得 %v", b3["reasoning_effort"])
|
||||
}
|
||||
|
||||
// ④ 头:Accept 必须是 SSE
|
||||
h, err := vm.BuildHeaders("buddy", map[string]interface{}{"api_key": "TOK"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h["Accept"] != "text/event-stream" {
|
||||
t.Errorf("Accept 必须是 text/event-stream(否则上游只回空帧),实得 %q", h["Accept"])
|
||||
}
|
||||
for _, k := range []string{"X-Domain", "X-Product", "X-Product-Code"} {
|
||||
if h[k] == "" {
|
||||
t.Errorf("缺少 buddy 身份头 %s", k)
|
||||
}
|
||||
}
|
||||
t.Logf("✓ glm-5.1 补 effort;hy3 不补;Accept=%s", h["Accept"])
|
||||
}
|
||||
|
||||
// 配对判据:协议转换必须与 openai 一致(buddy 只是补两个字段,不该改转换)。
|
||||
func TestBuddyAdapterKeepsOpenAIWire(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
chunk := `{"id":"x","object":"chat.completion.chunk","created":1,"model":"m","choices":[{"index":0,"delta":{"content":"hi","reasoning_content":"think"},"finish_reason":null}]}`
|
||||
out, err := vm.Transform("buddy", "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, `"content":"hi"`) || !strings.Contains(out, `"reasoning_content":"think"`) {
|
||||
t.Errorf("流式转换必须保留 content 与 reasoning_content,实得 %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 回归护栏:适配器**必须**定义全部四个转换函数。
|
||||
//
|
||||
// 真实事故(2026-10-16):我第一版把基座继承写成"在适配器里读兄弟适配器源码",
|
||||
// 而适配器运行在**另一个沙箱 VM**(无 __llmsproxy_plugin_dir、无文件系统),
|
||||
// 于是基座加载**静默失败** ⇒ 适配器只有 transform_request,
|
||||
// **缺 transform_stream_chunk** ⇒ 内核无法解析 SSE ⇒ 上游明明回了 39 帧正文,
|
||||
// 网关却记 completion_tokens=0、客户端看到**空回复**。
|
||||
// 更糟的是:hy3/glm-5.3/kimi-k3-1 全被它弄坏,而 glm-5.2 因多源兜底
|
||||
// "看起来正常" ⇒ 症状指向错误方向。
|
||||
//
|
||||
// ⇒ 判据必须咬住「四个转换函数都存在」,而不是"某个模型能回话"
|
||||
//
|
||||
// (后者会被别的源兜底掩盖 —— 本会话已栽过两次)。
|
||||
func TestBuddyAdapterDefinesAllTransforms(t *testing.T) {
|
||||
vm := loadBuddyAdapter(t)
|
||||
for _, fn := range []string{"transform_request", "transform_response", "transform_stream_chunk", "transform_error"} {
|
||||
if !vm.HasFn("buddy", fn) {
|
||||
t.Errorf("适配器缺 %s ⇒ 内核该路径会静默降级(流式会变成空回复)", fn)
|
||||
}
|
||||
}
|
||||
t.Log("✓ 四个转换函数齐备")
|
||||
}
|
||||
201
internal/lua/adapters/buddy.lua
Normal file
201
internal/lua/adapters/buddy.lua
Normal file
@ -0,0 +1,201 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "buddy"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- Claude-behind-OpenAI upstreams (tabitoken, 扇贝, …) convert /chat/completions
|
||||
-- to the Anthropic Messages API internally, so they inherit Anthropic's
|
||||
-- tool id rule: ^[a-zA-Z0-9_-]{1,64}$, enforced by rejecting the WHOLE request
|
||||
-- with "Invalid tool use format" / "tool_use.id: String should match pattern".
|
||||
-- Plain OpenAI has no such rule, so an OpenAI-compatible model can mint an id
|
||||
-- like "bash:0" (observed from moonshotai/kimi-k3). In a fan-out router that id
|
||||
-- is replayed to every other source, so one such id kills every Claude slot at
|
||||
-- once and an AUTO request falls through all tiers.
|
||||
--
|
||||
-- safe_tool_id is pure and deterministic, so a tool_calls entry and its
|
||||
-- matching tool_call_id are rewritten identically within one request. A
|
||||
-- rewritten id keeps an 8-hex digest of the ORIGINAL id, without which two
|
||||
-- distinct ids could collapse into one ("a:b" and "a_b") and become an
|
||||
-- unpaired/duplicate tool call. Already-legal ids are returned untouched, so
|
||||
-- well-behaved traffic is byte-identical to before.
|
||||
-- (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
|
||||
local TOOL_ID_MAX = 64
|
||||
|
||||
local function safe_tool_id(id)
|
||||
if type(id) ~= "string" or id == "" then return id end
|
||||
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
|
||||
if clean == id and #clean <= TOOL_ID_MAX then
|
||||
return clean
|
||||
end
|
||||
local digest = string.sub(sha256_hex(id), 1, 8)
|
||||
local keep = TOOL_ID_MAX - #digest - 1
|
||||
if #clean > keep then clean = string.sub(clean, 1, keep) end
|
||||
return clean .. "_" .. digest
|
||||
end
|
||||
|
||||
-- 需要补 reasoning_effort 的模型 → 补哪个档位。
|
||||
--
|
||||
-- 唯一出处:参考 product.ts:237 声明 glm-5.1 的 reasoningEfforts 只有 ['medium']。
|
||||
-- 但实测**任何非空档位都被接受**,故这里取 medium(与参考声明一致)。
|
||||
local NEED_EFFORT = {
|
||||
["glm-5.1"] = "medium",
|
||||
}
|
||||
|
||||
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
-- The gateway's private control fields are GONE from the body (v2): the
|
||||
-- neutral `thinking` field carries that intent and each adapter maps it in
|
||||
-- its own protocol terms. These upstreams have no thinking switch at all,
|
||||
-- so the field is simply dropped here — which is what the nil-ing below
|
||||
-- used to do, except the field being dropped is no longer the kernel's
|
||||
-- private one.
|
||||
req.thinking = nil
|
||||
req.extra_body = nil
|
||||
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
|
||||
-- 否则上游回 400:
|
||||
-- {"code":11150,"msg":"the reasoning effort value is not supported
|
||||
-- by the current model"}
|
||||
-- 实测(2026-10-16,逐字对照实验):
|
||||
-- glm-5.1 裸(无该字段) → 400 11150 ✗
|
||||
-- glm-5.1 + reasoning_effort:medium → 200 ✓
|
||||
-- glm-5.1 空串 → 400 11150 ✗
|
||||
-- 任何**非空**档位均可(low/medium/high/xhigh/max/minimal 逐个 200)
|
||||
-- 而其余 15 个 buddy 模型裸跑全 200(多带该字段也无害)—— 即这是
|
||||
-- glm-5.1 **独有**的要求。
|
||||
-- ⚠️ 只对**已实测需要**的模型补(而不是一律补):一律补会把"某个模型的
|
||||
-- 特殊要求"扩成"全平台行为",将来上游若对某些模型收紧就会一次性弄坏
|
||||
-- 所有模型。
|
||||
if NEED_EFFORT[req.model or ""] and
|
||||
not (type(req.reasoning_effort) == "string" and req.reasoning_effort ~= "") and
|
||||
req.reasoning_effort == nil then
|
||||
req.reasoning_effort = NEED_EFFORT[req.model]
|
||||
end
|
||||
if req.messages then
|
||||
for _, msg in ipairs(req.messages) do
|
||||
msg.reasoning_content = nil
|
||||
if msg.tool_call_id ~= nil then
|
||||
msg.tool_call_id = safe_tool_id(msg.tool_call_id)
|
||||
end
|
||||
if type(msg.tool_calls) == "table" then
|
||||
for _, tc in ipairs(msg.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
-- OpenAI-style streams may attach usage to a chunk with empty choices
|
||||
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
|
||||
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
|
||||
local uses = nil
|
||||
if type(chunk.usage) == "table" then
|
||||
uses = {
|
||||
prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0,
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens }
|
||||
elseif (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||||
uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens
|
||||
uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then
|
||||
if uses ~= nil then
|
||||
-- usage-only chunk is not a content/finish signal; the gateway
|
||||
-- emits its own terminal stop chunk and merges this usage.
|
||||
return json.encode({ usage = uses, done = false })
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
local finish = (type(fr) == "string" and fr ~= "") and fr or nil
|
||||
|
||||
local unified = {
|
||||
content = delta.content or "",
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
-- Sanitize on the way OUT too: an id this upstream happily minted (it
|
||||
-- does not validate them) becomes a landmine once the client replays
|
||||
-- it to a Claude upstream. Only the first fragment of a streamed call
|
||||
-- carries an id; later argument fragments have none and are untouched.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
end
|
||||
end
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||||
return __prelude.openai_error(status, body)
|
||||
end
|
||||
|
||||
|
||||
-- ★ build_headers:buddy 要求客户端身份头 + 流式的 Accept。
|
||||
--
|
||||
-- 实测(2026-10-16):不发 `Accept: text/event-stream` 时上游接受请求(200)
|
||||
-- 但只回一帧空内容(completion_tokens=0),既不报错也不产出正文 —— 表现是
|
||||
-- "模型不回答",极易误判成模型坏了或额度不足。
|
||||
-- 参考 buddy-adapter.ts 的 send() 固定发该头。
|
||||
--
|
||||
-- 身份头照参考 credentialRequestHeaders + CODEBUDDY 产品配置:
|
||||
-- X-Domain 用产品域(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
-- 跨产品迁移后会指错区);X-Product 是**部署类型**(SaaS);X-Product-Code
|
||||
-- 是产品码(codebuddy)。
|
||||
function adapter.build_headers(meta)
|
||||
local h = {
|
||||
["Accept"] = "text/event-stream",
|
||||
["Content-Type"] = "application/json",
|
||||
["X-Domain"] = "copilot.tencent.com",
|
||||
["X-Product"] = "SaaS",
|
||||
["X-Product-Code"] = "codebuddy",
|
||||
["User-Agent"] = "CodeBuddyIDE/1.106.1",
|
||||
}
|
||||
-- Authorization 由内核在 buildHeaders 之后补(值为源的 api_key)——
|
||||
-- 这里显式给出同一份,保证与其它 buddy 请求形态一致。
|
||||
if type(meta) == "table" and type(meta.api_key) == "string" and meta.api_key ~= "" then
|
||||
h["Authorization"] = "Bearer " .. meta.api_key
|
||||
end
|
||||
return h
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -183,7 +183,7 @@ local PLATFORMS = {
|
||||
adapter = "channels-trae",
|
||||
-- 照参考 TRAE_FALLBACK_MODELS 全量(32 项)。其中 4 项带 isHidden
|
||||
-- (内部子 agent)—— 不进用户可选列表,单列在 models_hidden。
|
||||
models = {
|
||||
models = {
|
||||
"DeepSeek-V4-Flash-Official", "Doubao-Seed-2.1-Pro", "seed-code-pro-0430",
|
||||
"Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.0-Code", "glm-5.2", "glm-5-turbo",
|
||||
"glm-5", "DeepSeek-V4-Pro", "DeepSeek-V4-Flash", "kimi-k3", "kimi-k2.7-code",
|
||||
@ -435,7 +435,11 @@ local PLATFORMS = {
|
||||
headers_fn = true,
|
||||
parse = "buddy_config",
|
||||
},
|
||||
adapter = "openai",
|
||||
-- ★ 用内置 buddy 适配器(而非通用 openai):buddy 的 glm-5.1 要求请求体里
|
||||
-- 必须有非空 reasoning_effort(否则 400 + 11150),且流式请求必须带
|
||||
-- Accept: text/event-stream(否则上游只回空帧、completion_tokens=0)。
|
||||
-- 该适配器复用 openai 的全部转换,只补这两处厂商要求。
|
||||
adapter = "buddy",
|
||||
-- 计费/状态请求头:逐行移植参考 checkinHeaders(credits.ts)。
|
||||
-- 实测只发 Authorization 会被上游 401:buddy 要求身份标识头齐全。
|
||||
-- X-Domain 以产品配置为准(不用凭据里的 domain 快照,那是登录时的区域,
|
||||
@ -461,7 +465,10 @@ local PLATFORMS = {
|
||||
return h
|
||||
end,
|
||||
models = {
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview", "hy4-preview-x",
|
||||
-- 兜底目录(远端不可用时才用)。照参考 CODEBUDDY_FALLBACK_MODELS 的取舍:
|
||||
-- **只列实测可调用的**。hy4-preview-x 已实测恒 11102(后端未开放)故剔除
|
||||
-- —— 参考的兜底表里同样没有它(它靠白名单达到同样效果)。
|
||||
"deepseek-v4-flash", "deepseek-v4-pro", "hy4-preview",
|
||||
"glm-5.3", "glm-5.3-flash", "glm-5.2",
|
||||
},
|
||||
-- 签到状态(对齐参考 credits.ts:必须用 checkin-activity-status,不能用
|
||||
@ -3317,7 +3324,23 @@ local function parseRemoteModels(kind, body, provider)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- ★ 已实测**不可调用**的模型(上游恒回 11102 "model service info not found")。
|
||||
--
|
||||
-- 为什么需要它:这类模型既**不是**前缀可判(不像 nes-/codewise-),也**不在**
|
||||
-- agent 引用清单之外 —— 实测 `hy4-preview-x` 被服务端 agents 引用,
|
||||
-- 于是会被下面的 agentRef 例外重新放回目录,用户选中就 400。
|
||||
-- 参考的做法是**白名单重建**(只保留编译期实测过的 id),本仓没有那份表,
|
||||
-- 故用等价的**黑名单**精确排除:只放实测确认调不通的,不做任何猜测。
|
||||
--
|
||||
-- ⚠️ 判据来源必须是**真实请求**,不是元数据:
|
||||
-- 实测 hy4-preview-x 加/不加 reasoning_effort 都是 11102 ⇒ 与 effort 无关,
|
||||
-- 是后端未开放(与 glm-5.1 的 11150 是**不同**原因,后者已被 adapter 修好)。
|
||||
-- 参考 product.ts 的兜底白名单里同样**没有** hy4-preview-x。
|
||||
local NOT_CALLABLE = {
|
||||
["hy4-preview-x"] = true,
|
||||
}
|
||||
local function buddyIsChatModel(id)
|
||||
if NOT_CALLABLE[id] then return false end
|
||||
if id == "auto" or id == "default" then return false end
|
||||
if id:sub(1, 4) == "nes-" or id:sub(1, 11) == "completion-"
|
||||
or id:sub(1, 9) == "codewise-" then return false end
|
||||
@ -3349,6 +3372,8 @@ local function parseRemoteModels(kind, body, provider)
|
||||
if type(id) == "string" then pushId(id) end
|
||||
end
|
||||
end
|
||||
-- agentRef 的例外也必须过 NOT_CALLABLE 闸:否则"被 agent 引用"会把
|
||||
-- 实测不可调的模型重新放回目录(hy4-preview-x 正是这样漏进来的)。
|
||||
for id in pairs(agentRef) do pushId(id) end
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user