mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
产品名早已是 ModelRouter,但仓库标识符仍沿用旧名。这次统一,读写两侧都做兼容, 已部署的机器不会因为顺序问题立刻失效。 - go module `llmsproxy` → `modelrouter`;全仓 import 与 `cmd/modelrouter` 同步。 - `Makefile BINARY=modelrouter`;发行包名、systemd unit、/usr/share 资源、 `/usr/local/bin` 目标全部改为 modelrouter。 - 默认配置目录 `/etc/llmsproxy` → `/etc/modelrouter`。**读取侧回退**:插件与 `storeDir` 仍会检查旧路径,所以"先换二进制、后改 unit"不会立即失效。 - 环境变量 `LLMSPROXY_*`(PLUGIN_DIR / XDISPLAY_BASE / ARCH / LIVE_ZEN / BUILD_DIR)改为 `MODELROUTER_*`;`LLMS_PROXY_MASTER_KEY` → `MODELROUTER_MASTER_KEY`。旧名在读取侧继续接受。 - 桌面端内嵌核心 `bin/llmsproxy` → `bin/modelrouter`,并保留旧文件名回退, 避免升级到一半的 profile 被判成"core binary missing"。 - 新增 `packaging/migrate-to-modelrouter.sh`:**默认 dry-run**,复制而非移动、 旧目录永不删除、支持 `--rollback`;随 release tar 分发并由 core-dist 校验。 - `deploy.sh` 增加改名保护:发现旧 `/etc/llmsproxy` 而新配置不存在时**中止**, 否则会部署出一个"能启动但没有 key、没有源"的空实例。 - 临时目录前缀 `/tmp/llmsproxy-*` → `/tmp/modelrouter-*`;清理逻辑同时回收两代。 兼容别名(`__llmsproxy_*` 全局、`llmsproxy` SDK 别名、旧路径/旧 env)刻意保留, 它们是升级路径而非遗留;docs/plugins.md 已标注为废弃。 本提交未包含:`adapters/opencodezen.lua`、`adapter_opencodezen_test.go`、 `adapter_opencodezen_live_test.go`(OpenCode UA/免费池的既有改动,另批提交)。
161 lines
6.1 KiB
Go
161 lines
6.1 KiB
Go
package gateway
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"modelrouter/internal/types"
|
|
)
|
|
|
|
// TestRecordChatUsageKeepsReasoningTokens pins the reasoning-token plumbing.
|
|
//
|
|
// The adapters already parse completion_tokens_details.reasoning_tokens and
|
|
// forward it (qwen38, gemini, anthropic all do), and types.go has carried
|
|
// the field since it was defined — but nothing ever READ it. The number arrived
|
|
// in the response and was discarded at the one place that could have kept it,
|
|
// so a pricing rule had no way to split thinking from output.
|
|
//
|
|
// The property that matters most: reasoning is a SUBSET of completion. Every
|
|
// provider measured bills it at the completion rate, so a billing plugin that
|
|
// added it to completion_tokens would double-charge every thinking token. The
|
|
// test asserts completion is left alone.
|
|
func TestRecordChatUsageKeepsReasoningTokens(t *testing.T) {
|
|
rec := &Req{}
|
|
resp := &types.UnifiedResponse{
|
|
TokenUsage: types.TokenUsage{
|
|
Prompt: 100,
|
|
Completion: 500,
|
|
CompletionTokensDetails: &types.CompletionTokensDetails{
|
|
ReasoningTokens: 320,
|
|
},
|
|
},
|
|
}
|
|
recordChatUsage(rec, &types.ChatRequest{}, resp)
|
|
|
|
if rec.Reasoning != 320 {
|
|
t.Errorf("Reasoning = %d, want 320", rec.Reasoning)
|
|
}
|
|
// The subset must not be folded into completion — it is already counted there.
|
|
if rec.Compl != 500 {
|
|
t.Errorf("Compl = %d, want 500 (reasoning is a subset, never additive)", rec.Compl)
|
|
}
|
|
if rec.Prompt != 100 {
|
|
t.Errorf("Prompt = %d, want 100", rec.Prompt)
|
|
}
|
|
}
|
|
|
|
// TestRecordChatUsageReasoningAbsentStaysZero: "not reported" and "reported as
|
|
// zero" must both leave the field at 0, and neither may invent a number. A
|
|
// plugin reading Reasoning cannot tell them apart, which is why the field is
|
|
// documented as "0 means not reported" rather than "0 means no thinking".
|
|
func TestRecordChatUsageReasoningAbsentStaysZero(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
u types.TokenUsage
|
|
}{
|
|
{"no details object", types.TokenUsage{Prompt: 10, Completion: 20}},
|
|
{"details without reasoning", types.TokenUsage{
|
|
Prompt: 10,
|
|
Completion: 20,
|
|
CompletionTokensDetails: &types.CompletionTokensDetails{},
|
|
}},
|
|
} {
|
|
rec := &Req{}
|
|
recordChatUsage(rec, &types.ChatRequest{}, &types.UnifiedResponse{TokenUsage: tc.u})
|
|
if rec.Reasoning != 0 {
|
|
t.Errorf("%s: Reasoning = %d, want 0", tc.name, rec.Reasoning)
|
|
}
|
|
if rec.Compl != 20 {
|
|
t.Errorf("%s: Compl = %d, want 20", tc.name, rec.Compl)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStreamedReasoningIsRecordedOnTheRequest is the streaming counterpart.
|
|
//
|
|
// TestRecordChatUsageKeepsReasoningTokens covers the non-streaming path only,
|
|
// and for a long time the streamed path dropped the number: the non-streaming
|
|
// recorder read TokenUsage.CompletionTokensDetails, while the streaming one read
|
|
// only Prompt and Completion from the terminal usage chunk. (Cache accounting was
|
|
// mirrored; reasoning simply never was.)
|
|
//
|
|
// The symptom is invisible in the response — the client sees a correct answer
|
|
// either way. It only shows in the audit trail, where a reasoning-heavy source's
|
|
// streamed requests recorded completion=8166 and no reasoning at all. Found by
|
|
// reading the production audit file, not by a failing test, which is exactly why
|
|
// the property is pinned here.
|
|
func TestStreamedReasoningIsRecordedOnTheRequest(t *testing.T) {
|
|
rec := &Req{}
|
|
applyStreamUsageDetails(rec, &types.TokenUsage{
|
|
Prompt: 159579,
|
|
Completion: 751,
|
|
Total: 160330,
|
|
CompletionTokensDetails: &types.CompletionTokensDetails{
|
|
ReasoningTokens: 512,
|
|
},
|
|
})
|
|
if rec.Reasoning != 512 {
|
|
t.Errorf("streamed Reasoning = %d, want 512 — a streamed request records no "+
|
|
"reasoning, so a pricing rule that splits thinking from output silently "+
|
|
"bills it as ordinary output", rec.Reasoning)
|
|
}
|
|
}
|
|
|
|
// TestStreamedReasoningIsNeverAddedToCompletion pins the SUBSET relation in the
|
|
// streaming path specifically. A presence check would pass if someone wrote
|
|
// rec.Compl += reasoning, and billing would then charge every thinking token
|
|
// twice.
|
|
func TestStreamedReasoningIsNeverAddedToCompletion(t *testing.T) {
|
|
rec := &Req{}
|
|
rec.Compl = 751 // set upstream of the call, as the streaming handler does
|
|
applyStreamUsageDetails(rec, &types.TokenUsage{
|
|
Completion: 751,
|
|
CompletionTokensDetails: &types.CompletionTokensDetails{ReasoningTokens: 512},
|
|
})
|
|
if rec.Compl != 751 {
|
|
t.Errorf("Compl = %d, want 751 unchanged — reasoning is a subset, not an addition", rec.Compl)
|
|
}
|
|
if rec.Reasoning >= rec.Compl {
|
|
t.Errorf("Reasoning (%d) must be a SUBSET of Compl (%d)", rec.Reasoning, rec.Compl)
|
|
}
|
|
}
|
|
|
|
// TestStreamedUsageWithoutReasoningLeavesItUnset: an upstream that reports no
|
|
// reasoning must not be turned into a reported zero. In the audit trail 0 means
|
|
// "not reported", and a plugin cannot tell that from "no reasoning happened".
|
|
func TestStreamedUsageWithoutReasoningLeavesItUnset(t *testing.T) {
|
|
rec := &Req{}
|
|
applyStreamUsageDetails(rec, &types.TokenUsage{Prompt: 10, Completion: 4, Total: 14})
|
|
if rec.Reasoning != 0 {
|
|
t.Errorf("Reasoning = %d for an upstream that reported none", rec.Reasoning)
|
|
}
|
|
}
|
|
|
|
// TestStreamedCacheAccountingStillWorks is a regression guard for the block the
|
|
// reasoning lines were added to. They share a function, so a future edit that
|
|
// short-circuits one would break the other — silently, since a missing cache
|
|
// number reads as "nothing was cached".
|
|
func TestStreamedCacheAccountingStillWorks(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
u types.TokenUsage
|
|
hit int64
|
|
miss int64
|
|
rep bool
|
|
}{
|
|
{"details object", types.TokenUsage{
|
|
PromptTokensDetails: &types.PromptTokensDetails{CachedTokens: 4096},
|
|
}, 4096, 0, true},
|
|
{"legacy hit/miss", types.TokenUsage{
|
|
PromptCacheHit: 2048, PromptCacheMiss: 512,
|
|
}, 2048, 512, true},
|
|
{"nothing reported", types.TokenUsage{}, 0, 0, false},
|
|
} {
|
|
rec := &Req{}
|
|
applyStreamUsageDetails(rec, &c.u)
|
|
if rec.CacheHit != c.hit || rec.CacheMiss != c.miss || rec.CacheReported != c.rep {
|
|
t.Errorf("%s: hit=%d miss=%d reported=%v; want %d/%d/%v",
|
|
c.name, rec.CacheHit, rec.CacheMiss, rec.CacheReported, c.hit, c.miss, c.rep)
|
|
}
|
|
}
|
|
}
|