Current section
Files
Jump to
Current section
Files
lib/cmdc/plugin/builtin/model_router.ex
defmodule CMDC.Plugin.Builtin.ModelRouter do
@moduledoc """
P2 模型路由插件 — 按规则在 `before_request` 自动切换 LLM 模型。
v0.1 支持 3 个条件(`:turn_gt` / `:cost_gt` / `:tokens_gt`),v0.2 新增 4 个
业务友好的条件(`:token_budget_lt` / `:task_complexity` / `:time_of_day_in` /
`:user_tier`),并从 `:intervene` 系统消息改为更干净的 `:switch_model`
Plugin action(RFC C8)。
## 配置
{CMDC.Plugin.Builtin.ModelRouter,
default_model: "anthropic:claude-sonnet-4-5",
rules: [
# 成本托底
%{condition: {:cost_gt, 0.5}, model: "openai:gpt-4.1-mini"},
# token 快耗尽时降级
%{condition: {:token_budget_lt, 5_000}, model: "openai:gpt-4.1-mini"},
# 复杂任务用最强模型
%{condition: {:task_complexity, :complex}, model: "anthropic:claude-opus-4"},
%{condition: {:task_complexity_in, [:simple]}, model: "openai:gpt-4.1-mini"},
# 夜间(22:00-06:59 UTC)跑经济模型
%{condition: {:time_of_day_in, [22..23, 0..6]}, model: "openai:gpt-4.1-mini"},
# 免费用户强制降级
%{condition: {:user_tier, :free}, model: "openai:gpt-4.1-mini"},
%{condition: {:user_tier_in, [:pro, :enterprise]}, model: "anthropic:claude-sonnet-4-5"},
# 通用 user_data 断言
%{condition: {:user_data, :region, "eu-west"}, model: "mistral:mistral-large"},
%{condition: {:user_data, :priority, :gt, 5}, model: "anthropic:claude-opus-4"}
]
}
## 规则条件一览
运行时:
- `{:turn_gt, n}` — 对话轮次超过 n
- `{:cost_gt, usd}` — 累计成本超过 usd
- `{:tokens_gt, n}` — 累计 token 超过 n
v0.2 新增:
- `{:token_budget_lt, n}` — `user_data[:token_budget] - total_tokens < n`
(没配 budget 时视为不触发)
- `{:task_complexity, v}` / `{:task_complexity_in, [v, ...]}`
— 读 `user_data[:task_complexity]`(`:simple` / `:normal` / `:complex`)
- `{:time_of_day_in, ranges}` — 当前 UTC 小时落在任一 `0..23` Range 里
- `{:user_tier, tier}` / `{:user_tier_in, [tier, ...]}`
— 读 `user_data[:user_tier]`(`:free` / `:pro` / `:enterprise` / 任意原子)
- `{:user_data, key, value}` — `user_data[key] == value`
- `{:user_data, key, op, value}` — `op` 为 `:eq` / `:gt` / `:lt` / `:gte` / `:lte`
## 规则匹配顺序
从上到下**顺序匹配**,命中第一条即 `:switch_model`,不再尝试后续规则。
把更严格 / 更具体的条件写在前面。
## Action
- 命中且目标模型与当前不同 → `{:switch_model, model, state}`
Pipeline 汇总后把下一次 LLM 请求切换到新模型,并发 `:model_switched` 事件
- 无命中 / 目标相同 → `:continue`
## 触发 Hook
`{:before_request, messages}` — 发送 LLM 请求前触发。
"""
@behaviour CMDC.Plugin
require Logger
@type rule :: %{
required(:condition) => condition(),
required(:model) => String.t(),
optional(:reason) => String.t()
}
@type condition ::
{:turn_gt, non_neg_integer()}
| {:cost_gt, number()}
| {:tokens_gt, non_neg_integer()}
| {:token_budget_lt, non_neg_integer()}
| {:task_complexity, atom()}
| {:task_complexity_in, [atom()]}
| {:time_of_day_in, [Range.t()]}
| {:user_tier, atom()}
| {:user_tier_in, [atom()]}
| {:user_data, atom(), any()}
| {:user_data, atom(), :eq | :gt | :lt | :gte | :lte, any()}
# ==========================================================================
# Callbacks
# ==========================================================================
@impl true
def init(opts) do
state = %{
rules: Keyword.get(opts, :rules, []),
default_model: Keyword.get(opts, :default_model),
current_model: nil,
now_fn: Keyword.get(opts, :now_fn, &DateTime.utc_now/0)
}
{:ok, state}
end
@impl true
def priority, do: 300
@impl true
def describe do
%{
name: "ModelRouter",
version: "2",
description: "按规则自动切换 LLM 模型(成本/token/时段/用户分层)",
events: [:before_request]
}
end
@impl true
def handle_event({:before_request, _messages}, state, ctx) do
case find_matching_rule(state.rules, ctx, state.now_fn) do
nil ->
{:continue, state}
%{model: target_model} = rule ->
current = state.current_model || Map.get(ctx, :model, state.default_model)
if target_model != current do
Logger.info(
"[ModelRouter] 路由模型: #{inspect(current)} → #{inspect(target_model)} " <>
"(condition=#{inspect(rule.condition)})"
)
{:switch_model, target_model, %{state | current_model: target_model}}
else
{:continue, state}
end
end
end
def handle_event(_event, state, _ctx), do: {:continue, state}
# ==========================================================================
# Private — rule matching
# ==========================================================================
defp find_matching_rule(rules, ctx, now_fn) do
Enum.find(rules, fn %{condition: condition} ->
safe_evaluate(condition, ctx, now_fn)
end)
end
defp safe_evaluate(condition, ctx, now_fn) do
evaluate_condition(condition, ctx, now_fn)
rescue
error ->
Logger.warning(
"[ModelRouter] evaluate_condition 异常: #{inspect(error)} cond=#{inspect(condition)}"
)
false
end
# -----
# v0.1 条件
# -----
defp evaluate_condition({:turn_gt, n}, ctx, _now) do
Map.get(ctx, :turn, 0) > n
end
defp evaluate_condition({:cost_gt, usd}, ctx, _now) do
Map.get(ctx, :cost_usd, 0.0) > usd
end
defp evaluate_condition({:tokens_gt, n}, ctx, _now) do
Map.get(ctx, :total_tokens, 0) > n
end
# -----
# v0.2 新增 — token budget
# -----
defp evaluate_condition({:token_budget_lt, n}, ctx, _now) when is_integer(n) do
case Map.get(user_data(ctx), :token_budget) do
budget when is_integer(budget) ->
budget - Map.get(ctx, :total_tokens, 0) < n
_ ->
false
end
end
# -----
# v0.2 新增 — task complexity
# -----
defp evaluate_condition({:task_complexity, target}, ctx, _now) do
Map.get(user_data(ctx), :task_complexity) == target
end
defp evaluate_condition({:task_complexity_in, targets}, ctx, _now) when is_list(targets) do
Map.get(user_data(ctx), :task_complexity) in targets
end
# -----
# v0.2 新增 — time of day
# -----
defp evaluate_condition({:time_of_day_in, ranges}, _ctx, now_fn) when is_list(ranges) do
hour = now_fn.() |> Map.get(:hour)
Enum.any?(ranges, &in_range?(hour, &1))
end
# -----
# v0.2 新增 — user tier
# -----
defp evaluate_condition({:user_tier, tier}, ctx, _now) do
Map.get(user_data(ctx), :user_tier) == tier
end
defp evaluate_condition({:user_tier_in, tiers}, ctx, _now) when is_list(tiers) do
Map.get(user_data(ctx), :user_tier) in tiers
end
# -----
# v0.2 新增 — 通用 user_data
# -----
defp evaluate_condition({:user_data, key, value}, ctx, _now) when is_atom(key) do
Map.get(user_data(ctx), key) == value
end
defp evaluate_condition({:user_data, key, op, value}, ctx, _now)
when is_atom(key) and op in [:eq, :gt, :lt, :gte, :lte] do
compare_op(Map.get(user_data(ctx), key), op, value)
end
defp evaluate_condition(_, _ctx, _now), do: false
# -----
# helpers
# -----
defp user_data(ctx) do
case Map.get(ctx, :user_data) do
m when is_map(m) -> m
_ -> %{}
end
end
defp in_range?(h, %Range{} = r), do: h in r
defp in_range?(_, _), do: false
defp compare_op(nil, _op, _value), do: false
defp compare_op(a, :eq, b), do: a == b
defp compare_op(a, :gt, b) when is_number(a) and is_number(b), do: a > b
defp compare_op(a, :lt, b) when is_number(a) and is_number(b), do: a < b
defp compare_op(a, :gte, b) when is_number(a) and is_number(b), do: a >= b
defp compare_op(a, :lte, b) when is_number(a) and is_number(b), do: a <= b
defp compare_op(_, _, _), do: false
end