mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-08 10:17:21 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 432129243e | |||
| c744ee151e | |||
| 55f5d7a0f4 | |||
| c9c09b2ba2 |
4
cmd/gui/package-lock.json
generated
4
cmd/gui/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.9",
|
||||
"license": "Proprietary",
|
||||
"devDependencies": {
|
||||
"electron": "^33.0.0",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "modelrouter-gui",
|
||||
"version": "1.5.7",
|
||||
"version": "1.5.9",
|
||||
"description": "ModelRouter Desktop — embedded ModelRouter core with tray",
|
||||
"author": "ModelRouter",
|
||||
"main": "main.js",
|
||||
|
||||
@ -29,6 +29,9 @@ type chatRequest struct {
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
DisableThinking bool `json:"disable_thinking"`
|
||||
ExtraBody map[string]interface{} `json:"extra_body,omitempty"`
|
||||
|
||||
// PromptCacheKey 是 OpenAI 原生的缓存键;部分客户端用它携带会话标识。
|
||||
PromptCacheKey string `json:"prompt_cache_key,omitempty"`
|
||||
}
|
||||
|
||||
// ChatCompletion is the non-streaming OpenAI response object.
|
||||
@ -39,6 +42,10 @@ type ChatCompletion struct {
|
||||
Model string `json:"model"`
|
||||
Choices []ChatChoice `json:"choices"`
|
||||
Usage *types.TokenUsage `json:"usage,omitempty"`
|
||||
// Cost is the upstream-reported charge for this request, passed through
|
||||
// verbatim (OpenCode reports it as a decimal string, always "0" on the
|
||||
// flat-rate Go subscription). Absent when the upstream reports nothing.
|
||||
Cost string `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
type ChatChoice struct {
|
||||
@ -63,6 +70,10 @@ type ChatChunk struct {
|
||||
// Usage is sent in the final chunk of a stream (empty choices) so
|
||||
// OpenAI-compatible clients can read token usage.
|
||||
Usage *types.TokenUsage `json:"usage,omitempty"`
|
||||
// Cost is the upstream-reported charge for this request, passed through
|
||||
// verbatim (OpenCode reports it as a decimal string). Emitted on the
|
||||
// terminal chunk, mirroring OpenCode's own {"choices":[],"cost":"0"}.
|
||||
Cost string `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
type ChunkChoice struct {
|
||||
@ -283,6 +294,8 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 客户端自带的会话标识(若有)比推导出来的更准,见 clientSessionFromRequest。
|
||||
clientSession := clientSessionFromRequest(r, req.PromptCacheKey)
|
||||
if len(req.Messages) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request", "messages is required")
|
||||
return
|
||||
@ -313,6 +326,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
ToolChoice: req.ToolChoice,
|
||||
DisableThinking: req.DisableThinking,
|
||||
ExtraBody: req.ExtraBody,
|
||||
ClientSession: clientSession,
|
||||
}
|
||||
rec := &Req{
|
||||
Key: keyID(reqKey(ctx)),
|
||||
@ -368,6 +382,7 @@ func (g *Gateway) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
ToolChoice: req.ToolChoice,
|
||||
DisableThinking: req.DisableThinking,
|
||||
ExtraBody: req.ExtraBody,
|
||||
ClientSession: clientSession,
|
||||
}
|
||||
rec := &Req{
|
||||
Key: keyID(reqKey(ctx)),
|
||||
@ -534,6 +549,9 @@ var overflowMarkers = []*regexp.Regexp{
|
||||
}
|
||||
|
||||
// overflowCanonical 命中 pi 的 /context[_ ]length[_ ]exceeded/i。
|
||||
//
|
||||
// 注意:这个前缀只是必要条件,不是充分条件——pi 还会先用
|
||||
// NON_OVERFLOW_PATTERNS 排除整条消息,见 overflowClientMessage。
|
||||
const overflowCanonical = "context_length_exceeded"
|
||||
|
||||
// looksLikeOverflow 判断错误文本是否属于上下文超窗。
|
||||
@ -552,14 +570,59 @@ func looksLikeOverflow(s string) bool {
|
||||
// Per-tier one-line reasons are kept (quota/cooling skips carry no error body
|
||||
// and are the actionable part); each is capped so HTML dumps can't leak.
|
||||
//
|
||||
// 超窗会被归一化成 overflowCanonical 前缀,见 overflowMarkers 的说明。
|
||||
// 超窗会被归一化成「干净」的 overflowCanonical 消息,见 overflowClientMessage。
|
||||
func clientUpstreamErr(err error) string {
|
||||
log.Printf("[gateway] upstream failure surfaced to client: %v", err)
|
||||
msg := upstreamErrSummary(err)
|
||||
if looksLikeOverflow(err.Error()) || looksLikeOverflow(msg) {
|
||||
return fmt.Sprintf("%s: %s", overflowCanonical, msg)
|
||||
if looksLikeOverflow(err.Error()) {
|
||||
return overflowClientMessage(err)
|
||||
}
|
||||
return msg
|
||||
return upstreamErrSummary(err)
|
||||
}
|
||||
|
||||
// overflowHint 是给客户端的超窗说明,刻意只用 pi 认得的措辞。
|
||||
const overflowHint = "context window is full; reduce the length of the messages"
|
||||
|
||||
// clientSessionFromRequest 取客户端自带的会话标识,拿不到时返回 ""。
|
||||
//
|
||||
// 通用 OpenAI 客户端默认不带会话 id,但 pi 支持:只要 provider 的 compat 里打开
|
||||
// sendSessionAffinityHeaders,pi 就会把**平台真实的会话 id**(uuidv7,整个会话
|
||||
// 恒定)放到 x-session-affinity / x-client-request-id 上(sessionAffinityFormat
|
||||
// 为 openrouter 时是 x-session-id,为 openai 时是 session_id)。
|
||||
// 有了它就不必再从首条 user 消息推导会话指纹(那只在客户端不发会话 id 时才作为
|
||||
// 退路,且历史压缩后会漂移)。prompt_cache_key 是 OpenAI 原生的缓存键,
|
||||
// 客户端若带也一并当会话用。
|
||||
//
|
||||
// 刻意不采纳 x-client-request-id:名字含 request,部分客户端每请求都换,
|
||||
// 拿它当会话会让上游缓存永不命中。pi 总会同时发 x-session-affinity,够用。
|
||||
func clientSessionFromRequest(r *http.Request, bodyKey string) string {
|
||||
for _, h := range []string{"X-Session-Affinity", "X-Session-Id", "Session-Id"} {
|
||||
if v := strings.TrimSpace(r.Header.Get(h)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(bodyKey)
|
||||
}
|
||||
|
||||
// overflowClientMessage 为超窗失败生成「干净」的客户端消息。
|
||||
//
|
||||
// 为什么不能沿用 upstreamErrSummary:pi 的 isContextOverflow 先查
|
||||
// NON_OVERFLOW_PATTERNS(/rate limit/、/too many requests/、Bedrock 前缀),
|
||||
// 一旦命中就直接判为「非超窗」——**哪怕消息里已经有 context_length_exceeded**,
|
||||
// pi 也不会压缩重试。而 AUTO 链的失败消息天生是多 tier 原因的拼接:
|
||||
// 超窗 tier(gozen 400 maximum context length)常与配额/限流 tier
|
||||
// (429 token plan exhausted、cooling、no free slot)同时出现。
|
||||
// 把明细原样带出去,等于让一条限流 tier 的措辞反过来封杀超窗识别。
|
||||
// 所以这里只保留「超窗」措辞 + 是哪个源超的窗,其余一律不带。
|
||||
func overflowClientMessage(err error) string {
|
||||
var ce *scheduler.ChainErr
|
||||
if errors.As(err, &ce) {
|
||||
for _, t := range ce.Tiers {
|
||||
if looksLikeOverflow(t.Err.Error()) {
|
||||
return fmt.Sprintf("%s: %s (%s/%s)", overflowCanonical, overflowHint, t.Source, t.Model)
|
||||
}
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", overflowCanonical, overflowHint)
|
||||
}
|
||||
|
||||
// upstreamErrSummary 把上游失败压成一行短消息。
|
||||
@ -648,6 +711,7 @@ func writeChatCompletion(w http.ResponseWriter, resp *types.UnifiedResponse, mod
|
||||
Created: time.Now().Unix(),
|
||||
Model: modelName,
|
||||
Choices: []ChatChoice{{Index: 0, Message: msg, FinishReason: resp.FinishReason}},
|
||||
Cost: resp.Cost,
|
||||
}
|
||||
if resp.TokenUsage.Total > 0 || resp.TokenUsage.Prompt > 0 || resp.TokenUsage.Completion > 0 {
|
||||
out.Usage = &resp.TokenUsage
|
||||
@ -767,11 +831,15 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
|
||||
// source's status-page latency average.
|
||||
rec.FirstByteMs = time.Since(t0).Milliseconds()
|
||||
var lastUsage *types.TokenUsage
|
||||
var lastCost string
|
||||
lastFinish := ""
|
||||
for ck := range chunks {
|
||||
if ck.Usage != nil {
|
||||
lastUsage = mergeUsage(lastUsage, ck.Usage)
|
||||
}
|
||||
if ck.Cost != "" {
|
||||
lastCost = ck.Cost
|
||||
}
|
||||
chunk := ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
|
||||
}
|
||||
@ -838,6 +906,14 @@ func (g *Gateway) pumpStream(w http.ResponseWriter, rec *Req, chunks <-chan type
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
|
||||
Choices: []ChunkChoice{},
|
||||
Usage: tut,
|
||||
Cost: lastCost,
|
||||
})
|
||||
} else if lastCost != "" {
|
||||
// Cost arrived without any usage (OpenCode sends it on its own frame).
|
||||
send(ChatChunk{
|
||||
ID: id, Object: "chat.completion.chunk", Created: created, Model: modelName,
|
||||
Choices: []ChunkChoice{},
|
||||
Cost: lastCost,
|
||||
})
|
||||
}
|
||||
fmt.Fprintf(w, "data: [DONE]\n\n")
|
||||
|
||||
47
internal/gateway/client_session_test.go
Normal file
47
internal/gateway/client_session_test.go
Normal file
@ -0,0 +1,47 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 客户端自带的会话标识必须被采纳:它比「首条 user 消息指纹」更准
|
||||
// (历史压缩后指纹会漂移),也是唯一能天然按会话隔离的来源。
|
||||
func TestClientSessionFromRequestHeaders(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
bodyKey string
|
||||
want string
|
||||
}{
|
||||
{"pi 默认格式 x-session-affinity", map[string]string{"X-Session-Affinity": "ses-1"}, "", "ses-1"},
|
||||
{"openrouter 格式 x-session-id", map[string]string{"X-Session-Id": "ses-2"}, "", "ses-2"},
|
||||
{"openai 格式 session_id", map[string]string{"Session-Id": "ses-3"}, "", "ses-3"},
|
||||
{"body prompt_cache_key 兜底", nil, "cache-4", "cache-4"},
|
||||
{"头优先于 body", map[string]string{"X-Session-Affinity": "ses-5"}, "cache-x", "ses-5"},
|
||||
{"空白不算", map[string]string{"X-Session-Affinity": " "}, "", ""},
|
||||
{"都没有则为空(适配器自行推导)", nil, "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
for k, v := range tc.headers {
|
||||
r.Header.Set(k, v)
|
||||
}
|
||||
if got := clientSessionFromRequest(r, tc.bodyKey); got != tc.want {
|
||||
t.Errorf("clientSessionFromRequest = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 刻意不采纳 x-client-request-id:名字含 request,部分客户端每请求都换,
|
||||
// 拿它当会话会让上游前缀缓存永不命中。
|
||||
func TestClientRequestIDIsNotUsedAsSession(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
r.Header.Set("X-Client-Request-Id", "per-request-uuid")
|
||||
if got := clientSessionFromRequest(r, ""); got != "" {
|
||||
t.Errorf("x-client-request-id 不应被当作会话(否则缓存永不命中),got %q", got)
|
||||
}
|
||||
}
|
||||
@ -94,3 +94,74 @@ func TestOverflowPhraseSurvivesTruncationWidth(t *testing.T) {
|
||||
t.Fatalf("归一化标记丢失: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// piNonOverflowRe 是 @earendil-works/pi-ai 的 NON_OVERFLOW_PATTERNS。
|
||||
// pi 的 isContextOverflow 先查这一组,命中则直接判为「非超窗」——
|
||||
// 即使 OVERFLOW_PATTERNS 也命中,也不会压缩重试。
|
||||
var piNonOverflowRe = []*regexp.Regexp{
|
||||
regexp.MustCompile(`^(Throttling error|Service unavailable):`),
|
||||
regexp.MustCompile(`(?i)rate limit`),
|
||||
regexp.MustCompile(`(?i)too many requests`),
|
||||
}
|
||||
|
||||
// piWouldCompact 复刻 isContextOverflow 的 Case 1 判定顺序:
|
||||
// 先排除 NON_OVERFLOW,再匹配 OVERFLOW。
|
||||
func piWouldCompact(errorMessage string) bool {
|
||||
for _, p := range piNonOverflowRe {
|
||||
if p.MatchString(errorMessage) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return piOverflowRe.MatchString(errorMessage)
|
||||
}
|
||||
|
||||
// issue 回归:AUTO 链失败消息天然把「超窗 tier」和「配额/限流 tier」拼在一起,
|
||||
// 超窗标记后面只要出现 rate limit / too many requests 的字样,
|
||||
// pi 就会把整条消息判为「非超窗」而拒绝压缩重试。
|
||||
func TestOverflowMessageSurvivesRateLimitedSiblingTier(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
sibling string
|
||||
}{
|
||||
{"rate limit 措辞", "api error 429: rate limit exceeded"},
|
||||
{"too many requests 措辞", "429 Too Many Requests"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := &scheduler.ChainErr{Tiers: []scheduler.TierError{
|
||||
{Tier: 1, Source: "sensenova", Model: "glm-5.2", Err: errors.New(tc.sibling)},
|
||||
{Tier: 3, Source: "gozen", Model: "deepseek-v4.1-flash",
|
||||
Err: errors.New("api error 400: This model's maximum context length is 1048576 tokens")},
|
||||
}}
|
||||
|
||||
got := clientUpstreamErr(err)
|
||||
if !piWouldCompact(got) {
|
||||
t.Fatalf("同链限流 tier 封杀了超窗识别,pi 不会压缩重试: %q", got)
|
||||
}
|
||||
// 干净:不得把限流 tier 的措辞带出去。
|
||||
for _, p := range piNonOverflowRe {
|
||||
if p.MatchString(got) {
|
||||
t.Fatalf("超窗消息仍带限流措辞 %v: %q", p, got)
|
||||
}
|
||||
}
|
||||
// 但仍要能看出是哪个源超的窗。
|
||||
if !strings.Contains(got, "gozen/deepseek-v4.1-flash") {
|
||||
t.Errorf("超窗源信息丢失: %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 直连超窗同样要干净可用。
|
||||
func TestDirectOverflowMessageIsClean(t *testing.T) {
|
||||
raw := "api error 400: Error from provider (Console Go): This model's maximum context length is 1048576 tokens. " +
|
||||
"However, you requested 1440050 tokens. Please reduce the length of the messages or completion."
|
||||
got := clientUpstreamErr(errors.New(raw))
|
||||
if !piWouldCompact(got) {
|
||||
t.Fatalf("直连超窗未被 pi 识别: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, overflowCanonical) {
|
||||
t.Fatalf("缺少归一化标记: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,33 @@ local function conversation_fingerprint(body)
|
||||
return ""
|
||||
end
|
||||
|
||||
-- session_seed 决定上游会话号(x-opencode-session 的种子)。
|
||||
--
|
||||
-- 优先级:
|
||||
-- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启
|
||||
-- compat.sendSessionAffinityHeaders 后会发 x-session-affinity,
|
||||
-- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。
|
||||
-- 2) 退路:历史里**第一条 user 消息**做会话指纹。
|
||||
-- 3) 再退:只按源固定(连 user 消息都没有时)。
|
||||
--
|
||||
-- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包
|
||||
-- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有
|
||||
-- user / session_id / conversation_id / metadata,请求头也只有
|
||||
-- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套
|
||||
-- x-opencode-session 是它自己的概念,通用客户端无从转发。
|
||||
--
|
||||
-- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变,
|
||||
-- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。
|
||||
local function session_seed(meta)
|
||||
local src = (meta.source and meta.source.name) or ""
|
||||
local base = "session|llmsproxy|" .. src
|
||||
local cs = meta.client_session
|
||||
if type(cs) == "string" and cs ~= "" then
|
||||
return base .. "|client|" .. cs
|
||||
end
|
||||
return base .. "|" .. conversation_fingerprint(meta.body)
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local ts = tostring(meta.timestamp or "")
|
||||
local src = (meta.source and meta.source.name) or ""
|
||||
@ -76,7 +103,7 @@ function adapter.build_headers(meta)
|
||||
-- 每请求换 session -> 永远 0 命中
|
||||
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
|
||||
-- 上游也无法做会话亲和路由。
|
||||
["x-opencode-session"] = rand_id("ses_", "session|llmsproxy|" .. src .. "|" .. conversation_fingerprint(meta.body)),
|
||||
["x-opencode-session"] = rand_id("ses_", session_seed(meta)),
|
||||
-- request id 仍每请求唯一(它只是请求标识,不参与缓存键)
|
||||
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
||||
}
|
||||
@ -194,6 +221,19 @@ function adapter.transform_response(raw_body)
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
|
||||
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
|
||||
local ctd = resp.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
|
||||
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
|
||||
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
|
||||
if resp.cost ~= nil then
|
||||
unified.cost = tostring(resp.cost)
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
@ -240,22 +280,36 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
|
||||
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
|
||||
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
|
||||
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
|
||||
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
|
||||
end
|
||||
if (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 }
|
||||
if uses.prompt_tokens_details == nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
local ctd = chunk.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_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 ""
|
||||
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
|
||||
-- {"choices":[],"usage":{...}} —— 终帧用量
|
||||
-- {"choices":[],"cost":"0"} —— 单独的费用帧
|
||||
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
|
||||
local out = { done = false }
|
||||
if uses ~= nil then out.usage = uses end
|
||||
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
|
||||
if uses == nil and chunk.cost == nil then return "" end
|
||||
return json.encode(out)
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
@ -63,6 +63,33 @@ local function conversation_fingerprint(body)
|
||||
return ""
|
||||
end
|
||||
|
||||
-- session_seed 决定上游会话号(x-opencode-session 的种子)。
|
||||
--
|
||||
-- 优先级:
|
||||
-- 1) 客户端自带的会话标识(meta.client_session)——pi 等在开启
|
||||
-- compat.sendSessionAffinityHeaders 后会发 x-session-affinity,
|
||||
-- 这是平台真实的会话 id,整个会话恒定且天然按会话隔离。
|
||||
-- 2) 退路:历史里**第一条 user 消息**做会话指纹。
|
||||
-- 3) 再退:只按源固定(连 user 消息都没有时)。
|
||||
--
|
||||
-- 为什么需要 2/3:通用 OpenAI 客户端默认**根本不发**会话标识 —— 实测抓包
|
||||
-- (tcpdump 抓 127.0.0.1:8081 真实 agent 请求)确认 body 里没有
|
||||
-- user / session_id / conversation_id / metadata,请求头也只有
|
||||
-- X-Stainless-*(OpenAI JS SDK)与 User-Agent。opencode 原生客户端那套
|
||||
-- x-opencode-session 是它自己的概念,通用客户端无从转发。
|
||||
--
|
||||
-- 为什么必须稳定:上游前缀缓存是**会话级**的。会话号每请求一变,
|
||||
-- 缓存永不命中(实测:固定会号第 2 次命中 5888,每请求换会号则恒为 0)。
|
||||
local function session_seed(meta)
|
||||
local src = (meta.source and meta.source.name) or ""
|
||||
local base = "session|llmsproxy|" .. src
|
||||
local cs = meta.client_session
|
||||
if type(cs) == "string" and cs ~= "" then
|
||||
return base .. "|client|" .. cs
|
||||
end
|
||||
return base .. "|" .. conversation_fingerprint(meta.body)
|
||||
end
|
||||
|
||||
function adapter.build_headers(meta)
|
||||
local ts = tostring(meta.timestamp or "")
|
||||
local src = (meta.source and meta.source.name) or ""
|
||||
@ -77,7 +104,7 @@ function adapter.build_headers(meta)
|
||||
-- 每请求换 session -> 永远 0 命中
|
||||
-- 原先用 meta.timestamp 派生,等于每请求都是新会话,缓存永远无效,
|
||||
-- 上游也无法做会话亲和路由。
|
||||
["x-opencode-session"] = rand_id("ses_", "session|llmsproxy|" .. src .. "|" .. conversation_fingerprint(meta.body)),
|
||||
["x-opencode-session"] = rand_id("ses_", session_seed(meta)),
|
||||
-- request id 仍每请求唯一(它只是请求标识,不参与缓存键)
|
||||
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
|
||||
}
|
||||
@ -182,6 +209,19 @@ function adapter.transform_response(raw_body)
|
||||
unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
-- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断
|
||||
-- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。
|
||||
local ctd = resp.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens }
|
||||
end
|
||||
end
|
||||
|
||||
-- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。
|
||||
-- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。
|
||||
-- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。
|
||||
if resp.cost ~= nil then
|
||||
unified.cost = tostring(resp.cost)
|
||||
end
|
||||
|
||||
if type(resp.choices) == "table" and #resp.choices > 0 then
|
||||
@ -228,22 +268,36 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
completion = chunk.usage.completion_tokens or chunk.usage.completion or 0,
|
||||
total = chunk.usage.total_tokens or chunk.usage.total or 0,
|
||||
}
|
||||
-- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif:
|
||||
-- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让
|
||||
-- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致
|
||||
-- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。
|
||||
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
|
||||
end
|
||||
if (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 }
|
||||
if uses.prompt_tokens_details == nil then
|
||||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||||
end
|
||||
end
|
||||
local ctd = chunk.usage.completion_tokens_details
|
||||
if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then
|
||||
uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_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 ""
|
||||
-- 空 choices 的帧既不是内容也不是结束信号。上游发两种:
|
||||
-- {"choices":[],"usage":{...}} —— 终帧用量
|
||||
-- {"choices":[],"cost":"0"} —— 单独的费用帧
|
||||
-- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。
|
||||
local out = { done = false }
|
||||
if uses ~= nil then out.usage = uses end
|
||||
if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end
|
||||
if uses == nil and chunk.cost == nil then return "" end
|
||||
return json.encode(out)
|
||||
end
|
||||
local delta = chunk.choices[1].delta or {}
|
||||
local fr = chunk.choices[1].finish_reason
|
||||
|
||||
@ -301,3 +301,53 @@ func TestOpenCodeSessionIsStableForCache(t *testing.T) {
|
||||
vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端自带的会话标识(pi 的 x-session-affinity)必须优先于会话指纹:
|
||||
// 它是平台真实会话 id,历史压缩后也不会漂移,且天然按会话隔离。
|
||||
func TestOpenCodePrefersClientSessionID(t *testing.T) {
|
||||
for _, name := range []string{"opencodego", "opencodezen"} {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := func(body, cs string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"timestamp": 1000,
|
||||
"body": body,
|
||||
"client_session": cs,
|
||||
"source": map[string]interface{}{"name": "somesource", "meta": nil},
|
||||
}
|
||||
}
|
||||
|
||||
// 同一条会话:历史在长,但客户端会话 id 不变。
|
||||
a, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-aaa"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"a totally different opener"}]}`, "cs-aaa"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a["x-opencode-session"] != b["x-opencode-session"] {
|
||||
t.Errorf("%s: 客户端会话 id 相同就必须同会话(内容无关): %q vs %q",
|
||||
name, a["x-opencode-session"], b["x-opencode-session"])
|
||||
}
|
||||
|
||||
// 不同会话。
|
||||
c, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, "cs-bbb"))
|
||||
if a["x-opencode-session"] == c["x-opencode-session"] {
|
||||
t.Errorf("%s: 不同客户端会话必须不同会号", name)
|
||||
}
|
||||
|
||||
// 客户端没给会话 id 时,回落到指纹(同内容 -> 同会号)。
|
||||
d, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, ""))
|
||||
e, _ := vm.BuildHeaders(name, meta(`{"messages":[{"role":"user","content":"fix the bug"}]}`, ""))
|
||||
if d["x-opencode-session"] != e["x-opencode-session"] {
|
||||
t.Errorf("%s: 无客户端会号时应回落到稳定指纹", name)
|
||||
}
|
||||
if d["x-opencode-session"] == a["x-opencode-session"] {
|
||||
t.Errorf("%s: 客户端会号与指纹两条路必须产出不同会号", name)
|
||||
}
|
||||
vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
177
internal/lua/usage_cost_test.go
Normal file
177
internal/lua/usage_cost_test.go
Normal file
@ -0,0 +1,177 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// OpenCode Zen/Go 在 usage 里报告输出有多少是思考 token,并在**顶层**用一个
|
||||
// **字符串**给出本次费用。前者决定「completion_tokens 里多少是可见回答」,
|
||||
// 后者是唯一的费用信号。两者都曾被适配器丢掉。
|
||||
func TestOpenCodeCostAndReasoningPassthrough(t *testing.T) {
|
||||
for _, name := range []string{"opencodego", "opencodezen"} {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ---- 非流式 ----
|
||||
resp := `{"id":"x","object":"chat.completion","model":"deepseek-v4.1-flash",
|
||||
"choices":[{"index":0,"message":{"role":"assistant","content":"hi","reasoning_content":"think"},
|
||||
"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":37,"completion_tokens":40,"total_tokens":77,
|
||||
"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":37,
|
||||
"prompt_tokens_details":{"cached_tokens":0},
|
||||
"completion_tokens_details":{"reasoning_tokens":40}},
|
||||
"cost":"0"}`
|
||||
out, err := vm.Transform(name, "transform_response", resp)
|
||||
if err != nil {
|
||||
t.Fatalf("%s transform_response: %v", name, err)
|
||||
}
|
||||
var r struct {
|
||||
TokenUsage struct {
|
||||
Completion int `json:"completion"`
|
||||
Details *struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
} `json:"completion_tokens_details"`
|
||||
} `json:"token_usage"`
|
||||
Cost string `json:"cost"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(out), &r); err != nil {
|
||||
t.Fatalf("%s unmarshal: %v (%s)", name, err, out)
|
||||
}
|
||||
if r.Cost != "0" {
|
||||
t.Errorf("%s: cost 未透传(上游给的是字符串 \"0\"): %q", name, r.Cost)
|
||||
}
|
||||
if r.TokenUsage.Details == nil || r.TokenUsage.Details.ReasoningTokens != 40 {
|
||||
t.Errorf("%s: completion_tokens_details.reasoning_tokens 未透传: %s", name, out)
|
||||
}
|
||||
// 前提校验:reasoning_tokens 确实 ≤ completion_tokens(全为思考)。
|
||||
if r.TokenUsage.Details != nil && r.TokenUsage.Details.ReasoningTokens > r.TokenUsage.Completion {
|
||||
t.Errorf("%s: reasoning 不应超过 completion", name)
|
||||
}
|
||||
|
||||
// ---- 流式:非零费用也要透传 ----
|
||||
chunk := `{"id":"x","object":"chat.completion.chunk","choices":[],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,
|
||||
"completion_tokens_details":{"reasoning_tokens":4}},"cost":"0.0012"}`
|
||||
co, err := vm.Transform(name, "transform_stream_chunk", chunk)
|
||||
if err != nil {
|
||||
t.Fatalf("%s transform_stream_chunk: %v", name, err)
|
||||
}
|
||||
var c struct {
|
||||
Cost string `json:"cost"`
|
||||
Usage *struct {
|
||||
Details *struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
} `json:"completion_tokens_details"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(co), &c); err != nil {
|
||||
t.Fatalf("%s unmarshal chunk: %v (%s)", name, err, co)
|
||||
}
|
||||
if c.Usage == nil || c.Usage.Details == nil || c.Usage.Details.ReasoningTokens != 4 {
|
||||
t.Errorf("%s: 流式 reasoning_tokens 未透传: %s", name, co)
|
||||
}
|
||||
if c.Cost != "0.0012" {
|
||||
t.Errorf("%s: 流式 cost 未透传: %s", name, co)
|
||||
}
|
||||
|
||||
// ---- 独立的 cost 帧(上游就这么发:choices 为空、只有 cost)----
|
||||
only := `{"choices":[],"cost":"0"}`
|
||||
oo, err := vm.Transform(name, "transform_stream_chunk", only)
|
||||
if err != nil {
|
||||
t.Fatalf("%s cost-only chunk: %v", name, err)
|
||||
}
|
||||
if oo == "" {
|
||||
t.Fatalf("%s: 纯 cost 帧被整个丢弃(客户端将看不到费用)", name)
|
||||
}
|
||||
var oc struct {
|
||||
Cost string `json:"cost"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(oo), &oc); err != nil {
|
||||
t.Fatalf("%s unmarshal cost chunk: %v (%s)", name, err, oo)
|
||||
}
|
||||
if oc.Cost != "0" {
|
||||
t.Errorf("%s: 纯 cost 帧内容丢失: %s", name, oo)
|
||||
}
|
||||
|
||||
// ---- 回归:没有 cost / 没有 details 时不得凭空造字段 ----
|
||||
bare := `{"choices":[{"index":0,"delta":{"content":"hi"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}}`
|
||||
bo, _ := vm.Transform(name, "transform_response", bare)
|
||||
if got := bo; len(got) > 0 && containsAll(got, `"cost"`) {
|
||||
t.Errorf("%s: 上游没给 cost 却透传了该字段: %s", name, got)
|
||||
}
|
||||
|
||||
vm.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func containsAll(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
found := false
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 流式与非流式对同一上游切片的产出必须一致:命中缓存时上游同时给
|
||||
// prompt_tokens_details.cached_tokens 和独立的 hit/miss 字段,两边都要透传。
|
||||
func TestOpenCodeStreamCacheFieldsMatchNonStream(t *testing.T) {
|
||||
for _, name := range []string{"opencodego", "opencodezen"} {
|
||||
vm := NewVM(freshAdapterDir(t))
|
||||
if err := vm.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type cache struct {
|
||||
Details *struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
Hit int `json:"prompt_cache_hit_tokens"`
|
||||
Miss int `json:"prompt_cache_miss_tokens"`
|
||||
}
|
||||
usage := `"usage":{"prompt_tokens":5732,"completion_tokens":20,"total_tokens":5752,
|
||||
"prompt_cache_hit_tokens":5504,"prompt_cache_miss_tokens":228,
|
||||
"prompt_tokens_details":{"cached_tokens":5504}}`
|
||||
|
||||
// 非流式
|
||||
nout, _ := vm.Transform(name, "transform_response",
|
||||
`{"choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],`+usage+`}`)
|
||||
var nr struct {
|
||||
U cache `json:"token_usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(nout), &nr); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
// 流式
|
||||
sout, _ := vm.Transform(name, "transform_stream_chunk",
|
||||
`{"choices":[],`+usage+`}`)
|
||||
var sr struct {
|
||||
U cache `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(sout), &sr); err != nil {
|
||||
t.Fatalf("%s chunk: %v (%s)", name, err, sout)
|
||||
}
|
||||
|
||||
if nr.U.Hit != 5504 || nr.U.Miss != 228 {
|
||||
t.Errorf("%s: 非流式 hit/miss 异常: %+v", name, nr.U)
|
||||
}
|
||||
if sr.U.Hit != nr.U.Hit || sr.U.Miss != nr.U.Miss {
|
||||
t.Errorf("%s: 流式 hit/miss 与非流式不一致 (stream %d/%d vs non-stream %d/%d): %s",
|
||||
name, sr.U.Hit, sr.U.Miss, nr.U.Hit, nr.U.Miss, sout)
|
||||
}
|
||||
if sr.U.Details == nil || sr.U.Details.CachedTokens != 5504 {
|
||||
t.Errorf("%s: 流式 cached_tokens 丢失: %s", name, sout)
|
||||
}
|
||||
vm.Stop()
|
||||
}
|
||||
}
|
||||
@ -707,7 +707,7 @@ func (p *Provider) probeModels(ctx context.Context) (bool, string) {
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
if hdrs, herr := p.buildHeaders("{}", u); herr == nil {
|
||||
if hdrs, herr := p.buildHeaders("{}", u, ""); herr == nil {
|
||||
req.Header = hdrs
|
||||
}
|
||||
resp, err := p.client.Do(req)
|
||||
@ -750,7 +750,7 @@ func (p *Provider) probeChat(ctx context.Context) (bool, string) {
|
||||
body, err := json.Marshal(probe)
|
||||
if err == nil {
|
||||
var hdr http.Header
|
||||
if hdrs, herr := p.buildHeaders(string(body), p.URL()); herr == nil {
|
||||
if hdrs, herr := p.buildHeaders(string(body), p.URL(), ""); herr == nil {
|
||||
hdr = hdrs
|
||||
}
|
||||
raw, status, derr := p.do(ctx, p.URL(), string(body), hdr)
|
||||
@ -1031,13 +1031,15 @@ func (p *Provider) Throttle(ctx context.Context) error {
|
||||
|
||||
// ---- request construction ----
|
||||
|
||||
func (p *Provider) buildHeaders(body, url string) (http.Header, error) {
|
||||
func (p *Provider) buildHeaders(body, url, clientSession string) (http.Header, error) {
|
||||
meta := map[string]interface{}{
|
||||
"url": url,
|
||||
"method": http.MethodPost,
|
||||
"body": body,
|
||||
"api_key": p.cfg.APIKey,
|
||||
"timestamp": types.Now(),
|
||||
// client_session 是客户端自带的会话标识(请求头),为空时适配器自行推导。
|
||||
"client_session": clientSession,
|
||||
"source": map[string]interface{}{
|
||||
"name": p.cfg.Name,
|
||||
"meta": p.cfg.Meta,
|
||||
@ -1082,7 +1084,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hdrs, err := p.buildHeaders(body, p.URL())
|
||||
hdrs, err := p.buildHeaders(body, p.URL(), req.ClientSession)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -1142,7 +1144,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
p.Release()
|
||||
return nil, err
|
||||
}
|
||||
hdrs, err := p.buildHeaders(body, p.URL())
|
||||
hdrs, err := p.buildHeaders(body, p.URL(), req.ClientSession)
|
||||
if err != nil {
|
||||
p.Release()
|
||||
return nil, err
|
||||
@ -1411,7 +1413,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type
|
||||
// fall back to passthrough adapter (openai-style)
|
||||
transformed = string(b)
|
||||
}
|
||||
hdrs, err := p.buildHeaders(transformed, p.ImageURL())
|
||||
hdrs, err := p.buildHeaders(transformed, p.ImageURL(), "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -1516,12 +1518,24 @@ func standardSSEChunk(data string) string {
|
||||
PromptTokensDetails *struct {
|
||||
CachedTokens int `json:"cached_tokens"`
|
||||
} `json:"prompt_tokens_details"`
|
||||
CompletionTokensDetails *struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
} `json:"completion_tokens_details"`
|
||||
} `json:"usage"`
|
||||
// Cost is a top-level decimal string on OpenCode Zen/Go responses.
|
||||
Cost json.RawMessage `json:"cost"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(data), &raw); err != nil {
|
||||
return ""
|
||||
}
|
||||
if len(raw.Choices) == 0 && raw.UpstreamUsage.Total == 0 && raw.UpstreamUsage.TotalTokens == 0 {
|
||||
// Cost-only chunk: OpenCode sends the charge on its own frame
|
||||
// {"choices":[],"cost":"0"} with no usage at all. Dropping it here
|
||||
// would lose the only cost signal the client can see.
|
||||
if c := rawCostString(raw.Cost); c != "" {
|
||||
out, _ := json.Marshal(types.UnifiedChunk{Cost: c})
|
||||
return string(out)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
var usage *types.TokenUsage
|
||||
@ -1539,6 +1553,11 @@ func standardSSEChunk(data string) string {
|
||||
CachedTokens: pu.PromptTokensDetails.CachedTokens,
|
||||
}
|
||||
}
|
||||
if pu.CompletionTokensDetails != nil {
|
||||
usage.CompletionTokensDetails = &types.CompletionTokensDetails{
|
||||
ReasoningTokens: pu.CompletionTokensDetails.ReasoningTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
finish := ""
|
||||
done := false
|
||||
@ -1563,6 +1582,24 @@ func standardSSEChunk(data string) string {
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// rawCostString normalizes an upstream cost field into a plain string. The
|
||||
// value is a decimal **string** on OpenCode Zen/Go ("0", "0.0012"), but other
|
||||
// upstreams have been seen to send a bare number, so both are accepted.
|
||||
// Returns "" when absent, null, or empty.
|
||||
func rawCostString(raw json.RawMessage) string {
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if s == "" || s == "null" {
|
||||
return ""
|
||||
}
|
||||
if s[0] == '"' {
|
||||
var unq string
|
||||
if json.Unmarshal(raw, &unq) == nil {
|
||||
return strings.TrimSpace(unq)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pickFirst returns a if non-zero, else b (for usage keys that may appear in
|
||||
// either standard *_tokens or legacy short form).
|
||||
func pickFirst(a, b int) int {
|
||||
|
||||
@ -29,6 +29,10 @@ type ChatRequest struct {
|
||||
ToolChoice interface{} `json:"tool_choice,omitempty"`
|
||||
DisableThinking bool `json:"disable_thinking"`
|
||||
ExtraBody map[string]interface{} `json:"-"`
|
||||
|
||||
// ClientSession 是客户端自带的会话标识(请求头 x-session-affinity 等)。
|
||||
// 不参与序列化:它只用于让适配器生成稳定的上游会话,不应泄漏给上游 body。
|
||||
ClientSession string `json:"-"`
|
||||
}
|
||||
|
||||
func (r *ChatRequest) MarshalJSON() ([]byte, error) {
|
||||
@ -113,6 +117,11 @@ type UnifiedResponse struct {
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
// ImageData used by image-generation adapters.
|
||||
ImageData []ImageData `json:"image_data,omitempty"`
|
||||
// Cost is the upstream-reported charge for this request, passed through
|
||||
// verbatim (OpenCode Zen/Go put a decimal **string** at the response top
|
||||
// level). Deliberately not parsed or summed by the gateway: it is an
|
||||
// upstream fact, and only some upstreams report it at all.
|
||||
Cost string `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
type TokenUsage struct {
|
||||
@ -129,6 +138,16 @@ type TokenUsage struct {
|
||||
// prompt_tokens_details.cached_tokens is absent.
|
||||
PromptCacheHit int `json:"prompt_cache_hit_tokens,omitempty"`
|
||||
PromptCacheMiss int `json:"prompt_cache_miss_tokens,omitempty"`
|
||||
// CompletionTokensDetails mirrors the OpenAI v2 usage.completion_tokens_details
|
||||
// object. OpenCode Zen/Go report how much of the completion was thinking
|
||||
// tokens here; without it clients cannot tell how much of the billed output
|
||||
// was reasoning rather than visible answer.
|
||||
CompletionTokensDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"`
|
||||
}
|
||||
|
||||
// CompletionTokensDetails is the OpenAI v2 completion_tokens_details object.
|
||||
type CompletionTokensDetails struct {
|
||||
ReasoningTokens int `json:"reasoning_tokens"`
|
||||
}
|
||||
|
||||
// PromptTokensDetails is the OpenAI v2 prompt_tokens_details object. Only
|
||||
@ -153,15 +172,16 @@ func (t TokenUsage) MarshalJSON() ([]byte, error) {
|
||||
pdetails = &PromptTokensDetails{CachedTokens: t.PromptCacheHit}
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
||||
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
Prompt int `json:"prompt"`
|
||||
Completion int `json:"completion"`
|
||||
Total int `json:"total"`
|
||||
PromptTokensDetails *PromptTokensDetails `json:"prompt_tokens_details,omitempty"`
|
||||
PromptCacheHitTokens int `json:"prompt_cache_hit_tokens,omitempty"`
|
||||
PromptCacheMissTokens int `json:"prompt_cache_miss_tokens,omitempty"`
|
||||
CompletionDetails *CompletionTokensDetails `json:"completion_tokens_details,omitempty"`
|
||||
}{
|
||||
PromptTokens: t.Prompt,
|
||||
CompletionTokens: t.Completion,
|
||||
@ -172,6 +192,7 @@ func (t TokenUsage) MarshalJSON() ([]byte, error) {
|
||||
PromptTokensDetails: pdetails,
|
||||
PromptCacheHitTokens: t.PromptCacheHit,
|
||||
PromptCacheMissTokens: t.PromptCacheMiss,
|
||||
CompletionDetails: t.CompletionTokensDetails,
|
||||
})
|
||||
}
|
||||
|
||||
@ -215,6 +236,9 @@ type UnifiedChunk struct {
|
||||
// often has empty choices). Gateway uses it to emit exact usage in the
|
||||
// final stream chunk instead of estimates.
|
||||
Usage *TokenUsage `json:"usage,omitempty"`
|
||||
// Cost is upstream-reported charge for this request (decimal string),
|
||||
// passed through verbatim; OpenCode sends it on its own stream chunk.
|
||||
Cost string `json:"cost,omitempty"`
|
||||
}
|
||||
|
||||
// Meta passed to Lua build_headers hook
|
||||
|
||||
@ -2,6 +2,7 @@ package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -117,3 +118,37 @@ func TestChatMessageNormalizesEmptyArrayContent(t *testing.T) {
|
||||
t.Fatalf("empty tool_calls array must be dropped, got %s", om.ToolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// 上游报告输出里有多少是思考 token;缺少这个字段,客户端无法判断
|
||||
// completion_tokens 里多少是可见回答、多少是思考(两者都按输出计费)。
|
||||
func TestTokenUsageMarshalsCompletionTokensDetails(t *testing.T) {
|
||||
u := TokenUsage{
|
||||
Prompt: 37, Completion: 40, Total: 77,
|
||||
CompletionTokensDetails: &CompletionTokensDetails{ReasoningTokens: 40},
|
||||
}
|
||||
b, err := json.Marshal(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(b, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctd, ok := got["completion_tokens_details"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("completion_tokens_details 未输出: %s", b)
|
||||
}
|
||||
if n, _ := ctd["reasoning_tokens"].(float64); int(n) != 40 {
|
||||
t.Errorf("reasoning_tokens = %v, want 40 (%s)", ctd["reasoning_tokens"], b)
|
||||
}
|
||||
// 标准 OpenAI 字段同时存在,老客户端不受影响。
|
||||
if _, ok := got["completion_tokens"]; !ok {
|
||||
t.Errorf("标准 completion_tokens 丢失: %s", b)
|
||||
}
|
||||
|
||||
// 未设置时不得凭空造字段。
|
||||
b2, _ := json.Marshal(TokenUsage{Prompt: 1, Completion: 2, Total: 3})
|
||||
if strings.Contains(string(b2), "completion_tokens_details") {
|
||||
t.Errorf("无数据时不应输出 completion_tokens_details: %s", b2)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user