feat(plugin): AUTO 调度轨迹可见(chain_step stage)

被问"还有 auto 调度相关 stage 呢?"问出来的真实缺口。

## 问题
chainDrive 只返回 (resp, src, model, err),调用方只知道**最终哪个槽位赢了**。
遍历过程中算出来又丢掉的东西——哪些档被跳过、为什么跳过、哪些槽位硬失败、
哪档全忙——一律不可见。ChainErr 里其实有这些,但**只在全部失败时**才填,
而它是 error 返回值不是记录。于是:

    "tier 1 冷却所以降级到 tier 3"  ==  "tier 1 正常接单"

对插件而言 tier 只是个常量 -2("resolved by the chain"),信息量为零。而这
恰恰是优先级链存在的全部理由,也是"我那个贵模型为什么没被用"的答案。

## 做法(scheduler 侧零新依赖)
新增 TraceEvent / TraceSink,chainDrive 多一个可选 sink 参数:

  - TraceEvent 是本包的普通 struct,sink 是 func 参数 ⇒ **不新增 import**,
    scheduler 仍然可独立测试
  - sink 为 nil 时每次 emit 只多一次 nil 判断;没有插件的网关在 AUTO 热路径上
    零开销(gateway 的 chainTraceSink 直接返回 nil)
  - 事件是纯观测:scheduler 不基于它做任何分支,gateway 也不把它喂回路由/
    冷却/配额

四种 kind:tier_skip / slot_fail / tier_busy / selected,selected 每次成功
遍历恰好一次且是最后一步。顺序保证所有 step 在 routed 之前。

## 暴露给插件
新增 chain_step stage(逐个步骤),并在 request_end 载荷里加三个便于做报表的
字段:chain_walk(上限 12 步,防审计记录膨胀)、degraded、tier_served。

## ★ 计费口径(我按推荐的做,已写进文档,需要你确认)
**按实际服务的模型计费**:降级到 tier 3 仍按 tier 3 的价算,轨迹只作观测。
理由与 §7.5 的边界一致——插件只报表不执法,两套口径混在一起会引出"降级该不该
多收钱"这种无法从代码判断的争议。若要改成"按本该用的档计价",需要在 models
价目里允许按 tier 定价,这我没做,因为那是个产品决策。

## 计费插件同步消费
by_tier_served / skip_reasons / degraded_reqs 三个新维度。skip_reasons 的等待
时长做了归一(`no free slot within <wait>`),否则 busy-wait 文案一变就多一行。
降级次数在 request_end 里计而不是在 chain_step 里计:一次降级的请求要走多步,
按步计会重复计数。

## 判据(346 个测试全绿,新增 15 个)
  scheduler  6 个:正常路径只发一个 selected / 跳档+降级可见 / 硬失败与跳档
                严格区分(不可混为一谈,否则抖动上游看起来像空闲上游)/
                nil sink 安全 / 全失败时轨迹与 ChainErr 并存且不互相破坏 /
                空链不发事件
  gateway    1 个端到端:tier 1 全 500 → 插件收到 slot_fail(tier 1) +
                selected(tier 2),request_end 的 tier_served=2 且 degraded=true
  lua        2 个:降级计数与按实际模型计价 / 跳过原因归一聚合
  lua        1 个:chain_step 是真 stage 且顺序正确

3 个变异都红:去掉 slot_fail(3 个判据红)/ 去掉 tier_skip(1 个)/
去掉 degraded 字段(1 个)。
This commit is contained in:
JianFeeeee
2026-10-02 01:03:39 +08:00
parent 8c18e0c3d7
commit 42764bc99e
11 changed files with 766 additions and 23 deletions

View File

