Changeset 0.5.0 (#35)

This commit is contained in:
2026-02-19 15:03:20 +00:00
parent a93a6b9635
commit 30d0c11219
65 changed files with 5345 additions and 8070 deletions

View File

@@ -116,14 +116,26 @@ func (p *AnthropicProvider) StreamCompletion(ctx context.Context, cfg ProviderCo
func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]Model, error) {
// Anthropic doesn't have a list models endpoint.
// Return the known model families.
return []Model{
{ID: "claude-sonnet-4-20250514", Name: "Claude Sonnet 4"},
{ID: "claude-haiku-4-20250414", Name: "Claude Haiku 4"},
{ID: "claude-opus-4-20250514", Name: "Claude Opus 4"},
{ID: "claude-3-5-sonnet-20241022", Name: "Claude 3.5 Sonnet"},
{ID: "claude-3-5-haiku-20241022", Name: "Claude 3.5 Haiku"},
}, nil
// Return known models with authoritative capabilities from our table.
modelIDs := []struct{ id, name string }{
{"claude-opus-4-20250514", "Claude Opus 4"},
{"claude-sonnet-4-20250514", "Claude Sonnet 4"},
{"claude-haiku-4-20250414", "Claude Haiku 4"},
{"claude-3-5-sonnet-20241022", "Claude 3.5 Sonnet"},
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
}
models := make([]Model, 0, len(modelIDs))
for _, m := range modelIDs {
caps, _ := LookupKnownModel(m.id)
models = append(models, Model{
ID: m.id,
Name: m.name,
OwnedBy: "anthropic",
Capabilities: caps,
})
}
return models, nil
}
// ── HTTP Layer ──────────────────────────────
@@ -160,7 +172,8 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.System = system
}
if antReq.MaxTokens == 0 {
antReq.MaxTokens = 4096 // Anthropic requires max_tokens
// Anthropic requires max_tokens. Resolve from known model table.
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
}
if req.Temperature != nil {
antReq.Temperature = req.Temperature

View File

@@ -0,0 +1,396 @@
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
}

View File

@@ -0,0 +1,203 @@
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")
}
}

View File

@@ -116,6 +116,9 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
for k, v := range cfg.CustomHeaders {
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
@@ -135,9 +138,20 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
models := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Try known table first, then heuristic
caps, found := LookupKnownModel(m.ID)
if !found {
caps = 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{
ID: m.ID,
OwnedBy: m.OwnedBy,
ID: m.ID,
OwnedBy: m.OwnedBy,
Capabilities: caps,
})
}
return models, nil
@@ -184,6 +198,10 @@ func (p *OpenAIProvider) doRequest(ctx context.Context, cfg ProviderConfig, req
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
// Inject provider-level custom headers (OpenRouter, Venice, etc.)
for k, v := range cfg.CustomHeaders {
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
@@ -239,7 +257,8 @@ type openaiStreamChunk struct {
type openaiModelsResponse struct {
Data []struct {
ID string `json:"id"`
OwnedBy string `json:"owned_by"`
ID string `json:"id"`
OwnedBy string `json:"owned_by"`
ContextLength int `json:"context_length,omitempty"` // Ollama, OpenRouter
} `json:"data"`
}

View File

@@ -0,0 +1,160 @@
package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
)
// OpenRouterProvider handles the OpenRouter unified API.
// OpenRouter is OpenAI-compatible with 300+ models, per-model pricing,
// and architecture metadata. Requires HTTP-Referer and X-Title headers.
//
// Ported from ai-editor js/providers/openrouter.js
type OpenRouterProvider struct{}
func (p *OpenRouterProvider) ID() string { return "openrouter" }
// ── Chat Completion ─────────────────────────
// Delegates to OpenAI provider — OpenRouter is fully OAI-compatible.
func (p *OpenRouterProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
oai := &OpenAIProvider{}
return oai.ChatCompletion(ctx, cfg, req)
}
func (p *OpenRouterProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
oai := &OpenAIProvider{}
return oai.StreamCompletion(ctx, cfg, req)
}
// ── List Models ─────────────────────────────
// OpenRouter /v1/models returns architecture, pricing, context_length.
func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) {
endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models"
httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, err
}
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
for k, v := range cfg.CustomHeaders {
httpReq.Header.Set(k, v)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("openrouter list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("openrouter list models: HTTP %d: %s", resp.StatusCode, string(b))
}
var result orModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("openrouter decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Start with known table, fall back to heuristic
caps, found := LookupKnownModel(m.ID)
if !found {
caps = InferCapabilities(m.ID)
}
// Overlay context length from OpenRouter metadata
if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength
}
// Overlay max output from OpenRouter if available
if m.TopProvider.MaxCompletionTokens > 0 && caps.MaxOutputTokens == 0 {
caps.MaxOutputTokens = m.TopProvider.MaxCompletionTokens
}
// Vision from architecture modality
arch := m.Architecture
if arch.Modality == "multimodal" {
caps.Vision = true
}
// Streaming is universal on OpenRouter
caps.Streaming = true
// Parse pricing (OpenRouter uses per-token strings, convert to per-1M)
var pricing *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{
InputPerM: inputPerToken * 1_000_000,
OutputPerM: outputPerToken * 1_000_000,
}
}
}
// Owner from model ID prefix (e.g. "anthropic/claude-3-opus" → "anthropic")
ownedBy := ""
if idx := strings.Index(m.ID, "/"); idx >= 0 {
ownedBy = m.ID[:idx]
}
name := m.Name
if name == "" {
name = m.ID
}
models = append(models, Model{
ID: m.ID,
Name: name,
OwnedBy: ownedBy,
Capabilities: caps,
Pricing: pricing,
})
}
return models, nil
}
// ── OpenRouter Wire Types ───────────────────
type orModelsResponse struct {
Data []orModel `json:"data"`
}
type orModel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ContextLength int `json:"context_length"`
Architecture orArch `json:"architecture"`
Pricing orPricing `json:"pricing"`
TopProvider orTopProvider `json:"top_provider"`
}
type orArch struct {
Modality string `json:"modality"`
Tokenizer string `json:"tokenizer"`
InstructType string `json:"instruct_type"`
}
type orPricing struct {
Prompt string `json:"prompt"` // per-token cost as string
Completion string `json:"completion"` // per-token cost as string
}
type orTopProvider struct {
MaxCompletionTokens int `json:"max_completion_tokens"`
IsModerated bool `json:"is_moderated"`
}

