Changeset 0.5.0 (#35)
This commit is contained in:
148
server/database/migrate.go
Normal file
148
server/database/migrate.go
Normal 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
|
||||
}
|
||||
408
server/database/migrations/001_full_schema.sql
Normal file
408
server/database/migrations/001_full_schema.sql
Normal 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();
|
||||
20
server/database/migrations/002_refresh_tokens.sql
Normal file
20
server/database/migrations/002_refresh_tokens.sql
Normal 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)
|
||||
15
server/database/migrations/003_global_settings.sql
Normal file
15
server/database/migrations/003_global_settings.sql
Normal 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;
|
||||
12
server/database/migrations/004_model_configs.sql
Normal file
12
server/database/migrations/004_model_configs.sql
Normal 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)
|
||||
);
|
||||
60
server/database/migrations/005_provider_capabilities.sql
Normal file
60
server/database/migrations/005_provider_capabilities.sql
Normal 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
116
server/events/bus.go
Normal 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
123
server/events/bus_test.go
Normal 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
94
server/events/types.go
Normal 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
279
server/events/ws.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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 ─────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
396
server/providers/capabilities.go
Normal file
396
server/providers/capabilities.go
Normal 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
|
||||
}
|
||||
203
server/providers/capabilities_test.go
Normal file
203
server/providers/capabilities_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
160
server/providers/openrouter.go
Normal file
160
server/providers/openrouter.go
Normal 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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -44,4 +44,6 @@ func List() []string {
|
||||
func Init() {
|
||||
Register(&OpenAIProvider{})
|
||||
Register(&AnthropicProvider{})
|
||||
Register(&VeniceProvider{})
|
||||
Register(&OpenRouterProvider{})
|
||||
}
|
||||
|
||||
149
server/providers/venice.go
Normal file
149
server/providers/venice.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user