mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:
1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
- `main` 独有的**文件**:0 个;
- `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
main 独有行数:0;
- `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
- `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
- main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
(`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
且不会丢任何 main 独有的修复。
本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth
校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
174 lines
7.1 KiB
Lua
174 lines
7.1 KiB
Lua
-- sensenova API format adapter
|
||
-- streaming: delta only has reasoning_content, no content field
|
||
-- non-streaming: has both content and reasoning_content
|
||
|
||
local adapter = {}
|
||
adapter.name = "sensenova"
|
||
adapter.version = "1.0.0"
|
||
adapter.endpoint = "/chat/completions"
|
||
adapter.headers = {}
|
||
|
||
-- Same as openai - 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
|
||
if req.messages then
|
||
for _, msg in ipairs(req.messages) do
|
||
msg.reasoning_content = nil
|
||
end
|
||
end
|
||
return json.encode(req)
|
||
end
|
||
|
||
-- Same as openai - extract content from response
|
||
function adapter.transform_response(raw_body)
|
||
local ok, resp = pcall(json.decode, raw_body)
|
||
if not ok or resp == nil then return raw_body end
|
||
|
||
local unified = {
|
||
content = "",
|
||
finish_reason = "",
|
||
token_usage = { prompt = 0, completion = 0, total = 0 }
|
||
}
|
||
|
||
if type(resp.usage) == "table" then
|
||
unified.token_usage.prompt = resp.usage.prompt_tokens or 0
|
||
unified.token_usage.completion = resp.usage.completion_tokens or 0
|
||
unified.token_usage.total = resp.usage.total_tokens or 0
|
||
local hit = 0
|
||
if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then
|
||
hit = resp.usage.prompt_tokens_details.cached_tokens
|
||
unified.token_usage.prompt_tokens_details = { cached_tokens = hit }
|
||
end
|
||
if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then
|
||
unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens
|
||
unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0
|
||
if hit == 0 then
|
||
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
|
||
end
|
||
end
|
||
end
|
||
|
||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||
local ch = resp.choices[1]
|
||
if type(ch.message) == "table" then
|
||
unified.content = ch.message.content or ""
|
||
if ch.message.reasoning_content then
|
||
unified.reasoning_content = ch.message.reasoning_content
|
||
elseif ch.message.reasoning then
|
||
-- sensenova-6.8-flash-lite 用 reasoning 字段而不是 reasoning_content
|
||
unified.reasoning_content = ch.message.reasoning
|
||
end
|
||
-- Tool calls MUST be forwarded. Dropping them while keeping
|
||
-- finish_reason="tool_calls" makes the client replay an assistant
|
||
-- message whose function name/arguments are empty, and sensenova
|
||
-- then rejects the next turn with
|
||
-- 400 invalid tool_call function, function/name/arguments cannot be empty
|
||
-- i.e. a tool-using conversation dies on its second request.
|
||
if type(ch.message.tool_calls) == "table" and #ch.message.tool_calls > 0 then
|
||
local tcs = {}
|
||
for _, tc in ipairs(ch.message.tool_calls) do
|
||
local fn = tc["function"] or {}
|
||
-- arguments arrives as a JSON *string* on the wire; the
|
||
-- unified shape expects a decoded object.
|
||
local args = fn.arguments
|
||
if type(args) == "string" then
|
||
local args_ok, decoded = pcall(json.decode, args)
|
||
args = args_ok and decoded or {}
|
||
elseif type(args) ~= "table" then
|
||
args = {}
|
||
end
|
||
table.insert(tcs, {
|
||
id = tc.id,
|
||
type = tc.type or "function",
|
||
name = fn.name,
|
||
arguments = args
|
||
})
|
||
end
|
||
unified.tool_calls = tcs
|
||
end
|
||
end
|
||
unified.finish_reason = ch.finish_reason or ""
|
||
end
|
||
|
||
return json.encode(unified)
|
||
end
|
||
|
||
-- Sensenova-specific stream handling
|
||
-- Upstream puts content in reasoning_content only (no content field)
|
||
-- Also sends finish_reason="" (empty string) on every chunk
|
||
function adapter.transform_stream_chunk(raw_chunk)
|
||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||
if not ok then return "" end
|
||
|
||
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
|
||
return json.encode({ usage = uses, done = false })
|
||
end
|
||
return ""
|
||
end
|
||
|
||
local delta = chunk.choices[1].delta or {}
|
||
|
||
-- Sensenova: delta has reasoning_content (deepseek-v4-flash) or reasoning
|
||
-- (sensenova-6.8-flash-lite) but no content field
|
||
local content = delta.content or ""
|
||
if content == "" and (delta.reasoning_content or delta.reasoning) then
|
||
content = delta.reasoning_content or delta.reasoning
|
||
end
|
||
|
||
-- Sensenova: finish_reason is "" on every chunk, "stop" on last
|
||
local fr = chunk.choices[1].finish_reason
|
||
local done = (fr == "stop" or fr == "length")
|
||
|
||
local unified = {
|
||
content = content,
|
||
done = done,
|
||
}
|
||
if chunk.choices[1].finish_reason and chunk.choices[1].finish_reason ~= "" then
|
||
unified.finish_reason = chunk.choices[1].finish_reason
|
||
end
|
||
if delta.tool_calls then
|
||
unified.tool_calls = delta.tool_calls
|
||
end
|
||
if uses ~= nil then
|
||
unified.usage = uses
|
||
end
|
||
|
||
return json.encode(unified)
|
||
end
|
||
|
||
-- 错误收敛:sensenova 为 OpenAI 风格 {error:{message,...}};
|
||
-- 配额类错误单独点出便于客户端识别重置周期。
|
||
function adapter.transform_error(status, body)
|
||
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
|
||
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
|
||
return __prelude.openai_error(status, body)
|
||
end
|
||
|
||
return adapter
|