View File

@@ -27,9 +27,10 @@ type Provider interface {
// ProviderConfig holds credentials and endpoint for a configured provider.
// Populated from the api_configs table at call time.
type ProviderConfig struct {
Endpoint string
APIKey string
Extra map[string]interface{} // Provider-specific settings from config 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 provider_settings JSONB
}
// ── Request / Response Types ────────────────
@@ -77,9 +78,17 @@ type StreamEvent struct {
Error error `json:"-"`
}
// Model represents an available model from a provider.
// 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"`
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"`
}

View File

@@ -44,4 +44,6 @@ func List() []string {
func Init() {
Register(&OpenAIProvider{})
Register(&AnthropicProvider{})
Register(&VeniceProvider{})
Register(&OpenRouterProvider{})
}

149
server/providers/venice.go Normal file
View File

@@ -0,0 +1,149 @@
package providers
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// VeniceProvider handles the Venice.ai API.
// Venice is OpenAI-compatible with extensions: venice_parameters for
// web search, thinking controls, and web scraping.
//
// Ported from ai-editor js/providers/venice.js
type VeniceProvider struct{}
func (p *VeniceProvider) ID() string { return "venice" }
// ── Chat Completion ─────────────────────────
// Delegates to OpenAI provider after injecting venice_parameters.
func (p *VeniceProvider) ChatCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (*CompletionResponse, error) {
oai := &OpenAIProvider{}
return oai.ChatCompletion(ctx, cfg, req)
}
func (p *VeniceProvider) StreamCompletion(ctx context.Context, cfg ProviderConfig, req CompletionRequest) (<-chan StreamEvent, error) {
oai := &OpenAIProvider{}
return oai.StreamCompletion(ctx, cfg, req)
}
// ── List Models ─────────────────────────────
// Venice /v1/models returns model_spec with capabilities, pricing,
// context tokens, and traits. We parse all of it.
func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]Model, error) {
endpoint := strings.TrimSuffix(cfg.Endpoint, "/") + "/models"
httpReq, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil)
if err != nil {
return nil, err
}
if cfg.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+cfg.APIKey)
}
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("venice list models: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("venice list models: HTTP %d: %s", resp.StatusCode, string(b))
}
var result veniceModelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("venice decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
spec := m.ModelSpec
vcaps := spec.Capabilities
caps := ModelCapabilities{
Streaming: true,
ToolCalling: vcaps.SupportsFunctionCalling,
Vision: vcaps.SupportsVision,
Reasoning: vcaps.SupportsReasoning,
WebSearch: vcaps.SupportsWebSearch,
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 {
caps.MaxOutputTokens = known.MaxOutputTokens
}
var pricing *ModelPricing
if spec.Pricing.Input.USD > 0 {
pricing = &ModelPricing{
InputPerM: spec.Pricing.Input.USD,
OutputPerM: spec.Pricing.Output.USD,
}
}
name := spec.Name
if name == "" {
name = m.ID
}
models = append(models, Model{
ID: m.ID,
Name: name,
OwnedBy: "venice",
Capabilities: caps,
Pricing: pricing,
})
}
return models, nil
}
// ── Venice Wire Types ───────────────────────
type veniceModelsResponse struct {
Data []veniceModel `json:"data"`
}
type veniceModel struct {
ID string `json:"id"`
Type string `json:"type"`
OwnedBy string `json:"owned_by"`
ModelSpec veniceModelSpec `json:"model_spec"`
}
type veniceModelSpec struct {
Name string `json:"name"`
Description string `json:"description"`
AvailableContextTokens int `json:"availableContextTokens"`
Capabilities veniceCapabilities `json:"capabilities"`
Pricing venicePricing `json:"pricing"`
Traits []string `json:"traits"`
Offline bool `json:"offline"`
}
type veniceCapabilities struct {
SupportsFunctionCalling bool `json:"supportsFunctionCalling"`
SupportsVision bool `json:"supportsVision"`
SupportsReasoning bool `json:"supportsReasoning"`
SupportsWebSearch bool `json:"supportsWebSearch"`
SupportsResponseSchema bool `json:"supportsResponseSchema"`
SupportsAudioInput bool `json:"supportsAudioInput"`
SupportsLogProbs bool `json:"supportsLogProbs"`
}
type venicePricing struct {
Input venicePriceUnit `json:"input"`
Output venicePriceUnit `json:"output"`
}
type venicePriceUnit struct {
USD float64 `json:"usd"`
}