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

148
server/database/migrate.go Normal file
View File

@@ -0,0 +1,148 @@
package database
import (
"database/sql"
"embed"
"fmt"
"log"
"sort"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Migrate checks the database schema state and applies any pending
// migrations. This runs at startup before the HTTP server opens.
//
// Flow:
// 1. Ping DB (health check)
// 2. Ensure schema_migrations table exists
// 3. Load applied versions
// 4. Discover embedded SQL files
// 5. Apply pending migrations in order
//
// All migrations run in individual transactions. A failed migration
// aborts startup — the backend will not serve traffic with an
// inconsistent schema.
func Migrate() error {
if DB == nil {
return fmt.Errorf("database not connected")
}
start := time.Now()
log.Println("📋 Schema migration check...")
// ── 1. Health check ─────────────────────────
if err := DB.Ping(); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}
// ── 2. Ensure tracking table exists ─────────
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}
// ── 3. Load already-applied versions ────────
applied := make(map[string]bool)
rows, err := DB.Query(`SELECT version FROM schema_migrations`)
if err != nil {
return fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return fmt.Errorf("scan version: %w", err)
}
applied[v] = true
}
// ── 4. Discover embedded migration files ────
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files) // lexicographic = version order (001_, 002_, ...)
// ── 5. Apply pending ────────────────────────
pending := 0
skipped := 0
for _, name := range files {
if applied[name] {
skipped++
continue
}
content, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
}
log.Printf(" ▶ applying: %s", name)
tx, err := DB.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s failed: %w", name, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations (version) VALUES ($1)`, name,
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
log.Printf(" ✓ %s applied", name)
pending++
}
elapsed := time.Since(start).Round(time.Millisecond)
if pending > 0 {
log.Printf("✅ Migrations complete: %d applied, %d skipped (%s)", pending, skipped, elapsed)
} else {
log.Printf("✅ Schema up to date (%d migrations, %s)", skipped, elapsed)
}
return nil
}
// SchemaVersion returns the most recently applied migration version,
// or "" if no migrations have been applied.
func SchemaVersion() string {
if DB == nil {
return ""
}
var version sql.NullString
err := DB.QueryRow(`
SELECT version FROM schema_migrations
ORDER BY version DESC LIMIT 1
`).Scan(&version)
if err != nil || !version.Valid {
return ""
}
return version.String
}

View File

