Files
vikingowl a2b7f8eb3f feat(router): vision capability gating and Ollama vision detection
Task gains a RequiresVision bool; filterFeasible enforces it on
both the primary feasibility pass and the last-resort fallback
(no degradation to a non-vision arm — the model literally cannot
consume image bytes).

Ollama discovery now probes /api/show for vision capability:
- details.families containing "clip" / "mllama" / "*vl"
- capabilities array containing "vision" (newer Ollama)
- name-prefix fallback for releases that predate either
  (llava, qwen2.5-vl, llama3.2-vision, moondream, pixtral, etc.)

OllamaProbeResult replaces the map[string]bool tool cache so the
single /api/show call can populate tools + vision + ctx-size in
one probe. DiscoverOllama / DiscoverLocalModels signatures updated;
nil-cache callers in cmd/gnoma keep working unchanged.
RegisterDiscoveredModels propagates SupportsVision into the arm's
Capabilities.Vision.

Tests cover RequiresVision filtering in both the happy path
(vision-only arm chosen when image present) and the fallback path
(non-vision arm rejected even as last resort).
2026-05-22 11:50:33 +02:00

72 lines
1.9 KiB
Go

package router
import (
"testing"
"somegit.dev/Owlibou/gnoma/internal/provider"
)
func TestFilterFeasible_RequiresVision_FiltersNonVisionArms(t *testing.T) {
textOnly := &Arm{
ID: NewArmID("ollama", "qwen2.5-coder:7b"),
Capabilities: provider.Capabilities{
ToolUse: true,
Vision: false,
ContextWindow: 32768,
},
}
visionArm := &Arm{
ID: NewArmID("ollama", "llava:7b"),
Capabilities: provider.Capabilities{
ToolUse: true,
Vision: true,
ContextWindow: 4096,
},
}
arms := []*Arm{textOnly, visionArm}
t.Run("no image: both arms feasible", func(t *testing.T) {
task := Task{Type: TaskGeneration, RequiresTools: true, RequiresVision: false}
got := filterFeasible(arms, task)
if len(got) != 2 {
t.Errorf("got %d arms, want 2", len(got))
}
})
t.Run("image present: only vision arm feasible", func(t *testing.T) {
task := Task{Type: TaskGeneration, RequiresTools: true, RequiresVision: true}
got := filterFeasible(arms, task)
if len(got) != 1 {
t.Fatalf("got %d arms, want 1", len(got))
}
if got[0].ID != visionArm.ID {
t.Errorf("selected arm = %s, want %s", got[0].ID, visionArm.ID)
}
})
}
func TestFilterFeasible_RequiresVision_FallbackAlsoFilters(t *testing.T) {
// All arms unavailable for normal quality path; fallback path must
// still respect RequiresVision (can't degrade to a text-only arm
// when the model literally cannot see the image).
textOnly := &Arm{
ID: NewArmID("ollama", "qwen2.5:0.5b"), // tiny → low quality
Capabilities: provider.Capabilities{
ToolUse: true,
Vision: false,
ContextWindow: 4096,
},
}
arms := []*Arm{textOnly}
task := Task{
Type: TaskGeneration,
RequiresTools: true,
RequiresVision: true,
}
got := filterFeasible(arms, task)
if len(got) != 0 {
t.Errorf("got %d arms, want 0 — non-vision arm must not be selected even as fallback", len(got))
}
}