Changeset 0.9.0 (#50)
This commit is contained in:
195
server/capabilities/capabilities_test.go
Normal file
195
server/capabilities/capabilities_test.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
func TestLookupKnownModel_ExactMatch(t *testing.T) {
|
||||
caps, ok := LookupKnownModel("gpt-4o")
|
||||
if !ok {
|
||||
t.Fatal("expected gpt-4o to be found")
|
||||
}
|
||||
if caps.MaxOutputTokens != 16384 {
|
||||
t.Errorf("gpt-4o max output: got %d, want 16384", caps.MaxOutputTokens)
|
||||
}
|
||||
if !caps.ToolCalling {
|
||||
t.Error("gpt-4o should support tool calling")
|
||||
}
|
||||
if !caps.Vision {
|
||||
t.Error("gpt-4o should support vision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupKnownModel_PrefixMatch(t *testing.T) {
|
||||
caps, ok := LookupKnownModel("claude-sonnet-4-20250514")
|
||||
if !ok {
|
||||
t.Fatal("expected claude-sonnet-4-20250514 to match prefix claude-sonnet-4")
|
||||
}
|
||||
if caps.MaxOutputTokens != 16000 {
|
||||
t.Errorf("claude-sonnet-4 max output: got %d, want 16000", caps.MaxOutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupKnownModel_ProviderPrefix(t *testing.T) {
|
||||
caps, ok := LookupKnownModel("anthropic/claude-sonnet-4-20250514")
|
||||
if !ok {
|
||||
t.Fatal("expected provider-prefixed model to match")
|
||||
}
|
||||
if caps.MaxOutputTokens != 16000 {
|
||||
t.Errorf("got %d, want 16000", caps.MaxOutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupKnownModel_NotFound(t *testing.T) {
|
||||
_, ok := LookupKnownModel("totally-unknown-model-xyz")
|
||||
if ok {
|
||||
t.Error("expected unknown model to not be found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferCapabilities_ToolCalling(t *testing.T) {
|
||||
tests := []struct {
|
||||
model string
|
||||
want bool
|
||||
}{
|
||||
{"llama-3.1-70b-instruct", true},
|
||||
{"mistral-7b", true},
|
||||
{"qwen-2.5-72b", true},
|
||||
{"phi-3-mini", true},
|
||||
{"random-smallmodel", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
caps := InferCapabilities(tt.model)
|
||||
if caps.ToolCalling != tt.want {
|
||||
t.Errorf("InferCapabilities(%q).ToolCalling = %v, want %v", tt.model, caps.ToolCalling, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferCapabilities_Vision(t *testing.T) {
|
||||
caps := InferCapabilities("llava-v1.6-34b")
|
||||
if !caps.Vision {
|
||||
t.Error("llava should infer vision capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferCapabilities_Reasoning(t *testing.T) {
|
||||
caps := InferCapabilities("deepseek-r1-distill-llama-70b")
|
||||
if !caps.Reasoning {
|
||||
t.Error("deepseek-r1 should infer reasoning capability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_FromCaps(t *testing.T) {
|
||||
caps := models.ModelCapabilities{MaxOutputTokens: 32000}
|
||||
got := ResolveMaxOutput("whatever-model", caps)
|
||||
if got != 32000 {
|
||||
t.Errorf("got %d, want 32000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
|
||||
caps := models.ModelCapabilities{}
|
||||
got := ResolveMaxOutput("claude-opus-4-20250514", caps)
|
||||
if got != 32000 {
|
||||
t.Errorf("got %d, want 32000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_FromContext(t *testing.T) {
|
||||
caps := models.ModelCapabilities{MaxContext: 32768}
|
||||
got := ResolveMaxOutput("unknown-model-abc", caps)
|
||||
if got != 4096 {
|
||||
t.Errorf("got %d, want 4096 (derived from context/8)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_ContextClamp(t *testing.T) {
|
||||
caps := models.ModelCapabilities{MaxContext: 1048576}
|
||||
got := ResolveMaxOutput("unknown-large-model", caps)
|
||||
if got != 16384 {
|
||||
t.Errorf("got %d, want 16384 (clamped)", got)
|
||||
}
|
||||
|
||||
caps = models.ModelCapabilities{MaxContext: 4096}
|
||||
got = ResolveMaxOutput("unknown-tiny-model", caps)
|
||||
if got != 2048 {
|
||||
t.Errorf("got %d, want 2048 (clamped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_LastResort(t *testing.T) {
|
||||
caps := models.ModelCapabilities{}
|
||||
got := ResolveMaxOutput("mystery-model", caps)
|
||||
if got != 4096 {
|
||||
t.Errorf("got %d, want 4096 (last resort)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIntrinsic(t *testing.T) {
|
||||
// Provider reports tool_calling and max_output — these should be authoritative.
|
||||
// Known table for claude-sonnet-4 has vision, thinking, etc — should fill gaps.
|
||||
providerCaps := models.ModelCapabilities{
|
||||
ToolCalling: true,
|
||||
MaxOutputTokens: 16384,
|
||||
}
|
||||
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps)
|
||||
|
||||
if !merged.ToolCalling {
|
||||
t.Error("tool_calling should be preserved from provider")
|
||||
}
|
||||
if merged.MaxOutputTokens != 16384 {
|
||||
t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens)
|
||||
}
|
||||
if !merged.Vision {
|
||||
t.Error("vision should be filled from known table")
|
||||
}
|
||||
if merged.MaxContext == 0 {
|
||||
t.Error("max_context should be filled from known table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) {
|
||||
providerCaps := models.ModelCapabilities{
|
||||
ToolCalling: true,
|
||||
Vision: false,
|
||||
MaxOutputTokens: 8192,
|
||||
MaxContext: 65536,
|
||||
}
|
||||
merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
|
||||
|
||||
if merged.Vision {
|
||||
t.Error("vision should remain false — provider is authoritative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIntrinsic_NilProvider(t *testing.T) {
|
||||
// Nil provider caps — should fall through entirely to known table
|
||||
merged := ResolveIntrinsic("gpt-4o", nil)
|
||||
|
||||
if !merged.ToolCalling {
|
||||
t.Error("should get tool_calling from known table when provider is nil")
|
||||
}
|
||||
if !merged.Vision {
|
||||
t.Error("should get vision from known table when provider is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasProviderData(t *testing.T) {
|
||||
empty := models.ModelCapabilities{}
|
||||
if empty.HasProviderData() {
|
||||
t.Error("empty caps should not have provider data")
|
||||
}
|
||||
|
||||
withTool := models.ModelCapabilities{ToolCalling: true}
|
||||
if !withTool.HasProviderData() {
|
||||
t.Error("caps with tool_calling should have provider data")
|
||||
}
|
||||
|
||||
withContext := models.ModelCapabilities{MaxContext: 128000}
|
||||
if !withContext.HasProviderData() {
|
||||
t.Error("caps with max_context should have provider data")
|
||||
}
|
||||
}
|
||||
351
server/capabilities/intrinsic.go
Normal file
351
server/capabilities/intrinsic.go
Normal file
@@ -0,0 +1,351 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Known Model Defaults ────────────────────
|
||||
// Authoritative capabilities for models where the provider API
|
||||
// doesn't report them. Keyed by exact model ID or prefix.
|
||||
//
|
||||
// Sources:
|
||||
// Anthropic: https://docs.anthropic.com/en/docs/about-claude/models
|
||||
// OpenAI: https://platform.openai.com/docs/models
|
||||
// Meta: Model cards on Hugging Face
|
||||
// Google: https://ai.google.dev/gemini-api/docs/models
|
||||
|
||||
var knownModels = map[string]models.ModelCapabilities{
|
||||
// ── Anthropic ────────────────────────────
|
||||
"claude-opus-4": {
|
||||
Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 32000,
|
||||
},
|
||||
"claude-sonnet-4": {
|
||||
Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 16000,
|
||||
},
|
||||
"claude-3-5-sonnet": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 8192,
|
||||
},
|
||||
"claude-3-5-haiku": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 8192,
|
||||
},
|
||||
"claude-3-opus": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 4096,
|
||||
},
|
||||
"claude-3-haiku": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 4096,
|
||||
},
|
||||
|
||||
// ── OpenAI ──────────────────────────────
|
||||
"gpt-4o": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 128000, MaxOutputTokens: 16384,
|
||||
},
|
||||
"gpt-4o-mini": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 128000, MaxOutputTokens: 16384,
|
||||
},
|
||||
"gpt-4-turbo": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 128000, MaxOutputTokens: 4096,
|
||||
},
|
||||
"gpt-4": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 8192, MaxOutputTokens: 4096,
|
||||
},
|
||||
"o1": {
|
||||
Streaming: true, Reasoning: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 100000,
|
||||
},
|
||||
"o1-mini": {
|
||||
Streaming: true, Reasoning: true,
|
||||
MaxContext: 128000, MaxOutputTokens: 65536,
|
||||
},
|
||||
"o3": {
|
||||
Streaming: true, ToolCalling: true, Reasoning: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 100000,
|
||||
},
|
||||
"o3-mini": {
|
||||
Streaming: true, ToolCalling: true, Reasoning: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 65536,
|
||||
},
|
||||
"o4-mini": {
|
||||
Streaming: true, ToolCalling: true, Reasoning: true,
|
||||
MaxContext: 200000, MaxOutputTokens: 100000,
|
||||
},
|
||||
|
||||
// ── Meta Llama ──────────────────────────
|
||||
"llama-3.1-405b": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 4096,
|
||||
},
|
||||
"llama-3.1-70b": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 4096,
|
||||
},
|
||||
"llama-3.1-8b": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 4096,
|
||||
},
|
||||
"llama-3.3-70b": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 4096,
|
||||
},
|
||||
"llama-4-maverick": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 1048576, MaxOutputTokens: 16384,
|
||||
},
|
||||
"llama-4-scout": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 524288, MaxOutputTokens: 16384,
|
||||
},
|
||||
|
||||
// ── Google Gemini ───────────────────────
|
||||
"gemini-2.5-pro": {
|
||||
Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
|
||||
MaxContext: 1048576, MaxOutputTokens: 65536,
|
||||
},
|
||||
"gemini-2.5-flash": {
|
||||
Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
|
||||
MaxContext: 1048576, MaxOutputTokens: 65536,
|
||||
},
|
||||
"gemini-2.0-flash": {
|
||||
Streaming: true, ToolCalling: true, Vision: true,
|
||||
MaxContext: 1048576, MaxOutputTokens: 8192,
|
||||
},
|
||||
|
||||
// ── DeepSeek ────────────────────────────
|
||||
"deepseek-r1": {
|
||||
Streaming: true, Reasoning: true,
|
||||
MaxContext: 65536, MaxOutputTokens: 8192,
|
||||
},
|
||||
"deepseek-v3": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 65536, MaxOutputTokens: 8192,
|
||||
},
|
||||
"deepseek-chat": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 65536, MaxOutputTokens: 8192,
|
||||
},
|
||||
|
||||
// ── Mistral ─────────────────────────────
|
||||
"mistral-large": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 8192,
|
||||
},
|
||||
"mistral-small": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 8192,
|
||||
},
|
||||
"codestral": {
|
||||
Streaming: true, ToolCalling: true, CodeOptimized: true,
|
||||
MaxContext: 262144, MaxOutputTokens: 8192,
|
||||
},
|
||||
|
||||
// ── Qwen ────────────────────────────────
|
||||
"qwen-2.5-72b": {
|
||||
Streaming: true, ToolCalling: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 8192,
|
||||
},
|
||||
"qwen-2.5-coder-32b": {
|
||||
Streaming: true, ToolCalling: true, CodeOptimized: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 8192,
|
||||
},
|
||||
"qwq-32b": {
|
||||
Streaming: true, Reasoning: true,
|
||||
MaxContext: 131072, MaxOutputTokens: 8192,
|
||||
},
|
||||
}
|
||||
|
||||
// LookupKnownModel finds capabilities for a model by exact ID or prefix match.
|
||||
func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) {
|
||||
id := normalizeModelID(modelID)
|
||||
|
||||
// Exact match first
|
||||
if caps, ok := knownModels[id]; ok {
|
||||
return caps, true
|
||||
}
|
||||
|
||||
// Prefix match: "claude-sonnet-4-20250514" matches "claude-sonnet-4"
|
||||
var bestKey string
|
||||
var bestCaps models.ModelCapabilities
|
||||
for key, caps := range knownModels {
|
||||
if strings.HasPrefix(id, key) && len(key) > len(bestKey) {
|
||||
bestKey = key
|
||||
bestCaps = caps
|
||||
}
|
||||
}
|
||||
if bestKey != "" {
|
||||
return bestCaps, true
|
||||
}
|
||||
|
||||
return models.ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
// ── Heuristic Capability Detection ──────────
|
||||
|
||||
var (
|
||||
toolPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)llama[_-]?[34]`),
|
||||
regexp.MustCompile(`(?i)granite[_-]?[34]`),
|
||||
regexp.MustCompile(`(?i)hermes[_-]?[23]?`),
|
||||
regexp.MustCompile(`(?i)qwen[_-]?2`),
|
||||
regexp.MustCompile(`(?i)mistral|mixtral`),
|
||||
regexp.MustCompile(`(?i)command[_-]?r`),
|
||||
regexp.MustCompile(`(?i)phi[_-]?[34]`),
|
||||
regexp.MustCompile(`(?i)deepseek[_-]?v[23]`),
|
||||
regexp.MustCompile(`(?i)functionary`),
|
||||
regexp.MustCompile(`(?i)^gpt[_-]?[34]`),
|
||||
regexp.MustCompile(`(?i)^claude`),
|
||||
regexp.MustCompile(`(?i)gemma[_-]?2`),
|
||||
regexp.MustCompile(`(?i)glm[_-]?4`),
|
||||
regexp.MustCompile(`(?i)gemini`),
|
||||
}
|
||||
|
||||
visionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)llava|bakllava`),
|
||||
regexp.MustCompile(`(?i)moondream`),
|
||||
regexp.MustCompile(`(?i)llama.*vision|vision.*llama`),
|
||||
regexp.MustCompile(`(?i)minicpm[_-]?v`),
|
||||
regexp.MustCompile(`(?i)^gpt[_-]?4[_-]?o|^gpt[_-]?4[_-]?turbo`),
|
||||
regexp.MustCompile(`(?i)^claude[_-]?3`),
|
||||
regexp.MustCompile(`(?i)gemini`),
|
||||
regexp.MustCompile(`(?i)qwen.*vl|vl.*qwen`),
|
||||
regexp.MustCompile(`(?i)phi[_-]?[34].*vision`),
|
||||
regexp.MustCompile(`(?i)internvl`),
|
||||
regexp.MustCompile(`(?i)glm[_-]?4v`),
|
||||
}
|
||||
|
||||
reasoningPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)deepseek[_-]?r1`),
|
||||
regexp.MustCompile(`(?i)qwq`),
|
||||
regexp.MustCompile(`(?i)^o[1234][_-]|^o[1234]$`),
|
||||
}
|
||||
|
||||
codePatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)codellama|code[_-]?llama`),
|
||||
regexp.MustCompile(`(?i)deepseek[_-]?coder`),
|
||||
regexp.MustCompile(`(?i)starcoder`),
|
||||
regexp.MustCompile(`(?i)coder|codestral`),
|
||||
regexp.MustCompile(`(?i)wizardcoder`),
|
||||
regexp.MustCompile(`(?i)codegemma`),
|
||||
}
|
||||
)
|
||||
|
||||
func matchesAny(id string, patterns []*regexp.Regexp) bool {
|
||||
for _, p := range patterns {
|
||||
if p.MatchString(id) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InferCapabilities guesses model capabilities from the model ID string.
|
||||
// Fallback when neither the known model table nor the provider API has data.
|
||||
func InferCapabilities(modelID string) models.ModelCapabilities {
|
||||
id := normalizeModelID(modelID)
|
||||
return models.ModelCapabilities{
|
||||
Streaming: true, // virtually everything streams
|
||||
ToolCalling: matchesAny(id, toolPatterns),
|
||||
Vision: matchesAny(id, visionPatterns),
|
||||
Reasoning: matchesAny(id, reasoningPatterns),
|
||||
CodeOptimized: matchesAny(id, codePatterns),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveIntrinsic determines the intrinsic capabilities of a model.
|
||||
// Priority:
|
||||
// 1. catalogCaps (from model_catalog DB — provider API data)
|
||||
// 2. Known model table (static, compiled-in)
|
||||
// 3. Heuristic inference (regex patterns)
|
||||
//
|
||||
// This is the ONLY function that computes intrinsic capabilities.
|
||||
func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) models.ModelCapabilities {
|
||||
// Start with catalog data if available
|
||||
var base models.ModelCapabilities
|
||||
if catalogCaps != nil && catalogCaps.HasProviderData() {
|
||||
base = *catalogCaps
|
||||
}
|
||||
|
||||
// Fill gaps from known model table
|
||||
if known, found := LookupKnownModel(modelID); found {
|
||||
mergeGaps(&base, &known)
|
||||
return base
|
||||
}
|
||||
|
||||
// Fill gaps from heuristic inference
|
||||
inferred := InferCapabilities(modelID)
|
||||
mergeGaps(&base, &inferred)
|
||||
return base
|
||||
}
|
||||
|
||||
// ResolveMaxOutput returns the max output tokens for a model.
|
||||
// Priority: explicit caps → known model table → derive from context → 4096 fallback.
|
||||
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
|
||||
if caps.MaxOutputTokens > 0 {
|
||||
return caps.MaxOutputTokens
|
||||
}
|
||||
if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 {
|
||||
return known.MaxOutputTokens
|
||||
}
|
||||
if caps.MaxContext > 0 {
|
||||
derived := caps.MaxContext / 8
|
||||
if derived < 2048 {
|
||||
derived = 2048
|
||||
}
|
||||
if derived > 16384 {
|
||||
derived = 16384
|
||||
}
|
||||
return derived
|
||||
}
|
||||
return 4096
|
||||
}
|
||||
|
||||
// mergeGaps fills zero/false fields in dst from src. Never overrides existing data.
|
||||
func mergeGaps(dst, src *models.ModelCapabilities) {
|
||||
if !dst.Streaming && src.Streaming {
|
||||
dst.Streaming = true
|
||||
}
|
||||
if !dst.ToolCalling && src.ToolCalling {
|
||||
dst.ToolCalling = true
|
||||
}
|
||||
if !dst.Vision && src.Vision {
|
||||
dst.Vision = true
|
||||
}
|
||||
if !dst.Thinking && src.Thinking {
|
||||
dst.Thinking = true
|
||||
}
|
||||
if !dst.Reasoning && src.Reasoning {
|
||||
dst.Reasoning = true
|
||||
}
|
||||
if !dst.CodeOptimized && src.CodeOptimized {
|
||||
dst.CodeOptimized = true
|
||||
}
|
||||
if !dst.WebSearch && src.WebSearch {
|
||||
dst.WebSearch = true
|
||||
}
|
||||
if dst.MaxContext == 0 && src.MaxContext > 0 {
|
||||
dst.MaxContext = src.MaxContext
|
||||
}
|
||||
if dst.MaxOutputTokens == 0 && src.MaxOutputTokens > 0 {
|
||||
dst.MaxOutputTokens = src.MaxOutputTokens
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeModelID strips provider prefix and lowercases.
|
||||
func normalizeModelID(modelID string) string {
|
||||
id := strings.ToLower(modelID)
|
||||
if idx := strings.Index(id, "/"); idx >= 0 {
|
||||
id = id[idx+1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
276
server/capabilities/resolver.go
Normal file
276
server/capabilities/resolver.go
Normal file
@@ -0,0 +1,276 @@
|
||||
package capabilities
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ModelsForUser returns all models and Personas visible to a user.
|
||||
//
|
||||
// Visibility is controlled by the three-state visibility field on each
|
||||
// catalog entry (enabled / team / disabled), applied per provider scope:
|
||||
//
|
||||
// Tier | enabled | team | disabled
|
||||
// ----------+----------------+----------------------+---------
|
||||
// Global | All users | Team admin → presets | Hidden
|
||||
// Team | Team members | Team admin → presets | Hidden
|
||||
// Personal | Owner direct | N/A | Hidden
|
||||
//
|
||||
// Sources aggregated:
|
||||
// 1. Global catalog: enabled models → all authenticated users
|
||||
// 2. Team catalog: enabled models → team members
|
||||
// 3. Personal BYOK: enabled models → owner (if allow_user_byok)
|
||||
// 4. Personas: global + team-scoped + personal + shared
|
||||
// 5. User hidden preferences applied last
|
||||
func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) {
|
||||
result := make([]models.UserModel, 0) // never nil — serializes as [] not null
|
||||
|
||||
// Load policies once
|
||||
policies, err := stores.Policies.GetAll(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allowBYOK := policies["allow_user_byok"] == "true"
|
||||
|
||||
// Load user's hidden preferences
|
||||
hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("warn: failed to load user model settings: %v", err)
|
||||
hiddenMap = make(map[string]bool)
|
||||
}
|
||||
|
||||
// Get user's team IDs
|
||||
teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("warn: failed to load user teams: %v", err)
|
||||
}
|
||||
|
||||
// ── 1. Global enabled catalog models → all users ────
|
||||
globalModels, err := stores.Catalog.ListVisible(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build provider name lookup for global providers
|
||||
globalProviders, _ := stores.Providers.ListGlobal(ctx)
|
||||
providerMap := make(map[string]models.ProviderConfig)
|
||||
for _, p := range globalProviders {
|
||||
providerMap[p.ID] = p
|
||||
}
|
||||
|
||||
for _, entry := range globalModels {
|
||||
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
|
||||
prov := providerMap[entry.ProviderConfigID]
|
||||
|
||||
result = append(result, models.UserModel{
|
||||
ID: entry.ModelID,
|
||||
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
||||
ModelID: entry.ModelID,
|
||||
Source: "catalog",
|
||||
ProviderConfigID: entry.ProviderConfigID,
|
||||
ConfigID: entry.ProviderConfigID,
|
||||
ProviderName: prov.Name,
|
||||
ProviderType: prov.Provider,
|
||||
Capabilities: caps,
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopeGlobal,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
})
|
||||
}
|
||||
|
||||
// ── 2. Team enabled catalog models → team members ────
|
||||
for _, teamID := range teamIDs {
|
||||
teamProviders, err := stores.Providers.ListForTeam(ctx, teamID)
|
||||
if err != nil {
|
||||
log.Printf("warn: failed to load team %s providers: %v", teamID, err)
|
||||
continue
|
||||
}
|
||||
for _, prov := range teamProviders {
|
||||
if !prov.IsActive {
|
||||
continue
|
||||
}
|
||||
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
|
||||
result = append(result, models.UserModel{
|
||||
ID: entry.ModelID,
|
||||
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
||||
ModelID: entry.ModelID,
|
||||
Source: "catalog",
|
||||
ProviderConfigID: prov.ID,
|
||||
ConfigID: prov.ID,
|
||||
ProviderName: prov.Name,
|
||||
ProviderType: prov.Provider,
|
||||
Capabilities: caps,
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopeTeam,
|
||||
OwnerID: prov.OwnerID,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 3. Personal BYOK enabled models → owner ────
|
||||
if allowBYOK {
|
||||
personalProviders, err := stores.Providers.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("warn: failed to load personal providers: %v", err)
|
||||
} else {
|
||||
for _, prov := range personalProviders {
|
||||
if !prov.IsActive {
|
||||
continue
|
||||
}
|
||||
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
|
||||
result = append(result, models.UserModel{
|
||||
ID: entry.ModelID,
|
||||
DisplayName: displayName(entry.DisplayName, entry.ModelID),
|
||||
ModelID: entry.ModelID,
|
||||
Source: "catalog",
|
||||
ProviderConfigID: prov.ID,
|
||||
ConfigID: prov.ID,
|
||||
ProviderName: prov.Name,
|
||||
ProviderType: prov.Provider,
|
||||
Capabilities: caps,
|
||||
Pricing: entry.Pricing,
|
||||
Scope: models.ScopePersonal,
|
||||
OwnerID: &userID,
|
||||
Hidden: hiddenMap[entry.ModelID],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Personas (always resolved) ────────────────
|
||||
personas, err := stores.Personas.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("warn: failed to load personas: %v", err)
|
||||
} else {
|
||||
for _, p := range personas {
|
||||
// Resolve base model capabilities
|
||||
var catalogCaps *models.ModelCapabilities
|
||||
if p.ProviderConfigID != nil {
|
||||
if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil {
|
||||
catalogCaps = &entry.Capabilities
|
||||
}
|
||||
}
|
||||
// Fallback: look up any provider's catalog entry for this model
|
||||
// (covers auto-resolve presets where provider_config_id is NULL)
|
||||
if catalogCaps == nil {
|
||||
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
|
||||
catalogCaps = &entry.Capabilities
|
||||
}
|
||||
}
|
||||
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps)
|
||||
|
||||
// Load tool grants
|
||||
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
|
||||
|
||||
if len(toolGrants) > 0 {
|
||||
caps.ToolCalling = true
|
||||
}
|
||||
|
||||
// Look up provider info
|
||||
var provName, provType string
|
||||
if p.ProviderConfigID != nil {
|
||||
if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil {
|
||||
provName = prov.Name
|
||||
provType = prov.Provider
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, models.UserModel{
|
||||
ID: p.ID,
|
||||
DisplayName: p.Name,
|
||||
ModelID: p.BaseModelID,
|
||||
Source: "persona",
|
||||
ProviderConfigID: deref(p.ProviderConfigID),
|
||||
ConfigID: deref(p.ProviderConfigID),
|
||||
ProviderName: provName,
|
||||
ProviderType: provType,
|
||||
Capabilities: caps,
|
||||
IsPreset: true,
|
||||
PresetID: p.ID,
|
||||
PresetScope: p.Scope,
|
||||
PresetAvatar: p.Avatar,
|
||||
PresetTeamName: teamName(p, stores, ctx),
|
||||
PersonaID: p.ID,
|
||||
Description: p.Description,
|
||||
Icon: p.Icon,
|
||||
Avatar: p.Avatar,
|
||||
SystemPrompt: p.SystemPrompt,
|
||||
Temperature: p.Temperature,
|
||||
MaxTokens: p.MaxTokens,
|
||||
ToolGrants: toolGrants,
|
||||
Scope: p.Scope,
|
||||
OwnerID: p.OwnerID,
|
||||
Hidden: hiddenMap[p.ID],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveForPersona returns effective capabilities for a specific Persona.
|
||||
// Used at completion time to determine what tools/features are available.
|
||||
func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) {
|
||||
var catalogCaps *models.ModelCapabilities
|
||||
if persona.ProviderConfigID != nil {
|
||||
if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil {
|
||||
catalogCaps = &entry.Capabilities
|
||||
}
|
||||
}
|
||||
// Fallback: any provider's catalog entry (auto-resolve presets)
|
||||
if catalogCaps == nil {
|
||||
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
|
||||
catalogCaps = &entry.Capabilities
|
||||
}
|
||||
}
|
||||
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps)
|
||||
|
||||
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
|
||||
if err != nil {
|
||||
return caps, nil, err
|
||||
}
|
||||
|
||||
return caps, toolGrants, nil
|
||||
}
|
||||
|
||||
func displayName(name, modelID string) string {
|
||||
if name != "" {
|
||||
return name
|
||||
}
|
||||
return modelID
|
||||
}
|
||||
|
||||
func deref(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// teamName resolves the team_name for a persona's owner (if team-scoped).
|
||||
func teamName(p models.Persona, stores store.Stores, ctx context.Context) string {
|
||||
if p.Scope != models.ScopeTeam || p.OwnerID == nil {
|
||||
return ""
|
||||
}
|
||||
team, err := stores.Teams.GetByID(ctx, *p.OwnerID)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return team.Name
|
||||
}
|
||||
Reference in New Issue
Block a user