@@ -0,0 +1,408 @@
-- ==========================================
-- Chat Switchboard - PostgreSQL Schema
-- ==========================================
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "vector"; -- For embeddings (pgvector)
-- ==========================================
-- Core Tables
-- ==========================================
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user', -- user, admin, moderator
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
-- API Configurations (user's API keys)
CREATE TABLE api_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- "OpenAI", "Claude", "Local Ollama"
provider VARCHAR(50) NOT NULL, -- openai, anthropic, ollama, openrouter
endpoint TEXT NOT NULL,
api_key_encrypted TEXT, -- Encrypted at rest
model_default VARCHAR(100),
config JSONB DEFAULT '{}'::jsonb, -- Custom settings per provider
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_api_configs_user ON api_configs(user_id);
-- ==========================================
-- Feature 1: CHATS (User to LLM)
-- ==========================================
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
model VARCHAR(100), -- Current model for this chat
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens, etc
is_archived BOOLEAN DEFAULT false,
is_pinned BOOLEAN DEFAULT false,
folder VARCHAR(100), -- For organization
tags TEXT[], -- For filtering
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_chats_user ON chats(user_id);
CREATE INDEX idx_chats_updated ON chats(updated_at DESC);
CREATE INDEX idx_chats_tags ON chats USING GIN(tags);
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID REFERENCES chats(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL, -- user, assistant, system, tool
content TEXT NOT NULL,
model VARCHAR(100), -- Which model generated this (for assistant)
tokens_used INTEGER,
tool_calls JSONB, -- Function calls made
metadata JSONB DEFAULT '{}'::jsonb, -- thinking_blocks, attachments, etc
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_chat_messages_chat ON chat_messages(chat_id, created_at);
-- Model routing history (for analytics/debugging)
CREATE TABLE model_routing_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES chat_messages(id) ON DELETE CASCADE,
requested_model VARCHAR(100),
routed_model VARCHAR(100),
reason TEXT, -- "cost_optimization", "context_length", "manual", "fallback"
latency_ms INTEGER,
cost_usd NUMERIC(10, 6),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_routing_log_chat ON model_routing_log(chat_id);
-- ==========================================
-- Feature 2: CHANNELS (User to User + AI)
-- ==========================================
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
type VARCHAR(20) DEFAULT 'public', -- public, private, dm
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
settings JSONB DEFAULT '{}'::jsonb, -- ai_participants, webhooks, etc
is_archived BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_channels_name ON channels(name);
CREATE INDEX idx_channels_type ON channels(type);
-- Channel memberships
CREATE TABLE channel_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Channel messages
CREATE TABLE channel_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- NULL for AI messages
content TEXT NOT NULL,
mentions JSONB, -- {users: [uuid], models: [name]}
thread_id UUID REFERENCES channel_messages(id) ON DELETE CASCADE, -- For threading
attachments JSONB, -- Files, images, etc
reactions JSONB DEFAULT '{}'::jsonb, -- {emoji: [user_ids]}
is_ai_message BOOLEAN DEFAULT false,
ai_model VARCHAR(100), -- If AI generated
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_channel_messages_channel ON channel_messages(channel_id, created_at DESC);
CREATE INDEX idx_channel_messages_thread ON channel_messages(thread_id);
CREATE INDEX idx_channel_messages_mentions ON channel_messages USING GIN(mentions);
-- ==========================================
-- Feature 3: NOTES & KNOWLEDGE BASES
-- ==========================================
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(20) DEFAULT 'markdown', -- markdown, html, plain
folder VARCHAR(100),
tags TEXT[],
is_pinned BOOLEAN DEFAULT false,
is_shared BOOLEAN DEFAULT false,
share_token UUID UNIQUE DEFAULT uuid_generate_v4(),
parent_note_id UUID REFERENCES notes(id) ON DELETE SET NULL, -- For hierarchies
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
CREATE INDEX idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX idx_notes_share_token ON notes(share_token) WHERE is_shared = true;
-- Knowledge Bases (Collections of documents)
CREATE TABLE knowledge_bases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
embedding_model VARCHAR(100) DEFAULT 'text-embedding-ada-002',
settings JSONB DEFAULT '{}'::jsonb, -- chunk_size, overlap, etc
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_knowledge_bases_user ON knowledge_bases(user_id);
-- Documents in knowledge bases
CREATE TABLE kb_documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
filename VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(50), -- application/pdf, text/markdown, etc
file_size INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_kb_documents_kb ON kb_documents(kb_id);
-- Document chunks (for RAG)
CREATE TABLE kb_chunks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID REFERENCES kb_documents(id) ON DELETE CASCADE,
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(1536), -- For pgvector similarity search
chunk_index INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_kb_chunks_document ON kb_chunks(document_id);
CREATE INDEX idx_kb_chunks_embedding ON kb_chunks USING ivfflat (embedding vector_cosine_ops);
-- ==========================================
-- Feature 4: PLUGIN ORCHESTRATION
-- ==========================================
-- Installed extensions/plugins
CREATE TABLE extensions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
version VARCHAR(20) NOT NULL,
author VARCHAR(100),
description TEXT,
runtime VARCHAR(20), -- python, go, node, rust
entry_point TEXT NOT NULL,
port INTEGER,
manifest JSONB NOT NULL, -- Full extension.json
is_enabled BOOLEAN DEFAULT true,
is_system BOOLEAN DEFAULT false, -- Core extensions
install_source VARCHAR(200), -- URL or marketplace ID
installed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_extensions_enabled ON extensions(is_enabled);
-- Extension tools/functions
CREATE TABLE extension_tools (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
parameters_schema JSONB NOT NULL, -- JSON Schema
response_schema JSONB,
is_enabled BOOLEAN DEFAULT true,
UNIQUE(extension_id, name)
);
CREATE INDEX idx_extension_tools_extension ON extension_tools(extension_id);
-- Tool usage log (for analytics)
CREATE TABLE tool_usage_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tool_id UUID REFERENCES extension_tools(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
chat_id UUID REFERENCES chats(id) ON DELETE SET NULL,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
input_params JSONB,
output_result JSONB,
execution_time_ms INTEGER,
success BOOLEAN,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_tool_usage_tool ON tool_usage_log(tool_id);
CREATE INDEX idx_tool_usage_user ON tool_usage_log(user_id);
-- ==========================================
-- Feature 5: WORKFLOWS (Unique Feature!)
-- ==========================================
-- Workflow definitions (DAG of AI operations)
CREATE TABLE workflows (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
graph JSONB NOT NULL, -- Node-edge DAG structure
input_schema JSONB, -- Expected inputs
output_schema JSONB, -- Expected outputs
is_public BOOLEAN DEFAULT false,
is_template BOOLEAN DEFAULT false,
tags TEXT[],
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_workflows_user ON workflows(user_id);
CREATE INDEX idx_workflows_public ON workflows(is_public) WHERE is_public = true;
CREATE INDEX idx_workflows_tags ON workflows USING GIN(tags);
-- Workflow executions
CREATE TABLE workflow_executions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workflow_id UUID REFERENCES workflows(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
input_data JSONB,
output_data JSONB,
status VARCHAR(20), -- running, completed, failed
steps JSONB, -- Execution trace
total_cost_usd NUMERIC(10, 6),
total_time_ms INTEGER,
error_message TEXT,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_workflow_executions_workflow ON workflow_executions(workflow_id);
CREATE INDEX idx_workflow_executions_user ON workflow_executions(user_id);
-- ==========================================
-- Shared/Utility Tables
-- ==========================================
-- File uploads
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
filename VARCHAR(500) NOT NULL,
content_type VARCHAR(100),
file_size INTEGER,
storage_path TEXT NOT NULL, -- S3/local path
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_user ON files(user_id);
-- Webhooks
CREATE TABLE webhooks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100),
url TEXT NOT NULL,
events TEXT[] NOT NULL, -- chat.message, channel.message, etc
secret VARCHAR(100),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_webhooks_user ON webhooks(user_id);
-- Audit log
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50),
resource_id UUID,
metadata JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
-- ==========================================
-- Functions & Triggers
-- ==========================================
-- Auto-update updated_at timestamps
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER chats_updated_at BEFORE UPDATE ON chats
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER notes_updated_at BEFORE UPDATE ON notes
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER knowledge_bases_updated_at BEFORE UPDATE ON knowledge_bases
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER workflows_updated_at BEFORE UPDATE ON workflows
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Increment workflow usage count
CREATE OR REPLACE FUNCTION increment_workflow_usage()
RETURNS TRIGGER AS $$
BEGIN
UPDATE workflows SET usage_count = usage_count + 1 WHERE id = NEW.workflow_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER workflow_executions_insert AFTER INSERT ON workflow_executions
FOR EACH ROW EXECUTE FUNCTION increment_workflow_usage();

View File

@@ -0,0 +1,20 @@
-- ==========================================
-- Chat Switchboard - Refresh Tokens
-- ==========================================
-- Supports JWT refresh token rotation.
-- Old tokens are revoked on each refresh.
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
-- Cleanup: auto-delete expired tokens older than 30 days
-- (run periodically or via pg_cron if available)

View File

@@ -0,0 +1,15 @@
-- Migration 003: Global Settings
-- Stores application-wide configuration managed by admins.
CREATE TABLE IF NOT EXISTS global_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- Seed defaults
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,12 @@
-- Model configurations: admin-curated list of available models
CREATE TABLE IF NOT EXISTS model_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
is_enabled BOOLEAN DEFAULT true,
capabilities JSONB DEFAULT '{"tool": false, "thinking": false, "vision": false, "code": false}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(api_config_id, model_id)
);

View File

@@ -0,0 +1,60 @@
-- Migration 005: Provider Capabilities & Custom Headers
--
-- Adds provider-level configuration (custom headers, provider-specific settings)
-- and expands model capabilities to include output token limits.
-- This eliminates hardcoded max_tokens defaults throughout the system.
-- ── api_configs: custom headers and global flag ──
ALTER TABLE api_configs
ADD COLUMN IF NOT EXISTS custom_headers JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS provider_settings JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS is_global BOOLEAN DEFAULT false;
COMMENT ON COLUMN api_configs.custom_headers IS 'Extra HTTP headers sent with every request (e.g. OpenRouter HTTP-Referer)';
COMMENT ON COLUMN api_configs.provider_settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
COMMENT ON COLUMN api_configs.is_global IS 'Admin-managed configs visible to all users';
-- Backfill: existing admin-created configs (user_id IS NULL) are global
UPDATE api_configs SET is_global = true WHERE user_id IS NULL;
-- ── model_configs: richer capabilities ──
-- The existing capabilities JSONB gets richer fields.
-- No schema change needed (it's JSONB), but let's update existing rows
-- that have the old minimal shape to include the new fields.
-- New canonical shape:
-- {
-- "streaming": true,
-- "tool_calling": false,
-- "vision": false,
-- "thinking": false,
-- "reasoning": false,
-- "code_optimized": false,
-- "max_context": 0,
-- "max_output_tokens": 0,
-- "web_search": false
-- }
-- max_output_tokens = 0 means "not set, use provider/heuristic default"
-- Migrate old "tool" key to "tool_calling" for consistency
UPDATE model_configs
SET capabilities = capabilities - 'tool' || jsonb_build_object('tool_calling', COALESCE(capabilities->>'tool', 'false')::boolean)
WHERE capabilities ? 'tool' AND NOT capabilities ? 'tool_calling';
-- Migrate old "code" key to "code_optimized"
UPDATE model_configs
SET capabilities = capabilities - 'code' || jsonb_build_object('code_optimized', COALESCE(capabilities->>'code', 'false')::boolean)
WHERE capabilities ? 'code' AND NOT capabilities ? 'code_optimized';
-- ── user_model_preferences ──
-- Users can enable/disable models from global providers for personal use.
CREATE TABLE IF NOT EXISTS user_model_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_config_id UUID NOT NULL REFERENCES model_configs(id) ON DELETE CASCADE,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_config_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_prefs_user ON user_model_preferences(user_id);

116
server/events/bus.go Normal file
View File

@@ -0,0 +1,116 @@
package events
import (
"strings"
"sync"
)
// Bus is a labeled publish/subscribe event bus.
// Handlers subscribe to patterns (exact or trailing wildcard).
// Publishing fans out to all matching subscribers.
type Bus struct {
mu sync.RWMutex
subs map[string][]*subscription
seq uint64 // subscription ID counter
}
type subscription struct {
id uint64
pattern string
handler Handler
}
// NewBus creates a new event bus.
func NewBus() *Bus {
return &Bus{
subs: make(map[string][]*subscription),
}
}
// Subscribe registers a handler for a label pattern.
// Returns an unsubscribe function.
//
// Patterns:
//
// "chat.message.abc123" — exact match
// "chat.message.*" — wildcard: matches chat.message.{anything}
// "chat.*" — wildcard: matches chat.{anything}
// "*" — matches all events
func (b *Bus) Subscribe(pattern string, handler Handler) func() {
b.mu.Lock()
b.seq++
sub := &subscription{id: b.seq, pattern: pattern, handler: handler}
b.subs[pattern] = append(b.subs[pattern], sub)
b.mu.Unlock()
return func() {
b.mu.Lock()
defer b.mu.Unlock()
subs := b.subs[pattern]
for i, s := range subs {
if s.id == sub.id {
b.subs[pattern] = append(subs[:i], subs[i+1:]...)
break
}
}
}
}
// Publish dispatches an event to all matching subscribers.
// Handlers are called synchronously in subscription order.
// Use PublishAsync for non-blocking dispatch.
func (b *Bus) Publish(event Event) {
b.mu.RLock()
var matched []Handler
for pattern, subs := range b.subs {
if match(event.Label, pattern) {
for _, s := range subs {
matched = append(matched, s.handler)
}
}
}
b.mu.RUnlock()
for _, h := range matched {
h(event)
}
}
// PublishAsync dispatches an event to all matching subscribers
// in separate goroutines. Useful for I/O-heavy handlers.
func (b *Bus) PublishAsync(event Event) {
b.mu.RLock()
var matched []Handler
for pattern, subs := range b.subs {
if match(event.Label, pattern) {
for _, s := range subs {
matched = append(matched, s.handler)
}
}
}
b.mu.RUnlock()
for _, h := range matched {
go h(event)
}
}
// match checks if a concrete label matches a subscription pattern.
//
// "chat.message.abc" matches "chat.message.abc" (exact)
// "chat.message.abc" matches "chat.message.*" (wildcard)
// "chat.message.abc" matches "chat.*" (wildcard)
// "chat.message.abc" matches "*" (global wildcard)
func match(label, pattern string) bool {
if pattern == "*" {
return true
}
if pattern == label {
return true
}
if !strings.HasSuffix(pattern, "*") {
return false
}
prefix := pattern[:len(pattern)-1] // "chat.message." from "chat.message.*"
return strings.HasPrefix(label, prefix)
}

123
server/events/bus_test.go Normal file
View File

@@ -0,0 +1,123 @@
package events
import (
"encoding/json"
"sync/atomic"
"testing"
)
func TestMatch(t *testing.T) {
tests := []struct {
label, pattern string
want bool
}{
{"chat.message.abc", "chat.message.abc", true},
{"chat.message.abc", "chat.message.*", true},
{"chat.message.abc", "chat.*", true},
{"chat.message.abc", "*", true},
{"chat.message.abc", "chat.message.xyz", false},
{"chat.message.abc", "channel.message.*", false},
{"chat.message.abc", "chat.message", false},
{"ping", "ping", true},
{"ping", "pong", false},
{"plugin.hook.pre_completion", "plugin.hook.*", true},
{"plugin.hook.pre_completion", "plugin.*", true},
}
for _, tt := range tests {
got := match(tt.label, tt.pattern)
if got != tt.want {
t.Errorf("match(%q, %q) = %v, want %v", tt.label, tt.pattern, got, tt.want)
}
}
}
func TestBusPublishExact(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe("chat.message.abc", func(e Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)})
if atomic.LoadInt32(&count) != 1 {
t.Errorf("expected 1 dispatch, got %d", count)
}
}
func TestBusPublishWildcard(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe("chat.message.*", func(e Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "chat.message.xyz", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "chat.typing.abc", Payload: json.RawMessage(`{}`)})
if atomic.LoadInt32(&count) != 2 {
t.Errorf("expected 2 dispatches, got %d", count)
}
}
func TestBusUnsubscribe(t *testing.T) {
bus := NewBus()
var count int32
unsub := bus.Subscribe("test.event", func(e Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "test.event", Payload: json.RawMessage(`{}`)})
unsub()
bus.Publish(Event{Label: "test.event", Payload: json.RawMessage(`{}`)})
if atomic.LoadInt32(&count) != 1 {
t.Errorf("expected 1 dispatch after unsub, got %d", count)
}
}
func TestBusGlobalWildcard(t *testing.T) {
bus := NewBus()
var count int32
bus.Subscribe("*", func(e Event) {
atomic.AddInt32(&count, 1)
})
bus.Publish(Event{Label: "chat.message.abc", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "system.notify", Payload: json.RawMessage(`{}`)})
bus.Publish(Event{Label: "plugin.hook.pre_completion", Payload: json.RawMessage(`{}`)})
if atomic.LoadInt32(&count) != 3 {
t.Errorf("expected 3 dispatches, got %d", count)
}
}
func TestRouteFor(t *testing.T) {
tests := []struct {
label string
want Direction
}{
{"chat.message.abc", DirBoth},
{"chat.typing.abc", DirBoth},
{"system.notify", DirToClient},
{"plugin.hook.pre_completion", DirLocal},
{"internal.db.write", DirLocal},
{"unknown.event", DirLocal}, // default
{"ping", DirFromClient},
{"pong", DirToClient},
}
for _, tt := range tests {
got := RouteFor(tt.label)
if got != tt.want {
t.Errorf("RouteFor(%q) = %d, want %d", tt.label, got, tt.want)
}
}
}

94
server/events/types.go Normal file
View File

@@ -0,0 +1,94 @@
package events
import "encoding/json"
// Event is the universal envelope for all bus communication.
type Event struct {
Label string `json:"event"`
Room string `json:"room,omitempty"`
Payload json.RawMessage `json:"payload"`
Ts int64 `json:"ts"`
// Server-side metadata (not serialized to clients)
SenderID string `json:"-"` // user ID of sender, empty for system events
ConnID string `json:"-"` // WebSocket connection ID, empty for server-origin
}
// Handler is a callback for bus subscriptions.
type Handler func(Event)
// Direction controls which events cross the WebSocket bridge.
type Direction int
const (
DirLocal Direction = iota // bus-internal only (plugins, DB hooks)
DirToClient // BE → FE
DirFromClient // FE → BE
DirBoth // bidirectional
)
// routeTable defines the default routing for known event prefixes.
// Events not listed default to DirLocal (server-only).
var routeTable = map[string]Direction{
// Chat events
"chat.message.": DirBoth, // new messages
"chat.typing.": DirBoth, // typing indicators
"chat.updated.": DirToClient, // chat title/metadata changed
"chat.deleted.": DirToClient, // chat removed
// Channel events
"channel.message.": DirBoth,
"channel.typing.": DirBoth,
"channel.updated.": DirToClient,
"channel.member.": DirToClient,
// User/presence
"user.presence": DirToClient,
"user.status": DirToClient,
// System
"system.notify": DirToClient,
"system.broadcast": DirToClient,
// Model/provider status
"model.status": DirToClient,
// Plugin hooks — never cross the wire
"plugin.hook.": DirLocal,
"internal.": DirLocal,
"db.": DirLocal,
// Heartbeat
"ping": DirFromClient,
"pong": DirToClient,
}
// RouteFor returns the routing direction for a given event label.
func RouteFor(label string) Direction {
// Check exact match first
if dir, ok := routeTable[label]; ok {
return dir
}
// Check prefix match (longest prefix wins)
best := ""
bestDir := DirLocal
for prefix, dir := range routeTable {
if len(prefix) > len(best) && len(label) >= len(prefix) && label[:len(prefix)] == prefix {
best = prefix
bestDir = dir
}
}
return bestDir
}
// ShouldSendToClient returns true if this event should be forwarded to FE.
func ShouldSendToClient(label string) bool {
d := RouteFor(label)
return d == DirToClient || d == DirBoth
}
// ShouldAcceptFromClient returns true if this event is allowed from FE.
func ShouldAcceptFromClient(label string) bool {
d := RouteFor(label)
return d == DirFromClient || d == DirBoth
}

279
server/events/ws.go Normal file
View File

@@ -0,0 +1,279 @@
package events
import (
"encoding/json"
"log"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
}
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMsgSize = 4096
)
// Hub manages WebSocket connections and bridges them to the Bus.
type Hub struct {
bus *Bus
mu sync.RWMutex
conns map[string]*Conn // connID → Conn
}
// Conn represents a single WebSocket connection.
type Conn struct {
id string
userID string
rooms map[string]bool // rooms this connection is subscribed to
ws *websocket.Conn
send chan []byte
hub *Hub
unsubs []func() // bus unsubscribe functions
}
// NewHub creates a Hub bound to an event bus.
func NewHub(bus *Bus) *Hub {
return &Hub{
bus: bus,
conns: make(map[string]*Conn),
}
}
// HandleWebSocket is a Gin handler for WebSocket upgrade.
// Expects userID set in context (by auth middleware or query param).
func (h *Hub) HandleWebSocket(c *gin.Context) {
// Auth: check token from query param
userID := c.GetString("user_id")
if userID == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
ws, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[ws] upgrade failed: %v", err)
return
}
connID := userID + "-" + time.Now().Format("150405.000")
conn := &Conn{
id: connID,
userID: userID,
rooms: make(map[string]bool),
ws: ws,
send: make(chan []byte, 64),
hub: h,
}
h.mu.Lock()
h.conns[connID] = conn
h.mu.Unlock()
log.Printf("[ws] connected: %s (user=%s, total=%d)", connID, userID, h.ConnCount())
// Subscribe this connection to bus events destined for clients
conn.subscribeToBus()
// Publish presence
h.bus.Publish(Event{
Label: "user.presence",
Payload: mustJSON(map[string]any{"user_id": userID, "status": "online"}),
Ts: time.Now().UnixMilli(),
SenderID: userID,
})
// Start read/write pumps
go conn.writePump()
go conn.readPump()
}
// ConnCount returns the number of active connections.
func (h *Hub) ConnCount() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.conns)
}
// ConnsByUser returns all connections for a given user.
func (h *Hub) ConnsByUser(userID string) []*Conn {
h.mu.RLock()
defer h.mu.RUnlock()
var result []*Conn
for _, c := range h.conns {
if c.userID == userID {
result = append(result, c)
}
}
return result
}
// removeConn cleans up a connection.
func (h *Hub) removeConn(conn *Conn) {
h.mu.Lock()
delete(h.conns, conn.id)
h.mu.Unlock()
// Unsubscribe from bus
for _, unsub := range conn.unsubs {
unsub()
}
log.Printf("[ws] disconnected: %s (total=%d)", conn.id, h.ConnCount())
// Publish presence offline (only if no other conns for this user)
if len(h.ConnsByUser(conn.userID)) == 0 {
h.bus.Publish(Event{
Label: "user.presence",
Payload: mustJSON(map[string]any{"user_id": conn.userID, "status": "offline"}),
Ts: time.Now().UnixMilli(),
SenderID: conn.userID,
})
}
}
// subscribeToBus registers this conn as a bus subscriber for client-facing events.
func (c *Conn) subscribeToBus() {
unsub := c.hub.bus.Subscribe("*", func(e Event) {
// Only forward events destined for clients
if !ShouldSendToClient(e.Label) {
return
}
// Don't echo typing events back to the sender
if e.Label == "chat.typing" || e.ConnID == c.id {
if e.SenderID == c.userID && e.ConnID == c.id {
return
}
}
// Room filtering: if event has a room, only send if conn is in that room
if e.Room != "" && !c.rooms[e.Room] {
return
}
data, err := json.Marshal(e)
if err != nil {
return
}
select {
case c.send <- data:
default:
// send buffer full, drop event
}
})
c.unsubs = append(c.unsubs, unsub)
}
// JoinRoom adds this connection to a named room.
func (c *Conn) JoinRoom(room string) {
c.rooms[room] = true
}
// LeaveRoom removes this connection from a named room.
func (c *Conn) LeaveRoom(room string) {
delete(c.rooms, room)
}
// ── Read/Write Pumps ─────────────────────────
func (c *Conn) readPump() {
defer func() {
c.hub.removeConn(c)
c.ws.Close()
}()
c.ws.SetReadLimit(maxMsgSize)
c.ws.SetReadDeadline(time.Now().Add(pongWait))
c.ws.SetPongHandler(func(string) error {
c.ws.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.ws.ReadMessage()
if err != nil {
break
}
var event Event
if err := json.Unmarshal(message, &event); err != nil {
continue
}
// Handle ping
if event.Label == "ping" {
pong, _ := json.Marshal(Event{Label: "pong", Ts: time.Now().UnixMilli()})
select {
case c.send <- pong:
default:
}
continue
}
// Validate: only accept events allowed from clients
if !ShouldAcceptFromClient(event.Label) {
continue
}
// Tag with sender info
event.SenderID = c.userID
event.ConnID = c.id
if event.Ts == 0 {
event.Ts = time.Now().UnixMilli()
}
// Publish into bus
c.hub.bus.Publish(event)
}
}
func (c *Conn) writePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.ws.Close()
}()
for {
select {
case msg, ok := <-c.send:
c.ws.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.ws.WriteMessage(websocket.CloseMessage, []byte{})
return
}
if err := c.ws.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-ticker.C:
c.ws.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.ws.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// ── Helpers ──────────────────────────────────
func mustJSON(v any) json.RawMessage {
data, err := json.Marshal(v)
if err != nil {
return json.RawMessage(`{}`)
}
return data
}

View File

@@ -5,6 +5,7 @@ go 1.22
require (
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
golang.org/x/crypto v0.14.0

View File

@@ -525,7 +525,7 @@ func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
func (h *AdminHandler) FetchModels(c *gin.Context) {
// Load all global api_configs
rows, err := database.DB.Query(`
SELECT id, provider, endpoint, api_key_encrypted
SELECT id, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE user_id IS NULL AND is_active = true
`)
@@ -539,6 +539,7 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
ConfigID string `json:"config_id"`
Provider string `json:"provider"`
Added int `json:"added"`
Updated int `json:"updated"`
Skipped int `json:"skipped"`
Error string `json:"error,omitempty"`
}
@@ -548,7 +549,8 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
for rows.Next() {
var cfgID, providerID, endpoint string
var apiKey *string
if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey); err != nil {
var customHeadersJSON []byte
if err := rows.Scan(&cfgID, &providerID, &endpoint, &apiKey, &customHeadersJSON); err != nil {
continue
}
@@ -563,9 +565,15 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
key = *apiKey
}
customHeaders := make(map[string]string)
if customHeadersJSON != nil {
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
}
models, err := prov.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
Endpoint: endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if err != nil {
results = append(results, fetchResult{ConfigID: cfgID, Provider: providerID, Error: err.Error()})
@@ -574,11 +582,18 @@ func (h *AdminHandler) FetchModels(c *gin.Context) {
fr := fetchResult{ConfigID: cfgID, Provider: providerID}
for _, m := range models {
// Serialize capabilities to JSONB
capsJSON, _ := json.Marshal(m.Capabilities)
result, err := database.DB.Exec(`
INSERT INTO model_configs (api_config_id, model_id, display_name)
VALUES ($1, $2, $3)
ON CONFLICT (api_config_id, model_id) DO NOTHING
`, cfgID, m.ID, m.Name)
INSERT INTO model_configs (api_config_id, model_id, display_name, capabilities)
VALUES ($1, $2, $3, $4)
ON CONFLICT (api_config_id, model_id)
DO UPDATE SET
display_name = COALESCE(NULLIF(model_configs.display_name, ''), EXCLUDED.display_name),
capabilities = EXCLUDED.capabilities,
updated_at = NOW()
`, cfgID, m.ID, m.Name, capsJSON)
if err != nil {
fr.Skipped++
continue

View File

@@ -3,6 +3,7 @@ package handlers
import (
"database/sql"
"encoding/json"
"log"
"math"
"net/http"
"strconv"
@@ -363,11 +364,12 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
defer rows.Close()
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
ConfigID string `json:"config_id"`
Provider string `json:"provider"`
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
ConfigID string `json:"config_id"`
Provider string `json:"provider"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
}
allModels := make([]modelEntry, 0)
@@ -398,12 +400,17 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
}
for _, m := range models {
// Provider caps are authoritative; fill gaps from known table
caps := providers.MergeCapabilities(m.Capabilities, m.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps)
allModels = append(allModels, modelEntry{
ID: m.ID,
Name: m.Name,
OwnedBy: m.OwnedBy,
ConfigID: cfgID,
Provider: providerID,
ID: m.ID,
Name: m.Name,
OwnedBy: m.OwnedBy,
ConfigID: cfgID,
Provider: providerID,
Capabilities: caps,
})
}
}
@@ -414,16 +421,22 @@ func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
// ── List Enabled Models (from model_configs) ─
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
type enabledModel struct {
ID string `json:"id"`
ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
ConfigID string `json:"config_id"`
Capabilities map[string]interface{} `json:"capabilities"`
ID string `json:"id"`
ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
ConfigID string `json:"config_id"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
Pricing *providers.ModelPricing `json:"pricing,omitempty"`
}
models := make([]enabledModel, 0)
// ── 1. Admin model_configs (pre-synced via FetchModels) ──
rows, err := database.DB.Query(`
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
FROM model_configs mc
@@ -431,22 +444,92 @@ func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
WHERE mc.is_enabled = true AND ac.is_active = true AND ac.user_id IS NULL
ORDER BY ac.name, mc.model_id
`)
if err != nil {
c.JSON(http.StatusOK, gin.H{"models": []interface{}{}})
return
}
defer rows.Close()
if err == nil {
defer rows.Close()
for rows.Next() {
var m enabledModel
var capsJSON []byte
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
continue
}
models := make([]enabledModel, 0)
for rows.Next() {
var m enabledModel
var capsJSON []byte
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
continue
// Parse DB capabilities (from provider at sync time)
var dbCaps providers.ModelCapabilities
_ = json.Unmarshal(capsJSON, &dbCaps)
// Provider-reported caps are authoritative; fill gaps from known table/heuristics
if dbCaps.HasProviderData() {
m.Capabilities = providers.MergeCapabilities(dbCaps, m.ModelID)
} else {
// No provider data — use known table or heuristics as base
knownCaps, found := providers.LookupKnownModel(m.ModelID)
if !found {
knownCaps = providers.InferCapabilities(m.ModelID)
}
m.Capabilities = knownCaps
}
m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities)
models = append(models, m)
}
}
// ── 2. User provider models (live query) ──
userRows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE user_id = $1 AND is_active = true
`, userID)
if err == nil {
defer userRows.Close()
for userRows.Next() {
var cfgID, name, providerID, endpoint string
var apiKey *string
var headersJSON []byte
if err := userRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil {
continue
}
provider, err := providers.Get(providerID)
if err != nil {
continue
}
key := ""
if apiKey != nil {
key = *apiKey
}
var customHeaders map[string]string
_ = json.Unmarshal(headersJSON, &customHeaders)
provModels, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if err != nil {
log.Printf("[models] user provider %q (%s) list failed: %v", name, providerID, err)
continue
}
for _, pm := range provModels {
caps := pm.Capabilities
// Provider-reported caps are authoritative; fill gaps
caps = providers.MergeCapabilities(caps, pm.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(pm.ID, caps)
models = append(models, enabledModel{
ID: pm.ID,
ModelID: pm.ID,
Provider: providerID,
ProviderName: name,
ConfigID: cfgID,
Capabilities: caps,
Pricing: pm.Pricing,
})
}
}
m.Capabilities = make(map[string]interface{})
_ = json.Unmarshal(capsJSON, &m.Capabilities)
models = append(models, m)
}
c.JSON(http.StatusOK, gin.H{"models": models})

View File

@@ -2,6 +2,7 @@ package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
@@ -61,7 +62,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Resolve provider config
providerCfg, providerID, model, err := h.resolveConfig(userID, req)
providerCfg, providerID, model, configID, err := h.resolveConfig(userID, req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -96,9 +97,17 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
Model: model,
Messages: messages,
}
// Resolve capabilities for this model — auto-set defaults
caps := h.getModelCapabilities(model, configID)
if req.MaxTokens > 0 {
provReq.MaxTokens = req.MaxTokens
} else {
// ResolveMaxOutput checks: caps → known models → context/8 → 4096
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
}
if req.Temperature != nil {
provReq.Temperature = req.Temperature
}
@@ -227,10 +236,42 @@ func (h *CompletionHandler) syncCompletion(
})
}
// ── Model Capabilities ──────────────────────
// getModelCapabilities looks up capabilities from model_configs DB,
// then overlays with known model defaults and heuristic detection.
func (h *CompletionHandler) getModelCapabilities(model, apiConfigID string) providers.ModelCapabilities {
// Start with known table or heuristic
// Start with known model table or heuristics
caps, found := providers.LookupKnownModel(model)
if !found {
caps = providers.InferCapabilities(model)
}
// Overlay with DB-stored capabilities (from provider sync or admin edit — authoritative)
var capsJSON []byte
err := database.DB.QueryRow(`
SELECT capabilities FROM model_configs
WHERE model_id = $1 AND api_config_id = $2
`, model, apiConfigID).Scan(&capsJSON)
if err == nil && capsJSON != nil {
var dbCaps providers.ModelCapabilities
if err := json.Unmarshal(capsJSON, &dbCaps); err == nil {
if dbCaps.HasProviderData() {
// DB has real provider data — use as authoritative, fill gaps
caps = providers.MergeCapabilities(dbCaps, model)
}
}
}
return caps
}
// ── Config Resolution ───────────────────────
// Priority: request.api_config_id → chat.api_config_id → user's first active config
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, error) {
func (h *CompletionHandler) resolveConfig(userID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
var configID string
// 1. Explicit config from request
@@ -249,33 +290,34 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
}
}
// 3. User's first active config
// 3. User's first active config (personal first, then global)
if configID == "" {
err := database.DB.QueryRow(`
SELECT id FROM api_configs
WHERE (user_id = $1 OR user_id IS NULL) AND is_active = true
WHERE (user_id = $1 OR is_global = true) AND is_active = true
ORDER BY user_id NULLS LAST, created_at ASC
LIMIT 1
`, userID).Scan(&configID)
if err != nil {
return providers.ProviderConfig{}, "", "", fmt.Errorf("no API config found — add one at /api-configs")
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no API config found — add one at /api-configs")
}
}
// Load the config
// Load the config including custom headers and provider settings
var providerID, endpoint string
var apiKey, modelDefault *string
var customHeadersJSON, providerSettingsJSON []byte
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, model_default
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
FROM api_configs
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND is_active = true
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault)
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows {
return providers.ProviderConfig{}, "", "", fmt.Errorf("API config not found or not accessible")
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("API config not found or not accessible")
}
if err != nil {
return providers.ProviderConfig{}, "", "", fmt.Errorf("failed to load API config: %w", err)
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("failed to load API config: %w", err)
}
// Resolve model: request > config default
@@ -284,7 +326,7 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
model = *modelDefault
}
if model == "" {
return providers.ProviderConfig{}, "", "", fmt.Errorf("no model specified and no default model in config")
return providers.ProviderConfig{}, "", "", "", fmt.Errorf("no model specified and no default model in config")
}
key := ""
@@ -292,10 +334,24 @@ func (h *CompletionHandler) resolveConfig(userID string, req completionRequest)
key = *apiKey
}
// Parse custom headers
customHeaders := make(map[string]string)
if customHeadersJSON != nil {
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
}
// Parse provider-specific settings
providerSettings := make(map[string]interface{})
if providerSettingsJSON != nil {
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
}
return providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
}, providerID, model, nil
Endpoint: endpoint,
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
}, providerID, model, configID, nil
}
// ── Conversation Loader ─────────────────────

