Changeset 0.29.1 (#196)
This commit is contained in:
@@ -67,6 +67,8 @@ func (e *Evaluator) Evaluate(ctx *Context, policies []Policy, candidates []Candi
|
||||
result, matched = applyCostLimit(result, p.Config, ctx)
|
||||
case PolicyModelAlias:
|
||||
result, matched = applyModelAlias(result, p.Config, ctx)
|
||||
case PolicyCapabilityMatch:
|
||||
result, matched = applyCapabilityMatch(result, p.Config, ctx)
|
||||
}
|
||||
|
||||
if matched {
|
||||
@@ -218,6 +220,102 @@ func applyModelAlias(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]
|
||||
return candidates, true
|
||||
}
|
||||
|
||||
// applyCapabilityMatch filters candidates to those whose model has all
|
||||
// required capabilities. Optionally sorts remaining candidates by cheapest
|
||||
// output price when PreferCheapest is set.
|
||||
//
|
||||
// Required capabilities map to ModelCapabilities boolean fields:
|
||||
// "tool_calling", "vision", "thinking", "reasoning",
|
||||
// "code_optimized", "web_search", "streaming"
|
||||
func applyCapabilityMatch(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) {
|
||||
if len(cfg.RequiredCaps) == 0 {
|
||||
return candidates, false
|
||||
}
|
||||
if ctx.Capabilities == nil {
|
||||
return candidates, false
|
||||
}
|
||||
|
||||
filtered := make([]Candidate, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
key := c.ConfigID + ":" + c.Model
|
||||
caps, ok := ctx.Capabilities[key]
|
||||
if !ok {
|
||||
continue // no capability data — skip (conservative)
|
||||
}
|
||||
if hasAllCaps(caps, cfg.RequiredCaps) {
|
||||
c.Reason = "capability match"
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
// No candidates match — fall back to original set rather than
|
||||
// returning empty (better to try than fail immediately).
|
||||
return candidates, false
|
||||
}
|
||||
|
||||
// Sort by cheapest output price if requested
|
||||
if cfg.PreferCheapest && ctx.Pricing != nil {
|
||||
sort.SliceStable(filtered, func(i, j int) bool {
|
||||
ki := filtered[i].ConfigID + ":" + filtered[i].Model
|
||||
kj := filtered[j].ConfigID + ":" + filtered[j].Model
|
||||
pi, oki := ctx.Pricing[ki]
|
||||
pj, okj := ctx.Pricing[kj]
|
||||
if !oki || pi == nil {
|
||||
return false // unknown price sorts last
|
||||
}
|
||||
if !okj || pj == nil {
|
||||
return true
|
||||
}
|
||||
return pi.OutputPerM < pj.OutputPerM
|
||||
})
|
||||
}
|
||||
|
||||
return filtered, true
|
||||
}
|
||||
|
||||
// hasAllCaps checks whether a ModelCapabilities struct has all requested
|
||||
// capability flags set. Unrecognized capability names are ignored.
|
||||
func hasAllCaps(caps *models.ModelCapabilities, required []string) bool {
|
||||
if caps == nil {
|
||||
return false
|
||||
}
|
||||
for _, req := range required {
|
||||
switch req {
|
||||
case "tool_calling":
|
||||
if !caps.ToolCalling {
|
||||
return false
|
||||
}
|
||||
case "vision":
|
||||
if !caps.Vision {
|
||||
return false
|
||||
}
|
||||
case "thinking":
|
||||
if !caps.Thinking {
|
||||
return false
|
||||
}
|
||||
case "reasoning":
|
||||
if !caps.Reasoning {
|
||||
return false
|
||||
}
|
||||
case "code_optimized":
|
||||
if !caps.CodeOptimized {
|
||||
return false
|
||||
}
|
||||
case "web_search":
|
||||
if !caps.WebSearch {
|
||||
return false
|
||||
}
|
||||
case "streaming":
|
||||
if !caps.Streaming {
|
||||
return false
|
||||
}
|
||||
// Unrecognized caps are ignored (forward-compatible)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── Health Sorting ──────────────────────────
|
||||
|
||||
var statusOrder = map[models.ProviderStatus]int{
|
||||
|
||||
@@ -292,3 +292,207 @@ func TestEvaluate_InactivePolicy(t *testing.T) {
|
||||
t.Fatalf("inactive policy should not filter, got %d candidates", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// ── capability_match tests ──────────────────
|
||||
|
||||
func capContext() *Context {
|
||||
return &Context{
|
||||
UserID: "u1", Model: "any",
|
||||
HealthStatus: map[string]models.ProviderStatus{},
|
||||
Capabilities: map[string]*models.ModelCapabilities{
|
||||
"cfg-openai:gpt-4o": {ToolCalling: true, Vision: true, Streaming: true},
|
||||
"cfg-anthropic:claude-sonnet-4-20250514": {ToolCalling: true, Vision: true, Thinking: true, Streaming: true},
|
||||
"cfg-venice:llama-3.1-405b": {ToolCalling: false, Vision: false, Streaming: true},
|
||||
},
|
||||
Pricing: map[string]*models.ModelPricing{
|
||||
"cfg-openai:gpt-4o": {OutputPerM: 15.0},
|
||||
"cfg-anthropic:claude-sonnet-4-20250514": {OutputPerM: 3.0},
|
||||
"cfg-venice:llama-3.1-405b": {OutputPerM: 0.5},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_ToolCalling(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Needs tool calling", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, decision := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// Venice (llama) has no tool_calling — should be filtered out
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 candidates with tool_calling, got %d", len(result))
|
||||
}
|
||||
for _, c := range result {
|
||||
if c.ConfigID == "cfg-venice" {
|
||||
t.Error("venice should be filtered (no tool_calling)")
|
||||
}
|
||||
}
|
||||
if decision.PolicyID != "p1" {
|
||||
t.Errorf("expected policy p1, got %s", decision.PolicyID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_MultipleCaps(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Needs thinking + tool_calling", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"thinking", "tool_calling"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// Only anthropic has both thinking AND tool_calling
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 candidate with thinking+tool_calling, got %d", len(result))
|
||||
}
|
||||
if result[0].ConfigID != "cfg-anthropic" {
|
||||
t.Errorf("expected anthropic, got %s", result[0].ConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_PreferCheapest(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Tool calling, cheapest first", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{
|
||||
RequiredCaps: []string{"tool_calling"},
|
||||
PreferCheapest: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
if len(result) != 2 {
|
||||
t.Fatalf("expected 2 candidates, got %d", len(result))
|
||||
}
|
||||
// Anthropic ($3/M) should be before OpenAI ($15/M)
|
||||
if result[0].ConfigID != "cfg-anthropic" {
|
||||
t.Errorf("expected cheapest (anthropic) first, got %s", result[0].ConfigID)
|
||||
}
|
||||
if result[1].ConfigID != "cfg-openai" {
|
||||
t.Errorf("expected openai second, got %s", result[1].ConfigID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_NoneMatch_FallsBack(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Needs web_search", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"web_search"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// None of the candidates have web_search — should fall back to all 3
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (fallback), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_StreamingOnly(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Name: "Streaming only", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"streaming"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// All three have streaming — all should pass
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (all have streaming), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_NoCapsData(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := &Context{
|
||||
UserID: "u1", Model: "any",
|
||||
HealthStatus: map[string]models.ProviderStatus{},
|
||||
Capabilities: nil, // no capability data
|
||||
}
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{"tool_calling"}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// No caps data — policy should not match, all candidates kept
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (no caps data), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluate_CapabilityMatch_EmptyRequiredCaps(t *testing.T) {
|
||||
e := NewEvaluator()
|
||||
ctx := capContext()
|
||||
policies := []Policy{
|
||||
{
|
||||
ID: "p1", Priority: 1,
|
||||
Type: PolicyCapabilityMatch, IsActive: true, Scope: "global",
|
||||
Config: PolicyConfig{RequiredCaps: []string{}},
|
||||
},
|
||||
}
|
||||
|
||||
result, _ := e.Evaluate(ctx, policies, baseCandidates())
|
||||
|
||||
// Empty required caps — no filtering
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 candidates (empty caps), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasAllCaps(t *testing.T) {
|
||||
caps := &models.ModelCapabilities{
|
||||
ToolCalling: true, Vision: true, Streaming: true,
|
||||
}
|
||||
|
||||
if !hasAllCaps(caps, []string{"tool_calling"}) {
|
||||
t.Error("should match tool_calling")
|
||||
}
|
||||
if !hasAllCaps(caps, []string{"tool_calling", "vision"}) {
|
||||
t.Error("should match tool_calling+vision")
|
||||
}
|
||||
if hasAllCaps(caps, []string{"thinking"}) {
|
||||
t.Error("should not match thinking")
|
||||
}
|
||||
if !hasAllCaps(caps, []string{"unknown_future_cap"}) {
|
||||
t.Error("unknown caps should be ignored (forward compat)")
|
||||
}
|
||||
if hasAllCaps(nil, []string{"tool_calling"}) {
|
||||
t.Error("nil caps should return false")
|
||||
}
|
||||
if !hasAllCaps(caps, nil) {
|
||||
t.Error("nil required should return true")
|
||||
}
|
||||
if !hasAllCaps(caps, []string{}) {
|
||||
t.Error("empty required should return true")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ const (
|
||||
PolicyCostLimit PolicyType = "cost_limit"
|
||||
// PolicyModelAlias maps a model alias to a specific provider+model pair.
|
||||
PolicyModelAlias PolicyType = "model_alias"
|
||||
// PolicyCapabilityMatch filters to models with required capabilities,
|
||||
// optionally sorted by cheapest output price. v0.29.1.
|
||||
PolicyCapabilityMatch PolicyType = "capability_match"
|
||||
)
|
||||
|
||||
// Policy is one routing rule stored in the routing_policies table.
|
||||
@@ -57,6 +60,10 @@ type PolicyConfig struct {
|
||||
AliasTo string `json:"alias_to,omitempty"` // model ID
|
||||
AliasProvider string `json:"alias_provider,omitempty"` // config ID
|
||||
|
||||
// capability_match: filter candidates by model capabilities
|
||||
RequiredCaps []string `json:"required_caps,omitempty"` // e.g. ["tool_calling", "vision"]
|
||||
PreferCheapest bool `json:"prefer_cheapest,omitempty"` // sort by output_price_per_m ascending
|
||||
|
||||
// Shared: health requirements
|
||||
SkipDown bool `json:"skip_down,omitempty"` // skip providers with status "down"
|
||||
PreferHealthy bool `json:"prefer_healthy,omitempty"` // rank healthy > degraded
|
||||
@@ -79,6 +86,10 @@ type Context struct {
|
||||
// Pricing for candidate models (keyed by "configID:modelID").
|
||||
// Populated lazily from the pricing store.
|
||||
Pricing map[string]*models.ModelPricing
|
||||
|
||||
// Capabilities for candidate models (keyed by "configID:modelID").
|
||||
// Populated lazily from the catalog store. Used by capability_match.
|
||||
Capabilities map[string]*models.ModelCapabilities
|
||||
}
|
||||
|
||||
// ── Candidates ─────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user