Changeset 0.9.0 (#50)
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -166,17 +168,17 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M
|
||||
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
|
||||
}
|
||||
|
||||
models := make([]Model, 0, len(modelIDs))
|
||||
out := make([]Model, 0, len(modelIDs))
|
||||
for _, m := range modelIDs {
|
||||
caps, _ := LookupKnownModel(m.id)
|
||||
models = append(models, Model{
|
||||
caps, _ := capabilities.LookupKnownModel(m.id)
|
||||
out = append(out, Model{
|
||||
ID: m.id,
|
||||
Name: m.name,
|
||||
OwnedBy: "anthropic",
|
||||
Capabilities: caps,
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── HTTP Layer ──────────────────────────────
|
||||
@@ -255,7 +257,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
|
||||
antReq.System = system
|
||||
}
|
||||
if antReq.MaxTokens == 0 {
|
||||
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
|
||||
antReq.MaxTokens = capabilities.ResolveMaxOutput(req.Model, models.ModelCapabilities{})
|
||||
}
|
||||
if req.Temperature != nil {
|
||||
antReq.Temperature = req.Temperature
|
||||
|
||||
@@ -1,396 +0,0 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ModelCapabilities describes what a model can do and its limits.
|
||||
// Zero values mean "unknown / use heuristic".
|
||||
type ModelCapabilities struct {
|
||||
Streaming bool `json:"streaming"`
|
||||
ToolCalling bool `json:"tool_calling"`
|
||||
Vision bool `json:"vision"`
|
||||
Thinking bool `json:"thinking"`
|
||||
Reasoning bool `json:"reasoning"`
|
||||
CodeOptimized bool `json:"code_optimized"`
|
||||
WebSearch bool `json:"web_search"`
|
||||
MaxContext int `json:"max_context"`
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
}
|
||||
|
||||
// ── Known Model Defaults ────────────────────
|
||||
// Authoritative output limits 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]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.
|
||||
// Returns the caps and true if found, zero value and false if not.
|
||||
func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
|
||||
id := strings.ToLower(modelID)
|
||||
|
||||
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-20250514" → "claude-sonnet-4-20250514")
|
||||
if idx := strings.Index(id, "/"); idx >= 0 {
|
||||
id = id[idx+1:]
|
||||
}
|
||||
|
||||
// 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 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 ModelCapabilities{}, false
|
||||
}
|
||||
|
||||
// ── Heuristic Capability Detection ──────────
|
||||
// Ported from ai-editor's ProviderRegistry.
|
||||
// Used for Ollama, LM Studio, and other generic endpoints
|
||||
// that don't report capabilities in their /models response.
|
||||
|
||||
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.
|
||||
// This is the fallback when neither the known model table nor the provider
|
||||
// API provides capability data.
|
||||
func InferCapabilities(modelID string) ModelCapabilities {
|
||||
id := strings.ToLower(modelID)
|
||||
// Strip provider prefix
|
||||
if idx := strings.Index(id, "/"); idx >= 0 {
|
||||
id = id[idx+1:]
|
||||
}
|
||||
|
||||
return ModelCapabilities{
|
||||
Streaming: true, // virtually everything streams
|
||||
ToolCalling: matchesAny(id, toolPatterns),
|
||||
Vision: matchesAny(id, visionPatterns),
|
||||
Reasoning: matchesAny(id, reasoningPatterns),
|
||||
CodeOptimized: matchesAny(id, codePatterns),
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveMaxOutput returns the max output tokens for a model.
|
||||
// Priority:
|
||||
// 1. Explicit value in caps (from DB / provider API)
|
||||
// 2. Known model table
|
||||
// 3. Derive from context window (context/8, clamped 2048..16384)
|
||||
// 4. 4096 as absolute last resort
|
||||
//
|
||||
// This is the ONE place the default lives. Nothing else in the
|
||||
// codebase should hardcode a max_tokens value.
|
||||
func ResolveMaxOutput(modelID string, caps ModelCapabilities) int {
|
||||
// 1. Already set (from model_configs DB or provider API)
|
||||
if caps.MaxOutputTokens > 0 {
|
||||
return caps.MaxOutputTokens
|
||||
}
|
||||
|
||||
// 2. Known model table
|
||||
if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 {
|
||||
return known.MaxOutputTokens
|
||||
}
|
||||
|
||||
// 3. Derive from context window
|
||||
if caps.MaxContext > 0 {
|
||||
derived := caps.MaxContext / 8
|
||||
if derived < 2048 {
|
||||
derived = 2048
|
||||
}
|
||||
if derived > 16384 {
|
||||
derived = 16384
|
||||
}
|
||||
return derived
|
||||
}
|
||||
|
||||
// 4. Last resort — the ONLY place 4096 appears as a default
|
||||
return 4096
|
||||
}
|
||||
|
||||
// MergeCapabilities takes authoritative caps (from provider API or DB) and fills
|
||||
// gaps from the known model table and heuristic detection. Provider-reported data
|
||||
// always wins; known table fills missing fields; heuristics are last resort.
|
||||
func MergeCapabilities(authoritative ModelCapabilities, modelID string) ModelCapabilities {
|
||||
merged := authoritative
|
||||
|
||||
// Fill gaps from known model table
|
||||
known, found := LookupKnownModel(modelID)
|
||||
if found {
|
||||
if !merged.ToolCalling && known.ToolCalling {
|
||||
merged.ToolCalling = true
|
||||
}
|
||||
if !merged.Vision && known.Vision {
|
||||
merged.Vision = true
|
||||
}
|
||||
if !merged.Thinking && known.Thinking {
|
||||
merged.Thinking = true
|
||||
}
|
||||
if !merged.Reasoning && known.Reasoning {
|
||||
merged.Reasoning = true
|
||||
}
|
||||
if !merged.CodeOptimized && known.CodeOptimized {
|
||||
merged.CodeOptimized = true
|
||||
}
|
||||
if !merged.WebSearch && known.WebSearch {
|
||||
merged.WebSearch = true
|
||||
}
|
||||
if merged.MaxContext == 0 && known.MaxContext > 0 {
|
||||
merged.MaxContext = known.MaxContext
|
||||
}
|
||||
if merged.MaxOutputTokens == 0 && known.MaxOutputTokens > 0 {
|
||||
merged.MaxOutputTokens = known.MaxOutputTokens
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// No known model — fill gaps from heuristics
|
||||
inferred := InferCapabilities(modelID)
|
||||
if !merged.ToolCalling && inferred.ToolCalling {
|
||||
merged.ToolCalling = true
|
||||
}
|
||||
if !merged.Vision && inferred.Vision {
|
||||
merged.Vision = true
|
||||
}
|
||||
if !merged.Thinking && inferred.Thinking {
|
||||
merged.Thinking = true
|
||||
}
|
||||
if !merged.Reasoning && inferred.Reasoning {
|
||||
merged.Reasoning = true
|
||||
}
|
||||
if !merged.CodeOptimized && inferred.CodeOptimized {
|
||||
merged.CodeOptimized = true
|
||||
}
|
||||
if merged.MaxContext == 0 && inferred.MaxContext > 0 {
|
||||
merged.MaxContext = inferred.MaxContext
|
||||
}
|
||||
if merged.MaxOutputTokens == 0 && inferred.MaxOutputTokens > 0 {
|
||||
merged.MaxOutputTokens = inferred.MaxOutputTokens
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
// HasProviderData returns true if this capability set contains any data that
|
||||
// was likely reported by a provider (not just zero values).
|
||||
func (c ModelCapabilities) HasProviderData() bool {
|
||||
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
|
||||
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package providers
|
||||
|
||||
import "testing"
|
||||
|
||||
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) {
|
||||
// Explicit value in caps takes priority
|
||||
caps := ModelCapabilities{MaxOutputTokens: 32000}
|
||||
got := ResolveMaxOutput("whatever-model", caps)
|
||||
if got != 32000 {
|
||||
t.Errorf("got %d, want 32000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
|
||||
// No value in caps, falls to known table
|
||||
caps := ModelCapabilities{}
|
||||
got := ResolveMaxOutput("claude-opus-4-20250514", caps)
|
||||
if got != 32000 {
|
||||
t.Errorf("got %d, want 32000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_FromContext(t *testing.T) {
|
||||
// Unknown model but has context window
|
||||
caps := ModelCapabilities{MaxContext: 32768}
|
||||
got := ResolveMaxOutput("unknown-model-abc", caps)
|
||||
// 32768 / 8 = 4096
|
||||
if got != 4096 {
|
||||
t.Errorf("got %d, want 4096 (derived from context/8)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveMaxOutput_ContextClamp(t *testing.T) {
|
||||
// Very large context → clamp derived output to 16384
|
||||
caps := ModelCapabilities{MaxContext: 1048576}
|
||||
got := ResolveMaxOutput("unknown-large-model", caps)
|
||||
if got != 16384 {
|
||||
t.Errorf("got %d, want 16384 (clamped)", got)
|
||||
}
|
||||
|
||||
// Very small context → clamp derived output to 2048
|
||||
caps = 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) {
|
||||
// Totally unknown, no context
|
||||
caps := ModelCapabilities{}
|
||||
got := ResolveMaxOutput("mystery-model", caps)
|
||||
if got != 4096 {
|
||||
t.Errorf("got %d, want 4096 (last resort)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCapabilities(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 := ModelCapabilities{
|
||||
ToolCalling: true,
|
||||
MaxOutputTokens: 16384,
|
||||
}
|
||||
merged := MergeCapabilities(providerCaps, "claude-sonnet-4-20250514")
|
||||
|
||||
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)
|
||||
}
|
||||
// Vision should be filled from known table (claude-sonnet-4 has it)
|
||||
if !merged.Vision {
|
||||
t.Error("vision should be filled from known table")
|
||||
}
|
||||
// MaxContext should be filled from known table
|
||||
if merged.MaxContext == 0 {
|
||||
t.Error("max_context should be filled from known table")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCapabilities_ProviderFalseWins(t *testing.T) {
|
||||
// Provider explicitly reports vision:false — should NOT be overridden by known table
|
||||
providerCaps := ModelCapabilities{
|
||||
ToolCalling: true,
|
||||
Vision: false, // provider says no vision
|
||||
MaxOutputTokens: 8192,
|
||||
MaxContext: 65536,
|
||||
}
|
||||
// Even though gpt-4o has vision in known table, provider caps are authoritative
|
||||
// Because HasProviderData() is true, the caller passes these as authoritative
|
||||
merged := MergeCapabilities(providerCaps, "some-unknown-model")
|
||||
|
||||
if merged.Vision {
|
||||
t.Error("vision should remain false — provider is authoritative")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeCapabilities_EmptyProvider(t *testing.T) {
|
||||
// Empty provider caps — should fall through entirely to known table
|
||||
merged := MergeCapabilities(ModelCapabilities{}, "gpt-4o")
|
||||
|
||||
if !merged.ToolCalling {
|
||||
t.Error("should get tool_calling from known table when provider is empty")
|
||||
}
|
||||
if !merged.Vision {
|
||||
t.Error("should get vision from known table when provider is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasProviderData(t *testing.T) {
|
||||
empty := ModelCapabilities{}
|
||||
if empty.HasProviderData() {
|
||||
t.Error("empty caps should not have provider data")
|
||||
}
|
||||
|
||||
withTool := ModelCapabilities{ToolCalling: true}
|
||||
if !withTool.HasProviderData() {
|
||||
t.Error("caps with tool_calling should have provider data")
|
||||
}
|
||||
|
||||
withContext := ModelCapabilities{MaxContext: 128000}
|
||||
if !withContext.HasProviderData() {
|
||||
t.Error("caps with max_context should have provider data")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
@@ -190,25 +191,25 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
return nil, fmt.Errorf("decode models: %w", err)
|
||||
}
|
||||
|
||||
models := make([]Model, 0, len(result.Data))
|
||||
out := make([]Model, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
// Try known table first, then heuristic
|
||||
caps, found := LookupKnownModel(m.ID)
|
||||
caps, found := capabilities.LookupKnownModel(m.ID)
|
||||
if !found {
|
||||
caps = InferCapabilities(m.ID)
|
||||
caps = capabilities.InferCapabilities(m.ID)
|
||||
}
|
||||
// Use context_length from API if available and we don't have it
|
||||
if m.ContextLength > 0 && caps.MaxContext == 0 {
|
||||
caps.MaxContext = m.ContextLength
|
||||
}
|
||||
|
||||
models = append(models, Model{
|
||||
out = append(out, Model{
|
||||
ID: m.ID,
|
||||
OwnedBy: m.OwnedBy,
|
||||
Capabilities: caps,
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── HTTP Layer ──────────────────────────────
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -65,12 +67,12 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
||||
return nil, fmt.Errorf("openrouter decode models: %w", err)
|
||||
}
|
||||
|
||||
models := make([]Model, 0, len(result.Data))
|
||||
out := make([]Model, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
// Start with known table, fall back to heuristic
|
||||
caps, found := LookupKnownModel(m.ID)
|
||||
caps, found := capabilities.LookupKnownModel(m.ID)
|
||||
if !found {
|
||||
caps = InferCapabilities(m.ID)
|
||||
caps = capabilities.InferCapabilities(m.ID)
|
||||
}
|
||||
|
||||
// Overlay context length from OpenRouter metadata
|
||||
@@ -93,12 +95,12 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
||||
caps.Streaming = true
|
||||
|
||||
// Parse pricing (OpenRouter uses per-token strings, convert to per-1M)
|
||||
var pricing *ModelPricing
|
||||
var pricing *models.ModelPricing
|
||||
if m.Pricing.Prompt != "" {
|
||||
inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64)
|
||||
outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64)
|
||||
if inputPerToken > 0 || outputPerToken > 0 {
|
||||
pricing = &ModelPricing{
|
||||
pricing = &models.ModelPricing{
|
||||
InputPerM: inputPerToken * 1_000_000,
|
||||
OutputPerM: outputPerToken * 1_000_000,
|
||||
}
|
||||
@@ -116,7 +118,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
||||
name = m.ID
|
||||
}
|
||||
|
||||
models = append(models, Model{
|
||||
out = append(out, Model{
|
||||
ID: m.ID,
|
||||
Name: name,
|
||||
OwnedBy: ownedBy,
|
||||
@@ -124,7 +126,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
|
||||
Pricing: pricing,
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── OpenRouter Wire Types ───────────────────
|
||||
|
||||
@@ -3,6 +3,8 @@ package providers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
)
|
||||
|
||||
// ── Provider Interface ──────────────────────
|
||||
@@ -26,12 +28,12 @@ type Provider interface {
|
||||
// ── Configuration ───────────────────────────
|
||||
|
||||
// ProviderConfig holds credentials and endpoint for a configured provider.
|
||||
// Populated from the api_configs table at call time.
|
||||
// Populated from the provider_configs table at call time.
|
||||
type ProviderConfig struct {
|
||||
Endpoint string
|
||||
APIKey string
|
||||
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
|
||||
Settings map[string]interface{} // Provider-specific settings from provider_settings JSONB
|
||||
Endpoint string
|
||||
APIKey string
|
||||
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
|
||||
Settings map[string]interface{} // Provider-specific settings from config JSONB
|
||||
}
|
||||
|
||||
// ── Request / Response Types ────────────────
|
||||
@@ -62,8 +64,8 @@ type FunctionCall struct {
|
||||
|
||||
// ToolDef describes a tool available to the LLM.
|
||||
type ToolDef struct {
|
||||
Type string `json:"type"` // "function"
|
||||
Function FunctionDef `json:"function"`
|
||||
Type string `json:"type"` // "function"
|
||||
Function FunctionDef `json:"function"`
|
||||
}
|
||||
|
||||
// FunctionDef is the schema for a tool function.
|
||||
@@ -96,38 +98,19 @@ type CompletionResponse struct {
|
||||
|
||||
// StreamEvent is a single chunk from a streaming response.
|
||||
type StreamEvent struct {
|
||||
// Delta is the incremental text content (empty for non-content events).
|
||||
Delta string `json:"delta,omitempty"`
|
||||
|
||||
// Done is true when the stream is complete.
|
||||
Done bool `json:"done,omitempty"`
|
||||
|
||||
// FinishReason is set on the final event.
|
||||
// "stop" = normal, "tool_calls" = LLM wants to call tools.
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
|
||||
// ToolCalls accumulates tool call data during streaming.
|
||||
// Fully populated on the final event when FinishReason is "tool_calls".
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
|
||||
// Model echoes back the model used.
|
||||
Model string `json:"model,omitempty"`
|
||||
|
||||
// Error is set if the stream encountered an error.
|
||||
Error error `json:"-"`
|
||||
Delta string `json:"delta,omitempty"`
|
||||
Done bool `json:"done,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Error error `json:"-"`
|
||||
}
|
||||
|
||||
// Model represents an available model from a provider, with capabilities.
|
||||
type Model struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OwnedBy string `json:"owned_by,omitempty"`
|
||||
Capabilities ModelCapabilities `json:"capabilities"`
|
||||
Pricing *ModelPricing `json:"pricing,omitempty"`
|
||||
}
|
||||
|
||||
// ModelPricing holds per-million-token costs.
|
||||
type ModelPricing struct {
|
||||
InputPerM float64 `json:"input_per_m,omitempty"`
|
||||
OutputPerM float64 `json:"output_per_m,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OwnedBy string `json:"owned_by,omitempty"`
|
||||
Capabilities models.ModelCapabilities `json:"capabilities"`
|
||||
Pricing *models.ModelPricing `json:"pricing,omitempty"`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -62,29 +64,30 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
return nil, fmt.Errorf("venice decode models: %w", err)
|
||||
}
|
||||
|
||||
models := make([]Model, 0, len(result.Data))
|
||||
out := make([]Model, 0, len(result.Data))
|
||||
for _, m := range result.Data {
|
||||
spec := m.ModelSpec
|
||||
vcaps := spec.Capabilities
|
||||
|
||||
caps := ModelCapabilities{
|
||||
caps := models.ModelCapabilities{
|
||||
Streaming: true,
|
||||
ToolCalling: vcaps.SupportsFunctionCalling,
|
||||
Vision: vcaps.SupportsVision,
|
||||
Reasoning: vcaps.SupportsReasoning,
|
||||
WebSearch: vcaps.SupportsWebSearch,
|
||||
CodeOptimized: vcaps.OptimizedForCode,
|
||||
MaxContext: spec.AvailableContextTokens,
|
||||
}
|
||||
|
||||
// Venice doesn't report max output tokens directly.
|
||||
// Try known table, then derive from context.
|
||||
if known, ok := LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
|
||||
if known, ok := capabilities.LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
|
||||
caps.MaxOutputTokens = known.MaxOutputTokens
|
||||
}
|
||||
|
||||
var pricing *ModelPricing
|
||||
var pricing *models.ModelPricing
|
||||
if spec.Pricing.Input.USD > 0 {
|
||||
pricing = &ModelPricing{
|
||||
pricing = &models.ModelPricing{
|
||||
InputPerM: spec.Pricing.Input.USD,
|
||||
OutputPerM: spec.Pricing.Output.USD,
|
||||
}
|
||||
@@ -95,7 +98,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
name = m.ID
|
||||
}
|
||||
|
||||
models = append(models, Model{
|
||||
out = append(out, Model{
|
||||
ID: m.ID,
|
||||
Name: name,
|
||||
OwnedBy: "venice",
|
||||
@@ -103,7 +106,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
|
||||
Pricing: pricing,
|
||||
})
|
||||
}
|
||||
return models, nil
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── Venice Wire Types ───────────────────────
|
||||
@@ -137,6 +140,7 @@ type veniceCapabilities struct {
|
||||
SupportsResponseSchema bool `json:"supportsResponseSchema"`
|
||||
SupportsAudioInput bool `json:"supportsAudioInput"`
|
||||
SupportsLogProbs bool `json:"supportsLogProbs"`
|
||||
OptimizedForCode bool `json:"optimizedForCode"`
|
||||
}
|
||||
|
||||
type venicePricing struct {
|
||||
|
||||
Reference in New Issue
Block a user