mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
fix(channels): 四渠道修复 —— qoder 裸 token 领取 / trae+buddy 非流式 / opencode 目录 / 隐藏适配器
生产实测逐渠道排查(2026-10-19):
* qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串;
qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test
走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包
{access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。
* trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧),
transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报
invalid character 'e'。补 aggregate_solo_sse 聚合。
* buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制
stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。
* provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为
tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。
* opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML);
兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。
opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹
分桶),保留免费层门禁四要素。
* 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的
隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。
* channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号
闪现在 opencode 卡片下的渲染竞态。
* channels request_end hook:挂接丢失的 isModelGone/recordDeadModel ——
真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进
deadModels,不再每轮 404 仍可选中。
This commit is contained in:
@ -55,6 +55,13 @@ function adapter.transform_request(raw_body)
|
||||
-- private one.
|
||||
req.thinking = nil
|
||||
req.extra_body = nil
|
||||
-- ★★ buddy(腾讯 CodeBuddy)上游**只支持流式**:body.stream=false 会被
|
||||
-- 400 拒绝(实测 2026-10-19):
|
||||
-- 11101: Non-stream chat request is currently not supported
|
||||
-- 与 Accept: text/event-stream 头无关(头早已在发),判据是 body。
|
||||
-- 故强制 stream=true,非流式请求由内核的"客户端要非流式、上游回流式"
|
||||
-- 路径消化(transform_response 需能聚合 SSE,见下)。
|
||||
req.stream = true
|
||||
-- ★★ buddy 的 glm-5.1 **要求请求体里有非空的 reasoning_effort**,
|
||||
-- 否则上游回 400:
|
||||
-- {"code":11150,"msg":"the reasoning effort value is not supported
|
||||
@ -92,7 +99,92 @@ function adapter.transform_request(raw_body)
|
||||
return json.encode(req)
|
||||
end
|
||||
|
||||
-- aggregate_sse(raw):把上游 SSE(data: 行流)聚合成一个非流式 OpenAI 响应。
|
||||
-- 强制 stream=true 后,内核的非流式路径会在这里收到完整 SSE 原文——必须聚合,
|
||||
-- 否则 json.decode 失败原样透传,Go 侧 unmarshal 报 invalid character 'd'。
|
||||
-- 聚合 content / reasoning_content / tool_calls(按 index 拼接分片)/
|
||||
-- finish_reason / usage;无可解析帧时返回 nil(调用方回退原样)。
|
||||
local function aggregate_sse(raw)
|
||||
local content, reasoning = {}, {}
|
||||
local finish, usage, id, model, created = nil, nil, nil, nil, nil
|
||||
local tools_by_index = {}
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
id = id or d.id
|
||||
model = model or d.model
|
||||
created = created or d.created
|
||||
if type(d.usage) == "table" then usage = d.usage end
|
||||
local ch = d.choices and d.choices[1]
|
||||
if type(ch) == "table" then
|
||||
if type(ch.finish_reason) == "string" and ch.finish_reason ~= "" then
|
||||
finish = ch.finish_reason
|
||||
end
|
||||
local dl = ch.delta
|
||||
if type(dl) == "table" then
|
||||
if type(dl.content) == "string" then content[#content + 1] = dl.content end
|
||||
if type(dl.reasoning_content) == "string" then
|
||||
reasoning[#reasoning + 1] = dl.reasoning_content
|
||||
end
|
||||
if type(dl.tool_calls) == "table" then
|
||||
for _, tc in ipairs(dl.tool_calls) do
|
||||
if type(tc) == "table" and tc.index ~= nil then
|
||||
local slot = tools_by_index[tc.index]
|
||||
if slot == nil then
|
||||
slot = { id = tc.id, type = tc.type or "function",
|
||||
["function"] = { name = "", arguments = "" } }
|
||||
tools_by_index[tc.index] = slot
|
||||
end
|
||||
if tc.id then slot.id = tc.id end
|
||||
local fn = tc["function"]
|
||||
if type(fn) == "table" then
|
||||
if fn.name then slot["function"].name = slot["function"].name .. fn.name end
|
||||
if fn.arguments then slot["function"].arguments = slot["function"].arguments .. fn.arguments end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local msg = { role = "assistant", content = table.concat(content) }
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then msg.reasoning_content = rc end
|
||||
local ordered = {}
|
||||
for i = 0, 63 do
|
||||
if tools_by_index[i] then ordered[#ordered + 1] = tools_by_index[i] end
|
||||
end
|
||||
if #ordered > 0 then msg.tool_calls = ordered end
|
||||
local out = {
|
||||
id = id or "", object = "chat.completion", created = created or 0,
|
||||
model = model or "",
|
||||
choices = { { index = 0, message = msg,
|
||||
finish_reason = (finish and finish ~= "") and finish or "stop" } },
|
||||
}
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- 上游只会回流式(transform_request 强制 stream=true)。客户端要非流式时
|
||||
-- 内核把完整 SSE 原文交到这里,先聚合再走统一转换。
|
||||
if type(raw_body) == "string"
|
||||
and (raw_body:match("^%s*data:") or raw_body:match("[\r\n]data:")) then
|
||||
local aggregated = aggregate_sse(raw_body)
|
||||
if aggregated ~= nil then raw_body = aggregated end
|
||||
end
|
||||
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
|
||||
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
|
||||
return __prelude.openai_response(raw_body)
|
||||
@ -147,11 +239,10 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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.
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
-- Do not serialize an empty Lua table as JSON `{}`. That shape is
|
||||
-- neither an OpenAI tool-call array nor an omitted field, and several
|
||||
-- agent clients reject it on the final chunk.
|
||||
for _, tc in ipairs(delta.tool_calls) do
|
||||
if type(tc) == "table" and tc.id ~= nil then
|
||||
tc.id = safe_tool_id(tc.id)
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "opencodezen"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.version = "1.1.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
|
||||
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
|
||||
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
|
||||
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
|
||||
adapter.hidden = true
|
||||
|
||||
-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1)
|
||||
--
|
||||
@ -99,7 +104,7 @@ local function now_ms(meta)
|
||||
return t * 1000
|
||||
end
|
||||
|
||||
local function rand_id(prefix, ms, kind)
|
||||
local function rand_id(prefix, ms, kind, affinity)
|
||||
local seg
|
||||
if kind == "session" then
|
||||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||||
@ -107,7 +112,15 @@ local function rand_id(prefix, ms, kind)
|
||||
seg = (ms * 16) % (MASK48 + 1)
|
||||
end
|
||||
-- %012x:12 位十六进制,不足补零
|
||||
return prefix .. string.format("%012x", seg) .. rand_tail(14)
|
||||
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
|
||||
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
|
||||
local tail
|
||||
if type(affinity) == "string" and affinity ~= "" then
|
||||
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
|
||||
else
|
||||
tail = rand_tail(14)
|
||||
end
|
||||
return prefix .. string.format("%012x", seg) .. tail
|
||||
end
|
||||
|
||||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||||
@ -120,6 +133,41 @@ end
|
||||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||||
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
|
||||
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
|
||||
-- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求**
|
||||
-- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶——
|
||||
-- 1) meta.client_session(pi 等真会话客户端自带,会话恒定)
|
||||
-- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何
|
||||
-- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定)
|
||||
-- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致)
|
||||
-- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。
|
||||
local function conversation_fingerprint(body)
|
||||
if type(body) ~= "string" or body == "" then return "" end
|
||||
local ok, req = pcall(json.decode, body)
|
||||
if not ok or type(req) ~= "table" then return "" end
|
||||
for _, m in ipairs(req.messages or {}) do
|
||||
if type(m) == "table" and m.role == "user" then
|
||||
local c = m.content
|
||||
if type(c) == "string" then
|
||||
if c ~= "" then return c end
|
||||
elseif type(c) == "table" then
|
||||
for _, part in ipairs(c) do
|
||||
if type(part) == "table" and part.type == "text" and part.text then
|
||||
return part.text
|
||||
end
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function session_affinity_key(meta)
|
||||
local cs = meta and meta.client_session
|
||||
if type(cs) == "string" and cs ~= "" then return cs end
|
||||
return conversation_fingerprint(meta and meta.body)
|
||||
end
|
||||
|
||||
local function session_bucket_ms(meta)
|
||||
local ms = now_ms(meta)
|
||||
return ms - (ms % 60000)
|
||||
@ -129,7 +177,9 @@ function adapter.build_headers(meta)
|
||||
local ms = now_ms(meta)
|
||||
local bucket = session_bucket_ms(meta)
|
||||
-- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。
|
||||
local sid = rand_id("ses_", bucket, "session")
|
||||
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
|
||||
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
|
||||
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
|
||||
return {
|
||||
["User-Agent"] = adapter.headers["User-Agent"],
|
||||
["x-opencode-client"] = "cli",
|
||||
|
||||
@ -253,7 +253,7 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
if delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
if type(delta.tool_calls) == "table" and #delta.tool_calls > 0 then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
|
||||
@ -902,10 +902,14 @@ local PLATFORMS = {
|
||||
-- "/chat/completions" ⇒ 若 base_url 已含完整路径,实际请求就变成
|
||||
-- .../v1/chat/completions/chat/completions ⇒ 404 Route Not Found。
|
||||
endpoint = "/v1/chat/completions",
|
||||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)
|
||||
-- 远端模型目录(照参考 opencode-product.ts modelsPath)。
|
||||
-- ★ 端点必须是 /zen/v1/models(带 UA+x-opencode-client 头):裸打
|
||||
-- /v1/models 回的是 HTML 网页(2026-10-19 实测),id_list 解析必空。
|
||||
models_remote = {
|
||||
endpoints = { { url = "https://opencode.ai/v1/models", method = "GET" } },
|
||||
auth = "bearer",
|
||||
endpoints = { { url = "https://opencode.ai/zen/v1/models", method = "GET",
|
||||
headers = { ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
|
||||
["x-opencode-client"] = "cli" } } },
|
||||
auth = "none",
|
||||
parse = "id_list",
|
||||
},
|
||||
adapter = "openai",
|
||||
@ -916,16 +920,24 @@ local PLATFORMS = {
|
||||
free_source = "capability",
|
||||
-- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义
|
||||
-- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。
|
||||
-- ⚠️ 2026-10-19 实测目录更新(/zen/v1/models):
|
||||
-- * space-bunny-free 已 410 下架(官方指路 minimax-m3.1-flash-free)
|
||||
-- * nemotron-3-ultra-free 已不在目录
|
||||
-- * 新增 beam-free / nemotron-3.5-super-vl-free / jev-1.13-free /
|
||||
-- glm-5.3-flash-free / minimax-m3.1-flash-free
|
||||
models = {
|
||||
"big-pickle", "space-bunny-free", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
|
||||
"nemotron-3.5-lightning-free", "deepseek-v4-flash", "deepseek-v4.1-flash",
|
||||
"big-pickle", "beam-free", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
|
||||
"nemotron-3.5-lightning-free", "jev-1.13-free", "glm-5.3-flash-free",
|
||||
"minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
|
||||
"deepseek-v4-flash", "deepseek-v4.1-flash",
|
||||
"glm-5.2", "kimi-k2.5", "minimax-m2.7", "minimax-m3", "qwen3.8-max",
|
||||
},
|
||||
models_free = {
|
||||
"big-pickle", "space-bunny-free", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "mimo-v2.5-free", "nemotron-3-ultra-free",
|
||||
"nemotron-3.5-lightning-free",
|
||||
"big-pickle", "beam-free", "longcat-2.5-preview-free",
|
||||
"mimo-v2.6-flash-free", "nemotron-3.5-super-vl-free",
|
||||
"nemotron-3.5-lightning-free", "jev-1.13-free",
|
||||
"glm-5.3-flash-free", "minimax-m3.1-flash-free", "deepseek-v4.1-flash-free",
|
||||
},
|
||||
},
|
||||
|
||||
@ -2105,9 +2117,20 @@ local function qoderClaimCampaign(plat, cred, campaignId)
|
||||
end
|
||||
|
||||
-- qoderClaim(plat, tok, args):完整活动领取(多活动逐个领)。
|
||||
-- ★ 凭据形状必须兼容**裸 token**:qoder 是 PKCE 设备码流且无 register 第二跳,
|
||||
-- complete_login 存进凭据库的就是裸 access_token 字符串(credentialOf 走裸快路
|
||||
-- 返回 (tok, nil, nil)),credArgs.credential 因此是 nil。此前在这里硬要求
|
||||
-- table,导致"测试通过(token 路径)、领取失败(缺凭据对象)"的自相矛盾。
|
||||
-- 活动接口只要 Bearer token,裸 tok 非空时就地包一层。
|
||||
local function qoderClaim(plat, tok, args)
|
||||
local cred = (args and args.credential) or nil
|
||||
if type(cred) ~= "table" then return nil, "缺少凭据对象" end
|
||||
if type(cred) ~= "table" then
|
||||
if type(tok) == "string" and tok ~= "" then
|
||||
cred = { access_token = tok }
|
||||
else
|
||||
return nil, "缺少凭据对象"
|
||||
end
|
||||
end
|
||||
local parsed, lerr = qoderLoadCampaigns(plat, cred)
|
||||
if not parsed then return { done = true, kind = "failed", message = lerr } end
|
||||
|
||||
@ -9925,7 +9948,68 @@ function adapter.transform_request(raw_body)
|
||||
return json.encode(body)
|
||||
end
|
||||
|
||||
-- aggregate_solo_sse(raw):把 SOLO 的 SSE(event:/data: 混合行)聚合成**末帧语义**
|
||||
-- 的非流式响应对象。SOLO 对非流式请求也回 SSE(真实缺陷:body 以 event:metadata
|
||||
-- 开头),而内核只把 data: 行交给流式路径——非流式路径收到的是**完整原始 body**
|
||||
-- (含 event: 行),直接 json.decode 必然失败(invalid character 'e')。
|
||||
-- 聚合规则:拼接所有 data: 帧的 response/reasoning_content,取最后的 finish_reason
|
||||
-- 与 usage。无任何可解析帧时返回 nil(调用方回退原样)。
|
||||
local function aggregate_solo_sse(raw)
|
||||
local content, reasoning = {}, {}
|
||||
local finish, usage = nil, nil
|
||||
local seen_tool_calls = nil
|
||||
local seen = false
|
||||
for line in (raw .. "\n"):gmatch("([^\n]*)\n") do
|
||||
local s = line:gsub("^%s+", ""):gsub("%s+$", "")
|
||||
if s:sub(1, 5) == "data:" then
|
||||
local payload = s:sub(6):gsub("^%s+", "")
|
||||
if payload ~= "" and payload ~= "[DONE]" then
|
||||
local ok, d = pcall(json.decode, payload)
|
||||
if ok and type(d) == "table" then
|
||||
seen = true
|
||||
if type(d.response) == "string" and d.response ~= "" then
|
||||
content[#content + 1] = d.response
|
||||
end
|
||||
if type(d.reasoning_content) == "string" and d.reasoning_content ~= "" then
|
||||
reasoning[#reasoning + 1] = d.reasoning_content
|
||||
end
|
||||
if d.finish_reason ~= nil then finish = tostring(d.finish_reason) end
|
||||
if d.prompt_tokens ~= nil or d.completion_tokens ~= nil or d.total_tokens ~= nil then
|
||||
usage = { prompt_tokens = tonumber(d.prompt_tokens) or 0,
|
||||
completion_tokens = tonumber(d.completion_tokens) or 0,
|
||||
total_tokens = tonumber(d.total_tokens) or 0 }
|
||||
end
|
||||
-- SOLO 把 tool_calls 放在帧上;聚合时保留**最后一个非空**数组
|
||||
if type(d.tool_calls) == "table" and #d.tool_calls > 0 then
|
||||
seen_tool_calls = d.tool_calls
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
if not seen then return nil end
|
||||
local out = {
|
||||
choices = { { index = 0,
|
||||
message = { role = "assistant", content = table.concat(content) },
|
||||
finish_reason = finish or "stop" } },
|
||||
}
|
||||
local rc = table.concat(reasoning)
|
||||
if rc ~= "" then out.choices[1].message.reasoning_content = rc end
|
||||
if seen_tool_calls then out.choices[1].message.tool_calls = seen_tool_calls end
|
||||
if usage then out.usage = usage end
|
||||
local ok2, enc = pcall(json.encode, out)
|
||||
if not ok2 then return nil end
|
||||
return enc
|
||||
end
|
||||
|
||||
function adapter.transform_response(raw_body)
|
||||
-- SOLO 上游对非流式请求也回 SSE(带 event: 前缀的帧)。识别出 SSE 就地聚合成
|
||||
-- OpenAI 非流式形状再走统一转换;识别不出(真 JSON)原样透传。
|
||||
if type(raw_body) == "string"
|
||||
and (raw_body:match("^%s*event:") or raw_body:match("[\r\n]event:") or raw_body:match("^data:")) then
|
||||
local aggregated = aggregate_solo_sse(raw_body)
|
||||
if aggregated ~= nil then raw_body = aggregated end
|
||||
end
|
||||
return __prelude.openai_response(raw_body)
|
||||
end
|
||||
|
||||
@ -11823,6 +11907,13 @@ plugin.ui = {
|
||||
const s = state.statuses.find((x) => x.id === id) || {};
|
||||
q("#ch-detail-name").textContent = s.name || id;
|
||||
q("#ch-detail-org").textContent = id + " · " + (s.flow || "");
|
||||
// ★ 切渠道立即清空旧渠道的账号/模型/余额状态。
|
||||
// 此前不清:renderAccounts/renderModels 是异步的,await 前的空窗里 DOM 仍挂着
|
||||
// 上一个渠道的账号行(实测:从 qoder 切到 opencode,qoder 的账号带着
|
||||
// "上次领取 · 未开通"文案闪现在 opencode 卡片下,被当成 opencode 的账号)。
|
||||
// 账号行还挂操作按钮(测试/删除),误点会打到错误平台。
|
||||
state.accounts = []; state.models = [];
|
||||
q("#ch-accts").innerHTML = '<p class="muted" style="font-size:13px">加载中…</p>';
|
||||
const cap = (await call("channels/auto_checkin","POST",{})) && true; // noop,保持顺序
|
||||
// 动作区:登录(加账号)/ 刷新全部 / 一键领取 / 供应商开关
|
||||
const acts = [];
|
||||
@ -11839,6 +11930,8 @@ plugin.ui = {
|
||||
|
||||
async function renderAccounts(id) {
|
||||
const j = await call("channels/account/list", "POST", { platform: id });
|
||||
// 竞态防护:await 期间用户可能已切到别的渠道,旧响应不得覆盖新渲染
|
||||
if (state.selected !== id) return;
|
||||
const accts = j.accounts || [];
|
||||
state.accounts = accts;
|
||||
// 逐账号余额(credits.balances 对齐参考;无端点平台如实显示不可用)
|
||||
@ -12193,6 +12286,20 @@ function plugin.on_request_end(payload)
|
||||
local msg = tostring(payload.error or payload.message or payload.error_message or "")
|
||||
local kind = plugin.term_error_kind(msg)
|
||||
if kind then plugin.mark_account_term_error(provider, kind) end
|
||||
-- ★ 模型级死亡记录(照参考 dead-model-store.ts):判据命中"下架"措辞时记一笔。
|
||||
-- 此前 isModelGone/recordDeadModel 只有测试 probe 在调(基础设施丢失挂接点),
|
||||
-- 真实 404(如 codearts 的 GLM-5.3 "The model is not registered")永远不会
|
||||
-- 进 deadModels,模型在 UI 里一直可选中、每轮 404。
|
||||
if msg ~= "" then
|
||||
local modelId = tostring(payload.model or payload.model_id or "")
|
||||
if modelId ~= "" and isModelGone(msg) then
|
||||
local isnew = recordDeadModel(provider, modelId, msg)
|
||||
if isnew then
|
||||
logEvent("模型已失效,已标记 " .. provider .. "/" .. modelId, "warn", provider)
|
||||
markDirty()
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
function plugin.credits_tier_probe(args)
|
||||
|
||||
@ -93,6 +93,12 @@ type staticInfo struct {
|
||||
// but once an adapter claims the endpoint, protocol iteration lives in the
|
||||
// adapter, not the kernel. Empty means the adapter claims no ingress.
|
||||
ingress string
|
||||
// hidden marks an adapter that should NOT appear in the user-facing adapter
|
||||
// list (GET /api/adapters → UI 的适配器页与源编辑器的适配器下拉)。隐藏的
|
||||
// 适配器仍然正常加载、仍然可以被源引用——它只是不从选择列表里冒出来。
|
||||
// 用途:厂商探针类/内部实验类适配器(如 opencodezen 的免费层门禁形态),
|
||||
// 直接暴露会让运维误当成通用 OpenAI 适配器去选。声明方式:adapter.hidden = true。
|
||||
hidden bool
|
||||
}
|
||||
|
||||
// adapterPool owns the worker states for one adapter. Idle workers are held in
|
||||
@ -596,6 +602,11 @@ type VM struct {
|
||||
dir string
|
||||
pools map[string]*adapterPool
|
||||
|
||||
// hiddenRefs 记录"被源引用的隐藏适配器"。hidden=true 的适配器默认不进
|
||||
// ListAdapters,但被源引用时必须保持可见(源编辑器要能回显当前值)。
|
||||
// 由内核在配置装载/源变更时调用 SetHiddenRefs 维护;nil = 未标记。
|
||||
hiddenRefs map[string]bool
|
||||
|
||||
// janitor drives elastic shrinking. One goroutine serves every pool (not
|
||||
// one per adapter), so an idle gateway costs a single sleeping goroutine.
|
||||
janitorOnce sync.Once
|
||||
@ -603,6 +614,21 @@ type VM struct {
|
||||
janitorDone chan struct{}
|
||||
}
|
||||
|
||||
// SetHiddenRefs 供内核同步"哪些隐藏适配器正被源引用"。传 nil/空集 = 没有引用。
|
||||
func (v *VM) SetHiddenRefs(names []string) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if len(names) == 0 {
|
||||
v.hiddenRefs = nil
|
||||
return
|
||||
}
|
||||
m := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
m[n] = true
|
||||
}
|
||||
v.hiddenRefs = m
|
||||
}
|
||||
|
||||
func NewVM(dir string) *VM {
|
||||
return &VM{
|
||||
dir: dir,
|
||||
@ -799,14 +825,32 @@ func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
|
||||
func (v *VM) ListAdapters() []APIAdapter {
|
||||
v.mu.RLock()
|
||||
defer v.mu.RUnlock()
|
||||
// hidden=true 的适配器不进用户可见列表(仍正常加载、仍可被源引用)。
|
||||
// 例外:**正在被某个源引用**的隐藏适配器必须仍出现在列表里——否则源编辑器
|
||||
// 打开时找不到当前值,下拉会被重置成空,造成"保存后适配器悄悄丢了"。
|
||||
// 引用关系由内核持有(core 在配置装载时标记),VM 只看这个位。
|
||||
keepHidden := v.hiddenKeep()
|
||||
list := make([]APIAdapter, 0, len(v.pools))
|
||||
for name, p := range v.pools {
|
||||
if p.static.hidden && !keepHidden[name] {
|
||||
continue
|
||||
}
|
||||
list = append(list, APIAdapter{Name: name, Version: p.static.version})
|
||||
}
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
|
||||
return list
|
||||
}
|
||||
|
||||
// hiddenKeep returns the set of hidden adapter names that must STAY visible
|
||||
// because at least one source references them. nil 表示内核未标记(测试环境/
|
||||
// 未装载配置),此时隐藏适配器一律隐藏——与"新适配器默认隐藏"的预期一致。
|
||||
func (v *VM) hiddenKeep() map[string]bool {
|
||||
if v.hiddenRefs == nil {
|
||||
return nil
|
||||
}
|
||||
return v.hiddenRefs
|
||||
}
|
||||
|
||||
// AdapterForIngress returns the registered name of the adapter that CLAIMS the
|
||||
// given client-facing endpoint path (its static.ingress), or "" when none does.
|
||||
//
|
||||
@ -1255,6 +1299,10 @@ func inspectScript(name, code string) (*staticInfo, error) {
|
||||
info.endpoint = readString(L, -1, "endpoint")
|
||||
info.endpointStream = readString(L, -1, "endpoint_stream")
|
||||
info.ingress = readString(L, -1, "ingress")
|
||||
// hidden:适配器可声明 adapter.hidden = true 退出用户可见列表(仍可被源引用)。
|
||||
L.GetField(-1, "hidden")
|
||||
info.hidden = L.ToBoolean(-1)
|
||||
L.Pop(1)
|
||||
if getFieldBatch(L, "headers") {
|
||||
luaTableToMap(L, -1, info.headers)
|
||||
L.Pop(1)
|
||||
|
||||
49
internal/lua/vm_hidden_test.go
Normal file
49
internal/lua/vm_hidden_test.go
Normal file
@ -0,0 +1,49 @@
|
||||
package lua
|
||||
|
||||
import "testing"
|
||||
|
||||
// hidden=true 的适配器:不被任何源引用时从 ListAdapters 消失;被引用时保持可见。
|
||||
// 这个行为支撑"内部适配器默认隐藏"的约定(opencodezen 是第一个使用者)。
|
||||
func TestAdapterHiddenFlag(t *testing.T) {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
defer vm.Stop()
|
||||
const src = `
|
||||
local adapter = {}
|
||||
adapter.name = "secretone"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.hidden = true
|
||||
function adapter.transform_request(raw) return raw end
|
||||
function adapter.transform_response(raw) return raw end
|
||||
return adapter
|
||||
`
|
||||
if err := vm.LoadAdapterSource("secretone", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 未被引用 → 隐藏
|
||||
vm.SetHiddenRefs(nil)
|
||||
for _, a := range vm.ListAdapters() {
|
||||
if a.Name == "secretone" {
|
||||
t.Fatal("hidden adapter must not be listed when unreferenced")
|
||||
}
|
||||
}
|
||||
// 被引用 → 可见
|
||||
vm.SetHiddenRefs([]string{"secretone"})
|
||||
found := false
|
||||
for _, a := range vm.ListAdapters() {
|
||||
if a.Name == "secretone" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("hidden adapter must stay listed while referenced by a source")
|
||||
}
|
||||
// transform 路径不受 hidden 影响:隐藏 ≠ 禁用
|
||||
out, err := vm.Transform("secretone", "transform_response", `{"x":1}`)
|
||||
if err != nil || out == "" {
|
||||
t.Fatalf("hidden adapter must still transform: %v %q", err, out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user