mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
gemini.lua 里 adapter.endpoint = "/v1/models",而它自己的注释写的是
POST /v1/models/{model}:generateContent
两者矛盾,而 Go 侧是静态拼接(provider.URL = base_url + endpoint),拼不出
模型名。预置模板 "Google Gemini"(base_url=.../v1beta)于是会 POST 到
https://generativelanguage.googleapis.com/v1beta/v1/models
既多一段 /v1,又缺 :generateContent——那是 Gemini 的模型**列表**端点,
对 POST 返 405。所以任何用户从模板建这个源,拿到的都是必定失败的源。
实测确认影响范围:线上 21 个源里没有 gemini(openai×15 / trae / sensenova /
opencodezen / deepseek / anthropic / agentrouter),所以是潜伏缺陷。
修法:endpoint 改成模板 `/v1beta/models/{model}:generateContent`,新增
provider.ChatURL(model, stream):
- 用 **PathEscape** 替换 {model}——模型 id 进的是 URL 路径,不转义的话
一个 "/" 就会静默指向另一个资源(判据里用 RequestURI 而非 URL.Path
断言,因为后者是解码后的,看不出 %2F);
- 流式把 ":generateContent" 换成 ":streamGenerateContent"(同一个路径、
不同动词,也在路径里)。替换刻意只认这个精确后缀,免得别的适配器
仅仅提到这个词就被改写;
- source 自己设的 endpoint: 仍然优先,模板被整体跳过。
Chat / ChatStream / probeChat 三处调用点改为传本次请求真实的 model——AUTO
按槽位把 req.Model 钉死,所以 URL 必须跟随**请求**的模型,用源默认模型会让
多模型源每次都打同一个(还记到别的模型的账上)。
判定静态 endpoint 的其他 10 个适配器零影响(TestNonGeminiEndpointsAreUntouched)。
顺带:Stats 的 mutex 不是可重入的,导出方法自己加锁、*Locked 后缀要求调用
方持锁。持锁调导出方法会死锁——我的探针真卡死过一次(直到 10 分钟超时)。
补上 LOCKING 注释,并加判据把这条规则钉住(含一个 20 秒上限的行为判据,
让未来的重构撞死锁时快速失败而不是拖满整个套件)。
133 lines
5.0 KiB
Go
133 lines
5.0 KiB
Go
package gateway
|
|
|
|
import (
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The Stats mutex is a plain sync.Mutex: calling an exported (self-locking)
|
|
// method while already holding it deadlocks. That happened for real — a probe
|
|
// held s.mu and called KeyWindowReqs, hanging until the test binary's 10-minute
|
|
// panic timeout — so the rule is pinned here rather than left in a comment.
|
|
//
|
|
// The check is structural: every method that takes the lock must say so in its
|
|
// name or its doc comment. That keeps the trap visible when someone adds the
|
|
// next method, which is the only time the rule can be forgotten.
|
|
|
|
// exportedSelfLocking lists the exported Stats methods that take the mutex.
|
|
// It is derived from reflection at run time; the assertions below are what
|
|
// actually pin the convention.
|
|
func TestStatsExportedMethodsDocumentTheirLocking(t *testing.T) {
|
|
typ := reflect.TypeOf(&Stats{})
|
|
for i := 0; i < typ.NumMethod(); i++ {
|
|
m := typ.Method(i)
|
|
if m.PkgPath != "" { // unexported
|
|
continue
|
|
}
|
|
// Methods that neither lock nor touch guarded state are fine either way;
|
|
// the ones that matter are those reaching into the maps under mu.
|
|
if !methodTouchesLockedState(m.Name) {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(m.Name, "Locked") {
|
|
t.Errorf("Stats.%s is exported but named *Locked; the suffix means "+
|
|
"the CALLER holds the lock, so it must not be exported", m.Name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// methodTouchesLockedState is the set of exported methods known to read or
|
|
// write state guarded by Stats.mu. Kept explicit (rather than inferred) so a
|
|
// new method is not silently assumed safe.
|
|
func methodTouchesLockedState(name string) bool {
|
|
switch name {
|
|
case "KeyWindowReqs", "KeyWindowModelTokens", "KeyWindowTokens",
|
|
"WindowTokens", "KeyTokens", "ModelTokens", "Record", "AppendAudit",
|
|
"LoadAudit", "Snapshot", "SourceRecent", "SourceAverages",
|
|
"AuditRecords", "AuditPage", "StreamAuditRecords", "ReplayPartial",
|
|
"PoolStats":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestStatsLockedMethodsAreNotCalledUnderLock is the behavioural half: it
|
|
// proves the internal helpers the ones above use are reachable while the lock
|
|
// is held. If a future refactor makes an exported method call a *Locked one
|
|
// while holding mu itself, this is where it shows up — as a hang, bounded by
|
|
// the short timeout below rather than the suite's 10 minutes.
|
|
func TestStatsLockedMethodsAreNotCalledUnderLock(t *testing.T) {
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
s := NewStats(10)
|
|
h := time.Now().Unix() / 3600
|
|
s.mu.Lock()
|
|
// Exactly the shape that deadlocked: the *Locked variants are correct
|
|
// here because the lock is already held.
|
|
s.addKeyReqLocked("k", h, 5)
|
|
s.addKeyHourLocked("k", h, 100)
|
|
s.wantPinnedBuckets("k", "m1")
|
|
s.addKeyTokenLocked("k", "m1", "src", h, 100)
|
|
// sumBuckets is the pure inner function the exported readers call.
|
|
if got := sumBuckets(s.keyReqHour["k"], time.Now().Unix(), 3600); got != 5 {
|
|
t.Errorf("sumBuckets under lock = %d, want 5", got)
|
|
}
|
|
s.mu.Unlock()
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(20 * time.Second):
|
|
buf := make([]byte, 1<<16)
|
|
n := runtime.Stack(buf, true)
|
|
t.Fatalf("deadlocked while using the *Locked helpers under s.mu:\n%s", buf[:n])
|
|
}
|
|
}
|
|
|
|
// TestSumBucketsEdgeCases covers the boundaries the quota check depends on,
|
|
// including the ones a regression would silently get wrong (an off-by-one here
|
|
// either lets a quota leak or locks a key out early).
|
|
func TestSumBucketsEdgeCases(t *testing.T) {
|
|
const hour = 3600
|
|
now := int64(10*hour + 61) // 10:00:61, i.e. just past the boundary
|
|
buckets := map[int64]int64{
|
|
0: 100, // ancient
|
|
9: 200, // previous hour
|
|
10: 7, // current hour
|
|
}
|
|
|
|
if got := sumBuckets(buckets, now, 0); got != 307 {
|
|
t.Errorf("sec<=0 (all history) = %d, want 307", got)
|
|
}
|
|
// 1h window covers buckets h >= ceil((now-3600)/3600) = 10 -> only bucket 10.
|
|
if got := sumBuckets(buckets, now, hour); got != 7 {
|
|
t.Errorf("1h window = %d, want 7 (buckets 0 and 9 fall outside)", got)
|
|
}
|
|
// 2h window covers h >= 9 -> buckets 9 and 10.
|
|
if got := sumBuckets(buckets, now, 2*hour); got != 207 {
|
|
t.Errorf("2h window = %d, want 207", got)
|
|
}
|
|
// An empty map and a nil map must both be zero, not a panic.
|
|
if got := sumBuckets(map[int64]int64{}, now, hour); got != 0 {
|
|
t.Errorf("empty map = %d, want 0", got)
|
|
}
|
|
if got := sumBuckets(nil, now, hour); got != 0 {
|
|
t.Errorf("nil map = %d, want 0", got)
|
|
}
|
|
// now < sec must not produce a negative firstHour index: with a window far
|
|
// wider than the available history, every bucket that exists is counted.
|
|
// (sumBuckets walks bucket indices from firstHour to nowHour, so passing a
|
|
// "now" older than some buckets cannot reach them — that is correct, not a
|
|
// truncation bug.)
|
|
if got := sumBuckets(buckets, 10*hour+61, 100*hour); got != 307 {
|
|
t.Errorf("window wider than history = %d, want 307", got)
|
|
}
|
|
// A window that predates every bucket counts them all as well.
|
|
if got := sumBuckets(buckets, 10*hour+61, hour); got != 7 {
|
|
t.Errorf("1h window at t=10:00:61 = %d, want 7", got)
|
|
}
|
|
}
|