@ -500,3 +500,127 @@ func TestRejectedChatStillFiresRequestStart(t *testing.T) {
"plugin can count real traffic, not just served traffic", got)
}
}
// TestChainStepReachesPluginOnDegradation is the end-to-end proof for the
// AUTO trace: a request that had to drop from tier 1 to tier 2 must be visible
// to a plugin as a tier_skip followed by a selected, and request_end must carry
// tier_served=2.
//
// Before the trace existed the plugin saw only tier=-2 ("resolved by the
// chain") and could not tell a degradation from a clean tier-1 hit — which is
// the whole question a priority chain exists to answer.
func TestChainStepReachesPluginOnDegradation(t *testing.T) {
// tier 1's source always fails, so the walk must drop to tier 2.
bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
}))
defer bad.Close()
good := mockUpstream()
defer good.Close()
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
os.WriteFile(cfgPath, []byte("listen: :0\n"+
"adapter_dir: "+filepath.Join(dir, "adapters")+"\n"+
"plugin_dir: "+filepath.Join(dir, "plugins")+"\n"+
"runtime_file: "+filepath.Join(dir, "runtime.json")+"\n"+
"gateway_keys:\n - sk-test\n"), 0600)
cfg, err := config.Load(cfgPath)
if err != nil {
t.Fatal(err)
}
cfg.Sources = []config.Source{
{Name: "t1", BaseURL: bad.URL, Adapter: "openai", APIKey: "sk-x",
Models: []config.Model{{ID: "hi-tier", Kind: "chat"}}},
{Name: "t2", BaseURL: good.URL, Adapter: "openai", APIKey: "sk-x",
Models: []config.Model{{ID: "lo-tier", Kind: "chat"}}},
}
if err := cfg.ApplyDefaults(); err != nil {
t.Fatal(err)
}
c, err := core.NewFromConfig(cfg)
if err != nil {
t.Fatal(err)
}
defer c.Close()
// A spy that records chain_step events too.
sp := `
local plugin = { name = "walker", version = "1.0.0" }
plugin.state = { steps = {}, ends = {} }
plugin.hooks = { chain_step = "step", request_end = "fin" }
function plugin.step(p)
table.insert(plugin.state.steps, { kind = p.kind, tier = p.tier, source = p.source, model = p.model, reason = p.reason })
return nil
end
function plugin.fin(p)
plugin.state.ends[#plugin.state.ends + 1] = {
tier_served = p.tier_served, degraded = p.degraded,
walk = p.chain_walk, source = p.source, model = p.model,
}
return nil
end
return plugin
`
if err := c.Plugins().LoadSource("walker", sp); err != nil {
t.Fatal(err)
}
g, err := New(c)
if err != nil {
t.Fatal(err)
}
// Two tiers, both in the chain.
if rr := doReq(t, g, http.MethodPut, "/api/auto",
`{"rules":[{"model":"hi-tier","source":"t1","tier":1},{"model":"lo-tier","source":"t2","tier":2}]}`); rr.Code != 200 {
t.Fatalf("save auto: %d %s", rr.Code, rr.Body.String())
}
rr := doReq(t, g, http.MethodPost, "/v1/chat/completions",
`{"model":"AUTO","messages":[{"role":"user","content":"hi"}]}`)
if rr.Code != http.StatusOK {
t.Fatalf("chat = %d %s", rr.Code, rr.Body.String())
}
raw := c.Plugins().State("walker")
b, _ := json.Marshal(raw)
var st struct {
Steps []struct {
Kind string `json:"kind"`
Tier int `json:"tier"`
Source string `json:"source"`
Model string `json:"model"`
} `json:"steps"`
Ends []struct {
TierServed int `json:"tier_served"`
Degraded bool `json:"degraded"`
Source string `json:"source"`
Model string `json:"model"`
} `json:"ends"`
}
if err := json.Unmarshal(b, &st); err != nil {
t.Fatalf("decode: %v (%s)", err, string(b))
}
if len(st.Steps) < 2 {
t.Fatalf("chain_step events = %+v, want at least a slot_fail and a selected", st.Steps)
}
if st.Steps[0].Kind != "slot_fail" || st.Steps[0].Tier != 1 {
t.Errorf("first step = %+v, want slot_fail on tier 1", st.Steps[0])
}
last := st.Steps[len(st.Steps)-1]
if last.Kind != "selected" || last.Tier != 2 {
t.Errorf("last step = %+v, want selected on tier 2", last)
}
if len(st.Ends) != 1 {
t.Fatalf("request_end count = %d, want 1", len(st.Ends))
}
if st.Ends[0].TierServed != 2 {
t.Errorf("tier_served = %d, want 2", st.Ends[0].TierServed)
}
if !st.Ends[0].Degraded {
t.Error("degraded = false, but the request dropped a tier")
}
if st.Ends[0].Model != "lo-tier" {
t.Errorf("served model = %q, want lo-tier", st.Ends[0].Model)
}
}