mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
feat(gateway): pass through upstream finish_reason end-to-end
The gateway hardcoded "stop" on every terminating stream chunk, so tool-call rounds reported finish_reason=stop and length caps were invisible to clients. UnifiedChunk now carries finish_reason; adapters emit it (with empty-string finish reasons like sensenova treated as non-terminal), standardSSEChunk passes it through for un-adapted upstreams, [DONE] no longer emits a duplicate reason-less done chunk, and both streaming paths emit the real reason with "stop" as fallback. Also vendor sensenova/agentrouter adapters into the repo: they were WebUI-only uploads and a deploy sync silently removed them while live AUTO-chain slots still referenced them.
This commit is contained in:
111
internal/lua/adapters/agentrouter.lua
Normal file
111
internal/lua/adapters/agentrouter.lua
Normal file
@ -0,0 +1,111 @@
|
||||
local adapter = {}
|
||||
|
||||
adapter.name = "agentrouter"
|
||||
adapter.version = "1.0.0"
|
||||
adapter.endpoint = "/chat/completions"
|
||||
adapter.headers = {}
|
||||
|
||||
-- AgentRouter 的 WAF 会按客户端指纹白名单校验,只放行官方客户端。
|
||||
-- 这里通过 build_headers 注入官方客户端 User-Agent 以通过校验。
|
||||
-- 默认 QwenCode 指纹(实测可通);可在 source.meta.user_agent 覆盖成 Claude Code 指纹。
|
||||
|
||||
local default_ua = "QwenCode/0.2.0 (linux; x64)"
|
||||
|
||||
function adapter.transform_request(raw_body)
|
||||
local ok, req = pcall(json.decode, raw_body)
|
||||
if not ok then return raw_body end
|
||||
req.disable_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
|
||||
|
||||
-- 注入 Authorization + 官方客户端 User-Agent
|
||||
function adapter.build_headers(meta)
|
||||
local ua = default_ua
|
||||
local meta_ua = (meta.source or {}).meta and (meta.source).meta.user_agent
|
||||
if meta_ua and meta_ua ~= "" then
|
||||
ua = meta_ua
|
||||
end
|
||||
return {
|
||||
["Content-Type"] = "application/json",
|
||||
["Authorization"] = "Bearer " .. meta.api_key,
|
||||
["User-Agent"] = ua,
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
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
|
||||
end
|
||||
if type(ch.message.tool_calls) == "table" then
|
||||
local tcs = {}
|
||||
for _, tc in ipairs(ch.message.tool_calls) do
|
||||
local args_ok, args = pcall(json.decode, tc["function"].arguments)
|
||||
if not args_ok then args = {} end
|
||||
table.insert(tcs, {
|
||||
id = tc.id,
|
||||
type = tc.type or "function",
|
||||
name = tc["function"].name,
|
||||
arguments = args
|
||||
})
|
||||
end
|
||||
unified.tool_calls = tcs
|
||||
end
|
||||
end
|
||||
unified.finish_reason = ch.finish_reason or ""
|
||||
end
|
||||
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
function adapter.transform_stream_chunk(raw_chunk)
|
||||
local ok, chunk = pcall(json.decode, raw_chunk)
|
||||
if not ok then return "" end
|
||||
|
||||
if not chunk.choices or #chunk.choices == 0 then 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 delta.reasoning_content then
|
||||
unified.reasoning_content = delta.reasoning_content
|
||||
end
|
||||
if delta.tool_calls then
|
||||
unified.tool_calls = delta.tool_calls
|
||||
end
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -124,12 +124,23 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
uses = { prompt = p, completion = c, total = p + c }
|
||||
end
|
||||
end
|
||||
local done = (chunk.delta and chunk.delta.stop_reason ~= nil)
|
||||
local finish = nil
|
||||
if chunk.delta and chunk.delta.stop_reason ~= nil then
|
||||
-- Anthropic stop_reason -> OpenAI finish_reason
|
||||
local sr = chunk.delta.stop_reason
|
||||
if sr == "max_tokens" then
|
||||
finish = "length"
|
||||
elseif sr == "tool_use" then
|
||||
finish = "tool_calls"
|
||||
else
|
||||
finish = "stop"
|
||||
end
|
||||
end
|
||||
if uses ~= nil then
|
||||
-- completion is final here; prompt is merged from message_start
|
||||
return json.encode({ content = "", done = done, usage = uses })
|
||||
return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish, usage = uses })
|
||||
end
|
||||
return json.encode({ content = "", done = done })
|
||||
return json.encode({ content = "", done = (finish ~= nil), finish_reason = finish })
|
||||
end
|
||||
if chunk.type == "content_block_start" and chunk.content_block
|
||||
and chunk.content_block.type == "tool_use" then
|
||||
|
||||
@ -106,10 +106,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
@ -110,7 +110,19 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return ""
|
||||
end
|
||||
local cand = chunk.candidates[1]
|
||||
local unified = { content = "", done = (cand.finishReason ~= nil) }
|
||||
-- Gemini finishReason -> OpenAI finish_reason
|
||||
local finish = nil
|
||||
if cand.finishReason ~= nil then
|
||||
if cand.finishReason == "MAX_TOKENS" then
|
||||
finish = "length"
|
||||
elseif cand.finishReason == "SAFETY" or cand.finishReason == "RECITATION"
|
||||
or cand.finishReason == "BLOCKLIST" then
|
||||
finish = "content_filter"
|
||||
else
|
||||
finish = "stop"
|
||||
end
|
||||
end
|
||||
local unified = { content = "", done = (finish ~= nil), finish_reason = finish }
|
||||
local reasoning = ""
|
||||
local tools = {}
|
||||
if cand.content and cand.content.parts then
|
||||
|
||||
@ -87,10 +87,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
@ -86,10 +86,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
@ -119,10 +119,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
@ -86,10 +86,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
@ -85,16 +85,27 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
end
|
||||
end
|
||||
|
||||
-- Ollama done_reason -> OpenAI finish_reason ("length" 透传,其余归一 stop)
|
||||
local finish = nil
|
||||
if chunk.done then
|
||||
if chunk.done_reason == "length" then
|
||||
finish = "length"
|
||||
else
|
||||
finish = "stop"
|
||||
end
|
||||
end
|
||||
|
||||
if not chunk.message then
|
||||
if uses ~= nil then
|
||||
return json.encode({ content = "", done = true, usage = uses })
|
||||
return json.encode({ content = "", done = true, finish_reason = finish, usage = uses })
|
||||
end
|
||||
return ""
|
||||
return json.encode({ content = "", done = true, finish_reason = finish })
|
||||
end
|
||||
|
||||
local unified = {
|
||||
content = chunk.message.content or "",
|
||||
done = chunk.done or false
|
||||
done = chunk.done or false,
|
||||
finish_reason = finish
|
||||
}
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
|
||||
@ -90,10 +90,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
@ -159,10 +159,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
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 = (fr ~= nil)
|
||||
done = (finish ~= nil)
|
||||
}
|
||||
if finish then
|
||||
unified.finish_reason = finish
|
||||
end
|
||||
if uses ~= nil then
|
||||
unified.usage = uses
|
||||
end
|
||||
|
||||
108
internal/lua/adapters/sensenova.lua
Normal file
108
internal/lua/adapters/sensenova.lua
Normal file
@ -0,0 +1,108 @@
|
||||
-- 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
|
||||
req.disable_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
|
||||
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
|
||||
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,
|
||||
}
|
||||
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 but no content
|
||||
local content = delta.content or ""
|
||||
if content == "" and delta.reasoning_content then
|
||||
content = delta.reasoning_content
|
||||
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
|
||||
|
||||
return adapter
|
||||
Reference in New Issue
Block a user