View File

@@ -7,6 +7,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
@@ -21,21 +22,34 @@ func main() {
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
} else {
// Schema check: init if fresh, upgrade if behind, proceed if current
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
}
defer database.Close()
r := gin.Default()
r.Use(middleware.CORS())
// ── EventBus + WebSocket Hub ─────────────
bus := events.NewBus()
hub := events.NewHub(bus)
// Health check (k8s probes hit this directly)
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
"version": Version,
"database": database.IsConnected(),
"status": "ok",
"version": Version,
"database": database.IsConnected(),
"schema_version": database.SchemaVersion(),
})
})
// WebSocket endpoint — auth via ?token= query param
r.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg)
authLimiter := middleware.NewRateLimiter(1, 5)
@@ -45,10 +59,11 @@ func main() {
// Health (routable through ingress)
api.GET("/health", func(c *gin.Context) {
info := gin.H{
"status": "ok",
"version": Version,
"database": database.IsConnected(),
"providers": providers.List(),
"status": "ok",
"version": Version,
"schema_version": database.SchemaVersion(),
"database": database.IsConnected(),
"providers": providers.List(),
}
// Include registration status for frontend
if database.IsConnected() {
@@ -147,7 +162,9 @@ func main() {
}
log.Printf("🔀 Chat Switchboard API v%s starting on port %s", Version, cfg.Port)
log.Printf(" Schema: %s", database.SchemaVersion())
log.Printf(" Providers: %v", providers.List())
log.Printf(" EventBus: ready, WebSocket on /ws")
if err := r.Run(":" + cfg.Port); err != nil {
log.Fatalf("Failed to start server: %v", err)
}

View File

@@ -31,6 +31,13 @@ func Auth(cfg *config.Config) gin.HandlerFunc {
}
header := c.GetHeader("Authorization")
if header == "" {
// WebSocket connections can't set headers from browser.
// Fall back to ?token= query parameter.
if qToken := c.Query("token"); qToken != "" {
header = "Bearer " + qToken
}
}
if header == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing authorization header",

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"`
}