feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()), Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型 - 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源 - 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false** - 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具; vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威) - 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切 - 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行) - 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异) - 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
This commit is contained in:
1 parent
63e5b70e3d
commit
fa33c79e11
24 files changed
+1941
-122
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
@@ -48,15 +49,67 @@ func NewChatChain(cfg config.LLMConfig) (*Chain, error) {
|
||||
// builds, so a provider-level reasoning decision (a thinking budget raised to fit
|
||||
// max_tokens, a reasoning_effort value downgraded or ignored) is reported with
|
||||
// its module source. A nil logger leaves the adapters' discard default in place.
|
||||
//
|
||||
// It is also the only builder that appends the optional local third rung
|
||||
// (裁决 03.4.3, primary → fallback → local), because that rung may speak a
|
||||
// different protocol than the active group and therefore needs the raw logger to
|
||||
// build its own adapter. It is appended only when the local rung is enabled AND
|
||||
// fully configured; otherwise the chain keeps its two-level shape and the reason
|
||||
// is reported.
|
||||
func NewChatChainWithLog(cfg config.LLMConfig, log *slog.Logger) (*Chain, error) {
|
||||
var chain *Chain
|
||||
var err error
|
||||
switch cfg.Provider {
|
||||
case ProviderOpenAI, "":
|
||||
return newOpenAIChain(cfg, WithOpenAILogger(log))
|
||||
chain, err = newOpenAIChain(cfg, WithOpenAILogger(log))
|
||||
case ProviderAnthropic:
|
||||
return newAnthropicChain(cfg, WithAnthropicLogger(log))
|
||||
chain, err = newAnthropicChain(cfg, WithAnthropicLogger(log))
|
||||
default:
|
||||
return nil, fmt.Errorf("model: %w: provider %q", ErrUnknownProvider, cfg.Provider)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if step, ok := localChainStep(cfg, log); ok {
|
||||
chain.steps = append(chain.steps, step)
|
||||
}
|
||||
return chain, nil
|
||||
}
|
||||
|
||||
// localChainStep builds the local third degradation rung as a chain step. It
|
||||
// returns ok=false (and, when the rung was asked for but is incomplete, a WARN
|
||||
// naming the missing field) when the rung is off, unconfigured or its provider is
|
||||
// unknown — a half-configured step would only ever fail, which is worse than not
|
||||
// having it.
|
||||
func localChainStep(cfg config.LLMConfig, log *slog.Logger) (Step, bool) {
|
||||
local := cfg.Local
|
||||
// Configured() already requires Enabled, so the rung is off by default and a
|
||||
// disabled rung can never become a chain step. The warning is emitted only
|
||||
// when the rung was actually asked for but is incomplete.
|
||||
if !local.Configured() {
|
||||
if local.Enabled {
|
||||
warn(log, "local model rung enabled but not configured; skipping it",
|
||||
"base_url_set", strings.TrimSpace(local.BaseURL) != "",
|
||||
"model_set", strings.TrimSpace(local.Model) != "")
|
||||
}
|
||||
return Step{}, false
|
||||
}
|
||||
localCfg := config.LLMConfig{
|
||||
Provider: local.Provider,
|
||||
BaseURL: local.BaseURL,
|
||||
APIKey: local.APIKey,
|
||||
Model: local.Model,
|
||||
}
|
||||
switch local.Provider {
|
||||
case ProviderAnthropic:
|
||||
return Step{Model: local.Model, Adapter: NewAnthropicChat(localCfg, WithAnthropicLogger(log))}, true
|
||||
case ProviderOpenAI, "":
|
||||
return Step{Model: local.Model, Adapter: NewOpenAIChat(localCfg, WithOpenAILogger(log))}, true
|
||||
default:
|
||||
warn(log, "local model rung skipped: unknown provider",
|
||||
"provider", local.Provider, "field", "LLM_LOCAL_PROVIDER")
|
||||
return Step{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// newOpenAIChain builds a fallback chain from the OpenAI-compatible config block.
|
||||
@@ -94,3 +147,14 @@ func DefaultModelForProvider(cfg config.LLMConfig) string {
|
||||
}
|
||||
return cfg.Model
|
||||
}
|
||||
|
||||
// warn reports through the injected logger, falling back to a discard logger that
|
||||
// still carries the module source so an unsourced record can never be emitted
|
||||
// (project rule §10). It exists because a chain may be built without a logger
|
||||
// (NewChatChain) while a skipped rung must still be reported.
|
||||
func warn(log *slog.Logger, msg string, args ...any) {
|
||||
if log == nil {
|
||||
log = discardLogger("model")
|
||||
}
|
||||
log.Warn(msg, args...)
|
||||
}
|
||||
@@ -44,6 +44,19 @@ func (c *Chain) WithTokenBudget(groupMaxTokens, fallbackMaxTokens int) *Chain {
|
||||
return c
|
||||
}
|
||||
|
||||
// stepModel picks the model name for step idx of the chain. The primary step
|
||||
// prefers the request's routed model — the per-persona / per-request decision the
|
||||
// Router produced — over the group's configured model, so a routing decision is
|
||||
// never silently discarded. Every fallback step pins its own configured model, so
|
||||
// a degradation never re-sends the primary model name to a different endpoint
|
||||
// (裁决 03.4.1: per-persona 路由必须真的落到请求上).
|
||||
func stepModel(req Request, idx int, configured string) string {
|
||||
if idx == 0 && req.Model != "" {
|
||||
return req.Model
|
||||
}
|
||||
return configured
|
||||
}
|
||||
|
||||
// applyBudget resolves the effective MaxTokens for step idx, honoring the
|
||||
// precedence: an explicit Request.MaxTokens > the group's MaxTokens > the
|
||||
// last-resort LLM_FALLBACK_MAX_TOKENS > the adapter default. The last-resort
|
||||
@@ -85,8 +98,8 @@ func (c *Chain) Chat(ctx context.Context, req Request, opts Options) (Response,
|
||||
var lastErr error
|
||||
for i, s := range c.steps {
|
||||
stepReq := c.applyBudget(req, i)
|
||||
if s.Model != "" {
|
||||
stepReq.Model = s.Model
|
||||
if m := stepModel(req, i, s.Model); m != "" {
|
||||
stepReq.Model = m
|
||||
}
|
||||
resp, err := s.Adapter.Chat(ctx, stepReq, opts)
|
||||
if err == nil {
|
||||
@@ -108,8 +121,8 @@ func (c *Chain) ChatStream(ctx context.Context, req Request, onEvent func(Stream
|
||||
var lastErr error
|
||||
for i, s := range c.steps {
|
||||
stepReq := c.applyBudget(req, i)
|
||||
if s.Model != "" {
|
||||
stepReq.Model = s.Model
|
||||
if m := stepModel(req, i, s.Model); m != "" {
|
||||
stepReq.Model = m
|
||||
}
|
||||
delivered := false
|
||||
wrapped := func(ev StreamEvent) {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
)
|
||||
|
||||
// recordingServer is an OpenAI-compatible /chat/completions endpoint that records
|
||||
// the model name of every request and either fails or succeeds. It backs the
|
||||
// local-rung tests (裁决 03.4.3): the point of the rung is that a request reaches
|
||||
// the local endpoint with the local model name, and never when the rung is off.
|
||||
type recordingServer struct {
|
||||
mu sync.Mutex
|
||||
models []string
|
||||
}
|
||||
|
||||
func (s *recordingServer) modelsSeen() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.models...)
|
||||
}
|
||||
|
||||
func newRecordingServer(t *testing.T, fail bool) (*httptest.Server, *recordingServer) {
|
||||
t.Helper()
|
||||
rec := &recordingServer{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
rec.mu.Lock()
|
||||
rec.models = append(rec.models, body.Model)
|
||||
rec.mu.Unlock()
|
||||
if fail {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"error":"down"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, okCompletion)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, rec
|
||||
}
|
||||
|
||||
// TestNewChatChainWithLog_LocalRungAppended verifies the third degradation level
|
||||
// (primary → fallback → local) only joins the chain when it is enabled AND fully
|
||||
// configured.
|
||||
func TestNewChatChainWithLog_LocalRungAppended(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
local config.LocalLLMConfig
|
||||
want int
|
||||
}{
|
||||
{"off", config.LocalLLMConfig{BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2},
|
||||
{"enabled but no model", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1"}, 2},
|
||||
{"enabled but no base url", config.LocalLLMConfig{Enabled: true, Model: "local"}, 2},
|
||||
{"enabled and configured", config.LocalLLMConfig{Enabled: true, BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 3},
|
||||
{"enabled with unknown provider", config.LocalLLMConfig{Enabled: true, Provider: "gemini", BaseURL: "http://127.0.0.1:1/v1", Model: "local"}, 2},
|
||||
}
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||
Provider: ProviderOpenAI, BaseURL: "http://127.0.0.1:1/v1", Model: "main", Fallback: "fb",
|
||||
Local: c.local,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||
}
|
||||
if len(chain.steps) != c.want {
|
||||
t.Fatalf("steps = %d, want %d", len(chain.steps), c.want)
|
||||
}
|
||||
if c.want == 3 && chain.steps[2].Model != "local" {
|
||||
t.Errorf("steps[2].Model = %q, want local", chain.steps[2].Model)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_Chat_LocalRungServesAfterFallback verifies the local rung really takes
|
||||
// over the request after the primary and fallback steps fail, with the local model
|
||||
// name on the wire and the local endpoint's own base URL/API key (not the group's).
|
||||
func TestChain_Chat_LocalRungServesAfterFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
mainSrv, _ := newRecordingServer(t, true)
|
||||
localSrv, localRec := newRecordingServer(t, false)
|
||||
|
||||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||
Provider: ProviderOpenAI, BaseURL: mainSrv.URL, APIKey: "sk-main", Model: "main", Fallback: "fb",
|
||||
Local: config.LocalLLMConfig{Enabled: true, BaseURL: localSrv.URL, APIKey: "sk-local", Model: "local-model"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||
}
|
||||
resp, err := chain.Chat(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}, Purpose: "chat"}, Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Chain.Chat: %v", err)
|
||||
}
|
||||
if resp.Content != "Hello!" {
|
||||
t.Errorf("resp.Content = %q, want Hello!", resp.Content)
|
||||
}
|
||||
seen := localRec.modelsSeen()
|
||||
if len(seen) != 1 || seen[0] != "local-model" {
|
||||
t.Fatalf("local endpoint models = %v, want [local-model]", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_Chat_LocalRungOffNeverCalled pins the default-off red line: with the
|
||||
// rung disabled the local endpoint must never be touched, even when every other
|
||||
// step fails.
|
||||
func TestChain_Chat_LocalRungOffNeverCalled(t *testing.T) {
|
||||
t.Parallel()
|
||||
mainSrv, _ := newRecordingServer(t, true)
|
||||
localSrv, localRec := newRecordingServer(t, false)
|
||||
|
||||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||
Provider: ProviderOpenAI, BaseURL: mainSrv.URL, Model: "main", Fallback: "fb",
|
||||
Local: config.LocalLLMConfig{BaseURL: localSrv.URL, Model: "local-model"},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||
}
|
||||
if _, err := chain.Chat(context.Background(),
|
||||
Request{Messages: []Message{TextMessage(RoleUser, "hi")}}, Options{}); !errors.Is(err, ErrUpstream) {
|
||||
t.Fatalf("err = %v, want ErrUpstream", err)
|
||||
}
|
||||
if seen := localRec.modelsSeen(); len(seen) != 0 {
|
||||
t.Fatalf("local endpoint called %v, want never called while the rung is off", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChain_Chat_PrimaryStepKeepsRoutedModel pins that the chain does not discard
|
||||
// the router's decision: the primary step sends the request's routed model, and
|
||||
// the fallback step still pins its own configured model (裁决 03.4.1).
|
||||
func TestChain_Chat_PrimaryStepKeepsRoutedModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
// One endpoint serves both steps (the factory builds every step against the
|
||||
// group's base URL): it fails for anything but the fallback model, so the
|
||||
// recorded model names show which model each step actually sent.
|
||||
rec := &recordingServer{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
rec.mu.Lock()
|
||||
rec.models = append(rec.models, body.Model)
|
||||
rec.mu.Unlock()
|
||||
if body.Model != "group-fb" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = io.WriteString(w, `{"error":"down"}`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, okCompletion)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
chain, err := NewChatChainWithLog(config.LLMConfig{
|
||||
Provider: ProviderOpenAI, BaseURL: srv.URL, Model: "group-main", Fallback: "group-fb",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewChatChainWithLog: %v", err)
|
||||
}
|
||||
if _, err := chain.Chat(context.Background(), Request{
|
||||
Model: "persona-main",
|
||||
Messages: []Message{TextMessage(RoleUser, "hi")},
|
||||
}, Options{}); err != nil {
|
||||
t.Fatalf("Chain.Chat: %v", err)
|
||||
}
|
||||
seen := rec.modelsSeen()
|
||||
if len(seen) != 2 || seen[0] != "persona-main" || seen[1] != "group-fb" {
|
||||
t.Fatalf("models seen = %v, want [persona-main group-fb] (routed model first, step model on fallback)", seen)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// TestRouter_RouteRequestForPersona pins 裁决 03.4.1: the "role" of per-role
|
||||
// routing is the persona a turn answers as, and a persona without a binding keeps
|
||||
// the message-role behavior.
|
||||
func TestRouter_RouteRequestForPersona(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRouter("default",
|
||||
WithRoleModel(RoleUser, "user-model"),
|
||||
WithPersonaModel("cyrene", "persona-model"),
|
||||
WithPersonaProvider("cyrene", ProviderAnthropic),
|
||||
WithDefaultProvider(ProviderOpenAI),
|
||||
)
|
||||
req := Request{Messages: []Message{TextMessage(RoleUser, "hi")}}
|
||||
|
||||
if got, _ := r.RouteRequestForPersona("cyrene", req); got != "persona-model" {
|
||||
t.Errorf("RouteRequestForPersona(cyrene) = %q, want persona-model", got)
|
||||
}
|
||||
if got, _ := r.RouteRequestForPersona("other", req); got != "user-model" {
|
||||
t.Errorf("RouteRequestForPersona(other) = %q, want user-model (role fallback)", got)
|
||||
}
|
||||
if got, _ := r.RouteRequestForPersona("", req); got != "user-model" {
|
||||
t.Errorf("RouteRequestForPersona(\"\") = %q, want user-model", got)
|
||||
}
|
||||
if got := r.RouteForPersona("cyrene"); got != "persona-model" {
|
||||
t.Errorf("RouteForPersona(cyrene) = %q, want persona-model", got)
|
||||
}
|
||||
if got := r.RouteProviderForPersona("cyrene"); got != ProviderAnthropic {
|
||||
t.Errorf("RouteProviderForPersona(cyrene) = %q, want anthropic", got)
|
||||
}
|
||||
if got := r.RouteProviderForPersona("other"); got != ProviderOpenAI {
|
||||
t.Errorf("RouteProviderForPersona(other) = %q, want the default provider openai", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch pins two edges: a
|
||||
// blank persona/model cannot create a binding, and a persona-routed image request
|
||||
// is not silently re-routed to a vision model (that decision belongs to
|
||||
// vision.Router, 裁决 14.4.1/14.4.5).
|
||||
func TestRouter_PersonaBindingIgnoresBlankAndImageDoesNotSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
r := NewRouter("default",
|
||||
WithPersonaModel(" ", "x"),
|
||||
WithPersonaModel("cyrene", ""),
|
||||
WithVisionModel("vision-model"),
|
||||
)
|
||||
if got := r.RouteForPersona("cyrene"); got != "default" {
|
||||
t.Errorf("RouteForPersona(cyrene) = %q, want default (no binding stored)", got)
|
||||
}
|
||||
img := Request{Messages: []Message{ImageMessage(RoleUser, "", "data:image/png;base64,abc")}}
|
||||
got, needsVision := r.RouteRequestForPersona("cyrene", img)
|
||||
if !needsVision {
|
||||
t.Error("needsVision = false, want true for an image-bearing request")
|
||||
}
|
||||
if got == "vision-model" {
|
||||
t.Errorf("RouteRequestForPersona = %q, want the persona/role model, not the vision model", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_PersonaRoutesFromModelsJSON pins the carrier: a per-persona
|
||||
// binding written on the models.json group reaches the router through the hot
|
||||
// snapshot, and a reload picks up a changed binding (裁决 03.4.1 载体 = models.json)。
|
||||
func TestRuntimeLLM_PersonaRoutesFromModelsJSON(t *testing.T) {
|
||||
path := writeRTFile(t, []runtimeconfig.Group{{
|
||||
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
|
||||
PersonaModels: map[string]string{"cyrene": "persona-model"},
|
||||
PersonaProviders: map[string]string{"cyrene": ProviderAnthropic},
|
||||
}})
|
||||
m := NewRuntimeLLM(path, rtEnv(), nil)
|
||||
|
||||
req := Request{Messages: []Message{TextMessage(RoleUser, "hi")}}
|
||||
got, _ := m.RouteRequestForPersona("cyrene", req)
|
||||
if got != "persona-model" {
|
||||
t.Fatalf("RouteRequestForPersona(cyrene) = %q, want persona-model", got)
|
||||
}
|
||||
if got, _ := m.RouteRequestForPersona("other", req); got != "group-model" {
|
||||
t.Fatalf("RouteRequestForPersona(other) = %q, want group-model", got)
|
||||
}
|
||||
|
||||
if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{{
|
||||
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
|
||||
}}); err != nil {
|
||||
t.Fatalf("SaveModels: %v", err)
|
||||
}
|
||||
if err := m.Reload(); err != nil {
|
||||
t.Fatalf("Reload: %v", err)
|
||||
}
|
||||
if got, _ := m.RouteRequestForPersona("cyrene", req); got != "group-model" {
|
||||
t.Fatalf("after reload RouteRequestForPersona(cyrene) = %q, want group-model (binding removed)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRuntimeLLM_NoVisionPreemptionOnImage pins that the runtime snapshot no longer
|
||||
// carries the env vision model: a configured LLM_VISION_MODEL must not switch an
|
||||
// image-bearing request to another model, because image routing is owned by
|
||||
// vision.Router (裁决 14.4.1/14.4.5).
|
||||
func TestRuntimeLLM_NoVisionPreemptionOnImage(t *testing.T) {
|
||||
path := writeRTFile(t, []runtimeconfig.Group{{
|
||||
ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "group-model",
|
||||
}})
|
||||
env := rtEnv()
|
||||
env.VisionModel = "vision-model"
|
||||
m := NewRuntimeLLM(path, env, nil)
|
||||
|
||||
req := Request{Messages: []Message{ImageMessage(RoleUser, "", "data:image/png;base64,abc")}}
|
||||
got, needsVision := m.RouteRequest(req)
|
||||
if !needsVision {
|
||||
t.Error("needsVision = false, want true for an image-bearing request")
|
||||
}
|
||||
if got != "group-model" {
|
||||
t.Errorf("RouteRequest = %q, want group-model (no vision pre-emption)", got)
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// Router maps roles to model names. An unknown role resolves to the configured
|
||||
// default model. When a request carries image content and a vision model is
|
||||
// configured, the request routes to the vision model. A Router also carries a
|
||||
// provider per role (defaulting to the global provider) so a multi-vendor
|
||||
// deployment can pair a role's model with the correct protocol; the provider
|
||||
// selection is surfaced via RouteProvider.
|
||||
// default model. A Router also carries a provider per role (defaulting to the
|
||||
// global provider) so a multi-vendor deployment can pair a role's model with the
|
||||
// correct protocol; the provider selection is surfaced via RouteProvider.
|
||||
//
|
||||
// Per 裁决 03.4.1 the "role" of per-role routing is the **persona** a turn
|
||||
// answers as, not the LLM message role: a persona binding wins over the
|
||||
// message-role binding, and a persona without a binding keeps the previous
|
||||
// behavior. The binding carrier is the active models.json group
|
||||
// (runtimeconfig.Group.PersonaModels / PersonaProviders).
|
||||
//
|
||||
// Image routing is deliberately NOT this router's job any more (裁决
|
||||
// 14.4.1/14.4.5): the single authority for layered image routing is
|
||||
// runtime/multimodal/vision.Router, which the loop calls with the main model's
|
||||
// real image-input capability. A "has image → switch to the vision model" rule
|
||||
// here used to bypass that decision.
|
||||
type Router struct {
|
||||
defaultModel string
|
||||
byRole map[Role]string
|
||||
visionModel string
|
||||
defaultProvider string
|
||||
providerByRole map[Role]string
|
||||
personaModel map[string]string
|
||||
personaProvider map[string]string
|
||||
}
|
||||
|
||||
// RouterOption configures a Router.
|
||||
@@ -22,11 +36,36 @@ func WithRoleModel(role Role, model string) RouterOption {
|
||||
return func(r *Router) { r.byRole[role] = model }
|
||||
}
|
||||
|
||||
// WithVisionModel configures the dedicated vision model.
|
||||
// WithVisionModel configures the dedicated vision model. It is kept for
|
||||
// backwards compatibility (tests and callers that route a pure image request by
|
||||
// model name) but production no longer sets it: image routing belongs to
|
||||
// vision.Router (裁决 14.4.1/14.4.5).
|
||||
func WithVisionModel(model string) RouterOption {
|
||||
return func(r *Router) { r.visionModel = model }
|
||||
}
|
||||
|
||||
// WithPersonaModel binds a model to a persona (裁决 03.4.1). A blank persona or
|
||||
// model is ignored so a partially configured group cannot create a binding that
|
||||
// routes a persona to an empty model.
|
||||
func WithPersonaModel(persona, model string) RouterOption {
|
||||
return func(r *Router) {
|
||||
if p := trimKey(persona); p != "" && model != "" {
|
||||
r.personaModel[p] = model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithPersonaProvider binds a vendor protocol to a persona, so one persona can
|
||||
// answer through a different vendor than the group default (裁决 03.4.1). Pair it
|
||||
// with WithPersonaModel.
|
||||
func WithPersonaProvider(persona, provider string) RouterOption {
|
||||
return func(r *Router) {
|
||||
if p := trimKey(persona); p != "" && provider != "" {
|
||||
r.personaProvider[p] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithDefaultProvider sets the provider returned by RouteProvider for roles with
|
||||
// no explicit binding. It defaults to openai.
|
||||
func WithDefaultProvider(provider string) RouterOption {
|
||||
@@ -48,6 +87,8 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router {
|
||||
byRole: map[Role]string{},
|
||||
defaultProvider: ProviderOpenAI,
|
||||
providerByRole: map[Role]string{},
|
||||
personaModel: map[string]string{},
|
||||
personaProvider: map[string]string{},
|
||||
}
|
||||
for _, o := range opts {
|
||||
o(r)
|
||||
@@ -55,6 +96,10 @@ func NewRouter(defaultModel string, opts ...RouterOption) *Router {
|
||||
return r
|
||||
}
|
||||
|
||||
// trimKey normalizes a persona id or provider name before it is stored, so
|
||||
// " cyrene" and "cyrene" cannot become two different bindings.
|
||||
func trimKey(k string) string { return strings.TrimSpace(k) }
|
||||
|
||||
// Route returns the model for a role, falling back to the default model.
|
||||
func (r *Router) Route(role Role) string {
|
||||
if m, ok := r.byRole[role]; ok && m != "" {
|
||||
@@ -76,10 +121,15 @@ func (r *Router) RouteProvider(role Role) string {
|
||||
}
|
||||
|
||||
// RouteRequest picks the model for a request and reports whether the request
|
||||
// needs a vision model. When the request carries image content and a vision
|
||||
// carries image content. When the request carries image content and a vision
|
||||
// model is configured, the vision model is returned with needsVision set to
|
||||
// true. Otherwise the role of the last message drives routing (user by
|
||||
// default).
|
||||
//
|
||||
// Deprecated in the production path: the loop routes by persona via
|
||||
// RouteRequestForPersona, and image routing belongs to vision.Router (裁决
|
||||
// 14.4.1/14.4.5). This method stays because it is the ModelRouter contract the
|
||||
// agent package and the existing tests are built on.
|
||||
func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
|
||||
role := RoleUser
|
||||
if n := len(req.Messages); n > 0 {
|
||||
@@ -92,6 +142,51 @@ func (r *Router) RouteRequest(req Request) (model string, needsVision bool) {
|
||||
return r.Route(role), needsVision
|
||||
}
|
||||
|
||||
// PersonaRouter is the optional persona-aware routing extension of ModelRouter
|
||||
// (裁决 03.4.1). The loop detects it on the agent's router and prefers it over
|
||||
// RouteRequest, so a per-persona model/provider binding actually reaches the
|
||||
// request. It is a separate interface so a router that only knows message roles
|
||||
// keeps working unchanged.
|
||||
type PersonaRouter interface {
|
||||
RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool)
|
||||
}
|
||||
|
||||
// RouteForPersona returns the model bound to persona, falling back to the
|
||||
// router's default model. Unlike Route (message role) this is the persona-level
|
||||
// binding: 裁决 03.4.1 defines the "role" of per-role routing as the persona.
|
||||
func (r *Router) RouteForPersona(persona string) string {
|
||||
if m, ok := r.personaModel[trimKey(persona)]; ok && m != "" {
|
||||
return m
|
||||
}
|
||||
return r.defaultModel
|
||||
}
|
||||
|
||||
// RouteProviderForPersona returns the provider bound to persona, falling back to
|
||||
// the router's default provider (itself the active group's protocol).
|
||||
func (r *Router) RouteProviderForPersona(persona string) string {
|
||||
if p, ok := r.personaProvider[trimKey(persona)]; ok && p != "" {
|
||||
return p
|
||||
}
|
||||
return r.defaultProvider
|
||||
}
|
||||
|
||||
// RouteRequestForPersona routes a request as the given persona: a persona
|
||||
// binding wins over the message-role decision, and a persona without a binding
|
||||
// falls back to RouteRequest so nothing changes for the unconfigured case. It
|
||||
// reports whether the request carries an image, but never switches the model to
|
||||
// the vision model — that decision belongs to vision.Router (裁决 14.4.1/14.4.5).
|
||||
func (r *Router) RouteRequestForPersona(personaID string, req Request) (model string, needsVision bool) {
|
||||
needsVision = HasImage(req.Messages)
|
||||
if m, ok := r.personaModel[trimKey(personaID)]; ok && m != "" {
|
||||
return m, needsVision
|
||||
}
|
||||
role := RoleUser
|
||||
if n := len(req.Messages); n > 0 {
|
||||
role = req.Messages[n-1].Role
|
||||
}
|
||||
return r.Route(role), needsVision
|
||||
}
|
||||
|
||||
// HasImage reports whether any message carries an image content part.
|
||||
func HasImage(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
|
||||
@@ -125,6 +125,17 @@ func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
|
||||
return r.RouteRequest(req)
|
||||
}
|
||||
|
||||
// RouteRequestForPersona implements PersonaRouter (裁决 03.4.1): the loop routes
|
||||
// a turn as the persona it answers as, and a per-persona binding in the active
|
||||
// models.json group reaches the request through here.
|
||||
func (m *RuntimeLLM) RouteRequestForPersona(personaID string, req Request) (string, bool) {
|
||||
r := m.load().router
|
||||
if r == nil {
|
||||
return "", false
|
||||
}
|
||||
return r.RouteRequestForPersona(personaID, req)
|
||||
}
|
||||
|
||||
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
|
||||
// calls report a clear no-model error instead of panicking.
|
||||
func (m *RuntimeLLM) loadInitial() {
|
||||
@@ -160,11 +171,20 @@ func (m *RuntimeLLM) load() *runtimeChainSnapshot {
|
||||
|
||||
// buildSnapshot turns the active group (index 0) into a chain+router. A group
|
||||
// without a base URL/model yields a nil chain (no model configured), surfaced as
|
||||
// ErrNoModel by the adapter methods. The Router keeps the env vision model so a
|
||||
// dedicated vision model survives a reload that only changes the vendor group.
|
||||
// ErrNoModel by the adapter methods.
|
||||
//
|
||||
// The env-local rung is copied onto the group's flat config because a models.json
|
||||
// group does not carry it: without that copy the third degradation level
|
||||
// (裁决 03.4.3) would silently disappear as soon as models.json exists.
|
||||
//
|
||||
// The router is seeded with the active group's per-persona bindings
|
||||
// (裁决 03.4.1). It is deliberately NOT given the env vision model: image routing
|
||||
// is owned by vision.Router, and a "has image → switch model" rule here bypassed
|
||||
// that decision (裁决 14.4.1/14.4.5).
|
||||
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog.Logger) (*runtimeChainSnapshot, error) {
|
||||
active := groups[0]
|
||||
cfg := active.ToLLMConfig()
|
||||
cfg.Local = env.Local
|
||||
chain, err := NewChatChainWithLog(cfg, log)
|
||||
if err != nil && !errors.Is(err, ErrNoModel) {
|
||||
return nil, err
|
||||
@@ -175,9 +195,20 @@ func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig, log *slog
|
||||
// last step of a chain that really has a fallback (design/04 §3.1).
|
||||
chain = chain.WithTokenBudget(active.MaxTokens, env.FallbackMaxTokens)
|
||||
}
|
||||
router := NewRouter(DefaultModelForProvider(cfg),
|
||||
WithVisionModel(env.VisionModel),
|
||||
WithDefaultProvider(cfg.Provider),
|
||||
)
|
||||
router := NewRouter(DefaultModelForProvider(cfg), personaRouterOptions(active, cfg)...)
|
||||
return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil
|
||||
}
|
||||
|
||||
// personaRouterOptions turns the active group's per-persona model/provider
|
||||
// bindings into router options (裁决 03.4.1). It is a helper so buildSnapshot
|
||||
// stays inside the 80-line function budget.
|
||||
func personaRouterOptions(active runtimeconfig.Group, cfg config.LLMConfig) []RouterOption {
|
||||
opts := []RouterOption{WithDefaultProvider(cfg.Provider)}
|
||||
for persona, model := range active.PersonaModels {
|
||||
opts = append(opts, WithPersonaModel(persona, model))
|
||||
}
|
||||
for persona, provider := range active.PersonaProviders {
|
||||
opts = append(opts, WithPersonaProvider(persona, provider))
|
||||
}
|
||||
return opts
|
||||
}
|
||||
Reference in new issue
Block a user