543 lines
20 KiB
Go
543 lines
20 KiB
Go
package middleware_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/backend"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
. "github.com/mudler/LocalAI/core/http/middleware"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/core/services/routing/router"
|
|
"github.com/mudler/LocalAI/core/templates"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// The RouteModel middleware wires the score classifier into request
|
|
// rewriting. The classifier itself is covered in
|
|
// router/score_test.go — these specs pin the middleware-level
|
|
// behaviour: candidate matching against the active label set, the
|
|
// fallback path, and the depth-1 invariant.
|
|
|
|
var _ = Describe("RouteModel middleware (score classifier)", func() {
|
|
var (
|
|
modelDir string
|
|
appConfig *config.ApplicationConfig
|
|
loader *config.ModelConfigLoader
|
|
store *fakeDecisionStore
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
d, err := os.MkdirTemp("", "router-test-*")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
modelDir = d
|
|
appConfig = &config.ApplicationConfig{
|
|
Context: context.Background(),
|
|
SystemState: &system.SystemState{Model: system.Model{ModelsPath: modelDir}},
|
|
}
|
|
loader = config.NewModelConfigLoader(modelDir)
|
|
store = &fakeDecisionStore{}
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_ = os.RemoveAll(modelDir)
|
|
})
|
|
|
|
It("routes to a candidate whose labels cover the active set", func() {
|
|
// 3 policies, 2 candidates. Small model has [casual-chat],
|
|
// bigger has [code-generation, math-reasoning, casual-chat].
|
|
// A query that activates code-generation should fall to the
|
|
// bigger candidate because it's the only one that covers it.
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
writeCandidate(modelDir, "small-model")
|
|
writeCandidate(modelDir, "big-model")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -0.05, // dominant
|
|
"casual-chat": -3.0,
|
|
"math-reasoning": -4.0,
|
|
}}
|
|
rec, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("debug my Go null pointer"), stubScorerFactory(s))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rec.Code).To(Equal(http.StatusOK))
|
|
Expect(rec.Body.String()).To(Equal("served:big-model"))
|
|
Expect(store.records).To(HaveLen(1))
|
|
Expect(store.records[0].ServedModel).To(Equal("big-model"))
|
|
Expect(store.records[0].Label).To(ContainSubstring("code-generation"))
|
|
})
|
|
|
|
It("prefers the smaller candidate when both cover the active set", func() {
|
|
// Both candidates list casual-chat. Admins order small →
|
|
// big, so a casual-chat-only request must route to small.
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
writeCandidate(modelDir, "small-model")
|
|
writeCandidate(modelDir, "big-model")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -5.0,
|
|
"casual-chat": -0.05, // dominant
|
|
"math-reasoning": -5.0,
|
|
}}
|
|
rec, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("hi"), stubScorerFactory(s))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rec.Body.String()).To(Equal("served:small-model"))
|
|
})
|
|
|
|
It("falls back when no candidate covers the active label set", func() {
|
|
// Only the bigger candidate covers math-reasoning. We
|
|
// deliberately drop it from the candidates list so neither
|
|
// matches; expect Fallback to fire.
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
// Remove the second candidate so coverage gap appears.
|
|
routerCfg.Router.Candidates = routerCfg.Router.Candidates[:1]
|
|
writeCandidate(modelDir, "small-model")
|
|
writeCandidate(modelDir, "qwen3-0.6b")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -5.0,
|
|
"casual-chat": -5.0,
|
|
"math-reasoning": -0.05, // dominant — but no candidate has it
|
|
}}
|
|
rec, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("3 apples cost $2.40"), stubScorerFactory(s))
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rec.Body.String()).To(Equal("served:qwen3-0.6b"))
|
|
})
|
|
|
|
It("rejects candidates that reference unknown labels at build time", func() {
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
routerCfg.Router.Candidates = append(routerCfg.Router.Candidates, config.RouterCandidate{
|
|
Model: "broken",
|
|
Labels: []string{"nonexistent-label"},
|
|
})
|
|
writeCandidate(modelDir, "small-model")
|
|
writeCandidate(modelDir, "big-model")
|
|
writeCandidate(modelDir, "broken")
|
|
writeCandidate(modelDir, "qwen3-0.6b")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -0.05,
|
|
"casual-chat": -3.0,
|
|
"math-reasoning": -4.0,
|
|
}}
|
|
_, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("debug something"), stubScorerFactory(s))
|
|
// Build-time config bugs (here: a candidate referencing a
|
|
// label not declared in policies) must surface to the client
|
|
// — the previous silent-fallback behaviour hid the broken
|
|
// config and left operators wondering why traces never showed
|
|
// the classifier model running.
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("unknown label"))
|
|
})
|
|
|
|
It("returns 500 when the candidate is itself a router (depth-1 invariant)", func() {
|
|
// The candidate model is itself a router. We must reject
|
|
// the dispatch — chained routers are deliberately
|
|
// disallowed.
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
// Bend the test setup: replace one of the candidate-model
|
|
// configs with a nested-router config.
|
|
nestedRouter := newScoreRouterModel(modelDir, "small-model")
|
|
Expect(os.WriteFile(filepath.Join(modelDir, "small-model.yaml"), []byte(toYAML(nestedRouter)), 0o644)).To(Succeed())
|
|
writeCandidate(modelDir, "big-model")
|
|
writeCandidate(modelDir, "qwen3-0.6b")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -5.0,
|
|
"casual-chat": -0.05,
|
|
"math-reasoning": -5.0,
|
|
}}
|
|
_, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("hi"), stubScorerFactory(s))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("depth-1 invariant"))
|
|
})
|
|
})
|
|
|
|
// Regression coverage for the rendered routing prompt — pins the
|
|
// guarantee that the routing system prompt (route listing, JSON
|
|
// output schema) actually reaches the classifier model. The first
|
|
// implementation of the template-aware renderer routed through
|
|
// EvaluateTemplateForPrompt, which only invokes the outer Chat
|
|
// template — and the gallery's outer Chat templates are
|
|
// `{{.Input -}}<|im_start|>assistant` shape, so .SystemPrompt was
|
|
// silently dropped. The fix routes through TemplateMessages, which
|
|
// renders each role through ChatMessage and joins the result into
|
|
// .Input. These specs would fail loudly if the renderer ever
|
|
// regresses back to bypassing per-role formatting.
|
|
var _ = Describe("RouteModel rendered classifier prompt", func() {
|
|
var (
|
|
modelDir string
|
|
appConfig *config.ApplicationConfig
|
|
loader *config.ModelConfigLoader
|
|
store *fakeDecisionStore
|
|
eval *templates.Evaluator
|
|
)
|
|
|
|
BeforeEach(func() {
|
|
d, err := os.MkdirTemp("", "router-render-*")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
modelDir = d
|
|
appConfig = &config.ApplicationConfig{
|
|
Context: context.Background(),
|
|
SystemState: &system.SystemState{Model: system.Model{ModelsPath: modelDir}},
|
|
}
|
|
loader = config.NewModelConfigLoader(modelDir)
|
|
store = &fakeDecisionStore{}
|
|
eval = templates.NewEvaluator(modelDir)
|
|
})
|
|
|
|
AfterEach(func() {
|
|
_ = os.RemoveAll(modelDir)
|
|
})
|
|
|
|
It("includes the routing system prompt in the rendered ChatML envelope", func() {
|
|
// Mirrors the live arch-router-1.5b.yaml: chatml-style chat +
|
|
// chat_message templates. This is the production-wired path.
|
|
writeChatMLClassifierModel(modelDir, "arch-router")
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -0.05,
|
|
"casual-chat": -3.0,
|
|
"math-reasoning": -4.0,
|
|
}}
|
|
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
|
|
openAIChat("debug this null pointer"),
|
|
ClassifierDeps{
|
|
Scorer: stubScorerFactory(s),
|
|
ModelLookup: loaderLookup(loader, appConfig),
|
|
Evaluator: eval,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
|
|
// The routing system prompt must reach the scorer. Three
|
|
// anchors: the route-listing block, one of the JSON-shaped
|
|
// route entries (escapeJSONString preserves the description),
|
|
// and the JSON output schema instruction.
|
|
Expect(s.lastPrompt).To(ContainSubstring("<routes>"),
|
|
"system prompt dropped: rendered prompt missing route-listing block. got: %q", s.lastPrompt)
|
|
Expect(s.lastPrompt).To(ContainSubstring(`{"name": "code-generation"`),
|
|
"system prompt dropped: rendered prompt missing route entries. got: %q", s.lastPrompt)
|
|
Expect(s.lastPrompt).To(ContainSubstring(`{"route": "<name>"}`),
|
|
"system prompt dropped: rendered prompt missing JSON output schema. got: %q", s.lastPrompt)
|
|
|
|
// And the per-role envelope must be present (proves we went
|
|
// through ChatMessage, not the SystemPrompt-only path).
|
|
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>system"),
|
|
"system role marker missing — ChatMessage template wasn't invoked")
|
|
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>user"),
|
|
"user role marker missing")
|
|
// User probe makes it through the per-role template. The trailing
|
|
// \n on the probe content is added by OpenAIProbeFromRequest;
|
|
// preserved through ChatMessage rendering.
|
|
Expect(s.lastPrompt).To(ContainSubstring("debug this null pointer"),
|
|
"user probe missing from rendered prompt")
|
|
// Outer Chat template must add the assistant-open marker so
|
|
// the scorer's first predicted token is the start of the
|
|
// candidate.
|
|
Expect(s.lastPrompt).To(MatchRegexp(`<\|im_start\|>assistant\s*$`),
|
|
"rendered prompt must end at assistant-open marker. got: %q", s.lastPrompt)
|
|
})
|
|
|
|
It("refuses to build the router when the classifier model has no chat_message template", func() {
|
|
// Partial template config: only the outer Chat, no per-role piece.
|
|
// The router renders the scoring prompt client-side from the
|
|
// classifier model's own template, so a missing template is a hard
|
|
// error rather than a silent fall back to a generic ChatML envelope
|
|
// the model may not have been trained on.
|
|
writePartialClassifierModel(modelDir, "arch-router")
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -0.05,
|
|
"casual-chat": -3.0,
|
|
"math-reasoning": -4.0,
|
|
}}
|
|
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
|
|
openAIChat("hello world"),
|
|
ClassifierDeps{
|
|
Scorer: stubScorerFactory(s),
|
|
ModelLookup: loaderLookup(loader, appConfig),
|
|
Evaluator: eval,
|
|
})
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("no chat template"),
|
|
"missing classifier template must surface as a clear config error. got: %v", err)
|
|
})
|
|
|
|
It("uses the classifier model's first stopword as the candidate suffix", func() {
|
|
writeChatMLClassifierModel(modelDir, "arch-router")
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -0.05,
|
|
"casual-chat": -3.0,
|
|
"math-reasoning": -4.0,
|
|
}}
|
|
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
|
|
openAIChat("hi"),
|
|
ClassifierDeps{
|
|
Scorer: stubScorerFactory(s),
|
|
ModelLookup: loaderLookup(loader, appConfig),
|
|
Evaluator: eval,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
// arch-router YAML lists <|im_end|> first.
|
|
for _, c := range s.lastCandidates {
|
|
Expect(c).To(HaveSuffix("<|im_end|>"),
|
|
"candidate must end with the classifier model's turn-end token. got: %q", c)
|
|
}
|
|
})
|
|
|
|
It("picks the actual turn-end token when the stopwords list is misordered (Llama-3 style)", func() {
|
|
// gallery/llama3-instruct.yaml et al. defensively list
|
|
// <|im_end|> first even though the actual Llama-3 assistant
|
|
// turn-end is <|eot_id|>. The naive "stopwords[0]" pick would
|
|
// suffix candidates with <|im_end|> — a token Llama-3 never
|
|
// emits at turn end. pickAssistantTurnEnd should scan the
|
|
// chat_message template and recognise <|eot_id|> as the real
|
|
// turn-end.
|
|
writeLlama3StyleClassifierModel(modelDir, "arch-router")
|
|
routerCfg := newScoreRouterModel(modelDir, "smart-router")
|
|
|
|
s := &stubScorer{labelToLogProb: map[string]float64{
|
|
"code-generation": -0.05,
|
|
"casual-chat": -3.0,
|
|
"math-reasoning": -4.0,
|
|
}}
|
|
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
|
|
openAIChat("hi"),
|
|
ClassifierDeps{
|
|
Scorer: stubScorerFactory(s),
|
|
ModelLookup: loaderLookup(loader, appConfig),
|
|
Evaluator: eval,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
for _, c := range s.lastCandidates {
|
|
Expect(c).To(HaveSuffix("<|eot_id|>"),
|
|
"candidate must end with the Llama-3 turn-end token, not the misordered first stopword. got: %q", c)
|
|
}
|
|
})
|
|
})
|
|
|
|
// --- helpers ---
|
|
|
|
// stubScorer scores each candidate label according to a fixed
|
|
// label→log-prob map; per-token length is faked at 2 tokens so length
|
|
// normalisation is a no-op. Captures the prompt + candidate list of
|
|
// the last Score call so regression tests can pin the rendered prompt
|
|
// shape.
|
|
type stubScorer struct {
|
|
labelToLogProb map[string]float64
|
|
lastPrompt string
|
|
lastCandidates []string
|
|
}
|
|
|
|
func (s *stubScorer) Score(_ context.Context, prompt string, candidates []string) ([]backend.CandidateScore, error) {
|
|
s.lastPrompt = prompt
|
|
s.lastCandidates = append([]string(nil), candidates...)
|
|
out := make([]backend.CandidateScore, len(candidates))
|
|
for i, c := range candidates {
|
|
// Match against the full `{"route": "<label>"}` envelope.
|
|
// Naively substring-matching on `"<label>"` would let a label
|
|
// that's a substring of another collide via Go's randomised
|
|
// map iteration order — `"code"` would also match the
|
|
// `"code-generation"` candidate.
|
|
var lp float64
|
|
for label, v := range s.labelToLogProb {
|
|
if strings.Contains(c, `{"route": "`+label+`"}`) {
|
|
lp = v
|
|
break
|
|
}
|
|
}
|
|
out[i] = backend.CandidateScore{
|
|
LogProb: lp * 2,
|
|
LengthNormalizedLogProb: lp,
|
|
NumTokens: 2,
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func stubScorerFactory(s *stubScorer) ScorerFactory {
|
|
return func(string) backend.Scorer { return s }
|
|
}
|
|
|
|
type fakeDecisionStore struct {
|
|
records []router.DecisionRecord
|
|
}
|
|
|
|
func (f *fakeDecisionStore) Record(_ context.Context, r router.DecisionRecord) error {
|
|
f.records = append(f.records, r)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeDecisionStore) List(_ context.Context, _ router.DecisionListQuery) ([]router.DecisionRecord, error) {
|
|
out := append([]router.DecisionRecord(nil), f.records...)
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeDecisionStore) Close() error { return nil }
|
|
func (f *fakeDecisionStore) Count(_ context.Context) (int, error) { return len(f.records), nil }
|
|
|
|
// newScoreRouterModel builds a smart-router config with 3 policies
|
|
// and 2 candidates (small with one label, bigger with all three).
|
|
// Admins are expected to order candidates small → large; the
|
|
// middleware picks the first whose labels are a superset of the
|
|
// active set.
|
|
func newScoreRouterModel(modelDir, name string) *config.ModelConfig {
|
|
cfg := &config.ModelConfig{
|
|
Name: name,
|
|
Router: config.RouterConfig{
|
|
Classifier: "score",
|
|
ClassifierModel: "arch-router",
|
|
Fallback: "qwen3-0.6b",
|
|
Policies: []config.RouterPolicy{
|
|
{Label: "code-generation", Description: "writing or debugging code"},
|
|
{Label: "casual-chat", Description: "small talk"},
|
|
{Label: "math-reasoning", Description: "arithmetic and word problems"},
|
|
},
|
|
Candidates: []config.RouterCandidate{
|
|
{Model: "small-model", Labels: []string{"casual-chat"}},
|
|
{Model: "big-model", Labels: []string{"code-generation", "casual-chat", "math-reasoning"}},
|
|
},
|
|
},
|
|
}
|
|
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(toYAML(cfg)), 0o644)).To(Succeed())
|
|
return cfg
|
|
}
|
|
|
|
func writeCandidate(modelDir, name string) {
|
|
body := "name: " + name + "\nbackend: mock-backend\n"
|
|
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
|
|
}
|
|
|
|
func toYAML(cfg *config.ModelConfig) string {
|
|
b, err := yaml.Marshal(cfg)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
return string(b)
|
|
}
|
|
|
|
func openAIChat(content string) *schema.OpenAIRequest {
|
|
req := &schema.OpenAIRequest{
|
|
Messages: []schema.Message{
|
|
{Role: "user", Content: content},
|
|
},
|
|
}
|
|
req.Model = "smart-router"
|
|
return req
|
|
}
|
|
|
|
func runRouter(loader *config.ModelConfigLoader, appConfig *config.ApplicationConfig, store router.DecisionStore, routerCfg *config.ModelConfig, parsed any, scorerFactory ScorerFactory) (*httptest.ResponseRecorder, error) {
|
|
return runRouterWithDeps(loader, appConfig, store, routerCfg, parsed, ClassifierDeps{Scorer: scorerFactory})
|
|
}
|
|
|
|
// runRouterWithDeps is runRouter's general form: lets the caller pass
|
|
// a fully-populated ClassifierDeps (ModelLookup, Evaluator, ...) so
|
|
// tests can exercise the template-renderer + stop-token derivation
|
|
// paths, not just the bare-scorer fast path.
|
|
func runRouterWithDeps(loader *config.ModelConfigLoader, appConfig *config.ApplicationConfig, store router.DecisionStore, routerCfg *config.ModelConfig, parsed any, deps ClassifierDeps) (*httptest.ResponseRecorder, error) {
|
|
mw := RouteModel(loader, appConfig, store, nil, OpenAIProbe, router.SourceChat, deps)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}"))
|
|
rec := httptest.NewRecorder()
|
|
c := echo.New().NewContext(req, rec)
|
|
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, routerCfg)
|
|
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, parsed)
|
|
handler := mw(func(c echo.Context) error {
|
|
served, _ := c.Get(ContextKeyServedModel).(string)
|
|
return c.String(http.StatusOK, "served:"+served)
|
|
})
|
|
err := handler(c)
|
|
return rec, err
|
|
}
|
|
|
|
// loaderLookup mirrors application.ModelConfigLookup — bridges the
|
|
// loader to the ModelConfigLookup signature ClassifierDeps wants.
|
|
func loaderLookup(loader *config.ModelConfigLoader, appConfig *config.ApplicationConfig) ModelConfigLookup {
|
|
return func(name string) *config.ModelConfig {
|
|
cfg, err := loader.LoadModelConfigFileByNameDefaultOptions(name, appConfig)
|
|
if err != nil || cfg == nil {
|
|
return nil
|
|
}
|
|
return cfg
|
|
}
|
|
}
|
|
|
|
// writeChatMLClassifierModel writes a classifier model YAML that
|
|
// mirrors the live arch-router-1.5b.yaml shipped at
|
|
// volumes/models/arch-router-1.5b.yaml: ChatML chat + chat_message
|
|
// templates, score usecase, <|im_end|> first in stopwords.
|
|
func writeChatMLClassifierModel(modelDir, name string) {
|
|
body := `name: ` + name + `
|
|
backend: llama-cpp
|
|
known_usecases:
|
|
- score
|
|
stopwords:
|
|
- <|im_end|>
|
|
- <|endoftext|>
|
|
template:
|
|
chat: |
|
|
{{.Input -}}
|
|
<|im_start|>assistant
|
|
chat_message: |
|
|
<|im_start|>{{ .RoleName }}
|
|
{{- if .Content }}
|
|
{{ .Content }}
|
|
{{- end }}<|im_end|>
|
|
`
|
|
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
|
|
}
|
|
|
|
// writeLlama3StyleClassifierModel writes a classifier model mirroring
|
|
// gallery/llama3-instruct.yaml — stopwords defensively list <|im_end|>
|
|
// first even though the assistant turn-end is actually <|eot_id|>.
|
|
// Exercises pickAssistantTurnEnd's template scan: the right token is
|
|
// the one that appears in chat_message, not the one at position 0.
|
|
func writeLlama3StyleClassifierModel(modelDir, name string) {
|
|
body := `name: ` + name + `
|
|
backend: llama-cpp
|
|
known_usecases:
|
|
- score
|
|
stopwords:
|
|
- <|im_end|>
|
|
- <dummy32000>
|
|
- "<|eot_id|>"
|
|
- <|end_of_text|>
|
|
template:
|
|
chat: |
|
|
{{.Input }}
|
|
<|start_header_id|>assistant<|end_header_id|>
|
|
chat_message: |
|
|
<|start_header_id|>{{ .RoleName }}<|end_header_id|>
|
|
|
|
{{ .Content }}<|eot_id|>
|
|
`
|
|
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
|
|
}
|
|
|
|
// writePartialClassifierModel writes a classifier model that has the
|
|
// outer Chat template but no ChatMessage — exercises the
|
|
// newTemplateRenderer "refuse partial templating" branch, which makes
|
|
// buildClassifier reject the router with a missing-template error.
|
|
func writePartialClassifierModel(modelDir, name string) {
|
|
body := `name: ` + name + `
|
|
backend: llama-cpp
|
|
known_usecases:
|
|
- score
|
|
stopwords:
|
|
- <|im_end|>
|
|
template:
|
|
chat: |
|
|
{{.Input -}}
|
|
<|im_start|>assistant
|
|
`
|
|
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
|
|
}
|